Despite the fancy name, the expression problem is definitely a stumbling block you’ve encountered many, many times in object-oriented programming, wether you knew what it was or not. From the OOP side (as opposed to the functional language perspective, where it is also a problem but looks quite different), it is simply the problem of trying to add a new operation to existing types. For what it’s worth, I go through this in depth in the video I linked above, but some people prefer written explanations. The core problem with class-based OOP is that methods live inside of classes and everyone has to agree on what methods make sense to add to each class. You may think, “What is the problem? I have added methods to classes all the time?” In your own code that you fully control, sure, if you want a new method, just open up the class definition and add the method. The problem occurs when, as happens in the real world, there are many people sharing and reusing code. Which, you will note, is one of the main problems that OOP is intended to solve.
Suppose, for example, that I maintain a package like ColorTypes but in a class-based language like Python or Java. Now further suppose that you want to treat each color object like a 3-vector (or however many channels it has) and do vector operations like addition, subtraction, and taking norms. You don’t even insist on using operators like +
and -
, you’ll happily write color1.add(color2)
, color1.sub(color2)
and color.norm()
. You try to convince me to add these methods to the Colorant
abstract base class. However, I don’t want to add these methods: I don’t want to take on more features to maintain and it’s not entirely clear to me if/how colors should behave like vectors because perceptual color space isn’t linear in terms of various representations like RGB, CMYK, HSV, Lab, XYZ, etc.
Given my refusal to add methods to ColorTypes, what can you do? Traditional OOP would tell you to define your own subclasses. Problem solved! Right? Well, let’s think it through. The implementation is not as simple as it sounds. You want to add methods to Colorant
so that all the color types inherit the new methods you add and any generic operations you define work across all color types. So you subclass VectorColorant <: Colorant
. But now how do you get all the corresponding vector versions of specific concrete subtypes like RGB
, HSV
, CMYK
, etc.? You have to define your own parallel type hierarchy that mirrors the one in ColorTypes
. If your language supports multiple inheritance (Java does not, Python does), you can define VectorRGB <: (VectorColorant, RGB)
, but you have to do that for every possible subclass — even ones defined outside of ColorTypes
. This isn’t great: the number of definitions you have to write scales with the number of concrete subtypes, but at least each definition is trivial.
If your language does not support multiple inheritance, then you’re pretty much stuck: you have to copy the definition of each subtype but inheriting from the Vector
-prefixed variant of what the original type inherited from. At this point, you’ve copied the entire class hierarchy aside from the topmost abstract base class, which probably doesn’t have much in it anyway, so you might as well just make your own copy of the whole thing into your own separate VectorColorTypes
package. You also fix a few annoyances with the original code while you’re at it. After a while the upstream ColorTypes
package makes a few changes that you haven’t kept up with, and the ColorTypes
and VectorColorTypes
packages start to diverge. They’re still similar, but unless you both try really hard to keep them in sync, they gradually become more and more incompatible. OOPs!
That’s not even the extent of the problems. Suppose you’re lucky enough to be in a language that supports multiple inheritance and you created a mirror hierarchy of trivial Vector
-prefixed types without completely parting ways with the original ColorTypes
package. Now you encounter some really neat palette
method that someone wrote using ColorTypes
that takes a single RGB
value and produces an entire palette of harmonious colors based on it. But you want to use it with your VectorRGB
type. You can’t use it directly, however, since the methods are defined specifically for the RGB
type. Even though VectorRGB
has the exact same structure as RGB
and even supports all the same methods that do all the same things (remember, I only wanted to add methods, not change the existing stuff), they are different types and not interchangeable: if some method returns RGB
values, you cannot apply methods for VectorRGB
objects to them. You have three options:
-
You can petition the author of the code to change it to handle
VectorRGB
as well asRGB
; they many not want to do this, since it’s an unnecessary complication. -
You can create a shim method for
VectorRGB
that converts theVectorRGB
value toRGB
, callspalette
on it and then converts the returned colors back toVectorRGB
. This works, but you have to do it for every function likepalette
. -
You can copy the code and duplicated it for the
VectorRGB
type and part ways with the original author.
None of these is that bad, but you have to do this for every bit of ColorTypes
functionality that you might want to use. It’s also funny how OOP, which is supposed to be this great enabler of code reuse and sharing, keeps pushing us towards just making our own copy of someone else’s code and modifying it .
The palette
method is actually quite simple, so adding support for VectorColorant
subtypes isn’t that complicated. In more complex situations where color types are consumed or constructed, adding support for different subtypes leads to a proliferation of complex OOP patterns in real world code bases, like the visitor pattern, the abstract factory pattern, the factory method pattern, the prototype pattern, etc.
How does all of this work out in Julia with multiple dispatch? All you have to do is define methods like this in your own code:
add(c1::RGB, c2::RGB) = RGB(c1.r + c2.r, c1.g + c2.g, c1.b + c2.b)
norm(c::RGB) = norm((c.r, c.g, c.b))
That’s it. You don’t have to convince anyone of anything. You don’t have to copy their code. You can add methods at whatever level of the abstraction hierarchy is appropriate. It doesn’t even matter if other people want to use the same names for methods because if you define an add
function in your code and they define a different add
function in their code, these are separate function objects in different namespaces and there’s no issue at all. In OOP, the namespace of methods is global and shared, so method name collisions are a problem. The key reason all this is better with multiple dispatch is that, unlike OOP where methods are defined inside of classes, methods belong to generic functions and are defined outside of the types they apply to — potentially not even in the same code base. Each user of a type can add methods as they please, which other users can reuse or ignore as they prefer.
So even if you never realized you encountered the expression problem because you didn’t have a name for it, you almost certainly have. That time you wanted to just add one teensy tiny method to someone else’s class but couldn’t. That was the expression problem. That time you discovered that your favorite OOP language’s ecosystem has two or more non-interoperable packages for some subject and you need some features from both of them but can’t use them together. If not for the expression problem, they could share a core set of simple types while extending them with different methods on top of that shared core. That time you were tearing your hair out trying to understand what the SimpleBeanFactoryAwareAspectInstanceFactory
class is and how the heck you’re supposed to use it. That was also the expression problem: if people weren’t forced to subclass just to add a few methods, they could just share concrete types instead of needing so many layers of abstraction. Despite seeming so different, these are all manifestations of the same core problem in OOP languages, a problem which multiple dispatch solves simply and elegantly, thereby eliminating all these issues at once. The effectiveness of this solution is borne out by the unusually high degree of code reuse and sharing we actually see in the Julia ecosystem.