Python Identity vs Equality Practice Problems & Exercises
Practice: Identity vs Equality
← Back to lessonEasy
Predict the output of each comparison. Think carefully about when Python reuses objects vs. creates new ones.
a = 42 b = 42 print(a is b) s1 = "hello" s2 = "hello" print(s1 is s2) print(a == b) x = [1, 2, 3] y = [1, 2, 3] print(x is y) print(x == y) print(s1 == s2)
Solution
True
True
True
False
True
True
Breakdown:
a is bisTrue: CPython caches integers from -5 to 256. Bothaandbpoint to the same cached42object.s1 is s2isTrue: CPython interns short string literals that look like valid identifiers. Both point to the same string object.a == bisTrue: Value equality —42 == 42.x is yisFalse: Each[1, 2, 3]literal creates a new list object. Different objects, differentid()values.x == yisTrue: Lists compare element-by-element for value equality.s1 == s2isTrue: Same string content, so value equality holds.
Key insight: == compares values. is compares object identity (memory address). They can disagree — two objects can be equal (==) without being identical (is).
Expected Output
True\nTrue\nTrue\nFalse\nTrue\nTrueHints
Hint 1: CPython interns small integers (-5 to 256) and short string literals that look like identifiers.
Hint 2: Two list literals with the same contents create two separate objects.
Hint 3: The `==` operator checks value equality; `is` checks object identity.
Write check_argument(value) that returns "missing" if the value is None, otherwise returns the value itself. Use the correct Python idiom for checking None.
print(check_argument(None)) # missing
print(check_argument("hello")) # hello
print(check_argument(None)) # missing
print(check_argument(0)) # 0
print(check_argument([])) # []
Important: Why Why not This would incorrectly treat PEP 8 rule: "Comparisons to singletons like None should always be done with 0 and [] are falsy but they are not None. Your function must distinguish them.Solution
is and not ==:
None is a singleton — there is exactly one None object in the entire Python runtime. Every variable that holds None points to the same object.is checks identity — fast, unambiguous, and cannot be overridden by a custom __eq__.== could be overridden by a class that considers itself equal to None, which would be a bug.if not value:0, [], "", and False as missing. The is None check is precise.is or is not, never the equality operators."
def check_argument(value):
"""Return 'missing' if value is None, otherwise return the value.
Use the correct comparison operator for None.
"""
passExpected Output
missing\nhello\nmissing\n0\n[]Hints
Hint 1: PEP 8 says: comparisons to singletons like None should always use `is` or `is not`, never `==`.
Hint 2: None is a singleton — there is exactly one None object in every Python process.
Hint 3: Be careful: `0`, `[]`, and `""` are falsy but they are NOT None.
Predict the output. For each pair, determine whether == and is agree or disagree.
a = [1, 2, 3]
b = list(a)
print(a == b)
print(a is b)
c = {"name": "Alice", "age": 30}
d = {"name": "Alice", "age": 30}
print(c == d)
print(c is d)
e = frozenset([1, 2, 3])
f = frozenset([1, 2, 3])
print(e == f)
print(e is f)Solution
True
False
True
False
True
False
Every pair is equal (==) but not identical (is):
list(a)creates a new list with the same elements. Same values, different object.- Two dict literals with the same key-value pairs are separate objects. Python does not intern dicts.
- Two
frozensetcalls with the same elements create separate objects. Unlike small ints and short strings, frozensets are not cached.
The pattern: For mutable types (list, dict, set), each constructor call or literal always creates a new object. For immutable types (frozenset, tuple), CPython might reuse objects in some cases, but you should never depend on it.
Rule of thumb: Use == when you care about values. Use is only for singletons (None, True, False) or when you explicitly need identity checking.
Expected Output
True\nFalse\nTrue\nFalse\nTrue\nFalseHints
Hint 1: Mutable objects (lists, dicts, sets) are always separate objects even with the same content.
Hint 2: Tuples with the same content may or may not be the same object — it depends on CPython optimization.
Hint 3: Use `id()` to see the actual memory addresses.
Write identity_report(a, b) that prints the id() of each object, whether they are identical, and returns True/False.
x = [1, 2, 3]
y = x # alias
z = [1, 2, 3] # new object
n = None
m = None
print(identity_report(x, y)) # Same object
print(identity_report(x, z)) # Different objects, same value
print(identity_report(n, m)) # None is a singleton
Solution
def identity_report(a, b):
identical = a is b
print(f"id(a)={id(a)} id(b)={id(b)} identical={identical}")
return identical
Key takeaways:
id()returns an integer that uniquely identifies the object during its lifetime. In CPython, this is the memory address.a is bis literallyid(a) == id(b)— same check, more readable.y = xdoes not copy the list. It creates a second name pointing to the same object. Bothxandyhave the sameid().Noneis a singleton — everyNonereference has the sameid(). This is whyis Noneworks reliably.
Warning: Never use id() for comparison in production code. Use is instead — it is clearer and faster. id() is a debugging and learning tool.
def identity_report(a, b):
"""Print the id of each object and whether they are identical.
Return True if they are the same object, False otherwise.
"""
passExpected Output
id(a)=ADDR id(b)=ADDR identical=True\nTrue\nid(a)=ADDR id(b)=ADDR identical=False\nFalse\nid(a)=ADDR id(b)=ADDR identical=True\nTrueHints
Hint 1: `id(obj)` returns the memory address of the object (in CPython).
Hint 2: `a is b` is equivalent to `id(a) == id(b)`.
Hint 3: Assignment (`y = x`) creates an alias — same object, same id.
Medium
Implement __eq__ on the Temperature class so that two temperatures are equal if they represent the same Celsius value (within floating-point tolerance).
t1 = Temperature(100.0)
t2 = Temperature.from_fahrenheit(212.0)
t3 = Temperature(99.0)
print(t1 == t2) # True — 212F == 100C
print(t1 == t3) # False — 100C != 99C
print(t1 == Temperature(100.0)) # True
print(t1 is t2) # False — different objects
print(t1 == "100") # False — different type
Solution
class Temperature:
def __init__(self, celsius):
self.celsius = celsius
@classmethod
def from_fahrenheit(cls, f):
return cls((f - 32) * 5 / 9)
def __eq__(self, other):
if not isinstance(other, Temperature):
return NotImplemented
return abs(self.celsius - other.celsius) < 1e-9
def __repr__(self):
return f"Temperature({self.celsius:.1f}C)"
Critical details:
-
Type check with
isinstance: Ifotheris not aTemperature, returnNotImplemented(the singleton, not an exception). This tells Python to try the reverse comparison (other.__eq__(self)). If both sides returnNotImplemented, Python falls back toFalse. -
Floating-point tolerance: Direct
==comparison with floats is unreliable due to rounding.(212 - 32) * 5 / 9might produce99.99999999999999instead of100.0. Usingabs(a - b) < epsilonhandles this. -
isvs==: Even thought1 == t2isTrue,t1 is t2isFalsebecause they are separate objects in memory. This is the whole point of having__eq__— it decouples value equality from identity.
class Temperature:
"""Represents a temperature that can be compared by value.
Two temperatures are equal if they represent the same
value in Celsius, regardless of original unit.
"""
def __init__(self, celsius):
self.celsius = celsius
@classmethod
def from_fahrenheit(cls, f):
return cls((f - 32) * 5 / 9)
def __eq__(self, other):
# Implement value equality
pass
def __repr__(self):
return f"Temperature({self.celsius:.1f}C)"Expected Output
True\nFalse\nTrue\nFalse\nFalseHints
Hint 1: Check that `other` is a Temperature instance before comparing — return NotImplemented otherwise.
Hint 2: Use `round()` or a tolerance (epsilon) for floating-point comparison.
Hint 3: Returning NotImplemented (not raising it) lets Python try the reverse comparison.
Observe what breaks when you implement __eq__ without __hash__, then fix it.
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __eq__(self, other):
if not isinstance(other, Point):
return NotImplemented
return self.x == other.x and self.y == other.y
def __repr__(self):
return f"Point({self.x}, {self.y})"
p1 = Point(0, 0)
p2 = Point(0, 0)
print("Equal:", p1 == p2)
# This will fail — try it
try:
s = {p1, p2}
print("Set size:", len(s))
except TypeError as e:
print(f"ERROR: {e}")
# Now fix it: add __hash__ to Point and try again
class FixedPoint:
def __init__(self, x, y):
self.x = x
self.y = y
def __eq__(self, other):
if not isinstance(other, FixedPoint):
return NotImplemented
return self.x == other.x and self.y == other.y
def __hash__(self):
return hash((self.x, self.y))
def __repr__(self):
return f"FixedPoint({self.x}, {self.y})"
fp1 = FixedPoint(0, 0)
fp2 = FixedPoint(0, 0)
fp3 = FixedPoint(1, 1)
s = {fp1, fp2, fp3}
print("Fixed set size:", len(s))
d = {fp1: "origin", fp3: "diagonal"}
print("Fixed dict works:", d[fp2]) # fp2 == fp1, same hash -> finds itSolution
Equal: True
ERROR: unhashable type: 'Point'
Fixed set size: 2
Fixed dict works: origin
The rule Python enforces:
When you define __eq__, Python sets __hash__ = None on your class. This makes instances unhashable — they cannot be used as set members or dict keys.
Why: Sets and dicts use hash tables internally. If two objects are equal (a == b), they must have the same hash (hash(a) == hash(b)). Without a custom __hash__, Python cannot guarantee this invariant, so it disables hashing entirely.
The contract:
- If
a == b, thenhash(a) == hash(b)(REQUIRED) - If
hash(a) == hash(b), thena == b(NOT required — hash collisions are allowed)
How to implement __hash__:
def __hash__(self):
return hash((self.x, self.y)) # Hash a tuple of the same fields used in __eq__
Use the same fields in __hash__ that you use in __eq__. If equality checks x and y, the hash must depend on x and y.
Warning: Only implement __hash__ on objects whose equality-relevant fields are immutable. If x or y can change after insertion into a set/dict, the object becomes "lost" — its hash bucket no longer matches its content.
Expected Output
Equal: True\nERROR: unhashable type: 'Point'\nFixed set size: 2\nFixed dict works: originHints
Hint 1: When you define __eq__, Python automatically sets __hash__ to None — making the class unhashable.
Hint 2: If two objects are equal, they MUST have the same hash. The reverse is not required.
Hint 3: A common pattern: hash a tuple of the fields used in __eq__.
Demonstrate and explain the mutable default argument bug using is and id() to prove that the same object is reused across calls. Then write the fixed version.
def buggy_append(item, items=[]):
items.append(item)
return items
r1 = buggy_append("a")
print(f"Call 1: {r1} id={id(r1)}")
r2 = buggy_append("b")
print(f"Call 2: {r2} id={id(r2)}")
r3 = buggy_append("c")
print(f"Call 3: {r3} id={id(r3)}")
# Prove it is the SAME object every time
print(f"Same object every time: {r1 is r2 is r3}")
# --- Fixed version ---
def fixed_append(item, items=None):
if items is None:
items = []
items.append(item)
return items
f1 = fixed_append("x")
print(f"Fixed call 1: {f1}")
f2 = fixed_append("y")
print(f"Fixed call 2: {f2}")
print(f"Same object: {f1 is f2}")Solution
The bug in detail:
Python evaluates default argument expressions once, at function definition time (when the def statement executes). The list [] is created once and stored in buggy_append.__defaults__. Every call that does not pass items uses the same list object.
You can inspect it directly:
print(buggy_append.__defaults__) # (['a', 'b', 'c'],)
Why is proves the bug:
r1 is r2 is r3isTrue— all three return values point to the exact same list object.- The
id()is the same every time — same memory address.
The fix uses is None:
def fixed_append(item, items=None):
if items is None: # Use `is`, not `==`
items = [] # Fresh list every call
items.append(item)
return items
Noneis immutable and a singleton, so it is safe as a default.is None(not== None) is correct per PEP 8.- Each call creates a new
[]inside the function body.
This is one of the most common Python interview questions. Interviewers expect you to explain both the mechanism (default evaluated once) and the fix (None sentinel pattern).
Expected Output
Call 1: ['a'] id=SAME\nCall 2: ['a', 'b'] id=SAME\nCall 3: ['a', 'b', 'c'] id=SAME\nSame object every time: True\nFixed call 1: ['x']\nFixed call 2: ['y']\nSame object: FalseHints
Hint 1: Default arguments are evaluated ONCE at function definition time.
Hint 2: The same list object is reused across all calls — check with `is`.
Hint 3: The fix: use None as the default and create a new list inside the function body.
Write a function that creates objects and returns them, then check identity of return values across multiple calls. Predict which types will be identical across calls.
def make_int():
return 42
def make_large_int():
return 10**7
def make_string():
return "hello"
def make_computed_string():
return "".join(["h", "e", "l", "l", "o"])
def make_none():
return None
def make_bool():
return True
def make_tuple():
return (1, 2, 3)
def make_empty_tuple():
return ()
# Check identity across two calls
print(f"ints_same: {make_int() is make_int()}")
print(f"large_ints_same: {make_large_int() is make_large_int()}")
print(f"strings_same: {make_string() is make_string()}")
print(f"computed_strings_same: {make_computed_string() is make_computed_string()}")
print(f"none_same: {make_none() is make_none()}")
print(f"bool_same: {make_bool() is make_bool()}")
print(f"tuple_same: {make_tuple() is make_tuple()}")
print(f"empty_tuple_same: {make_empty_tuple() is make_empty_tuple()}")Solution
ints_same: True
large_ints_same: False
strings_same: True
computed_strings_same: False (usually)
none_same: True
bool_same: True
tuple_same: True (in CPython 3.12+ due to constant folding)
empty_tuple_same: True
What CPython caches (and what it does not):
| Object | Cached/Interned? | Why |
|---|---|---|
| Small ints (-5 to 256) | Yes | Pre-allocated at startup |
| Large ints | No | Created fresh each time |
| String literals | Yes | Compiler interns identifier-like strings |
| Computed strings | Usually no | Created at runtime, not interned |
None | Always | Singleton — exactly one object |
True / False | Always | Singletons — exactly one of each |
Empty tuple () | Always | Singleton optimization |
| Non-empty tuples | Sometimes | Constant folding by the compiler may reuse them |
Critical rule: These are CPython implementation details, NOT language guarantees. Never write code that depends on is for value comparison. Use == for values, is only for None/True/False or explicit identity checks.
Expected Output
ints_same: True\nlarge_ints_same: False\nstrings_same: True\ncomputed_strings_same: varies\nnone_same: True\nbool_same: True\ntuple_same: varies\nempty_tuple_same: TrueHints
Hint 1: CPython caches: small ints (-5 to 256), interned strings, None, True, False, empty tuple.
Hint 2: Strings created by computation (e.g., joining) may not be interned.
Hint 3: Empty tuple is a singleton in CPython — there is only one `()` object.
Build a test harness that checks == and is for pairs of equal-valued objects across all common built-in types. Report which types have identity match and which do not.
pairs = [
(42, 42), # int (cached)
(3.14, 3.14), # float
("test", "test"), # str (interned)
([1, 2], [1, 2]), # list
((1, 2), (1, 2)), # tuple
({"a": 1}, {"a": 1}), # dict
({1, 2}, {1, 2}), # set
(None, None), # NoneType
(True, True), # bool
(frozenset([1, 2]), frozenset([1, 2])),# frozenset
]
results = identity_equality_report(pairs)
for r in results:
name = r["type"].ljust(11)
print(f"{name} equal={str(r['equal']).ljust(5)} "
f"identical={str(r['identical']).ljust(5)} "
f"agree={r['agree']}")
Solution
def identity_equality_report(pairs):
results = []
for a, b in pairs:
equal = a == b
identical = a is b
results.append({
"type": type(a).__name__,
"equal": equal,
"identical": identical,
"agree": equal == identical,
})
return results
The pattern this reveals:
Only singletons and cached objects have is return True for equal values:
None— singletonTrue/False— singletons- Small ints — cached (-5 to 256)
- Interned strings — cached by the compiler
All mutable types (list, dict, set) and most immutable types (float, tuple, frozenset) create new objects even for identical values.
Interview insight: This is the definitive test to show an interviewer you understand the difference. == is about the values the objects hold. is is about whether they are the same object in memory. They are orthogonal concerns that happen to overlap for singletons and cached objects.
def identity_equality_report(pairs):
"""For each (a, b) pair, report whether a == b, a is b,
and whether they agree or disagree.
Return a list of dicts with keys: 'type', 'equal', 'identical', 'agree'.
"""
results = []
for a, b in pairs:
# Build the report for this pair
pass
return resultsExpected Output
int: equal=True identical=True agree=True\nfloat: equal=True identical=False agree=False\nstr: equal=True identical=True agree=True\nlist: equal=True identical=False agree=False\ntuple: equal=True identical=False agree=False\ndict: equal=True identical=False agree=False\nset: equal=True identical=False agree=False\nNoneType: equal=True identical=True agree=True\nbool: equal=True identical=True agree=True\nfrozenset: equal=True identical=False agree=FalseHints
Hint 1: For each pair, check both `a == b` and `a is b`.
Hint 2: They "agree" when both are True or both are False.
Hint 3: Use `type(a).__name__` to get a readable type name.
Hard
Implement a complete value object pattern. Money must be:
- Immutable (cannot change after creation)
- Value-equal (two
Moneywith same amount and currency are==) - Hashable (usable as dict keys and set members)
- Consistent (
__eq__and__hash__use the same fields)
m1 = Money(10.00, "USD")
m2 = Money(10.00, "USD")
m3 = Money(10.00, "EUR")
m4 = Money(20.00, "USD")
print(f"m1 == m2: {m1 == m2}") # True — same value
print(f"m1 is m2: {m1 is m2}") # False — different objects
print(f"m1 == m3: {m1 == m3}") # False — different currency
print(f"m1 == m4: {m1 == m4}") # False — different amount
# Usable in sets (deduplication)
prices = {m1, m2, m3, m4}
print(f"Set size: {len(prices)}") # 3 (m1 and m2 collapse)
# Usable as dict keys
ledger = {m1: "ten dollars", m3: "ten euros"}
print(f"Dict lookup: {ledger[m2]}") # Works because m1 == m2 and hash matches
# Addition
print(f"Sum: {m1 + m4}") # Money(30.0, 'USD')
# Immutability
try:
m1.amount = 999
except AttributeError:
print("Immutable: True")
Solution
class Money:
__slots__ = ('_amount', '_currency')
def __init__(self, amount, currency="USD"):
object.__setattr__(self, '_amount', round(amount, 2))
object.__setattr__(self, '_currency', currency.upper())
@property
def amount(self):
return self._amount
@property
def currency(self):
return self._currency
def __setattr__(self, name, value):
raise AttributeError("Money objects are immutable")
def __eq__(self, other):
if not isinstance(other, Money):
return NotImplemented
return self._amount == other._amount and self._currency == other._currency
def __hash__(self):
return hash((self._amount, self._currency))
def __repr__(self):
return f"Money({self._amount}, '{self._currency}')"
def __add__(self, other):
if not isinstance(other, Money):
return NotImplemented
if self._currency != other._currency:
raise ValueError(f"Cannot add {self._currency} and {other._currency}")
return Money(self._amount + other._amount, self._currency)
The value object contract:
-
Immutability:
__slots__prevents adding new attributes.__setattr__raises on any mutation.object.__setattr__in__init__bypasses our custom__setattr__to set the initial values. -
Equality by value:
__eq__compares_amountand_currency. TwoMoney(10, "USD")objects are equal regardless of identity. -
Hash consistency:
__hash__useshash((self._amount, self._currency))— the exact same fields as__eq__. This guarantees: ifa == b, thenhash(a) == hash(b). -
Why
round(amount, 2): Prevents floating-point drift.Money(10.001)becomesMoney(10.0). Without rounding,10.0 + 0.1 + 0.1 + 0.1might not equal10.3.
Why this matters in production:
- Value objects are everywhere: coordinates, dates, monetary amounts, API keys, color values.
- Getting the
__eq__/__hash__contract wrong causes silent bugs — objects "disappear" from dicts and sets, or duplicate entries appear where there should be one. - Python's
dataclasseswithfrozen=Trueautomate this pattern, but understanding it manually is essential.
class Money:
"""Immutable value object representing a monetary amount.
Two Money objects are equal if they have the same amount and currency.
Must be usable as dict keys and set members.
"""
__slots__ = ('_amount', '_currency')
def __init__(self, amount, currency="USD"):
# Store as immutable (use object.__setattr__ with __slots__)
object.__setattr__(self, '_amount', round(amount, 2))
object.__setattr__(self, '_currency', currency.upper())
@property
def amount(self):
return self._amount
@property
def currency(self):
return self._currency
def __setattr__(self, name, value):
raise AttributeError("Money objects are immutable")
def __eq__(self, other):
pass
def __hash__(self):
pass
def __repr__(self):
return f"Money({self._amount}, '{self._currency}')"
def __add__(self, other):
if not isinstance(other, Money):
return NotImplemented
if self._currency != other._currency:
raise ValueError(f"Cannot add {self._currency} and {other._currency}")
return Money(self._amount + other._amount, self._currency)Expected Output
m1 == m2: True\nm1 is m2: False\nm1 == m3: False\nm1 == m4: False\nSet size: 3\nDict lookup: ten dollars\nSum: Money(30.0, 'USD')\nImmutable: TrueHints
Hint 1: The __eq__ must check isinstance and compare both amount AND currency.
Hint 2: The __hash__ must use the same fields as __eq__ — hash((amount, currency)).
Hint 3: __slots__ prevents adding arbitrary attributes, making the object truly immutable.
Hint 4: Round the amount in __init__ to avoid floating-point equality issues.
Build an IdentityTracker that monitors variable-to-object bindings and can detect when two names are aliases (point to the same object).
tracker = IdentityTracker()
data = [1, 2, 3]
ref = data # alias
other = [1, 2, 3] # equal but not identical
tracker.register("x", data)
tracker.register("y", ref)
tracker.register("z", other)
print(f"Aliases of x: {tracker.aliases('x')}")
print(f"is_alias(x, y): {tracker.is_alias('x', 'y')}")
print(f"is_alias(x, z): {tracker.is_alias('x', 'z')}")
print("--- Alias Report ---")
tracker.report()
tracker.unregister("y")
print("After unregister y:")
print(f"Aliases of x: {tracker.aliases('x')}")
print("--- Alias Report ---")
tracker.report()
Solution
class IdentityTracker:
def __init__(self):
self._names = {} # name -> id
self._ids = {} # id -> set of names
def register(self, name, obj):
obj_id = id(obj)
# If name was previously registered, clean up old binding
if name in self._names:
self.unregister(name)
self._names[name] = obj_id
if obj_id not in self._ids:
self._ids[obj_id] = set()
print(f"Registered {name} -> id={obj_id}")
else:
existing = ", ".join(sorted(self._ids[obj_id]))
print(f"Registered {name} -> id={obj_id} (aliases: {existing})")
self._ids[obj_id].add(name)
def unregister(self, name):
if name not in self._names:
return
obj_id = self._names.pop(name)
self._ids[obj_id].discard(name)
if not self._ids[obj_id]:
del self._ids[obj_id]
def aliases(self, name):
if name not in self._names:
return []
obj_id = self._names[name]
return sorted(n for n in self._ids[obj_id] if n != name)
def is_alias(self, name1, name2):
if name1 not in self._names or name2 not in self._names:
return False
return self._names[name1] == self._names[name2]
def report(self):
found = False
for obj_id, names in self._ids.items():
if len(names) >= 2:
print(f"Object {obj_id}: {', '.join(sorted(names))}")
found = True
if not found:
print("(no aliases)")
How this mirrors Python's own name binding:
Python's runtime does exactly this — it maintains mappings from names (in namespaces) to objects. When you write y = x, Python does not copy the object. It creates a new name entry that points to the same object id(). Our IdentityTracker makes this invisible mechanism visible.
Why this is useful for debugging:
- Detecting unintentional aliasing in large codebases (two variables pointing to the same mutable list)
- Understanding memory layout in data pipelines
- Teaching: visualizing that
y = xcreates an alias, not a copy
Caveat with id(): In CPython, id() returns the memory address. If an object is freed and a new object is allocated at the same address, the ids can collide. This tracker only works for objects that are alive simultaneously.
class IdentityTracker:
"""Tracks objects and detects aliasing.
Usage:
tracker = IdentityTracker()
tracker.register('x', some_obj)
tracker.register('y', some_obj) # alias!
tracker.aliases('x') # returns ['y']
"""
def __init__(self):
self._names = {} # name -> id
self._ids = {} # id -> set of names
def register(self, name, obj):
"""Register a name-object binding. Detect if it aliases an existing name."""
pass
def unregister(self, name):
"""Remove a name binding (like del name)."""
pass
def aliases(self, name):
"""Return list of other names that point to the same object."""
pass
def is_alias(self, name1, name2):
"""Check if two names point to the same object."""
pass
def report(self):
"""Print all alias groups (objects with 2+ names)."""
passExpected Output
Registered x -> id=ADDR\nRegistered y -> id=ADDR (aliases: x)\nRegistered z -> id=ADDR\nAliases of x: ['y']\nis_alias(x, y): True\nis_alias(x, z): False\n--- Alias Report ---\nObject ADDR: x, y\nAfter unregister y:\nAliases of x: []\n--- Alias Report ---\n(no aliases)Hints
Hint 1: Use `id(obj)` as the key to track which names point to the same object.
Hint 2: Maintain two mappings: name->id and id->set_of_names.
Hint 3: When registering, check if the id already exists to detect aliasing.
Hint 4: When unregistering, clean up both mappings.
Implement a complete, consistent comparison protocol on Version. This is the gold standard for custom comparison in Python.
v1 = Version(1, 0, 0)
v2 = Version(1, 0, 0)
v3 = Version(2, 0, 0)
# Equality and inequality
print(f"v1 == v2: {v1 == v2}")
print(f"v1 != v3: {v1 != v3}")
print(f"v1 is v2: {v1 is v2}")
# Hash consistency
print(f"hash match: {hash(v1) == hash(v2)}")
# Set deduplication
versions = {v1, v2, v3}
print(f"Set: {versions}")
# Dict key lookup
registry = {v1: "stable"}
print(f"Dict: found v{registry[v2]}")
# Sorting
unsorted = [Version(2, 0, 0), Version(1, 2, 3), Version(1, 0, 0), Version(1, 2, 0)]
print(f"Sorted: {sorted(unsorted)}")
# Cross-type comparison (must not crash)
print(f"v1 == 'not a version': {v1 == 'not a version'}")
print(f"v1 != 42: {v1 != 42}")
# Consistency check
def check_consistency(a, b):
eq = a == b
ne = a != b
hash_ok = (not eq) or (hash(a) == hash(b))
ne_ok = ne == (not eq)
return hash_ok and ne_ok
pairs = [(v1, v2), (v1, v3), (v2, v3)]
all_consistent = all(check_consistency(a, b) for a, b in pairs)
print(f"Consistency: {all_consistent}")
Solution
class Version:
def __init__(self, major, minor=0, patch=0):
self.major = major
self.minor = minor
self.patch = patch
def _as_tuple(self):
return (self.major, self.minor, self.patch)
def __eq__(self, other):
if not isinstance(other, Version):
return NotImplemented
return self._as_tuple() == other._as_tuple()
def __ne__(self, other):
result = self.__eq__(other)
if result is NotImplemented:
return result
return not result
def __hash__(self):
return hash(self._as_tuple())
def __lt__(self, other):
if not isinstance(other, Version):
return NotImplemented
return self._as_tuple() < other._as_tuple()
def __le__(self, other):
if not isinstance(other, Version):
return NotImplemented
return self._as_tuple() <= other._as_tuple()
def __gt__(self, other):
if not isinstance(other, Version):
return NotImplemented
return self._as_tuple() > other._as_tuple()
def __ge__(self, other):
if not isinstance(other, Version):
return NotImplemented
return self._as_tuple() >= other._as_tuple()
def __repr__(self):
return f"Version({self.major}, {self.minor}, {self.patch})"
def __str__(self):
return f"{self.major}.{self.minor}.{self.patch}"
The complete comparison protocol has three invariants:
1. __eq__ / __hash__ consistency:
if a == b, then hash(a) == hash(b) # REQUIRED
Both use _as_tuple() which returns (major, minor, patch). Since tuple.__eq__ and tuple.__hash__ are consistent, ours are too.
2. __ne__ / __eq__ consistency:
(a != b) == (not (a == b)) # REQUIRED
Python 3 auto-generates __ne__ as not __eq__ if you do not define it. But if you define __ne__ manually, you must ensure this invariant. Our implementation delegates to __eq__ and negates the result, preserving NotImplemented propagation.
3. Ordering consistency (total order):
if a < b, then b > a # antisymmetry
if a < b and b < c, then a < c # transitivity
a < b or a == b or a > b # totality
Tuple comparison in Python satisfies all three, so delegating to tuples gives us a correct total order.
Why NotImplemented (not raise NotImplementedError):
- Returning
NotImplementedtells Python: "I don't know how to compare with this type." - Python then tries the reverse operation:
other.__eq__(self). - If both sides return
NotImplemented, Python falls back to identity comparison for==(returnsFalse) and raisesTypeErrorfor ordering operators.
Production shortcut: Use @functools.total_ordering to implement only __eq__ and __lt__, and Python generates the rest. But knowing the full protocol is essential for interviews and debugging.
class Version:
"""Semantic version with consistent comparison protocol.
Rules:
- Two versions are equal if major, minor, patch match
- __ne__ must be consistent with __eq__
- __hash__ must be consistent with __eq__
- Must handle comparison with non-Version types gracefully
- Must work correctly in sets, dicts, and sorted collections
"""
def __init__(self, major, minor=0, patch=0):
self.major = major
self.minor = minor
self.patch = patch
def __eq__(self, other):
pass
def __ne__(self, other):
pass
def __hash__(self):
pass
def __lt__(self, other):
pass
def __le__(self, other):
pass
def __gt__(self, other):
pass
def __ge__(self, other):
pass
def __repr__(self):
return f"Version({self.major}, {self.minor}, {self.patch})"
def __str__(self):
return f"{self.major}.{self.minor}.{self.patch}"Expected Output
v1 == v2: True\nv1 != v3: True\nv1 is v2: False\nhash match: True\nSet: {Version(1, 0, 0), Version(2, 0, 0)}\nDict: found v1.0.0\nSorted: [Version(1, 0, 0), Version(1, 2, 0), Version(1, 2, 3), Version(2, 0, 0)]\nv1 == 'not a version': False\nv1 != 42: True\nConsistency: TrueHints
Hint 1: Use tuple comparison for ordering: (major, minor, patch) < (major, minor, patch).
Hint 2: __ne__ should delegate to __eq__ — return not (self == other), but handle NotImplemented.
Hint 3: All ordering operators (__lt__, __le__, __gt__, __ge__) should check isinstance and return NotImplemented for non-Version types.
Hint 4: The consistency check: for all a, b — (a == b) implies hash(a) == hash(b), and (a != b) == (not (a == b)).
