はじめに
本記事はiimon Advent Calendar 2024 21日目の記事となります!
こんにちは!DDDとTDDには熱心だけど、推し活はDD(誰でも大好き)ではなく、とことん一途なエンジニアマネージャー、まつだです。
本日12月21日は私の誕生日ということで、なんと101101歳になってしまいました!(この手のやつ、16進数にしてるのはよく見るけど、2進数は斬新やな⋯)
ちょっとでも誕生日を祝ってくれる気持ちのある方は、ぜひ↓のボタンをぽちっとしてブックマークでもしてやってください(笑)
今回は、私もさらに歳を重ねて型にはまった人間にならねば、ということで、「関数型ドメインモデリング」という本を読んで学んだことを普段の業務で実践できるように、自分なりに落とし込んでみたいと思います。
まずは戦う相手を見定めよう
有史以来、さまざまなアーキテクチャや設計手法が編み出され、IO(入出力)まわりについてはDI(Dependency Injection)などを用いてかなりシンプルに疎結合な形で書くことができるようになりました。(DIについて知りたい場合は私の過去記事をごらんください)
IOにも変化がないとは言いませんが、データを読み込む先がMySQLからPostgreSQLに変わったり、出力形式がプレーンテキストからCSVに変わったりといったことは頻度としては少なくて、ドメインロジック(業務ロジックともいう)の変更のほうが頻繁にあるのが一般的ですし、事実、報告される不具合のほとんどはここで起こっています。
ドメインロジックの変更に素早く安全に対応できるか否かが、その開発組織の生産性に直結していると言っても過言ではないでしょう。
戦術はいかに
ドメインロジックの複雑さにどう立ち向かうかについてもさまざまな手法があり、その1つが自動テストであり、手動テストでもあります。
ただ、今回は0x2D歳になってしまった私が型にはまった人間になる(ダブル・ミーニングですよ!?念のため笑)のが目的なので、「関数型ドメインモデリング」を活かして実装ミスをあぶり出す方法を見ていきます!
まずは関数を知る
10年前、私はあるゲーム会社にいて、社内有志で実施された「Haskellだけでブログを作ろう勉強会」にはりきって参加したものの、参加2回目にしてついていけずに脱落したダメ人間です(笑)
そのあとも、同僚に誕生日プレゼントでもらった「すごいHaskell たのしく学ぼう!」を読もうとしては3行読んだところで寝落ちする、を繰り返して結局全然読めなかったくらい、「関数型」に苦手意識があります。
関数型に頼らなくても、従来通りの「命令型」だけでいける!そう思っていた時期が私にもありましたが、世の中はそんなに甘くありませんでした⋯
しかも「関数型」の足音は、まったく予期していなかった方向からやってきたのです。そう、まさかのフロントエンドから⋯
ReactやVue.jsに代表される「宣言的UI」というやつです。
「命令的UI」では複雑すぎたUIの状態管理を、「関数による状態遷移」に置き換えたことでシンプルにし、一躍脚光を浴びて今やデファクト・スタンダードにまでのぼりつめています。
状態sに関数fを適用することで、状態s'に遷移させる、こんなイメージです。
ではこれをドメインロジック視点で応用してみましょう。
あるひとつの記事をあらわすモデルがあったとして、閲覧したら閲覧済みのモデルに状態遷移させるのを想定してみます。(弊社では主にTypeScriptを使っているので、コードはTypeScriptを使います)
const viewArticle = (article: Article): Article => ({ ...article, isView: true, });
こんな関数があって、
const viewedArticle = viewArticle(article);
とすると閲覧済みの記事が返ってくる感じですね。
これの何が嬉しいのかパッと見ではわかりにくいですが、関数型じゃなかったときのことを思い出して比較してみるとよくわかると思います。
// 閲覧済みにする処理がこの中に書かれてあり、 // 自身のプロパティを書き換えている article.view();
たぶんこんな形になったのではないでしょうか。
- 関数型の方が状態の遷移が明示的でわかりやすい
- 関数が返しているオブジェクトはあくまでもコピーであり、引数として受け取っているarticleを書き換えていない
- 関数型ではない方(メソッド呼び出し)では、article自身を書き換えている(かもしれない)
ということで、嬉しいポイントがいくつもありますね。
ここまでで、モデルの状態遷移に関数型を使うメリットがわかってもらえたかなと思います。
ここからは関数型をより安全に便利に使うために知っておきたい知識です。
しゅ、しゅ、出力がないだってぇ!?
何かの入力を与えると、出力が返ってくる、それが関数です。
でもうっかり油断していると、出力を返さない関数を作ってしまうこともあるんです。
const divide = (a: number, b: number):number => { if (b === 0) { throw new Error("除数は0では割れません"); } return a / b; } console.log(divide(10, 5)); // 2 console.log(divide(27, 9)); // 3 console.log(divide(50, 0)); // エラー!
よかれと思って0で割り算しようとしたときに例外を投げようとしたのに、戻り値の型がnumberなのでエラーになってしまいます。
これではせっかくの関数の性質をうまく使いこなすことができません⋯。
そこで登場する言葉が、「全域関数」です。
「全域関数」とは、すべての入力に対して出力がある関数のことです。
全域関数にするためのアプローチとしては、
- 入力値の制限
- 出力の拡張
の2つがあります。
「入力値の制限」はそもそも0を渡せないように型で縛ってしまう方法、「出力の拡張」は戻り値の型を拡張し、正しく出力を返せるようにすることです。
型を縛る?型を拡張?
型を自由自在に使いこなすには、「型は値の集合である」ということを意識する必要があります。
型=値の集合とはなんぞや?
たとえばTypeScriptでいうnumber型には、0もあれば1もあれば-2もあれば3.45もあるし、trueとfalseはboolean型、""や"これ"などはstring型というように、型は値の集合になっています。
ここからは私のように数学が苦手な方には目を背けたくなる言葉、「直積集合」「直和集合」が登場します。
直積集合は構造体とか、TypeScriptで言うクラスを想像してください。
class Chokuseki<T, U> { constructor(public a: T, public b: U) {} } const chokuseki1 = new Chokuseki(1, 'a'); const chokuseki2 = new Chokuseki(2, 'b');
1つめの引数aと2つめの引数b、それぞれの掛け合わせでペアができています。
このように要素aと要素bのペアの組み合わせができることを直積集合といいます。
では直和はなんでしょうか。
直和はTypeScriptで書くと例がわかりやすいと思います。
サバイバルTypeScriptさんの説明がめちゃめちゃわかりやすいので引用させていただきます。
type UploadStatus = InProgress | Success | Failure; type InProgress = { type: "InProgress"; progress: number }; type Success = { type: "Success" }; type Failure = { type: "Failure"; error: Error };
これの1行目、いわゆるタグつきユニオンがまさに直和集合です。
直和集合はInProgress「か」Success「か」Failure「か」という、いわゆる「OR」を持つことができます。
そしてこの「直積集合」と「直和集合」の2つの掛け合わせを表現できる型を「代数的データ型」といいます。
「代数的データ型」を適切に使いこなすことで、全域関数を作ることができますし、複雑なドメインロジックの状態遷移も確実に表現することができるというわけです!
まとめ
関数型のアプローチを取り入れると、従来の命令型に比べて多くのメリットがあることがわかりました。そのメリットをまとめてみます。
「状態管理のやりやすさ」⋯命令型では状態が直接変更されてしまい、状態の変化(遷移)を追いにくいことがありましたが、関数型だと状態は不変(イミュータブル)であり、状態変更時には新しい状態が作られるので追いやすい。かつ、過去の状態の再利用もしやすい。
「副作用の管理のしやすさ」⋯関数型は新しい状態を返すだけで不変(イミュータブル)なので、副作用が少ない。命令型は内部の要素を変更するため、副作用が発生し、コードも複雑になりやすい。
「型チェックでエラーに気づける」⋯実際に動かしてみなくても、関数型の場合は型チェックで実装エラーに気づける場合が多い。
あと、本文中ではあえて触れませんでしたが、下記のような「ついで」の良い部分もあります。
「テストが簡単」⋯命令型は状態が変更されるので、どう変更されるかをテスト等で詳しく見る必要があるが、関数型は新しい状態を返すだけなので状態遷移のテストは簡単。
「並行処理が安全」⋯関数型は不変(イミュータブル)なので並行処理が簡単・安全にできる。
弊社で主に利用しているTypeScriptは、従来の命令型に、今回紹介した関数型のアプローチをいいバランスで取り入れられる優秀な言語です。
少しずつ取り入れていって、複雑なビジネスロジックとの戦いに勝利できるようにしていきたいですね!
おわりに
明日のアドベントカレンダーは、いつもニコニコ、神スマイルの人格者、takushiさんの登場です!
最後になりますが、現在弊社ではエンジニアを募集しています。
この記事を読んで少しでも興味を持ってくださった方は、ぜひカジュアル面談でお話ししましょう!
iimon採用サイト / Wantedly / Green
最後まで読んでいただきありがとうございました!