Don't return named tuples in new APIs
snarky.ca
snarky.ca
>>> Foo = namedtuple("Foo", ["bar"])
>>> Baz = namedtuple("Baz", ["qux"])
>>> Foo(bar="hello") == Baz(qux="hello")
True
This also happens with the "new-style" namedtuples (typing.NamedTuple).I like the convenience of namedtuples but I agree with the author: there are enough footguns to prefer other approaches.
>>> temperatures_fahrenheit = np.array([10, 20, 30])
>>> temperatures_celsius = np.array([10, 20, 30])
>>> temperatures_fahrenheit == temperatures_celsius
array([ True, True, True])At risk of belaboring the point or being redundant, GP is making the point that the Celsius vs Fahrenheit meaning of the arrays makes them Semantically different and therefore the equality could Be taken to be misleading when this is taken into account. GP thinks this is nonsense and draws a parallel with named tuples.
You can use frozen=true to "simulate" immutability, but that just overwrites the setter with a dummy implementation, something you (or your very clever coworker) can circumvent by using object.__setattr__()
So you neither get the performance benefits nor the invariants of actual immutability.
This fits pretty well with a lot of other stuff in Python (e.g. there’s no real private members in classes). There’s a bunch of escape hatches that you should avoid (but that can still be useful sometimes), and those usually are pretty obvious (e.g. if you see code using object.__setattr__, something is definitely not right).
Can’t tell whether this is good design or not, but personally I like it.
In [1]: from dataclasses import dataclass
In [2]: @dataclass(frozen=True)
...: class Foo:
...: a: int
...:
In [3]: foo = Foo(5)
In [4]: foo.a = 10
FrozenInstanceError: cannot assign to field 'a'
In [5]: setattr(foo, "a", 10)
FrozenInstanceError: cannot assign to field 'a'
In [6]: object.__setattr__(foo, "a", 10)
In [7]: foo.a
Out[7]: 10What bugs me more about frozen dataclasses is how post-init methods have to use the setattr hack.
- Everything in Python is mutable, including the definitions of constants like `3` and `True`. It's much like "unsafe" in Rust; you can do stupid things, but when you see somebody reaching for `__setattr__` or `ctypes` then you know to take out your magnifying glass on the PR, find a better solution, ban them from the repo, or start searching for a new job.
- Performance-wise, named tuples are sometimes better because more work happens in C for every line of Python, not because of any magic immutability benefits. It's similar to how you should prefer comprehensions to loops (most of the time) if you're stuck with Python but performance still matters a little bit. Yes, maybe still use named tuples for performance reasons, but don't give the credit to immutability.
from typing import NamedTuple
class Point(NamedTuple):
x: int
y: int
z: int
I don't see "don't use namedtuples in APIs" as a useful rule of thumb, to be honest. Ordered and iterable return-types make sense for a lot of APIs. Use them where it makes sense.There are other reasons to reach for namedtuples, for example when order or iterability matter. I don't see a general rule of not using namedtuples in APIs to be that useful. Use the right tool when the need calls for it.
Mostly people just reach for the tool that does what they want, named tuples for tuply stuff, typed dicts for mapping applications, and dataclasses when you actually want a class.
You get the stuff you get from `NamedTuple`, but you can also easily add helper methods to the class as needed. And there are other dataclass goodies (though some things I find to be a bit anti-feature-y).
I agree.
> You get the stuff you get from `NamedTuple`, but you can also easily add helper methods to the class as needed. And there are other dataclass goodies (though some things I find to be a bit anti-feature-y).
I've seen examples where dataclasses were used when order matters, however, hence why I'm not comfortable with a general rule against namedtuples. Sometimes order and iterability matter, and dogmatically reaching for a different data type that doesn't preserve that information might be the wrong choice.
Though the provided options are not immutable and it is much more important than whether you leak o[0] access. dataclass(frozen=True) is the only acceptable alternative.
But yes, they're still the least-bad choice.
(It's surprisingly difficult to implement a rigorous way to detect this vs compile-time constant evaluation though; note that identical objects of certain types are pooled already when generating/loading bytecode files. I don't think any current implementation is smart enough to optimize the following though)
$ python3 -c 'o = object(); print(id(o) is id(o))'
False
$ pypy3 -c 'o = object(); print(id(o) is id(o))'
True
$ jython -c 'o = object(); print(id(o) is id(o))'
True $ python -c "print(int('3') is int('3'))"
True
$ python -c "print(int('300') is int('300'))"
False
Other implementations make different choices.How so?
Tuples are awkward with immutability, if you put mutable things inside them.
In languages with value semantics, nothing about this problem even makes sense, since obviously a dict's key is taken by value if it needs to be stored.
--
Tuple behavior is sensible if you are familiar with discussion of reference semantics (though not as much as if you also support value semantics).
Still, at least we aren't Javascript where the question of using composite keys is answered with "screw you".
obj[JSON.stringify(x)] = y
* Ducks * x = [Symbol('geese')]Tuples are a core concept and fundamental data aggregation tool for Python. However, this post uses a trivial `Point()` class strawman to try to shoot down the idea of using tuples at all. IMO that is fighting the language and every existing API that either accepts tuple inputs or returns tuple outputs. That is a vast ecosystem.
According the glossary a named tuple "any type or class that inherits from tuple and whose indexable elements are also accessible using named attributes." Presumably, no one disputes that having names improves readability. So really this weak post argues against tuples themselves.
I trust that the maintainers of the Python language & the Python Standard Library are not going to change their tuple-using APIs in a breaking way, without a clear signal (like a major-version bump).
I do not extend that same trust to other Python projects. Maybe I extend that same trust to projects that demonstrate proper use of Semantic Versioning, but not to others.
Using something other than tuples trades some performance for some stability, which is a trade I’m OK with.
If you store all your data is dataclasses, you end-up having to either convert back to dictionaries or having to rebuild all that tooling. Python's abstract syntax trees are an example. If nodes had been represented with native dictionaries, then pprint would work right out the box. But with every node being its own class, a custom pretty printer is needed.
Dataclasses are cool but people should have a strong preference for Python's native types: list, tuple, dict, and set. Those work with just about everything. In contrast, a new dataclass is opaque and doesn't work with any existing tooling.
Also you can easily convert a dataclass to a dict with dataclasses.asdict. Not so easy to go from dict to dataclass though
Or you can just keep returning namedtuple instead of something else, because then you absolutely can skimp on testing whether what you return does, in fact, satisfies the namedtuple's interface.
Consider the object returned by os.stat. It allows getting terms by index:
>>> import os
>>> os.stat("/dev/null")
os.stat_result(st_mode=8630, st_ino=336, st_dev=-2065165533, st_nlink=1,
st_uid=0, st_gid=0, st_size=0, st_atime=1730960121, st_mtime=1730981460,
st_ctime=1730981460)
>>> os.stat("/dev/null")[0]
8630
>>> os.stat("/dev/null")[-1]
1730981720
But that list of 10 values became locked because people do: (mode, ino, dev, nlink, uid, gid, size, atime, mtime, ctime) = o.stat(filename)
Which means new fields are not accessible by indexing, only attribute lookup: >>> os.stat("/dev/null").st_blksize
65536
Assuming there wasn't the historical baggage which made os.stat the way it is, why would namedtuple still be the more Pythonic solution, and simpler than a frozen dataclass?(Historically, os.stat originally returned a tuple, then migrated to os.stat_result and named attributes, both for readability and to allow new fields, but keeping indexing for backwards compatibility support.)
The number of characters and lines to access the members of namedtuple is significantly less than dataclass.
dataclass would have similar issues if st_dev was now defined as a string whereas namedtuple would not.
Whilst there maybe edge cases in using simpler constructs, in scripting languages we accept the edge cases as it pays off in 99.99% of cases to simply ignore them. If something goes wrong, you catch the exception "Ask for forgiveness not for permission" if you want to look up the concept.
Namedtuple is not strictly simpler. It implements additional features which would not be in an frozen dataclass, which makes "simpler" a personal bias.
I've been using Python continuously since 1998 and well remember the "look before you leap" vs. "ask for forgiveness not for permission" debate from the bygone comp.lang.python forum. And I learned the concept of "easier to ask for forgiveness" from the 60 Minutes interview with Grace Hopper back in the 1980s.
That have nothing to do with this issue, which is one of taking on an API burden without due consideration simply because it's less typing.
>>> Point = dataclasses.make_dataclass("Point", ["x", "y", "z"])
>>> Point(1, 2, 3)
Point(x=1, y=2, z=3) def __iter__():
yield self.my_field
yield self.my_other_field
Recreates tuple unpacking. class IWantToHaveNamedTupleInterfaceButWasToldTheyAreBad:
def __iter__(self):
return iter(self.__dict__.values())
and voila: @dataclass(frozen=True)
class Point(IWantToHaveNamedTupleInterfaceButWasToldTheyAreBad):
x: int
y: int
p = Point(1, 2)
x, y = pthere was a point a while back where python added __slots__ to classes to help with this; and in practice these days the largest systems are using numpy if they're in python at all
not sure what modern versions do. but in the olden days, if you were creating lots of small objects, tuples were a low-overhead way to do it
Thanks, I hate it. There’s a lot I like about Python, but this is a major pain point.
NamedTuple, TypedDict, Dataclass, Record ... Remember the Zen of Python? „There should be one-- and preferably only one --obvious way to do it“ - it feels like Python has gone way overboard with ways to structure data.
In Javascript everything is an object, you can structurally type them with Typescript and I don’t feel like I’m missing much.
Point(x,y,z)