Skip to main content

Python Identity vs Equality Practice Problems & Exercises

Practice: Identity vs Equality

12 problems4 Easy5 Medium3 Hard45–60 min
← Back to lesson

Easy

#1Predict is vs == for Strings and IntsEasy
is==interningidentity

Predict the output of each comparison. Think carefully about when Python reuses objects vs. creates new ones.

Python
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 b is True: CPython caches integers from -5 to 256. Both a and b point to the same cached 42 object.
  • s1 is s2 is True: CPython interns short string literals that look like valid identifiers. Both point to the same string object.
  • a == b is True: Value equality — 42 == 42.
  • x is y is False: Each [1, 2, 3] literal creates a new list object. Different objects, different id() values.
  • x == y is True: Lists compare element-by-element for value equality.
  • s1 == s2 is True: 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\nTrue
Hints

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.

#2None Should Always Use isEasy
Noneissentinelbest-practice

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: 0 and [] are falsy but they are not None. Your function must distinguish them.

Solution
def check_argument(value):
if value is None:
return "missing"
return value

Why 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.

Why not if not value:

if not value: # WRONG — catches 0, [], "", False, etc.
return "missing"

This would incorrectly treat 0, [], "", and False as missing. The is None check is precise.

PEP 8 rule: "Comparisons to singletons like None should always be done with 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.
    """
    pass
Expected 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.

#3Equal but Not IdenticalEasy
==isequalityidentity

Predict the output. For each pair, determine whether == and is agree or disagree.

Python
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 frozenset calls 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\nFalse
Hints

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.

#4Identity Verifier with id()Easy
idisidentitymemory-address

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 b is literally id(a) == id(b) — same check, more readable.
  • y = x does not copy the list. It creates a second name pointing to the same object. Both x and y have the same id().
  • None is a singleton — every None reference has the same id(). This is why is None works 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.
    """
    pass
Expected 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\nTrue
Hints

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

#5Custom __eq__ on a ClassMedium
__eq__custom-classequalitydunder

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:

  1. Type check with isinstance: If other is not a Temperature, return NotImplemented (the singleton, not an exception). This tells Python to try the reverse comparison (other.__eq__(self)). If both sides return NotImplemented, Python falls back to False.

  2. Floating-point tolerance: Direct == comparison with floats is unreliable due to rounding. (212 - 32) * 5 / 9 might produce 99.99999999999999 instead of 100.0. Using abs(a - b) < epsilon handles this.

  3. is vs ==: Even though t1 == t2 is True, t1 is t2 is False because 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\nFalse
Hints

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.

#6__eq__ Without __hash__ Breaks Sets and DictsMedium
__eq____hash__setdictunhashable

Observe what breaks when you implement __eq__ without __hash__, then fix it.

Python
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 it
Solution
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, then hash(a) == hash(b) (REQUIRED)
  • If hash(a) == hash(b), then a == 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: origin
Hints

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__.

#7The Mutable Default Argument is GotchaMedium
mutable-defaultisidentitygotcha

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.

Python
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 r3 is True — 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
  • None is 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: False
Hints

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.

#8Identity Across Function CallsMedium
identityfunction-callsinterningcaching

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.

Python
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):

ObjectCached/Interned?Why
Small ints (-5 to 256)YesPre-allocated at startup
Large intsNoCreated fresh each time
String literalsYesCompiler interns identifier-like strings
Computed stringsUsually noCreated at runtime, not interned
NoneAlwaysSingleton — exactly one object
True / FalseAlwaysSingletons — exactly one of each
Empty tuple ()AlwaysSingleton optimization
Non-empty tuplesSometimesConstant 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: True
Hints

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.

#9Identity vs Equality Type TesterMedium
is==built-in-typescomprehensive

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 — singleton
  • True/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 results
Expected 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=False
Hints

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

#10Value Object with Proper __eq__ and __hash__Hard
__eq____hash__value-objectimmutabledesign-pattern

Implement a complete value object pattern. Money must be:

  1. Immutable (cannot change after creation)
  2. Value-equal (two Money with same amount and currency are ==)
  3. Hashable (usable as dict keys and set members)
  4. 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:

  1. Immutability: __slots__ prevents adding new attributes. __setattr__ raises on any mutation. object.__setattr__ in __init__ bypasses our custom __setattr__ to set the initial values.

  2. Equality by value: __eq__ compares _amount and _currency. Two Money(10, "USD") objects are equal regardless of identity.

  3. Hash consistency: __hash__ uses hash((self._amount, self._currency)) — the exact same fields as __eq__. This guarantees: if a == b, then hash(a) == hash(b).

  4. Why round(amount, 2): Prevents floating-point drift. Money(10.001) becomes Money(10.0). Without rounding, 10.0 + 0.1 + 0.1 + 0.1 might not equal 10.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 dataclasses with frozen=True automate 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: True
Hints

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.

#11Object Identity Tracker (Aliasing Detector)Hard
identityaliasingidtrackingdebugging

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 = x creates 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)."""
        pass
Expected 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.

#12Consistent Comparison Protocol: __eq__, __ne__, __hash__Hard
__eq____ne____hash__protocolconsistency

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 NotImplemented tells 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 == (returns False) and raises TypeError for 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: True
Hints

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)).

© 2026 EngineersOfAI. All rights reserved.