Separating type-specific code with singledispatch
Have you ever found yourself writing a litany of if-elif-else statements peppered with isinstance() calls? Notwithstanding error handling, they are often found where your code intersects with APIs; third-party libraries; and services. As it turns out, coalescing complex types – such as pathlib.Path to string, or decimal.Decimal to float or string – is a common occurrence.
But writing a wall of if-statements makes code reuse harder, and it can complicate testing:
# -*- coding: utf-8 -*-
from pathlib import Path
from decimal import Decimal, ROUND_HALF_UP
def convert(o, *, bankers_rounding: bool = True):
if isinstance(o, (str, int, float)):
return o
elif isinstance(o, Path):
return str(o)
elif isinstance(o, Decimal):
if bankers_rounding:
return float(o.quantize(Decimal(".01"), rounding=ROUND_HALF_UP))
return float(o)
else:
raise TypeError(f'Cannot convert {o}')
assert convert(Path('/tmp/hello.txt')) == '/tmp/hello.txt'
assert convert(Decimal('49.995'), bankers_rounding=True) == 50.0
assert convert(Decimal('49.995'), bankers_rounding=False) == 49.995
In this example I have a convert function that converts complex objects into their primitive types, and if it cannot resolve the given object type, it raises a TypeError. There’s also a keyword argument, bankers_rounding intended for the decimal converter.
Let’s quickly test the converter to make sure it works:
>>> json.dumps({"amount": Decimal('49.995')}, default=convert)
'{"amount": 50.0}'
Yep. It does. Remove the default= argument and the dumps function throws an exception because it does not understand how to serialize Decimal.
But now I’ve trapped a number of independent pieces of logic in one function: I can convert data, yes, but how can I easily test that each conversion function actually does what it’s supposed to? Ideally, there would be a clear separation of concerns. And the keyword argument bankers_rounding only applies to the Decimal routine and yet it’s passed to our shared convert function. In a real-world application there might be dozens of converters and keyword arguments.
But I think we can do better. One easy win is to separate the converter logic into distinct functions, one for each type. That has the advantage of letting me test – and use independently – each converter in isolation. That way I specify the keyword arguments I need for the converter functions that need them. The bankers_rounding keyword is not tangled up with converters where it does not apply.
The code for that will look something like this:
def convert_decimal(o, bankers_rounding: bool = False):
if not bankers_rounding:
return str(o)
else:
# ...
# ... etc ...
def convert(o, **kwargs):
if isinstance(o, Path):
return convert_path(o, **kwargs)
else:
# ...
At this point I have built a dispatcher that delegates the act of converting the data to distinct functions. Now I can test the dispatcher and the converters separately. At this point I could call it quits, but I can get rid of the convert dispatcher almost entirely, by offloading the logic of checking the types to a little-known function hidden away in the functools module called singledispatch.
Read More ->
from Planet Python
via read more
No comments:
Post a Comment