Build. Translate. Understand.

依存関係の排除

2025-06-17 10:00:25

原文: Dependency rejection by Mark Seemann

関数型プログラミングでは、依存関係という概念を排除しなければなりません。その代わりに、アプリケーションは純粋関数と不純な関数から構成されるべきです。

この記事は、依存関係の注入から依存関係の排除へという短い連載記事の3番目の記事です。この連載の前の記事では、依存関係の注入はすべてを不純にしてしまうため、関数型的ではあり得ないことを学びました。この記事では、その代わりに何をすべきかを見ていきます。

間接的な入力と出力

プログラミングを学び始めた頃に最初に学ぶ概念の一つに、操作の単位(関数、メソッド、手続き)は入力を受け取り、出力を生成するというものがあります。入力は入力引数の形で、出力は戻り値の形で与えられます。(もっとも、メソッドが何も返さないこともありますが、圏論によれば、何もないこともまた値(ユニットと呼ばれます)であることが分かっています。)

このような入力と出力に加えて、依存関係を持つ単位は、間接的な入力も受け取り、間接的な出力も生成します。

依存関係を持ち、直接的および間接的な入力と出力を持つ単位

ある単位が依存関係に対してデータを問い合わせる場合、その依存関係から返されるデータは間接的な入力となります。この連載で使っているレストラン予約の例では、tryAcceptreadReservationsを呼び出すとき、返される予約情報が間接的な入力です。

同様に、ある単位が依存関係を呼び出す場合、その依存関係に渡されるすべての引数が間接的な出力を構成します。この例では、tryAcceptcreateReservationを呼び出すとき、その関数呼び出しの入力引数として使われる予約の値が出力になります。この場合の意図は、予約をデータベースに保存することです。

間接的な出力から直接的な出力へ

間接的な出力を生成する代わりに、関数をリファクタリングして直接的な出力を生成するようにできます。

依存関係を持ち、間接的な出力はなく、直接的な入力と出力を持つ単位

このようなリファクタリングは、C#やJavaのような主流のオブジェクト指向言語では問題になることがよくあります。なぜなら、間接的な出力が生成される状況を制御したいからです。間接的な出力はしばしば副作用を伴いますが、その副作用は特定の条件が満たされた場合にのみ発生させたいかもしれません。レストラン予約の例では、望ましい副作用はデータベースに予約を追加することですが、これはレストランに要求された人数を受け入れるだけの十分な残席がある場合にのみ行われなければなりません。C#やJavaのような文ベースの言語では、意思決定と作用を分離することが難しい場合があります。

F#やHaskellのような式ベースの言語では、意思決定と作用を切り離すのは簡単です

前の記事では、次のシグネチャを持つtryAcceptのバージョンを見ました。

// int -> (DateTimeOffset -> Reservation list) -> (Reservation -> int) -> Reservation
// -> int option

2番目の関数引数(型はReservation -> int)は、間接的な出力を生成します。Reservation値が出力です。この関数はコマンド・クエリ分離に違反して、追加された予約のデータベースIDを返すため、これは追加の間接的な入力にもなっています。関数全体としてはint optionを返します。予約が追加された場合はデータベースID、されなかった場合はNoneです。

間接的な出力を直接的な出力にリファクタリングするのは簡単です。createReservation関数を削除し、代わりにReservation値を返すだけです。

// int -> (DateTimeOffset -> Reservation list) -> Reservation -> Reservation option
let tryAccept capacity readReservations reservation =
    let reservedSeats =
        readReservations reservation.Date |> List.sumBy (fun x -> x.Quantity)
    if reservedSeats + reservation.Quantity <= capacity
    then { reservation with IsAccepted = true } |> Some
    else None

このリファクタリングされたバージョンのtryAcceptReservation option値を返すことに注目してください。これが意味するのは、戻り値がSomeケースであれば予約が受け入れられ、Noneであれば拒否されたということです。意思決定は値に埋め込まれていますが、データベースへの書き込みという副作用からは切り離されています。

この関数がデータベースに書き込むことは決してないので、アプリケーションの境界部分で、意思決定と作用を結びつける必要があります。前の記事との一貫性を保つため、これをtryAcceptComposition関数の中で次のように行います。

// Reservation -> int option
let tryAcceptComposition reservation =
    reservation
    |> tryAccept 10 (DB.readReservations connectionString)
    |> Option.map (DB.createReservation connectionString)

tryAcceptCompositionの型がReservation -> int optionのままであることに注目してください。これは真のリファクタリングです。全体的なAPIも振る舞いも、以前と同じままです。予約は、十分な残席がある場合にのみデータベースに追加され、その場合には予約のIDが返されます。

間接的な入力から直接的な入力へ

間接的な出力を直接的な出力にリファクタリングできたように、間接的な入力を直接的な入力にリファクタリングすることもできます。

依存関係を持ち、直接的な入力と出力を持つ単位

繰り返しますが、C#やJavaのような文ベースの言語では、クエリを遅延させたり、単位内部の意思決定に基づいてクエリを実行したりしたいため、これが問題になる可能性があります。式ベースの言語では意思決定と作用を切り離すことができ、遅延実行は、もし必要であれば、常に遅延評価によって行うことができます。しかし、今回の例の場合、リファクタリングは簡単です。

// int -> Reservation list -> Reservation -> Reservation option
let tryAccept capacity reservations reservation =
    let reservedSeats = reservations |> List.sumBy (fun x -> x.Quantity)
    if reservedSeats + reservation.Quantity <= capacity
    then { reservation with IsAccepted = true } |> Some
    else None

(潜在的に不純な)関数を呼び出す代わりに、このバージョンのtryAcceptは既存の予約リストを入力として受け取ります。それは依然としてすべての数量を合計し、残りのコードは以前と同じです。

当然、既存の予約のリストはデータベースのようなどこかから来なければならないので、tryAcceptCompositionが依然としてその面倒を見る必要があります。

// ('a -> 'b -> 'c) -> 'b -> 'a -> 'c
let flip f x y = f y x
 
// Reservation -> int option
let tryAcceptComposition reservation =
    reservation.Date
    |> DB.readReservations connectionString
    |> flip (tryAccept 10) reservation
    |> Option.map (DB.createReservation connectionString)

この合成関数の型と振る舞いは以前と同じですが、データの流れが異なります。まず、この関数はデータベースに問い合わせます。これは不純な操作です。次に、結果として得られた予約のリストを、今や純粋関数となったtryAcceptにパイプします。それが返すReservation optionは、最終的に別の不純な操作にマッピングされ、予約が受け入れられた場合にデータベースに予約を書き込みます。

合成をより簡潔にするためにflip関数を追加したことにもお気づきでしょうが、tryAcceptを呼び出す際にラムダ式を使うこともできました。flip関数はHaskellの標準ライブラリの一部ですが、F#のコアライブラリにはありません。もっとも、この例にとって重要なものではありません。

評価

先ほどの上の図で、単位とその依存関係の間の矢印がすべてなくなっていることにお気づきでしょうか?これは、この単位がもはや何の依存関係も持たなくなったことを意味します。

直接的な入力と出力を持ち、依存関係を持たない単位

依存関係はその性質上、不純であり、純粋関数は不純な関数を呼び出せないため、関数型プログラミングは依存関係という概念を排除しなければなりません。純粋関数は不純な関数に依存することはできないのです。

その代わりに、純粋関数は直接的な入力を受け取り、直接的な出力を生成しなければならず、アプリケーションの不純な境界部分で、望ましい振る舞いを実現するために不純な関数と純粋な関数を組み合わせる必要があります。

前の記事では、実装が関数型的であるかどうかを評価するためにHaskellを使えることを見ました。上記のF#コードをHaskellに移植して、これが事実であることを確認できます。

tryAccept :: Int -> [Reservation] -> Reservation -> Maybe Reservation
tryAccept capacity reservations reservation =
  let reservedSeats = sum $ map quantity reservations
  in  if reservedSeats + quantity reservation <= capacity
      then Just $ reservation { isAccepted = True }
      else Nothing

このバージョンのtryAcceptは純粋であり、コンパイルもできますが、前の記事で学んだように、それは重要な問題ではありません。問題は、合成関数がコンパイルできるか、です。

tryAcceptComposition :: Reservation -> IO (Maybe Int)
tryAcceptComposition reservation = runMaybeT $
  liftIO (DB.readReservations connectionString $ date reservation)
  >>= MaybeT . return . flip (tryAccept 10) reservation
  >>= liftIO . DB.createReservation connectionString

このバージョンのtryAcceptCompositionはコンパイルでき、期待通りに動作します。このコードは、Haskellでよく見られるパターンを示しています。第一に、不純なソースからデータを集める。第二に、純粋なデータを純粋関数に渡す。第三に、純粋関数からの純粋な出力を受け取り、それで何か不純なことをする。

それは、真ん中に一番おいしい部分があり、その周りを必要なもので囲んだサンドイッチのようなものです。

まとめ

依存関係はその性質上、不純です。それらは非決定的であったり、副作用を持っていたり、あるいはその両方であったりします。純粋関数は不純な関数を呼び出すことはできません(なぜなら、呼び出せば自身も不純になってしまうからです)。したがって、純粋関数は依存関係を持つことができません。関数型プログラミングは、依存関係という概念を排除しなければならないのです。

当然ながら、ソフトウェアは不純な振る舞いがあってこそ役に立ちます。そのため、依存関係を注入する代わりに、関数型プログラムは不純なコンテキストで構成されなければなりません。不純な関数は純粋関数を呼び出すことができるので、境界部分で、アプリケーションは不純なデータを集め、それを使って純粋関数を呼び出す必要があります。これは自動的にポートとアダプターアーキテクチャに行き着きます

このスタイルのプログラミングは驚くほど多くの場合に可能ですが、万能な解決策ではありません。他の代替案も存在します


インデックスへ戻る