Song recommendations from F# combinators by Mark Seemann
Traversing sequences of tasks. A refactoring.
This article is part of a series named Alternative ways to design with functional programming. In the previous article, you saw how to refactor the example code base to a composition of standard combinators. It's a pragmatic solution to the problem of dealing with lots of data in a piecemeal fashion, but although it uses concepts and programming constructs from functional programming, I don't consider it a proper functional architecture.
Porting the C# code to F# doesn't change that part, but most F# developers will probably agree that this style of programming is more idiomatic in F# than in C#.
Please consult the previous articles for context about the example code base. The code shown in this article is from the fsharp-combinators Git branch. It refactors the code shown in the article Porting song recommendations to F#.
The goal is to extract pure functions from the overall recommendations algorithm and compose them using standard combinators, such as bind
, map
, and traverse
.
Composition from combinators #
Let's start with the completed composition, and subsequently look at the most interesting parts.
type RecommendationsProvider (songService : SongService) = member _.GetRecommendationsAsync = // 1. Get user's own top scrobbles // 2. Get other users who listened to the same songs // 3. Get top scrobbles of those users // 4. Aggregate the songs into recommendations 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))
This is a single expression with nested subexpressions, and you may notice that it's completely point-free. This may be a little hardcore even for most F# programmers, since F# idiomatically favours explicit lambda expressions and the pipeline operator |>
.
Although I'm personally fascinated by point-free programming, I might consider a more fun
alternative if working in a team. You can see such a variation in the Git repository in an intermediary commit. The reason that I've pulled so heavily in this direction here is that it more clearly demonstrate why we call such functions combinators: They provide the glue that enable us to compose functions together.
If you're wondering, >>
is also a combinator. In Haskell it's more common with 'unpronounceable' operators such as >>=
, .
, &
, etc., and I'd argue that such terse operators can make code more readable.
The functions getOwnTopScrobbles
, getTopScrobbles
, aggregateRecommendations
, and takeTopRecommendations
are helper functions. Here's one of them:
let private getOwnTopScrobbles scrobbles = scrobbles |> Seq.sortByDescending (fun s -> s.ScrobbleCount) |> Seq.truncate 100
The other helpers are also simple, single-expression functions like this one.
As Oleksii Holub implies, you could make each of these small functions public if you wished to test them individually.
Let's now look at the various building blocks that enable this composition.
Combinators #
The F# base library comes with more standard combinators than are generally available for C#, not only for lists, but also Option
and Result
values. On the other hand, when it comes to asynchronous monads, the F# base library offers task
and async
computation expressions, but no Task
module. You'll need to add Task.bind
and Task.map
yourself, or import a library that exports those combinators. The article Asynchronous monads shows the implementation used here.
The traverse implementation shouldn't be too surprising, either, but here I implemented it directly, instead of via 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 [] })
Finally, the flatten
function is the standard implementation that goes via monadic bind. In F#'s Seq
module, bind is called collect
.
let flatten xs = Seq.collect id xs
That all there is to it.
Conclusion #
In this article, you saw how to port the C# code from the previous article to F#. Since this style of programming is more idiomatic in F#, more building blocks and language features are available, and hence this kind of refactoring is better suited to F#.
I still don't consider this proper functional architecture, but it's pragmatic and I could see myself writing code like this in a professional setting.
Next: Song recommendations from Haskell combinators.