読/計/書サンドイッチ
2025-04-09 14:20:25
原文: Recawr Sandwich by Mark Seemann
デザインパターンのバリエーション
以前に「結果値の収集と処理」と「非同期処理における短絡評価」という記事を書いた後、Impureimサンドイッチ パターンのバリエーションとして、より規律正しい形を説明することには価値があるのではないか、と気づきました。
名著『デザインパターン』では、各パターンがいくつかのセクションに分けて解説されています。 そこには、パターン全体の動機、構造、UML 図、コード例などが含まれています。 あるセクションでは、実装上の様々なバリエーションが議論されています。私も同様に、より大きな枠組みである Impureimサンドイッチ パターンの中でも、特定のバリエーションに明確に光を当てることは有益だと考えています。
このバリエーションは、元のパターンに追加の制約を加えるものです。一見すると制約は窮屈に感じるかもしれませんが、「制約は自由をもたらす」のです。
読/計/書サンドイッチは、Impureimサンドイッチ全体の中の、特殊化されたサブセットと考えることができます。
読み込み (Read)、計算 (Calculate)、書き込み (Write)
読/計/書サンドイッチの制約を簡単に言うと、処理が以下の順序で構成されるべき、というものです。
- データの読み込み (Read)。このステップは副作用を伴う「不純な」処理です。
- 読み込んだデータから結果を計算 (Calculate) する。このステップは純粋関数であるべきです。
- データを書き込み (Write) する。このステップも不純な処理です。
サンドイッチが3層以上になる場合でも、この「読み込み → 計算 → 書き込み」の順序は維持されるべきです。 つまり、ネットワーク、ディスク、データベース、あるいはユーザーインターフェースへのデータ書き込みを開始したら、その後で追加のデータを読み込む処理に戻るべきではない、ということです。
命名について
読/計/書(Recawr)サンドイッチという名前は、REad CAlculate WRite の頭文字を取って作りました。「リカバー・サンドイッチ」のように発音します。
このバリエーションに名前を付けようと考えた当初は、read/write sandwich(読み書きサンドイッチ)という名前を思いつきました。しかし、それでは最も重要な要素である純粋関数が名前から抜け落ちてしまうと考え直しました。 read, pure, write sandwich(読み込み、純粋、書き込みサンドイッチ)や input, referential transparency, output sandwich(入力、参照透過性、出力サンドイッチ)といった他の案も検討しましたが、read, calculate, write(読み込み、計算、書き込み)ほど、このパターンの要点をうまく表現できているものはない、と私は考えています。
発想のきっかけとなった例
はっきりさせておきたいのですが、私自身はこの 読/計/書サンドイッチ パターンを長年適用してきました。しかし、時には反例に遭遇して初めて、これまで暗黙のうちに実践してきた知識を明確な言葉で表現する必要性に気づかされることがあります。 私にとってのその契機は、Impureimサンドイッチの以下の実装について議論していた時でした。
// Impure
IEnumerable<OneOf<ShoppingListItem, NotFound<ShoppingListItem>, Error>> results =
await itemsToUpdate.Traverse(item => UpdateItem(item, dbContext));
// Pure
var result = results.Aggregate(
new BulkUpdateResult([], [], []),
(state, result) =>
result.Match(
storedItem => state.Store(storedItem),
notFound => state.Fail(notFound.Item),
error => state.Error(error)));
// Impure
await dbContext.SaveChangesAsync();
return new OkResult(result);
注目すべきは、コードの最初にある不純なステップが、アイテムのコレクションを走査し、各アイテムに対して UpdateItem
というアクションを適用している点です。
元の記事でも述べたように、UpdateItem
が具体的に何をするかは分かりませんが、その名前からして特定のデータベース行を更新する処理であることが強く示唆されます。
たとえ実際の書き込み処理が SaveChangesAsync
の呼び出しまで遅延されるとしても、この構成には違和感を覚えます。
正直に言うと、この問題に気づいたのは、「もし自分がこの問題をゼロから解決するとしたら、どうアプローチするだろうか?」と考え始めてからのことでした。 なぜなら、おそらく私なら、そもそもこのような実装はしないだろうからです。
更新処理を「早すぎる」段階で行うことが、コードを必要以上に複雑にしてしまっているように思えるのです。
では、読/計/書サンドイッチなら、どのような形になるでしょうか?
読/計/書パターンの適用例
一つの方法として、まずデータベースに問い合わせて、どのアイテムが実際に存在するかを確認し、次に計算処理で結果を準備し、最後に更新処理を実行する、という手順が考えられます。
// Read
var existing = await FilterExisting(itemsToUpdate, dbContext);
// Calculate
var result = new BulkUpdateResult([.. existing], [.. itemsToUpdate.Except(existing)], []);
// Write
var results = await existing.Traverse(item => UpdateItem(item, dbContext));
await dbContext.SaveChangesAsync();
return new OkResult(result);
正直なところ、このバリエーションは Error
値が発生した場合の挙動が元のコードとは異なります。しかし、そもそも元のコードにおけるエラー値の目的自体が私には完全には理解できていませんでした。
もしその目的が「クライアントコード側では回復不能なエラーを表現すること」であるなら、代わりに例外をスローすべきでしょう。
いずれにせよ、この例は、多くのI/O 処理が集中する操作の典型であり、そうした操作はパターンとして意味をなさなくなる「退化」の瀬戸際にあることが多いものです。 実際には、ここに含まれるロジックはそれほど多くないため、この例自体が有用なのか、と疑問に思う人もいるかもしれません。 しかし、この例こそが、私に 読/計/書サンドイッチという名前を明示的に与えることを思い立たせたきっかけとなったのです。
その他の例
実は、オリジナルの Impureimサンドイッチ の記事で紹介されている例は、すべて 読/計/書サンドイッチに該当します。 その他、読/計/書サンドイッチの明確な例を示している記事としては、以下のようなものがあります。
言い換えれば、私はこれらの既存の例に対して、後付けでより具体的な 読/計/書サンドイッチというラベルを貼っているに過ぎません。
では、読/計/書サンドイッチ ではない Impureimサンドイッチの例とは何でしょうか? 皮肉なことに、それはこの記事の冒頭で示した最初の例です。
まとめ
読/計/書サンドイッチは、より一般的な Impureimサンドイッチ パターンを特殊化したものです。 この特殊化は、サンドイッチ構造における2つの不純な層(副作用を伴う処理層)に明確な役割を与える点にあります。最初の不純な層では、コードはデータの読み込みを行います。 2番目の不純な層では、データの書き込みを行います。そして、これらの不純な層の間で、参照透過性を持つ純粋な計算処理を実行します。
元のパターンよりも制約は厳しくなりますが、この特殊化された形は、実装における優れた指針となります。うまく設計されたサンドイッチ構造の多くは、この 読/計/書のテンプレートに従っています。