Callable Objects in Python

This blog is about callables in Python, the different types that exist in the language, and how to use them. Callable is a very important topic in Python and is widely used in many very popular libraries like PyTorch (they call them Hooks), Tensorflow, Fast.ai, and many more. Learning them can be of great use and that’s the motivation behind writing this post. A bunch of these ideas are taken from the absolutely incredible (but long) book, Fluent Python.
Author

Uzair Tahamid Siam

Published

March 25, 2023

Functions as First-Class Objects

Before we can define what a callable is, it is very important to understand what a first-class function or function as a first-class object means. That is at the end of the day key to a lot of ideas like callables, decorators, and more.

A first-class function is simply one that can be used like any other object like let’s say an int, float, dict, etc except it can also be invoked. The requirements for a language to have functions as first-class objects are:

  • They can be created at runtime

  • They can be assigned to a variable or be stored like a variable in a data structure

  • They can be passed as an argument to a function (higher-order functions use this)

  • They can be returned as the result of a function (decorators use this)

While Python is not a strictly functional language, first-class functions are a key feature of functional programming languages like Haskell.

Example of first-class function

Let’s say you have a list of integers and you want to calculate of their factorials. In a language without first-class functions it would look something like this:

def factorial(n: int) -> int:
    """
    A factorial (!) is defined as the product of all numbers from n to 1. It also has a recursive
    definition where n! = n * (n - 1)!. 0! is defined as 1.

    Example:
    5! = 5 * 4 * 3 * 2 * 1 = 120
    """
    assert n >= 0, "Cannot calculate factorials of negatives"
    return 1 if n < 2 else n * factorial(n-1)

def factorials(nums: list[int])->list[int]:
    """
    Given a list of ints return a list of all their factorial
    """
    res = []
    for n in nums:
            res.append(factorial(n))
    return res

Of course in Python this can be done in one line as well with a list comprehension. But with first-class functions and another very important functional programming tool, map we can do this in one line in any functional language - even the ones without list comp.

def factorials_functional(nums: list[int])->list[int]:
    return list(map(factorial, nums))

map is what’s called a higher-order function, a function that takes other functions as a parameter. The idea of a higher-order function is also tied to this idea of functions as first class objects.

We can also alias functions if they’re first class.

fact = factorial
fact(5)
120

Other higher-order functions that are built-in are filter and sorted.

What is a callable?

We treated a function as an object above. Why not treat an object as a function? A callable is an object that we can treat an object as a function. What does that even mean? Well in Python you do this by implementing one of the dunder methods __call__. If you don’t know what these dunder methods are, take a look at this video: https://www.youtube.com/watch?v=3ohzBxoFHAY.

If you’re interested in the internals of Python that make callable work, take a look at this article: https://dzone.com/articles/Python-internals-how-callables-0.

Let’s try to understand callables with a simple example.

Simple callable

Let’s create a class that represents a bag of balls and when you use an instance of it as a function, it gives you a random ball.

import random
class BagOfBalls:
    def __init__(self):
        self.balls = ["red", "green", "blue", "yellow", "black", "orange", "white"]

    def __call__(self):
        return random.choice(self.balls)

bob = BagOfBalls()
print(list(bob() for _ in range(10)))
['green', 'black', 'yellow', 'orange', 'white', 'orange', 'blue', 'green', 'red', 'black']

This is a very simple example (one with a Neural Network is shown later) so we understand how easy it is to treat any user-defined object as a function in Python. You can use this to create as complex or as simple callables as you want. Of course, you could’ve just used a function like pick_random(self) if you wanted in this case but sometimes a callable is much better.

Now that we’ve looked a simple example of a callable object, let’s see take a look at some of the other ones described in the Python Data Model

Types of callable objects

You can check if an object is a callable by passing the object into the callable() function. The data model currently (Python 3.10) describes nine types.

User-defined functions

This is what we usually think of when we think of functions. Things that start with the keyword def.

def foo(*args, **kwargs):
    print(args, kwargs)

Built-in functions

These are functions implemented in CPython e.g. len(), zip() and all the ones in here.

x = [1,2,3,4,5]
len(x)
5

Methods

Functions that define the behavior of some class are also callable just like regular functions.

class Foo:
    def __init__(self, x):
        self.x = x

    def bar(self):
        print(f"This is a method of the class: {self.__class__.__name__}\nIt has attribute x = {self.x}")

foo = Foo(2)
foo.bar()
This is a method of the class: Foo
It has attribute x = 2

bar() is a callable in this example.

Built-in methods

Built-in methods are behaviors of built-in objects. For example any function that are bound to instances of lists or dicts.

x = [4,42,535,14,1,2,414,1,5]
x.sort()
x.append(6)

d = dict(a=2, b=3, c=4)
a_el = d.get("a")

Classes

In Python, calling a class is similar to calling a function. The class’s __new__ method creates an instance of the class, and then the __init__ method initializes it before returning it to the caller. By overriding __new__, it is possible to change this default behavior of class instantiation.

We can use the Singleton design pattern as an example for this. A Singelton must have at most one instance of it. So, we can create a __new__() with that in mind.

class Singleton:
    instance = None

    def __new__(cls):
        if not cls.instance:
            cls.instance = super().__new__(cls)
        return cls.instance

    def __init__(self):
        print("Initializing Singleton instance")

If there’s already an instance of Singleton it will just return it otherwise it will create a new one and then return it.

Class instances

Instances of arbitrary classes can be made callable by defining a __call__() method in their class. We already look at this above. Let’s maybe look a neural netword example here.

import torch.nn as nn
import torch 
class NeuralNetwork(nn.Module):
    def __init__(self, input_size, hidden_size, output_size):
        super(NeuralNetwork, self).__init__()
        self.fc1 = nn.Linear(input_size, hidden_size)
        self.relu = nn.ReLU()
        self.fc2 = nn.Linear(hidden_size, output_size)

    def forward(self, x):
        x = self.fc1(x)
        x = self.relu(x)
        x = self.fc2(x)
        return x

    def __call__(self, x):
        return self.forward(x)

Because we implement both forward() and __call__() for our neural network, we can invoke a forward pass using either of them.

model = NeuralNetwork(784, 128, 10)

input_data = torch.randn(64, 784)
output = model(input_data) 

# OR

model = NeuralNetwork(784, 128, 10)

input_data = torch.randn(64, 784)
output = model.forward(input_data) 
output[:5]
tensor([[ 0.0839, -0.2072,  0.2811, -0.1413, -0.1829,  0.2572,  0.1366,  0.2296,
         -0.4719, -0.3536],
        [-0.1014, -0.2167, -0.0223,  0.0751, -0.0148, -0.1090,  0.0389,  0.2360,
          0.0982,  0.2635],
        [-0.0802,  0.0572, -0.1526, -0.0523, -0.2342, -0.1481,  0.1288, -0.0323,
          0.1277,  0.0186],
        [ 0.0528, -0.0634, -0.1725, -0.6848,  0.1953,  0.0757,  0.1492,  0.2386,
          0.1147,  0.2566],
        [-0.0599,  0.0195,  0.0392, -0.1495,  0.2507, -0.2939,  0.3084,  0.1569,
          0.2854,  0.1107]], grad_fn=<SliceBackward0>)

Native coroutine functions

Native coroutine functions are a feature of Python’s asyncio library that allow you to write asynchronous code using the async and await keywords.

In Python, a coroutine is a special type of function that can be paused and resumed during its execution, allowing other code to run in the meantime. This makes it possible to write asynchronous code that doesn’t block the main thread or require threads or callbacks.

A native coroutine function is a coroutine function that is defined using the async def syntax. When you call a native coroutine function, it returns a coroutine object, which can then be scheduled and run using the asyncio event loop.

import asyncio

async def async_sleep():
    print('Sleeping for one second...')
    await asyncio.sleep(1)
    print('Awake!')

The function returns an awaitable object, which can be used with the await keyword to wait for the function to complete.

async def main():
    print('Before sleep')
    await async_sleep()
    print('After sleep')

# asyncio.run(main())

Note that the asyncio.run() function is used to run the main() coroutine function, which in turn calls the async_sleep() coroutine function using the await keyword.

Generator functions

Functions or methods that use theyield keyword in their body that return a generator object when called.

def count_up_to(n):
    i = 1
    while i <= n:
        yield i
        i += 1

counter = count_up_to(5) # creates a generator object

for num in counter: # loop through the generator object
    print(num)
1
2
3
4
5

It’s very important to realize that counter is NOT a list it is a generator that generates each value on demand. This can be more memory-efficient than storing all the values in a data structure at once.

Asynchronous generator functions

Asynchronous generator functions are analogous to native coroutine functions except they return generators. They allow you to write asynchronous code using the async and yield keywords and are part of the asyncio module.

An asynchronous generator function is defined with the async def syntax and uses the yield keyword to produce values. The difference from a regular generator function is that it can also use the await keyword to pause the execution of the function while waiting for an asynchronous operation to complete.

import asyncio

async def async_count_up_to(n):
    i = 1
    while i <= n:
        yield i
        await asyncio.sleep(1)
        i += 1

This function is similar to the example in Generator functions, but uses async def and await to support asynchronous operations. In this case, it yields a number and then waits for one second using asyncio.sleep() before continuing.

async def print_numbers():
    async_counter = async_count_up_to(5)

    async for num in async_counter:
        print(num)

await print_numbers()
1
2
3
4
5

This will print the numbers 1 through 5, waiting for one second between each number.

Conclusion

Some of the examples above might be very new. When I was trying to learn about callables, the asynchronous programming examples definitely threw me off a little. But, that’s not the most important part right now. If you do feel interested in asynchronous programming, do look into it. It’s everywhere in web programming where no one wants to wait for anything!

Hopefully, callables (all nine types) make some more sense now after you’ve read this blog. In addition, I hope it has also given you some appreciation of the power of first-class functions. In the future I hope to write something similar for decorators which are something I struggle with as well.