On this page
Description
Every value in Python is an object, and most interview code sooner or later defines its own: a Point, an Interval, a Task in a queue. This lesson is about making those objects behave - comparing them correctly, using them as dictionary keys, making them read-only, and copying them without surprises. It's also about a few ways Python's objects work differently from Java's: there's no method overloading, no final, and a variable never holds an object, only a name for one.
Here's the kind of bug we mean. A grid search stores visited points in a set:
class Point:
def __init__(self, x: int, y: int):
self.x, self.y = x, y
def __eq__(self, other: object) -> bool:
return isinstance(other, Point) and (self.x, self.y) == (other.x, other.y)
visited = {Point(1, 2)} # TypeError: cannot use 'Point' as a set element (unhashable type: 'Point')
The equality looks right, yet Python refuses to put the point in a set. By the end of the first section you'll know why, and how to fix it in one line.
We'll go from simple to more technical:
- Equality and hashing:
__eq__and__hash__together. - Dataclasses - the quick way to write value classes.
- Names and objects: why
b = adoesn't copy, and when+=changes both. - Copies: shallow and deep, and what a tuple really protects.
- Which method runs: no overloading, abstract classes, and
super().
Equality and hashing
== calls a class's __eq__. Sets and dict keys also need a hash: a number computed from the object, used to decide where to store it. The rule that ties them together: objects that are equal must have equal hashes.
That's why Python sets __hash__ to None the moment a class defines __eq__ without __hash__: the default hash (based on the object's identity) would break the rule, so Python makes the class unhashable rather than let it misbehave. (Java allows it and quietly loses entries in a HashSet.)
The fix is to define both, over the same fields:
class Point:
def __init__(self, x: int, y: int):
self._x, self._y = x, y
def __eq__(self, other: object) -> bool:
if not isinstance(other, Point):
return NotImplemented # "I don't know how to compare with that"
return (self._x, self._y) == (other._x, other._y)
def __hash__(self) -> int:
return hash((self._x, self._y)) # hash exactly what __eq__ compares
Two details:
- Return
NotImplemented, notFalse, for a type you don't recognise. It tells Python to ask the other object's__eq__; only if both decline does==fall back to comparing identity. - Don't change a key after you've stored it. If an object's hash changes while it sits in a set or dict, it's filed in the wrong place: neither the changed object nor an equal copy of the old one can find it again. That's why mutable classes are usually left unhashable on purpose.
Dataclasses: value classes in a few lines
Writing __init__, __eq__, __repr__ and __hash__ by hand gets old. @dataclass generates them from the field list:
from dataclasses import dataclass, field
@dataclass
class Draft: # generates __init__, __repr__ and __eq__
title: str
tags: list[str] = field(default_factory=list)
@dataclass(frozen=True)
class Range: # frozen: read-only, and hashable
lo: int
hi: int
def __post_init__(self) -> None: # runs after __init__: a good place to validate
if self.lo > self.hi:
raise ValueError(f"lo > hi: {self.lo} > {self.hi}")
Things to know:
- A plain
@dataclasscan't go in a set. It generates__eq__, so (as above)__hash__becomesNone. Make itfrozen=Trueand it gets a hash built from its fields. - Mutable defaults need
default_factory.tags: list[str] = []would be one list shared by every instance, so dataclasses refuse it with aValueError.field(default_factory=list)makes a new list each time. - Frozen is shallow. A frozen dataclass can't have its fields reassigned (
FrozenInstanceError), but a list stored in a field can still be changed - and the object then can't be hashed, because lists aren't hashable. Store tuples or frozensets in frozen classes. dataclasses.replace(r, hi=10)gives a modified copy of a frozen object.
Names and objects
A variable is a name attached to an object. Assignment attaches the name to an object; it never copies anything:
a = [1]
b = a # two names, one list
a += [2] # += changes the list in place: b is [1, 2] too
a = a + [3] # + builds a new list, and only a moves to it
print(a, b) # [1, 2, 3] [1, 2]
For lists, += means "extend this list" while a = a + [...] means "make a new list". With immutable types like ints, strings and tuples there's nothing to change in place, so both forms make a new object.
Identity versus equality. is asks "the very same object?"; == asks "the same value?". Use is only for None and other one-of-a-kind objects. CPython reuses objects for small ints, so is on numbers seems to work for small values and fails for bigger ones - a classic trap.
Class attributes are shared. A list written directly in the class body belongs to the class, so every instance sees the same list:
class Team:
members = [] # one list for EVERY Team - create it in __init__ instead
Copies, shallow and deep
Most ways of copying are shallow: they copy the outer container and share whatever is inside it.
import copy
grid = [[0, 0], [0, 0]]
copy1 = grid[:] # or list(grid), or copy.copy(grid): rows are shared
copy2 = [row[:] for row in grid] # rows copied too
copy3 = copy.deepcopy(grid) # everything copied, however deep
copy1[0][0] = 9 # grid[0][0] is now 9 as well
A tuple can't change, but the objects inside it can. And t[1] += [4] on a tuple holding a list does something surprising: it extends the list and raises TypeError, because += first changes the list in place and then tries to store the result back into the tuple.
For a read-only view of a dict - say, configuration you hand to other code - types.MappingProxyType(d) rejects writes but shows later changes to d; dict(d) is a frozen-in-time copy.
Which method runs
- No overloading. A class body runs top to bottom, so a second
def area(...)replaces the first. Use default arguments,*args, orfunctools.singledispatchmethodto dispatch on type. - Every method can be overridden. Calls always look the method up on the object's actual class while the program runs. There's no
final(typing.finalis only a hint for type checkers). - Abstract classes (
abc.ABCwith@abstractmethod) fail when you try to create an instance of a subclass that's missing an implementation - Java would catch it when compiling.typing.Protocoldescribes "anything with these methods", checked by type checkers. - Multiple inheritance follows the method resolution order (MRO). For
class D(B, C)whereBandCboth extendA, the order is D, B, C, A.super()means "the next class in that order", sosuper().hello()insideBcallsC.hello, notA.hello.
One more trap: a base class __init__ that calls a method the subclass overrides runs the subclass version before the subclass has finished setting itself up:
class Base:
def __init__(self):
self.describe() # runs Child.describe...
class Child(Base):
def __init__(self):
super().__init__()
self.items = [] # ...before this line
def describe(self):
print(self.items) # AttributeError: 'Child' object has no attribute 'items'
Which tool for which job?
| You want | Use |
|---|---|
| A value object you can put in sets and dicts | @dataclass(frozen=True) (or __eq__ + __hash__ over the same fields) |
| A mutable record | @dataclass (unhashable - on purpose) |
| A list or dict default on a dataclass | field(default_factory=list) |
| Validation when an object is created | __post_init__ |
| A modified copy of a frozen object | dataclasses.replace |
| A copy of a grid | [row[:] for row in grid] or copy.deepcopy |
| A read-only view of a dict | types.MappingProxyType |
| Several call signatures | default arguments or singledispatchmethod |
| An interface | abc.ABC + @abstractmethod, or typing.Protocol |
Common mistakes
| Mistake | What happens | Do this instead |
|---|---|---|
__eq__ without __hash__ | unhashable: can't go in a set | define both, over the same fields |
Returning False for an unknown type in __eq__ | the other side never gets a say | return NotImplemented |
| Changing an object while it's a dict key | the entry is lost | keep keys immutable |
A plain @dataclass in a set | TypeError | frozen=True |
tags: list = [] on a dataclass | ValueError | field(default_factory=list) |
is to compare numbers or strings | works by accident, then fails | == |
| A list in the class body | shared by all instances | create it in __init__ |
grid[:] to copy a grid | rows are shared | copy each row, or deepcopy |
| Two methods with one name | the first one disappears | defaults or singledispatchmethod |
A base __init__ calling an overridden method | runs before the subclass is ready | don't call overridable methods from __init__ |
Check yourself
Try to answer before opening each one. Recalling beats re-reading.
1. Why can't the Point at the top go in a set, and what's the one-line fix?
Show answer
Defining __eq__ without __hash__ sets __hash__ to None, so the class is unhashable. Add def __hash__(self): return hash((self.x, self.y)), hashing exactly the fields __eq__ compares - or make it a @dataclass(frozen=True).
2. After a = [1]; b = a; a += [2]; a = a + [3], what are a and b?
Show answer
a is [1, 2, 3] and b is [1, 2]. += extended the shared list in place; a + [3] built a new list and moved only a to it.
3. For class D(B, C) where B and C both extend A, which hello does super().hello() inside B.hello call?
Show answer
C.hello. super() follows the object's MRO - D, B, C, A - so the class after B is C, not A.
Key takeaways
- Equal objects must have equal hashes: define
__eq__and__hash__together, and returnNotImplementedfor strangers. @dataclass(frozen=True)gives value objects; freezing is shallow; usedefault_factoryfor mutable defaults and__post_init__to validate.- Variables are names: assignment never copies,
+=changes lists in place, and class-body lists are shared. isis for identity (None);==is for values.- Copies are shallow unless you copy the insides too; a tuple doesn't freeze what it holds.
- No overloading, every method is overridable, and
super()follows the MRO.