C#版 コンビネータを用いた曲レコメンデーション
2025-06-16 17:00:25
原文: Song recommendations from C# combinators by Mark Seemann
SelectMany や Traverse を含む LINQ スタイルの関数合成。
この記事は、関数型プログラミングによる設計の代替アプローチというシリーズの一部です。前回の記事(こちら)では、いかにも関数型に見えるけれども実際にはそうでもない、実用的な小規模アーキテクチャについて大まかに紹介しました。
サンプルコードの背景については、これまでの記事をご参照ください。この記事に登場するコードは combinators ブランチからのものです。
ここでの目標は、レコメンドアルゴリズム全体から 純粋関数 を抽出し、それらを SelectMany(モナディックな bind)、Select、Traverse といった標準的なコンビネーターで合成することです。
コンビネーターによる合成
まずは最終的な合成コードを見てから、注目すべき部分を順に見ていきましょう。
public Task<IReadOnlyList<Song>> GetRecommendationsAsync(string userName)
{
// 1. 自ユーザーのトップスクロブルを取得
// 2. 同じ曲を聴いた他ユーザーを取得
// 3. そのユーザーのトップスクロブルを取得
// 4. 曲を集約してレコメンドを生成
return _songService.GetTopScrobblesAsync(userName)
.SelectMany(scrobbles => UserTopScrobbles(scrobbles)
.Traverse(scrobble => _songService
.GetTopListenersAsync(scrobble.Song.Id)
.Select(TopListeners)
.SelectMany(users => users
.Traverse(user => _songService
.GetTopScrobblesAsync(user.UserName)
.Select(TopScrobbles))
.Select(Songs)))
.Select(TakeTopRecommendations));
}
これは入れ子になった副式を含む単一の式です。
UserTopScrobbles、TopListeners、TopScrobbles、Songs、TakeTopRecommendations はすべて private なヘルパー関数です。そのうちの一つは次のようなものです:
private static IEnumerable<Scrobble> UserTopScrobbles(IEnumerable<Scrobble> scrobbles)
{
return scrobbles.OrderByDescending(scrobble => scrobble.ScrobbleCount).Take(100);
}
他のヘルパー関数も、これと同様に単一の式で定義されたシンプルな関数です。
Oleksii Holub 氏が示唆しているように、これらの小さな関数をそれぞれテストしたい場合には、public にすることもできます。
それでは、こうした合成を可能にしている各要素を見ていきましょう。
非同期モナド
C#(や .NET)には IEnumerable<T> 向けの標準コンビネーターしか備わっていないため、他の モナド に対してこれらを使いたい場合は、自分で定義するか、それを含むライブラリを導入する必要があります。今回の合成では、Task 計算に対する SelectMany と Select が必要です。その実装については、Asynchronous monads という記事で紹介しているので、ここでは繰り返しません。
ただし、以下の拡張メソッドだけは例外です。これはモナディックな return に相当する変種なのですが、記事を公開したことがあったかは定かではありません:
internal static Task<T> AsTask<T>(this T source)
{
return Task.FromResult(source);
}
特筆すべき点はあまりありません。Task.FromResult のラッパーにすぎないからです。ただし、this キーワードにより AsTask は拡張メソッドとなり、見た目が若干すっきりします。このメソッドは上記の合成コードでは使っていませんが、後述する Traverse の実装では登場します。
トラバーサル
トラバーサル は仮想的な Sequence アクションから実装することも可能ですが、今回は直接実装する方法を選びました。
internal static Task<IEnumerable<TResult>> Traverse<T, TResult>(
this IEnumerable<T> source,
Func<T, Task<TResult>> selector)
{
return source
.Select(selector)
.Aggregate(
Enumerable.Empty<TResult>().AsTask(),
async (acc, x) => (await acc).Append(await x));
}
source に対して selector をマッピングすると、Task のシーケンスが得られます。それを Aggregate 式で畳み込み、コンテナの構造を反転させて、結果値のシーケンスを内包する単一の Task に変換しています。
本質的には、それだけのことです。
結論
前回の記事でも率直に述べましたが、今回のリファクタリングは、この具体例においては効果があるとしてもごくわずかです。この記事の目的は、この書き方を強制することではありません。可能性の一例を示すことにあります。
より複雑な問題に直面したときには、標準コンビネーターによるリファクタリングが有効になることもあるでしょう。SelectMany や Traverse などの標準コンビネーターは、理解が広く行き届いており、法則性も明確です。こうしたコンビネーターは不具合が起きにくいと期待できるため、条件付きのネストループといったアドホックなコード構造よりも、些細なバグを回避しやすくなります。
また、こうした抽象に慣れているチームであれば、標準コンビネーターで組み立てられたコードのほうが、命令型の 制御フロー に埋もれたコードよりも 読みやすい ものになるかもしれません。仮にチーム全体がこのスタイルにまだ馴染んでいなくても、スタイルの幅を広げるよい機会かもしれません。
もちろん、このような構文がすでに慣習となっている言語を使っている場合は、同僚たちもこうした書き方には慣れていることでしょう。