Create your own
Lesson illustration

Expression Problem: OO vs. Functional Approaches

Hello! Welcome back to our course on programming theory.

In our last lesson, we established the fundamental distinction between the imperative and declarative programming paradigms, tracing their origins to the von Neumann architecture and lambda calculus, respectively.

Today, we will make that abstract distinction concrete. We'll explore a classic software design challenge known as the Expression Problem. This will allow us to directly contrast the object-oriented (imperative-style) and functional (declarative-style) approaches to building extensible software. This lesson directly addresses the learning outcome: Contrast approaches to the Expression Problem in object-oriented versus functional languages, synthesizing prior concepts on data modeling and polymorphism.

We will see how each paradigm's core tenets create a fundamental trade-off, and then explore patterns and language features designed to overcome it. This discussion will synthesize concepts we've covered previously, such as subtype polymorphism versus ad-hoc polymorphism (Module 5) and class hierarchies versus algebraic data types (Module 6).

1. What is the Expression Problem?

At its heart, the Expression Problem is about a fundamental tension in software design. As a program evolves, we typically need to extend it in two dimensions:

  1. Adding new data types (or "forms" or "cases").
  2. Adding new operations that work on those types.

The "problem" is that most mainstream programming paradigms make it easy to extend in one dimension but difficult in the other, especially without modifying existing, working code.

To get a formal definition and a powerful mental model for this problem, please start by reading the first few sections of the following article.

The Expression Problem and its solutions

This article, 'The Expression Problem and its solutions' by Eli Bendersky, provides an excellent definition of the problem and introduces a matrix visualization that we will use throughout this lesson.

Please read the introduction (from the beginning until the 'A motivating example' section) and the section 'The expression problem matrix'. Focus on the core definition and understanding the 2D matrix visualization.

As the article explains, we can visualize this as a matrix where rows are data types and columns are operations.

This matrix clearly illustrates the trade-off. Now, let's see this in action with a concrete code example.

2. The Trade-off in Practice: An Expression Evaluator

We'll use a classic example: an evaluator for simple arithmetic expressions. We'll model expressions like 1 + (2 + 3).

The following article walks through implementing this in both an OOP and an FP style using Scala. Your familiarity with multiple languages will make the Scala syntax straightforward to follow.

OOP vs. FP. The pursuit of extensibility part #1

This article, 'OOP vs. FP. The pursuit of extensibility part #1', provides a clear, side-by-side comparison of the two paradigms when faced with the Expression Problem.

Please read from the beginning through the section 'The Functional Programming approach'. This will establish the initial setup for our expression evaluator in both styles.

To summarize the initial setup:

  • The OOP approach uses an Expr trait (an interface) with concrete classes Number and Add. Each class encapsulates its data and implements the eval operation. This is a classic use of subtype polymorphism.
  • The FP approach uses an Algebraic Data Type (ADT), also called Expr, to define the data structure. The eval operation is a separate function that uses pattern matching to handle the different cases (Number and Add).

Now, let's test the extensibility of each approach along the two dimensions of the Expression Problem.

Scenario 1: Adding a New Operation

Imagine we want to add a print operation to generate a string representation of an expression (e.g., "1.0 + 2.0").

OOP vs. FP. The pursuit of extensibility part #1

Let's continue with the same article to see how each paradigm handles adding a new operation.

Please read the section 'Extending by adding a new operation'. Notice which approach requires modifying existing code.

As you saw:

  • In FP, adding print is easy. We just write a new, separate print function that pattern matches on the Expr data type. No existing code is touched. This corresponds to adding a new column to our matrix.
  • In OOP, adding print is difficult. We have to modify the base Expr trait and then add a print method to every single existing class (Number, Add, etc.). This violates the open/closed principle.

Winner for adding operations: Functional Programming.

Scenario 2: Adding a New Data Type

Now, let's try extending in the other direction. We want to add a new form of expression, Negate, to represent negation.

OOP vs. FP. The pursuit of extensibility part #1

Finally, let's see what happens when we add a new data type.

Please read the section 'Extending forms'. Again, notice which approach requires modification of existing code.

Here, the situation is reversed:

  • In OOP, adding Negate is easy. We just create a new Negate class that implements the existing Expr interface (i.e., it provides an eval method and a print method). No existing code is touched. This corresponds to adding a new row to our matrix.
  • In FP, adding Negate is difficult. We have to add a Negate case to our Expr ADT, and then we must modify every single existing function (eval, print, etc.) to add a case for Negate.

Winner for adding data types: Object-Oriented Programming.

This is the essence of the Expression Problem. Neither paradigm provides a perfect solution for extensibility in both dimensions out of the box. The choice of paradigm dictates where you place your "wall" against modification.

Caption: The OOP approach is a direct consequence of its core principles. By bundling data and operations into objects (Encapsulation) and using class hierarchies (Inheritance, Polymorphism), it naturally organizes code by data type, making it easy to add new types but hard to add new operations across all types.

3. Workarounds and Solutions

The story doesn't end here. Programmers have developed patterns to mitigate these issues, and some languages offer features that solve the problem more elegantly.

The Visitor Pattern: Inverting the OOP Matrix

In the OOP world, the most well-known solution is the Visitor design pattern. In essence, it's a technique for simulating the functional approach. It decouples operations from the object structure, allowing you to add new operations without changing the data classes.

The trade-off is that it effectively "flips" the OOP matrix: it makes adding new operations easy, but makes adding new data types hard (as it requires updating the core Visitor interface).

The Expression Problem and its solutions

Let's return to Bendersky's article to see how the Visitor pattern works and why it's only a partial solution.

Please read the section 'Flipping the matrix with the visitor pattern'. You don't need to get lost in the C++ syntax; focus on the conceptual shift: how it moves operations out of the Expr classes and into Visitor classes.

The Visitor pattern is powerful but adds significant boilerplate and complexity. As the article hints, extending it to also allow new types easily becomes extremely convoluted in languages like C++ or Java.

True Solutions: The Power of Open Methods

A more elegant solution comes from languages that don't rigidly bind methods to class definitions. Your experience with Lisp provides a good intuition for this. Clojure, a modern Lisp dialect, solves the Expression Problem cleanly using a feature called multimethods.

Multimethods (or "generic functions") are defined independently of any data type. You can then add new implementations for a multimethod for any type, at any time, without modifying the original type or function definition.

The Expression Problem and its solutions

This final reading from Bendersky's article demonstrates the elegant solution offered by Clojure's multimethods.

Please read the section 'Solving the expression problem in Clojure'. Notice how both new operations (stringify) and new types (FunctionCall) can be added without touching any existing code. This achieves the 'holy grail' of filling out the matrix in any direction.

Clojure's approach, which the author calls "open methods," allows both rows and columns to be added to our matrix without modifying existing code. This is a profound advantage in language design.

Other languages solve this using different mechanisms that achieve a similar decoupling:

  • Haskell and Rust use type classes (a form of ad-hoc polymorphism that we discussed in Module 5).
  • The Common Lisp Object System (CLOS) also features multimethods.

These solutions are generally found in the functional and multi-paradigm language families, highlighting a key strength of their design philosophy: the separation of data from the operations that act upon it.

Conclusion

Today we've moved from the abstract idea of programming paradigms to a concrete, practical challenge that reveals their deepest architectural trade-offs.

Key Takeaways:

  • The Expression Problem is the challenge of extending a system with both new data types and new operations without modifying existing code.
  • Classic OOP (subtype polymorphism) makes adding types easy but operations hard. Code is organized around data.
  • Classic FP (ADTs and pattern matching) makes adding operations easy but types hard. Code is organized around operations.
  • The Visitor pattern can invert the trade-off in OOP, but at the cost of significant complexity.
  • Languages with "open methods", like Clojure's multimethods or Haskell's type classes, solve the problem by decoupling function/method implementations from data type definitions, allowing for extension in both dimensions.

This exploration shows that the choice of paradigm isn't just a matter of style; it has fundamental consequences for the structure and long-term maintainability of software.

Preview of the Next Lesson:

In this lesson, we saw how central pattern matching is to the functional approach. In our next session, we will investigate the origins of this powerful feature. We'll analyze the influence of logic programming, exemplified by the language Prolog, on modern language features like pattern matching and the unification algorithms used in type inference.

Can't find a good explanation? Sign up and we'll make it for you

Sign up