Python: To OOP or to FP?

That is the question

Photo by Susan Holt Simpson on Unsplash

Programmers can never agree on anything, but by far one of the biggest arguments that constantly plagues the interwebs is the battle between object-oriented programming (OOP) and functional programming (FP).

As a reminder, OOP revolves around wrapping all your business logic and data in classes, which can then create objects that share the same functionality. It also includes concepts such as inheritance and polymorphism, which make it easier to have classes with similar, but slightly altered functionality.

The language usually used to demonstrate OOP is Java. In Java, everything must be wrapped in a class, including your program’s main execution loop.

Functional programming, on the other hand, is more concerned with — you guessed it — functions. In functional programming, data is often piped from function to function, each performing an operation on the data. Functions are often designed to produce the exact same output if given the same input.

The most popular functional programming languages are Clojure, Elixir and Haskell.

But what about Python?

Python is an interesting case. It shares a lot of the common features of object-oriented languages, allowing you to create classes and inherit from superclasses, but it also has functionality that you’d normally see in functional languages. You’re allowed to define functions in the main body of the program, and functions are also first-class citizens, meaning you can pass them about as objects.

The truth is, Python is very flexible. If you’re coming from Java and want to write everything in a purely object-oriented style, you’ll be able to accomplish most of the things you want. If you’re a previous Clojure developer, you won’t have too much trouble replicating FP patterns in Python either.

However, the beauty of Python is that you’re not restricted to either way of doing things. You can use features of both paradigms to create readable, extensible code that will keep your codebase maintainable, even as it grows.

Below are three examples of the same (very simple) program written in OOP, FP and a more Pythonic mixture of both. I’ll highlight the strengths and weaknesses of each one, which should give you a good base when architecting your next Python project.

The program

The program used to demonstrate is very simple — it creates two animals (a dog and a fish) and has them perform some very simple actions. In this example, the actions just log to stdout, but they could obviously do a lot more.

OOP example

from abc import ABC, abstractmethod

class Logger(ABC):
    @abstractmethod
    def log(self, message: str):
        ...

class MyLogger(Logger):
    def __init__(self, name: str):
        self.name = name

    def log(self, message: str):
        print(f'{self.name}: {message}')

class Animal:
    def __init__(self, name: str, logger: Logger):
        self.name = name
        self.logger = logger

    def speak(self):
        self.logger.log('Speaking')
        ...

class Dog(Animal):
    def speak(self):
        self.logger.log('Woof!')
        ...

    def run(self):
        self.logger.log('Running')
        ...

class Fish(Animal):
    ...

class App:
    @staticmethod
    def run():
        fido = Dog(name='Fido', logger=MyLogger('Fido'))
        goldie = Fish(name='Goldie', logger=MyLogger('Goldie'))

        fido.speak()
        fido.run()
        goldie.speak()

if __name__ == '__main__':
    App.run()

# Fido: Woof!
# Fido: Running
# Goldie: Speaking

As you can see, the code creates a MyLogger class for logging events to stdout , an Animal base class and then Dog and Fish classes for more specific animals.

To follow the OOP paradigm a little more closely, it also defines an App class with a single method run that runs the program.

The nice thing about OOP and inheritance is that we didn’t have to define a speak method on the Fish class and it’ll still be able to speak.

However, if we wanted to have more animals that could run, we’d have to introduce a RunningAnimal class between Animal and Dog that defines the run method, and potentially a similar SwimmingAnimal class for Fish , but then our hierarchies start getting more and more complicated.

Also, the MyLogger and App classes are pretty much useless here. Each does only one thing and actually makes the code slightly less readable. These would be better pulled out into a log and a main (or run) function.

We’ve also had to create a Logger abstract base class purely so that the code can be properly type hinted and allow users of our API to pass in other loggers if they want to log to somewhere other than stdout, or if they wanted to log with a different format.

FP example

Just a head’s up — I’m less familiar with FP than OOP, so this might not be the most FP-like way to implement this behaviour, but it’s what I’m going with.

import functools
from typing import Callable

Logger = Callable[[str], None]

def log(message: str, name: str):
    print(f'{name}: {message}')

def bark( name: str,
    log_fn: Logger,) -> (str, Logger):
    log_fn('Woof!')
    return name, log_fn

def run( name: str,
    log_fn: Logger,) -> (str, Logger):
    log_fn('Running')
    return name, log_fn

def speak( name: str,
    log_fn: Logger,) -> (str, Logger):
    log_fn('Speaking')
    return name, log_fn

def main():
    run(
        *bark(
            'Fido',
            functools.partial(log, name='Fido'),
        ),
    )

    speak(
        'Goldie',
        functools.partial(log, name='Goldie'),
    )

if __name__ == '__main__':
 main()

# Fido: Woof!
# Fido: Running
# Goldie: Speaking

Off the bat, we can see that our Logger class has just become a handy type alias for Callable[[str], None]. We’ve also defined a log function to deal with our printing. Instead of defining classes for our animals, we’ve simply defined functions that take in the name of an animal and a Logger function.

You’ll notice that the run, speak, and bark functions also all return their name and logging function arguments to allow them to be composed together into pipelines as we’ve done for run and bark for Fido.

We’ve also moved our logic into a main function, removing the need to define an entire class just to run our program.

To get around the fact that our log function doesn’t match the Logger type, we’re usingfunctools.partial to create a partial function that does match. This allows us to replace our logger with anything we like, as long as we can use a partial function to reduce it so that it matches our Logger type.

However, since we’re not encapsulating the data in anything, if we wanted to add more attributes to our animals, we’d probably have to start using dict objects to represent them and pass those around, but then there’s always a worry that the dictionary is created incorrectly, and thus is missing a key that’s relied on in one of our functions.

To get around that, we’d need to create initialiser functions for our animals, at which point the code gets messier and messier again.

A little bit of both

So, what would happen if we were to combine a bit of OOP with a bit of FP? I’m going to bring in a few more Pythonic bits and pieces to pull away from the traditional OOP and FP paradigms, and hopefully make the code a little cleaner and easier to read.

from dataclasses import dataclass
from functools import partial
from typing import Callable

Logger = Callable[[str], None]

def log(message: str, name: str):
    print(f'{name}: {message}')

@dataclass
class Animal:
    name: str
    log: Logger

    def speak(self):
        self.log('Speaking')

@dataclass
class Dog(Animal):
    breed: str = 'Labrador'

    def speak(self):
        self.log('Woof!')

    def run(self):
        self.log('Running')

@dataclass
class Fish(Animal):
    ...

def main():
    fido = Dog('Fido', partial(log, name='Fido'))
    goldie = Fish('Goldie', partial(log, name='Goldie'))

    fido.speak()
    fido.run()

    goldie.speak()

if __name__ == '__main__':
 main()

# Fido: Woof!
# Fido: Running
# Goldie: Speaking

In this example, I’m using Pythons dataclasses module to avoid having to write constructors for my classes. This not only reduces some of the code I need to write but also makes it a lot easier to add new attributes down the line if I need to.

Similar to the OOP example, we have an Animal base class with Dog and Fish subclasses. However, like in the FP example, I’m using the Logger type alias and functools.partial to create the loggers for the animals. This is made easier by Python’s support for functions as first-class citizens.

Also, the main function is just a function. I’ll never understand why Java is how Java is.

Mixing OOP and FP in production

Okay, so I’ll admit that this example was incredibly basic, and while it served as a good starting point for this discussion, I’d now like to give you an example of how these concepts are used in production, and I’m going to use two of my favourite Python libraries: FastAPI and Pydantic. FastAPI is a lightweight API framework for Python, and Pydantic is a data validation and settings management library.

I’m not going to go into these libraries in detail, but Pydantic effectively allows you to define data structures using Python classes, and then validate incoming data and access it via object attributes. This means you don’t run into the problems that stem from working with dictionaries, and you always know that your data is in the format you’d expect.

FastAPI allows you to define your API routes as functions, wrapping each one with a decorator (which is a very FP-like concept) to encapsulate your logic.

Below is an example of how this might be used. Again, it’s a simple example, but it’s fairly representative of what you might see in production.

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

class Baz(BaseModel):
    qux: int

class Foo(BaseModel):
    bar: str
    baz: Baz

@app.get('/foo')
async def get_foo(name: str, age: int) -> Foo:
    ...  # Some logic here
    return Foo(
        bar=name,
        baz=Baz(qux=age),
    )

# GET /foo?name=John&age=42
# {
#   "bar": "John",
#   "baz": {
#     "qux": 42
#   }
# }

As you can see, FastAPI uses Pydantic’s ability to convert nested objects to JSON to create a JSON response for our endpoint. The app.get decorator has also registered our get_foo function with the app object, allowing us to make GET requests to the /foo endpoint.

I hope you’ve found this article helpful. I’d love to hear what you think, and which paradigm you lean towards when writing Python.

Obviously, this isn’t the only way to combine FP and OOP in Python, and there are plenty of design patterns that can be implemented and improved upon using this sort of combination.

I’ll be writing about those in the future. I also tweet about Python and my current projects on Twitter, and (more recently) post about them on Mastodon too.

I’m sure I’ll see you soon!

- Isaac