C#版 フリーモナドを用いた曲レコメンデーション
2025-09-01 18:30:25
原文: Song recommendations with C# free monads
できるからといって、仕事で使ってはいけません。
これは「関数型プログラミングによる設計の代替アプローチ」という連載の最後の記事です。このシリーズでは、さまざまな関数型プログラミングのパターンを用いて、非自明な問題をどうモデリングできるかを見てきました。直前の2記事では、Haskell と F# で フリーモナドを使って問題をモデリングする方法を紹介しました。本記事では同じ課題を C# でやってみます。美しくはありませんが、不可能ではありません。
この演習の目的は何でしょうか? ほとんどはデモコードの対比を揃えるためです。もし F# や Haskell よりも C# に馴染みがあるなら、フリーモナドが何であり、どう動作するのかをより実感できるかもしれません。それだけのことです。以下のコードを実用的だとは考えていませんし、推奨するものでもありません。
ここで示すコードは、この連載とあわせて公開している DotNet Git リポジトリの free ブランチを元にしています。
ファンクター
フリーモナドを使うと、任意のファンクターからモナドを定義できます。ここでは、ドメイン固有言語(DSL)の命令セットを表現するファンクターから始めます。目標は、この命令セットで SongService インターフェイスを置き換えることです。
念のため、このインターフェイスを示します。
public interface SongService
{
Task<IReadOnlyCollection<User>> GetTopListenersAsync(int songId);
Task<IReadOnlyCollection<Scrobble>> GetTopScrobblesAsync(string userName);
}
このコードは、Pure-Impure Segregation Principle(純粋・不純分離原則) に出てくるサンプルコードを再現しようとした私の試みです。SongService が基底クラスなのかインターフェイスなのかは分かりません。C# のインターフェイスに慣例的につけられる I プレフィックスがないため、本来は基底クラスだった可能性もありますが、私は基底クラスよりインターフェイスの方が扱いやすいので、ここではインターフェイスとして扱います。
いずれにせよ、これを命令セットで置き換えるのが目標です。まずは直和型から始めます。C# には直和型がネイティブで存在しないため、チャーチエンコーディングかVisitor パターンを使って表現する必要があります。Visitor は何度も扱ってきたので、ここではシンプルなチャーチ方式を採用します。
まず SongInstruction<T> というクラスを定義し、以下のメソッドを追加します。
public TResult Match<TResult>(
Func<(int, Func<IReadOnlyCollection<User>, T>), TResult> getTopListeners,
Func<(string, Func<IReadOnlyCollection<Scrobble>, T>), TResult> getTopScrobbles)
{
return imp.Match(getTopListeners, getTopScrobbles);
}
よく見れば、getTopListeners 引数がインターフェイスの GetTopListenersAsync メソッドに対応し、もう一方の引数も同様に対応しているのが分かるでしょう。
すでに別の文脈で詳細な説明をしているので、ここでは細部の実装は省きます。すべてのコードは Git リポジトリで確認できます。
ファンクターとするためには、このクラスに Select メソッドが必要です。
public SongInstruction<TResult> Select<TResult>(Func<T, TResult> selector)
{
return Match(
t => SongInstruction.GetTopListeners(
t.Item1, // songId
users => selector(t.Item2(users))),
t => SongInstruction.GetTopScrobbles(
t.Item1, // userName
songs => selector(t.Item2(songs))));
}
ここでの t は tuple(タプル)の略です。最近の C# ではラムダ式でタプルのパターンマッチが可能になっているかもしれませんが、このコードが基にしているバージョンではまだできません。どちらの場合も Item2 が「継続」関数です。
モナド
次のステップは、このファンクターを包んで命令を逐次実行できるデータ構造を作ることです。これは別の直和型になり、SongProgram<T> という名前を付けます。その特徴は以下の Match メソッドです。
public TResult Match<TResult>(
Func<SongInstruction<SongProgram<T>>, TResult> free,
Func<T, TResult> pure)
{
return imp.Match(free, pure);
}
この型を正しいモナドにするためには、SelectMany メソッドが必要です。
public SongProgram<TResult> SelectMany<TResult>(
Func<T, SongProgram<TResult>> selector)
{
return Match(
i => SongProgram.Free(i.Select(p => p.SelectMany(selector))),
selector);
}
すでにコードが冗長なので、ここでは i を instruction(命令)、p を program(プログラム)の略として使っています。
リフトされたヘルパー
必須ではありませんが、命令型の各ケースごとにヘルパーメソッドを用意すると便利です。
public static SongProgram<IReadOnlyCollection<User>> GetTopListeners(int songId)
{
return Free(SongInstruction.GetTopListeners(songId, Pure));
}
public static SongProgram<IReadOnlyCollection<Scrobble>> GetTopScrobbles(string userName)
{
return Free(SongInstruction.GetTopScrobbles(userName, Pure));
}
これにより、利用側のコードが少し見やすくなります。
DSL としての曲レコメンデーション
C#版 コンビネータを用いた曲レコメンデーション の合成を出発点として、SongProgram を返す関数に書き換えるのはそれほど大きな変更ではありません。
public static SongProgram<IReadOnlyList<Song>> GetRecommendations(string userName)
{
// 1. 自ユーザーのトップスクロブルを取得
// 2. 同じ曲を聴いた他ユーザーを取得
// 3. そのユーザーのトップスクロブルを取得
// 4. 曲を集約してレコメンドを生成
return SongProgram.GetTopScrobbles(userName)
.SelectMany(scrobbles => UserTopScrobbles(scrobbles)
.Traverse(scrobble => SongProgram
.GetTopListeners(scrobble.Song.Id)
.Select(TopListeners)
.SelectMany(users => users
.Traverse(user => SongProgram
.GetTopScrobbles(user.UserName)
.Select(TopScrobbles))
.Select(Songs)))
.Select(TakeTopRecommendations));
}
元の RecommendationsProvider クラスの GetRecommendationsAsync メソッドと非常によく似ています。songService.GetTopScrobblesAsync の代わりに SongProgram.GetTopScrobbles を呼び、songService.GetTopListenersAsync の代わりに SongProgram.GetTopListeners を呼んでいるだけです。
ただし、この合成をサポートするためにはトラバーサルが必要になります。
トラバース
まず SongProgram に Select 関数を追加します。
public SongProgram<TResult> Select<TResult>(Func<T, TResult> selector)
{
return SelectMany(x => SongProgram.Pure(selector(x)));
}
これはモナドからファンクターを実装する標準的な方法です。
さらに、シーケンスを返すプログラム同士を結合する関数も定義しておくと便利です。
private static SongProgram<IEnumerable<T>> Concat<T>(
this SongProgram<IEnumerable<T>> xs,
SongProgram<IEnumerable<T>> ys)
{
return xs.SelectMany(x => ys.Select(y => x.Concat(y)));
}
これは public でもよいかもしれませんが、ここでは Traverse を実装するためにしか使わないので private にしました。一方で、Traverse 関数自体は public です。
public static SongProgram<IEnumerable<TResult>> Traverse<T, TResult>(
this IEnumerable<T> source,
Func<T, SongProgram<TResult>> selector)
{
return source.Aggregate(
Pure(Enumerable.Empty<TResult>()),
(acc, x) =>
acc.Concat(selector(x).Select(xr => new[] {xr}.AsEnumerable())));
}
Traverse は値のシーケンスに対して selector を適用し、結果として得られるプログラムを一つのシーケンス値プログラムにまとめます。上の GetRecommendations の合成で使われているのが確認できるでしょう。
インタープリター
最後に必要なのはプログラムを評価するインタープリターです。すでに FakeSongService クラスがあるので、そこに Interpret メソッドを追加するのが最も簡単な実装方法でした。
public T Interpret<T>(SongProgram<T> program)
{
return program.Match(
i => i.Match(
t => Interpret(t.Item2(GetTopListernes(t.Item1))),
t => Interpret(t.Item2(GetTopScrobbles(t.Item1)))),
x => x);
}
ここで GetTopListernes と GetTopScrobbles は private の補助関数です。
private IReadOnlyCollection<User> GetTopListernes(int songId)
{
var listeners =
from kvp in users
where kvp.Value.ContainsKey(songId)
select new User(kvp.Key, kvp.Value.Values.Sum());
return listeners.ToList();
}
private IReadOnlyCollection<Scrobble> GetTopScrobbles(string userName)
{
var scrobbles = users
.GetOrAdd(userName, new ConcurrentDictionary<int, int>())
.Select(kvp => new Scrobble(songs[kvp.Key], kvp.Value));
return scrobbles.ToList();
}
この実装は元の Fake インターフェイスの実装にきわめて近いものです。ここで users と songs は FakeSongService クラスのフィールドです。このクラスは 曲レコメンデーション問題の仕様化 で初めて紹介しました。
これで全てのテストを書き換えることが可能になります。
テストのリファクタリング
元の GetRecommendationsAsync メソッドはタスクベースだったため、すべてのテストはタスクワークフローの中で実行する必要がありました。もうその必要はありません。以下は簡略化された FsCheck のプロパティの例です。
[<Property>]
let ``One user, some songs`` () =
gen {
let! user = Gen.userName
let! songs = Gen.arrayOf Gen.song
let! scrobbleCounts =
Gen.choose (1, 100) |> Gen.arrayOfLength songs.Length
return (user, Array.zip songs scrobbleCounts) }
|> Arb.fromGen |> Prop.forAll <| fun (user, scrobbles) ->
let srvc = FakeSongService ()
scrobbles |> Array.iter (fun (s, c) -> srvc.Scrobble (user, s, c))
let actual =
RecommendationsProvider.GetRecommendations user |> srvc.Interpret
Assert.Empty actual
もともとは task コンピュテーション式を使って定義する必要がありましたが、今では純粋な関数です。Act(実行)フェーズでは RecommendationsProvider.GetRecommendations user を呼び、その返り値のプログラムを srvc.Interpret に渡します。結果 actual は単なる IReadOnlyCollection<Song> 値です。
同様に、サンプルベースのテストもすべて移行できました。
[<Fact>]
let ``One verified recommendation`` () =
let srvc = FakeSongService ()
srvc.Scrobble ("cat", Song (1, false, 6uy), 10)
srvc.Scrobble ("ana", Song (1, false, 5uy), 10)
srvc.Scrobble ("ana", Song (2, true, 5uy), 9_9990)
let actual =
RecommendationsProvider.GetRecommendations "cat" |> srvc.Interpret
Assert.Equal<Song> ([ Song (2, true, 5uy) ], actual)
すべてのテストを新しい GetRecommendations 関数に移行した後は、古い RecommendationsProvider クラスや SongService インターフェイスは不要になったため削除しました。
まとめ
Haskell の do 記法や F# のコンピュテーション式のような適切な構文糖衣がないため、C# において フリーモナドは有効な設計手段とは言えません。それでも、この3記事が フリーモナドの理解に役立ったなら幸いです。