Topic 5.10
Designing Good Functions
In one line
A good function does one job, has a clear name and a small signature, returns its result instead of printing it, avoids changing its inputs or hidden state, and uses early returns to keep the main path flat. These habits make code easy to read, test and reuse.
Think of it like this
Kitchen tools. A good peeler peels. A good whisk whisks. A single gadget that peels, whisks, bakes and washes the dishes is hard to use, hard to clean and impossible to repair. Functions are your tools: each should do one job, be named for that job, and take what it needs from your hand rather than reaching into drawers you did not mention.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Pure function
- A function whose result depends only on its arguments and which changes nothing outside itself.
- Side effect
- Any change a function makes beyond returning a value: printing, writing a file, changing a global or an argument.
- Guard clause
- An early
ifthat deals with a special case and returns, so the rest of the code can assume it is not there. - Single responsibility
- The idea that a function (or class) should have one reason to change, one job.
- In place
- Changing the original object instead of making a new one.
- Refactor
- To change the structure of code without changing what it does.
- Sentinel
- A special value, often
None, that means "nothing was given". - Testable
- Easy to check automatically, because inputs and outputs are clear.
Step by step
01Return versus print
Both functions below add two numbers. Only add can be used in more calculation. add_and_print returns None, so using its "answer" gives None.
def add_and_print(a, b):
print(a + b)
def add(a, b):
return a + b
x = add_and_print(2, 3) # prints 5, x is None
y = add(2, 3) + 10 # y is 1502Change the input, or return a new value
add_item_in_place changes the list that the caller owns. with_item builds a new list and leaves the input alone. Neither is wrong, but the caller must be able to tell which one they are using. Python's own list.append changes in place and returns None; sorted returns a new list.
def add_item_in_place(items, item):
items.append(item) # changes the caller's list
def with_item(items, item):
return items + [item] # new list, input untouched03Flatten with guard clauses
A nested version of this function needs the reader to track four conditions at once (the Run example below shows it next to this one). The flat version below handles the invalid case first, and each remaining rule is a short check. Both give the same answers, and the Run example proves it on a few inputs.
def price_flat(price, is_member, coupon):
if price <= 0:
return 0.0
if is_member and coupon:
return price * 0.8
if is_member:
return price * 0.9
if coupon:
return price * 0.95
return float(price)04Split a function that does too much
Cleaning text, counting words and finding the most common one are three ideas. As three functions they can each be tested alone, reused (counting works on any list of words), and read without scrolling. The final part is a short script that connects them.
counts = count_words(clean_words(text))
print(most_common(counts))05Names and signatures
A name should tell the reader what the call returns or does. calculate_total(items) beats process(items). If a function needs a boolean flag that changes its whole behaviour (save(data, to_cloud=True)), it may really be two functions (save_local, save_cloud). For optional settings, prefer keyword-only parameters with defaults.
06Checking a function by hand
For every function, ask: what does it need (arguments), what does it give back (return value), what else does it touch (side effects)? If you can list all three in one breath, the function is well-shaped. A quick set of assert lines is the simplest test: assert add(2, 3) == 5 does nothing if the statement is true, and stops the program with an AssertionError if it is false. Phase 11 builds on that with pytest.
Try it yourself
- 1
Test it with assert
Add
assert add(2, 3) == 5andassert with_item([1], 2) == [1, 2]at the bottom of the first example. Nothing prints when they pass. Change one expected value to see theAssertionError. - 2
Add an edge case
Call
most_common({})in the third example. Read the error. Decide what a good function should do for an empty dictionary (returnNone? raise a clear error? you'll learn how in Phase 7), and write it. - 3
Refactor one more
Take any function from earlier in the phase and look for: a
printthat should be areturn, a changed argument, a nestedifthat could return early. Fix one and re-run your checks.
Code & diagrams
x is None because add_and_print only prints. The list functions show the difference between changing in place and returning a new list.
Expected output
5
x is None
y is 15
after in place: ['pen', 'book']
new cart: ['pen', 'book', 'bag'] old cart: ['pen', 'book']The nested and the flat version are compared on five inputs. A line that says DIFFERENT would mean the refactor changed behaviour.
Expected output
(100, True, True) 80.0 80.0 same
(100, True, False) 90.0 90.0 same
(100, False, True) 95.0 95.0 same
(100, False, False) 100.0 100.0 same
(0, True, True) 0.0 0.0 sameEach function has one job and a docstring. Only the last two lines talk to the user.
Expected output
{'the': 4, 'cat': 2, 'saw': 2, 'dog': 2}
('the', 4)Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
A function that silently changes its argument
Write a helper that edits the list it receives, and then print the original list.
def add_tax(prices):
for i in range(len(prices)):
prices[i] = prices[i] * 1.5
return prices
original = [100, 200]
taxed = add_tax(original)
print("taxed :", taxed)
print("original:", original)Myth vs fact
Myth
Shorter functions are always better.
Fact
A function should be as short as its single job allows. Splitting into tiny pieces that no one can name or understand separately makes code harder, not easier.
Myth
Functions with side effects are bad.
Fact
Programs exist to have effects (save, send, show). The skill is to keep the effects in a few clearly named places and the logic pure and testable.
Myth
print inside a function is how it "gives back" its answer.
Fact
Printing shows text to a person; only return gives a value to the program.
Myth
Comments are a substitute for clear names.
Fact
A good name and a small function remove the need for most comments. Write comments for why, not what.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Pure functions are easy to test, can be cached (
functools.cache), and are safe to call from many threads or tasks. A common architecture is a "functional core, imperative shell": pure logic in the middle, with input and output at the edges. - ▸
Python has no way to forbid mutation of arguments, so the convention matters. Naming (
sort_versussorted_) and docstrings carry the contract. Standard-library precedent: methods that mutate returnNone(list.sort,list.append) so you cannot chain them by accident. - ▸
When
Noneis a valid value for a parameter, use a private sentinel:_MISSING = object()andif value is _MISSING. This lets you tell "not passed" from "passed None". - ▸
Cyclomatic complexity (the number of independent paths) is a rough measure of how hard a function is to test. Linters such as Ruff can flag functions with too many branches (rule C901) or too many arguments (PLR0913) as a prompt to split them.
Remember this
- 1
One job. Try to describe the function in one short sentence without "and". "Clean the words and count them and print the top one" is three functions. Split it into
clean_words,count_wordsandmost_common, and a small outer part that prints. Small functions can be named, tested and reused separately, and bugs have fewer places to hide. - 2
Return, don't print. A function that computes something should
returnit. Printing is for the outermost layer of the program, the part that talks to a person. If an inner function prints, you cannot reuse its answer, test it withassert, or send it to a file or a web page. A function that returns nothing because it only prints also gives you theNonesurprises from Topic 5.1. - 3
Pure functions and side effects. A pure function's result depends only on its arguments, and it changes nothing else:
add(2, 3)is always 5 and touches nothing. A side effect is any change outside the function: printing, writing a file, changing a global, or changing an object that was passed in. Side effects are sometimes the whole point (saving a file), but they make behaviour harder to predict. Keep the pure logic separate from the parts that have effects, and make the effects obvious in the name (save_report). - 4
Do not surprise the caller by changing their data. Passing a list gives the function the same list, not a copy (Topic 4.9). If the function sorts or edits it in place, the caller's data changes too. Either say so clearly (
sort_in_place, returningNone, likelist.sort) or return a new list and leave the input alone (likesorted). Pick one per function and name it accordingly. - 5
Small signatures. Aim for three or four parameters at most. A long list is a warning: some parameters may belong together (pass a dict, or later a dataclass, Phase 8), the function may do two jobs, or the options should be keyword-only (Topic 5.4). Give optional parts safe defaults (using the
Nonepattern for mutable ones, Topic 5.2). - 6
Early returns (guard clauses). Handle the special cases first and return at once, so the main path is not buried under nested
ifblocks. Flat code is easier to read than the arrow-shaped nesting. Names matter as much: use a verb for something that does (calculate_total,send_email), a noun or question for something that answers (is_valid,total_price), and no vague names likeprocessordata2. - 7
In AI work these habits are practical rules: a model-callable tool must be a small function with a clear name, simple typed arguments, a docstring that says exactly what it does, and a return value the program can check. Functions that quietly change global state or do five things make agents unpredictable and are hard to test.
Explain it without notes
What is a pure function, and why do programmers like them?
Why should a function return its result instead of printing it?
How should a function treat a list argument it receives?
What is a guard clause, and how does it improve a function?
Give signs that a function should be split.
Practice
This function prints instead of returning. Rewrite it so it returns: def area(w, h): print(w * h). Use it to print area(3, 4) + 1.
Write normalised(numbers) that returns a new list with every number divided by the largest one, without changing the input. Show that the original list is unchanged.
Rewrite with guard clauses: def grade(score): returns "invalid" if score is below 0 or above 100, "pass" for 40 or more, otherwise "fail".
Trade-offs
- ↔
Many small functions versus fewer bigger ones. Small functions are testable and nameable, but too much splitting forces the reader to jump around. Split where a piece has its own meaning.
- ↔
Pure and copying versus in place. Returning new data is safer and simpler to reason about. In-place changes save memory and time for very large data, so numerical libraries use them deliberately (and name them clearly).
Done when you can
Done when you can describe each of your functions in one sentence without "and".
Done when you can turn a printing function into a returning one.
Done when you can tell whether a function changes its arguments, and say so in its name or docstring.
Done when you can flatten nested conditions with guard clauses and check the results are the same.
Done when you can list a function's inputs, output and side effects in one breath.