The Messenger or Data Transfer Object
passes a package of information around. Most often it carries a
function’s return values. You use tuples and dictionaries for
that, but both rely on indexing. A tuple requires the consumer
to keep track of numerical order. A dict requires
the clumsier d["name"] syntax.
A Messenger is an object with attributes corresponding to the names of the data you pass or return:
# messenger_idiom.py
from typing import Any
class Messenger:
def __init__(self, **kwargs: Any) -> None:
self.__dict__ = kwargs
m: Any = Messenger(info="Spam", b=["x", "y"])
print(vars(m))
#: {'info': 'Spam', 'b': ['x', 'y']}
m.more = 11
print(m.info, m.b, m.more)
#: Spam ['x', 'y'] 11
print(vars(m))
#: {'info': 'Spam', 'b': ['x', 'y'], 'more': 11}The constructor replaces the object’s __dict__
with the dict that the **kwargs
parameter automatically creates. vars(m) returns
that same __dict__, and its output shows that the
attributes and the keyword arguments are one dict:
m.more = 11 adds a key, just as passing
more=11 to the constructor would.
Because **kwargs is the only parameter,
Messenger accepts only keyword arguments.
Messenger("Spam") raises a TypeError.
The * marker from Positional-Only
and Keyword-Only Parameters is unnecessary here, and
def __init__(self, *, **kwargs) is a syntax error,
since a named parameter must follow a bare *.
The m: Any annotation is not decoration. Without
it, the type checker rejects both m.more = 11 and
m.info, since the Messenger class
declares no attributes. Any switches the checker
off for m. You can move that Any into
the class instead of repeating it at every use site, by
declaring a __getattr__() that returns
Any and a __setattr__() that accepts
one. If you declare only the first, the checker still rejects
the write, m.more = 11. The standard library’s stub
for SimpleNamespace declares such a pair (its read
half is __getattribute__(), which intercepts every
attribute access), which is why the next listing needs no
annotation. The price of an ad-hoc attribute bag is that no
checker knows your attribute names. A typo like
m.inof is a runtime AttributeError,
not a static error.
In the standard Python library,
types.SimpleNamespace is a ready-made Messenger.
Here, too, keyword arguments become attributes in the instance’s
__dict__:
# display_namespace.py
from types import SimpleNamespace
m = SimpleNamespace(info="Spam", b=["x", "y"])
print(vars(m))
#: {'info': 'Spam', 'b': ['x', 'y']}
m.more = 11
print(m)
#: namespace(info='Spam', b=['x', 'y'], more=11)
print(m == SimpleNamespace(info="Spam", b=["x", "y"], more=11))
#: TrueThe first print() shows the same instance
__dict__ the hand-rolled version has.
SimpleNamespace adds the rest: a readable
repr() and equality by contents.
Messenger prints as
<Messenger object at 0x...>, and two
Messengers with identical attributes compare
unequal, because it inherits object’s
identity-based equality.
A SimpleNamespace also accepts any name you
invent, so no checker can know which names to expect. Its type
declaration says so: reading any attribute yields
Any, and no checker reports m.inof
here either.
When you want the fields named and checked, declare them. A
@dataclass generates __init__(),
__repr__(), and equality from those declarations,
and produces a mutable record:
# point_dataclass.py
from dataclasses import dataclass
@dataclass
class Point:
x: float
y: float
p = Point(1.0, 2.0)
print(p)
#: Point(x=1.0, y=2.0)
p.x = 3.5
print(p)
#: Point(x=3.5, y=2.0)A NamedTuple declares its fields the same way
but produces an immutable record. typing.NamedTuple
is the class form of the namedtuple() in Containers. Both build
a subclass of tuple whose positions also have
names, but the class form declares a type for each field, so a
checker knows a Color’s r is an
int while the functional form leaves it unknown.
Because it is a tuple underneath, you can read each field by
name or by position:
# color_namedtuple.py
from typing import NamedTuple
class Color(NamedTuple):
r: int
g: int
b: int
red = Color(255, 0, 0)
print(red)
#: Color(r=255, g=0, b=0)
print(red.r, red[0])
#: 255 255
try:
red.r = 9 # type: ignore
except AttributeError as e:
print(type(e).__name__)
#: AttributeError
print(red._replace(g=128))
#: Color(r=255, g=128, b=0)
print(red._asdict(), Color._fields)
#: {'r': 255, 'g': 0, 'b': 0} ('r', 'g', 'b')Printing a NamedTuple gives the same readable
output a data class gives. A bare tuple prints
(255, 0, 0) and leaves you counting positions.
Assigning to a field raises an AttributeError, and
ty reports it before the program runs. The
attribute bag catches nothing; a declared field catches this.
Since nothing can mutate the fields, _replace()
produces an updated copy. copy.replace() from The
General Form of replace() does the same job for
any immutable record, including a frozen data class.
Immutability also makes the record hashable, so a
Color can key a dict or join a
set.
The immutability guarantee reaches the fields, not the
objects they name. A NamedTuple holding a list
still lets that list change, the same leak frozen=True
has. Nor can you hash such a record, whether or not anyone
mutates the list, because hashing a tuple hashes its contents.
An immutable record needs immutable fields.
The leading underscore on _replace(),
_asdict(), and _fields does not mean
private. NamedTuple marks its own members that way
so they cannot collide with a field you name. A record can
declare a field called replace or
fields.
A function that computes two results can return them in a
NamedTuple:
# fetch_stats.py
from typing import NamedTuple
class Stats(NamedTuple):
mean: float
count: int
def summarize(data: list[float]) -> Stats:
return Stats(sum(data) / len(data), len(data))
print(summarize([2.0, 4.0, 6.0]))
#: Stats(mean=4.0, count=3)
mean, count = summarize([1.0, 3.0]) # Unpacks like a tuple
print(mean, count)
#: 2.0 2Without Stats you annotate the return as
tuple[float, int] and return a bare tuple. Every
caller then owns the knowledge that position 0 is the mean and
position 1 is the count, knowledge the code no longer states
anywhere. Stats names the fields and documents
itself at each call site, and because a NamedTuple
is a tuple, you can unpack it.
A data class cannot do that last part.
mean, count = summarize(data) against a
@dataclass version of Stats raises a
TypeError, since a data class is not iterable.
dataclasses.astuple() converts one when you need
the positional form. It recurses, though: a nested data class
comes back as a nested tuple, and astuple()
deep-copies every other field rather than sharing it.
A NamedTuple inherits its equality from
tuple: positional and type-blind. Any tuple-shaped
value with the same contents compares equal, including a
different record type that happens to have the same shape:
# still_a_tuple.py
from dataclasses import dataclass
from typing import NamedTuple
class Color(NamedTuple):
r: int
g: int
b: int
class Dimensions(NamedTuple):
width: int
height: int
depth: int
print(Color(1, 2, 3) == Dimensions(1, 2, 3))
#: True
print(Color(1, 2, 3) == (1, 2, 3))
#: True
print(Color(1, 2, 3) < Dimensions(1, 2, 4))
#: True
@dataclass(frozen=True)
class FrozenColor:
r: int
g: int
b: int
@dataclass(frozen=True)
class FrozenDimensions:
width: int
height: int
depth: int
print(FrozenColor(1, 2, 3) == FrozenDimensions(1, 2, 3))
#: False
try:
FrozenColor(1, 2, 3) < FrozenColor(1, 2, 4) # type: ignore
except TypeError as e:
print(type(e).__name__)
#: TypeErrorColor and Dimensions mean different
things, but the first comparison cannot tell them apart. The
frozen data classes can, because a dataclass’s generated
__eq__() checks the class before the fields.
A NamedTuple inherits ordering from
tuple the same way, and that ordering is as
type-blind as equality. Sorting a list of Colors
orders them by r, then g, then
b, with nothing in the code declaring that intent.
A frozen data class refuses the comparison instead.
< between two FrozenColors raises a
TypeError unless the decorator receives
order=True, and a comparison between two different
frozen types raises one even then.
Tuple behavior shows up in serialization too.
json.dumps(Color(1, 2, 3)) writes the array
[1, 2, 3], since json sees a sequence
and the field names never reach the output. Converting first,
json.dumps(Color(1, 2, 3)._asdict()), writes
{"r": 1, "g": 2, "b": 3}. json.dumps()
on a data class raises a TypeError instead. That is
the safer failure of the two, because the array version loses
the names without saying so.
Use SimpleNamespace for an ad-hoc bag of
attributes, a @dataclass for a typed mutable
record, and a NamedTuple for a typed immutable one.
The hand-rolled Messenger is worth writing only to
show how SimpleNamespace works underneath.
Between the two typed records, the deciding question is
whether tuple behavior is a feature. Choose
NamedTuple when it is: unpacking, multiple return
values, compatibility with code that expects a tuple. Choose a
frozen
dataclass when a record should be a distinct type that
equals only its own kind, and when inherited ordering and
array-shaped JSON would be wrong rather than convenient.
When the data must stay a dict, because it arrives as JSON or
goes back out as JSON, a TypedDict from Static
Typing names the keys and their types for the checker while
the value stays a real dict. When it need only become a
dict on the way out, _asdict() on a
NamedTuple and dataclasses.asdict() on
a data class each produce one. To make a @dataclass
guarantee that its values are legal, not merely typed, see Data
Classes as Types.
messenger_idiom.py, create a second
Messenger with different keyword arguments and
confirm the two instances do not share attributes (unlike a
class attribute from Class
Attributes).point_dataclass.py, add a third field,
z: float, to the Point dataclass, and
update the Point(...) call to pass three
arguments.NamedTuple called Recipe
with fields name: str and
steps: list[str] to
color_namedtuple.py. Mutate the steps
list of an instance and print the record. Then try to use the
record as a dict key and explain the result.display_namespace.py, add a fourth attribute
to m by passing it to the constructor, then add it
by assignment after the existing m.more = 11
instead. Confirm vars(m) reports the same four
attributes either way, and note whether they come out in the
same order.fetch_stats.py, change
summarize() to return a bare
tuple[float, int]. Every caller still runs. What
did the call sites lose, and which mistakes would the type
checker still catch?still_a_tuple.py, add
class Point3(NamedTuple) with fields
x, y, z. Predict
Color(1, 2, 3) == Point3(1, 2, 3) before running
it, then predict FrozenColor(1, 2, 3) == (1, 2, 3)
and check that too.