Modifying Operators with the Interceptor Pattern
The interceptor design pattern does what?
In this framework, an interceptor adds to the behaviour of an operator. This example implements an interceptor that changes any selector to an elitist selector.
To begin, consider a simple example. Declare a class NumberBox with attribute .value
and method .increment
. For now, calling .increment
increments .value
by 1.
[12]:
from typing import Self
from typing import override
[13]:
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(f"Increased by {new_value - old_value}")
Check that NumberBox
behaves as expected:
[14]:
nb = NumberBox(1)
check_increment(nb)
Initial value is 1
Increased by 1
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
.
[15]:
class NumberBoxBy2(NumberBox):
@override
def increment(self: Self)-> None:
self.value = self.value + 2
[16]:
nb_new = NumberBoxBy2(1)
check_increment(nb_new)
Initial value is 1
Increased by 2
This approach is inflexible, however. For each class with .increment
that increases its .value
, one must extend it separately. [STRIKEOUT:It would be helpful] to have a way to change the behaviour of .increment
for any class that has that method.
Suppose there is a function that takes a NumberBox
and changes its behaviour, so that its .increment
now increases .value
by 2. The signature of that function is as follows:
[17]:
def by2(sel: NumberBox):
pass
[18]:
from typing import Any, Callable
from functools import wraps
from types import MethodType
[19]:
def by2(numbox: NumberBox):
pass
In this example, by2
creates a wrapper of the .increment
method of its argument numbox
, then replaces the original .increment
with that wrapper.
[20]:
def by2(numbox: NumberBox):
def wrap_function(original_increment:
Callable[[NumberBox], None]) -> Callable:
@wraps(original_increment)
def wrapper(self: NumberBox) -> None:
original_increment(self)
original_increment(self)
return wrapper
setattr(numbox, 'increment',
MethodType(
wrap_function(numbox.increment.__func__), # type:ignore
numbox))
[21]:
new_nb = NumberBox(1)
check_increment(new_nb)
Initial value is 1
Increased by 1
[22]:
modified_nb = NumberBox(1)
by2(modified_nb)
check_increment(modified_nb)
Initial value is 1
Increased by 2
This use case is common in the framework. For example, all selectors share methods named .select
and .select_to_many
. It would be good to be able to change the behaviour of an instance of a selector without modifying or extending its class.
[23]:
from evokit.core import Selector, Population, Individual
from typing import Callable, Tuple, TypeVar, Any
D = TypeVar("D", bound=Individual)
def Elitist(sel: Selector[D]) -> Selector:
def wrap_function(original_select_to_many:
Callable[[Selector[D], Population[D]],
Tuple[D, ...]]) -> Callable:
@wraps(original_select_to_many)
def wrapper(self: Selector[D],
population: Population[D],
*args: Any, **kwargs: Any) -> Tuple[D, ...]:
population_best: D = population.best()
my_best: D
# Monkey-patch an attribute onto the selector. If the
# Current name is taken from a randomly generated SSH pubkey.
# Nobody else will use a name *this* absurd.
UBER_SECRET_BEST_INDIVIDUAL_NAME = "___g1AfoA2NMh8ZZCmRJbwFcne4jS1f3Y2TRPIvBmVXQP"
if not hasattr(self, UBER_SECRET_BEST_INDIVIDUAL_NAME):
setattr(self, UBER_SECRET_BEST_INDIVIDUAL_NAME, population_best.copy())
hof_individual: D
my_best = getattr(self, UBER_SECRET_BEST_INDIVIDUAL_NAME)
if my_best.fitness > population_best.fitness:
hof_individual = my_best
#print("use my best", end="")
else:
hof_individual = population_best
setattr(self, UBER_SECRET_BEST_INDIVIDUAL_NAME, population_best.copy())
#print("use population best", end="")
#print(f", {str(hof_individual)}score is m{my_best.fitness} > p{population_best.fitness}")
# Acquire results of the original selector
results: Tuple[D, ...] = \
original_select_to_many(self, population, *args, **kwargs)
# Append the best individual to results
return (*results, hof_individual.copy())
return wrapper
setattr(sel, 'select_to_many',
MethodType(
wrap_function(sel.select_to_many.__func__), # type:ignore
sel))
return sel