Programming paradigms in Python
Better Code, Better Science: Chapter 2, bonus section
This is a possible section from the open-source living textbook Better Code, Better Science, which is being released in sections on Substack. The entire book can be accessed here and the Github repository is here. This material is released under CC-BY-NC-ND.
This week I am posting a number of bonus sections, which are sections that were written after the earlier chapters had been posted.
Historically there have been three primary approaches (or paradigms) for writing computer programs: Procedural programming, functional programming, and object-oriented programming. Although each paradigm has often been primarily associated with specific languages, Python supports all three, as I will show in examples below. Because object-oriented programming is widely used but unfamiliar to many Python coders, I will focus on introducing it in more detail.
2.3.1 Procedural Programming
Procedural programming is the approach that most programmers learn first. It organizes the program around procedures (such as functions in Python), which perform operations on data and either return new data or change the state of shared variables. These are generally performed sequentially, with the flow of control specified by operations such as selection operators (like if/else or match/case statements in Python) and iteration operators (like for or while statements).
As a running example across all three paradigms, I will generate code to compute the running mean of a stream of numbers. Here is a version written using procedural programming:
def proc_new() -> dict[str, float]:
return {"count": 0, "total": 0.0}
def proc_add(acc: dict[str, float], value: float) -> None:
acc["count"] += 1
acc["total"] += value
def proc_mean(acc: dict[str, float]) -> float:
return acc["total"] / acc["count"]
def compute_procedural_result(data: list[float]) -> float:
acc = proc_new()
for value in data:
proc_add(acc, value)
return proc_mean(acc)Here we see a couple of the defining features of procedural programming. First, the data are represented separately from the procedures that are applied to them. The data and acc data structures are separate objects that are passed into the relevant functions. Second, the data objects are mutable, meaning that they can be changed by any function that has access to them. We can see this in the action of the proc_add() function, which directly modifies the contents of the accumulator acc; note that proc_add() doesn’t actually return anything.
Running this produces the following output:
>>> from bettercode.three_paradigms import compute_procedural_result
>>> compute_procedural_result([10.0, 12.0, 14.0, 11.0, 13.0])
12.02.3.2 Functional Programming
A second approach is known as functional programming. It isn’t the presence of functions that defines functional programming (since they are important in each of the approaches); rather, it’s the way that they are used. A primary goal of functional programming is to eliminate hidden state and side effects, where the execution of a function changes the program’s state in a non-transparent way. Perhaps the biggest difference between functional programming and other paradigms is that variables are immutable: That is, once they are created, they aren’t actually variable any more! Functional programming has a reputation for being difficult to learn. In part this is because most people learn structured programming first, and thus have to unlearn features like mutable variables. In addition, the functional programming community has a tendency to use mathematical terms (like “monad” and “endofunctor”) to name concepts, making them seem especially mysterious. Here is an implementation of the running mean using a functional programming approach in Python:
from functools import reduce
State = tuple[int, float] # (count, total)
def func_add(state: State, value: float) -> State:
count, total = state
return (count + 1, total + value)
def func_mean(state: State) -> float:
count, total = state
return total / count
def compute_functional_result(data: list[float]) -> float:
final_state = reduce(func_add, data, (0, 0.0))
return func_mean(final_state)There are a couple of important differences to point out here. First note that the functions don’t change the input data; for example, rather than modifying the state object that is passed into func_add(), the function instead uses the values within the input object to create a new object that is returned. In fact, it wouldn’t be possible for the function to change the data, since it is represented as a tuple which is immutable. While some functional programming languages (such as Haskell) strictly enforce immutability, Python doesn’t have a mechanism to enforce it by default, so it’s up to the programmer to build it in, as we did here by using an immutable data type.
The main difference is the use of the reduce function, which is one of the most important tools for functional programming in Python. This operator takes three arguments as input: a function to be applied, data to be supplied to the function, and a starting value for the input. It then cumulatively applies the function across all of the data points and returns the final value. Running this produces the following output:
>>> from bettercode.three_paradigms import compute_functional_result
>>> compute_functional_result([10.0, 12.0, 14.0, 11.0, 13.0])
12.0There are some Python packages (notably the JAX package for machine learning) that make heavy use of functional programming constructs, and there is a package called Toolz that provides access to functional programming tools for general-purpose use. However, most Python users avoid functional programming in general due to the fact that functional code is usually less readable for programmers who were trained to write procedural or object-oriented Python code.
2.3.3 Object-oriented Programming
The third approach is object-oriented programming, which focuses on defining conceptual bundles (called classes in Python) that coherently combine data structures and functions that can be applied to them (referred to as methods). Because object-oriented programming is commonly used in Python but may not be familiar to many Python coders, I will provide a more detailed outline of it here. Here is a simple solution of the running average problem using object-oriented programming:
class RunningMean:
def __init__(self) -> None:
self.count = 0
self.total = 0.0
def add(self, value: float) -> None:
self.count += 1
self.total += value
@property
def mean(self) -> float:
return self.total / self.count
def compute_oop_result(data: list[float]) -> float:
running = RunningMean()
for value in data:
running.add(value)
return running.meanWe first define a class (RunningMean) that includes both the data and the functions needed to perform the computation. Within the function compute_oop_result(), the first thing that we do is to create an instance of the class (called running). After that, we loop through and add each of the data values using the running.add() method. The current state is stored in the instance variables, which are referred to as self.count and self.total within the class definition. Running this produces the following output, matching the other versions above:
>>> from bettercode.three_paradigms import compute_oop_result
>>> compute_oop_result([10.0, 12.0, 14.0, 11.0, 13.0])
12.0A main goal of object-oriented programming is encapsulation, such that the internal details (such as the data and internal methods) are not accessible to the outside world, which only has access to a set of methods that are publicly exposed by the class. This helps reduce coupling, referring to the degree to which different objects are dependent upon one another. As we will see in the next chapter, reducing coupling is an important way to reduce complexity of the code, which is a primary goal of software design.
It’s worth noting that while some object-oriented languages explicitly prevent access to the instance variables of a class, Python does not, as we can see by accessing the total instance variable from the class instance:
>>> from bettercode.three_paradigms import RunningMean
>>> running = RunningMean()
>>> for value in data:
... running.add(value)
>>> running.total
60.0The idiom in Python class definitions is to use an underscore prefix for the names of instance variables that are not meant to be accessed from outside. For example, instead of using self.total, we would use self._total if we didn’t mean it to be accessed from the outside. However, Python doesn’t actually enforce the privacy of these variables; they are simply meant as a signal to users that the variable is an implementation detail that is not meant to be accessed from outside of the class.
Finally, note that the mean() method is preceded by a @property decorator. This means that while it is defined in the class as a method (in the sense that it returns a value when called), it is treated from the outside as a instance variable, which is why compute_oop_result returns running.mean rather than running.mean(). This is useful because it ensures that the mean value that is returned will always be up to date, since it is computed on the fly when requested.
2.3.3.1 Inheritance and composition
A general feature of object-oriented programming is inheritance, in which a class representing a more specific form of an object (e.g. a cat) can inherit the features of a more general version of the object (e.g. an animal). In the following example, we create an Animal class, and then a Cat class that inherits the Animal class.
class Animal:
def __init__(self, name: str):
self.name = name
self.fed = False
def describe(self) -> str:
return f"{self.name} is an animal"
def feed(self) -> None:
self.fed = True
class Cat(Animal):
def describe(self) -> str:
return super().describe() + ", specifically a cat"
def speak(self) -> str:The Cat class has all of the features of the Animal class (particularly the .describe() and .feed() methods), as well as having the .speak() method defined specifically within the Cat class. In addition, Cat has its own definition of the .describe() method that overrides the version in the parent class (which is referred to as super() within the Cat.describe() definition). Here we see how these two classes work:
>>> creature = Animal("unknown")
>>> creature.describe()
'unknown is an animal'
>>> felix = Cat("Felix")
>>> felix.describe()
'Felix is an animal, specifically a cat'
>>> felix.speak()
'Meow'
>>> felix.fed
False
>>> felix.feed()
>>> felix.fed
TrueInheritance reflects an is-a relationship, in which the subclass is a kind of the class (e.g. a cat is a kind of animal). It is considered a form of white-box reuse (Gamma 1995), in the sense that the children need to know the internal details of the parent implementation. In this sense, inheritance tends to result in increased coupling between classes.
There are other cases when one class has parts that can also be defined as classes (a has-a relationship); when a class calls other classes to implement its parts, we refer to this as composition. For example, we can build a Summarizer class to summarize a list of numbers that has two components: one for cleaning the data (either keeping all values or removing negative values) and one for computing the summary (either using the mean or the maximum). The specific implementation of each of the components is implemented as a separate classs:
import statistics
# --- independent "cleaner" components ---
class KeepAll:
def clean(self, values):
return values
class DropNegatives:
def clean(self, values):
return [v for v in values if v >= 0]
# --- independent "statistic" components ---
class Mean:
def compute(self, values):
return statistics.mean(values)
class Maximum:
def compute(self, values):
return max(values)
class Summarizer:
def __init__(self, cleaner, statistic):
self.cleaner = cleaner
self.statistic = statistic
def run(self, values):
cleaned = self.cleaner.clean(values)
return self.statistic.compute(cleaned)We can create an instance of the summarizer and then apply it to some data:
>>> s = Summarizer(KeepAll(), Mean())
>>> data = [3, 5, 1, -4, 2]
>>> s.run(data)
1.4
>>> s.statistic.compute(data)
1.4
>>> s_pos = Summarizer(DropNegatives(), Mean())
>>> s.run(data)
1.4
>>> s_pos.run(data)
2.75What’s particularly useful here is that the top-level class (Summarizer) doesn’t need to know anything about which classes might be used as cleaners or statistics or any details about their internal implementations; any class that has the right interface (a .clean() method for cleaners and a .compute() method for statistics) can be used.
Inheritance and composition both have their place, depending on the kind of relationships that are inherent between objects. However, inheritance does tend to result in more coupling and thus more complexity, since any changes to a top-level class have the potential to affect any class that inherits it. Composition, on the other hand, tends to reduce coupling. It is for this reason that (Gamma 1995) proposed the following as one of their principles of object-oriented programming: “Favor object composition over class inheritance.”
