The same class twice. The second one is not shorter by accident — every line it loses is a line that could have had a typo in it.
class Point:
def __init__(self, x=0, y=0):
self.x = x
self.y = y
def __eq__(self, other):
if not isinstance(other, Point):
return NotImplemented
return (self.x, self.y) == (other.x, other.y)
def __repr__(self):
return f"Point(x={self.x!r}, y={self.y!r})"
# All of the above, generated from the two field lines
@dataclass
class Point:
x: float = 0
y: float = 0
- Without __eq__, equal points are not equal
A plain class inherits identity comparison, so Point(1, 2) == Point(1, 2) is False — two separate objects. That single default has caused more confused afternoons than almost anything else in Python, because the values print identically while refusing to match. @dataclass generates the field-by-field comparison, and the confusion never starts.
- NotImplemented is the polite refusal, and you get it free
Returning NotImplemented rather than False tells Python 'I do not know how to compare these — ask the other object', and only if that also declines does the answer become False. Getting it wrong by returning False directly breaks comparisons with classes that would have known what to do. The generated method already does the right thing.
- The annotations are what make it a field
x: float = 0 declares a field with a default. Writing x = 0 with no annotation makes it an ordinary class attribute and @dataclass ignores it completely — no parameter, no comparison, no repr entry. Nothing warns you; the class simply comes out smaller than you meant. The annotation is not documentation here, it is the declaration.
The two fields are two annotated lines with defaults — the decorator turns them into the constructor. Then add the two methods, and make sure moved_by builds something rather than changing something.
x: float = 0 and y: float = 0 under @dataclass. moved_by returns Point(self.x + dx, self.y + dy) — a new object, so nothing is assigned to self anywhere in this class. distance_to needs the square root of the summed squares of the differences.
For the distance, math.hypot(self.x - other.x, self.y - other.y) does the whole calculation, including the square root, and handles very large values without overflowing. ((dx ** 2 + dy ** 2) ** 0.5 is the same arithmetic written out.
from dataclasses import dataclass import math @dataclass class Point: x: float = ____ y: float = ____ def moved_by(self, dx, dy): return ____(self.x + dx, self.y + ____) def distance_to(self, other): return math.____(self.x - other.x, self.y - other.y)
import math
from dataclasses import dataclass
@dataclass
class Point:
"""A position on a plane."""
x: float = 0
y: float = 0
def moved_by(self, dx, dy):
return Point(self.x + dx, self.y + dy)
def distance_to(self, other):
return math.hypot(self.x - other.x, self.y - other.y)
A point that moves in place is a thing with a history; a point that answers 'here is where you would be' is a value, like a number. Values can be shared, cached, put in a dictionary and passed to a function without anyone wondering who else is holding them. That single choice — return, do not assign — is most of what people mean by immutability, and it costs nothing here.
(dx ** 2 + dy ** 2) ** 0.5 is correct and fine. math.hypot is the same answer computed so that it does not overflow when the values are huge and does not lose precision when they are tiny — the standard library already paid for that. It also takes any number of arguments, so the three-dimensional version is the same call with one more.
A plain @dataclass is deliberately unhashable — generating __eq__ sets __hash__ to None — so Point cannot be a dictionary key or go in a set until you say @dataclass(frozen=True), which makes it read-only and hashable together. That pairing is not arbitrary: something that can change must not be usable as a key, because it would go missing from the set the moment it changed.
Do not reach for a dataclass when a tuple already says it. Returning (x, y) from one function to one caller needs no class, and NamedTuple sits in between when you want names and tuple behaviour. Go the other way — a plain class — when construction has real work in it, when the fields should not all be public, or when you want to control exactly which of __init__, __eq__ and __repr__ exist. The decorator is a default that is right often enough to reach for first, not a rule.
- Add @dataclass(frozen=True) and find out which line of your class stops working — then decide whether you preferred it before or after.
- Add order=True and sort a list of points, then work out what order you actually got and whether it is the one you would have chosen.
- Write moved_by using dataclasses.replace(self, x=..., y=...) instead, and consider which version survives adding a third field better.