Tuples, Dictionaries, Strings and Sets
Tuples, dictionaries and sets solve tasks that lists handle poorly. String methods connect text to these containers, and the central question is which container fits a particular problem.
By the end of this lecture you should be able to
- Explain how a tuple differs from a list, and why an immutable sequence is useful
- Store and retrieve data by name with dictionaries, avoiding KeyError and the resize-during-iteration trap
- Use sets for deduplication and membership tests, and say why they are fast
- Work comfortably with split, join, strip and the other string methods
- Given a scenario, name which of the five containers fits and why
Contents
Where lists run out
L5 covered lists: a batch of values, reached by position. Three kinds of problem give them trouble:
- A group of values that should not be altered — a coordinate
(3, 4), say, where nobody should be able toappendto it - Looking things up by name rather than by position — “what did Alex score”, not “what did person number 37 score”
- Asking whether something is present — a list’s
inscans from the front, and that gets slow as the data grows
Tuples, dictionaries and sets answer those three problems. String processing turns raw text into values those containers can handle. Finally, compare all five containers side by side.
Tuples
A list you cannot change
Tuples are written with parentheses and behave much like lists otherwise:
t = (1, 2, 3)
print(t[0]) # same indexing
print(t[1:]) # same slicing
print(len(t)) # same length
print(3 in t) # same membership test
for x in t:
print(x, end=" ")
print()
The central difference is that a tuple is immutable: you cannot replace, add or remove its elements.
t = (1, 2, 3)
t[0] = 99
There is no append, remove or sort, because all of those modify in place. A tuple has only two methods:
t = (1, 2, 2, 3, 2)
print(t.count(2)) # how many times 2 appears
print(t.index(3)) # where 3 first appears
count(x) returns 0 when x is absent; index(x) raises ValueError in that case.
One comma makes all the difference
a = (1) # not a tuple — those are just grouping parentheses
b = (1,) # a one-element tuple; the comma is what counts
c = 1, 2, 3 # parentheses are optional
print(a, type(a))
print(b, type(b))
print(c, type(c))
Construction, concatenation and repetition
tuple() converts an iterable to a tuple. Tuples also support concatenation with + and repetition with *; neither modifies the original:
print(tuple())
print(tuple([1, 2, 3]))
print(tuple("abc"))
t = (1, 2)
print(t + (3,))
print(t * 2)
print(t)
A fixed outer sequence can contain mutable objects
Like a list, a tuple holds references to objects. Immutability fixes those references; it does not freeze every object they point at.
t = ([1, 2], "fixed")
t[0].append(3)
print(t)
This changes the inner list. The tuple’s first slot still points at the same list, so no tuple element was replaced.
t = ([1, 2], "fixed")
t[0] = [9, 9] # replacing that reference is forbidden
A tuple being immutable therefore does not make its entire nested structure immutable. This is the same reference model used for shallow copies in L5.
Packing and unpacking
This is where tuples earn their keep, and you have been using them already:
# packing: several values become a tuple
point = 3, 4
print(point, type(point))
# unpacking: a tuple spreads across several names
x, y = point
print(f"x={x}, y={y}")
# swapping two variables — the classic Python one-liner
a, b = 1, 2
a, b = b, a
print(a, b)
The “return several values” functions from L3 were returning tuples all along:
def min_max(nums: list[int]) -> tuple[int, int]:
return min(nums), max(nums)
result = min_max([3, 9, 5])
print(result, type(result))
low, high = min_max([3, 9, 5]) # unpack directly
print(low, high)
Unpacking requires the counts to match, or it fails:
a, b = 1, 2, 3
A * collects the remaining items into a list. This resembles L4’s *args, but the result types differ: function arguments are collected into a tuple, while starred unpacking produces a list:
first, *rest = [1, 2, 3, 4, 5]
print(first, rest)
*init, last = [1, 2, 3, 4, 5]
print(init, last)
Nested unpacking
The structure on the left can mirror the structure on the right:
((x, y), label) = ((3, 4), "point")
print(x, y, label)
Each level must match, not just the outermost number of items.
When to use a tuple
- The values form one thing that should not be taken apart: a coordinate, a date, an RGB color
- You need a dictionary key: a tuple works when all its elements are hashable; a list does not (see below)
- A function returns several values
Dictionaries
Look things up by name
Suppose you are storing a class’s results. With a list you would have to remember that “Alex is number 37”, which is clearly hopeless.
A dictionary stores key: value pairs:
scores = {"Alex": 87, "Blake": 92, "Chen": 65}
print(scores["Alex"]) # look up by name
print(len(scores))
print("Blake" in scores) # is that key present
For ordinary data, the average cost of a dictionary lookup does not grow linearly with the number of entries. A list searches item by item and may inspect every element.
Dictionaries use a hash table. A hash value directs the search toward a possible slot. Different keys can lead to the same slot—a collision—so Python may need more probing and key comparisons. Lookup is usually fast, not guaranteed to be a single operation. L11 examines the mechanism.
Creating one
a = {"x": 1, "y": 2} # a literal
b = dict(x=1, y=2) # keyword arguments
c = dict([("x", 1), ("y", 2)]) # a list of pairs
d = {} # empty
print(a, b, c, d)
print(a == b == c)
A dictionary as a value
A value can be another dictionary. To store several subjects for each student, let the outer key choose the student and the inner key choose the subject:
results = {
"Alex": {"math": 87, "physics": 92},
"Blake": {"math": 65},
}
print(results["Alex"])
print(results["Alex"]["math"])
results["Alex"]["math"] = 90
print(results["Alex"]["math"])
The first lookup returns a dictionary; the second looks up a key in that dictionary. These are two ordinary lookups, not a new indexing rule. Later, once string splitting has been covered, we will build this structure from a line of text.
Asking for a key that is not there
scores = {"Alex": 87}
print(scores["Dana"])
KeyError is the most common dictionary error. Three ways to deal with it:
scores = {"Alex": 87}
# 1. check first
if "Dana" in scores:
print(scores["Dana"])
else:
print("no such person")
# 2. get(): returns None, or a default you supply
print(scores.get("Dana"))
print(scores.get("Dana", 0)) # an example default, not a general rule
# 3. setdefault(): inserts it if missing
print(scores.setdefault("Dana", 0))
print(scores) # Dana is now in there
Choose the default to match the meaning of missing data. A missing student record is not necessarily a score of zero; None keeps those two situations distinct. For a count, however, an unseen word really does have a previous count of zero.
Adding, removing, changing
scores = {"Alex": 87, "Blake": 92}
scores["Chen"] = 65 # add
scores["Alex"] = 90 # change — same syntax, the key already exists
print(scores)
del scores["Blake"] # remove
print(scores)
value = scores.pop("Chen") # remove and return the value
print(value, scores)
Assignment both adds and updates, depending on whether the key was there. Lists behave differently — assigning to a non-existent index raises IndexError.
Iterating
scores = {"Alex": 87, "Blake": 92, "Chen": 65}
for name in scores: # iterating a dict gives the keys
print(name, end=" ")
print()
for name in scores.keys(): # spelled out
print(name, end=" ")
print()
for score in scores.values(): # just the values
print(score, end=" ")
print()
for name, score in scores.items(): # both at once — what you want most often
print(f"{name}: {score}")
Views track the dictionary
keys(), values() and items() return views, not lists. A view reflects the dictionary’s current contents. list(...) instead copies the contents as they stand at that moment.
scores = {"Alex": 87, "Blake": 92}
view = scores.keys()
snapshot = list(view)
scores["Chen"] = 65
print(list(view))
print(snapshot)
The view includes the new entry; the existing snapshot does not acquire it automatically.
Keep the keys fixed during iteration
scores = {"Alex": 87, "Blake": 92}
for name in scores:
if scores[name] < 90:
del scores[name]
This example deletes a key, changes the dictionary’s size and raises RuntimeError: dictionary changed size during iteration. The broader rule is to avoid adding or deleting keys while traversing that dictionary. Other changes may raise an error or cause entries to be missed or newly added entries to be visited; the absence of an exception does not make them safe.
Updating the value of an existing key is different: it leaves the key membership unchanged.
The fix is to collect first, delete afterwards:
scores = {"Alex": 87, "Blake": 92, "Chen": 65}
to_delete = [name for name, s in scores.items() if s < 90]
for name in to_delete:
del scores[name]
print(scores)
Or build a new dictionary and skip the deletion entirely:
scores = {"Alex": 87, "Blake": 92, "Chen": 65}
passed = {name: s for name, s in scores.items() if s >= 90}
print(passed)
That last form is a dict comprehension — the same idea as a list comprehension.
Keys must be hashable
d = {}
d[(1, 2)] = "a tuple works as a key"
d["abc"] = "so does a string"
d[42] = "and a number"
print(d)
d = {}
d[[1, 2]] = "a list does not"
A dictionary key must be hashable: it has a hash value that stays fixed during its lifetime, and keys that compare equal must have the same hash. Those conditions let a dictionary reliably find an existing entry.
Among the built-in types used here, numbers and strings are hashable; lists, dictionaries and sets are not. A tuple is hashable only when every element is hashable.
d = {}
d[([1, 2], "fixed")] = "this tuple cannot be a key"
The tuple itself is immutable, but its list element is unhashable, so this raises TypeError. Hashability and immutability are related, not interchangeable.
Removing the last entry and clearing
pop(key) removes a chosen key and returns its value. popitem() removes the last inserted key/value pair and returns it as a tuple. clear() empties the dictionary and returns None.
d = {"x": 1, "y": 2}
print(d.popitem())
print(d)
print(d.clear())
print(d)
Calling popitem() on an empty dictionary raises KeyError; it does not return an empty tuple.
Creating a dictionary from keys
dict.fromkeys(keys, value) creates entries with one common initial value; omitting the value uses None:
print(dict.fromkeys(["a", "b", "a"]))
print(dict.fromkeys(["a", "b"], 0))
Repeated keys occur only once, in first-occurrence order. This will be useful for ordered deduplication later.
Copying and merging
Assignment does not copy a dictionary. copy() or dict(d) duplicates only the outer container; nested objects remain shared, just as in L5’s list examples.
a = {"scores": [87, 92]}
b = a.copy()
b["scores"].append(65)
print(a)
print(a is b)
print(a["scores"] is b["scores"])
a = {"x": 1, "y": 2}
b = {"y": 20, "z": 30}
print({**a, **b}) # unpack both; later wins on collisions
print(a | b) # Python 3.9+, equivalent
c = a.copy()
print(c.update(b)) # update in place, return None
print(c)
** spreads a dictionary’s entries into the new dictionary. Both {**a, **b} and a | b build a new dictionary; a.update(b) modifies a. For duplicate keys, the later dictionary supplies the value.
String methods
A string is an immutable sequence; L3 covered the basics. None of its methods modifies the original string, but their result types differ:
| Method | Result type |
|---|---|
upper(), strip(), replace(), join() | string |
split() | list |
find(), count() | integer |
isdigit(), startswith() | boolean |
Even a method returning a string need not allocate a fresh object when nothing changes. Compare values, not addresses, to check the result.
Splitting and joining
line = "Alex,87,maths"
parts = line.split(",")
print(parts)
print("-".join(parts)) # join with hyphens
print("".join(parts)) # straight concatenation
text = " the quick brown fox "
print(text.split()) # no argument: split on any whitespace, ignoring runs
print(text.split(" ")) # a single space: strict, and you get empty strings
Trimming whitespace
s = " hello \n"
print(repr(s.strip())) # both ends
print(repr(s.lstrip())) # left
print(repr(s.rstrip())) # right
Decide first whether whitespace is meaningful. A score field usually needs trimming, while indentation and line breaks in prose may need preserving. input() already removes the input line’s final newline, but retains extra spaces typed by the user.
In strip(chars), chars is a set of characters to remove repeatedly from either end, not a prefix to match. Characters in the middle remain. This is useful for punctuation around words:
print("...hello!?".strip(".,!?;:"))
print("a,b".strip(","))
The first result is hello; the second remains a,b.
Case and classification
s = "Hello World"
print(s.upper())
print(s.lower())
print(s.title())
print(s.startswith("Hello"), s.endswith("!"))
for s in ["123", "12.3", "abc", "abc123", " ", ""]:
print(f"{s!r:10} isdigit={s.isdigit()!s:<6} isalpha={s.isalpha()!s:<6} isalnum={s.isalnum()}")
Searching and replacing
s = "the quick brown fox"
print(s.find("quick")) # index, or -1 when absent
print(s.find("cat"))
print(s.count("o"))
print(s.replace("quick", "slow"))
print(s) # unchanged
find() and count() also accept a search range, using the same half-open convention as slicing:
s = "banana"
print(s.find("an", 2))
print(s.count("a", 0, 4))
The results are 3 and 2.
Common parts of a format specification
L3 covered the everyday f-string forms. This is a simplified reference, not the entire grammar; the available options also depend on the value’s type:
{value:[[fill]align][sign][#][0][width][grouping][.precision][type]}
Most of it you will never need. Consult this table when you do:
| Slot | Written | Effect |
|---|---|---|
| align | < > ^ = | left / right / center; = pads after a numeric sign |
| fill | any character before the align symbol | *^10 centers, padding with asterisks |
| sign | + - | always show / only for negatives / space for positives |
| grouping | , _ | {1234567:,} → 1,234,567 |
| precision | .n | decimal places, or truncation length for strings |
| type | d f e % b o x | integer / fixed / scientific / percentage / bases |
| other types | c s g n | character from an integer / string / general numeric / locale-aware numeric |
| uppercase forms | E F G X | uppercase versions of the corresponding formats |
n = 1234567.891
print(f"{n:,.2f}") # grouping plus two decimals
print(f"{n:>15,.2f}") # and right-aligned in 15 columns
print(f"{0.856:.1%}") # as a percentage
print(f"{42:*^11}") # centered, asterisk fill
print(f"{'truncate':.4}") # strings can be cut short too
Dictionary applications: text and lookup tables
We can now split text into fields and store related values under keys. Two common tasks put these tools together: collecting results from text lines, and counting words in a paragraph.
From one text line to a nested record
Suppose a line contains a student’s name, a subject and a score, separated by commas. Start with one line, so each stage is visible:
| Stage | Operation | Result |
|---|---|---|
| Separate the fields | line.split(",") | three strings |
| Remove surrounding whitespace | strip() on each field | clean name, subject and score text |
| Convert the score | int(score_text) | an integer |
| Find or create the student’s record | check the outer dictionary | a subject-to-score dictionary |
| Store the mark | results[name][subject] = mark | update that student’s subject |
line = " Alex , math , 87 "
fields = line.split(",")
name = fields[0].strip()
subject = fields[1].strip()
score_text = fields[2].strip()
mark = int(score_text)
results: dict[str, dict[str, int]] = {}
if name not in results:
results[name] = {}
results[name][subject] = mark
print(results)
The result is {"Alex": {"math": 87}}. This example assumes exactly three fields and an integer score. For multiple lines, keep the same outer results dictionary and repeat the field processing for each line. Create an inner dictionary only when the name is absent: assigning a fresh {} for every line would erase that student’s earlier subjects. If the same name and subject occur again, the new assignment replaces the stored mark.
Lists as dictionary values
A group may need several values rather than one. To group nonempty words by first letter, give each letter its own list:
words = ["apple", "avocado", "banana"]
groups: dict[str, list[str]] = {}
for word in words:
group = groups.setdefault(word[0], [])
group.append(word)
print(groups)
For the first a, setdefault inserts an empty list and returns it. On the next a, it returns the list already stored under that key, so both words enter one group. The b key gets a different list. The shorter form groups.setdefault(word[0], []).append(word) performs the same two operations.
get() does not create a stored group:
groups = {}
groups.get("a", []).append("apple")
print(groups)
This still prints {}. The temporary default list received the word, but was never inserted into the dictionary. Use setdefault, or check whether the key exists and assign the new list explicitly.
Counting
The simplest count starts with already-separated words:
text = "the quick brown fox jumps over the lazy dog the end"
counts: dict[str, int] = {}
for word in text.split():
counts[word] = counts.get(word, 0) + 1
print(counts)
counts.get(word, 0) + 1 means: an unseen word starts at zero, then this occurrence adds one. An existing word uses its stored count.
Clean the words before counting
Real text may mix upper and lower case and surround words with punctuation. Apply the agreed cleaning rules before choosing the dictionary key:
text = "The cat. THE dog! ... the cat"
counts: dict[str, int] = {}
for raw_word in text.split():
word = raw_word.lower().strip(".,!?;:")
if not word:
continue
counts[word] = counts.get(word, 0) + 1
print(counts)
This gives {"the": 3, "cat": 2, "dog": 1}. The token "..." becomes an empty string and is skipped, rather than counted as a word. All spellings of the are combined after lower().
These rules remove punctuation only at the ends. A comma in "a,b" remains in the middle, as the strip() example showed. Splitting every possible natural-language word is a different problem; for this exercise, follow the specified whitespace and end-punctuation rules.
Sorting by a computed key
A count dictionary stores the frequencies, but does not sort them. sorted() returns a new list. With no key function it sorts the values themselves; a key function supplies the value to compare instead.
nums = [3, 1, 4, 1, 5]
print(sorted(nums)) # returns a new list; nums untouched
print(sorted(nums, reverse=True))
print(nums)
words = ["banana", "kiwi", "apple"]
print(sorted(words, key=len)) # by length
print(sorted(words, key=str.lower)) # case-insensitively
key=len hands the function len to sorted, which calls it on every item and sorts by the results. L3 showed that a function is an object like any other, so it can be passed as an argument. Your own functions work too:
counts = {"the": 3, "fox": 1, "dog": 2}
def count_of(pair: tuple[str, int]) -> int:
return pair[1]
print(sorted(counts.items(), key=count_of, reverse=True))
Each item of counts.items() is a (word, count) tuple; count_of pulls out the count, so sorted orders the words from most to least frequent.
For an alphabetical tiebreaker, return a tuple from the key function. Tuples compare lexicographically: first item first, then the second only if the first items are equal.
counts = {"the": 3, "fox": 1, "dog": 2, "cat": 2}
def order(pair: tuple[str, int]) -> tuple[int, str]:
return (-pair[1], pair[0])
print(sorted(counts.items(), key=order))
Negating the count turns descending frequency into ascending numeric order; the word breaks ties.
Counting with Counter
The standard library’s Counter performs counting, but does not automatically lowercase words or remove punctuation. Give it the items you actually want counted:
from collections import Counter
text = "the quick brown fox jumps over the lazy dog the end"
print(Counter(text.split()).most_common(3))
most_common(3) returns up to three frequent items. Equal counts are ordered by first appearance, not alphabetically. The distinction matters for Lab 6-6:
from collections import Counter
print(Counter("dog cat dog cat".split()).most_common(2))
The result is [("dog", 2), ("cat", 2)], because dog was encountered first. The lab requires cat before dog. Counter can provide the counts, but its most_common(n) is not a replacement for the lab’s two-priority sort; apply the order function above to the counted pairs, then take the first n results.
Replacing a chain of elifs
Both versions below assume a valid month number from 1 to 12 and a non-leap year. February therefore has 28 days. Input validation and leap-year handling are separate requirements, not supplied by this example.
# the long way
def month_days_if(month: int) -> int:
if month in (1, 3, 5, 7, 8, 10, 12):
return 31
elif month == 2:
return 28
else:
return 30
# as a lookup table
MONTH_DAYS = {1: 31, 2: 28, 3: 31, 4: 30, 5: 31, 6: 30,
7: 31, 8: 31, 9: 30, 10: 31, 11: 30, 12: 31}
def month_days(month: int) -> int:
return MONTH_DAYS[month]
print(month_days_if(2), month_days(2))
print(month_days_if(4), month_days(4))
When the branches are nothing more than input-to-output mapping, a dictionary reads better than an if-elif chain — and adding a case means adding a line of data rather than editing logic.
Sets
No duplicates, no order
s = {3, 1, 4, 1, 5, 9, 2, 6, 5}
print(s) # the duplicates are gone
print(len(s))
Two properties: no element appears twice, and no order is recorded — which is why you cannot index into one.
s = {1, 2, 3}
print(s[0])
Construction and changes
set() takes items from an iterable and removes duplicates. For a dictionary, those items are the keys:
print(set("banana"))
print(set([1, 2, 2, 3]))
print(set({"x": 1, "y": 2}))
The displayed order is not guaranteed. add(x) inserts one element; update(it) inserts every element from an iterable. Both modify the set in place and return None.
s = {1, 2}
print(s.add(3))
s.update([3, 4])
print(sorted(s))
s.remove(2)
s.discard(99)
print(sorted(s))
s.clear()
print(s)
remove(x) raises KeyError if x is absent; discard(x) does nothing in that case. In-place operations can also be written s |= other, s &= other, s -= other and s ^= other.
Like dictionary keys, set elements must be hashable. Lists cannot be set members; tuples of hashable elements can.
Deduplicating
The most common use:
nums = [3, 1, 4, 1, 5, 9, 2, 6, 5, 3]
print(list(set(nums))) # deduplicated, order not preserved
# to keep the original order, use a dict — keys are unique and ordered
print(list(dict.fromkeys(nums)))
Membership tests are fast
big_list = list(range(100000))
big_set = set(big_list)
# same answer, very different cost
print(99999 in big_list)
print(99999 in big_set)
A list’s in checks items one by one. Sets use a hash table like dictionaries: average membership checks are usually cheap, though collisions can require extra work. Converting a large collection to a set pays off when membership is checked repeatedly; conversion itself takes a pass over the data, so it is not automatically worthwhile for a single query.
Set operations
a = {1, 2, 3, 4}
b = {3, 4, 5, 6}
print(a | b) # union: in a or in b
print(a & b) # intersection: in both
print(a - b) # difference: in a but not b
print(a ^ b) # symmetric difference: in exactly one
# something real: which courses two students share
alex = {"math", "physics", "english", "programming"}
blake = {"physics", "chemistry", "programming", "sport"}
print("both: ", alex & blake)
print("alex only: ", alex - blake)
print("between: ", alex | blake)
Subsets and proper subsets
Set comparisons are not lexicographic. a <= b means that every member of a is in b; a < b additionally requires the sets to differ:
a = {1, 2}
b = {1, 2, 3}
print(a <= b)
print(a < b)
print(a <= a)
print(a < a)
>= and > mean superset and proper superset.
Keeping first-occurrence order
A set does not record order, but it can help a list check whether an item has appeared before:
items = [3, 1, 3, 5, 1, 7]
seen = set()
result = []
for x in items:
if x not in seen:
seen.add(x)
result.append(x)
print(result)
seen handles membership; result preserves order. The two containers have different jobs.
Five containers: which one?
The choice depends on the operations the problem needs. A suitable container can make both the code and the work it performs much simpler; there is no fixed speedup that applies to every program.
| list | tuple | str | dict | set | |
|---|---|---|---|---|---|
| written | [1,2] | (1,2) | "ab" | {"k":1} | {1,2} |
| mutable | ✅ | ❌ | ❌ | ✅ | ✅ |
| ordered | ✅ | ✅ | ✅ | ✅ (insertion) | ❌ |
| indexable / sliceable | ✅ | ✅ | ✅ | ❌ (by key) | ❌ |
| duplicates allowed | ✅ | ✅ | ✅ | keys unique | ❌ |
| usable as a dict key | ❌ | if all elements are hashable | ✅ | ❌ | ❌ |
| membership | item-by-item | item-by-item | substring search | usually fast | usually fast |
Choosing by the problem
| What you need | Reach for | Why |
|---|---|---|
| a batch of similar values, processed in order | list | ordered, and you can add and remove |
| a fixed group of values, e.g. a coordinate | tuple | fixed element references; usable as a key when all elements are hashable |
| data retrieved by name or id | dict | fast lookup, clear intent |
| “is this present”, or removing duplicates | set | fast lookup, deduplicates for free |
| how many times each thing occurs | dict | key holds the thing, value holds the count |
| working with text | str | it has the methods for it |
| a compound key | dict keyed by tuples | d[(x, y)] = value |
Two traps common to all of them
Modifying a container while iterating it
Avoid adding or removing elements from the container being traversed. You have now seen the problem on both lists and dictionaries:
# a list fails silently
a = [1, 2, 2, 3]
for x in a:
if x == 2:
a.remove(x)
print("list:", a) # not [1, 3]
# a set raises
s = {1, 2, 3}
for x in s:
if x == 2:
s.remove(x)
One remedy covers both cases: collect first and remove afterwards, or build a new container. The second is usually clearer.
Arguments and in-place changes
def add_item(items: list[int], x: int) -> None:
items.append(x) # modifies the caller's list directly
nums = [1, 2, 3]
add_item(nums, 4)
print(nums) # the list outside changed
That is not a bug — as L5 explained, what gets passed is a reference, not a copy. But if the caller did not expect it, the resulting problem is very hard to track down.
Two ways to handle it:
# 1. leave the input alone and return something new
def with_item(items: list[int], x: int) -> list[int]:
return items + [x] # + builds a new list
nums = [1, 2, 3]
new = with_item(nums, 4)
print(nums, new) # the original is untouched
# 2. modify, but make it obvious in the name and the docstring
def append_in_place(items: list[int], x: int) -> None:
"""Append x to items. Note: modifies the list you pass in."""
items.append(x)
nums = [1, 2, 3]
append_in_place(nums, 4)
print(nums)
+= is not always the same as +
For numbers and strings, a += b can be understood as a = a + b. Lists behave differently: + builds a new list, while += extends the existing one, so aliases see different effects.
a = [1, 2]
b = a
a = a + [3]
print(a, b)
c = [1, 2]
d = c
c += [3]
print(c, d)
An assignment-like spelling alone does not tell you whether the old object was modified or a new one produced.
Truth values of containers
Empty strings, lists, tuples, dictionaries and sets are false; nonempty ones are true. Numeric zero and None are false too:
for x in ["", [], (), {}, set(), 0, None, [0], "0"]:
print(repr(x), bool(x))
[0] is true despite containing zero: the list is not empty. if items: is the usual nonempty test, and while items: keeps looping while the container has items.
Parallel iteration with zip and enumerate
names = ["Alex", "Blake", "Chen"]
scores = [87, 92, 65]
for name, score in zip(names, scores): # walk two sequences together
print(f"{name}: {score}")
print(dict(zip(names, scores))) # straight into a dictionary
for i, name in enumerate(["a", "b", "c"], start=1): # with a running index
print(i, name)
zip() returns an iterator you can walk one pair at a time, not a list. Use list(zip(...)) to inspect all pairs. By default it stops when the shortest input runs out:
print(list(zip(["Alex", "Blake", "Chen"], [87, 92])))
Chen has no score, so no pair is produced for Chen. Check the input lengths when every item needs a partner.
zip and enumerate remove the need for most range(len(...)) loops.
Summary
- Tuple = an immutable sequence. It is the comma that makes it, not the parentheses. Use it for data that should not change, for dictionary keys, and for returning several values
- Dict = storage by name, with fast lookup.
get(k, default)avoidsKeyError;items()gives you both halves - Dictionaries have preserved insertion order since 3.7
- Dictionary keys and set members must be hashable: numbers and strings qualify; tuples require hashable elements; lists do not
- Set = unique, unordered, fast to search. Good for deduplication and membership, but it loses the order
- String methods leave the original unchanged, but return different types;
split()produces a list andstrip()removes surrounding whitespace - Do not change container membership while iterating over that container; changing existing values is different
- An in-place change to a parameter object is visible to the caller; mutable objects nested inside tuples also need care
- To choose a container, ask: access by position? does each item have a name? do I only care whether it is present?
Reference notes
These forms help when reading existing code. Keep them available for lookup rather than memorizing a method table.
Rebinding a tuple name
A tuple’s items cannot be replaced, but a name can refer to a different tuple:
t = (1, 2)
old = t
t = t + (3,)
print(t, old)
del t
print(old)
del t removes the binding, not the object and everything reachable from it. The name old still refers to the original tuple.
A graph represented by nested dictionaries
The previous course used a weighted three-node graph as a dictionary example. No graph algorithm is needed here: the outer key is the starting node, the inner key is the destination, and the value is the edge’s weight.
graph = {
"A": {"B": 3, "C": 1},
"B": {"C": 4},
"C": {},
}
print(graph["A"]["C"])
print(graph["B"]["C"])
A to C has weight 1, B to C weight 4. The empty dictionary means no outgoing edges from C in this example. The two lookups work just like results[name][subject] in the results table.
Frozen sets
A mutable set is unhashable and cannot be a dictionary key. frozenset has fixed members and can be a key:
key = frozenset({1, 2})
d = {key: "pair"}
print(d[frozenset({2, 1})])
print(key == {1, 2})
Equality depends on the members, not insertion order. A frozen set does not guarantee iteration order either.
Tabs and expandtabs()
A tab advances to the next tab stop; it is not a fixed number of spaces. expandtabs(n) replaces tabs with spaces at stops n columns apart without changing the original string:
print(repr("a\tb".expandtabs(4)))
print(repr("abc\td".expandtabs(4)))
The first inserts three spaces after a, the second only one after abc. The default interval is 8; a terminal’s actual display depends on its settings.
Constants in the string module
The standard library provides common ASCII character groups:
import string
print(string.ascii_letters)
print(string.ascii_lowercase)
print(string.ascii_uppercase)
print(string.digits)
print(string.hexdigits)
print(string.octdigits)
print(string.punctuation)
print(repr(string.whitespace))
string.printable also combines ASCII digits, letters, punctuation and whitespace. These are ASCII groups, not every Unicode character; ascii_letters contains no Chinese characters, for example.
Statements across lines
Inside parentheses, brackets or braces, a statement can continue on another line. Prefer this implicit continuation:
scores = {
"Alex": 87,
"Blake": 92,
}
total = (
scores["Alex"]
+ scores["Blake"]
)
print(total)
A trailing backslash also continues a line, but no spaces or comment may follow it:
total = 87 + \
92
print(total)
Other numeric formatting options
F-strings, str.format() and the built-in format(value, spec) share format specifications. These are reference forms, not a replacement for the f-strings recommended in L3:
print(f"{42:08b}")
print(f"{42:o}")
print(f"{42:#x}")
print(f"{65:c}")
print(f"{3.14:f}")
print(f"{-42:0=6d}")
print("{name}: {score:.1f}".format(name="Ada", score=87.5))
print(format(87.5, ".1f"))
# adds the appropriate base prefix; c formats an integer as a character code; f defaults to six decimal places when precision is omitted; = places padding after the sign. g chooses a general numeric representation, and n follows the current locale. Consult Python’s formatting reference when needed.
eval() and exec()
From L3, eval() evaluates an expression and returns its value. exec() executes statements and returns None. These examples use only trusted constants written into the program:
print(eval("[1, 2, 3]"))
print(eval("3 * 4 + 1"))
print(exec("n = 3\nprint(n * 4)"))
Assignment is a statement and cannot be passed to eval():
eval("n = 3")
Neither should directly execute user input or external text. For a literal such as "[1, 2, 3]", ast.literal_eval() can parse literal data without executing general expressions, though untrusted input still needs length and nesting limits. Ordinary numeric input should continue to use int() or float(), not dynamic execution.
Exercises
- Write
word_count(text: str) -> dict[str, int], counting occurrences of each word, case-insensitively. - Write
unique_ordered(items: list[int]) -> list[int], deduplicating while preserving order, using a set to make the membership test fast. - Given two students’ sets of enrolled courses, print the ones both take, the ones only the first takes, and the union.
- Write
invert(d: dict[str, int]) -> dict[int, str], swapping keys and values. Think about what happens if the original has duplicate values. - Using
zipand a dict comprehension, combine["a","b","c"]and[1,2,3]into{"a":1,"b":2,"c":3}in a single line. - This is meant to group words by their first letter, but it fails. Find the cause and fix it:
words = ["apple", "avocado", "banana", "blueberry"] groups = {} for w in words: groups[w[0]].append(w) - Pick a container for each of these and justify the choice:
- a mapping from each of 116 student numbers to a name
- every score from one exam, from which you later want the mean and the maximum
- testing whether a word is in a list of ten thousand common English words
- a point on a 2D plane, to be used as a dictionary key
The lab problems for this lecture are in Lab 6.