A function computes several results, and the caller needs all of them. A Data Transfer Object carries them back under names instead of positions.
The Messenger, called a Data Transfer
Object (DTO) in Martin Fowler’s Patterns of Enterprise
Application Architecture, 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.
Fowler’s DTO crosses a process or network boundary, batching several values into one object to cut round trips. The objects in this chapter have the same shape, whether or not they leave the process. When one does leave, a process pool pickles it across the process boundary, and Serializing to JSON turns a data class into the wire format for a network call.
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", tags=["urgent", "todo"])
print(vars(m))
#: {'info': 'Spam', 'tags': ['urgent', 'todo']}
m.more = 11
print(m.info, m.tags, m.more)
#: Spam ['urgent', 'todo'] 11
print(vars(m))
#: {'info': 'Spam', 'tags': ['urgent', 'todo'], '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, as passing
more=11 to the constructor does.
Because **kwargs is the only parameter,
Messenger accepts keyword arguments alone.
Messenger("Spam") raises a TypeError,
and the * marker from Positional-Only
and Keyword-Only Parameters is unnecessary here. Writing
def __init__(self, *, **kwargs) is a syntax error,
since a named parameter must follow a bare *.
The m: Any annotation does real work. The
Messenger class declares no attributes, so without
that annotation the type checker rejects both
m.more = 11 and m.info.
Any switches the type 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. With __getattr__() alone, the
type checker still rejects the write, m.more = 11.
The standard library’s type declaration for
SimpleNamespace has such a pair (its read half is
__getattribute__(), which intercepts every
attribute access), so the next listing needs no annotation.
With an ad-hoc attribute bag, no type checker knows your
attribute names. A typo like m.inof is a runtime
AttributeError, not a static error.
SimpleNamespaceIn 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", tags=["urgent", "todo"])
print(vars(m)) # [1]
#: {'info': 'Spam', 'tags': ['urgent', 'todo']}
m.more = 11
print(m)
#: namespace(info='Spam', tags=['urgent', 'todo'], more=11)
print(m == SimpleNamespace(info="Spam",
tags=["urgent", "todo"],
more=11))
#: TrueThe print() at [1] shows the same
instance __dict__ the hand-rolled version has.
SimpleNamespace adds a readable repr()
and equality by contents. Messenger prints as
<__main__.Messenger object at 0x...>, and two
Messengers with identical attributes compare
unequal, because Messenger inherits
object’s identity-based equality.
A SimpleNamespace also accepts any name you
invent, so no type checker knows which names to expect. Its type
declaration says so. Reading any attribute yields
Any, so m.inof passes the type checker
here too.
@dataclassWhen you want the fields named and checked, declare them. A
@dataclass generates __init__(),
__repr__(), and equality from those declarations,
and its fields stay assignable:
# 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)p.x = 3.5 succeeds because a data class is
mutable unless you freeze it. A NamedTuple Is Still a
Tuple compares the frozen form, which this book’s listings
write as @record,
with a NamedTuple.
NamedTupleA NamedTuple declares its fields the same way
but produces an immutable record. typing.NamedTuple
is the class form of namedtuple().
Both build a subclass of tuple whose positions also
have names, but the class form declares a type for each field,
so a type checker knows a Color’s r is
an int. Because a Color is a tuple
underneath, you can read each field by name or by position:
# color_namedtuple.py
from typing import NamedTuple
from exceptions import expected
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
with expected(AttributeError):
red.r = 9 # type: ignore
#: [AttributeError] can't set attribute
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 as a data class. A bare tuple prints
(255, 0, 0) and leaves you counting positions.
Assigning to a field raises an AttributeError, and
the type checker reports the assignment as well. An attribute
bag accepts every write. A NamedTuple rejects this
one, at runtime and in the checker. Because no field can change,
_replace() is the way to change one. It produces an
updated copy. copy.replace()
does the same job for a NamedTuple, a frozen data
class, and any other type that defines
__replace__(). 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. Such a record is also unhashable, mutated or not,
because hashing a tuple hashes its contents, and a list has no
hash. An immutable record needs immutable fields.
The leading underscore on _replace(),
_asdict(), and _fields keeps every
unprefixed name free for your fields. A record can declare a
field called replace or fields. The
underscore marks NamedTuple’s own members, and says
nothing about privacy.
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))
result = summarize([2.0, 4.0, 6.0])
print(result)
#: Stats(mean=4.0, count=3)
print(result.mean, result.count)
#: 4.0 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 must then remember that position 0 is the mean and
position 1 is the count, a fact the code states nowhere.
Stats names the fields and documents itself at each
call site, and because a NamedTuple is a tuple, you
can unpack it. Unpacking goes by position, so it brings the bare
tuple’s hazard back. count, mean = summarize(data)
runs, passes the type checker, and binds each name to the
other’s value. Reading result.mean has no order to
get wrong.
Unpacking is the part a data class lacks.
mean, count = summarize(data) against a
@dataclass version of Stats raises a
TypeError, since a data class is not iterable.
dataclasses.astuple() converts a data class when
you need the positional form. More
Data Class Tools covers the recursive, copying behavior of
astuple().
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 with the same shape:
# still_a_tuple.py
from dataclasses import dataclass
from typing import NamedTuple
from exceptions import expected
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
with expected(TypeError):
FrozenColor(1, 2, 3) < FrozenColor(1, 2, 4) # type: ignore
#: [TypeError] '<' not supported between instances of
#: 'FrozenColor' and 'FrozenColor'
@dataclass(frozen=True, order=True)
class OrderedColor:
r: int
g: int
b: int
@dataclass(frozen=True, order=True)
class OrderedDimensions:
width: int
height: int
depth: int
with expected(TypeError):
OrderedColor(1, 2, 3) < OrderedDimensions(1, 2, 4) # type: ignore
#: [TypeError] '<' not supported between instances of
#: 'OrderedColor' and 'OrderedDimensions'Color and Dimensions mean different
things, yet Color(1, 2, 3) == Dimensions(1, 2, 3)
is True. Equal tuples hash alike, so the two are
also the same dict key. The frozen data classes
tell the two apart, because a data class’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.
The listing writes @dataclass(frozen=True) in
full instead of @record, so that
order=True is the one difference between each
Frozen class and its Ordered twin.
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 leaves the field names out. Converting first, with
json.dumps(Color(1, 2, 3)._asdict()), writes
{"r": 1, "g": 2, "b": 3}. json.dumps()
on a data class raises a TypeError instead. The
TypeError is the safer failure of the two, because
the array version drops the names silently.
Use SimpleNamespace for an ad-hoc bag of
attributes, a @dataclass for typed fields you
reassign, and a NamedTuple for typed fields you
cannot. The hand-rolled Messenger is worth writing
only to show how SimpleNamespace works
underneath.
The frozen data class is the other typed immutable record.
Between it and a NamedTuple, 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
data class when the value should be a distinct type that
equals instances of its own class alone. This book writes it as
@record. Choose it too when inherited ordering and
array-shaped JSON are wrong rather than convenient.
When the data must stay a dict, because it arrives as JSON or
goes back out as JSON, a TypedDict
names the keys and their types for the type checker while the
value stays a real dict. When the data becomes a dict for output
alone, _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.
This chapter’s solutions give a hint, usually the shape of the code, and a full answer for each exercise.
messenger_idiom.py, create
a second Messenger with different keyword arguments
and confirm the two instances do not share attributes (unlike a
class
attribute).point_dataclass.py, add a
third field, z: float, to the Point
data class, 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], and repair the one line that
stops working. What do the call sites lose, and which mistakes
does 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.dict key; a
record decoded from a JSON API response whose fields you also
validate.