1. Data Class Builders Overview
- Python offers a few ways to build a simple class that is just a collection of fields, with little or no extra functionality. That pattern is known as a “data class”—and dataclasses is one of the packages that supports this pattern
- None of the class builders discussed here depend on inheritance to do their work. Both collections.namedtuple and typing.NamedTuple build classes that are tuple subclasses. @dataclass is a class decorator that does not affect the class hierarchy in any way. Each of them uses different metaprogramming techniques to inject methods and data attributes into the class under construction
1 | # 1. default class has no necessary __init__, __repr__, and __eq__ methods |
| namedtuple | NamedTuple | dataclass | |
|---|---|---|---|
| mutable instances | NO | NO | YES (by default, frozen=False) |
| class statement syntax | NO | YES | YES |
| construct dict | x._asdict() | x._asdict() | dataclasses.asdict(x) |
| get field names | x._fields | x._fields | [f.name for f in dataclasses.fields(x)] |
| get defaults | x._field_defaults | x._field_defaults | [f.default for f in dataclasses.fields(x)] |
| get field types | N/A | x.__annotations__ | x.__annotations__ |
| new instance with changes | x._replace(…) | x._replace(…) | dataclasses.replace(x, …) |
| new class at runtime | namedtuple(…) | NamedTuple(…) | dataclasses.make_dataclass(…) |
- The classes built by typing.NamedTuple and @dataclass have an __annotations__ attribute holding the type hints for the fields. However, reading from __annotations__ directly is not recommended. Instead, the recommended best practice to get that information is to call inspect.get_annotations(MyClass) (added in Python 3.10) or typing.get_type_hints(MyClass) (Python 3.5 to 3.9). That’s because those functions provide extra services, like resolving forward references in type hints
2. Classic Named Tuples
- The collections.namedtuple function is a factory that builds subclasses of tuple enhanced with field names, a class name, and an informative __repr__. Classes built with namedtuple can be used anywhere where tuples are needed, and in fact many functions of the Python standard library that are used to return tuples now return named tuples for convenience, without affecting the user’s code at all
- Each instance of a class built by namedtuple takes exactly the same amount of memory as a tuple because the field names are stored in the class
1 | from collections import namedtuple |
- We can also add methods to a namedtuple, but it’s a hack. In Python, it’s not as common, because it doesn’t work with any built-in type—str, list, etc.
1
2
3
4
5
6
7
8
9
10
11
12
13
14 ranks = [str(n) for n in range(2, 11)] + list('JQKA')
Card = namedtuple('Card', ['rank', 'suit'])
# add a class attribute to namedtuple
Card.suit_values = dict(spades=3, hearts=2, diamonds=1, clubs=0)
def spades_high(card):
rank_value = ranks.index(card.rank)
suit_value = card.suit_values[card.suit]
return rank_value * len(card.suit_values) + suit_value
# add a function to namedtuple
Card.overall_rank = spades_high
lowest_card = Card('2', 'clubs')
highest_card = Card('A', 'spades')
print(lowest_card.overall_rank()) # 0
print(highest_card.overall_rank()) # 51
3. Typed Named Tuples
- Classes built by typing.NamedTuple don’t have any methods beyond those that collections.namedtuple also generates—and those that are inherited from tuple. The only difference is the presence of the __annotations__ class attribute—which Python completely ignores at runtime
3.1. Type Hints Overview
- Type hints—a.k.a. type annotations—are ways to declare the expected type of function arguments, return values, variables, and attributes, they are not enforced at all by the Python bytecode compiler and interpreter. Think about Python type hints as “documentation that can be verified by IDEs and type checkers”. That’s because type hints have no impact on the runtime behavior of Python programs
- The basic syntax of variable annotation is
var_name: some_type [= a_value]. In the context of defining a data class, these types are more likely to be useful:- A concrete class, for example, str or FrenchDeck
- A parameterized collection type, like list[int], tuple[str, float], etc.
- typing.Optional, for example, Optional[str]—to declare a field that can be a str or None
1 | import typing |
3.2. Variable Annotations
- Type hints have no effect at runtime. But at import time—when a module is loaded—Python does read them to build the __annotations__ dictionary that typing.NamedTuple and @dataclass then use to enhance the class
1 | class DemoPlainClass: |
4. More About @dataclass
- The decorator accepts several keyword arguments. This is its signature:
@dataclass(*, init=True, repr=True, eq=True, order=False, unsafe_hash=False, frozen=False)- The * in the first position means the remaining parameters are keyword-only
- @dataclass emulates immutability by generating __setattr__ and __delattr__, which raise dataclass.FrozenInstanceError—a subclass of AttributeError—when the user attempts to set or delete a field
- If the eq and frozen arguments are both True, @dataclass produces a suitable __hash__ method, so the instances will be hashable. The generated __hash__ will use data from all fields that are not individually excluded using a field option. If frozen=False, @dataclass will set __hash__ to None, signalling that the instances are unhashable, therefore overriding __hash__ from any superclass
| Option | Meaning | Default | Notes |
|---|---|---|---|
| init | Generate __init__ | True | Ignored if __init__ is implemented by user |
| repr | Generate __repr__ | True | Ignored if __repr__ is implemented by user |
| eq | Generate __eq__ | True | Ignored if __eq__ is implemented by user |
| order | Generate __lt__, __le__, __gt__, __ge__ | False | If True, raises exceptions if eq=False, or if any of the comparison methods that would be generated are defined or inherited |
| unsafe_hash | Generate __hash__ | False | If True, force create a __hash__() method, even though it may not be safe. Otherwise, generate a __hash__() method according to how eq and frozen are set |
| frozen | Generate __setattr__, __delattr__ | False | Instances will be reasonably safe from accidental change, but not really immutable |
4.1. Field Options
- The instance fields declared in a dataclass become parameters in the generated __init__. After declaring a field with a default value, all remaining fields must also have default values. Use dataclasses.field() when a field needs options beyond a simple default value. The most common option is default_factory, used to build a new mutable default value for each instance
| Option | Meaning | Default |
|---|---|---|
| default | Default value for field | _MISSING_TYPE |
| default_factory | 0-parameter callable used to produce a default | _MISSING_TYPE |
| init | Include field in parameters to __init__ | True |
| repr | Include field in __repr__ | True |
| compare | Use field in comparison methods | True |
| hash | Include field in __hash__ calculation | None |
| metadata | Mapping with user-defined data; ignored by @dataclass | None |
1 | from dataclasses import dataclass, field |
4.2. Post-init Processing
- The __init__ method generated by @dataclass only takes the arguments passed and assigns them—or their default values, if missing—to the instance attributes that are instance fields. But you may need to do more than that to initialize the instance. If that’s the case, you can provide a __post_init__ method. When that method exists, @dataclass will add code to the generated __init__ to call __post_init__ as the last step. Common use cases for __post_init__ are validation and computing field values based on other fields
1 | from dataclasses import dataclass, field, fields |
4.3. Typed Class Attributes
- @dataclass decorator doesn’t care about the types in the annotations, except in two cases: if the type is ClassVar or InitVar, an instance field will not be generated for that attribute
1 | from typing import ClassVar |
4.4. Initialization Variables
- Sometimes you may need to pass arguments to __init__ that are not instance fields. Such arguments are called init-only variables, and will be arguments that the generated __init__ will accept, and it will be also passed to __post_init__
1 | from dataclasses import dataclass, InitVar, fields |
5. Pattern Matching Class Instances
- Class patterns are designed to match class instances by type and—optionally—by attributes. The subject of a class pattern can be any class instance, not only instances of data classes. There are three variations of class patterns: simple, keyword, and positional
1 | # 1. Simple Class Patterns |
References
- Fluent Python