The beginners guide to why some people prefer functional programming and others object-oriented

Functional example of pipes in Python.
authors
Matthias Kainer

Contents

Some people just love their opinions. They will argue eloquently for their ideas, to the point where they forget why they are arguing at all. Functional programming (FP) vs object-oriented programming (OOP) seems to be one of those cases. But what are the high-level differences, and what should I choose?

We are going to make a little comparison contrasting the code. The language I choose for this exercise is Python. It’s neither the best example for a complete OOP language nor an FP language, but it supports both well enough to serve as a good reference language. You don’t have to be fluent in Python, but knowing how to code in any language before reading this article will help you to follow this article.

A quick primer on object-oriented programming

Let’s start with their characteristics first. In a nutshell, in OOP, objects are the central unit. They store the state of the data and also provide the behaviours for the respective object. Those objects can be inherited and composed, and hiding complexity is essential. A simple example would be an application with animals. We could have a base class Animal, and some actual animals inheriting from it.

import unittest

# The base class for an animal
class Animal(object):
  def __init__(self, sound: str = None) -> None:
    super().__init__()
    self.__sound = sound

  def make_noise(self):
    return self.__sound

class Dog(Animal):
  def __init__(self) -> None:
    super().__init__("woff!")

class Cat(Animal):
  def __init__(self) -> None:
    super().__init__("miau!")

class Inheritance(unittest.TestCase):
  def test_noises(self):
    self.assertEqual(Cat().make_noise(), "miau!")
    self.assertEqual(Dog().make_noise(), "woff!")
    self.assertEqual(Animal().make_noise(), None)

Inheritance allows for easy usage and creating a zoo of animals with shared behaviours, like making noise, eating, or moving. Looking at moving, there we see an excellent use-case for the next part of OOP, Composition. While both dogs and cats walk, a bird would fly. Specifics like this can be added by passing behaviours or strategies to the animal, in our example, for movement.

class Animal(object):
  def __init__(self, sound: str = None, movement: Movement = Movement()) -> None:
    super().__init__()
    self.__movement = movement
    self.position = (0,0)
    self.turns = 0
  # ... other code

  def move_to(self, direction: tuple):
    # use the provided strategy to execute the movement
    (position, turns) = self.__movement.move_to(self.position, direction)
    # and store the new position in the state
    self.position = position
    # also store how many turns the movement cost
    self.turns = turns

class Cat(Animal):
  def __init__(self) -> None:
    # By providing a WalkingStrategy, we override the default movement method
    super().__init__("miau!", WalkingStrategy())

class Bird(Animal):
  def __init__(self) -> None:
    # By providing a FlyingStrategy, we override the default movement method
    super().__init__("chirp", FlyingStrategy())

class Composition(unittest.TestCase):
  def test_movement(self):
    cat=Cat()
    cat.move_to((1,1))
    cat.move_to((2,2))
    self.assertEqual(cat.position, (3, 3))
    self.assertEqual(cat.turns, 6)
    bird=Bird()
    bird.move_to((1,1))
    bird.move_to((2,2))
    self.assertEqual(bird.position, (3, 3))
    # bird needs fewer turns because it can use the direct connection
    self.assertEqual(bird.turns, 3)

This allows us to extend the behaviour of animals without modifying them. It is called the open-closed principle.

A quick primer on functional programming

While in OOP, data and behaviour is brought together, in functional programming, they are separated. The behaviour is put into pure functions. Our animals would look very different in a functional realm indeed.

def get_noise(animal: tuple):
  (voice,_)=animal
  return voice

cat=("miau!", (0,0))
dog=("woff!", (0,0))
bird=("chirp", (0,0))

class FP(unittest.TestCase):
  def test_noises(self):
    self.assertEqual(make_noise(cat), "miau!")
    self.assertEqual(make_noise(dog), "woff!")

While in OOP, we create an object for every cat, we only make a tuple (a data structure holding the data) in FP. Thus, the make_noise function is redundant as long as it doesn’t do more than returning the string, but it gets more interesting when we start with the movement. Remember again what we did with the OOP world - we modified the cat’s position when moving it. In FP realms, our approach is different - we create a pure function that produces new data, which it then returns.

def walk_to(animal: tuple, direction: tuple):
  return (
    get_noise(animal),
    position_add(animal, direction)
  )

The difference can be best seen if we compare the tests:

class CatMovement(unittest.TestCase):
  def test_movement(self):
    self.assertEqual(walk_to(walk_to(cat, (1,1)), (0,2)), ("miau!", (1, 3)))
    # in the FP world, the cat still has the old state
    self.assertEqual(cat, ("miau!", (0, 0)))

    cat=Cat()
    cat.move_to((1,1))
    cat.move_to((0,2))
    # in the OOP world, the cat has the new state
    self.assertEqual(cat.position, (1, 3))

Also, in the OOP world, you can implement the cat where a move_to function would return a new cat. However, in traditional OOP languages, this might be more tricky because to clone the cat, the Animal object needs the information about the child object type in the move_to call to clone it correctly. OOP thus does not enforce the immutability of the objects, nor is it always wanted by the person implementing it.

This is one of the differences between FP and OOP - while in the former, you work with immutable data, in the latter, you can choose if you want to use immutable or mutable data structures, with mutable data structures being the easier choice in most scenarios.

With this quick introduction, we will dive deeper into some of the constructs we have already seen and compare the different approaches in more detail.

The curious case of mutability

Neither mutability nor immutability is wrong. Mutability comes with the tradeoff of side-effects (which we will discuss in a little). In contrast, sometimes immutability can come with a performance penalty, for instance, when working with terabytes of data or in quicksort, thus reallocating memory over and over again. In other cases, however, immutable data structures might even outperformance mutable ones as data creation can be cheaper. We will look into that later in more detail. Of course, it always depends on the specialities of your case. But what does it mean in our average scenario?

For this little exercise, we will create a little Todo-List Service. The service will manage our Todo-Items, and allow us to perform certain operations on them. For example, our Todo item has a name and a flag that marks it as done, and our first operation is to remove the ones that are done.

Object Oriented Todo

Let’s start with the OOP code. As always, the first thing we define is the classes and the behaviours we need. The Todo looks as such:

class Todo(object):
  name: str
  done: bool = False

  def __init__(self, name: str, done: bool = False) -> None:
    super().__init__()
    self.name = name
    self.done = done

  def __eq__(self, other):
    if (isinstance(other, Todo)):
      return self.name == other.name and self.done == other.done
    return False

This will allow us to create a new todo via Todo("open todo") and Todo("done todo", False), while the __eq__ method will allow us to compare the todo’s by name and status, so that Todo("open todo") == Todo("open todo").

The next thing to create is our list of todos, which in OOP is again a class:

class Todos(object):
  _list: List[Todo]

  def __init__(self, todos: List[Todo] = []) -> None:
    super().__init__()
    self._list = todos

  def get_todos(self):
    return self._list

We hide the list in a private variable _list to control adding/removing items. It’s the core idea of encapsulation - Independent of the mess I create, it’s my mess, and I have it contained. You stay out of it. We still want to provide a way to access the todo items, so we expose them via the function get_todos.

Our first requirement is to show only open todos, and we write a little test for that:

class TodoList(unittest.TestCase):
  def test_only_open(self):
    todos=Todos([Todo("first"), Todo("second", True), Todo("Third")])
    todos.to_only_open()

    self.assertEqual(todos.get_todos(), [Todo("first"), Todo("Third")])

The test is red because we didn’t implement the function yet, so let’s do that:

class Todos(object):
  def to_only_open(self):
    for todo in reversed(self._list):   # for each todo item in list
      if todo.done:                     # if it's marked as done
        self._list.remove(todo)         # then remove it
    return self

The test is green, but mutability might play evil tricks with us now behind the scenes. So let’s create a second test that shows the issue.

class TodoList(unittest.TestCase):
  def test_only_open_with_sideeffect(self):
    # Given we have todos
    todos=Todos([
      Todo("first"), 
      Todo("second", True), 
      Todo("Third")]
    )
    # And a second list with the same todos
    other_todos_with_the_same_todos=Todos(todos.get_todos())

    # When we reduce the second list to the open todos only
    other_todos_with_the_same_todos.to_only_open()

    # Then we get a list without any done todos
    self.assertEqual(other_todos_with_the_same_todos.get_todos(), [
      Todo("first"), 
      Todo("Third")]
    )

    # And the other todolist should be untouched
    #   but this fails, because both had a reference to 
    #   the same mutable list 
    self.assertEqual(todos.get_todos(), [
      Todo("first"), 
      Todo("second", True), # this one won't be there
      Todo("Third")]
    )

As you can see, we accidentally changed the list of the original instance. In this case, it’s an implementation issue; in other scenarios, this might be an issue in a multithreaded environment where multiple threads change the same mutable instances.

We can (OOP does allow you to be more immutable, remember) fix this, but the next issue will be waiting for us around the corner. Our change is in the get_todos function, where we have to modify our code to this:

class Todos(object):
    def get_todos(self):
-        return self._list
+        return list(self._list)

Now the test passes. But we can make it red again quickly with a new line:

class TodoList(unittest.TestCase):
  def test_only_open_with_sideeffect(self):
    # Given we have todos
    todos=Todos([
      Todo("first"), 
      Todo("second", True), 
      Todo("Third")]
    )
    # And a second list with the same todos
    other_todos_with_the_same_todos=Todos(todos.get_todos())

    # When we reduce the second list to the open todos only
    other_todos_with_the_same_todos.to_only_open()

    # Then we get a list without any done todos
    self.assertEqual(other_todos_with_the_same_todos.get_todos(), [
      Todo("first"), 
      Todo("Third")]
    )

    # this passes now
    self.assertEqual(todos.get_todos(), [
      Todo("first"), 
      Todo("second", True), 
      Todo("Third")]
    )

    # When we change an item on the other todo list
    other_todos.get_todos()[0].name = "changed it"

    # then it shouldn't change the original list
    #  but it does, because in the line above, we changed 
    #  a shared mutable reference.
    self.assertEqual(todos.get_todos(), [
      Todo("first"), # this one will be called "changed it"
      Todo("second", True), 
      Todo("Third")]
    )

This is even harder to see, and the issue is that we have a shared reference to the same Todo in the two different lists. Thus, it’s not enough to create a new list; we also have to recreate every single Todo item:

class Todos(object):
    def get_todos(self):
-        return self._list
+        return list(
+            map(lambda todo: Todo(todo.name, todo.done), self._list)
+        )

Coincidentally, with those two changes, we have enriched our OOP with an important construct from functional programming: Higher Order Functions. map is a function that takes another function as an argument and applies it to every list element. Indeed, we can also implement this in a nonfunctional way like so:

def get_todos(self):
    result=[]
    for todo in self._list:
        result.append(Todo(todo.name, todo.done))
    return result

However, the map function is a well-established function in most programming languages now, and it allows you to write the entire transformation in a single line. So, as FP is starting to show off, let’s look into how they would do it.

Functional Todo

In Python, the data structure I usually use for functional programming is a tuple. This type is immutable by default, and the creation of it is very performant. To make it more accessible by consumers, I often use a namedtuple, which allows me to create named properties, and a pure function to create it that looks like this:

from collections import namedtuple
Todo = namedtuple("Todo", "name done")

def todo(name: str, done: bool = False):
  return Todo(name, done)

This is the equivalent of the Todo Object we defined in the OOP realms, which also has the same equality build in already. The significant difference is that this todo is immutable. Doing this:

item = todo("hello")
item.name = "changed!"

will throw an exception with the message AttributeError: can't set attribute.

Unlike in the OOP world, this time, we will not create a container for the list but just put every todo into another tuple. Furthermore, our pure functions will operate on tuples and return tuples; thus, the data structure is guaranteed to be immutable. Also, the allocation of tuples is fast (faster than Object instantiation, as we will see in a little). Our assumption is thus that the new list is different from the old, and our test looks like this:

class TodoList(unittest.TestCase):
  def test_functional(self):
    list=(todo("first"), todo("second", True), todo("Third"))
    filtered=only_open(list)

    # the original list should not have been modified by the call
    self.assertNotEqual(list, filtered)
    # the new list should only contain the open items
    self.assertEqual(filtered, (todo("first"), todo("Third")))

Let’s take a look at our implementation:

def only_open(todos: Tuple[Todo]):
    return tuple(
        filter(lambda todo: not todo.done, todos)
    )

It’s straightforward - filter will iterate over all todos, remove the once not done, and return a new tuple with immutable tuples. The test is green.

A quick look into the performance

People often wonder how the performance is impacted by immutability and if they should instead use mutable data. Let’s measure.

class Performance(unittest.TestCase):
  def setUp(self):
    self.startTime = time.time()

  def tearDown(self):
    t = time.time() - self.startTime
    print('%s: %.3f' % (self.id(), t))

  def test_functional(self):
    for _ in range(1, 2000000):
      list=(todo("first"), todo("second", True), todo("Third"))
      filtered=remove_done(list)

      self.assertNotEqual(list, filtered)
      self.assertEqual(filtered, (todo("first"), todo("Third")))

  def test_classy(self):
    for _ in range(1, 2000000):
      todos=Todos([Todo("first"), Todo("second", True), Todo("Third")])
      todos.to_only_open()

      self.assertEqual(todos.get_todos(), [Todo("first"), Todo("Third")])

With the implementations from above, the results are pretty consistent:

tests.test_oopfp.Performance.test_classy: 10.242
tests.test_oopfp.Performance.test_functional: 6.511

Suppose I change this to recursion with billions of entries and huge data maps that the OOP can hold as reference. In that case, the advantage for functional will disappear to the point where object references are faster; unless you start using the power of immutability to enter the complexities of asynchronous programming. But that’s a topic for another post.

For typical use cases like the one shared here, immutability will provide you with a bit of performance boost. That boost, however, will only be there if your language provides you with a type optimized for this scenario. So, for example, there wouldn’t have been many gains if we hadn’t used Python’s tuple but a list or dictionary.

With every choice come specific implications; know and understand them well, and you will find the correct answer for your problem.

Composition in the two worlds

Composition plays a significant role in both worlds. It’s the art of creating your software as Lego, which you can connect to make a bigger whole. The result, however, is different in both of them for their difference in handling data. We’ll take a look into the details, as the new requirements of getting only important todos, getting only the once not in another todo list, splitting them up based on criteria, and combining all of that.

Composition in the OOP world

The OOP World follows the paradigm to put Composition over Inheritance. And also Open for extension, closed for modification. But what does that mean?

Think about our Todos class and our requirements. We don’t want to modify the base class every time we change one of the filters or improve them. An inheritance-based approach would lead to classes similar to those used in the following test:

class Composition(unittest.TestCase):
  def test_classy_inheritance(self):
    # Given we start with todos
    todos=Todos([
      Todo("first important"), 
      Todo("second important", True), 
      Todo("important Third"), 
      Todo("Third"), 
      Todo("fourth"), 
      Todo("Important!")]
    )

    # When getting the important todos that are still open
    result=OpenOnlyTodos(ImportantTodos(todos.get_todos()).get_todos())

    # then we get the expected list of todos
    self.assertEqual(result.get_todos(), [
      Todo("first important"), 
      Todo("important Third"), 
      Todo("Important!")]
    )

    # And when using this list to get the difference
    result=DifferenceTodos(result.get_todos())

    # then we get the expected list
    self.assertEqual(result.get_todos(todos), [
      Todo("second important", True), 
      Todo("Third"), 
      Todo("fourth")]
    )

Each class overloads the get_todos function and returns their view on the underlying results; for instance, the ImportantTodos would look like the following:

class ImportantTodos(Todos):
  def __init__(self, todos: List[Todo] = []) -> None:
    super().__init__(todos)

  def get_todos(self):
    result = []
    for todo in self._list:
      if "important" in todo.name.lower():
        result.append(todo)
    return result

Tying the implementation to the class, however, has inevitable tradeoffs. The filtering of the todos, for instance, can only be used in a specific context. Once you implement it for Todos, you cannot reuse it for a wishlist. Inheritance, by design, is meant for subclassing and not for code reuse. Trying to achieve the latter can lead to side-effects and broken tests/production instances whenever you try to change the reused code.

This is why, in OOP, you should do this via Composition. Rather than inheriting from Todos, with the todolist being the filter, the todolist has a filter. This is again a generic base class Filter that has inherited specialized children. A helper function and_filter_with allows us to chain filters and apply them in every order that we want.

class Filter(object):
  def get(self, items):
    return list(items)

class ImportantFilter(object):
  def get(self, items):
    items=list(items)
    for todo in reversed(items):
      if not "important" in todo.name.lower():
        items.remove(todo)
    return items

class Todos(object):
  _list: List[Todo]

  def __init__(self, todos: List[Todo] = [], filter: Filter = Filter()) -> None:
    super().__init__()
    self._list = todos
    self._filter = filter

  def and_filter_with(self, filter: Filter):
    return Todos(self.get_todos(), filter)

  # ... other code

class Composition(unittest.TestCase):
  def test_classy_composition(self):
    # Given we start with a todo with an empty filter
    todos=Todos([
      Todo("first important"), 
      Todo("second important", True),
      Todo("important Third"), 
      Todo("Third"), 
      Todo("fourth"), 
      Todo("Important!")],
      Filter()
    )

    # when getting the open and important todos
    result=todos.and_filter_with(ImportantFilter()).and_filter_with(OpenOnlyFilter())

    # then we get the expected list of todos
    self.assertEqual(
      result.get_todos(),
      [
        Todo("first important"),
        Todo("important Third"), 
        Todo("Important!")
      ]
    )

    # when using this list to get the either open or not important todos
    result=result.and_filter_with(DifferenceFilter(todos))

    # then we get the expected list
    self.assertEqual(result.get_todos(), [
      Todo("second important", True), 
      Todo("Third"), 
      Todo("fourth")]
    )

Thanks to operator overloading in Python, we can add some syntactic sugar to it by creating the function __add__, which is calling and_filter_with and write:

class Composition(unittest.TestCase):
  def test_classy_operator_composition(self):
    # Given we start with a todo with an empty filter
    todos=Todos([
      Todo("first important"), 
      Todo("second important", True),
      Todo("important Third"), 
      Todo("Third"), 
      Todo("fourth"), 
      Todo("Important!")],
      Filter()
    )

    # when getting the either open or not important todos
    result=todos + ImportantFilter() + OpenOnlyFilter() + DifferenceFilter(todos)

    # then we get the expected list
    self.assertEqual(result.get_todos(), [
      Todo("second important", True), 
      Todo("Third"), 
      Todo("fourth")]
    )

Now, this is not how you would usually create OOP code; I only tried to make the way composition worked in OOP more explicit. In my perception, composition in OOP is about adding behaviour to an object, returning the object. So, for example, in pseudo-code, this looks like:

Object<A> + Behaviour<B> = Object<A> // but with different state or behaviour

A more common example would be a Service class that receives a Repository class via dependency injection to access an API or Database.

ServiceClass + ExternalApiRepositioryClass + DataMapping = 
  ServiceClass(with ability to call the external api and map it to internal domain)

The service class would thus look something like

class Service(object):
  def __init__(self, repository, mapper):
    self._repository = repository
    self._mapper = mapper

  def get_data(self, id):
    data=self._repository.get(id)
    return self._mapper.map(data)

As we will see, for functional programming, this is different.

Composition in the FP world

We have already implemented our only_open pure function; let’s look at the next. The first thing that we will add is the only_important filtering we already know from OOP. We start with the test:

class Composition(unittest.TestCase):
  def test_functional_composition(self):
    # given I have a list of todos
    list=(
      todo("first important"), 
      todo("second important", True), 
      todo("important Third"), 
      todo("Third"), 
      todo("fourth"), 
      todo("Important!")
    )

    # when getting only the open and important todos
    filtered=important(only_open(list))

    # then we get the expected list
    self.assertEqual(filtered, (
      todo("first important"), 
      todo("important Third"), 
      todo("Important!"))
    )

As we can see, this is going to be another pure function. It is immaculate, and as FP purists like to point out, no overhead of instantiating classes over and over again. However, as we can already see, this would develop into a lot of brackets. The other issue is that to understand that we are taking a list, remove the items done and then use only the important, we would have to read it from inner right to outer left. That’s not the natural way of reading code, and with more pure functions, that would worsen. In most functional languages, an operator exists that allows you to turn this around. One example is the ->> operator in Clojure. For this showcase, I chose the | operator. I created an annotation called pipe that I add to my functions like so:

def pipe(original):
  class Pipe(object):
    data = {'function': original}

    def __ror__(self, other):
      return self.data['function'](
          other
      )

  return Pipe

@pipe
def only_open(todos: Tuple[Todo]):
  return tuple(filter(lambda todo: not todo.done, todos))

@pipe
def important(todos: Tuple[Todo]):
  return tuple(filter(lambda todo: "important" in todo.name.lower(), todos))

As you can see, the functions don’t change. But the way I call them does.

class Composition(unittest.TestCase):
  def test_functional_composition(self):
    # given I have a list of todos
    list=(
      todo("first important"), 
      todo("second important", True), 
      todo("important Third"), 
      todo("Third"), 
      todo("fourth"), 
      todo("Important!")
    )

    # when getting only the open and important todos
    # filtered=important(only_open(list))
    filtered = list | only_open() | important()

    # then we get the expected list
    self.assertEqual(filtered, (
      todo("first important"), 
      todo("important Third"), 
      todo("Important!"))
    )

    # and we can continue filtering
    filtered = filtered | difference(list)
    self.assertEqual(filtered, (
      todo("second important", True), 
      todo("Third"), 
      todo("fourth"))
    )

Now we can read the code in the order we would read a sentence.

However, the considerable advantage of the pure functions comes as we continue growing this query, as the pure functions can call other pure functions. Let’s take an example that would be slightly harder in OOP, splitting up the open items in a todo list into two lists based on if they are important. Sounds difficult? Let’s take a look at the final test:

  def test_functional_composition(self):
    # given I have a list of todos
    list=(
      todo("first important"), 
      todo("second important", True), 
      todo("important Third"), 
      todo("Third"), 
      todo("fourth"), 
      todo("Important!")
    )

    # when taking only the open and splitting them by importance
    (important_list, not_important_list) = list | only_open() | split(important)

    # then I will get the expected lists 
    self.assertEqual(important_list, (
      todo("first important"), 
      todo("important Third"), 
      todo("Important!"))
    )
    self.assertEqual(not_important_list, (
      todo("Third"), 
      todo("fourth"))
    )

That was probably less code than expected. But, then indeed, the split method must feature long lines of complexity, doesn’t it? Well, not really. Let’s take a look:

@pipe
def split(todos: Tuple[Todo], *args):
  (condition_to_split_on,) = args
  return (
      todos | condition_to_split_on(),                      # Get the positive matches
      todos | condition_to_split_on() | difference(todos)   # Get the negative matches
  )

It just continues the piping we already did before, and given it’s a higher-order function, it simply calls the function passed to it. This brings me to the way I perceive functional programming. Rather than adding behaviour to the state of an object, it applies a transformation on data, returning different data. The formula in pseudo-code is, therefore, like you know it from bash:

data | transform = new_data

To come back to our service/repository example from OOP, creating the flow from before would look very different in an FP world. Let’s compare the two pseudo-code descriptions:

OOP

ServiceClass + ExternalApiRepositioryClass + DataMapping = 
  ServiceClass(with ability to call the external api and map it to internal domain)

FP

id | get("http://example.com/api/") | map(lambda x: x.title) =
  data

The difference, I hope, is more evident now: Whereas in OOP, we create an object that encapsulates the details of what’s happening, in FP, we focus on making those details explicit and apply them to data.

What to choose then?

Ah, the big question! Either, or - is it? No, not really. Functional programming has the considerable advantage to be clear and linear, and, well, functional. But, unfortunately, the reality is not always like that, and that’s where object-oriented gets to shine. Those itsy-gritty parts that you want to put into a hierarchical order, to have it structured in a way that makes it easy to explain the high-level architecture of your system to new joiners on the teams.

Thus, when asked what to prefer, my answer will be “a mix”. That’s not a very comforting thought, and I guess it means you have to learn and master both. But only by combining FP and OOP will you be able to create great software that scales while writing it in an easy way for everyone to follow your code. An important thing to be aware of is that not every language supports this mix well; some may make it hard for you to keep a strong separation of data and functions. Others will not provide you with structures to create an object-oriented system. While you might be able to use features (like higher-order functions in OOP languages), using the pure approach would lead to code that will be hard to understand or grasp and might throw the team off the desired outcome. An example could be the Java code that someone tried to make more functional by creating a dozen static functions.

The other thing to consider is team preferences. For example, if you have a lot of people with strong OOP backgrounds, it will be easier for your team to create robust object-oriented code than a functional one and the other way around. Also, there seems to be a difference in how well different people can understand the different models. For some developers, the functional approach feels true and correct; for others, having everything in an object is a natural choice.

Now, what will be for you? Object-oriented, functional programming, both?