Command Palette

Search for a command to run...

Hectal
PHASE 5Beginner ~27 min· topic 10 of 10

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 if that 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.

main.pywhole filepython
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 15

02Change 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.

main.pywhole filepython
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 untouched
Change the input, or return a new valuediagram
Rendering diagram…

03Flatten 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.

main.pywhole filepython
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.

main.pywhole filepython
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. 1

    Test it with assert

    Add assert add(2, 3) == 5 and assert with_item([1], 2) == [1, 2] at the bottom of the first example. Nothing prints when they pass. Change one expected value to see the AssertionError.

  2. 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 (return None? raise a clear error? you'll learn how in Phase 7), and write it.

  3. 3

    Refactor one more

    Take any function from earlier in the phase and look for: a print that should be a return, a changed argument, a nested if that could return early. Fix one and re-run your checks.

Code & diagrams

return, print and changing the caller's list New tab

x is None because add_and_print only prints. The list functions show the difference between changing in place and returning a new list.

Sign in to run this example in your browser.

Expected output

5
x is None
y is 15
after in place: ['pen', 'book']
new cart: ['pen', 'book', 'bag'] old cart: ['pen', 'book']
Guard clauses give the same answers New tab

The nested and the flat version are compared on five inputs. A line that says DIFFERENT would mean the refactor changed behaviour.

Sign in to run this example in your browser.

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 same
Three small functions and a short script New tab

Each function has one job and a docstring. Only the last two lines talk to the user.

Sign in to run this example in your browser.

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.

main.pywhole filepython
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)
terminal
$ python main.py
── what you'll see ──
taxed : [150.0, 300.0]
original: [150.0, 300.0]

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_ versus sorted_) and docstrings carry the contract. Standard-library precedent: methods that mutate return None (list.sort, list.append) so you cannot chain them by accident.

  • ▸

    When None is a valid value for a parameter, use a private sentinel: _MISSING = object() and if 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. 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_words and most_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. 2

    Return, don't print. A function that computes something should return it. 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 with assert, or send it to a file or a web page. A function that returns nothing because it only prints also gives you the None surprises from Topic 5.1.

  3. 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. 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, returning None, like list.sort) or return a new list and leave the input alone (like sorted). Pick one per function and name it accordingly.

  5. 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 None pattern for mutable ones, Topic 5.2).

  6. 6

    Early returns (guard clauses). Handle the special cases first and return at once, so the main path is not buried under nested if blocks. 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 like process or data2.

  7. 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

01

What is a pure function, and why do programmers like them?

02

Why should a function return its result instead of printing it?

03

How should a function treat a list argument it receives?

04

What is a guard clause, and how does it improve a function?

05

Give signs that a function should be split.

Practice

01

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.

02

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.

03

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.