python-course.eu

13. Callable Instances of Classes

By Bernd Klein. Last modified: 24 Mar 2024.

The call method

Robot making calls

There will be hardly any Python user who hasn't stumbled upon exceptions like 'dict' object is not callable or 'int' object is not callable. After a while they find out the reason. They used parentheses (round bracket) in situation where they shouldn't have done it. Expressions like f(x), gcd(x, y) or sin(3.4) are usually used for function calls. The question is, why do we get the message 'dict' object is not callable if we write d('a'), if d is a dictionary? Why doesn't it say 'dict' object is not a function? First of all, when we invoke a function in Python, we also say we 'call the function'. Secondly, there are objects in Python, which are 'called' like functions but are not functions strictly speaking. There are 'lambda functions', which are defined in a different way. It is also possible to define classes, where the instances are callable like 'regular' functions. This will be achieved by adding another magic method the __call__ method.

Before we will come to the __call__ method, we have to know what a callable is. In general, a "callable" is an object that can be called like a function and behaves like one. All functions are also callables. Python provides a function with the name callable. With the help of this funciton we can determine whether an object is callable or not. The function callable returns a Boolean truth value which indicates whether the object passed as an argument can be called like a function or not. In addition to functions, we have already seen another form of callables: classes

def the_answer(question):
    return 42

print("the_answer: ", callable(the_answer))

OUTPUT:

the_answer:  True

The __call__ method can be used to turn the instances of the class into callables. Functions are callable objects. A callable object is an object which can be used and behaves like a function but might not be a function. By using the __call__ method it is possible to define classes in a way that the instances will be callable objects. The __call__ method is called, if the instance is called "like a function", i.e. using brackets. The following class definition is the simplest possible way to define a class with a __call__ method.

class FoodSupply:
    
    def __call__(self):
        return "spam"
    
foo = FoodSupply()
bar = FoodSupply()

print(foo(), bar())

OUTPUT:

spam spam

The previous class example is extremely simple, but useless in practical terms. Whenever we create an instance of the class, we get a callable. These callables are always defining the same constant function. A function without any input and a constant output "spam". We'll now define a class which is slightly more useful. Let us slightly improve this example:

class FoodSupply:
    
    def __init__(self, *incredients):
        self.incredients = incredients
    
    def __call__(self):
        result = " ".join(self.incredients) + " plus delicious spam!"
        return result
    
f = FoodSupply("fish", "rice")
f()

OUTPUT:

'fish rice plus delicious spam!'

Let's create another function:

g = FoodSupply("vegetables")
g()

OUTPUT:

'vegetables plus delicious spam!'

Now, we define a class with the name TriangleArea. This class has only one method, which is the __call__method. The __call__ method calculates the area of an arbitrary triangle, if the length of the three sides are given.

class TriangleArea:
    
    def __call__(self, a, b, c):
        p = (a + b + c) / 2
        result = (p * (p - a) * (p - b) * (p - c)) ** 0.5
        return result
    
    
area = TriangleArea()

print(area(3, 4, 5))

OUTPUT:

6.0

This program returns 6.0. This class is not very exciting, even though we can create an arbitrary number of instances where each instance just executes an unaltered __call__ function of the TrianlgeClass. We cannot pass parameters to the instanciation and the __call__ of each instance returns the value of the area of the triangle. So each instance behaves like the area function.

After the two very didactic and not very practical examples, we want to demonstrate a more practical example with the following. We define a class that can be used to define linear equations:

class StraightLines():
    
    def __init__(self, m, c):
        self.slope = m
        self.y_intercept = c
        
    def __call__(self, x):
        return self.slope * x + self.y_intercept
    
line = StraightLines(0.4, 3)

for x in range(-5, 6):
    print(x, line(x))

OUTPUT:

-5 1.0
-4 1.4
-3 1.7999999999999998
-2 2.2
-1 2.6
0 3.0
1 3.4
2 3.8
3 4.2
4 4.6
5 5.0

We will use this class now to create some straight lines and visualize them with matplotlib:

lines = []
lines.append(StraightLines(1, 0))
lines.append(StraightLines(0.5, 3))
lines.append(StraightLines(-1.4, 1.6))

import matplotlib.pyplot as plt
import numpy as np
X = np.linspace(-5,5,100)
for index, line in enumerate(lines):
    line = np.vectorize(line)
    plt.plot(X, line(X), label='line' + str(index))

plt.title('Some straight lines')
plt.xlabel('x', color='#1C2833')
plt.ylabel('y', color='#1C2833')
plt.legend(loc='upper left')
plt.grid()
plt.show()

No description has been provided for this image

Our next example is also exciting. The class FuzzyTriangleArea defines a __call__ method which implements a fuzzy behaviour in the calculations of the area. The result should be correct with a likelihood of p, e.g. 0.8. If the result is not correct the result will be in a range of ± v %. e.g. 0.1.

import random

class FuzzyTriangleArea:
    
    def __init__(self, p=0.8, v=0.1):
        self.p, self.v = p, v
        
    def __call__(self, a, b, c):
        p = (a + b + c) / 2
        result = (p * (p - a) * (p - b) * (p - c)) ** 0.5
        if random.random() <= self.p:
            return result
        else:
            return random.uniform(result-self.v, 
                                  result+self.v)
        
area1 = FuzzyTriangleArea()
area2 = FuzzyTriangleArea(0.5, 0.2)
for i in range(12):
    print(f"{area1(3, 4, 5):4.3f}, {area2(3, 4, 5):4.2f}")

OUTPUT:

5.993, 5.95
6.000, 6.00
6.000, 6.00
5.984, 5.91
6.000, 6.00
6.000, 6.00
6.000, 6.17
6.000, 6.13
6.000, 6.01
5.951, 6.00
6.000, 5.95
5.963, 6.00

Beware that this output differs with every call! We can see the in most cases we get the right value for the area but sometimes not.

We can create many different instances of the previous class. Each of these behaves like an area function, which returns a value for the area, which may or may not be correct, depending on the instantiation parameters p and v. We can see those instances as experts (expert functions) which return in most cases the correct answer, if we use p values close to 1. If the value v is close to zero, the error will be small, if at all. The next task would be merging such experts, let's call them exp1, exp2, ..., expn to get an improved result. We can perform a vote on the results, i.e. we will return the value which is most often occuring, the correct value. Alternatively, we can calculate the arithmetic mean. We will implement both possibilities in our class FuzzyTriangleArea:

MergeExperts class with __call__ method

from random import uniform, random
from collections import Counter

class FuzzyTriangleArea:

    def __init__(self, p=0.8, v=0.1):
        self.p, self.v = p, v
        
    def __call__(self, a, b, c):
        p = (a + b + c) / 2
        result = (p * (p - a) * (p - b) * (p - c)) ** 0.5
        if random() <= self.p:
            return result
        else:
            return uniform(result-self.v, 
                                  result+self.v)
     
   
class MergeExperts:
    
    def __init__(self, mode, *experts):
        self.mode, self.experts = mode, experts
        
    def __call__(self, a, b, c):
        results= [exp(a, b, c) for exp in self.experts]
        if self.mode == "vote":
            c = Counter(results)
            return c.most_common(1)[0][0]
        elif self.mode == "mean":
            return sum(results) / len(results)

rvalues = [(uniform(0.7, 0.9), uniform(0.05, 0.2)) for _ in range(20)]
experts = [FuzzyTriangleArea(p, v) for p, v in rvalues]
merger1 = MergeExperts("vote", *experts)
print(merger1(3, 4, 5))
merger2 = MergeExperts("mean", *experts)
print(merger2(3, 4, 5))

OUTPUT:

6.0
6.0073039634137375

The following example defines a class with which we can create abitrary polynomial functions:

class Polynomial:
    
    def __init__(self, *coefficients):
        self.coefficients = coefficients[::-1]
        
    def __call__(self, x):
        res = 0
        for index, coeff in enumerate(self.coefficients):
            res += coeff * x** index
        return res

# a constant function
p1 = Polynomial(42)

# a straight Line
p2 = Polynomial(0.75, 2)

# a third degree Polynomial
p3 = Polynomial(1, -0.5, 0.75, 2)

for i in range(1, 10):
    print(i, p1(i), p2(i), p3(i))

OUTPUT:

1 42 2.75 3.25
2 42 3.5 9.5
3 42 4.25 26.75
4 42 5.0 61.0
5 42 5.75 118.25
6 42 6.5 204.5
7 42 7.25 325.75
8 42 8.0 488.0
9 42 8.75 697.25

You will find further interesting examples of the __call__ function in our tutorial in the chapters Decorators and Memoization with Decorators. You may also consult our chapter on Polynomials.

# Create a RunningAverage instance
average = RunningAverage()

# Add numbers to the running average
average.add_number(5)
average.add_number(10)
average.add_number(15)

# Print the current running average
print("Current running average:", average())

# Reset the running average
average.reset()

# Add more numbers
average.add_number(20)
average.add_number(25)

# Print the new running average
print("New running average:", average())
# Create a TemperatureConverter instance with an initial temperature of 25 degrees Celsius
converter = TemperatureConverter(25, 'C')

# Print the current temperature
print("Current temperature:", converter())

# Convert the temperature to Fahrenheit
print("Temperature in Fahrenheit:", converter.convert())

# Change the unit to Fahrenheit
converter.change_unit('F')

# Print the current temperature after changing the unit
print("Current temperature:", converter())

Live Python training

instructor-led training course

Enjoying this page? We offer live Python training courses covering the content of this site.

See: Live Python courses overview

Enrol here

Solutions to our Exercises

Solution to Exercise 1

class RunningAverage:
    def __init__(self):
        """
        Initialize the RunningAverage object with an empty list to store numbers.
        """
        self.numbers = []

    def add_number(self, number):
        """
        Add a number to the list of numbers.
        """
        self.numbers.append(number)

    def __call__(self):
        """
        Calculate and return the current running average of all numbers added so far.
        """
        if not self.numbers:
            return 0
        return sum(self.numbers) / len(self.numbers)

    def reset(self):
        """
        Clear the list of numbers and reset the running average to 0.
        """
        self.numbers = []


average = RunningAverage()
print("Current running average after initialization:", average())
for x in [3, 5, 12, 9, 1]:
    average.add_number(x)
    print("Current running average:", average())
average.reset()
print("average is reset: ", average())
for x in [3.1, 19.8, 3]:
    average.add_number(x)
    print("Current running average:", average())

OUTPUT:

Current running average after initialization: 0
Current running average: 3.0
Current running average: 4.0
Current running average: 6.666666666666667
Current running average: 7.25
Current running average: 6.0
average is reset:  0
Current running average: 3.1
Current running average: 11.450000000000001
Current running average: 8.633333333333335

Solution to Exercise 2:

class TemperatureConverter:
    def __init__(self, temperature, unit='C'):
        """
        Initialize the TemperatureConverter object with an initial temperature and unit.
        Default unit is Celsius ('C').
        """
        self.temperature = temperature
        self.unit = unit

    @property
    def unit(self):
        return self.__unit

    @unit.setter
    def unit(self, unit):
        if unit.upper() in {'C', 'F'}:
            self.__unit = unit
        else:
            raise ValueError("Should be 'C' or 'F'")

    def convert(self):
        """
        Convert the temperature to the other unit and return it.
        """
        new_unit = 'F' if self.unit == 'C' else 'C'  # Determine the opposite unit
        return self._convert_to_unit(new_unit)

    def __call__(self):
        """
        Return the current temperature in the current unit.
        """
        return self.temperature

    def change_unit(self, new_unit):
        """
        Change the unit of the temperature to the specified new unit.
        """
        new_unit = new_unit.upper()  # Ensure the new unit is uppercase
        if new_unit not in ['C', 'F']:
            raise ValueError("Invalid unit. Choose 'C' for Celsius or 'F' for Fahrenheit.")
        
        if new_unit != self.unit:  # Only convert if the new unit is different
            self.temperature = self._convert_to_unit(new_unit)
            self.unit = new_unit

    def _convert_to_unit(self, target_unit):
        """
        Convert the temperature to the specified unit and return it.
        """
        if target_unit == 'C':
            return (self.temperature - 32) * 5/9  # Convert Fahrenheit to Celsius
        elif target_unit == 'F':
            return (self.temperature * 9/5) + 32  # Convert Celsius to Fahrenheit



# Example usage:
converter = TemperatureConverter(25, 'C')
print("Current temperature:", converter())
print("Temperature in Fahrenheit:", converter.convert())
converter.change_unit('F')
print("Current temperature:", converter())

OUTPUT:

Current temperature: 25
Temperature in Fahrenheit: 77.0
Current temperature: 77.0

Live Python training

instructor-led training course

Enjoying this page? We offer live Python training courses covering the content of this site.

See: Live Python courses overview

Upcoming online Courses

Enrol here