8. Functional Programming OOP
By Bernd Klein. Last modified: 09 Mar 2024.
When working with higher-order functions, especially in the context of decorators, we often encounter the need to make inner states or objects of our function visible or accessible from the outside. In our decorator examples, we have already observed that by using closures, inner objects were created that we couldn't access externally. This was intentional or the desired effect in many cases. However, what if we want to expose these inner states externally? In this chapter, we want to discuss various ways to achieve this. Essentially, we can achieve this "window to the outside" through attribution or through complex return objects such as tuples or dictionaries. We have used both techniques before, but here we aim to refine this approach.
Example
The following call_counter
decorator tracks the number of calls to a function. This aids in performance analysis, optimization, or debugging by providing a simple mechanism to monitor and log function invocations, enabling developers to understand usage patterns and identify potential areas for improvement.
For the purpose of this chapter of our tutorial the inner function get_calls
is of special interest. The get_calls
function provides a convenient way to retrieve and access the count of function calls
externally. The get_calls function within the call_counter decorator
behaves similarly to a getter method in a class. It encapsulates the logic for accessing the value of the calls
variable, providing a way to retrieve this value from outside the decorator. This design pattern aligns with the principles of encapsulation and abstraction commonly used in object-oriented programming, where getter methods are used to access the internal state of an object while maintaining data integrity and hiding implementation details. In this case, get_calls
acts as a getter function for the calls
variable, providing a clean and controlled way to retrieve its value externally.
def call_counter(func):
calls = 0
def helper(*args, **kwargs):
nonlocal calls
calls += 1
return func(*args, **kwargs)
def get_calls():
return calls
helper.get_calls = get_calls
return helper
from random import randint
@call_counter
def f1(x):
return 3*x
@call_counter
def f2(x, y):
return x + 3*y
for i in range(randint(100, 3999)):
f1(i)
for i in range(randint(100, 3999)):
f2(i, 32)
print(f"{f1.get_calls()=}, {f2.get_calls()=}")
OUTPUT:
f1.get_calls()=691, f2.get_calls()=3622
Thus, we have only a read-only access to the counter from outside through the get_calls function. If, for any reason, one desires to allow external modification of the counter – even though it may not seem very sensible – this can be easily achieved with an additional function called set_calls.
We will go a step further in the following section and show how functional programming techniques can indeed be used to mimic aspects of class design. In Python, functions and closures can be leveraged to encapsulate state and behavior similar to how classes do.
Live Python training
Enjoying this page? We offer live Python training courses covering the content of this site.
Object Oriented Programming (OOP) and Functional Programming
Object-oriented programming (OOP) and functional programming (FP) are among the most widely used programming paradigms. OOP focuses on using objects to represent data and behavior, while FP aims to design programs by combining functions for data transformation. Higher-order functions are a powerful concept in functional programming. They allow the abstraction and composition of code by treating functions as "first-class citizens," enabling manipulation like any other data type. Purely syntactically, one might get the impression that both programming paradigms are entirely different.
But if you look at the following implementation, you might get the impression that somebody wanted to write a class and just made an error by writing def
in front of Person
instead of class
:
def Person(name, age):
def self():
return None
def get_name():
return name
def set_name(new_name):
nonlocal name
name = new_name
def get_age():
return age
def set_age(new_age):
nonlocal age
age = new_age
self.get_name = get_name
self.set_name = set_name
self.get_age = get_age
self.set_age = set_age
return self
# Create a person object
person = Person("Russel", 25)
print(person.get_name(), person.get_age())
person.set_name('Jane')
print(person.get_name(), person.get_age())
OUTPUT:
Russel 25 Jane 25
The above program defines a function Person that, in a sense, behaves like a class definition. One can instantiate "Person" objects using Person. Within Person, there is a function named person, serving as a container for other inner functions. In a proper class definition, these inner functions would be referred to as methods. Similar to a class, we have defined getters (get_name and get_age) and setters (set_name and set_age). The Person function creates a closure for the local variables name and age, preserving their state. Using nonlocal, inner functions can access them. To access inner functions externally, we attach them as attributes to self, returned by Person.
In the following program, we define a corresponding "proper" class definition:
class Person2():
def __init__(self, name, age):
self.set_name(name)
self.set_age(age)
def get_name(self):
return self.__name
def set_name(self, new_name):
self.__name = new_name
def get_age(self):
return self.__age
def set_age(self, new_age):
self.__age = new_age
# Create a Person2 object
person = Person2("Russel", 25)
print(person.get_name(), person.get_age())
person.set_name('Jane')
print(person.get_name(), person.get_age())
OUTPUT:
Russel 25 Jane 25
We extend our "functional class" function by adding a repr
and a equal
function (method in class terminology):
def Person(name, age):
def self():
return None
def get_name():
return name
def set_name(new_name):
nonlocal name
name = new_name
def get_age():
return age
def set_age(new_age):
nonlocal age
age = new_age
def repr():
return f"Person(name={name}, age={age})"
def equal(other):
nonlocal name, age
return name == other.get_name() and age == other.get_age()
self.get_name = get_name
self.set_name = set_name
self.get_age = get_age
self.set_age = set_age
self.repr = repr
self.equal= equal
return self
# Create a Person2 object
person = Person("Russel", 25)
print(person.get_name(), person.get_age())
person.set_name('Eve')
print(person.get_name(), person.get_age())
OUTPUT:
Russel 25 Eve 25
person2 = Person("Jane", 25)
person3 = Person("Eve", 25)
print(person.equal(person2))
OUTPUT:
False
print(person.equal(person3))
OUTPUT:
True
person.repr()
OUTPUT:
'Person(name=Eve, age=25)'
type(person)
OUTPUT:
function
Mimicking Inheritance
It's truly fascinating! This approach offers the flexibility to simulate inheritance or even multiple inheritance. By leveraging the power of higher-order functions, we can mimic the behavior of inheritance without explicitly defining classes. This purely functional approach opens up new possibilities for structuring and organizing our code, offering a unique perspective on object-oriented concepts.
Let us first define our class-like function again.
def Person(name, age):
def self():
return None
def get_name():
return name
def set_name(new_name):
nonlocal name
name = new_name
def get_age():
return age
def set_age(new_age):
nonlocal age
age = new_age
def repr():
return f"Person(name={name}, age={age})"
def equal(other):
nonlocal name, age
return name == other.get_name() and age == other.get_age()
methods = ['get_name', 'set_name', 'get_age', 'set_age', 'repr', 'equal']
# creating attributes of the nested function names to self
for method in methods:
self.__dict__[method] = eval(method)
return self
# Create a Person2 object
person = Person("Russel", 25)
print(person.get_name(), person.get_age())
person.set_name('Eve')
print(person.get_name(), person.get_age())
OUTPUT:
Russel 25 Eve 25
Employee is supposed to behave like a child class of Person:
from functools import wraps
def Employee(name, age, stuff_id):
self = Person(name, age)
# all attributes of Person are attached to self:
self = wraps(self)(self)
def get_stuff_id():
return stuff_id
def set_stuff_id(new_stuff_id):
nonlocal stuff_id
stuff_id = new_stuff_id
# adding 'methods' of child class
methods = ['get_stuff_id', 'set_stuff_id']
for method in methods:
self.__dict__[method] = eval(method)
return self
x = Employee('Homer', 42, '007')
x.get_age()
OUTPUT:
42
x.set_stuff_id('434')
x.get_stuff_id()
OUTPUT:
'434'
Live Python training
Enjoying this page? We offer live Python training courses covering the content of this site.
Upcoming online Courses
Exercises
Exercise 1
Create a Python class called Librarycatalogue
that represents a catalogue for a library. The class should have the following attributes and methods:
Attributes:
books
: A dictionary where the keys are book titles (strings) and the values are the corresponding authors (also strings).
Live Python training
Enjoying this page? We offer live Python training courses covering the content of this site.
Methods:
__init__(self)
: The constructor method that initializes thebooks
dictionary.add_book(self, title, author)
: A method that takes in parameterstitle
andauthor
and adds the book to the catalogue.remove_book(self, title)
: A method that takes in a parametertitle
and removes the book from the catalogue if it exists. If the book is not found, print a message indicating that the book is not in the catalogue.find_books_by_author(self, author)
: A method that takes in a parameterauthor
and returns a list of book titles written by that author.find_author_by_book(self, title)
: A method that takes in a parametertitle
and returns the author of the specified book.display_catalogue(self)
: A method that prints the entire catalogue, listing all books and their authors.
Your task is to implement the Librarycatalogue
class with the specified attributes and methods. Then, create instances of the class and test its functionality by adding books, removing books, finding books by author, finding authors by book, and displaying the catalogue.
Exercise 2
Rewrite the previous class as a function
Solutions
Solution to Exercise 1
class Librarycatalogue:
def __init__(self):
self.books = {}
def add_book(self, title, author):
self.books[title] = author
def remove_book(self, title):
if title in self.books:
del self.books[title]
print(f"Book '{title}' removed from the catalogue.")
else:
print(f"Book '{title}' is not in the catalogue.")
def find_books_by_author(self, author):
found_books = [title for title, auth in self.books.items() if auth == author]
return found_books
def find_author_by_book(self, title):
if title in self.books:
return self.books[title]
else:
return f"Author of '{title}' is not found in the catalogue."
def display_catalogue(self):
print("catalogue:")
for title, author in self.books.items():
print(f"- {title} by {author}")
# Test the Librarycatalogue class
library = Librarycatalogue()
# Add books to the catalogue
library.add_book("1984", "George Orwell")
library.add_book("To Kill a Mockingbird", "Harper Lee")
library.add_book("Ulysses", "James Joyce")
# Display the catalogue
library.display_catalogue()
# Find books by author
print("\nBooks by Harper Lee:", library.find_books_by_author("Harper Lee"))
# Find author by book
print("\nAuthor of '1984':", library.find_author_by_book("1984"))
print("Author of 'Ulysses':", library.find_author_by_book("Ulysses"))
# Remove a book
library.remove_book("To Kill a Mockingbird")
# Display the catalogue again
library.display_catalogue()
OUTPUT:
catalogue: - 1984 by George Orwell - To Kill a Mockingbird by Harper Lee - Ulysses by James Joyce Books by Harper Lee: ['To Kill a Mockingbird'] Author of '1984': George Orwell Author of 'Ulysses': James Joyce Book 'To Kill a Mockingbird' removed from the catalogue. catalogue: - 1984 by George Orwell - Ulysses by James Joyce
Solution to Exercise 2
def Librarycatalogue():
books = {}
def self():
return None
# names of the nested functions to be exported
methods = ['add_book', 'remove_book', 'find_books_by_author',
'find_author_by_book', 'display_catalogue']
def add_book(title, author):
books[title] = author
def remove_book(title):
if title in books:
del books[title]
print(f"Book '{title}' removed from the catalogue.")
else:
print(f"Book '{title}' is not in the catalogue.")
def find_books_by_author(author):
found_books = [title for title, auth in books.items() if auth == author]
return found_books
def find_author_by_book(title):
if title in books:
return books[title]
else:
return f"Author of '{title}' is not found in the catalogue."
def display_catalogue():
print("catalogue:")
for title, author in books.items():
print(f"- {title} by {author}")
# creating attributes of the nested function names to self
for method in methods:
self.__dict__[method] = eval(method)
return self
# Test the Librarycatalogue class
library = Librarycatalogue()
# Add books to the catalogue
library.add_book("1984", "George Orwell")
library.add_book("Hotel New Hampshire", "John Irving")
library.add_book("Ulysses", "James Joyce")
# Display the catalogue
library.display_catalogue()
# Find books by author
print("\nBooks by Harper Lee:", library.find_books_by_author("Harper Lee"))
# Find author by book
print("\nAuthor of '1984':", library.find_author_by_book("1984"))
print("Author of 'Ulysses':", library.find_author_by_book("Ulysses"))
# Remove a book
library.remove_book("To Kill a Mockingbird")
# Display the catalogue again
library.display_catalogue()
OUTPUT:
catalogue: - 1984 by George Orwell - Hotel New Hampshire by John Irving - Ulysses by James Joyce Books by Harper Lee: [] Author of '1984': George Orwell Author of 'Ulysses': James Joyce Book 'To Kill a Mockingbird' is not in the catalogue. catalogue: - 1984 by George Orwell - Hotel New Hampshire by John Irving - Ulysses by James Joyce
Live Python training
Enjoying this page? We offer live Python training courses covering the content of this site.
Upcoming online Courses