Hacker News new | past | comments | ask | show | jobs | submit login

I’ve lost track of all of the red/blue/white/black pill color metaphors, but unions with exhaustive pattern matching is one of the toughest of all programming language features to live without once you become aware of it.

I’ve never felt like I’ve fully understood the implications of the expression problem https://en.wikipedia.org/wiki/Expression_problem but my best/latest personal hypothesis is that providing extension points via conventional polymorphism might be best suited for unknown/future clients who might extend the code, but unions with exhaustive pattern matching seem better suited for code that I or my team owns. I don’t typically want to extend that code. More often, I instead want to update my core data structures to reflect ongoing changes in my understanding of the business domain, more often than not using these Union-type relationships, and then lean on the compiler errors maximally to get feedback about where the rest of the imperative code now no longer matches the business domain data structures.




I wrote this to help me understand the differences.

https://deliberate-software.com/christmas-f-number-polymorph...


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()
  // }


When statement will be exhaustive thanks to the sealed class, so compiler catches this, one of the great things about Kotlin.


I think you've nailed the definitions.

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.


Sum types are not a new discovery for me, personally.


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.




Guidelines | FAQ | Lists | API | Security | Legal | Apply to YC | Contact

Search: