Maybe I'm off, but to me the gist of the expression problem can be explained by contrasting how code extensibility is achieved in OOP/FP.
OOP Approach with interface/inheritance:
Easy: Adding new types (variants) of a base class/interface.
Hard: Adding new functionality to the base class/interface, as it requires implementing it in all existing types.
FP Approach with Discriminated Unions:
Easy: Adding new functions. Create a function and match on the DU; the compiler ensures all cases are handled.
Hard: Adding new types to the DU, as it requires updating all existing exhaustive pattern matches throughout the codebase.
Here's some Kotlin code. Kotlin is great because it can do both really well.
// Object-Oriented Approach
interface Shape {
fun area(): Double
fun perimeter(): Double
}
class Circle(val radius: Double) : Shape {
override fun area() = Math.PI \* radius \* radius
override fun perimeter() = 2 \* Math.PI \* radius
}
class Rectangle(val width: Double, val height: Double) : Shape {
override fun area() = width \* height
override fun perimeter() = 2 \* (width + height)
}
// Easy to add new shape
class Triangle(val a: Double, val b: Double, val c: Double) : Shape {
override fun area(): Double {
val s = (a + b + c) / 2
return Math.sqrt(s \* (s - a) \* (s - b) \* (s - c))
}
override fun perimeter() = a + b + c
}
// Hard to add new function (need to modify all existing shapes)
// interface Shape {
// fun area(): Double
// fun perimeter(): Double
// fun draw(): String // New function
// }
// Functional Approach
sealed class ShapeFP {
data class CircleFP(val radius: Double) : ShapeFP()
data class RectangleFP(val width: Double, val height: Double) : ShapeFP()
}
fun area(shape: ShapeFP): Double = when (shape) {
is ShapeFP.CircleFP -> Math.PI \* shape.radius \* shape.radius
is ShapeFP.RectangleFP -> shape.width \* shape.height
}
fun perimeter(shape: ShapeFP): Double = when (shape) {
is ShapeFP.CircleFP -> 2 \* Math.PI \* shape.radius
is ShapeFP.RectangleFP -> 2 \* (shape.width + shape.height)
}
// Easy to add new function
fun draw(shape: ShapeFP): String = when (shape) {
is ShapeFP.CircleFP -> "O"
is ShapeFP.RectangleFP -> "[]"
}
// Hard to add new shape (need to update all existing functions)
// sealed class ShapeFP {
// data class CircleFP(val radius: Double) : ShapeFP()
// data class RectangleFP(val width: Double, val height: Double) : ShapeFP()
// data class TriangleFP(val a: Double, val b: Double, val c: Double) : ShapeFP()
// }
However the definitions make the 2 choices (adding new types vs adding new functions/operations) sound like a toss-up.
It is the fact that I tend to find the FP/DU approach so much more frequently useful for my/my team's own code that makes me wonder if I'm missing something.
Perhaps the important distinction I've been missing is in Wikipedia's definition:
"The goal is to define a data abstraction that is extensible both in its representations and its behaviors, where one can add new representations and new behaviors to the data abstraction, without recompiling existing code, and while retaining static type safety (e.g., no casts)."
... but when I'm working on my own/team's code, it is perfectly sensible to recompile the code constantly.
The reason why it matters less than people intuitively think is precisely that it matters a lot less when you're in full control of both the operations and the types anyhow, and that's actually the most common case. Generally you are "composing" in library code, that is, just using it, not extending the library itself.
When you are extending, you actually want to choose the correct thing depending on what you need. Going the wrong direction is painful in both directions.
Personally I think one of the reasons sum types are greeted with such "oh my gosh where have you been all my life" reactions is precisely that we had type extension as our only option for so long. If we had had only sum types, and type extension was given to us for the first time in obscure languages 20 years ago and they only really started getting popular in the last 5 or so, I think they'd be considered in much the same way. Just as in a world with only screwdrivers, the invention of the hammer would be hailed as a revolution... and in a world with only hammers, the invention of the screwdriver would be hailed as a revolution. But in both cases the real mystery is how the hypothetical world got that far in the first place.
Not that they aren't useful; consider what it means that I'm analogizing them to something like a hammer and a screwdriver, not an oil filter remover or something. It is weird that we were missing one of them for as long as we were in the mainstream.
And I've known about them for, let's see, at least fifteen years, and I've definitely gotten over my "oh my gosh I must use these for everything" phase.
Though I do wonder as well how many people encountered them in their "spread their wings" phase and happened to be learning about sum types just as their general programming skill was leveling up in general, and conflate the two. When you learn how to use both skillfully, I really feel like the differences collapse quite a bit. I see so, so much bad code with type-based extension, but it's not because they're using type-based extension, but just that it's bad code, regardless. Of course bad type-extension code is worse than good sum types code, but there's still times and places for both approaches when you know what you're doing with both.
What do you do when you are handed a DLL with publicly exposed types? You can't recompile someone else's DLL without the source, but the public non-sealed types are totally open to inherit from; it's just a question of how useful the inheritance would actually be if there's not a logical public interface (as provided by the original designer).
Maybe you can read all the public fields, but you can't actually modify them or create functions that modify the object. You then must wrapper around the instances and fight to bridge every little behavior between their code and yours.
This to me is evidence that one core tenet of OOP "Open to extension" is in-practice meaningless.
OOP Approach with interface/inheritance:
Easy: Adding new types (variants) of a base class/interface. Hard: Adding new functionality to the base class/interface, as it requires implementing it in all existing types.
FP Approach with Discriminated Unions:
Easy: Adding new functions. Create a function and match on the DU; the compiler ensures all cases are handled. Hard: Adding new types to the DU, as it requires updating all existing exhaustive pattern matches throughout the codebase.
Here's some Kotlin code. Kotlin is great because it can do both really well.