It seems like F# code often pattern matches against types. Certainly
match opt with
| Some val -> Something(val)
| None -> Different()
seems common.
But from an OOP perspective, that looks an awful lot like control-flow based on a runtime type check, which would typically be frowned on. To spell it out, in OOP you’d probably prefer to use overloading:
type T =
abstract member Route : unit -> unit
type Foo() =
interface T with
member this.Route() = printfn "Go left"
type Bar() =
interface T with
member this.Route() = printfn "Go right"
This is certainly more code. OTOH, it seems to my OOP-y mind to have structural advantages:
- extension to a new form of
T
is easy; - I don’t have to worry about finding duplication of the route-choosing control flow; and
- route choice is immutable in the sense that once I have a
Foo
in hand, I need never worry aboutBar.Route()
‘s implementation
Are there advantages to pattern-matching against types that I’m not seeing? Is it considered idiomatic or is it a capability that is not commonly used?
3
You are correct in that OOP class hierarchies are very closely related to discriminated unions in F# and that pattern matching is very closely related to dynamic type tests. In fact, this is actually how F# compiles discriminated unions to .NET!
Regarding extensibility, there are two sides of the problem:
- OO lets you add new sub-classes, but makes it hard to add new (virtual) functions
- FP lets you add new functions, but makes it hard to add new union cases
That said, F# will give you a warning when you miss a case in pattern matching, so adding new union cases is actually not that bad.
Regarding finding duplications in root choosing – F# will give you a warning when you have a match that is duplicate, e.g.:
match x with
| Some foo -> printfn "first"
| Some foo -> printfn "second" // Warning on this line as it cannot be matched
| None -> printfn "third"
The fact that “route choice is immutable” might also be problematic. For example, if you wanted to share the implementation of a function between Foo
and Bar
cases, but do something else for the Zoo
case, you can encode that easily using pattern matching:
match x with
| Foo y | Bar y -> y * 20
| Zoo y -> y * 30
In general, FP is more focused on first designing the types and then adding functions. So it really benefits from the fact that you can fit your types (domain model) in a couple of lines in a single file and then easily add the functions that operate on the domain model.
The two approaches – OO and FP are quite complementary and both have advantages and disadvantages. The tricky thing (coming from the OO perspective) is that F# usually uses the FP style as the default. But if there really is more need for adding new sub-classes, you can always use interfaces. But in most systems, you equally need to add types and functions, so the choice really does not matter that much – and using discriminated unions in F# is nicer.
I’d recommend this great blog series for more information.
2
You have correctly observed that pattern matching (essentially a supercharged switch statement) and dynamic dispatch have similarities. They also coexist in some languages, with a very enjoyable result. However, there are slight differences.
I might use the type system to define a type that can only have a fixed number of subtypes:
// pseudocode
data Bool = False | True
data Option a = None | Some item:a
data Tree a = Leaf item:a | Node (left:Tree a) (right:Tree a)
There will never be another subtype of Bool
or Option
, so subclassing does not appear to be useful (some languages like Scala have a notion of subclassing that can handle this – a class can be marked as “final“ outside of the current compilation unit, but subtypes can be defined inside this compilation unit).
Because the subtypes of a type like Option
are now statically known, the compiler can warn if we forget to handle a case in our pattern match. This means that a pattern match is more like a special downcast that forces us to handle all options.
Furthermore, dynamic method dispatch (which is required for OOP) also implies a runtime type check, but of a different kind. It is therefore fairly irrelevant if we do this type check explicitly through a pattern match or implicitly through a method call.
1
F# pattern matching is typically done with a discriminated union rather than with classes (and thus isn’t technically a type-check at all). This allows the compiler to give you a warning when you have unaccounted for cases in a pattern-match.
Another thing to note is that in a functional style, you organize things by functionality rather than by data, so pattern matches allow you to gather the different functionality into one place rather than scattered across classes. This also has the advantage that you can see how other cases are handled right next to where you need to make your changes.
Adding a new option then looks like:
- Add a new option to your discriminated union
- Fix all of the warnings on incomplete pattern matches
Partially, you see it more often in functional programming because you use types to make decisions more often. I realize you probably just chose examples more or less at random, but the OOP equivalent to your pattern matching example would more often look like:
if (opt != null)
opt.Something()
else
Different()
In other words, it’s relatively rare to use polymorphism to avoid routine things like null checks in OOP. Just like an OO programmer doesn’t create a null object in every little situation, a functional programmer doesn’t always overload a function, especially when you know your list of patterns is guaranteed to be exhaustive. If you use the type system in more situations, you’re going to see it used in ways you are not accustomed to.
Conversely, the idiomatic functional programming equivalent to your OOP example would most likely not use pattern matching, but would have fooRoute
and barRoute
functions that would get passed as arguments to the calling code. If someone used pattern matching in that situation, it would usually be considered wrong, just like someone switching on types would be considered wrong in OOP.
So when is pattern matching considered good functional programming code? When you’re doing more than just looking at the types, and when extending the requirements won’t require adding more cases. For example, Some val
doesn’t merely check that opt
has type Some
, it also binds val
to the underlying type for type-safe use on the other side of the ->
. You know you’ll most likely never have a need for a third case, so it’s a good use.
Pattern matching may superficially resemble an object-oriented switch statement, but there is a lot more going on, especially with longer or nested patterns. Make sure you take everything it’s doing into account before declaring it equivalent to some poorly-designed OOP code. Often, it is succinctly handling a situation that is not able to be cleanly represented in an inheritance hierarchy.
1