Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Do Discriminated Unions conflict with the Open Close Principle

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?

like image 422
Scott Nimrod Avatar asked Jan 04 '16 18:01

Scott Nimrod


2 Answers

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.

like image 88
Grundoon Avatar answered Sep 28 '22 06:09

Grundoon


Objects and discriminated unions have limitations that are dual to each other:

  • When using an interface, it's easy to add new classes that implement the interface without affecting other implementations, but hard to add new methods (i.e. if you add a new method you need to add implementations of the method to each class implementing the interface).
  • When designing a DU type, it's easy to add new methods using the type without affecting other methods, but hard to add new cases (i.e. if you add a new case then every existing method needs to be updated to handle it).

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).

like image 41
kvb Avatar answered Sep 28 '22 05:09

kvb