Most classes exist mainly to hold a few values. Writing __init__, __repr__
and __eq__ for each one by hand is exactly the sort of work a language should
do for you.
The boilerplate problem
class Book:
def __init__(self, title, author, pages=0):
self.title = title
self.author = author
self.pages = pages
def __repr__(self):
return f"Book(title={self.title!r}, author={self.author!r}, pages={self.pages})"
def __eq__(self, other):
return (self.title, self.author, self.pages) == (other.title, other.author, other.pages)
book = Book("Dune", "Herbert", 412)
print(book)
print(book == Book("Dune", "Herbert", 412))The same class as a dataclass
from dataclasses import dataclass
@dataclass
class Book:
title: str
author: str
pages: int = 0
book = Book("Dune", "Herbert", 412)
print(book) # __repr__ for free
print(book == Book("Dune", "Herbert", 412)) # __eq__ for free
print(book.title, book.pages)
book.pages = 500 # still an ordinary object
print(book)The @dataclass line is a decorator: a function that takes your class and
returns an enhanced version of it. It writes __init__, __repr__ and
__eq__ from the annotated fields.
Dataclass options
from dataclasses import dataclass, field
@dataclass(frozen=True, order=True)
class Version:
major: int
minor: int
patch: int = 0
def __str__(self):
return f"{self.major}.{self.minor}.{self.patch}"
v1 = Version(1, 4)
v2 = Version(1, 10)
print(str(v1), str(v2))
print(v1 < v2) # order=True gives comparison from field order
print(sorted([v2, v1]))
v1.major = 2 # frozen=True makes it immutablefrozen=True makes instances immutable and hashable, so they can be dictionary
keys or set members. order=True generates the comparison methods from the
field order.
Mutable defaults, once more
tags: list = [] in a dataclass raises an error outright — Python knows the
trap. Use tags: list = field(default_factory=list) to give each instance its
own list.
from dataclasses import dataclass, field
@dataclass
class Playlist:
name: str
tracks: list = field(default_factory=list)
plays: int = 0
a = Playlist("Focus")
b = Playlist("Party")
a.tracks.append("Kind of Blue")
print(a)
print(b) # still empty, as it should beType hints
The title: str annotations above are type hints. Python does not enforce
them at runtime — but editors, linters and type checkers do, and readers do
most of all.
def repeat(text: str, times: int = 2) -> str:
"""Return text repeated, separated by spaces."""
return " ".join([text] * times)
print(repeat("ha", 3))
print(repeat.__annotations__)
# Nothing stops you passing the wrong type - Python just runs it:
print(repeat(5, 2))That last line produces a TypeError deep inside join. A type checker such
as mypy or ruff would have flagged the call before you ran it.
def total_prices(prices: list[float]) -> float:
return sum(prices)
def lookup(table: dict[str, int], key: str) -> int | None:
"""Return the value, or None when the key is absent."""
return table.get(key)
print(total_prices([1.5, 2.5]))
print(lookup({"a": 1}, "a"), lookup({"a": 1}, "z"))list[float], dict[str, int] and int | None are the modern spellings —
no imports needed on Python 3.10 and later.
Properties
Sometimes an attribute should be computed, or validated on assignment.
@property makes a method look like an attribute.
class Rectangle:
def __init__(self, width: float, height: float):
self.width = width
self.height = height
@property
def area(self) -> float:
"""Computed on access - no parentheses at the call site."""
return self.width * self.height
class Temperature:
def __init__(self, celsius: float):
self.celsius = celsius
@property
def celsius(self) -> float:
return self._celsius
@celsius.setter
def celsius(self, value: float) -> None:
if value < -273.15:
raise ValueError("below absolute zero")
self._celsius = value
@property
def fahrenheit(self) -> float:
return self._celsius * 9 / 5 + 32
r = Rectangle(3, 4)
print(r.area)
t = Temperature(21.5)
print(t.celsius, t.fahrenheit)
t.celsius = -300Properties let you start with a plain attribute and add validation later without changing a single line of calling code.
Model an order
Write a frozen dataclass Item with name: str and price: float, and a
dataclass Order with customer: str and items: list[Item] (defaulting to
empty). Give Order an add method and a total property.
from dataclasses import dataclass, field
# Your code hereShow one solution
from dataclasses import dataclass, field
@dataclass(frozen=True)
class Item:
name: str
price: float
@dataclass
class Order:
customer: str
items: list[Item] = field(default_factory=list)
def add(self, item: Item) -> None:
self.items.append(item)
@property
def total(self) -> float:
return round(sum(item.price for item in self.items), 2)
order = Order("Ada")
order.add(Item("keyboard", 49.99))
order.add(Item("mouse", 25.50))
print(order)
print(order.total)Item is frozen because an item in a placed order should not change under
you; Order is not, because adding items is the whole point of it.
Day 6 is done
You can now design types that carry their own rules — the step from writing scripts to designing programs.
What you learned
@dataclassgenerates__init__,__repr__and__eq__from annotated fields.frozen=Truegives immutability;order=Truegives comparisons.- Use
field(default_factory=list)for mutable defaults. - Type hints document intent and power editors and type checkers; Python does not enforce them.
@propertyturns a method into a computed or validated attribute.