- Annotate functions and variables and explain that hints are not checked at run time
- Describe complex types with
Callable,Literal, generic functions andProtocol - Write a data class with
@dataclass,field(default_factory=...)and__post_init__ - Choose the
frozen,orderandslotsoptions appropriately
Look at def price(total, discount): is discount 0.1 or 10? Can total be None? In a small script you remember; in a 50 000-line project with five developers you do not. Type hints write the answer into the code itself: def price(total: float, discount: float) -> float. Editors use them for autocompletion, and checkers such as mypy find bugs before the program even runs. Dataclasses build on the same annotations and write the boring parts of a class for you.
Annotating functions and variables
A hint is written after a colon, and the return type after the arrow ->. Since Python 3.9 the built-in collections can be parameterized directly: list[int], dict[str, float], tuple[int, int], set[str]. Since Python 3.10 a union is written with |: int | None means an integer or None. Variables can be annotated too: count: int = 0.
def average(scores: list[float], precision: int = 1) -> float:
return round(sum(scores) / len(scores), precision)
print(average([4.5, 5, 3.5]))
print(average.__annotations__)
print(average((1, 2)))▸ Expected output
4.3
{'scores': list[float], 'precision': <class 'int'>, 'return': <class 'float'>}
1.5The last line matters: the tuple (1, 2) was accepted even though the hint says list[float]. Python does not check hints at run time. They are stored in __annotations__ and read by tools. To actually find mistakes, run a static type checker:
$ mypy shop.py
shop.py:12: error: Argument 1 to "average" has incompatible type "str"; expected "list[float]" [arg-type]
Found 1 error in 1 file (checked 1 source file)pip install mypy and analyses the file without running it.The typing toolbox
Callable[[int], int]— a function that takes anintand returns anint.Literal['r', 'w']— only these exact values are allowed.type Vector = list[float](Python 3.12+) — a type alias: a short name for a long type.def first[T](items: list[T]) -> T(Python 3.12+) — a generic function:Tis a type variable, so for alist[str]the result is astr. Older versions write this withT = TypeVar('T').Any— switches checking off; use it as rarely as possible.
from typing import Callable, Literal
type Vector = list[float]
Mode = Literal['r', 'w']
def scale(v: Vector, k: float) -> Vector:
return [k * x for x in v]
def first[T](items: list[T], default: T | None = None) -> T | None:
return items[0] if items else default
def apply(func: Callable[[int], int], value: int) -> int:
return func(value)
def describe(mode: Mode) -> str:
return 'read' if mode == 'r' else 'write'
print(scale([1.0, 2.5], 2))
print(first(['Aysel', 'Murad']), first([], default='nobody'))
print(apply(lambda n: n * n, 7))
print(describe('w'))▸ Expected output
[2.0, 5.0] Aysel nobody 49 write
Python's philosophy is duck typing: if an object has the method you need, it will do. typing.Protocol brings this idea into the type system. A protocol describes what an object can do; any class with the right methods matches it automatically, without inheriting from it. This is called structural typing:
from typing import Protocol
class HasArea(Protocol):
def area(self) -> float: ...
class Square:
def __init__(self, side: float) -> None:
self.side = side
def area(self) -> float:
return self.side ** 2
class Circle:
def __init__(self, r: float) -> None:
self.r = r
def area(self) -> float:
return 3.14159 * self.r ** 2
def total_area(shapes: list[HasArea]) -> float:
return sum(s.area() for s in shapes)
print(round(total_area([Square(2), Circle(1)]), 2))▸ Expected output
7.14
Square and Circle know nothing about HasArea, yet a type checker accepts both in a list[HasArea], because each has an area() -> float method. A class that forgets area would be reported before the program runs.
dataclasses: classes without boilerplate
A class that mainly stores data needs an __init__ that copies the arguments into attributes, a readable __repr__ and an __eq__ that compares field by field. Writing them by hand is long and error-prone. The decorator **@dataclass reads the annotated class attributes (the fields**) and generates these methods for you:
from dataclasses import dataclass, field
@dataclass
class Student:
name: str
grade: int
scores: list[int] = field(default_factory=list)
def average(self) -> float:
return sum(self.scores) / len(self.scores) if self.scores else 0.0
a = Student('Aysel', 9, [5, 4, 5])
b = Student('Aysel', 9, [5, 4, 5])
print(a)
print(a == b, a is b)
print(round(a.average(), 2))
print(Student('Murad', 8))▸ Expected output
Student(name='Aysel', grade=9, scores=[5, 4, 5]) True False 4.67 Student(name='Murad', grade=8, scores=[])
a == b is True because the generated __eq__ compares the fields, while a is b is False — they are two separate objects. The field scores got a fresh empty list for Murad thanks to field(default_factory=list): the factory is called separately for every new object.
from dataclasses import dataclass
try:
@dataclass
class Basket:
items: list[str] = []
except ValueError as e:
print(e)▸ Expected output
mutable default <class 'list'> for field items is not allowed: use default_factory
frozen, order, slots and __post_init__
The decorator's parameters switch on extra features. frozen=True makes objects immutable (and therefore hashable, so they can be set elements and dictionary keys), order=True generates <, <=, >, >= that compare the fields in order, like tuples, and slots=True (Python 3.10+) stores attributes in __slots__ instead of a dictionary, which saves memory and speeds up access. The method __post_init__ runs right after the generated __init__ — it is the place for validation:
from dataclasses import dataclass, replace, asdict
@dataclass(frozen=True, order=True, slots=True)
class Version:
major: int
minor: int = 0
patch: int = 0
def __post_init__(self):
if self.major < 0:
raise ValueError('major must be >= 0')
v1 = Version(0, 1)
v2 = replace(v1, minor=2)
print(v1 < v2)
print(sorted([Version(1), v2, v1]))
print(asdict(v2))
print({v1, Version(0, 1, 0)})
try:
v1.major = 1
except AttributeError as e:
print(type(e).__name__)▸ Expected output
True
[Version(major=0, minor=1, patch=0), Version(major=0, minor=2, patch=0), Version(major=1, minor=0, patch=0)]
{'major': 0, 'minor': 2, 'patch': 0}
{Version(major=0, minor=1, patch=0)}
FrozenInstanceErrorLet's read the output. sorted works thanks to order=True: versions are compared like (major, minor, patch) tuples. The set kept one element, because equal frozen objects also have equal hashes. The attempt to assign raised FrozenInstanceError, a subclass of AttributeError — once created, the object cannot be changed.
| Option | What it gives |
|---|---|
frozen=True | immutable objects; assignment raises FrozenInstanceError; hashing |
order=True | field-by-field comparison < <= > >=, so objects can be sorted |
slots=True | __slots__: less memory, faster access, no new attributes |
kw_only=True | all fields must be passed by name |
field(default_factory=...) | a fresh default value for every object |
field(repr=False, compare=False) | leaves a field out of repr or comparisons |
Complete the Cart dataclass: add a field items that is an empty dictionary by default (every cart must get its own), make add store a product with its price, and make total return the sum of the prices rounded to 2 decimals.
from dataclasses import dataclass, field
@dataclass
class Cart:
owner: str
# add a field `items`: dict[str, float], an empty dict by default
def add(self, name: str, price: float) -> None:
...
def total(self) -> float:
...
a = Cart('Aysel')
b = Cart('Murad')
a.add('tea', 3.5)
a.add('bread', 0.8)
print(a)
print(b)
print(a.total())▸ Expected output
Cart(owner='Aysel', items={'tea': 3.5, 'bread': 0.8})
Cart(owner='Murad', items={})
4.3The runners must be sorted by time (fastest first) and by name when the times are equal. Without using a key argument, change the class so that sorted() does exactly that.
from dataclasses import dataclass
@dataclass(order=True, frozen=True)
class Runner:
name: str
time: float
results = [Runner('Murad', 12.4), Runner('Leyla', 11.9), Runner('Aysel', 12.4)]
for r in sorted(results):
print(r.name, r.time)▸ Expected output
Leyla 11.9 Aysel 12.4 Murad 12.4
Key points
- Hints (
x: int,-> str,list[int],int | None) document types; Python does not enforce them at run time. - mypy or pyright check the hints statically, before the program runs.
- Generics (
def first[T]),Callable,LiteralandProtocoldescribe richer contracts;Protocolis structural typing. @dataclassgenerates__init__,__repr__and__eq__from annotated fields; mutable defaults needfield(default_factory=...).frozen,order,slotsand__post_init__add immutability, ordering, memory savings and validation.
Check yourself
10 questions. Every correct answer earns XP.
x: int?