関数型アーキテクチャとは - その定義
2025-04-14 18:00:25
原文: Functional architecture: a definition by Mark Seemann
ご自身のソフトウェアアーキテクチャが、関数型プログラミングの優れた実践方法に沿っているかどうか、どうすれば判断できるのでしょうか?その方法の一つをご紹介します。
私は長年、関数型アーキテクチャに関する記事を執筆してきました。たとえば、「関数型アーキテクチャはポートとアダプターである」という記事や、会議での講演、さらにはこのテーマに関するPluralsightの講座も作成しました。しかし、そもそも「関数型アーキテクチャ」とは、どのように定義すべきなのでしょうか?
時折、F#で書かれたコードについて、「自分のF#コードが関数型的であるかどうか、どうすれば分かりますか」という質問を受けることがあります。
この問いにお答えする前に、少々寄り道をさせてください。
オブジェクト指向設計の定義とは?
オブジェクト指向設計(OOD)は1960年代から存在しており、すでに数十年の歴史があります。時々、特定の設計が優れたオブジェクト指向設計と言えるかどうかについて議論が交わされます。私自身も何度かそのような議論に参加した経験があります。
しかし、これらの議論は、最終的に結論が出ないまま終わることがほとんどです。なぜなら、オブジェクト指向設計について、議論の決着をつけられるほど厳密な定義を誰も示せないように思えるからです。とはいえ、一つだけ確かなことがあります。そこで、ゴドウィンの法則の系として、次のことを提案します。
オブジェクト指向設計に関する議論が長引くほど、アラン・ケイの名前が話題に上る可能性は限りなく1に近づきます。
私は決して、アラン・ケイとヒトラーの間に何らかの論理的なつながりがあると言いたいわけではありません。しかし、オブジェクト指向設計について議論していると、遅かれ早かれ誰かがこう言い出すのです。
「それはアラン・ケイの意図したことではない!」
実際、それは真実かもしれません。
しかし、私がこの主張に対して疑問を感じるのは、アラン・ケイが何を意図していたのかを、どうしても掴めないからです。おそらく、メッセージパッシングとSmalltalkが関係しているのでしょう。そして、このスタイルのプログラミングの現代における最も近い例は、Erlangかもしれません(皮肉なことに、関数型プログラミング言語として宣伝されることが多いのですが)。
しかし、これは、何かがオブジェクト指向であるかどうかを判断するための適切な基準とは思えません。
いずれにせよ、アラン・ケイが何を意図していたかに関係なく、私たちが手にしたオブジェクト指向プログラミングは、彼の意図したものではありませんでした。Eiffelは、多くの点で独特なプログラミング言語ですが、『オブジェクト指向入門』で示されたオブジェクト指向設計の哲学は、私にはJavaが発展する土壌となったように思われます。
Javaの詳細な歴史については知りませんが、この言語の精神は、アラン・ケイのビジョンよりも、バートランド・メイヤーのビジョンとより親和性があるように思えます。
そして、C#が現在の形になったのは、Javaの影響が大きかったからに他なりません。
つまり、私たちが手にしたオブジェクト指向設計は、当初意図されていたものとは異なっていたのです。さらに悪いことに、私たちが手にしたオブジェクト指向設計は、不明確な原則に基づいて成り立っているように思えます。確かに、カプセル化という考え方はありますが、メイヤーが「契約による設計」について非常に具体的な考えを持っていた一方で、彼のビジョンの中で際立っていたその特徴は、JavaやC#には受け継がれませんでした。
オブジェクト指向設計が何であるかは明確ではありませんが、関数型プログラミング(FP)に関しては、もっと明確にできると考えています。
参照透過性
関数型プログラミングが何であるかは、オブジェクト指向設計よりも明確に示すことができます。もちろん、以下の定義に異論がある人もいるかもしれません。これは一般的に受け入れられている定義であるとは主張しません。しかし、この定義には、正確であり、反証可能であるという利点があります。
関数型プログラミングの基礎は、参照透過性です。これは、ある式において、等号の左辺と右辺が真に等しいという考え方です。
two = 1 + 1
Haskellでは、これはコンパイラによって強制されます。=
演算子は、真に等価性を意味します。念のために言っておきますが、これはC#には当てはまりません。
var two = 1 + 1;
C#、Javaなどの命令型言語では、=
は等価性ではなく、代入を意味します。ここでは、直感に反するかもしれませんが、two
の値が変わる可能性があります。
コードが参照透過的である場合、式中の右辺を左辺の記号で置き換えることができます。これは、2つの数値を足す場合は当然のことのように思えますが、関数呼び出しを考えると、それほど明確ではなくなります。
i = findBestNumber [42, 1337, 2112, 90125]
Haskellでは、関数は参照透過的です。findBestNumber
が具体的に何を行うかは分かりませんが、i
をfindBestNumber [42, 1337, 2112, 90125]
で置き換えたり、その逆を行ったりできることが分かります。
関数が参照透過的である(つまり、純粋関数である)ためには、次の2つの性質を持つ必要があります。
- 同じ入力に対して、常に同じ出力を返すこと。この性質を「決定的」と呼びます。
- 副作用がないこと。
私の知る限り、関数型プログラミングの他のすべての特性は、この定義から導き出すことができます。たとえば、値は不変でなければなりません。なぜなら、不変でない場合、値を変更できてしまい、それが副作用と見なされるからです。
私がこの定義を好むのは、反証可能だからです。関数や値が純粋であると主張できます。そして、それが純粋でないことを証明するには、反例を一つ示せば十分です。反例としては、常に同じ戻り値を生成するとは限らない入力値や、副作用を生み出す関数呼び出しなどが挙げられます。
私は、これほど強力な判断基準となる定義を他に知りません。
IO
すべてのソフトウェアは何らかの副作用を伴います。モニター上のピクセルを変更することも副作用ですし、バイトをディスクに書き込むことも副作用です。ネットワークを介してビットを送信することも、副作用の一つです。純粋関数だけでソフトウェアを構成するのは不可能に思えます。実際、不純な処理を行うための何らかの仕組みがなければ、純粋関数だけで完結させることはできません。
Haskellでは、この問題はIO
モナドによって解決されますが、この記事の目的は、Haskell、モナド、またはIO
の入門をすることではありません。ここで重要なのは、関数型プログラミングでは、現実世界とやり取りするための何らかの「抜け道」が必要になるという点です。それを避けることはできませんが、論理的なレベルでは、依然として規則が適用されます。純粋関数は、決定的であり、副作用がない状態を維持しなければなりません。
したがって、処理は大きく分けて、不純な処理と純粋関数の2つのグループに分けられます。
純粋関数には規則がありますが、これらの規則はインタラクションを妨げるものではありません。一つの純粋関数は、別の純粋関数を呼び出すことができます。このようなインタラクションは、これらの関数のいずれの性質も変えることはありません。呼び出し側と呼び出される側の両方が、副作用がなく、決定的な状態を維持します。
不純な処理も互いにやり取りできます。そして、不純な処理には何の規則も適用されません。
最後に、不純な処理には規則が適用されないため、純粋関数を呼び出すこともできます。
不純な処理は規則に縛られずに、ピクセルを描画したり、ファイルに書き込んだり、純粋関数を呼び出したりするなど、必要なことは何でも実行できます。純粋関数は、決定的であり、副作用がありません。そして、これらの性質は、その結果が画面に表示されるという理由だけで変わることはありません。
ただし、4番目の矢印の組み合わせは許可されていません。
純粋関数は、不純な処理を呼び出すことはできません。
もし呼び出すと、結果として副作用が生じたり、非決定的な動作を引き起こしたりする可能性があります。
これが、関数型アーキテクチャの規則です。これは、次の表を使って説明することもできます。
呼び出し先 | |||
---|---|---|---|
不純 | 純粋 | ||
呼び出し元 | 不純 | 有効 | 有効 |
純粋 | 無効 | 有効 |
上記の規則を「関数型インタラクションの法則」と呼ぶことにしましょう。つまり、「純粋関数は不純な処理を呼び出すことはできない」ということです。したがって、関数型アーキテクチャとは、この法則に従い、コードのかなりの部分が純粋なコードで構成されているコードベースのことです。
もちろん、すべてのコードを不純なコードとして記述すれば、関数型インタラクションの法則には結果的に従うことになります。ある意味で、これは命令型プログラミング言語でコードを書く際のデフォルトのやり方と言えるでしょう。Haskellに慣れている方であれば、プログラム全体をIO
モナドで記述することを想像してみてください。それは可能かもしれませんが、意味がありません。
したがって、「コードベースのかなりの部分が純粋なコードで構成されている必要がある」という条件を追加する必要があります。では、コードのどれくらいの割合を純粋なコードにする必要があるのでしょうか?多ければ多いほど良いと言えます。私の主観的な意見としては、コードベースの半分を大幅に超える部分が純粋であるべきだと思います。しかし、コードカバレッジの場合と同様に、ここで厳密な制限を設けることはあまり意味がないと考えています。
ツール
関数型インタラクションの法則に従っていることを、どのように検証するのでしょうか?残念ながら、ほとんどの言語では、これを確認するには手間のかかる分析を行う必要があります。そして、これは意外と難しい作業になることがあります。次のF#の例を見てみましょう。
let createEmailNotification templates msg (user : UserEmailData) =
let { SubjectLine = subjectTemplate; Content = contentTemplate } =
templates
|> Map.tryFind user.Localization
|> Option.defaultValue (Map.find Localizations.english templates)
let r =
Templating.append
(Templating.replacementOfEnvelope msg)
(Templating.replacementOfFlatRecord user)
let subject = Templating.run subjectTemplate r
let content = Templating.run contentTemplate r
{
RecipientUserId = user.UserId
EmailAddress = user.EmailAddress
NotificationSubjectLine = subject
NotificationText = content
CreatedDate = DateTime.UtcNow
}
これは純粋関数でしょうか?
Templating.replacementOfFlatRecord
が何を行うのか分からないので、これは不当な質問だと感じる方もいるかもしれません。しかし、それは実は関係ありません。DateTime.UtcNow
が存在するということは、現在の日時を取得する処理が非決定的であるため、関数全体が不純であることを意味します。そして、この不純性は伝わります。つまり、createEmailNotification
を呼び出すコードはすべて不純になるということです。
したがって、次のような式が純粋かどうかを判断するのは、容易ではありません。
let emailMessages = specificUsers |> Seq.map (createEmailNotification templates msg)
これは純粋な式でしょうか?この例では、createEmailNotification
が不純であることをすでに確認しているため、答えを出すのは難しくありません。しかし、問題は、どの関数が純粋で、どれが不純であるかを、コードを読む人が覚えておく必要があるという点です。コードベースが大きくなると、これは非常に困難な作業になります。
関数型インタラクションの法則を自動的にチェックできるツールがあればいいのですが。
そして、関数型プログラミングコミュニティの多くの人々は、関数型アーキテクチャのこの定義に不快感を覚えるでしょう。私が知る限り、関数型インタラクションの法則を強制できるツールを備えているプログラミング言語は、Haskellなどごくわずかです(他にもいくつかあります)。
Haskellは、IO
型を介して関数型インタラクションの法則を強制します。純粋関数(IO
を返さない関数)内でIO
型の値を使用することはできません。もし使用しようとすると、コードがコンパイルされません。
私は、関数型アーキテクチャの限界を理解するために、Haskellを繰り返し使用してきました。たとえば、依存関係の注入はすべてを不純にしてしまうため関数型的ではない、ということを確認するために使用しました。
しかし、ツールが全体的に不足しているため、多くの人が不安を感じるかもしれません。なぜなら、F#、Erlang、Elixir、Clojureなどの、いわゆる関数型言語のほとんどが、関数型アーキテクチャの検証や強制をサポートしていないからです。
私自身の経験から言うと、F#でアプリケーション全体を記述していると、コードの奥深くに潜むどこかで、関数型インタラクションの法則にうっかり違反していることがよくあります。
結論
関数型アーキテクチャとは何でしょうか?私は、関数型アーキテクチャの法則に従い、コードのかなりの部分が純粋関数で構成されているコードであると提案します。
これは、狭義の定義です。この定義では、「十分に関数型」と容易に考えられる多くのコードベースが除外されます。しかし、この定義によって、F#、Clojure、Erlangなどの優れたプログラミング言語を貶めるつもりはありません。私自身、.NETプログラミングのデフォルト言語としてF#を使用しており、F#でのコーディングを楽しんでいます。
このやや制限的な定義を提示する理由は、何かがオブジェクト指向であるかどうかについて、完全に主観的な判断になりがちというオブジェクト指向設計の状況を避けたいからです。関数型インタラクションの法則があれば、ほとんどの(非Haskell)プログラムは「真の意味で」関数型的ではないと結論付けることができます。しかし、少なくとも、私たちが目指すべき反証可能な理想を確立することはできます。
これにより、たとえば、F#のコードベースを見て、「これは理想にどれだけ近いか」という議論を始めることができるようになります。
結局のところ、関数型アーキテクチャはそれ自体が目標ではありません。持続可能なコードベースなどの目標を達成するための手段です。私は、関数型プログラミングがコードベースを持続可能に保つ上で役立つと考えていますが、多くの場合、「十分に関数型である」ことが、その目的を達成するには十分です。