Exploring Existence

Object-Oriented vs Functional Programming—Why Not Both?

Good. Bad. Right. Wrong. This. That. People love binary oppositions, and Software Engineering is full of them. One such opposition is the discussion of object-oriented vs functional programming. However, I’d argue it doesn’t have to be either/or. With care, we can have the best of both worlds, but to do so we need to understand the strengths and weaknesses of both paradigms.

Object-Oriented Programming

Object-oriented programming(OOP) has been a mainstay ever since the 1970s and 1980s and remains a popular paradigm to this day, found in languages like Java, C#, Python, etc.

It sets itself apart by modeling your software as ‘objects’, which bring together data and possible interactions with it. A major aim is to hide the internals and expose them only via its functions.

The paradigm aligns well with our natural way of thinking in terms of things, or ‘objects.’ When we perceive the world around us we tend to classify things as objects—we see a large brown thing with leaves and categorize it as a tree. In programming, this works too, especially for rich domains.

OOP has advantages and disadvantages. Like any tool, its effectiveness depends on how we employ it and in what context. Over the years, however, I’ve found OOP’s strengths to be as follows:

However, there are also downsides:

Let’s explore some Object-Oriented code. Imagine we’re creating software for train logistics. The code needs to couple and decouple train sets from an entire train. Here’s some object-oriented code to solve the problem:

class Train:
    def __init__(self, composition):
        self.composition = composition

    def couple(self, train_set):
        self.composition.append(train_set)

    def decouple(self):
        self.composition.pop()

    def length(self):
        lengths = [train_set.length for train_set in self.composition]
        return sum(lengths)

    def weight(self):
        weights = [train_set.weight for train_set in self.composition]
        return sum(weights)


class TrainSet:
    def __init__(self, weight, length):
        self.weight = weight
        self.length = length

And we’d use it like this:

def test_couple_increases_weight_length():
    train = Train([TrainSet(1000, 150)])

    train.couple(TrainSet(500, 75))

    assert train.weight() == 1000 + 500
    assert train.length() == 150 + 75
    assert len(train.composition) == 2


def test_decouple_decreases_weight_length_and_trainsets():
    train = Train([TrainSet(1000, 150), TrainSet(500, 75)])

    train.decouple()

    assert train.weight() == 1000
    assert train.length() == 150
    assert len(train.composition) == 1

Let’s analyze it. We express a train as a Train object with its composition as a property. Both length and weight can be calculated properties, which are preferable. It’s then quite natural to create interactions with that object, expressing them as couple and decouple a train set. Both affect the length, weight, and composition of the entire train. Consider this code sample our starting point for further comparison.

Functional programming has an entirely different philosophy, bringing with it a completely different set of advantages and disadvantages. Let’s see what those are.

Functional Programming

Functional programming(FP) is on the rise, despite its origins tracing back to the 1950s, and there are good reasons for that. Done well, FP can make codebases easier to grasp and less prone to complex bugs.

The paradigm differs from OOP in that it treats functions as first-class citizens, emphasizes the use of functions, immutability and isolating side-effects. FP aligns well with calculations—inputs and outputs.

I’ve learned most about FP from the excellent book Grokking Simplicity. A crucial perspective the author offers is that functional programming is all about identifying and separating Actions, Calculations, and Data.

These ACDs of functional programming have permanently changed how I read and write code. They have caused me to be on the lookout for this distinction and pull them apart in code.

Functional programming has a whole other set of benefits compared to OOP:

The downsides I’ve experienced are:

Time for some code—functional programming style. We’ll consider the same example as before: coupling and decoupling train sets on a train.

def couple_train(train, train_set):
    return train + (train_set,)


def decouple_train(train):
    return train[:-1]


def train_length(train):
    lengths = map(lambda train_set: train_set["length"], train)
    return sum(lengths)


def train_weight(train):
    weights = map(lambda train_set: train_set["weight"], train)
    return sum(weights)

And we’d use it as such:

def test_couple_increases_weight_length_and_trainsets():
    train = ({"length": 150, "weight": 1000},)

    coupled = couple_train(train, {"length": 75, "weight": 500})

    assert train_length(coupled) == 150 + 75
    assert train_weight(coupled) == 1000 + 500
    assert len(coupled) == 2
    
def test_decouple_decreases_weight_length_and_trainsets():
    train = ({"length": 150, "weight": 1000}, {"length": 75, "weight": 500})

    decoupled = decouple_train(train)

    assert train_length(decoupled) == 150
    assert train_weight(decoupled) == 1000
    assert len(decoupled) == 1

Compared to the OOP example we’ve expressed the behavior as calculations operating on immutable data. For instance, instead of a mutable list, we’ve used a tuple instead. Another crucial difference is found in the usage of the code: we assign results of functions to new variables coupled and decoupled. No more mutating state in place. We win in predictability, but we lose the clearly expressed concepts.

Could we do better? I believe so, but first, we should consider how we look at programming paradigms.

The Toolbox

There always seems to be discussion around ‘which paradigm is best’ or ‘why this paradigm is better than that one.’ But what if we look at it differently? Time to break the binary opposition.

Imagine your skillset as a software engineer as a toolbox. Every skill is neatly stored in one of its drawers. Now, let’s say that you’re handed a new screwdriver. Instead of throwing out your other tools, you would let them coexist and pick the appropriate tool for the job. Sometimes using them together to get a job done neither one could have achieved alone.

Each paradigm is an addition to your toolbox, not a replacement. This is why I advocate exploring different languages. As we’ve seen, each paradigm has its advantages and disadvantages, and each shines in different situations. Adding a tool to our collection makes us more flexible and enables us to synthesize entirely new things out of their coexistence.

More and more languages are becoming multi-paradigm, supporting different paradigms to varying degrees. Individual languages will lean more towards one or the other. For instance Scala—by default—is more functionally inclined than Java. A language being multi-paradigm can be likened to a language giving you a bigger toolbox to choose from. You can leverage FP for data processing, and OOP for rich domain modeling and join the two to get the best of both worlds.

What would a union of the best of both worlds look like to me?

We can model software using objects but still encourage locality by making them immutable and side-effect-free. Functional Object-Oriented Programming (FOOP?):

@dataclass(frozen=True)
class TrainSet:
    weight: int
    length: int


@dataclass(frozen=True)
class Train:
    composition: tuple[TrainSet, ...]

    def couple(self, train_set):
        return Train(self.composition + (train_set,))

    def decouple(self):
        return Train(self.composition[:-1])

    def length(self):
        lengths = map(lambda ts: ts.length, self.composition)
        return sum(lengths)

    def weight(self):
        weights = map(lambda ts: ts.weight, self.composition)
        return sum(weights)

Used like this:

def test_couple_increases_weight_length():
    train = Train(
        (TrainSet(1000, 150),)
    )

    coupled = train.couple(TrainSet(500, 75))

    assert coupled.weight() == 1000 + 500
    assert coupled.length() == 150 + 75
    assert len(coupled.composition) == 2


def test_decouple_decreases_weight_length_and_trainsets():
    train = Train((TrainSet(1000, 150), TrainSet(500, 75)))

    decoupled = train.decouple()

    assert decoupled.weight() == 1000
    assert decoupled.length() == 150
    assert len(decoupled.composition) == 1

Comparing this solution to the OOP and FP example, we’ve hit upon the Goldilocks zone for a problem like this. Frozen data classes make the objects immutable after creation, making them easy to reason about, whilst clearly capturing the concepts of Train and TrainSet. We compute new states by returning new Train instances from couple and decouple. This changes the interaction for client code, as now Train is returned from those methods.

This happens to be the programming style I gravitate towards in programming languages supporting both OOP and FP. At least for the core of non-trivial programs solving complex domain problems.

Concluding we can see that no tool is inherently good or bad. Good code can be written in any language or paradigm, but the same holds for the inverse. In the end, it isn’t the tool that decides the outcome. That responsibility lies with the one who wields it.

Summary

As much as we’d like reality to be black and white, it is more nuanced than that. In this case that turns out to be a great outcome:

We don’t have to choose either Object-Oriented Programming or Functional Programming. We can have the best of both.

Used well together, these paradigms allow you to synthesize something better than either one could have done by itself. The sum of the whole is greater than its parts.

The code samples are available on Github.

#FP #OOP #software design #tech