classooppolymorphismrefactoringdiscriminated-union

How to move logic from runtime type matches to inherited classes without bloating the classes


I learned F# before C#, so I am unused to inheritance and do not understand its best practices.

As I understand, the nearest object-oriented equivalent to the discriminated union (the "one of" data type) is the subclass. The difference is that (unsealed) inheritance allows new cases to be added arbitrarily while the DU allows behaviors using the existing cases to be added arbitrarily. So the inherited entity defines all behaviors and allows cases (with their implementations) to be added anywhere, whereas the DU defines all cases and allows behaviors to be added anywhere.

Now I am working on a C# project whose previous developers apparently were accustomed to using DUs; I find runtime type matches frequently. My reading on the Internet indicates that runtime type matches are a Bad Thing, which makes sense to me since they inherently lack exhaustive matching. The OOP right way is to use polymorphism instead.

At last, here is my question: If I move all the behavior currently implemented in the runtime matches to the classes being matched, then those classes will grow significantly and will require access to far more of the code than they currently do, besides violating the single-responsibility principle. How do I avoid that? Or is there some refactoring I can perform later to remedy it?


Solution

  • Indeed, what you've described is the well-known expression problem. It's not that the 'standard' object-oriented approach is always bad. Sometimes you do need to be able to add behaviour in an unconstrained fashion.

    F# (and other statically typed functional languages, like Haskell) also allow this, although usually, the granule of variation is the function. For example, a function of the type int -> int is polymorphic, and could be implemented in various sane ways, such as negation, incrementation, squaring, etc. You can, however, also, after the fact, add arbitrary implementations as your please, such as adding 42, multiplying by 3, and so on.

    It's often useful to have such a kind of unconstrained variability of behaviour. Objects, essentially being tuples of functions, work the same way.

    Sometimes, however, you need the other kind of algebraic data type, but unfortunately, most object-oriented languages don't natively support sum types.

    And you're right, turning to downcasting isn't a maintainable design.

    Sometimes, you can reframe a problem so that it becomes 'nicely' object-oriented, in such a way that downcasting isn't required. When working in an object-oriented code base, I consider that my first priority, because you want to use language constructs and idioms familiar to your co-maintainers.

    Occasionally, however, I fail to identify a good object-oriented solution that doesn't entail some of the drawbacks you've already described. Sometimes, a sum type just is the best solution.

    Fortunately, you can implement sum types in object-oriented languages using either Church encoding or the Visitor design pattern.