F#版 コンビネータを用いた曲レコメンデーション
2025-06-26 21:30:25
原文:Song recommendations from F# combinators(Mark Seemann)
タスクのシーケンスを横断的に処理する。リファクタリングの一例。
この記事は、「関数型プログラミングによる設計の代替アプローチ」シリーズの一部です。前回の記事では、標準のコンビネータを組み合わせてサンプルコードをリファクタリングする方法をご紹介しました。これは大量のデータを断片的に扱う問題に対して実用的な解決策ではあるものの、関数型プログラミングの概念や構文を活用しているとはいえ、真の意味での関数型アーキテクチャとは私は見なしていません。
このC#のコードをF#に移植してもその点は変わりませんが、多くのF#開発者にとっては、このスタイルのプログラミングはC#よりもF#の方がイディオマティックと感じられることでしょう。
サンプルコードの背景については前回までの記事をご参照ください。本記事で紹介するコードは、Gitブランチ「fsharp-combinators」に含まれるもので、「曲レコメンデーションのF#への移植」の記事で示したコードをリファクタリングしたものです。
本記事の目的は、曲レコメンデーションの全体的なアルゴリズムから純粋関数を抽出し、それらをbind、map、traverseといった標準的なコンビネータで合成することにあります。
コンビネータによる合成
まずは完成した関数合成をご覧いただき、その後で注目すべきポイントを解説していきます。
type RecommendationsProvider (songService : SongService) =
member _.GetRecommendationsAsync =
// 1. 自ユーザーのトップスクロブルを取得
// 2. 同じ曲を聴いた他ユーザーを取得
// 3. そのユーザーのトップスクロブルを取得
// 4. 曲を集約してレコメンドを生成
songService.GetTopScrobblesAsync
>> Task.bind (
getOwnTopScrobbles
>> TaskSeq.traverse (
_.Song.Id
>> songService.GetTopListenersAsync
>> Task.bind (
getTopScrobbles
>> TaskSeq.traverse (
_.UserName
>> songService.GetTopScrobblesAsync
>> Task.map aggregateRecommendations)))
>> Task.map (Seq.flatten >> Seq.flatten >> takeTopRecommendations))
これは単一の式であり、入れ子になった副式を含んでいます。ご覧の通り、完全にポイントフリーなスタイルです。これはF#プログラマーにとってもやや過激に感じられるかもしれません。というのも、F#では明示的なラムダ式やパイプライン演算子|>を使うのが慣例だからです。
私自身はポイントフリーなプログラミングに魅力を感じていますが、チーム開発においてはもう少し「楽しい(fun)」スタイルにするかもしれません。実際、そのようなバリエーションはGitリポジトリの中間コミットで確認できます。今回ここまで徹底したスタイルを採ったのは、「なぜこれらの関数を“コンビネータ”と呼ぶのか」をより明確に示したかったからです。これらの関数は、他の関数を組み合わせるための「接着剤」のような役割を果たしてくれます。
ちなみに、>>もコンビネータの一種です。Haskellでは、>>=, ., &など、読み上げにくい演算子がよく使われます。こうした簡潔な演算子は、ビジネスロジックのコードをより読みやすくすると私は考えています。
getOwnTopScrobbles、getTopScrobbles、aggregateRecommendations、takeTopRecommendationsは補助的な関数です。以下にそのうちの一つをご紹介します。
let private getOwnTopScrobbles scrobbles =
scrobbles |> Seq.sortByDescending (fun s -> s.ScrobbleCount) |> Seq.truncate 100
他の補助関数もこのように、単一式からなるシンプルな関数です。
Oleksii Holub氏が示唆しているように、これらの小さな関数は、必要であれば個別にテストできるよう公開関数にしても構いません。
それでは、これらの合成を可能にする各種ビルディングブロックを見ていきましょう。
コンビネータ
F#の標準ライブラリには、C#に比べて多くの標準コンビネータが用意されています。リストだけでなく、Option型やResult型にも対応しています。一方で、非同期モナドに関しては、F#の標準ライブラリにはtaskおよびasyncのコンピュテーション式は用意されていますが、Taskモジュールは存在しません。したがって、Task.bindやTask.mapは自前で定義するか、それらを提供するライブラリを導入する必要があります。具体的な実装は、非同期モナドの記事で紹介しています。
traverseの実装も特に驚くようなものではありませんが、今回はsequence経由ではなく、直接実装しました。
let traverse f xs =
let go acc x = task {
let! x' = x
let! acc' = acc
return Seq.append acc' [x'] }
xs |> Seq.map f |> Seq.fold go (task { return [] })
最後に、flatten関数についてですが、これは標準的な実装で、モナドのbindを通じて定義されています。F#のSeqモジュールでは、bindはcollectという名前で提供されています。
let flatten xs = Seq.collect id xs
これで全てです。
おわりに
本記事では、前回の記事で紹介したC#コードをF#に移植する手法をご紹介しました。このスタイルのプログラミングはF#の方がよりイディオマティックであり、利用可能なビルディングブロックや言語機能も豊富であるため、このようなリファクタリングにF#はより適しています。
私は依然としてこれを真の関数型アーキテクチャとは見なしていませんが、実用的ではあり、業務でこうしたコードを書く可能性も十分にあると感じています。
次回: Haskell版 コンビネータを用いた曲レコメンデーション