I can't help but question if the use of Discriminated Unions within a large system violates the Open/Close principle.
I understand the Open/Close Principle is Object Oriented and NOT Functional. However, I have reason to believe that the same code-smell exists.
I often avoid switch statements because I am usually forced to handle cases that were not initially accounted for. Thus, I find myself having to update each reference with a new case and some relative behavior.
Thus, I still believe that Discriminated Unions have the same code-smell as switch-statements.
Are my thoughts accurate?
Why are switch statements frowned upon but Discriminated Unions are embraced?
Do we not run into the same maintenance concerns using Discriminated Unions as we do switch-statements as the codebase evolves or digresses?
In my opinion, the Open/Closed principle is a bit fuzzy -- what does "open for extension" actually mean?
Does it mean extending with new data, or extending with new behavior, or both?
Here's a quote from Betrand Meyer (taken from Wikipedia):
A class is closed, since it may be compiled, stored in a library, baselined, and used by client classes. But it is also open, since any new class may use it as parent, adding new features. When a descendant class is defined, there is no need to change the original or to disturb its clients.
And here's a quote from Robert Martin's article:
The open-closed principle attacks this in a very straightforward way. It says that you should design modules that never change. When requirements change, you extend the behavior of such modules by adding new code, not by changing old code that already works.
What I take away from these quotes is the emphasis on never breaking clients that depend on you.
In the object-oriented paradigm (behavior-based), I would interpret that as a recommendation to use interfaces (or abstract base classes). Then, if the requirements change, you either create a new implementation of an existing interface, or, if new behavior is needed, create a new interface that extends the original one. (And BTW, switch statements are not OO -- you should be using polymorphism!)
In the functional paradigm, the equivalent of an interface from a design point of view is a function. Just as you would pass an interface to an object in an OO design, you would pass a function as a parameter to another function in an FP design. What's more, in FP, every function signature is automatically an "interface"! The implementation of the function can be changed later as long as its function signature does not change.
If you do need new behavior, just define a new function -- the existing clients of the old function will not be affected, while clients that need this new functionality will need to be modified to accept a new parameter.
Now in the specific case of changing requirements for a DU in F#, you can extend it without affecting clients in two ways.
Say that you have a simple DU like this:
type NumberCategory =
| IsBig of int
| IsSmall of int
And you want to add a new case IsMedium
.
In the composition approach, you'd create a new type without touching the old type, for example like this:
type NumberCategoryV2 =
| IsBigOrSmall of NumberCategory
| IsMedium of int
For clients that need just the original NumberCategory
component, you could convert the new type to the old like this:
// convert from NumberCategoryV2 to NumberCategory
let toOriginal (catV2:NumberCategoryV2) =
match catV2 with
| IsBigOrSmall original -> original
| IsMedium i -> IsSmall i
You can think of this as a kind of explicit upcasting :)
Alternatively, you can hide the cases and only expose active patterns:
type NumberCategory =
private // now private!
| IsBig of int
| IsSmall of int
let createNumberCategory i =
if i > 100 then IsBig i
else IsSmall i
// active pattern used to extract data since type is private
let (|IsBig|IsSmall|) numberCat =
match numberCat with
| IsBig i -> IsBig i
| IsSmall i -> IsSmall i
Later on, when the type changes, you can alter the active patterns to stay compatible:
type NumberCategory =
private
| IsBig of int
| IsSmall of int
| IsMedium of int // new case added
let createNumberCategory i =
if i > 100 then IsBig i
elif i > 10 then IsMedium i
else IsSmall i
// active pattern used to extract data since type is private
let (|IsBig|IsSmall|) numberCat =
match numberCat with
| IsBig i -> IsBig i
| IsSmall i -> IsSmall i
| IsMedium i -> IsSmall i // compatible with old definition
Which approach is best?
Well, for code that I control completely, I would use neither -- I would just make the change to the DU and fix up the compiler errors!
For code that is exposed as an API to clients I don't control, I would use the active pattern approach.
Objects and discriminated unions have limitations that are dual to each other:
So DUs definitely aren't appropriate for modeling every problem; but neither are traditional OO designs. Often, you know in which "direction" you'll need to make future modifications, so it's easy to choose (e.g. lists are definitely either empty or else have a head and a tail, so modeling them via a DU makes sense).
Sometimes you want to be able to extend things in both directions (add new "kinds" of objects and also add new "operations") - this is related to the expression problem, and there aren't particularly clean solutions in either classic OO programming or classic FP programming (though somewhat baroque solutions are possible, see e.g. Vesa Karvonen's comment here, which I've transliterated to F# here).
One reason that DUs may be seen more favorably than switch statements is that the F# compiler's support for exhaustiveness and redundancy checking can be more thorough than, say, the C# compiler's checking of switch statements (e.g. if I have match x with | A -> 'a' | B -> 'b'
and I add a new DU case C
then I will get a warning/error, but when using an enum
in C# I need to have a default
case anyway so the compile-time checks can't be as strong).
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With