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.