Python: Duck Type Compatibility and Consistent-With


To be honest, I did such a mistake quite often — I mean, I did this redundant thing, something like below:

from typing import Iterable

def sum_of_squares(x: Iterable[float | int]) -> float:
n, s = len(x), sum(x)
return sum((x - s/n)**2)

I always thought I was making the user’s life easier, thanks to clarifying that x can include both integers and floating-point numbers.

Was I? I don’t know. For sure, I was making the code verbose. A person who does not know that int is a duck type of float may think, why only float? On the other hand, we should not write the code in a way that makes easy to understand by those who do not know. Of course, there are some limits, but I don’t think this situation crosses a line. Besides, anyone who knows Python a little bit should know that where a float is expected, an int can be used; this is rather common knowledge. Anyway, this is one of the reasons why I’m writing this article — so that my readers know that not only can an int be used dynamically instead of a float, but also that this is fine from a static checkers point of view.

Let’s return to the sum_of_squares() function. When you know about duck type compatibility, the concise version is as clear but shorter and thus cleaner:

from typing import Iterable

def sum_of_squares(x: Iterable[float]) -> float:
n, s = len(x), sum(x)
return sum((x - s/n)**2)

So, I could say that my lack of Python knowledge made me think I was doing a favor to the users of my code — now I know that I wasn’t.

With collection.namedtupes and typing.NamedTuples, the situation is similar, with a small difference. Both these types are subtypes of the regular tuple type, and this is why they are consistent-with it.

That’s why the below annotation is… Well, it’s not the best:

from collections import namedtuple
from typing import NamedTuple

def join_names(names: tuple | namedtuple | NamedTuple) -> str:
return " ".join(names)

The function itself is not the smartest among those I’ve written, but that’s not the point. The point is, if you want to accept a tuple, a namedtuple and a NamedTuple, you can do it this way:

def join_names(names: tuple) -> str:
return " ".join(names)

However, if you want to accept only one of the two named tuples, you can type hint it, for example:

from collections import namedtuple

def join_names(names: namedtuple) -> str:
return " ".join(names)

And here, only instances of collections.namedtuple and of its subclasses can be used. You could of course indicate typing.NamedTuple the same way, and then a collections.namedtuple could not be used. Remember, if T1 is consistent-with T2, it does not mean that T2 is consistent-with T1.

Remember, if T1 is consistent-with T2, it does not mean that T2 is consistent-with T1.

We learned what consistent-with and duck type compatibility mean. Don’t be afraid to use this knowledge in your code. You know how to respond to the following questions: “Why only float? What if I want to use an int?”

¹ That sum_of_squares() defined that way does not accept a generator makes plenty of sense. To see why, analyze the function’s body, keeping in mind how generators work.

Note that calculating len(x) would consume the generator — so, the function would not be able to calculate the sum of x. Look:

>>> sum_of_squares((i for i in (1., 2, 34)))
Traceback (most recent call last):
...
n, s = len(x), sum(x)
^^^^^^
TypeError: object of type 'generator' has no len()

Pylance screams:

Screenshot from Visual Studio Code and Pylance: generators are not accepted by the sum_of_squares() function

mypy does not like it, either:

error: Argument 1 to "sum_of_squares" has incompatible type 
"Generator[float, None, None]"; expected "Sequence[Union[float, int]]"
[arg-type]

Do you see how using a static type checker can help you catch errors that otherwise would be caught at runtime?

So, kudos to type hinting? Yes — but kudos to good type hinting!



Source link

Leave a Comment