The first post in this series on ROP covered the high-level concepts of the approach, and then I implemented some code building blocks that allow us to promote functions of various shapes into composable 2-track functions.
Here I'd like to share some simple code samples that exercise these ideas. If you'd like, you can follow along by browsing the code samples in my repo here: github.com/lightw8/rop-examples-csharp
First off, after a little refactoring and paring down a bit1, here's the sum total of the ROP helpers from last time, along with a Compose
extension method to chain functions together:
using System;
namespace RailwayOrientedProgramming
{
public class Error
{
public Error(string message) => Message = message;
public string Message { get; init; }
public override string ToString() => Message;
}
public class Result<T> : Choice<T, Error>
{
public static implicit operator Result<T>(T value) => new Result<T>(value);
public static implicit operator Result<T>(Error error) => new Result<T>(error);
public Result(T item) : base(item) { }
public Result(Error item) : base(item) { }
}
public class Choice<A, B>
{
public Choice(A item) { Item = item!; }
public Choice(B item) { Item = item!; }
public dynamic Item { get; }
// equality and string representation delegated to member (Item)
public override bool Equals(object? obj) => Item.Equals(obj);
public override int GetHashCode() => Item.GetHashCode();
public override string ToString() => Item.ToString();
}
public static class RopExtensions
{
public static Func<T1, T3> Compose<T1, T2, T3>(this Func<T1, T2> func1, Func<T2, T3> func2) =>
value => func2(func1(value));
public static Func<Result<TIn>, Result<TOut>> Bind<TIn, TOut>(this Func<TIn, Result<TOut>> func) =>
choice => choice.Item switch
{
Error e => (Result<TOut>)e, // ugly cast because C#
TIn tIn => func(tIn).Item switch
{
Error eOut => eOut,
TOut tOut => tOut,
_ => new Error($"Invalid argument")
},
_ => new Error($"Invalid argument")
};
public static Func<Result<TIn>, Result<TOut>> Map<TIn, TOut>(this Func<TIn, TOut> func) =>
Bind<TIn, TOut>(input => func(input)); // implicit upcast of return type of func to Result<TOut>
public static Func<TIn, TIn> Tee<TIn>(this Action<TIn> deadEndFunction) =>
input => { deadEndFunction(input); return input; };
// Removed, as it's the same signature as Tee (with Result<TIn> as the TIn type)
// public static Func<Result<TIn>, Result<TIn>> Audit<TIn>(this Action<Result<TIn>> deadEndFunction) =>
// input => { deadEndFunction(input); return input; }; // perform the action and return the input
}
}
And now, let's contrive some overly-simple examples to exercise the helpers, using the minimalistic style of "top-level statements," a new capability in C# 9.
I've created five separate functions with different signatures: two error-generating functions (maybeAdd3
and maybeInverse
), one simple function, expected to always succeed (subtract7
), and two "audit" functions that either perform an action on simple "1-track" data value (printFloats
) or on a 2-track Result
type (printFinalResult
).
using System;
using System.Linq;
using RailwayOrientedProgramming;
// 1) error-generating switch function
Func<int, Result<long>> maybeAdd3 = val => val switch
{
13 => new Error("Unlucky!"),
_ => (Result<long>)(val + 3)
};
// 2) simple 1-track function
Func<long, float> subtract7 = val => val - 7f;
// 3) 1-track "dead-end" action (no return)
Action<float> printFloats = val => Console.Write($"The value is currently {val}.\t");
// 4) error-generating inverse function
Func<float, Result<double>> maybeInverse = val => val switch
{
0f => new Error("Inverse!"),
_ => (Result<double>)(1f / val)
};
// 5) 2-track "dead-end" action (no return)
Action<Result<double>> printFinalResult = result =>
{
Action action = result.Item switch
{
double d => () => Console.WriteLine($"Happy path! Final value is {d}"),
Error e => () => Console.WriteLine($"Error path :( ({e.Message})")
};
action();
};
var compositeFunc = maybeAdd3 // 1) error-generating function
.Compose(subtract7.Map()) // 2) map a simple 1-track function
.Compose(printFloats.Tee().Map()) // 3) tee and map a simple dead-end action
.Compose(maybeInverse.Bind()) // 4) bind error-generating inverse function
.Compose(printFinalResult.Tee()); // 5) tee a 2-track dead-end action
Console.WriteLine("\nOutput:\n");
var results = Enumerable.Range(0, 16) // 0,1,2,...,15
.Select(compositeFunc) // perform composite operation
.ToArray(); // force enumeration
For each of the methods, I use one or more of the adapter methods to shape the inputs and outputs into the 2-track railway model. Notice:
that
Bind
isn't needed at the beginning formaybeAdd3
because it's starting with a simpleint
, not a 2-trackResult
how
Map
"lifts" the simple signature into theResult
domainhow
Tee
is used to intercept the chain and perform actions on the intermediate valuesthat if we were to output the result (e.g. what you'd do at an API boundary), we would do something similar to
printFinalResult
, where we'd have to decide how to "roll-up" the two tracks into one
Here's the output of the code:
Output:
The value is currently -4. Happy path! Final value is -0.25
The value is currently -3. Happy path! Final value is -0.3333333432674408
The value is currently -2. Happy path! Final value is -0.5
The value is currently -1. Happy path! Final value is -1
The value is currently 0. Error path :( (Inverse!)
The value is currently 1. Happy path! Final value is 1
The value is currently 2. Happy path! Final value is 0.5
The value is currently 3. Happy path! Final value is 0.3333333432674408
The value is currently 4. Happy path! Final value is 0.25
The value is currently 5. Happy path! Final value is 0.20000000298023224
The value is currently 6. Happy path! Final value is 0.1666666716337204
The value is currently 7. Happy path! Final value is 0.1428571492433548
The value is currently 8. Happy path! Final value is 0.125
Error path :( (Unlucky!)
In the end, there are 12 fully-processed values, and two errors (one because we hit the unlucky value 13, and one for an unallowed inverse operation). Also, note the side-effect of printing the value 0 succeeds, even though the final result is an Error
, which occurs later on. ALL inputs, however, propagate through to the end, regardless. That's quite satisfying. I haven't included exception-to-error mapping, but ultimately those functions would end up looking just like the "maybe" functions.
A few nits:
I ended up demonstrating this with
Func
-type functions, but this would work with methods as well.In terms of readability, I felt being able to call extension methods on the
Func
s directly was more concise/clear to illustrate the point, but this would work equally well by calling the fully-qualified ROP helpers with method delegates.Scott Wlaschin points out that you can also hide the "ROP-ified" functions behind a new namespace, so you can use the same names as before, where the extension methods aren't visible at the top-level.
Compared with F#, where function composition and pipe operators built into the language,
Compose
is a bit annoying. It would be great to have these in the language. I've always thought C# was a bit too restrictive with operator overloading, and it would be powerful to be able to define these ourselves and provide for this kind of functionality. But, I see how the language could get messy quickly if they let us expand the operators willy-nilly. Functions are becoming more and more like first-class citizens in C#, though, so maybe we'll also see some new features or operators emerge around composing them, too.
Final Thoughts
I haven't yet exercised this model in any production code, but there are certainly some very nice qualities about it, particularly in the high-level clarity of the code's intent, and how the error approach nudges you toward TDD-style development where the errors get explicitly handled and handed off to an appropriate consumer.
One of the places it might be most appealing is in massively parallel compute code, where you don't want to break the propagation for spurious edge cases (that yield NaN, for example), or where you want to skip computation at various points if the values fall outside of certain tolerances or desired ranges. On the flipside, there is a significant amount of abstraction happening to achieve this functional model, and so we lose the tight data-locality and compiler optimizations you'd get from classic "for-loop" iteration and direct application of logic, versus the functional parameters passed in. The provided Choice
type is also a bit heavyweight, as it requires runtime dynamic computations. I'd like to do some more reading to understand how functional programmers have worked toward these high-performance scenarios.
In any case, it's been a fun journey to work through this talk. Thanks to Mr. Wlaschin for the clear, interesting, useful, and funny [presentation](favorite talks). What are your thoughts about this style of programming? What would be your major barriers to adopting this in your code?
Thanks for reading, and talk soon. David
1 I ultimately removed Audit
, as it shares the same generic signature as Tee. That is, at least how I'm understanding the talk. Haven't reviewed other work on the topic...not yet, at least!