Our booking manager has functions. Now the cinema wants different booking fees for different sales channels. A function that secretly reads whichever global price happens to exist is going to make this interesting in the wrong way.
You need: functions, return values, and dictionaries. By the end: you can describe where a function gets its inputs, choose readable calls, and trace local assignments. Allow 25–35 minutes.
Positional and keyword arguments
Save this complete example as booking_fees.py:
def booking_total(quantity, price_cents, fee_cents=0):
return quantity * price_cents + fee_cents
print(booking_total(3, 850))
print(booking_total(3, 850, fee_cents=100))
print(booking_total(quantity=3, price_cents=850, fee_cents=100))
Output:
2550
2650
2650
In a positional call, values match parameters from left to right. A keyword argument names its destination explicitly. The =0 in the definition supplies a default when the caller omits the fee. Here the fee applies once per booking, not once per ticket. That business rule deserves an explanation even if the formula fits on one line.
Ordinary positional arguments must come before keyword arguments. Supplying quantity both positionally and by name raises TypeError; leaving out a required argument does too. Those are errors in how the function is called, distinct from an integer quantity that violates a booking rule.
Defaults are evaluated when the def statement executes, not freshly on every call. An immutable integer such as zero is a suitable default here. A mutable list has a more surprising lifetime; we will investigate that in the next lesson.
Make easily confused choices explicit
Three unlabelled numbers can be hard to review. A * in the parameter list requires everything after it to be supplied by keyword:
def booking_total(quantity, price_cents, *, fee_cents=0):
return quantity * price_cents + fee_cents
print(booking_total(3, 850, fee_cents=100))
This prints 2650. With this definition, booking_total(3, 850, 100) raises TypeError: the fee is keyword-only. The * is parameter syntax here, not multiplication. Use it where explicit naming prevents realistic mistakes, rather than adding complexity to every tiny helper.
A local assignment stays local
price_cents = 850
def discounted_price(price_cents):
price_cents = price_cents - 50
return price_cents
new_price = discounted_price(price_cents)
print(new_price)
print(price_cents)
Output:
800
850
The parameter is a local name in this call. Reassigning it does not reassign the caller’s name. The function returns the new number so the caller can decide what to do with it.
This does not mean a function cannot affect an object passed to it. If that object is a list and the function calls .append(), its contents change. Rebinding a name and mutating an object are different operations; lesson 14 makes that distinction concrete.
Reading a global is a hidden input
fee_cents = 100
def hidden_total(quantity, price_cents):
return quantity * price_cents + fee_cents
print(hidden_total(3, 850))
fee_cents = 200
print(hidden_total(3, 850))
The identical calls print 2650 and 2750. The calculation depends on a name outside its parameter list. This may be intentional, but it makes the function’s behaviour harder to understand and test in isolation.
Prefer passing changing configuration explicitly. Python permits global assignment through the global keyword, but it is unnecessary for this calculation. Returning a value leaves control with the caller.
One common trap is reading a name and then assigning to that same name inside a function without a parameter. Python normally treats that name as local throughout the function, so reading it before the local assignment raises UnboundLocalError. Solve the dependency problem instead of sprinkling global over the program.
Performance: pass prepared inputs, not repeated setup
Suppose each booking needs a venue’s ticket price. Pass the already-loaded price or lookup dictionary to the calculation. Do not make every call reload a price file or fetch the same remote configuration.
Passing a dictionary does not automatically copy its contents. That makes sharing prepared data inexpensive, but the function must respect the intended mutation policy. Making a defensive full copy in every call can trade one problem for substantial extra work.
Explicit arguments mainly improve correctness and testability. They are not a promise that a function call itself will run faster. The resource saving comes from avoiding unnecessary work behind that call.
Your turn: delivery fees
Write order_total(quantity, unit_price_cents, *, delivery_cents=0). Delivery applies once. Test two items at 500 cents with no delivery fee, then with a 250-cent fee. Assume positive integer quantities and non-negative integer prices.
Show one solution
def order_total(quantity, unit_price_cents, *, delivery_cents=0):
return quantity * unit_price_cents + delivery_cents
print(order_total(2, 500))
print(order_total(2, 500, delivery_cents=250))
Expected output: 1000, then 1250. Multiplying the delivery fee by the quantity would produce the wrong second answer. Changing quantity to 3 should give 1750 with delivery.
Checkpoint: distinguish a default from a global input, explain why keyword-only options can prevent mistakes, and predict whether a local reassignment changes a caller’s variable.