Build. Translate. Understand.

曲レコメンデーションのF#への移植

2025-04-15 17:20:25

原文: Porting song recommendations to F# by Mark Seemann

C#のコードベースをF#に翻訳したものです。

この記事は、より大きな連載記事の一部であり、関数型アーキテクチャを用いて非自明な問題にどのように取り組むか、その様々な手法を検討する内容となっています。
前回の記事では、C#によるベースラインのコードベースを確立しました。今後の記事では、このC#コードベースを出発点として、リファクタリングしたコードを紹介していく予定です。

一方で、こうしたソリューションがF#Haskellといった言語ではどう見えるのかも示したいと考えています。
本記事では、このC#のベースラインをF#へ移植する方法をご紹介します。

この記事で紹介するコードは、付随するGitリポジトリの fsharp-port ブランチに含まれています。

データ構造

まずは、必要となるデータ構造を定義するところから始めましょう。これらはすべてレコード型として定義されます。

type User = { UserName : string; TotalScrobbleCount : int }

C#版と同様に、User は単なる stringint の組み合わせです。

新しい値を作成する際には、レコード構文が少し扱いづらいこともあるため、User 値を作るためのカリー化関数も定義しています:

let user userName totalScrobbleCount =
    { UserName = userName; TotalScrobbleCount = totalScrobbleCount }

同様にして、SongScrobble も同じように定義します:

type Song = { Id : int; IsVerifiedArtist : bool; Rating : byte }
let song id isVerfiedArtist rating =
    { Id = id; IsVerifiedArtist = isVerfiedArtist; Rating = rating }
 
type Scrobble = { Song : Song; ScrobbleCount : int }
let scrobble song scrobbleCount = { Song = song; ScrobbleCount = scrobbleCount }

正直に言うと、これらのカリー化関数はあまり使っていないので、やや冗長かもしれません。いずれ削除することも検討した方がいいかもしれませんが、今のところは残しておきます。

全コードをF#に移植する以上、インターフェースも翻訳する必要があります。

type SongService =
    abstract GetTopListenersAsync : songId : int -> Task<IReadOnlyCollection<User>>
    abstract GetTopScrobblesAsync : userName : string -> Task<IReadOnlyCollection<Scrobble>>

構文はC#と異なりますが、それ以外は同じインターフェースです。

実装

ここまでで、RecommendationsProvider を実装するために必要な補助的な型はすべてそろいました。
以下は、C#のコードをできる限り直接的にF#へ翻訳したものです:

type RecommendationsProvider (songService : SongService) =
    member _.GetRecommendationsAsync userName = task {
        // 1. 自ユーザーのトップスクロブルを取得
        // 2. 同じ曲を聴いた他ユーザーを取得
        // 3. そのユーザーのトップスクロブルを取得
        // 4. 曲を集約してレコメンドを生成

        // Impure
        let! scrobbles = songService.GetTopScrobblesAsync userName
 
        // Pure
        let scrobblesSnapshot =
            scrobbles
            |> Seq.sortByDescending (fun s -> s.ScrobbleCount)
            |> Seq.truncate 100
            |> Seq.toList
 
        let recommendationCandidates = ResizeArray ()
        for scrobble in scrobblesSnapshot do
            // Impure
            let! otherListeners =
                songService.GetTopListenersAsync scrobble.Song.Id
 
            // Pure
            let otherListenersSnapshot =
                otherListeners
                |> Seq.filter (fun u -> u.TotalScrobbleCount >= 10_000)
                |> Seq.sortByDescending (fun u -> u.TotalScrobbleCount)
                |> Seq.truncate 20
                |> Seq.toList
 
            for otherListener in otherListenersSnapshot do
                // Impure
                let! otherScrobbles =
                    songService.GetTopScrobblesAsync otherListener.UserName
 
                // Pure
                let otherScrobblesSnapshot =
                    otherScrobbles
                    |> Seq.filter (fun s -> s.Song.IsVerifiedArtist)
                    |> Seq.sortByDescending (fun s -> s.Song.Rating)
                    |> Seq.truncate 10
                    |> Seq.toList
 
                otherScrobblesSnapshot
                |> List.map (fun s -> s.Song)
                |> recommendationCandidates.AddRange
 
        // Pure
        let recommendations =
            recommendationCandidates
            |> Seq.sortByDescending (fun s -> s.Rating)
            |> Seq.truncate 200
            |> Seq.toList
            :> IReadOnlyCollection<_>
 
        return recommendations }

ご覧のとおり、元の記事にあったコメントもそのまま残しています。

テストダブル

前回の記事では、フェイクSongService をC#で実装しました。本記事ではすべてをF#に翻訳するため、このフェイクも翻訳する必要があります。

type FakeSongService () =
    let songs = ConcurrentDictionary<int, Song> ()
    let users = ConcurrentDictionary<string, ConcurrentDictionary<int, int>> ()
 
    interface SongService with
        member _.GetTopListenersAsync songId =
            let listeners =
                users
                |> Seq.filter (fun kvp -> kvp.Value.ContainsKey songId)
                |> Seq.map (fun kvp -> user kvp.Key (Seq.sum kvp.Value.Values))
                |> Seq.toList
 
            Task.FromResult listeners
 
        member _.GetTopScrobblesAsync userName =
            let scrobbles =
                users.GetOrAdd(userName, ConcurrentDictionary<_, _> ())
                |> Seq.map (fun kvp -> scrobble songs[kvp.Key] kvp.Value)
                |> Seq.toList
 
            Task.FromResult scrobbles
 
    member _.Scrobble (userName, song : Song, scrobbleCount) =
        let addScrobbles (scrobbles : ConcurrentDictionary<_, _>) =
            scrobbles.AddOrUpdate (
                song.Id,
                scrobbleCount,
                fun _ oldCount -> oldCount + scrobbleCount)
            |> ignore
            scrobbles
 
        users.AddOrUpdate (
            userName,
            ConcurrentDictionary<_, _>
                [ KeyValuePair.Create (song.Id, scrobbleCount) ],
            fun _ scrobbles -> addScrobbles scrobbles)
        |> ignore
        
        songs.AddOrUpdate (song.Id, song, fun _ _ -> song) |> ignore

ここに示したコード以外では、ごくわずかな修正しかテストに必要ありませんでした。たとえば、コンストラクタの代わりにこれらのカリー化関数を使ったり、SongService へのキャストを加えたり、動作には関係しない細かな点を直した程度です。
すべてのテストは引き続きパスしているので、C#コードベースを忠実に翻訳できたと考えています。

結論

この記事では、さらなる基盤づくりを行いました。ひとつの問題を複数のプログラミング言語で表現することで得られる示唆もあると思いますので、C#、F#、そしてHaskellの3言語で同じ問題を取り上げています。次回の記事では、今回のF#コードをHaskellに翻訳してご紹介します。この3つのベースラインが揃った時点で、ソリューションのバリエーションを導入していきます。

Haskellの例には関心がないという方は、連載の最初の記事に戻り、目次から次のC#例へジャンプしていただけます。

次回: 曲レコメンデーションのHaskellへの移植


インデックスへ戻る