Intro
In Part 1 and Part 2 of these series, I’ve made a practical comparison between two fundamentally orthogonal programming styles – Object-Oriented and Functional. You’ve seen how these two paradigms use an opposite approach to accomplish the same thing.
You’ve explored how the requirements can be modeled using a 2-dimensional grid I call the Operations Matrix where the rows are the different variants (or data types), while the columns are the various operations we support.
If programs were written once and set in stone, the story would end up here. However, it doesn’t take long for anyone to understand how fragile our assumptions about the requirements are and how they can change at any point in a very unpredictable direction.
That’s probably one of the trickiest parts of our job. How do we make sure that future changes and extensions will be easy to make, and we won’t need to rewrite everything? What does easy even mean when it comes to extending our program? What is easy from FP and OOP perspectives?
This article is all about answering these questions. So read along!
Note: This series of articles is very much influenced by some of the core ideas presented in the excellent three-part course “Programming Languages” on Coursera. I encourage you to read my review and pick up the course yourself.
Additionally, you can read through Chapter 6 – “Objects and Data Structures” of the bestseller Clean Code by Uncle Bob. Some of the concepts presented here are also discussed in this great book. By the way, you should read the book anyway if you haven’t already.
Extensibility in Terms of the Operations Matrix
Recall that the Operations Matrix for our simple interpreter looks like this:
So far, we’ve been supporting two operations – Eval and Stringify on a couple of variants(types of expressions) – Integer Constant and Addition.
In the first two parts of the series, we implemented our requirements by filling out all the cells of the grid(those question marks). First, we did that using OOP. Then, by following the FP style.
This article is about extending the Operations Matrix. In other words, adding new requirements (or new cells), we need to cover. This can happen in two directions – by adding a new variant(a new row), or by adding a new operation(a new column).
Depending on the style we’ve been following so far (OOP or FP) and the type of the new required extension(variant or operation), our task may come up as easy or hard to accomplish.
The following table illustrates when it’s easy or hard to extend our program from OOP and FP perspectives:
So, with OOP, it’s easy to add a new variant, and it’s hard to add operation. With FP, it’s precisely the opposite. Why is that? I think this can be best explained in the context of our SOLID friend – the Open-Closed Principle.
The Open-Closed Principle via OOP and FP
The Open-Closed Principle states the following:
Our program should be “Open for extensions, but Closed for modifications.”
What does that mean?
It really boils down to designing our program in such a way that we prevent future modifications of existing code as much as possible. Why is that? Because when we change existing code, we break things. Of course, a robust test suite can significantly reduce the risk, but there is always a chance we mess something up.
There’s tons of literature on SOLID principles. If you want to dig deeper, I recommend the book by the inventor of the term – Robert C. Martin: Agile Principles, Patterns, and Practices in C#. However, in this book, and in many of the other sources, you’ll see OCP discussed primarily from the OOP point of view, which to me, is unfair. I am also presenting the FP side of things here, which builds a more comprehensive picture.
In this article, though, I’d like to concentrate on the Open-Closed Principle in the context of our OOP vs. FP discussion.
So, how do you add a new feature to your program without modifying the existing code? That even sounds like a contradiction, right? If we are changing the program, we need to change the code, correct?
Yes and no. We think of good program design as a toolbox. We have plenty of tools in this toolbox. When we need to do something new, we look for the right tool(or set of tools), and we try to do our work with those set of tools. The tools are simple. They do their job very well, and they do nothing more.
If we don’t find an appropriate tool, we create one. That’s how we extend our toolbox. We don’t want to modify the existing tools in some mysterious way to handle some additional edge cases. That would reduce the generality and re-usability of our tools.
In OPP, the tools are objects. We can extend our program without modifying the existing code by adding a new type of data, a new variant.
In FP, the tools are functions. We can extend our program without modifying the existing code by adding a new function, a new operation.
But what if we need a new operation in OOP or a new variant in FP? These are the cases that the two programming paradigms just don’t support well. In other words, we will be forced to modify our existing types or functions.
For completeness, I should mention there are ways with some smart upfront design to support somewhat well such unnatural extensions both for OOP and FP. I’ll discuss that later in this article and give full details and examples in a future post.
For now, let’s get practical and see some actual OOP and FP implementations when adding a new variant and operation.
Adding a Variant
To add a variant for our interpreter means adding a new type of expression. I chose this to be a Negation expression. A new variant is represented by a new row in the Operations Matrix:
Let’s see how we’d implement this extension, starting with OOP.
Adding a Variant with OOP
Remember that OOP implements the Operations Matrix “by rows.” Every expression type implements every operation on itself. Same with the new Negation:
From a coding perspective, this means just adding a new stand-alone class Negation that implements our Eval()
and Stringify()
operations:
public class Negation : IExpression { private readonly IExpression _expression; public Negation(IExpression expression) { _expression = expression; } public int Eval() { return -_expression.Eval(); } public string Stringify() { return $"-({_expression.Stringify()})"; } }
The exact implementation is even not that important. The point is that we just extended our program with a new expression type by just adding one more class. This is what we call an easy extension. No old code was touched, so there’s no risk for regression errors. We conform to OCP.
Let’s contrast that with what we need to do in FP to support our new expression type.
Adding a Variant with FP
The FP implementation direction is “vertical,” and the key element is the function. Every function implements one operation. Adding a new variant in this context means creating a conflict with the existing operations:
All of the existing operations now need to handle this additional “row.” Thus, we need to modify them. First, we need to extend our Expression
type with the new Negation:
type Expression = | MyInt of int | Addition of Expression * Expression | Negation of Expression
Then modify our eval
function with a new case:
let rec eval expression = match expression with | MyInt i -> i | Addition (op1, op2) -> eval op1 + eval op2 | Negation exp -> -eval exp
And similarly for stringify
:
let rec stringify expression = match expression with | MyInt i -> i.ToString() | Addition (op1, op2) -> stringify op1 + " + " + stringify op2 | Negation exp -> "-(" + stringify exp + ")"
This is what a hard change looks like. We had to modify every function that was using the Expression type. This is a clear violation of the OCP.
This example is too simple, and the change was still pretty easy to make. Nevertheless, you can imagine in a more realistic scenario, modifying existing code to handle some additional cases can be error-prone.
Let’s go to the other side of the spectrum and see how we would add a new operation to our program.
Adding an Operation
The operation we will be adding is called CountZeroes. Quite intuitively, it will count how many zeros there are in an expression.
Adding a new operation means adding a new column to the Operations Matrix:
If your intuition tells you this case would be easily covered by the Functional approach, you are correct.
Let’s see the details.
Adding an Operation with FP
FP implements the Operations Matrix “by columns.” Every operation has an implementation for every expression type. This logic holds for CountZeros:
In FP, adding a new operation means just implementing a new function:
let rec countZeros expression = match expression with | MyInt i -> if i = 0 then 1 else 0 | Addition (op1, op2) -> countZeros op1 + countZeros op2
This is why adding an operation in FP is easy.
On the other hand, because of the orthogonality between the two paradigms, we would expect OOP to cause trouble in this case. Let’s confirm that.
Adding an Operation with OOP
In OOP, it’s hard to add a new operation in exactly the same way it was hard in FP to add a new variant. In our example, CountZeros “gets in the way” of the existing variants, meaning they need to be modified to support it:
From a coding perspective, first, we need to extend our IExpression
interface.
public interface IExpression { int Eval(); string Stringify(); int CountZeros(); }
Then add the additional method in the MyInt
type:
public class MyInt : IExpression { private int Val { get; } public MyInt(int val) { Val = val; } public int Eval() => Val; public string Stringify() => Val.ToString(); public int CountZeros() => Val == 0 ? 1 : 0; }
And the same with Addition
:
public class Addition : IExpression { private readonly IExpression _operand1; private readonly IExpression _operand2; public Addition(IExpression operand1, IExpression operand2) { _operand1 = operand1; _operand2 = operand2; } public int Eval() => _operand1.Eval() + _operand2.Eval(); public string Stringify() => $"{_operand1.Stringify()} + {_operand2.Stringify()}"; public int CountZeros() => _operand1.CountZeros() + _operand2.CountZeros(); }
Please note how fundamentally identical those changes are to the ones we needed to perform when adding a variant in the Functional case.
There is a Middle Ground
From the discussion so far, it may look like you need to be very confident about the future extensibility direction of your program so that you choose the proper implementation style. If you get it right – you’re in the right spot. If you get it wrong, though – you’re out of luck. You’ll need to perform “dangerous” code changes every time you need to extend your program.
Fortunately, this is not exactly the case. First of all, in most cases, there is no way for you to be 100% confident about how your program will change in the future. Second of all, it’s rarely the case when you’ll need to add just variants or operations. In most of the scenarios, you’ll be adding some mix of the two.
Luckily there is a way, with some initial work, to make the hard extensions for FP and OOP somewhat simpler. In OOP, we have even invented a name for this – the famous Visitor Pattern. I will review the exact technical details for these cases in a future article.
Although it’s possible to add support for “unnatural” extensions, this can add a certain level of awkwardness to your program. This is why it’s essential to have at least some sense of how your program will change in the future.
Whatever you do, though, there will be cases when you get your initial design wrong. You need to accept this and do your best to fix your mistake in a timely manner – more on that in the next section.
The Future is Hard to Predict
The reality is that planning for future extensions is fundamentally difficult. You might not know what kind of extensions to expect, or you might expect both, in which case one of them is going to be awkward.
Extensibility is a double-edged sword. It does make the code more general and re-usable. But on the other hand, it can add some mental burden to reason about your system.
Also, too much upfront design analysis can get into the famous Analysis Paralysis situation. I believe that the design emerges from the existing implementation. The more we work and the more we “live” with some piece of software, the more we start discovering the right abstractions to model. This is very hard to get right in the beginning.
With this said, when faced with a new problem, don’t try to design it perfectly upfront. That’s pretty much impossible. Just think a little, draw a small diagram, write some code. Repeat this process over and over again. Don’t be afraid to make significant changes(something hard to get to without a stable suite of unit tests). Follow this advice, and I’m sure you’ll end up with a very decent and maintainable system your clients will be happy using.
Summary
In this article, you’ve seen that choosing between FP and OOP is not just a matter of style and personal preference. This can affect the maintainability characteristics of your program in the future.
We’ve seen how and why OOP naturally supports new variants, while FP deals better with new operations.
In the next part, you’ll dive even deeper into more concrete edge cases and see how FP and OOP handle them. This will allow us to discuss some advanced techniques like Double Dispatch.
I’ll also present some design patterns that enable us to support the “unnatural” extension types both for OOP and FP.
Stay tuned and thanks for reading!
Resources
- Programming Languages, Part A
- Programming Languages, Part B
- Programming Languages, Part C
- Agile Principles, Patterns, and Practices in C#
- https://stackoverflow.com/questions/3151702/discriminated-union-in-c-sharp
- http://astreltsov.com/software-development/discriminated-unions-in-c-sharp-dot-net.html
- http://boustrophedonic.com/blog/2012/10/21/union-types-in-csharp/
- https://docs.microsoft.com/en-us/dotnet/fsharp/language-reference/discriminated-unions