Mutability, copies and matrices in Python — practice with a shopping receipt, a cart that changes and a price table (and the sorted list that was None)
I wanted to know which product in my cart was the most expensive and which the cheapest, so I sorted the prices. I wrote sorted_prices = prices.sort(), printed sorted_prices to see the result and the screen answered with a single word: None. No list, no error, no warning. A list that had been there a second ago had turned into “nothing”.
The list had not been lost: sort() had sorted it inside, and all it returned was None, which I was happily storing in sorted_prices. It is the kind of bug that is fixed by removing half a line and takes a long while to find if you do not know that methods that modify a list return nothing.
Today we practise mutability, copies, zip, enumerate, list comprehensions and matrices with three complete programs: a shopping receipt, a cart that is modified in place and a price table by store. This practice builds on the mutability, copies and matrices theory. If you still hesitate with basic lists, review lists and tuples in Python first.
Table of Contents
How to use this article
Each program is complete in a single file. Copy it, save it with the name shown and launch it from the terminal with python3 name.py. Before looking at the output, try to predict it: it is the best way to know whether you have understood the code.
No program uses input(). The data is fixed in the code so that your output is identical to the one you see here.
Program 1 · The shopping receipt
We have the purchase in three parallel lists: the products, their prices and the quantities, always in the same order. We want a numbered receipt with the amount of each line, the subtotal, the VAT and the total, and a list with the lines over 5 €. It is the perfect program for zip (going through lists in parallel), enumerate (numbering) and a comprehension.
receipt.py
PRODUCTS = ["Bread", "Milk", "Eggs", "Rice", "Oil"]
PRICES = [1.20, 0.95, 2.40, 1.75, 6.90]
QUANTITIES = [2, 3, 1, 2, 1]
VAT = 0.10
def amounts(prices, quantities):
"""Amount of each line: price times quantity."""
return [price * quantity for price, quantity in zip(prices, quantities)]
lines = amounts(PRICES, QUANTITIES)
for num, (product, quantity, amount) in enumerate(zip(PRODUCTS, QUANTITIES, lines), 1):
print(f"{num}. {product} x{quantity} -> {amount:.2f} €")
subtotal = sum(lines)
print(f"Subtotal: {subtotal:.2f} €")
print(f"VAT ({VAT:.0%}): {subtotal * VAT:.2f} €")
print(f"Total: {subtotal * (1 + VAT):.2f} €")
pricey = [product for product, amount in zip(PRODUCTS, lines) if amount > 5]
print("Lines over 5 €:", pricey)
Launch it with python3 receipt.py:
1. Bread x2 -> 2.40 € 2. Milk x3 -> 2.85 € 3. Eggs x1 -> 2.40 € 4. Rice x2 -> 3.50 € 5. Oil x1 -> 6.90 € Subtotal: 18.05 € VAT (10%): 1.81 € Total: 19.86 € Lines over 5 €: ['Oil']
Between the lines:
amountsbuilds a new list with a comprehension: for each(price, quantity)pair thatzipgives it, it computesprice * quantity. It modifies nothing and returns the list of amounts.- The main
foris the densest part of the program:zip(PRODUCTS, QUANTITIES, lines)pairs up the three lists at once (zipaccepts as many as you like) andenumerate(…, 1)numbers each triple starting at 1. Each turn receives something like(1, ("Bread", 2, 2.4)), and the patternnum, (product, quantity, amount)unpacks it in one go, tuple inside included. {amount:.2f}forces two decimals, and{VAT:.0%}turns0.10into10%.- The numbers add up: 2.40 + 2.85 + 2.40 + 3.50 + 6.90 = 18.05. The VAT is 1.805, which is shown as 1.81, and 18.05 + 1.81 = 19.86, which is the total.
priceyis a comprehension with a filter: it keeps only the products whose amount is over 5 €. Only the oil meets it (6.90).- This program does not modify any list: it creates new lists from the previous ones. It is the safe way to work when you do not need to change the source data.
Try it yourself
- Remove the last price from
PRICES(leave four) and run it. What happens to the receipt? Where is the oil? Why is there no error at all? - Write a comprehension that produces a list with the amounts in
lineswith a 15 % discount, and another that keeps only the products whose quantity is greater than 1.
Program 2 · A cart that changes
Now the cart is a list of tuples (product, price) that we are going to modify with functions. Here you can see the difference between functions that change the cart in place and those that return a new one, exactly what the theory calls mutability.
cart.py
def add_item(cart, product, price):
"""Adds a product at the end of the cart (modifies the cart)."""
cart.append((product, price))
def remove_item(cart, product):
"""Removes the first product with that name. Returns True if it removed one."""
for pos, (name, price) in enumerate(cart):
if name == product:
cart.pop(pos)
return True
return False
def with_discount(cart, percentage):
"""Returns a NEW cart with the discounted prices (does not touch the original)."""
return [(name, round(price * (1 - percentage), 2)) for name, price in cart]
def total(cart):
"""Sum of the prices in the cart."""
return sum([price for name, price in cart])
cart = [("Bread", 1.20), ("Milk", 0.95)]
add_item(cart, "Rice", 1.75)
add_item(cart, "Oil", 6.90)
print(cart)
print("Remove Milk:", remove_item(cart, "Milk"))
print("Remove Coffee:", remove_item(cart, "Coffee"))
print(cart)
discounted = with_discount(cart, 0.20)
print(discounted)
print(cart)
print(f"Total: {total(cart):.2f} € -> {total(discounted):.2f} €")
prices = [price for name, price in cart]
sorted_prices = sorted(prices, reverse=True)
print("Prices from highest to lowest:", sorted_prices)
print("Original list:", prices)
Launch it with python3 cart.py:
[('Bread', 1.2), ('Milk', 0.95), ('Rice', 1.75), ('Oil', 6.9)]
Remove Milk: True
Remove Coffee: False
[('Bread', 1.2), ('Rice', 1.75), ('Oil', 6.9)]
[('Bread', 0.96), ('Rice', 1.4), ('Oil', 5.52)]
[('Bread', 1.2), ('Rice', 1.75), ('Oil', 6.9)]
Total: 9.85 € -> 7.88 €
Prices from highest to lowest: [6.9, 1.75, 1.2]
Original list: [1.2, 1.75, 6.9]
Between the lines:
add_itemandremove_itemmodify the cart they receive (appendandpop). That is whyadd_itemreturns nothing and is called without storing the result: the change is already visible outside. The first line of the output shows it: after two calls, the cart has four products.remove_itemgoes through the cart withenumerateto know the position, and as soon as it finds the product it doespop(pos)andreturn Trueimmediately. Stopping right after removing is not a whim: continuing to traverse a list from which you have just removed an element would make the positions shift. If it does not find it, it reaches the end of the loop and returnsFalse.- That is why
remove_item(cart, "Milk")givesTrueand the milk disappears, andremove_item(cart, "Coffee")givesFalseand the cart stays the same. with_discountdoes the opposite: it does not touch the original cart and returns a new one, built with a comprehension. Each element is a new(name, price)tuple holding the discounted price, because tuples cannot be modified. The proof is in the output:discountedhas the prices with the 20 % discount and the nextprint(cart)still shows the old prices.totaladds up the prices with a comprehension that unpacks each tuple. The two totals (9.85 and 7.88) match the lists above.- The last two lines are the story from the beginning:
sorted(prices, reverse=True)returns a new list sorted from highest to lowest, andpricesstays in its original order. Had we writtenprices.sort(reverse=True), the original list would have changed and there would have been nothing to store.
Try it yourself
- Write
sorted_prices = prices.sort(reverse=True)and printsorted_pricesandprices. What comes out in each, and why? - Change
with_discountso that it modifies the cart in place, going through it by index and assigningcart[pos] = (name, new_price). What changes in the output? Which function would no longer make sense returning anything?
Program 3 · A price table by store
To finish, a matrix: three stores (rows) and three products (columns). We want the cost of one unit of each product in each store, the cheapest store for each product, a new table with a discount and a copy of the matrix that we can modify without breaking the original.
stores.py
STORES = ["Market", "Supermarket", "Online"]
PRODUCTS = ["Bread", "Milk", "Oil"]
PRICES = [
[1.20, 0.85, 6.90], # Market
[1.10, 0.95, 7.20], # Supermarket
[1.35, 0.80, 6.50], # Online
]
# One unit of each product, per store (the sum of each row)
for store, row in zip(STORES, PRICES):
print(store, round(sum(row), 2))
# Product by product: the cheapest store (traversal by columns)
for j, product in enumerate(PRODUCTS):
column = [row[j] for row in PRICES]
cheapest = column.index(min(column))
print(product, "->", STORES[cheapest], min(column))
# A new table with a 10 % discount (a comprehension inside another)
discounted = [[round(price * 0.90, 2) for price in row] for row in PRICES]
print(discounted[0])
print(PRICES[0])
# Copying a matrix: every row has to be copied
copy = [row[:] for row in PRICES]
copy[1][0] = 0.99
print(PRICES[1], copy[1])
Launch it with python3 stores.py:
Market 8.95 Supermarket 9.25 Online 8.65 Bread -> Supermarket 1.1 Milk -> Online 0.8 Oil -> Online 6.5 [1.08, 0.77, 6.21] [1.2, 0.85, 6.9] [1.1, 0.95, 7.2] [0.99, 0.95, 7.2]
Between the lines:
- The first loop goes by rows:
zip(STORES, PRICES)pairs each store with its list of prices, andsum(row)adds up a whole row.round(…, 2)prevents odd decimals from floating-point arithmetic from showing up. It adds up: 1.20 + 0.85 + 6.90 = 8.95 at the market. - The second loop goes by columns. With
enumerate(PRODUCTS)we have the positionjof each product, and[row[j] for row in PRICES]takes out that column. Thencolumn.index(min(column))gives the position of the minimum, which is also the position of the store: bread is cheaper at the supermarket (1.10) and milk and oil at the online store. discountedis a comprehension inside another: the outer one creates one row per row, and the inner one discounts each price. The result is a new matrix; the first row of the original prices is printed afterwards unchanged ([1.2, 0.85, 6.9]).- At the end, the copy:
[row[:] for row in PRICES]copies each row. Had we writtencopy = PRICES[:](or.copy()), we would have a new outer list but with the same rows, that is, a shallow copy: modifyingcopy[1][0]would have changedPRICEStoo. As it is,copy[1]has the 0.99 andPRICES[1]keeps its 1.1.
Try it yourself
- Change
[row[:] for row in PRICES]toPRICES[:]and run it again. What comes out now on the last line? Check it withPRICES[1] is copy[1]. - Add a function
cheapest_store(prices)that returns the name of the store whose row adds up to the least. Hint: a comprehension withsum(row)gives you the totals, andmin+indexgive you the position.
Watch it step by step in Python Tutor
Python Tutor runs your code line by line and draws variables and objects in memory. Choose Python 3 and paste this program, which compares the two ways of creating a 2 by 2 matrix that we saw in the price table:
bad = [[0] * 2] * 2 good = [[0] * 2 for _ in range(2)] bad[0][0] = 5 good[0][0] = 5 print(bad) print(good) print(bad[0] is bad[1], good[0] is good[1])
Expected output:
[[5, 0], [5, 0]] [[5, 0], [0, 0]] True False
What to watch, step by step:
- After the first two lines,
badis an outer list with two arrows pointing to the same inner list, whereasgoodhas two arrows to two different inner lists. The difference in the drawing is the whole difference in the bug. - When
bad[0][0] = 5runs, follow the arrow of row 0 to its list and change position 0. Since row 1 points to that same list, the 5 shows up in both rows. - With
good[0][0] = 5what you would expect happens: only the first row changes, because the second one is an independent list. - The first two
printcalls confirm it:[[5, 0], [5, 0]]versus[[5, 0], [0, 0]]. - The last line outputs
True False: inbad, the two rows are the same object; ingood, they are not. Python Tutor shows it to you with arrows, butistells you in writing.
To finish
Today you have seen the tools of this topic in action: zip and enumerate to traverse without hand-made indexes, comprehensions to build new lists, functions that modify and functions that return, and matrices traversed by rows and by columns, with their proper copy. If something did not click, go back to the theory; and when you feel comfortable, move on to the exercises, where the bugs come included.
Did this article help you? Here is how you can support SergioLearns →
