Replace overloading with Discriminated Unions by Mark Seemann
In F#, Discriminated Unions provide a good alternative to overloading.
When you're learning a new programming language, you may experience these phases:
- [language x] is the coolest thing ever :D
- [language x] sucks, because it can't do [xyz], like [language y], which I know really well :(
- Now I finally understand that in [language x] I don't need [xyz], because it has different idioms o_O
From C#, I'm used to method overloading as a design technique to provide an API with easy-to-learn default methods, and more complicated, but more flexible, methods - all in the same family of methods. Alas, in F#, function overloading isn't possible. (Well, method overloading is still possible, because you can create .NET classes in F#, but if you're creating a module with free-standing functions, you can't have two functions with the same name, but different parameters.)
This really bothered me until I realized that F# has other language constructs that enable you to approach that problem differently. Discriminated Unions is one such language construct.
Multiple related functions #
Recently, I was working with a little module that enabled me to list a range of dates. As an example, I wanted to be able to list all dates in a given year, or all dates in a given month.
My first attempt looked like this:
module Dates = let InitInfinite (date : DateTime) = date |> Seq.unfold (fun d -> Some(d, d.AddDays 1.0)) let InYear year = DateTime(year, 1, 1) |> InitInfinite |> Seq.takeWhile (fun d -> d.Year = year) let InMonth year month = DateTime(year, month, 1) |> InitInfinite |> Seq.takeWhile (fun d -> d.Month = month)
These functions did what I wanted them to do, but I found the names awkward. The InYear and InMonth functions are closely related, so I would have liked to call them both In, as I would have been able to do in C#. That would have enabled me to write code such as:
let actual = Dates.In year
and
let actual = Dates.In year month
where year
and month
are integer values. Alas, that's not possible, so instead I had to settle for the slightly more clumsy InYear:
let actual = Dates.InYear year
and InMonth:
let actual = Dates.InMonth year month
That is, until I realized that I could model this better with a Discriminated Union.
One function #
After a day or so, I had one of those small revelations described in Domain-Driven Design: implicitly, I was working with a concept of a period. Time to make the implicit concept explicit:
type Period = | Year of int | Month of int * int module Dates = let InitInfinite (date : DateTime) = date |> Seq.unfold (fun d -> Some(d, d.AddDays 1.0)) let private InYear year = DateTime(year, 1, 1) |> InitInfinite |> Seq.takeWhile (fun d -> d.Year = year) let private InMonth year month = DateTime(year, month, 1) |> InitInfinite |> Seq.takeWhile (fun d -> d.Month = month) let In period = match period with | Year(y) -> InYear y | Month(y, m) -> InMonth y m
Notice that I defined a Period Discriminated Union outside the module, because it enables me to write client code like:
let actual = Dates.In(Year(year))
and
let actual = Dates.In(Month(year, month))
This syntax requires slightly more characters than the previous alternative, but is (subjectively) more elegant.
If you prefer, you can refactor the In function to:
let In = function | Year(y) -> InYear y | Month(y, m) -> InMonth y m
You may have noticed that this implementation still relies on the two private functions InYear and InMonth, but it's easy to refactor the In function to a single function:
let In period = let generate dt predicate = dt |> InitInfinite |> Seq.takeWhile predicate match period with | Year(y) -> generate (DateTime(y, 1, 1)) (fun d -> d.Year = y) | Month(y, m) -> generate (DateTime(y, m, 1)) (fun d -> d.Month = m)
As you can see, the introduction of a Period Discriminated Union enabled me to express the API in a way that closely resembles what I originally envisioned.
A richer API #
Once I made the change to a Discriminated Union, I discovered that I could make my API richer. Soon, I had this Dates module:
type Period = | Year of int | Month of int * int | Day of int * int * int module Dates = let InitInfinite (date : DateTime) = date |> Seq.unfold (fun d -> Some(d, d.AddDays 1.0)) let In period = let generate dt predicate = dt |> InitInfinite |> Seq.takeWhile predicate match period with | Year(y) -> generate (DateTime(y, 1, 1)) (fun d -> d.Year = y) | Month(y, m) -> generate (DateTime(y, m, 1)) (fun d -> d.Month = m) | Day(y, m, d) -> DateTime(y, m, d) |> Seq.singleton let BoundariesIn period = let getBoundaries firstTick (forward : DateTime -> DateTime) = let lastTick = forward(firstTick).AddTicks -1L (firstTick, lastTick) match period with | Year(y) -> getBoundaries (DateTime(y, 1, 1)) (fun d -> d.AddYears 1) | Month(y, m) -> getBoundaries (DateTime(y, m, 1)) (fun d -> d.AddMonths 1) | Day(y, m, d) -> getBoundaries (DateTime(y, m, d)) (fun d -> d.AddDays 1.0)
Notice that I added a Day
case. Originally, I didn't think it would be valuable, as Dates.In(Day(year, month, day))
seemed like quite a convoluted way of saying DateTime(year, month, day)
. However, it turned out that the In
abstraction was valuable, also with a single day - simply because it's an abstraction.
Additionally, I then discovered the utility of a function called BoundariesIn, which gives me the boundaries of a Period - that is, the very first and last tick of the Period.
Summary #
It's easy to become frustrated while learning a new programming language. In F#, I was annoyed by the lack of function overloading, until I realized that a single function taking a Discriminated Union might actually be a richer idiom.