Type Hints & Dataclasses
This page covers two features that make code easier to read and harder to get wrong: type hints, which record what kind of value a variable or parameter is meant to hold, and dataclasses, which remove most of the repetitive code involved in writing a class that mainly stores data.
The Python documentation has guides on Type Hints and Dataclasses.
Type Hints on Functions
A hint is written after a parameter name with a colon, and after the parameter list with an arrow for the return value:
import math
def delta_v(exhaust_velocity: float, mass_ratio: float) -> float:
"""Tsiolkovsky rocket equation."""
return exhaust_velocity * math.log(mass_ratio)
print(delta_v(3000.0, 5.0))
4828.313737302301
exhaust_velocity: float says the parameter is meant to be a float, and -> float says the function returns one.
The function behaves exactly as it would without the hints.
Hints Are Not Enforced
Python does not check hints while the program runs. Passing the wrong type is allowed, and the program only fails if and when the value is actually used in a way it doesn’t support:
import math
def delta_v(exhaust_velocity: float, mass_ratio: float) -> float:
return exhaust_velocity * math.log(mass_ratio)
try:
print(delta_v("3000", "5"))
except TypeError as err:
print(f"Ran anyway, then crashed: {err}")
Ran anyway, then crashed: must be real number, not str
The call ran, and the error came from math.log receiving a string, not from the hint being violated.
Hints are documentation for you, your teammates, and tools like editors and linters, rather than a runtime guarantee.
Hints on Variables and Collections
Variables can be annotated the same way, and collection types can say what they contain:
altitude: float = 400.0
name: str = "ISS"
is_active: bool = True
altitudes: list[float] = [400.0, 550.0, 800.0]
part_masses: dict[str, float] = {"proptank": 1200.0, "engine": 450.0}
coordinates: tuple[float, float] = (28.5721, -80.6480)
print(altitudes)
print(part_masses)
print(coordinates)
[400.0, 550.0, 800.0]
{'proptank': 1200.0, 'engine': 450.0}
(28.5721, -80.648)
list[float] is a list of floats, dict[str, float] is a dictionary with string keys and float values, and tuple[float, float] is a tuple of exactly two floats.
Values That Might Be Missing
A vertical bar between two types means “either one.” This is how a function says it returns a value or nothing, which is common for a lookup that might not find anything:
def find_part_mass(parts: dict[str, float], name: str) -> float | None:
"""Return the mass of a named part, or None if it isn't found."""
return parts.get(name)
def scale(value: int | float, factor: float) -> float:
return value * factor
part_masses = {"proptank": 1200.0, "engine": 450.0}
print(find_part_mass(part_masses, "engine"))
print(find_part_mass(part_masses, "nosecone"))
print(scale(10, 1.5))
450.0
None
15.0
float | None tells a reader to expect None sometimes and to check for it before doing arithmetic on the result.
This pattern is also written Optional[float], using from typing import Optional, which you will see in older code.
The Problem Dataclasses Solve
Writing a class whose job is mostly to hold a few values takes a lot of repetitive code, with each field name spelled out several times:
class PartPlain:
def __init__(self, name, subsystem, mass):
self.name = name
self.subsystem = subsystem
self.mass = mass
def __repr__(self):
return f"PartPlain(name={self.name!r}, subsystem={self.subsystem!r}, mass={self.mass})"
def __eq__(self, other):
return (self.name, self.subsystem, self.mass) == (other.name, other.subsystem, other.mass)
p1 = PartPlain("proptank", "propulsion", 1200.0)
print(p1)
print(p1 == PartPlain("proptank", "propulsion", 1200.0))
PartPlain(name='proptank', subsystem='propulsion', mass=1200.0)
True
Every field appears in the parameter list, in an assignment, in __repr__, and in __eq__.
Adding one more field means editing four places, and forgetting one produces a subtle bug.
Dataclasses
Putting @dataclass above a class definition tells Python to generate __init__, __repr__, and __eq__ from the fields you list:
from dataclasses import dataclass
@dataclass
class Part:
name: str
subsystem: str
mass: float
p = Part("proptank", "propulsion", 1200.0)
print(p) # __repr__ came for free
print(p == Part("proptank", "propulsion", 1200.0)) # so did __eq__
print(p.mass)
Part(name='proptank', subsystem='propulsion', mass=1200.0)
True
1200.0
Each field is declared once, as a name with a type hint, and the three methods follow from that.
This is one place the hints are doing real work: @dataclass reads them to decide which class-body assignments are fields.
@dataclass is a decorator, a piece of syntax that modifies the thing defined below it.
Default Values
A field can be given a default, which makes it optional when creating an object:
from dataclasses import dataclass
@dataclass
class Part:
name: str
subsystem: str
mass: float
is_reusable: bool = False
print(Part("fairing", "structure", 900.0))
print(Part("booster", "propulsion", 25000.0, is_reusable=True))
Part(name='fairing', subsystem='structure', mass=900.0, is_reusable=False)
Part(name='booster', subsystem='propulsion', mass=25000.0, is_reusable=True)
Fields with defaults must come after fields without them, for the same reason default parameters come last in a normal function.
Mutable Defaults
A list or dictionary default needs field(default_factory=...) rather than a plain = []:
from dataclasses import dataclass, field
@dataclass
class Spacecraft:
name: str
parts: list[str] = field(default_factory=list)
sc1 = Spacecraft("Dragon")
sc2 = Spacecraft("Starliner")
sc1.parts.append("proptank")
print(sc1.parts)
print(sc2.parts) # still empty, each object got its own list
['proptank']
[]
default_factory=list calls list once per object, so each one gets its own empty list.
Writing parts: list[str] = [] instead would give every object made from the class one shared list, so appending to one vehicle’s parts would silently change all of them.
Dataclasses refuse to be defined that way, raising ValueError: mutable default ... is not allowed: use default_factory.
A plain class or a default function argument has the same hazard without the warning, so the rule is worth remembering beyond dataclasses.
Adding Methods
A dataclass is still a normal class, so methods work exactly as they do on any other:
from dataclasses import dataclass, field
@dataclass
class Part:
name: str
mass: float
@dataclass
class Spacecraft:
name: str
parts: list[Part] = field(default_factory=list)
def add_part(self, part: Part) -> None:
self.parts.append(part)
def total_mass(self) -> float:
return sum(part.mass for part in self.parts)
sc = Spacecraft("Orion")
sc.add_part(Part("proptank", 1200.0))
sc.add_part(Part("heat shield", 1600.0))
print(sc.total_mass())
print(sc)
2800.0
Spacecraft(name='Orion', parts=[Part(name='proptank', mass=1200.0), Part(name='heat shield', mass=1600.0)])
The generated __repr__ also descends into the parts, so printing the spacecraft shows everything it contains.
Validating Fields
__post_init__ runs automatically right after the generated __init__, which makes it the place to check that the values given make sense:
from dataclasses import dataclass
@dataclass
class Part:
name: str
mass: float
def __post_init__(self):
if self.mass < 0:
raise ValueError(f"mass must be non-negative, got {self.mass}")
print(Part("proptank", 1200.0))
try:
Part("proptank", -50.0)
except ValueError as err:
print(f"Rejected: {err}")
Part(name='proptank', mass=1200.0)
Rejected: mass must be non-negative, got -50.0
Raising an error here stops an invalid object from ever existing, rather than letting a bad value travel through the rest of the program before causing trouble somewhere else.
Ordering and Immutability
@dataclass takes arguments that change what it generates.
order=True adds the comparison methods, so objects can be sorted, and frozen=True blocks changes after creation:
from dataclasses import dataclass
@dataclass(order=True)
class Reading:
altitude: float
status: str = "nominal"
readings = [Reading(800.0), Reading(400.0), Reading(1200.0)]
print(sorted(readings))
@dataclass(frozen=True)
class Coordinate:
latitude: float
longitude: float
site = Coordinate(28.5721, -80.6480)
print(site)
try:
site.latitude = 0.0
except Exception as err:
print(f"Rejected: {type(err).__name__}")
[Reading(altitude=400.0, status='nominal'), Reading(altitude=800.0, status='nominal'), Reading(altitude=1200.0, status='nominal')]
Coordinate(latitude=28.5721, longitude=-80.648)
Rejected: FrozenInstanceError
With order=True, objects compare using their fields in the order declared, so Reading sorts by altitude first.
With frozen=True, assigning to a field raises a FrozenInstanceError, which suits values that should never change once set.
Example: Telemetry Readings
Question
A vehicle reports telemetry readings, each with a timestamp, an altitude, and a status that defaults to "nominal".
An altitude below zero is invalid and should be rejected outright.
Build a list of readings, report the ones that aren’t nominal, and compute the mean altitude.
Solution
@dataclass supplies the constructor and the printing, __post_init__ enforces the altitude rule, and a regular method answers whether a reading is nominal.
from dataclasses import dataclass
@dataclass
class Reading:
timestamp: float
altitude: float
status: str = "nominal"
def __post_init__(self):
if self.altitude < 0:
raise ValueError(f"altitude must be non-negative, got {self.altitude}")
def is_nominal(self) -> bool:
return self.status == "nominal"
readings: list[Reading] = [
Reading(0.0, 400.0),
Reading(1.0, 405.0, "warning"),
Reading(2.0, 410.0),
Reading(3.0, 300.0, "critical"),
]
problems = [r for r in readings if not r.is_nominal()]
for r in problems:
print(r)
mean_altitude = sum(r.altitude for r in readings) / len(readings)
print(f"Mean altitude: {mean_altitude:.1f} m")
try:
Reading(4.0, -10.0)
except ValueError as err:
print(f"Rejected: {err}")
Reading(timestamp=1.0, altitude=405.0, status='warning')
Reading(timestamp=3.0, altitude=300.0, status='critical')
Mean altitude: 378.8 m
Rejected: altitude must be non-negative, got -10.0
Filtering and averaging use the same comprehension patterns as a list of dictionaries, with r.altitude in place of r["altitude"].
Dataclasses vs. Dictionaries
Both hold a group of labeled values, and either can be the right choice:
- A dictionary suits data whose keys aren’t known in advance, such as rows read from a file whose columns vary.
- A dataclass suits data with a fixed, known set of fields, and gives you editor autocompletion and typo protection in return.
The typo case is the clearest difference.
Writing part.subsytsem is flagged by your editor before you run anything, while part["subsytsem"] looks fine until it raises a KeyError at runtime.
Reading Questions
- What does the hint in
def f(x: float) -> float:say aboutxand about the return value? - Does Python stop a program from passing a string to a parameter hinted as
float? What actually happens? - What does the hint
dict[str, float]describe? - What does
float | Nonesay about a function’s return value, and what should the caller do about it? - Which three methods does
@dataclassgenerate for you? - Why must a list field use
field(default_factory=list)instead of= []? - When does
__post_init__run, and what is it typically used for? - What do the
order=Trueandfrozen=Truearguments to@dataclassdo? - Give one situation where a dictionary is a better choice than a dataclass, and one where the reverse is true.