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