Modifying Operators with the Interceptor Pattern

[1]:
from typing import Any, Callable
from functools import wraps
from types import MethodType

In software engineering, the interceptor design pattern changes the behaviour of an existing service [1]. This approach is used, for example, to implement the evokit.evolvables.selectors.Elitist wrapper.

This short tutorial illustrates how this is done. You can define new wrappers using this approach.

A Trivial Problem

To begin, consider a simple example. Declare a class NumberBox with attribute .value and method .increment. For now, calling .increment increments .value by 1.

The problem, then, is to find a way to modify .increment so that it increments .value by 2, 3, and so on.

[2]:
from typing import Self
from typing import override
[3]:
class NumberBox:
    def __init__(self: Self, value: int)-> None:
        self.value: int = value

    def increment(self: Self)-> None:
        self.value = self.value + 1

def check_increment(nb: NumberBox):
    old_value = nb.value
    print(f"Initial value is {old_value}")
    new_value = (nb.increment(), nb.value)[-1]
    print("Calling `.increment()` increases the value by"
          f" {new_value - old_value}")

Check that NumberBox behaves as expected.

[4]:
nb = NumberBox(1)
check_increment(nb)
Initial value is 1
Calling `.increment()` increases the value by 1

Attempt 1: Overriding .increment

There are many ways to change the number .increment increases .value by. For example, one can define a new class, NumberBoxBy2, which extends NumberBox and overrides .increment.

[5]:
class NumberBoxBy2(NumberBox):
    @override
    def increment(self: Self)-> None:
        self.value = self.value + 2

This approach is inflexible, however. NumberBoxBy2.increment now increases .value by exactly 2; changing the behaviour of .increment further requires extending NumberBoxBy2.

[6]:
nb_new = NumberBoxBy2(1)
check_increment(nb_new)
Initial value is 1
Calling `.increment()` increases the value by 2

Attempt 2: Constructing an Interceptor

The correct approach follows. Suppose there is a function by1more: NumberBox -> NumberBox that, when given an NumberBox, returns a NumberBox whose .increment increases .value by 1 more.

def by1more(sel: NumberBox) -> NumberBox:
    pass

The key is using functools.wraps. A minimal example follows:

[7]:
def by1more(numbox: NumberBox):
    def wrap_function(original_increment:
                      Callable[[NumberBox], None]) -> Callable:

        @wraps(original_increment)
        def wrapper(self: NumberBox) -> None:
            original_increment(self)
            self.value = self.value + 1
        return wrapper

    setattr(numbox, 'increment',
            MethodType(
                wrap_function(numbox.increment.__func__),  # type:ignore
                numbox))
    return numbox
[8]:
new_nb = NumberBox(1)
check_increment(new_nb)
Initial value is 1
Calling `.increment()` increases the value by 1
[9]:
modified_nb = NumberBox(1)
check_increment(by1more(by1more(modified_nb)))
Initial value is 1
Calling `.increment()` increases the value by 3

Note that by1more modifies its argument in-place.