Lesson 02 / 16
Tuples
Three of six operations fail on a tuple with an exception, but the operation that mutates an inner item still passes on a tuple; immutability is one layer deep, and in exchange the container gains the capability to be a key, as seen in three of five candidates.
Contents
Every measurement in the previous lesson rested on the list being mutable: a slot could be added, deleted, rearranged. Objects created was zero in most forms, because the list only rearranged slots.
This lesson repeats the same slots in an immutable container. A tuple is also an ordered sequence of slots, but once built, its slots cannot be rewritten. The question has two countable parts: which operation does immutability actually make impossible, and what capability does the container gain in exchange?
What Immutability Closes Off
The concept of immutability itself is not this course’s subject. The distinction between a mutable and an immutable object was built in the Programming Fundamentals course; the Software Design and Architecture Principles course took up immutability as a design technique eliminating shared state. Neither repeats here. What’s measured is narrower and more concrete: when the same operations run on both containers, which lines pass, which fail with an exception.
Where the distinction stands has to be said up front, since the measurement looks exactly there. A tuple protects its own slots. It cannot interfere with the objects those slots point to — those are separate objects, subject to their own rules. A tuple’s guarantee is therefore not “the contents do not change” but “which object I’m looking at does not change.”
- CO13 — Both containers try all six operations in the same order, each with a freshly built container from the same values.
- CO14 — The result column takes one of three values: if it passed, whether the result is the same container or a new container; if not, the class name of the exception raised.
- CO15 — “Same container” is an
iscomparison against the reference held before the operation. - CO16 — Sorting uses a key function; no comparison method is added to the item class, so counting order does not change.
- CO17 — The line mutating an inner item never touches the container itself; it only writes to a field on the object a slot points to.
"""Same six operations on a list and a tuple: what does immutability actually make impossible.""" COUNTER = {"created": 0} class Item: def __init__(self, value): COUNTER["created"] += 1 self.value = value def __repr__(self): return f"Item({self.value})" def reset(): COUNTER["created"] = 0 def created(): return COUNTER["created"] def value(o): return o.value POOL = [Item(i) for i in (3, 1, 4)] def overwrite_slot(k): k[0] = Item(9) return k def append_end(k): k.append(Item(9)) return k def delete_slot(k): del k[0] return k def inplace_concat(k): k += type(k)((Item(9),)) return k def mutate_inner(k): k[0].value = 9 return k def sort_new(k): return sorted(k, key=value) OPERATIONS = (("k[0] = new", overwrite_slot), ("k.append(new)", append_end), ("del k[0]", delete_slot), ("k += (new,)", inplace_concat), ("k[0].value = 9", mutate_inner), ("sorted(k, ...)", sort_new)) print(f"{'operation':<18s}{'in list':<16s}{'in tuple'}") for label, op in OPERATIONS: row = [] for kind in (list, tuple): k = kind(Item(o.value) for o in POOL) before = k try: result = op(k) row.append("same container" if result is before else "new container") except Exception as e: row.append(type(e).__name__) print(f"{label:<18s}{row[0]:<16s}{row[1]}") print() tup = tuple(POOL) reset() bigger = tup + (Item(9),) print(f"appending to tuple: created {created()} | old slots {len(tup)} -> " f"new slots {len(bigger)}") print(f"bigger is tup -> {bigger is tup} | " f"bigger[0] is tup[0] -> {bigger[0] is tup[0]}") print() print(f"reading tup[0].value: {tup[0].value}") tup[0].value = 9 print(f"after mutating the inner item: {tup[0].value} | " f"tup[0] is POOL[0] -> {tup[0] is POOL[0]}")
operation in list in tuple k[0] = new same container TypeError k.append(new) same container AttributeError del k[0] same container TypeError k += (new,) same container new container k[0].value = 9 same container same container sorted(k, ...) new container new container appending to tuple: created 1 | old slots 3 -> new slots 4 bigger is tup -> False | bigger[0] is tup[0] -> True reading tup[0].value: 3 after mutating the inner item: 9 | tup[0] is POOL[0] -> True
Three Rows Fail, Three Pass
Three of the six operations fail on a tuple with an exception, three pass.
The three that fail all tried the same thing: rewriting, adding, or
deleting a slot. How they fail differs. Writing to a slot and deleting one
give TypeError — these spellings mean something on a tuple too, but it
does not support that meaning. Appending, though, gives AttributeError:
there is no append method on a tuple. The first says “I cannot,” the
second says “I know no such thing.”
Two of the three passing rows already did not change the container. sorted
gives a new container on both, since it always returns a new list and
never touches the source. k += (new,) sits right in the middle: same
container on the list, new container on the tuple. The syntax is the
same, the result looks the same, but one extends the existing container and
the other builds a new one and binds the name to it. Because a tuple is
immutable, growing it “in place” is not an extension there, it is a
rebuild.
The numbers in the middle give the bill: created is 1, the appended
item itself. The container grows — three slots to four — but the three old
objects inside it are not recreated. bigger is tup is False, so the
container really is new; but bigger[0] is tup[0] is True, so the item
is old. Growing a tuple by appending one item a thousand times means
building a thousand containers, not copying the objects inside it a
thousand times.
The last two lines are this lesson’s most critical measurement. k[0].value = 9 passes on a tuple — without any warning. The tuple protected its
own slot: it still points to the same object, and tup[0] is POOL[0]
confirms it. But that object’s inside changed; the value read was 3,
then became 9. This means immutability is one layer deep: a
container’s guarantee is limited to which object its slots point to, not
the state of those objects.
What Immutability Buys
A capability opens up in exchange for the three operations closed off. An object being hashable means a fixed hash value can be computed from it, one that stays unchanged for the object’s whole lifetime. A container whose slots can be rewritten cannot make that promise: when it changed, its hash value would change too, and the object would get lost wherever it had been placed. A tuple can make that promise — but only if what’s inside it can too.
- CO18 — Hashability is tested directly: taking the hash value is attempted, and the exception’s class name is written. The hash value is never printed; what’s measured is whether it could be taken, not the value.
- CO19 — Whether it can be a key is a separate column, tested by building a single-entry mapping; the two columns confirm each other.
- CO20 — In the multiple-return measurement, the function returns already existing items, so only the cost of the return and the unpacking themselves is counted.
- CO21 — Created count during unpacking is found by subtracting the count at return time from the count after.
"""What a tuple gains: being usable as a key and carrying a multiple return value.""" COUNTER = {"created": 0} class Item: def __init__(self, value): COUNTER["created"] += 1 self.value = value def __repr__(self): return f"Item({self.value})" def reset(): COUNTER["created"] = 0 def created(): return COUNTER["created"] CANDIDATES = (("(2031, 7)", (2031, 7)), ("[2031, 7]", [2031, 7]), ("((2031, 7), (2032, 1))", ((2031, 7), (2032, 1))), ("(2031, [7])", (2031, [7])), ("'north'", "north")) print(f"{'candidate':<24s}{'hashable':<16s}{'can be a key'}") for label, x in CANDIDATES: try: hash(x) h = "yes" except TypeError as e: h = type(e).__name__ try: {x: 0} a = "yes" except TypeError as e: a = type(e).__name__ print(f"{label:<24s}{h:<16s}{a}") RECORD = {(2031, 7): "slope", (2031, 8): "north", (2032, 1): "summary"} print() print(f"dict keyed by tuple: {len(RECORD)} entries | " f"RECORD[(2031, 8)] -> {RECORD[(2031, 8)]!r}") POOL = [Item(i) for i in (3, 1, 4)] def triple(): return POOL[0], POOL[1], POOL[2] reset() result = triple() return_created = created() a, b, c = result unpack_created = created() print() print(f"return container type {type(result).__name__} | slots {len(result)} | " f"created on return {return_created} | created on unpack " f"{unpack_created - return_created}") print(f"a is POOL[0] -> {a is POOL[0]} | c is POOL[2] -> {c is POOL[2]}") reset() x, y = POOL[0], POOL[1] x, y = y, x print(f"swapping two names: created {created()} | " f"x is POOL[1] -> {x is POOL[1]}") reset() head, *middle = POOL print(f"starred unpacking: created {created()} | middle is a " f"{type(middle).__name__} with {len(middle)} slots | " f"middle[0] is POOL[1] -> {middle[0] is POOL[1]}")
candidate hashable can be a key (2031, 7) yes yes [2031, 7] TypeError TypeError ((2031, 7), (2032, 1)) yes yes (2031, [7]) TypeError TypeError 'north' yes yes dict keyed by tuple: 3 entries | RECORD[(2031, 8)] -> 'north' return container type tuple | slots 3 | created on return 0 | created on unpack 0 a is POOL[0] -> True | c is POOL[2] -> True swapping two names: created 0 | x is POOL[1] -> True starred unpacking: created 0 | middle is a list with 2 slots | middle[0] is POOL[1] -> True
Three of Five Candidates Pass
The table shows hashability looks not at the container but at the container as a whole. A pair of numbers passes, a nested tuple passes too — because everything inside it is hashable as well. A list fails, as expected. The real row is the fourth: a tuple carrying a list fails too, even though the container itself is immutable. A tuple’s hash value is computed from the hash values inside it, and if one of them cannot give one, the tuple cannot either.
This is the second face of the previous section’s finding. There, immutability was one layer deep; here, the language is seen to know it. A tuple gives its guarantee only to the extent its contents give it too, and when they cannot, it loses the capability to be a key. The two columns matching is not coincidence: a mapping’s condition for being a key is exactly that a hash value can be taken.
The three measurements below count a tuple’s invisible use. When a function
returns three comma-separated values, a tuple is what comes out — not a
separate syntax, just the parenthesis-free spelling of building one.
Created on return is 0, created on unpacking is 0. The function
packed up three objects and handed them over, the assignment bound three
names to those objects; no copy came out anywhere, confirmed by a is POOL[0].
Swapping two names is the shortest form of the same mechanism: the right side builds a tuple, the left side unpacks it, items created 0. Starred unpacking adds one detail — the container collecting the remaining slots is not a tuple, it is a list, and its slots still go to the old objects. A multiple return value is cheap for this reason: what gets carried is not the objects, it is the links to them.
How a Key Is Found
That a tuple can be a key was measured; how the key is found was not. This matters, because in the previous lesson’s deletion measurement, a method that looked at equality fell back to identity. There is no such fallback in a mapping: a key is always searched for by hash value and equality, never by identity.
- CO22 — The sought key is built from two variables at runtime; the
key inside the mapping comes from the source text’s spelling. That the
two are separate objects is tested with
is. - CO23 — The equality of the hash values is compared; the values themselves are never printed.
- CO24 — Three tuples carrying the same values are built separately in a loop; two are equal, and entries remaining in the mapping are counted.
"""A key is found by equality, not identity; the container's type takes part in equality.""" RECORD = {(2031, 7): "slope", (2031, 8): "north"} YEAR, MONTH = 2031, 7 SOUGHT = (YEAR, MONTH) # built at runtime, not a literal FIRST = next(iter(RECORD)) print(f"sought is the dict's key -> {SOUGHT is FIRST}") print(f"sought == the dict's key -> {SOUGHT == FIRST}") print(f"hash values equal -> {hash(SOUGHT) == hash(FIRST)}") print(f"RECORD[SOUGHT] -> {RECORD[SOUGHT]!r}") print() print(f"(2031, 7) == [2031, 7] -> {(2031, 7) == [2031, 7]}") print(f"(2031, 7) == (2031, 7, None) -> {(2031, 7) == (2031, 7, None)}") for candidate in ((2031, 7), [2031, 7]): try: found = candidate in RECORD except TypeError as e: found = type(e).__name__ print(f"{str(candidate):<12s} in RECORD -> {found}") print() TUPLES = [(2031, n) for n in (7, 8, 7)] print(f"three tuples built | distinct objects: " f"{TUPLES[0] is TUPLES[2]} | equal: {TUPLES[0] == TUPLES[2]}") MAPPING = {t: i for i, t in enumerate(TUPLES)} print(f"all three used as keys, entries remaining in the mapping: {len(MAPPING)}")
sought is the dict's key -> False sought == the dict's key -> True hash values equal -> True RECORD[SOUGHT] -> 'slope' (2031, 7) == [2031, 7] -> False (2031, 7) == (2031, 7, None) -> False (2031, 7) in RECORD -> True [2031, 7] in RECORD -> TypeError three tuples built | distinct objects: False | equal: True all three used as keys, entries remaining in the mapping: 2
The first four lines give the lookup rule: the object is distinct, the value is equal, the hashes are equal, and the lookup succeeds. Identity is not what finds a key. This is why a tuple is a cheap key — no need to hold on to it, building a new one from the same values is enough.
The lines in between show equality covers the container’s type too. A
list carrying the same two numbers is not equal to a tuple, and no rule
strips the type and looks only at content. A tuple of a different length
is not equal either. The membership test is even stricter: a list is not just
judged unequal, it fails with TypeError — a hash value has to be taken
before searching, and that step never happens at all.
The last two lines make equality’s result concrete. Three tuples were built separately; the first and third are distinct objects but equal. With all three used as keys, 2 entries remain in the mapping: two equal keys landed in the same place, and the second overwrote the first’s value. The container’s identity never entered into it anywhere.
Summary
- Immutability closes off three operations: writing to a slot, deleting a
slot, and adding a slot. The first two fail with
TypeError, the last withAttributeError— one says “I cannot,” the other “I do not know that.” k += (new,)on a tuple is not an extension, it is a rebuild: the container is new (bigger is tup→ False) but the items are old (bigger[0] is tup[0]→ True), and created is 1.- Immutability is one layer deep:
k[0].value = 9still passes on a tuple, because the tuple protects its slot, not the inside of the object the slot points to. - The capability a container gains is being usable as a key, and that capability depends on its contents: three of five candidates pass, and a tuple carrying a list fails even though the container itself is immutable.
- A multiple return value is a tuple and creates 0 items; unpacking creates 0 too, because what gets carried is not objects, it is the links to them.
Next Step
A tuple could be a key; but the container that takes a key and reaches a value hasn’t been measured yet. The next lesson looks at the mapping and pays the first half of the course’s fourth debt: when the same two hundred items are searched for in a list and in a set, how many comparisons happen? The data is the same, the items are the same, only the container holding them changes. The same lesson asks a second question too: when a dictionary’s keys are asked for, is what comes back a copy, or a window that keeps looking at the dictionary?
To keep your progress and take notes, Log in
My notes
Log in to take notes.