In F#, Discriminated Unions provide a good alternative to overloading.

When you're learning a new programming language, you may experience these phases:

  1. [language x] is the coolest thing ever :D
  2. [language x] sucks, because it can't do [xyz], like [language y], which I know really well :(
  3. Now I finally understand that in [language x] I don't need [xyz], because it has different idioms o_O
Recently, I had this experience with F# and overloading.

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.



Wish to comment?

You can add a comment to this post by sending me a pull request. Alternatively, you can discuss this post on Twitter or Google Plus, or somewhere else with a permalink. Ping me with the link, and I may add it as a comment.

Published

Monday, 21 October 2013 07:15:00 UTC

Tags



"Our team wholeheartedly endorses Mark. His expert service provides tremendous value."
Hire me!