コンビネータを用いた曲レコメンデーション
2025-06-11 22:40:25
原文: Song recommendations from combinators by Mark Seemann
純粋な関数と不純な処理の交互実行。これは本当の意味での関数型プログラミングではありません。
この記事は、関数型プログラミングによる設計の代替アプローチに関する連載記事の一部であり、特に大量データを扱う際の手法に焦点を当てています。直近の記事では、関数型アーキテクチャの限界と思しき場面が登場しました。データ量が十分に大きくなると、Impureimサンドイッチパターンが破綻し始めます。これはそのパターンを否定するものではなく、すべての設計パターンに万能性はないという観察にすぎません。
今回と次回以降の記事では、より実践的な選択肢を取り上げます。本記事ではその概要を説明し、次回からは3つの異なる言語で具体例を紹介します。
この連載全体では、Oleksii Holub 氏による刺激的な記事 Pure-Impure Segregation Principle(純粋・不純分離原則) をコード例の出発点としています。これまでの記事で基礎部分はすでに扱いましたが、要点としては、過去の再生情報(「スクロブル」)をもとに新しい曲をユーザーに推薦するサービスです。
純粋関数と不純な合成処理の分離
元の記事では、Oleksii Holub 氏が 純粋関数 と不純な処理を分離する方法を提案しています。アルゴリズムからできる限り多くの純粋なコードを抽出することは可能ですが、それでも純粋関数と不純な処理は混在したままになります。
以下は、私がその提案をわずかに変更して再現したコード例です:
// Pure
public static IReadOnlyList<int> HandleOwnScrobbles(IReadOnlyCollection<Scrobble> scrobbles) =>
scrobbles
.OrderByDescending(s => s.ScrobbleCount)
.Take(100)
.Select(s => s.Song.Id)
.ToArray();
// Pure
public static IReadOnlyList<string> HandleOtherListeners(IReadOnlyCollection<User> users) =>
users
.Where(u => u.TotalScrobbleCount >= 10_000)
.OrderByDescending(u => u.TotalScrobbleCount)
.Take(20)
.Select(u => u.UserName)
.ToArray();
// Pure
public static IReadOnlyList<Song> HandleOtherScrobbles(IReadOnlyCollection<Scrobble> scrobbles) =>
scrobbles
.Where(s => s.Song.IsVerifiedArtist)
.OrderByDescending(s => s.Song.Rating)
.Take(10)
.Select(s => s.Song)
.ToArray();
// Pure
public static IReadOnlyList<Song> FinalizeRecommendations(IReadOnlyList<Song> songs) =>
songs
.OrderByDescending(s => s.Rating)
.Take(200)
.ToArray();
public async Task<IReadOnlyList<Song>> GetRecommendationsAsync(string userName)
{
// Impure
var scrobbles = await _songService.GetTopScrobblesAsync(userName);
// Pure
var songIds = HandleOwnScrobbles(scrobbles);
var recommendationCandidates = new List<Song>();
foreach (var songId in songIds)
{
// Impure
var otherListeners = await _songService
.GetTopListenersAsync(songId);
// Pure
var otherUserNames = HandleOtherListeners(otherListeners);
foreach (var otherUserName in otherUserNames)
{
// Impure
var otherScrobbles = await _songService
.GetTopScrobblesAsync(otherUserName);
// Pure
var songsToRecommend = HandleOtherScrobbles(otherScrobbles);
recommendationCandidates.AddRange(songsToRecommend);
}
}
// Pure
return FinalizeRecommendations(recommendationCandidates);
}
Holub 氏は次のように述べています:
「しかし、ひとつのまとまりのある要素として捉える代わりに、意味も価値も持たない複数の断片になってしまった。各部分のユニットテストは簡単になったかもしれないが、アルゴリズム全体の正しさに対する信頼性は得られない。実際、それには大きな疑問が残る。」
この評価には同意しますが、それでもこのアプローチをもう少し追求してみる価値はあると思います。というのも、この記事全体の目的は、何かを指示するのではなく、選べる道を示すことにあるからです。さまざまな選択肢を提示して比較することで、私たちはより多くの手段を知り、状況に応じて「最適な道具を選ぶ」助けになるはずです。
三層構造のサンドイッチ?
この別案を詳しく見てみると、実際には不純な処理は3つだけであることがわかります。したがって、これは拡張版のサンドイッチ、つまり三層構造のサンドイッチと考えることもできるかもしれません。
ただし、これは妥当な議論だとは思いません。たとえサンドイッチの比喩を拡張し、純粋なバリデーションステップを加えたとしても、レイヤーの数や構造はコンパイル時に決まっているはずです。最初に不純な境界から始まり、純粋なバリデーションに進み、さらに別の不純なステップでデータを取得し、「メイン」の純粋関数を呼び出し、最後に出力へ書き出すという流れになります。以下の図は、サンドイッチとは何か?の記事から引用しています:

しかし、先ほどのコード例はこの構造には当てはまりません。曲レコメンデーションアルゴリズムの問題は、不純な処理が連鎖している点にあります。最初は外部プロセスへの1つの不純な問い合わせから始まりますが、その結果をもとにループ処理を行い、さらに n 回の問い合わせを実行します。さらにそのループの中でネストされた同様の処理が行われるため、ネットワーク呼び出しの観点では O(n) のアルゴリズムとなります。
もう少し正確に見ると、「外側」のクエリは結果セットに制限があります。最初のクエリは上位100件のみを対象としているため、GetTopListenersAsync の呼び出しは最大でも100回です。さらにその結果は上位20件に制限されており、GetTopScrobblesAsync の内側の呼び出しは最大で 20×100 = 2,000 回になります。つまり、合計では 1 + 100 + 2,000 = 最大2,101回のネットワーク呼び出しが行われます。(つまり実際は O(1) のアルゴリズムですが、1〜2,101の範囲で定数です。)
とはいえ、これでも実行時間はそれなりにかかるでしょう。
ここで問題にしているのは実行時間ではありません。このアルゴリズムがすでに最適化されており、2,101回の呼び出しが必要不可欠であると仮定します。問題は、コードをいかに保守しやすい形で整理するかという点です。例題としてのこのアルゴリズムは、より複雑な問題の代替として考えてください。Holub 氏の元のコード例も、たかだか50行程度であり、それ自体がここまで深刻に議論されるほどの規模ではありません。
むしろ注目すべきは、純粋関数と不純な処理との間のダイナミックなやり取りです。数千回に及ぶ外部呼び出しはすべて非決定的であり、このアルゴリズムを保守・変更しようとすると、予測不可能な挙動に脳が疲弊します。微妙なバグが潜んでいる可能性も高まります。
コードをできるだけ純粋関数に寄せることができれば、その分だけ良くなります。なぜなら、参照透過性は頭に収まりやすいからです。
したがって、私はこの種の合成処理を「拡張版 Impureim サンドイッチ」とは考えていません。
標準的なコンビネーター
この提案を、ほんの少しでも改善できる方法はあるでしょうか? 少しでも「より関数型らしい」見た目にできるでしょうか?
例えば、モナディックな bind や トラバーサル など、標準的なコンビネーターを使うことができます。
正直に言えば、この曲レコメンデーションの例においてはその恩恵はごくわずかです。それでも、ある特定のテクニックを示すことには意味があります。例えば、recommendationCandidates のローカルな状態変更を排除することができますが、それ以上の効果はほとんどありません。
それでも、自己完結型の式にリファクタリングすることは、他のリファクタリングを容易にします。逆の例として、上記コード例にある foreach の内側ループをヘルパーメソッドに切り出すとします。
private async Task CollectOtherUserTopScrobbles(
List<Song> recommendationCandidates,
IReadOnlyList<string> otherUserNames)
{
foreach (var otherUserName in otherUserNames)
{
// Impure
var otherScrobbles = await _songService
.GetTopScrobblesAsync(otherUserName);
// Pure
var songsToRecommend = HandleOtherScrobbles(otherScrobbles);
recommendationCandidates.AddRange(songsToRecommend);
}
}
その呼び出し元は以下のようになります:
// Pure
var otherUserNames = HandleOtherListeners(otherListeners);
// Impure
await CollectOtherUserTopScrobbles(recommendationCandidates, otherUserNames);
この例では切り出しはそれほど難しくありませんが、もっと単純で済んだかもしれません。状態の変更があるため、recommendationCandidates をメソッドの引数として渡す必要があります。今回は1つだけですが、もし2つのオブジェクトの状態を変更するコードであれば、追加で2つの引数を渡す必要があります。
実際のコードベースでヘルパーメソッドを抽出しようとして、変更対象のオブジェクトと密結合しているために引数が長大になってしまった経験はないでしょうか? こうなると、簡略化のつもりがかえって状況を悪化させる恐れがあります。
一方で、自己完結した式(たとえ非決定的であっても)は状態を変更しないため、より容易に部分式として切り出すことができます。必要な引数はあるにしても、変更すべきオブジェクトを渡す必要がなくなるからです。
こうしたリファクタリング手法を紹介する理由は、式に基づいたスタイルへのリファクタリングの実例を示すことで、読者自身のコンテキストにも応用してもらえると考えるからです。そして場合によっては、これを通じてより大きな改善が可能になるかもしれません。
この連載では例によって、以下の3言語でこの手法の適用例を紹介します:
- C#版 コンビネータを用いた曲レコメンデーション
- F#版 コンビネータを用いた曲レコメンデーション
- Haskell版 コンビネータを用いた曲レコメンデーション
とはいえ、これは正統な 関数型アーキテクチャ だとは私は考えていません。Haskellの例ですら、私の基準では非決定性が強すぎるのです。
結論
曲レコメンデーションのような問題に対して最も現実的なアプローチは、不純な処理と純粋関数を適度に交差させることかもしれません。私は関数型プログラミングだけがコードを保守しやすくする方法だと主張するつもりはありません。他の原則に基づいてコードを整理することも可能であり、そうしたコードベースも現在および将来にわたって十分に機能を果たしうるのです。
もうひとつ考慮すべきは、そのコードベースを保守するチームのスキルレベルです。彼らが何に慣れているかも大切です。
もちろん、現状に甘んじるべきだとは思いません。進歩は 少し背伸びすること によってのみ得られますが、あまりに突飛なコードベースを作ってしまうと、チームメンバーが扱えなくなるリスクもあります。
次に紹介する3つの記事のスタイルこそが、あるチームにとって唯一保守可能なスタイルであるということも、十分あり得るのです。