Functions as first-class objects
It shouldn't come as a surprise that Python functions are first-class objects. In Python, function objects have a number of attributes. The reference manual lists a number of special member names that apply to functions. Since functions are objects with attributes, we can extract the docstring or the name of a function, using special attributes such as __doc__ or __name__. We can also extract the body of the function through the __code__ attribute. In compiled languages, this introspection is relatively complex because of the source information that needs to be retained. In Python, it's quite simple.
We can assign functions to variables, pass functions as arguments, and return functions as values. We can easily use these techniques to write higher-order functions.
Additionally, a callable object helps us to create functions. We can consider the callable class definition as a higher-order function. We do need to be judicious in how we use the __init__() method of a callable object; we should avoid setting stateful class variables. One common application is to use an __init__() method to create objects that fit the Strategy design pattern.
A class following the Strategy design pattern depends on other objects to provide an algorithm or parts of an algorithm. This allows us to inject algorithmic details at runtime, rather than compiling the details into the class.
Here is an example of a callable object with an embedded Strategy object:
from typing import Callable class Mersenne1: def __init__(self, algorithm : Callable[[int], int]) -> None: self.pow2 = algorithm def __call__(self, arg: int) -> int: return self.pow2(arg)-1
This class uses __init__() to save a reference to another function, algorithm, as self.pow2. We're not creating any stateful instance variables; the value of self.pow2 isn't expected to change. The algorithm parameter has a type hint of Callable[[int], int], a function that takes an integer argument and returns an integer value.
The function given as a Strategy object must raise 2 to the given power. Three candidate objects that we can plug into this class are as follows:
def shifty(b: int) -> int: return 1 << b
def multy(b: int) -> int: if b == 0: return 1 return 2*multy(b-1)
def faster(b: int) -> int: if b == 0: return 1 if b%2 == 1: return 2*faster(b-1) t= faster(b//2) return t*t
The shifty() function raises 2 to the desired power using a left shift of the bits. The multy() function uses a naive recursive multiplication. The faster() function uses a divide and conquer strategy that will perform multiplications instead of b multiplications.
All three of these functions have identical function signatures. Each of them can be summarized as Callable[[int], int], which matches the parameter, algorithm, of the Mersenne1.__init__() method.
We can create instances of our Mersenne1 class with an embedded strategy algorithm, as follows:
m1s = Mersenne1(shifty) m1m = Mersenne1(multy) m1f = Mersenne1(faster)
This shows how we can define alternative functions that produce the same result but use different algorithms.