In my last post, I illustrated how we could model a simple real-world problem using classic OO concepts such as type hierarchies, interfaces and stateful objects. In this post I want to contrast that with a functional-first approach using F#.
In order to model the different types of Positions on a Monopoly board in C#, we used a type hierarchy with an abstract base class and derived specialisations. However, whilst F# has those same constructs, it also has the lightweight Discriminated Union, which for our problem is a much better fit: –
This gives us, in C# terms, a full class hierarchy with Position as the “abstract base class”, and the others “inheriting” from it. Each of the above lines equates to a full class in C# terms. Read that again. Each of those lines maps to a full C# class, with constructors and properties etc. The first two have a constructor that takes in a string which then gets copied into a read-only field and exposed as a get-only property. The types are immutable and have full value-based equality checking. Compile the above in F#, and then do a “go to definition” from a C# project to see just how much boilerplate this is doing for you.
Notice the following as well: –
- We just store data for the union types that we need (Property and Station) e.g. “Old Kent Road” or “Kings Cross”. The others don’t have any unique data in them so we don’t store any.
- There’s also no “base” commonality between the types – we can shape them however we want in an unrestricted fashion.
- There’s no behaviour on them – just some data.
Applying behaviour to type hierarchies in F#
So given that there’s no methods on these types, how do we do stuff like getting the Name of the position that we’ve landed on in a “polymorphic”-esque manner? The answer is that we use the Match keyword to write a single function for all of the union types.
This is a key difference from how we modelled this in the OO world. Previously, this behaviour existed across all types in the domain. Now we have a single function which represents the behaviour of printing the name for all types of Position. Note how for Property and Tax we “declare” an appropriate variable inline to get at the Property Name or Tax Amount. Match is also smart enough to give compiler warnings if you miss out a type of discriminated union, so as we add new types the compiler will alert us for missing cases.
Optional behaviours on type hierarchies
Now let’s implement the equivalent for the IMovementPosition that GoToJail and Chance etc. would implement. We can do this with a function that takes in a position and returns an optional Position: –
Here we’re using F#’s Option<T> type. The calculateMove function may or may not return a new position; it’ll only do so if we landed on e.g. Go To Jail, or a Chance card that involved a movement e.g. Advance to Go. So we can return “Some” Position or None; indeed notice also the “_” match; for positions on the board that don’t apply e.g. Properties, Stations or FreeParking etc., we just return None. This is a much better alternative than null, as we’ll see below when we consume this method – now when we want to do something like the original C# “Roll” method, we can do this: –
We’re using Match again, this time on the result of the just-declared calculateMove method; if we got Some<Position> back, we print it out and then return it. Otherwise we got None, and just return the original position that we landed on. Easy. As is common with F#, there are no type annotations required in the code above – everything is inferred based on usage. Some methods are implicitly generic; others are specific for types depending on their usage; the compiler works this all out for us. The dice argument is a tuple of int and int; we’ll use that in the moveBy method, but again this is inferred.
Hopefully these two blog posts have illustrated some simple differences between functional and OO design that don’t involve the usual “recursion versus for loops” etc.. F# Discriminated Unions are an excellent way to quickly declare a number of types, and in tandem with the Match keyword you can define behaviours against the Union quickly and easily. Even from this simplified example, there are some fundamental differences between how we model this problem in F# to C#: –
- Data and Behaviour are separate. Behaviour can easily be added outside of the type hierarchy, but new Types in the hierarchy affect all behaviours.
- Functions avoid state where possible; easier to test and easier to infer effect of any given function.
- Pattern Matching over a discriminated using with Option types allow us to easily create new behaviours without interfaces etc..
- Option types allow us to perform “null-style” checks in a much stronger fashion using Pattern Matching.