Functions: one job, few parameters, no hidden side effects
In short: A good function does one job at one level of abstraction, takes few parameters and no boolean flags, and does not quietly change state its name does not promise.
Problem
The function that does everything
The names from the last lesson are in place. The shop's constants:
FREE_DELIVERY_FROM = 50STANDARD_DELIVERY_FEE = 4.99EXPRESS_SURCHARGE = 7SPRING_COUPON = "SPRING10"SPRING_DISCOUNT = 0.10
orders = []And this is the function every order goes through:
def process_order(email, prices, coupon, express): amount = 0 for price in prices: amount += price if coupon == SPRING_COUPON: amount = amount * (1 - SPRING_DISCOUNT) delivery = 0 if amount >= FREE_DELIVERY_FROM else STANDARD_DELIVERY_FEE if express: delivery += EXPRESS_SURCHARGE total = amount + delivery orders.append(total) print(f"To {email}: your order is confirmed, €{total:.2f}") print(f"[log] order #{len(orders)} for €{total:.2f}")process_order("maria@example.com", [24.50, 18.00], "SPRING10", True)process_order("ivan@example.com", [64.00], "", False)To maria@example.com: your order is confirmed, €50.24[log] order #1 for €50.24To ivan@example.com: your order is confirmed, €64.00[log] order #2 for €64.00It works. Now marketing wants the coupon not to apply to delivery. Accounting wants the fee shown separately in the cart. The tester wants to check delivery below and above €50, and for every check has to place an order, which means saving it and sending an email. Everyone touches the same function. And what True at the end of the call means still can't be seen.
The idea
One job and one level
Try to say what process_order does: "it adds up the prices, and applies the coupon, and calculates delivery, and saves, and sends an email, and writes to the log". Every "and" is a separate job. Each one changes for its own reason and because of a different person.
The rule is one job per function. A function with one job is small, has a name that says the whole job, and has few parameters. How you know the job is one:
- you describe it in one sentence without "and";
- you can't pull a piece out of it with a name that isn't just a retelling of the code.
The second rule is one level of abstraction. The top function reads like the table of contents of a book: calculate the total, save, confirm, log. How exactly the prices are added up (the loops, the comparisons, the format of the string) is one level below, in the small functions. When "save the order" and amount += price are in the same method, the reader jumps between the two levels on every line.
See it
The same order, split up
At the top is the table of contents:
def place_order(email, prices, coupon, express): total = order_total(prices, coupon, express) orders.append(total) send_confirmation(email, total) log_order(len(orders), total)
def send_confirmation(email, total): print(f"To {email}: your order is confirmed, €{total:.2f}")
def log_order(number, total): print(f"[log] order #{number} for €{total:.2f}")Below it is the calculation, each job in its own function:
def subtotal(prices): amount = 0 for price in prices: amount += price return amount
def apply_coupon(amount, coupon): if coupon == SPRING_COUPON: return amount * (1 - SPRING_DISCOUNT) return amount
def delivery_fee(amount, express): fee = 0 if amount >= FREE_DELIVERY_FROM else STANDARD_DELIVERY_FEE if express: fee += EXPRESS_SURCHARGE return feedef order_total(prices, coupon, express): amount = subtotal(prices) discounted = apply_coupon(amount, coupon) return discounted + delivery_fee(discounted, express)place_order("maria@example.com", [24.50, 18.00], "SPRING10", True)place_order("ivan@example.com", [64.00], "", False)To maria@example.com: your order is confirmed, €50.24[log] order #1 for €50.24To ivan@example.com: your order is confirmed, €64.00[log] order #2 for €64.00The output is the same. Now the coupon is in one function of three lines, delivery in another, and each one changes without touching the email. There are more lines of code, but each piece is read, changed and checked on its own.
More precisely, if you are curious
How small? There is no number. A 30-line function that does one thing (for example a long but flat switch with similar branches) is fine. An 8-line function with three levels of nesting and two flags is not. The measure is the one-sentence description, not the number of lines. Splitting a long function in steps, so that the program works after each one, is Extract Method in the sixth lesson of this module.
Try it
Put the lines in order
Put order_total in order so that the coupon is applied before delivery. Why the order matters: the discount can take the order below €50, and then delivery is no longer free.
return discounted + delivery_fee(discounted, express)discounted = apply_coupon(amount, coupon)amount = subtotal(prices)def order_total(prices, coupon, express):
The idea
Few parameters and no flags
Every parameter is something the reader has to keep in mind, and something every call has to pass in the right order. Zero, one and two parameters read easily. Three is already a lot. Four or more usually mean that the parameters are one thing broken into pieces: the email, the prices, the coupon and the delivery together are an order. Then they become one object. This is the Introduce Parameter Object refactoring from the sixth lesson.
The worst parameter is a flag: a boolean that chooses what the function does.
def print_slip(prices, for_courier): if for_courier: print(f"Parcel: {len(prices)} items") else: for price in prices: print(f"€{price:.2f}")cart = [24.50, 18.00]print_slip(cart, True)print_slip(cart, False)Parcel: 2 items€24.50€18.00At the call, True says nothing. And inside, the function has two separate paths, so two jobs. The fix is two functions with names:
def print_courier_slip(prices): print(f"Parcel: {len(prices)} items")
def print_receipt(prices): for price in prices: print(f"€{price:.2f}")cart = [24.50, 18.00]print_courier_slip(cart)print_receipt(cart)Parcel: 2 items€24.50€18.00Sometimes the boolean really is data, not a choice of behavior: express delivery is part of the order. Then at least the call should say what it means:
Python has keyword-only arguments: everything after * in the definition can only be passed by its name. Trying without the name is an error:
def delivery_fee(amount, *, express=False): fee = 0 if amount >= FREE_DELIVERY_FROM else STANDARD_DELIVERY_FEE if express: fee += EXPRESS_SURCHARGE return fee
print(f"€{delivery_fee(38.25, express=True):.2f}")
try: delivery_fee(38.25, True)except TypeError as error: print(error)€11.99delivery_fee() takes 1 positional argument but 2 were givenThe idea
Side effect
Anything a function changes outside itself is a side effect: a field of an object, a list it was given, a global variable, a file, a database, an email, a line on the screen.
Side effects are not bad on their own. The order has to be saved somewhere, and the email has to go out. It is bad when the effect is hidden: the name says "I ask", but the function also does. Whoever calls it doesn't expect anything to change, and calls it twice without a second thought.
See it
The question that changes the answer
The warehouse says whether there are lamps. Someone decided that if there are, it's handy to reserve them right away:
class Warehouse: def __init__(self, stock): self.stock = stock
def is_in_stock(self, quantity): if self.stock >= quantity: self.stock -= quantity return True return FalseThe cart asks so that it can show "available". Then checkout asks the same thing:
lamps = Warehouse(3)print("Cart: " + ("available" if lamps.is_in_stock(2) else "out of stock"))print("Checkout: " + ("available" if lamps.is_in_stock(2) else "out of stock"))print(f"In stock: {lamps.stock}")Cart: availableCheckout: out of stockIn stock: 1class Warehouse: def __init__(self, stock): self.stock = stock
def is_in_stock(self, quantity): if self.stock >= quantity: self.stock -= quantity return True return False
lamps = Warehouse(3)print("Cart: " + ("available" if lamps.is_in_stock(2) else "out of stock"))print("Checkout: " + ("available" if lamps.is_in_stock(2) else "out of stock"))print(f"In stock: {lamps.stock}")Step by step, in words
- The warehouse has 3 lamps on the shelf.
- The cart only wants to show whether there are 2. It asks is_in_stock(2): a question, judging by the name.
- 3 >= 2: there are enough.
- But the question also takes the lamps: self.stock goes from 3 to 1. Nobody bought anything, and the cart doesn't know this happened.
- The cart gets True and shows "available".
- The customer goes to checkout. Checkout asks the same thing: is_in_stock(2).
- 1 >= 2 is False. The same question gets a different answer, and the customer sees "out of stock".
- In stock: 1. The answer changed because the first question had a hidden side effect.
Nobody bought anything, two lamps are missing, and the customer can't pay. In a real shop, this is an order lost because of a page that asks about stock. And the bug is hard to find, because every line on its own looks fine.
More precisely, if you are curious
@property: lamps.stock looks like reading an attribute, but it can be a function that changes something. The PyCharm debugger evaluates properties to show them, and that can change the state.Try it
Predict the output
The warehouse with the is_in_stock that also reserves now has 5 lamps. The same question is asked three times. What does the program print?
lamps = Warehouse(5)print(lamps.is_in_stock(2))print(lamps.is_in_stock(2))print(lamps.is_in_stock(2))Every "yes" takes 2 lamps: 5 becomes 3, then 1. At the third question, 1 is less than 2. If the question only asked, all three answers would be "yes".
The idea
A command or a query, not both
The rule is called command–query separation: a function either answers a question and changes nothing, or does something and doesn't pretend to be a question. The warehouse becomes:
class Warehouse: def __init__(self, stock): self.stock = stock
def is_in_stock(self, quantity): return self.stock >= quantity
def reserve(self, quantity): if not self.is_in_stock(quantity): raise ValueError("Not enough stock") self.stock -= quantitylamps = Warehouse(3)print("Cart: " + ("available" if lamps.is_in_stock(2) else "out of stock"))print("Checkout: " + ("available" if lamps.is_in_stock(2) else "out of stock"))lamps.reserve(2)print(f"In stock: {lamps.stock}")Cart: availableCheckout: availableIn stock: 1The cart and checkout ask as much as they like. The lamps are reserved on one line that says exactly that: reserve(2).
More precisely, if you are curious
There are well-known exceptions, and the name gives them away: items.pop() removes the element and returns it, next(iterator) moves forward and returns. These are verbs, not questions, so nobody calls them "just to check". The rule is not "a command never returns anything" but "a query never changes anything".
The idea
The pure function
delivery_fee from the split order answers only from its parameters and changes nothing. A function like this is called pure: with the same parameters it always returns the same thing, no matter how many times or in what order you call it.
print(f"€{delivery_fee(38.25, False):.2f}")print(f"€{delivery_fee(38.25, True):.2f}")print(f"€{delivery_fee(64.00, False):.2f}")print(f"€{delivery_fee(38.25, True):.2f}")€4.99€11.99€0.00€11.99Checking delivery below and above the threshold takes four lines. There is no warehouse to fill, no email to send, no database. With process_order, you had to place an order for the same thing. That is why the calculations are kept pure, and the side effects are gathered in a few clearly named commands at the top. How checks like these become tests that protect the code at every change is the topic of the fifth lesson of this module.
Try it
Find the bug
The "Remove last" button should remove the product the customer added last. The customer adds milk, apples and bread. Before that, the shop prints a receipt sorted alphabetically:
def print_receipt(items): items.sort() for item in items: print(" " + item)cart = ["milk", "apples", "bread"]print_receipt(cart)print("Last added: " + cart[-1]) apples bread milkLast added: milkThe bread was added last, but the program says milk. Which line is the bug on?
Pick the line with the bug.
items.sort() sorts the very list the function got, and that list is the caller's cart. The receipt only wanted to print, but it quietly changed the order of the cart. The fix is to sort a copy and leave the cart untouched:
def print_receipt(items): for item in sorted(items): print(" " + item)sorted(items) returns a new list, while items.sort() changes the list itself.Exercise
The order shipped notification
Solve the task in your editor.
The notification. This function builds the text, chooses the channel with a flag, prints and counts what was sent:
sent = 0
def notify_shipped(name, order_id, sms): global sent sent += 1 text = f"Hello, {name}! Order {order_id} has shipped." if sms: print("SMS: " + text) else: print("Email: " + text)notify_shipped("Maria", 1042, False)notify_shipped("Ivan", 1043, True)print("Sent: " + str(sent))Email: Hello, Maria! Order 1042 has shipped.SMS: Hello, Ivan! Order 1043 has shipped.Sent: 2Split it into three functions: a pure one that only builds the text, and two commands, one that sends by email and one by SMS, each counting what it sent. The flag goes away. The output must stay the same:
Email: Hello, Maria! Order 1042 has shipped.SMS: Hello, Ivan! Order 1043 has shipped.Sent: 2More tasks
- A parameter object.
place_orderhas four parameters. Group them in anorderobject and see what the call looks like. - The coupon doesn't apply to delivery. Marketing changes the rule. Which function do you touch, and how many others stay untouched?
- The queue. Write
peek_next()andtake_next()for a queue of orders for the courier. Which one is a query and which one is a command?
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Exceptions · Throwing and your own exceptions · Lists: append, remove and sort · Names in code
- Continues in: Comments that lie and comments that help · Code smells: long method, large class, duplication, primitives instead of types, long if chains · Tests as a safety net · Refactoring in small steps
- Tasks on this topic: Clean code and refactoring