Constants and pure functions in Python: how to do it right
Under the functional programming paradigm, functions should be pure: the same input always produces the same output, with no side effects. But sometimes theory clashes with reality. When you use constants you start to depend on scope. There are variables external to the function. Constants, by definition, don't break purity since they are... constant. The result is predictable. However, we make testing harder, since we have to import them or create mocks, the code starts to be less readable, becomes less portable, and we also add an element of fragility since we can't control their value or the location of those external values.
An example of the above would be:
TAX = 0.21
def calculate_total(price: float) -> float:
return price + (price * TAX)
What happens if the tax changes between countries? And what if wherever I call calculate_total already has its own TAX?
Let me give you some solutions.
Dependency injection with functools.partial
You can use partial application with functools.partial. The base functions receive everything as parameters and then we "fix" the constants to create the final versions.
from functools import partial
def calculate_total(tax: float, price: float) -> float:
return price + (price * tax)
def apply_discount(discount: float, price: float) -> float:
return price - discount
TAX = 0.21
BASE_DISCOUNT = 5.0
calculate_total_spain = partial(calculate_total, TAX)
apply_fixed_discount = partial(apply_discount, BASE_DISCOUNT)
print(calculate_total_spain(100)) # 121.0
Here we gain maximum purity. The base functions are one hundred percent reusable, easy to test (you just pass different configurations as arguments) and isolated from the environment.
The cost is some visual complexity if your team isn't used to partial.
Closures
Another way is to encapsulate the functions inside a constructor function, a kind of factory.
def create_pricing_system(tax: float, discount: float):
def calculate_total(price: float) -> float:
return price + (price * tax)
def apply_discount(price: float) -> float:
return price - discount
def process_invoice(price: float) -> float:
return apply_discount(calculate_total(price))
return calculate_total, apply_discount, process_invoice
calculate, discount_fn, process = create_pricing_system(0.21, 5.0)
The constants are trapped in the closure, and we logically group the functions that share context without resorting to classes, thus avoiding mutable state.
The downside is that it can make reading harder if the inner functions grow too large.
Conclusions
Python is multiparadigm, so you're not forced to marry a single approach. The important thing is to be aware of the trade-off: the more a function looks outward, the easier it is to write, but the harder it is to test in isolation.
This work is under a Attribution-NonCommercial-NoDerivatives 4.0 International license.
Support me on Ko-fi
Comments
There are no comments yet.