Converting an F# pipeline into a C# fluent interface
08 Jan 2025In my previous post, I went over one of the changes I made to my library, Quipu, to make it more C# friendly. In this installment, I will go over another design change, turning the initial F# version, which used a classic pipeline, into a Fluent Interface.
For reference, here is how the original F# pipeline looks like:
let f (x, y) = pown (x - 1.0) 2 + pown (y - 2.0) 2 + 42.0
let solverResult =
NelderMead.objective f
|> NelderMead.withTolerance 0.000_0001
|> NelderMead.startFrom (Start.around (100.0; 100.0))
|> NelderMead.minimize
This looks pretty similar to a Fluent Interface. It is not, though: it is a classic F# pipeline, chaining functions using the pipe-forward operator. From the F# side, this feels like a fluent interface, but for a C# consumer, it is more or less unusable.
Can we turn this into an actual C# friendly Fluent Interface? Yes we can, and this is what I will go over in this post.
Fluent Interface, Take One
First, if we mimicked what the F# code does, how would a C# Fluent Interface look like? Probably something along these lines:
Func<Double,Double,Double> f =
(x, y) => Math.Pow(x - 1.0, 2) + Math.Pow(y - 2.0, 2) + 42.0;
var solverResult =
NelderMead
.Objective(f)
.WithTolerance(0.0000001)
.StartFrom(Start.Around(100.0, 100.0))
.Minimize();
As it turns out, this is exactly the current C# Quipu API. So how did we go from an F# pipeline to this?
Let’s start from the F# side, taking one of the pipeline steps for illustration:
type NelderMead () =
static member withTolerance (tolerance: float) (problem: Problem) =
{ problem with
Configuration = {
problem.Configuration with
Termination = Termination.tolerance tolerance
}
}
Sidebar: Why is
NelderMead
a class and not a module? And why iswithTolerance
a static method, and not a function on a module? I ended up using a class because, for other functions, I needed overloads.
The signature of the withTolerance
function is
withTolerance: float -> Problem -> Problem
If we already had an instance of a Problem
, say, initialProblem
, we could apply
withTolerance
using the pipe-forward operator, which will return a new,
updated Problem
:
initialProblem
|> NelderMead.withTolerance 0.000_001
As long as we have functions that look along these lines:
someProblemTransformation: argument1 -> ... -> Problem -> Problem
… we can keep chaining them together, passing an initial Problem
through a
series of transformations that will eventually give us a Problem
.
Stated differently, our original pipeline can be rewritten in the following equivalent code, fully expanding each step:
let f (x, y) = pown (x - 1.0) 2 + pown (y - 2.0) 2 + 42.0
let problem0 = NelderMead.objective f
let problem1 = NelderMead.withTolerance 0.000_0001 problem0
let problem2 = NelderMead.startFrom (Start.around [ 100.0; 100.0]) problem1
let solverResult = NelderMead.minimize problem2
All we are doing is passing around a Problem
and transforming it.
Can we do the same with C#? We can, by using essentially the same idea. All we
need is a method on an instance, which returns a new instance of the same type.
We could for instance add a method on the Problem
type, and do something like
this:
type Problem = {
// omitted for readability
}
with
member this.WithTolerance (tolerance: float): Problem =
{ this with
// slightly simplified for readability
Tolerance = tolerance
}
WithTolerance
returns a Problem
, so we can now chain the calls like so:
var problem1 = problem0.WithTolerance(0.001);
var problem2 = problem1.WithTolerance(0.01);
var problem3 = problem2.WithTolerance(0.1);
Or, omitting the intermediate variables, and calling WithTolerance
directly
on the Problem
that the previous step returned:
var problem3 =
problem0
.WithTolerance(0.001)
.WithTolerance(0.01)
.WithTolerance(0.1);
Of course, this example is a little absurd (you would not set the tolerance three times in a row, to three different values), but it illustrates the point. If we have methods on an instance that return an instance of the same type, we have a Fluent Interface.
Fluent Interface, Take Two
While the general idea works, I wasn’t entirely satisfied with it. My issue was
that the Problem
type is not meant to be front-and-center. Leaving the type
public is fine, so you can directly manipulate it in case you want to do
something unusual, but by default you should not have to touch it.
In addition to this, Problem
is a record which contains “non-obvious” types
(IVectorFunction
, IStartingPoint
). Instantiating a Problem
manually
requires understanding how all these types work, and will be at best error
prone and unpleasant (in particular for C#).
The F# pipeline completely hides Problem
from the user, can we do something
similar for C# consumers?
Ignoring for now how to instantiate a Problem
, one approach would be to do
something like this. Remove the WithTolerance
method from Problem
, and move
it to the NelderMead
class instead:
// the constructor expects an instance of Problem
type NelderMead(problem: Problem) =
member this.WithTolerance(tolerance: float) =
// we update the Problem, using the existing F# method
let updatedProblem =
problem
|> NelderMead.withTolerance tolerance
// and instantiate a new NelderMead, re-wrapping the updated Problem
NelderMead(updatedProblem)
Instead of an empty constructor earlier, we expose a single constructor that
expects an instance of a Problem
. Because we want to chain method calls, we
return a NelderMead
from the WithTolerance
method, so we can do the
following:
NelderMead(problem0)
.WithTolerance(0.001)
.WithTolerance(...)
We are still left with one problem, though. We need to pass a well-formed
Problem
in the constructor. How can we avoid that?
The F# pipeline gets around this by using factory methods. The first call in
our original pipeline creates a Problem
, using a static method objective
:
let f (x, y) = pown (x - 1.0) 2 + pown (y - 2.0) 2 + 42.0
let solverResult =
NelderMead.objective f
|> NelderMead.withTolerance 0.000_0001
// more steps omitted
We can easily achieve the same effect from the C# side, by hiding the default constructor, and exposing a similar factory method:
type NelderMead private (problem: Problem) =
static member Objective(f: System.Func<float,float,float>) =
NelderMead.objective f.Invoke
|> NelderMead
member this.WithTolerance(tolerance: float) =
problem
|> NelderMead.withTolerance tolerance
|> NelderMead
Applying the same trick to the other steps of the pipeline leads to the following API:
Func<Double,Double,Double> f =
(x, y) => Math.Pow(x - 1.0, 2) + Math.Pow(y - 2.0, 2) + 42.0;
var solverResult =
NelderMead
.Objective(f)
.WithTolerance(0.0000001)
.StartFrom(Start.Around(100.0, 100.0))
.Minimize();
… which is a Fluent Interface that mimicks the original pipeline, but is also usable from the C# side.
Parting thoughts
A couple of final comments before closing shop!
First, as of the latest version (0.5.2), the F# and C# APIs are separated
in 2 different namespaces, Quipu
for F#, Quipu.CSharp
for C#. I initially
used a single class, as in this post, but as a result, the NelderMead
type
had a lot of methods, mixing code formatting conventions (withTolerance
and
WithTolerance
for example). I can see only one drawback to introducing this
separation: you need to open the correct namespace depending on your preferred
language. This seemed like an acceptable price to pay for the benefit of an
uncluttered API.
Speaking of formatting, I also wanted the code to follow the expected standards
for both F# and C#. I ended up using the [<CompiledName>]
attribute on the
Start
type, like so:
type Start =
[<CompiledName("Around")>]
static member around (startingPoint: seq<float>) =
// omitted code
As a result, the code looks as you would expect in both languages:
NelderMead.objective f
|> NelderMead.startFrom (Start.around (100.0; 100.0))
NelderMead
.Objective(f)
.StartFrom(Start.Around(100.0, 100.0))
Finally, the goal was to design a C#-friendly API, and the best way to check if that goal is achieved is to experience potential painpoints yourself, by using your own code (aka dog-fooding). I ended up writing a battery of unit tests in C#, which is a simple but effective way to do that.
One thing I was wondering about is how I could also confirm API parity between the two versions. The thought here is that if I write a unit test in F#, it would be nice to automatically also run the same test, using the equivalent C# code. I am still mulling over that one, it is an interesting problem, but one I can leave to think about later :)
That’s what I got for today! I am still planning to make some changes to the library, but I expect these to be much less drastic than the recent ones. Anyways, Quipu is usable as-is today - if you have questions, feedback or requests, let me know! And in the meantime, I hope you found something of interest in this post.