I find this an interesting example to ponder, because I think it actually reveals a weakness in Python’s data model, more than in its type hinting—a weakness inherent in class-based programming that is also found to a considerable extent in just about every dynamic language I can think of, but which is made particularly obvious in Python’s operators.<p>The cause of most of the trouble here is that `lhs + rhs` isn’t <i>simple</i> sugar like a reasonable person might imagine if they don’t think through the implications long enough; but instead, turns into something like this terrific mess:<p><pre><code> def +(lhs, rhs):
lhs_type = type(lhs)
rhs_type = type(rhs)
if do_radd_first := issubclass(rhs, lhs):
if hasattr(rhs_type, '__radd__'):
output = rhs_type.__radd__(rhs, lhs)
if output != NotImplemented:
return output
if hasattr(lhs_type, '__add__'):
output = lhs_type.__add__(lhs, rhs)
if output != NotImplemented:
return output
if not do_radd_first and hasattr(rhs_type, '__radd__'):
output = rhs_type.__radd__(rhs, lhs)
if output != NotImplemented:
return output
raise TypeError(f'unsupported operand type(s) for +: {lhs_type.__name__!r} and {rhs_type.__name__!r}')
</code></pre>
(This is almost certainly imperfect in details, and may be imperfect in larger pieces; it’s a quick sketch based on memory from about nine years ago, plus a quick check of <a href="https://docs.python.org/3/reference/datamodel.html#object.__radd__" rel="nofollow">https://docs.python.org/3/reference/datamodel.html#object.__...</a> where I had completely forgotten about the subclass thing. But I think it’s pretty close. Good luck figuring any of this out as a beginner, though, or even being confident of exactly what it does, because there’s no clear documentation <i>anywhere</i> on it, and the reference material misses details like the handling of NotImplemented in __radd__, so that I’m not in the slightest bit confident that my sketch is correct. If I still worked in Python, I’d probably turn this into a reference-style blog post, but I don’t.)<p>And why is this so? Because with class-based programming, the only place you can attach behaviour to an object or type is <i>on</i> that object or type. Which means that for operator overloading, it must goes on the first operand. But there are many legitimate cases where you <i>can’t</i> do that, and not all operators are commutative (e.g. a - b ≠ b - a in general), so you pretty much <i>have</i> to support the reflected operators, rhs.__radd__(lhs) instead of lhs.__add__(rhs), and then you get worried about subclassing problems, and it all just gets painfully complicated.<p>Is it any wonder, then, that a typing system would have trouble with it? Certainly Addable/RAddable are the wrong level of abstraction: you need a bound that covers both of them in one go, and I doubt you can do that with Protocol or similar, since you’re defining a <i>pair</i> of types, where one has this method, or the other has that method (and good luck handling NotImplemented scenarios). I imagine it needs to be built into the typing module as a new primitive.<p>—⁂—<p>By contrast, in Rust, you might <i>possibly</i> start with a concrete type (the first attempt), but you’d be more likely to just go straight to the fully-correct solution that this example is never able to reach in Python, of being generic with an Add trait bound <<a href="https://doc.rust-lang.org/std/ops/trait.Add.html" rel="nofollow">https://doc.rust-lang.org/std/ops/trait.Add.html</a>>:<p><pre><code> fn slow_add<A: std::ops::Add<B>, B>(a: A, b: B) -> A::Output {
std::thread::sleep(std::time::Duration::from_secs_f32(0.1));
a + b
}
</code></pre>
Certainly Rust does have the advantage of having started with and built upon a type system, rather than retrofitting it. But note how this allows you to use different left-hand side, right-hand side and output types (A::Output is here short for <A as std::ops::Add<B>>::Output, meaning “the type produced by A + B”; Output is what’s called an <i>associated type</i>), and threads the types through fully properly. And note more importantly how this <i>doesn’t run into the __add__/__radd__ problems</i>, because the implementation isn’t attached to the single type A, but is rather defined, as it were, for the (A, B) tuple: when you compile it, it’s like you have a lookup table keyed by a (Lhs, Rhs) tuple. Depending on what sorts of additions the left hand side type defines, the right hand side type may be able to define additions of its own. (If you’re interested in how this is done, so that different libraries can’t define conflicting additions, look up <i>trait implementation coherence</i>.)<p>Rust thus demonstrates one solution to the problem this case exposes with using classes in this way: instead of putting data and behaviour together in classes, separate them.<p>(It’s not <i>all</i> sunshine and roses: some things do map to class structures very nicely, so that implementing them in Rust can be painful and take time to figure out a decent alternative, especially when interacting with existing systems; but in general, I find myself strongly appreciating languages with this sort of data/behaviour division.)<p>—⁂—<p>My favourite demonstration of the advantages of separating data and behaviour is actually iterators:<p>• In Rust, when you implement the Iterator trait on your type, you can call any iterator methods on it—built-in ones like .map() and .filter(), but also methods defined in other extension traits, e.g. <a href="https://docs.rs/itertools/latest/itertools/trait.Itertools.html#method.interleave" rel="nofollow">https://docs.rs/itertools/latest/itertools/trait.Itertools.h...</a>. This is why iterators are very popular in Rust: because they just work, with no trouble.<p>• By contrast, in Python map() and filter() have to be globals, leading to messy code reading order and the preferred alternative approach of list comprehensions/generator expressions (which work pretty well, but <i>are</i> more limited, really only covering map, filter and flat_map in their capabilities).<p>• In JavaScript, you get Array.prototype.{map, filter, …}, and those methods are defined as working on any iterator, not just an array, because otherwise it’d be just too painful—but unless you copy the methods you want (like NodeList has done with forEach, but <i>not</i> any other method!) you can’t just chain things automatically, and you can’t add new methods anywhere.<p>• Ruby has a… <i>different</i> approach to all this, but I can’t remember all that much about it and this comment is long enough already.