13

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?

Scott Nimrod
  • 11,206
  • 11
  • 54
  • 118
  • 1
    Yes, if you add additional case to discriminated union you have to handle it somehow in the code that uses this type. Whether it is a code smell or a code aroma depends on your concrete use case. – Petr Jan 04 '16 at 18:54
  • @Petr Can you please provide guidance on when and when NOT to use DUs? – Scott Nimrod Jan 04 '16 at 19:07
  • Guidance is here: http://stackoverflow.com/q/17291932 – Robert Harvey Jan 04 '16 at 19:14
  • 1
    I don't think there is a clear guidance when use and when not to use DU. But if your use case required frequently adding new case to DU then probably it is not a good case for DU. See more here: https://msdn.microsoft.com/en-us/library/dd233226.aspx – Petr Jan 04 '16 at 19:18
  • Possible duplicate of [Repeatable pattern matching](http://stackoverflow.com/questions/33298466/repeatable-pattern-matching) – Mark Seemann Jan 04 '16 at 20:23

4 Answers4

15

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.

Grundoon
  • 2,734
  • 1
  • 18
  • 21
14

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

kvb
  • 54,864
  • 2
  • 91
  • 133
1

I'm not sure what is your approach for Open-Close principle with OO but I often end-up implementing such principled code by resorting to higher order functions, the other approach I employ is to use interfaces. I tend to avoid base classes.

You can use the same approach with DU by having a open for extension case which has a functor as parameter on top of other helpful cases which are more hard-coded like:

type Cases<T> =
| Case1 of string
| Case2 of int
| Case3 of IFoo
| OpenCase of (unit -> T)

when using OpenCase you can pass a function which is specific to the site you instantiate this value of the discriminated union.

Why are switch statements frowned upon but Discriminated Unions are embraced?

You might collate DU with pattern matching so I'll try to clarify:

Pattern matching is a code construct (like a switch), while DU is a type construct (like a closed hierarchy of classes or structs, or an enum).

Pattern matching with match in F# has greater abilities than switch in C#.

Do we not run into the same maintenance concerns using Discriminated Unions as we do switch-statements as the codebase evolves or digresses?

Discriminated unions used with pattern matching have more type safety/exhaustiveness properties than a regular switch statement, the compiler is more helpful as it will issue warnings for incomplete matches, which you don't get with switch statements from C#.

You can have maintenance concerns with OO code which is Open-Close principled and I don't feel DU is related to this.

smoothdeveloper
  • 1,972
  • 18
  • 19
0

Switch statements are not antithetical to the Open/Closed principle. It all depends where you put them.

OCP tells you that adding new implementations of dependencies shouldn't force you to modify the code that's consuming them.

But when you add a new implementation, the logic that decides to choose that implementation over another one has to be somewhere in the code. The new class won't be taken into account by magic. Such a decision can take place in IoC container configuration code, or in a conditional somewhere during the program's execution. This conditional can perfectly be a switch statement.

The same goes for pattern matching. You can use it to decide what function to pass to a higher order function F (which would be the equivalent of injecting a dependency in OO). It doesn't mean that F itself makes the choice or is aware of what concrete function is passed to it. The abstraction is preserved.

guillaume31
  • 13,738
  • 1
  • 32
  • 51