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.
Extending a DU
Now in the specific case of changing requirements for a DU in F#, you can extend it without affecting clients in two ways.
- Use composition to build a new data type from the old one, or
- Hide the cases from the clients and use active patterns.
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.