Skip to content

Callable Objects

In Python, any object with a __call__ method is considered callable — you can invoke it with parentheses just like a function. This is one of Python's core protocols: Python doesn't care what type your object is, only whether it implements __call__. The same protocol-based approach drives containers, iteration, and context managers.

Callable objects are powerful because, unlike plain functions, they can carry state between invocations. Whenever you need a function that remembers configuration or accumulates results, a callable object is a natural fit.

Mental Model

A callable object is a function that remembers. Where a plain function is stateless (or relies on closures), a callable object stores configuration in self and uses __call__ as its entry point. Think of it as upgrading a function to a class whenever you need persistent state between calls.

Making Objects Callable

1. The call Method

When you define __call__ on a class, instances of that class become callable. Conceptually, writing obj(args) is translated by Python into type(obj).__call__(obj, args) — Python looks up __call__ on the class, not the instance. (The actual C-level call path is more complex, but this model is accurate for understanding behavior.) This is the same descriptor-based lookup used by all dunder methods.

Class Lookup, Not Instance Lookup

obj() does not call obj.__call__() via instance attribute lookup. It calls type(obj).__call__(obj), which means __call__ must be defined on the class (or a superclass), not assigned to the instance. This is consistent with how all Python protocols work: the method is looked up on the type, not on the object itself.

2. Use Cases

Callable objects are useful in several common scenarios:

  • Function-like objects: create reusable operations that carry configuration state, such as a multiplier with a fixed factor.
  • Decorators: implement decorators as classes when you need to maintain state across decorated function calls.
  • State machines: encode transitions and current state inside the object, and trigger transitions by calling it.

3. Example

The following class creates a callable that multiplies its argument by a fixed factor set at initialization.

```python class Multiplier: def init(self, factor): self.factor = factor

def __call__(self, x):
    return x * self.factor

double = Multiplier(2) print(double(5)) # 10 print(double(12)) # 24 ```

The double object carries the factor 2 as internal state. Each call to double(x) multiplies x by that stored factor, producing the same behavior as a function but with the flexibility to change the factor or add methods later.

Callable Objects vs Closures

Closures can also carry state, so when should you use a callable object instead?

```python

Closure approach

def make_multiplier(factor): def multiply(x): return x * factor return multiply

Callable object approach

class Multiplier: def init(self, factor): self.factor = factor def call(self, x): return x * self.factor ```

Use a closure when the state is simple and read-only. Use a callable object when you need mutable state, multiple methods, inheritance, or introspection (isinstance checks, attribute access).

Summary

  • Defining __call__ on a class makes its instances callable with the same syntax as a regular function.
  • Callable objects combine the convenience of function call syntax with the ability to store and update internal state.
  • Prefer closures for simple stateless or read-only-state cases; prefer callable objects when you need mutability, methods, or introspection.
  • Common applications include configurable operations (strategy pattern), stateful decorators, and state machines.
  • Callable objects are widely used in libraries like PyTorch (nn.Module.__call__), scikit-learn (transformers), and TensorFlow (layers) — any framework where objects need to behave like functions while carrying learned state.

When NOT to Use call

Don't implement __call__ when its meaning would be ambiguous. A User() object that is callable — what does calling it do? If the answer is not immediately obvious, use a named method instead. Protocols should make code more readable, not clever.


Runnable Example: hash_digest_callable_example.py

```python """ Callable Objects: Stream Hasher with call

Demonstrates the call magic method by creating a callable class that computes file hash digests. The object acts like a function but maintains internal state (algorithm, buffer size).

Topics covered: - call to make instances callable - hashlib for cryptographic hash functions - Dynamic module/attribute loading with import and getattr - Iterator pattern with iter(callable, sentinel)

Based on concepts from Python-100-Days example07 and ch06/dunder_advanced materials. """

import hashlib import io

=============================================================================

Example 1: Stream Hasher (Callable Class)

=============================================================================

class StreamHasher: """A callable object that computes hash digests of data streams.

By implementing __call__, instances can be used like functions:
    hasher = StreamHasher('sha256')
    digest = hasher(file_stream)  # Calls hasher.__call__(file_stream)

This is more flexible than a plain function because the object
retains configuration (algorithm, buffer size) as state.
"""

SUPPORTED = ('md5', 'sha1', 'sha256', 'sha512')

def __init__(self, algorithm: str = 'md5', buffer_size: int = 4096):
    """Initialize with hash algorithm and buffer size.

    Args:
        algorithm: Hash algorithm name (md5, sha1, sha256, sha512).
        buffer_size: Bytes to read per chunk.
    """
    if algorithm.lower() not in self.SUPPORTED:
        raise ValueError(
            f"Unsupported algorithm '{algorithm}'. "
            f"Choose from: {self.SUPPORTED}"
        )
    self.algorithm = algorithm.lower()
    self.buffer_size = buffer_size

def digest(self, data_stream) -> str:
    """Compute hexadecimal hash digest from a data stream.

    Uses iter(callable, sentinel) pattern to read chunks:
    - callable: lambda that reads buffer_size bytes
    - sentinel: b'' (empty bytes = end of stream)
    """
    hasher = hashlib.new(self.algorithm)
    for chunk in iter(lambda: data_stream.read(self.buffer_size), b''):
        hasher.update(chunk)
    return hasher.hexdigest()

def __call__(self, data_stream) -> str:
    """Make instances callable: hasher(stream) == hasher.digest(stream)."""
    return self.digest(data_stream)

def __repr__(self):
    return f"StreamHasher('{self.algorithm}', buffer_size={self.buffer_size})"

=============================================================================

Example 2: Using Callable Objects

=============================================================================

def demo_callable(): """Demonstrate callable objects with hash computation.""" print("=== Callable Object Demo ===")

# Create hashers for different algorithms
md5_hasher = StreamHasher('md5')
sha256_hasher = StreamHasher('sha256')

# Hash some data using BytesIO as a stream
data = b"Hello, World! This is a test of the callable hasher."

stream1 = io.BytesIO(data)
stream2 = io.BytesIO(data)

# Both calling styles work:
md5_digest = md5_hasher(stream1)           # Using __call__
sha256_digest = sha256_hasher.digest(stream2)  # Using method directly

print(f"Data: {data.decode()!r}")
print(f"MD5:    {md5_digest}")
print(f"SHA256: {sha256_digest}")
print()

# Verify callable() built-in
print(f"callable(md5_hasher): {callable(md5_hasher)}")
print(f"callable('string'):   {callable('string')}")
print()

=============================================================================

Example 3: Callable vs Function

=============================================================================

def simple_md5(data: bytes) -> str: """Plain function alternative to callable class.""" return hashlib.md5(data).hexdigest()

def demo_callable_vs_function(): """Compare callable class vs plain function.""" print("=== Callable Class vs Function ===")

data = b"test data"

# Function: simple but no configuration
result1 = simple_md5(data)
print(f"Function:        {result1}")

# Callable object: configurable and stateful
hasher = StreamHasher('md5', buffer_size=2)
result2 = hasher(io.BytesIO(data))
print(f"Callable object: {result2}")
print(f"Same result:     {result1 == result2}")

print()
print("When to use callable classes:")
print("  - Need configurable behavior (algorithm, buffer size)")
print("  - Want to maintain state between calls")
print("  - Implementing strategy/command patterns")
print("  - Need both function-call and method-call interfaces")
print()

=============================================================================

Example 4: iter(callable, sentinel) Pattern

=============================================================================

def demo_iter_sentinel(): """Demonstrate the iter(callable, sentinel) pattern used in StreamHasher.""" print("=== iter(callable, sentinel) Pattern ===")

# iter() with two args: calls the callable until it returns sentinel
data = io.BytesIO(b"Hello World")

print("Reading 3 bytes at a time until empty:")
chunks = list(iter(lambda: data.read(3), b''))
print(f"  Chunks: {chunks}")
print()

print("This is equivalent to:")
print("  while True:")
print("      chunk = stream.read(3)")
print("      if chunk == b'':  # sentinel")
print("          break")
print("      process(chunk)")
print()

=============================================================================

Main

=============================================================================

if name == 'main': demo_callable() demo_callable_vs_function() demo_iter_sentinel() ```


Runnable Example: callable_and_context_tutorial.py

```python """ Example 5: Callable Objects and Context Managers Demonstrates: call, enter, exit """

import time

class Multiplier: """A callable class that multiplies by a factor."""

def __init__(self, factor):
    self.factor = factor

def __call__(self, x):
    """Make the object callable."""
    return x * self.factor

def __repr__(self):
    return f"Multiplier({self.factor})"

class Counter: """A callable counter that increments each time it's called."""

def __init__(self, start=0):
    self.count = start

def __call__(self):
    """Increment and return the count."""
    self.count += 1
    return self.count

def reset(self):
    """Reset the counter."""
    self.count = 0

def __repr__(self):
    return f"Counter(current={self.count})"

class Timer: """A context manager that times code execution."""

def __init__(self, name="Code block"):
    self.name = name
    self.start_time = None
    self.elapsed_time = None

def __enter__(self):
    """Start the timer when entering the context."""
    print(f"Starting timer for: {self.name}")
    self.start_time = time.time()
    return self

def __exit__(self, exc_type, exc_val, exc_tb):
    """Stop the timer when exiting the context."""
    self.elapsed_time = time.time() - self.start_time
    print(f"Finished: {self.name}")
    print(f"Time elapsed: {self.elapsed_time:.4f} seconds")

    # Return False to propagate any exceptions
    # Return True to suppress exceptions
    return False

class FileWriter: """A context manager for safe file writing."""

def __init__(self, filename):
    self.filename = filename
    self.file = None

def __enter__(self):
    """Open the file when entering the context."""
    print(f"Opening file: {self.filename}")
    self.file = open(self.filename, 'w')
    return self.file

def __exit__(self, exc_type, exc_val, exc_tb):
    """Close the file when exiting the context."""
    if self.file:
        print(f"Closing file: {self.filename}")
        self.file.close()

    # Handle exceptions
    if exc_type is not None:
        print(f"An error occurred: {exc_val}")

    return False  # Don't suppress exceptions

class DatabaseConnection: """A context manager simulating a database connection."""

def __init__(self, db_name):
    self.db_name = db_name
    self.connected = False

def __enter__(self):
    """Establish connection when entering context."""
    print(f"Connecting to database: {self.db_name}")
    self.connected = True
    return self

def __exit__(self, exc_type, exc_val, exc_tb):
    """Close connection when exiting context."""
    print(f"Disconnecting from database: {self.db_name}")
    self.connected = False
    return False

def execute(self, query):
    """Simulate executing a query."""
    if not self.connected:
        raise RuntimeError("Not connected to database")
    print(f"Executing query: {query}")
    return f"Result of: {query}"

Examples

if name == "main":

# ============================================================================
print("=== Callable Objects: Multiplier ===")
double = Multiplier(2)
triple = Multiplier(3)

print(f"double: {double}")
print(f"double(5) = {double(5)}")
print(f"double(10) = {double(10)}")

print(f"\ntriple: {triple}")
print(f"triple(5) = {triple(5)}")
print(f"triple(10) = {triple(10)}")

# Use in map
numbers = [1, 2, 3, 4, 5]
doubled = list(map(double, numbers))
print(f"\nOriginal: {numbers}")
print(f"Doubled: {doubled}")

print("\n\n=== Callable Objects: Counter ===")
counter = Counter()
print(f"Counter: {counter}")

print(f"Call 1: {counter()}")
print(f"Call 2: {counter()}")
print(f"Call 3: {counter()}")
print(f"Current state: {counter}")

counter.reset()
print(f"After reset: {counter}")
print(f"Next call: {counter()}")

print("\n\n=== Context Manager: Timer ===")
with Timer("Example computation"):
    # Simulate some work
    total = 0
    for i in range(1000000):
        total += i
    print(f"Sum calculated: {total}")

print("\n=== Context Manager: Timer with Variable ===")
with Timer("Another task") as timer:
    time.sleep(0.1)  # Sleep for 100ms
print(f"Recorded time: {timer.elapsed_time:.4f} seconds")

print("\n\n=== Context Manager: FileWriter ===")
# Note: In this example, we won't actually create a file
# but show how it would work
print("Example of file writing (demonstration):")
print("with FileWriter('output.txt') as f:")
print("    f.write('Hello, World!')")
print("    f.write('This is a test.')")

print("\n\n=== Context Manager: DatabaseConnection ===")
with DatabaseConnection("mydb") as db:
    result1 = db.execute("SELECT * FROM users")
    print(f"Result: {result1}")

    result2 = db.execute("INSERT INTO users VALUES (1, 'Alice')")
    print(f"Result: {result2}")

print("\n=== Multiple Context Managers ===")
with Timer("Database operations"), DatabaseConnection("testdb") as db:
    db.execute("SELECT * FROM products")
    db.execute("UPDATE products SET price = 99.99")

print("\n=== Combining Callable and Context Manager ===")

class CallableTimer:
    """A class that's both callable and a context manager."""

    def __init__(self):
        self.times = []

    def __call__(self, duration):
        """Record a time when called."""
        self.times.append(duration)
        print(f"Recorded time: {duration:.4f}s")

    def __enter__(self):
        """Start timing."""
        self.start = time.time()
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        """Calculate and record elapsed time."""
        elapsed = time.time() - self.start
        self(elapsed)  # Use __call__ to record
        return False

    def average(self):
        """Calculate average time."""
        return sum(self.times) / len(self.times) if self.times else 0

timer_recorder = CallableTimer()

# Use as context manager
with timer_recorder:
    time.sleep(0.05)

with timer_recorder:
    time.sleep(0.08)

print(f"\nAverage time: {timer_recorder.average():.4f}s")

```


Exercises

Exercise 1. Create a Multiplier callable class. Its __init__ takes a factor, and calling the instance multiplies the argument by that factor. For example, double = Multiplier(2); double(5) returns 10. Also make it work with map(): list(map(Multiplier(3), [1, 2, 3])) returns [3, 6, 9].

Solution to Exercise 1
class Multiplier:
    def __init__(self, factor):
        self.factor = factor

    def __call__(self, x):
        return x * self.factor

double = Multiplier(2)
print(double(5))   # 10
print(double(100)) # 200

triple = Multiplier(3)
print(list(map(triple, [1, 2, 3])))  # [3, 6, 9]

Exercise 2. Write a CallCounter callable class that wraps any function. Each time the instance is called, it forwards arguments to the wrapped function and increments a count attribute. Demonstrate by wrapping a square function and showing the call count after several invocations.

Solution to Exercise 2
class CallCounter:
    def __init__(self, func):
        self.func = func
        self.count = 0

    def __call__(self, *args, **kwargs):
        self.count += 1
        return self.func(*args, **kwargs)

def square(x):
    return x ** 2

counted_square = CallCounter(square)
print(counted_square(5))  # 25
print(counted_square(3))  # 9
print(counted_square(7))  # 49
print(f"Called {counted_square.count} times")  # Called 3 times

Exercise 3. Build a Pipeline callable class that chains multiple functions together. Its __init__ takes a list of functions. Calling the instance with a value passes it through each function in sequence. For example, Pipeline([str.strip, str.lower, str.title])(" hello WORLD ") returns "Hello World".

Solution to Exercise 3
class Pipeline:
    def __init__(self, functions):
        self.functions = functions

    def __call__(self, value):
        result = value
        for func in self.functions:
            result = func(result)
        return result

clean = Pipeline([str.strip, str.lower, str.title])
print(clean("  hello WORLD  "))  # Hello World

math_pipe = Pipeline([abs, float, lambda x: x ** 2])
print(math_pipe(-5))  # 25.0

Exercise 4. Explain why the following code does not make the instance callable, even though __call__ is assigned as an instance attribute. What does this reveal about how Python looks up dunder methods?

```python class Foo: pass

f = Foo() f.call = lambda: "called!" f() # TypeError: 'Foo' object is not callable ```

Solution to Exercise 4

Python looks up dunder methods on the class (via type(obj)), not on the instance. When you write f(), Python does type(f).__call__(f), which looks for __call__ on Foo, not on f itself. Since Foo doesn't define __call__, the call fails with TypeError.

The instance attribute f.__call__ exists but is never consulted by the call machinery. To make it work, you must define __call__ on the class:

class Foo:
    def __call__(self):
        return "called!"

f = Foo()
f()  # "called!" — works because __call__ is on the class

This class-based lookup rule applies to all dunder methods (__len__, __iter__, __enter__, etc.) and is part of Python's data model. It exists so that metaclasses and type behavior remain consistent — the class defines the behavior, not individual instances.


Exercise 5. Compare a closure and a callable class for implementing a rate limiter that allows at most n calls per interval seconds. Implement both approaches. Then explain which is easier to extend if you later need to add a reset() method and track the total number of rejected calls.

Solution to Exercise 5
import time

# Closure approach
def make_rate_limiter(max_calls, interval):
    timestamps = []

    def limiter():
        now = time.time()
        # Remove expired timestamps
        while timestamps and now - timestamps[0] > interval:
            timestamps.pop(0)
        if len(timestamps) < max_calls:
            timestamps.append(now)
            return True
        return False

    return limiter

# Callable class approach
class RateLimiter:
    def __init__(self, max_calls, interval):
        self.max_calls = max_calls
        self.interval = interval
        self._timestamps = []
        self.rejected = 0

    def __call__(self):
        now = time.time()
        while self._timestamps and now - self._timestamps[0] > self.interval:
            self._timestamps.pop(0)
        if len(self._timestamps) < self.max_calls:
            self._timestamps.append(now)
            return True
        self.rejected += 1
        return False

    def reset(self):
        self._timestamps.clear()
        self.rejected = 0

# Usage
limiter = RateLimiter(max_calls=3, interval=1.0)
print(limiter())  # True
print(limiter())  # True
print(limiter())  # True
print(limiter())  # False — rate limited
print(f"Rejected: {limiter.rejected}")  # 1

The closure works but cannot easily gain a reset() method or a rejected counter without resorting to mutable containers (e.g., a dict) shared between inner functions — which is awkward. The callable class naturally supports additional methods and attributes, making it the better choice when the "function" needs to grow beyond simple state.