The Zen of Polymorphism
Brett Slatkin | 2024-10-10 | PyCon NL
Build a calculator program
"1 + 2"
3
1
2
+
1
2
+
Declare objects to represent formulas
class Integer:
def __init__(self, value):
self.value = value
class Add:
def __init__(self, left, right):
self.left = left
self.right = right
class Multiply:
def __init__(self, left, right):
self.left = left
self.right = right
1
+
*
Parse a formula into objects
formula = "(3 + 5) * (4 + 7)"
tree = parse_formula(formula)
# tree == Multiply(
# Add(Integer(3), Integer(5)),
# Add(Integer(4), Integer(7)),
# )
3
5
+
4
7
+
*
Three ways to calculate the result
1. One big function
Implement calculate using isinstance checks
def calculate(node):
if isinstance(node, Integer):
return node.value
elif isinstance(node, Add):
return calculate(node.left) + calculate(node.right)
elif isinstance(node, Multiply):
return calculate(node.left) * calculate(node.right)
else:
raise NotImplementedError
Exercise the calculate function
assert calculate(tree) == 88
# What it's doing
calculate(tree)
calculate(node.left)
calculate(node.left) == 3
calculate(node.right) == 5
== 3 + 5 == 8
calculate(node.right)
calculate(node.left) == 4
calculate(node.right) == 7
== 4 + 7 == 11
== 8 * 11 == 88
3
5
+
4
7
+
*
Add a new node type
class Power:
def __init__(self, base, exponent):
self.base = base
self.exponent = exponent
def calculate(node):
if isinstance(node, Integer): ...
elif isinstance(node, Add): ...
elif isinstance(node, Multiply): ...
elif isinstance(node, Power):
return calculate(node.base) ** calculate(node.exponent)
else: ...
Use a new node type
formula = "(2 ^ 3) + 4"
tree = parse_formula(formula)
# tree == Add(
# Power(
# Integer(2),
# Integer(3),
# ),
# Integer(4),
# )
assert calculate(tree) == 12
# What it's doing
calculate(tree)
calculate(node.left)
calculate(node.base) == 2
calculate(node.exponent) == 3
== 2 ** 3 == 8
calculate(node.right) == 4
== 8 + 4 == 12
Add a node subclass
class PositiveInteger(Integer):
def __init__(self, value):
assert value > 0
super().__init__(value)
x = PositiveInteger(3)
assert isinstance(x, Integer)
assert calculate(x) == 3
Add a new function
def pretty(node):
if isinstance(node, Integer):
return f"{node.value}"
elif isinstance(node, Add):
return f"({pretty(node.left)} + {pretty(node.right)})"
elif isinstance(node, Multiply):
return f"({pretty(node.left)} * {pretty(node.right)})"
elif isinstance(node, Power):
return f"({pretty(node.base)} ^ {pretty(node.exponent)})"
else:
raise NotImplementedError
assert pretty(tree) == "((3 + 5) * (4 + 7))"
Summary for "one big function"
Pros
Cons
2. Object-oriented programming
Implement calculate using polymorphism
class Node:
def calculate(self):
raise NotImplementedError
class Integer(Node):
...
def calculate(self):
return self.value
class Add(Node):
...
def calculate(self):
return (
self.left.calculate() +
self.right.calculate()
)
class Multiply(Node):
...
def calculate(self):
return (
self.left.calculate() *
self.right.calculate()
)
Exercise the calculate method
# One big function
assert calculate(tree) == 88
# What it's doing
calculate(tree)
calculate(node.left)
calculate(node.left) == 3
calculate(node.right) == 5
== 3 + 5 == 8
calculate(node.right)
calculate(node.left) == 4
calculate(node.right) == 7
== 4 + 7 == 11
== 8 * 11 == 88
# OOP
assert tree.calculate() == 88
# What it's doing
Multiply.calculate(tree)
Add.calculate(self.left)
Integer.calculate(self.left) == 3
Integer.calculate(self.right) == 5
== 3 + 5 == 8
Add.calculate(self.right)
Integer.calculate(self.left) == 4
Integer.calculate(self.right) == 7
== 4 + 7 == 11
== 8 * 11 == 88
Add a new node type
class Power(Node):
def __init__(self, base, exponent):
self.base = base
self.exponent = exponent
def calculate(self):
return (
self.base.calculate() **
self.exponent.calculate()
)
Use a new node type
formula = "(2 ^ 3) + 4"
tree = parse_formula(formula)
# tree == Add(
# Power(
# Integer(2),
# Integer(3),
# ),
# Integer(4),
# )
assert tree.calculate() == 12
# What it's doing
Add.calculate(tree)
Power.calculate(self.left)
Integer.calculate(self.base) == 2
Integer.calculate(self.exponent) == 3
== 2 ** 3 == 8
Integer.calculate(self.right) == 4
== 8 + 4 == 12
Add will dispatch "calculate" calls to Power
Add a node subclass
class PositiveInteger(Integer):
def __init__(self, value):
assert value > 0
super().__init__(value)
tree = Add(PositiveInteger(3), Integer(-1))
assert tree.calculate() == 2
Add another method
class Node:
def calculate(self):
raise NotImplementedError
def pretty(self):
raise NotImplementedError
Implement pretty for all node classes
class Multiply(Node):
...
def pretty(self):
return (
f"({self.left.pretty()} *"
f" {self.right.pretty()})"
)
class Power(Node):
...
def pretty(self):
return (
f"({self.base.pretty()} ^"
f" {self.exponent.pretty()})"
)
class Integer(Node):
...
def pretty(self):
return f"{self.value}"
class Add(Node):
...
def pretty(self):
return (
f"({self.left.pretty()} +"
f" {self.right.pretty()})"
)
Use the pretty method
formula = "2 * 10 ^ (3 + 4)"
tree = parse_formula(formula)
# tree == Multiply(
# Integer(2),
# Power(
# Integer(10),
# Add(Integer(3), Integer(4)),
# )
# )
assert tree.pretty() == "(2 * (10 ^ (3 + 4)))"
Imagine we need more methods
class Node:
def calculate(self):
raise NotImplementedError
def pretty(self):
raise NotImplementedError
def solve(self):
raise NotImplementedError
def derivative(self):
raise NotImplementedError
# And 20 more...
What you get with OOP
One file per class
What really you want
One file per feature
solve.py
def solve_add(...):
def solve_multiply(...):
def solve_power(...):
def solve_integer(...):
...
derivative.py
def deriv_add(...):
def deriv_multiply(...):
def deriv_power(...):
def deriv_integer(...):
...
pretty.py
def pretty_add(...):
def pretty_multi(...):
def pretty_power(...):
def pretty_integer(...):
...
calculate.py
def calc_add(...):
def calc_multiply(...):
def calc_power(...):
def calc_integer(...):
...
integer.py
class Integer:
def calc(...):
def pretty(...):
def solve(...):
def derivative(...):
...
power.py
class Power:
def calc(...):
def pretty(...):
def solve(...):
def derivative(...):
...
add.py
class Add:
def calc(...):
def pretty(...):
def solve(...):
def derivative(...):
...
multiply.py
class Multiply:
def calc(...):
def pretty(...):
def solve(...):
def derivative(...):
...
What you get with OOP
Scattered dependencies
What really you want
Isolated dependencies
numerical library
integer.py
power.py
multiply.py
add.py
formatting library
symbolic math library
differentiation library
derivative.py
solve.py
pretty.py
calculate.py
Summary for "OOP"
Cons
Pros
3: Dynamic dispatch
Background: Using the singledispatch decorator
from functools import singledispatch
@singledispatch
def my_print(value):
print(f"Unexpected: {type(value)}, {value!r}")
@my_print.register(int)
def _(value):
print("Integer!", value)
@my_print.register(float)
def _(value):
print("Float!", value)
Background: Calling a singledispatch function
my_print(10)
my_print(1.23)
my_print("hello")
>>>
Integer! 10
Float! 1.23
Unexpected: <class 'str'>, 'hello'
Implement calculate using singledispatch
@singledispatch
def calculate(node):
raise NotImplementedError
@calculate.register(Integer)
def _(node):
return node.value
@calculate.register(Add)
def _(node):
return (
calculate(node.left) +
calculate(node.right)
)
@calculate.register(Multiply)
def _(node):
return (
calculate(node.left) *
calculate(node.right)
)
Exercise the calculate dispatching function
formula = "(2 + 3) * 4"
tree = parse_formula(formula)
# tree == Multiply(
# Add(
# Integer(2),
# Integer(3),
# ),
# Integer(4),
# )
assert calculate(tree) == 20
# What it's doing
calculate(tree)
calculate(node.left)
calculate(node.left) == 2
calculate(node.right) == 3
== 2 + 3 == 5
calculate(node.right) == 4
== 5 * 4 == 20
Add a new node type
class Power:
def __init__(self, base, exponent):
self.base = base
self.exponent = exponent
@calculate.register(Power)
def _(node):
return (
calculate(node.base) **
calculate(node.exponent)
)
Use a new node type
formula = "(2 ^ 3) + 4"
tree = parse_formula(formula)
# tree == Add(
# Power(
# Integer(2),
# Integer(3),
# ),
# Integer(4),
# )
assert calculate(tree) == 12
# What it's doing
calculate(tree)
calculate(node.left)
calculate(node.base) == 2
calculate(node.exponent) == 3
== 2 ** 3 == 8
calculate(node.right) == 4
== 8 + 4 == 12
Add a node subclass
class PositiveInteger(Integer):
def __init__(self, value):
assert value > 0
super().__init__(value)
tree = Add(PositiveInteger(3), Integer(-1))
assert calculate(tree) == 2
Add another function
@pretty.register(Multiply)
def _(node):
return (
f"({pretty(node.left)} *"
f" {pretty(node.right)})"
)
@pretty.register(Power)
def _(node):
return (
f"({pretty(node.base)} ^"
f" {pretty(node.exponent)})"
)
@singledispatch
def pretty(node):
raise NotImplementedError
@pretty.register(Integer)
def _(node):
return f"{node.value}"
@pretty.register(Add)
def _(node):
return (
f"({pretty(node.left)} +"
f" {pretty(node.right)})"
)
Use the pretty dispatching function
formula = "2 * 10 ^ (3 + 4)"
tree = parse_formula(formula)
# tree == Multiply(
# Integer(2),
# Power(
# Integer(10),
# Add(Integer(3), Integer(4)),
# )
# )
assert pretty(tree) == "(2 * (10 ^ (3 + 4)))"
Summary for "dynamic dispatch"
Cons
Pros
Bonus: How does singledispatch work
from collections import defaultdict
dispatch_map = defaultdict(dict)
def register_dispatch(dispatch_func, kind, func):
kind_map = dispatch_map[dispatch_func]
kind_map[kind] = func
def call_dispatch(dispatch_func, value, *args, **kwargs):
kind_map = dispatch_map[dispatch_func]
for kind in type(value).__mro__:
if kind in kind_map:
func = kind_map[kind]
return func(value, *args, **kwargs)
return dispatch_func(value, *args, **kwargs)
Bonus: How does singledispatch work
from functools import wraps
def my_dispatch(dispatch_func):
@wraps(dispatch_func)
def inner(*args, **kwargs):
return call_dispatch(dispatch_func, *args, **kwargs)
setattr(inner, "register", register_helper(dispatch_func))
return inner
def register_helper(dispatch_func):
def outer(kind):
def decorator(func):
register_dispatch(dispatch_func, kind, func)
return func
return decorator
return outer
Bonus: How does singledispatch work
@my_dispatch
def my_print(value):
print(f"Default implementation: {value}")
@my_print.register(int)
def _(value):
print(f"Integer print: {value}")
@my_print.register(float)
def _(value):
print(f"Float print: {value}")
my_print(5)
my_print(1.23)
my_print("unknown")
Conclusion
Slides, code, & 35% book discount
github.com/bslatkin/pyconnl24
@haxor
onebigfluke.com