WasiLearn Code
Python · Functions

Designing a good function

Getting a function to work is the easy part. Making it one that somebody can read in six months, call without reading, and change without fear is the part worth learning deliberately.

def weeks_from_days(days):
    """Return the number of started weeks in a span of days.

    A partial week counts as a whole one, matching the late-fee policy.
    """
    return -(-days // 7)

print(weeks_from_days(9))
print(weeks_from_days(7))
output
2
1

Everything in this lesson is in that example: a name that says what it returns, one job, no surprises, and a docstring explaining the one thing the code cannot say for itself.

Docstrings

A string as the first statement in a function is its docstring. It is not a comment — Python keeps it, and tools read it.

def with_tax(price):
    """Return price with 15% sales tax, rounded to the nearest rupee."""
    return round(price * 1.15)

print(with_tax.__doc__)
print(with_tax(250))
output
Return price with 15% sales tax, rounded to the nearest rupee.
288

help(with_tax) prints the same thing in an interactive session, and every editor shows it when you hover the function name. That is the payoff: the explanation arrives at the call site, where the reader actually is.

Triple quotes are the convention even for one line, because it makes adding a second line later a non-event.

The house style for a longer one:

def split_bill(total, people, tip_percent=0):
    """Return each person's share of a bill.

    Args:
        total: The bill total, in whole rupees.
        people: How many ways to split it. Must be at least 1.
        tip_percent: Optional tip, as a percentage of the total.

    Returns:
        The amount each person owes, rounded up to a whole rupee.

    Raises:
        ValueError: If people is less than 1.
    """
    if people < 1:
        raise ValueError("people must be at least 1")
    with_tip = total * (1 + tip_percent / 100)
    return -(-int(with_tip) // people)

print(split_bill(1000, 3))
print(split_bill(1000, 4, tip_percent=10))
output
334
275

Write the first line as an instruction — "Return each person's share" — not a description of the code. And document what the caller cannot see: units, rounding behaviour, what makes it raise.

One function, one job

The clearest signal that a function is doing too much is that its name needs an "and".

def process_and_print_order(items, prices):
    total = 0
    for price in prices:
        total = total + price
    print(f"{len(items)} items")
    print(f"Total: {total}")
    return total

process_and_print_order(["chai", "paratha"], [250, 120])
output
2 items
Total: 370

It calculates and it prints. So it cannot be used anywhere the answer is needed without also printing, and it cannot be tested without capturing output.

Split it:

def order_total(prices):
    """Return the sum of a list of prices."""
    return sum(prices)

def format_order(items, total):
    """Return a human-readable summary line."""
    return f"{len(items)} items, total {total}"

items = ["chai", "paratha"]
prices = [250, 120]
total = order_total(prices)

print(format_order(items, total))
print(total * 2)
output
2 items, total 370
740

Now the calculation is reusable, the formatting is separate, and the printing happens where printing belongs — at the top level, not buried three functions deep.

Return, do not print

A function that prints has made a decision for every future caller. A function that returns leaves it open.

def area(width, height):
    return width * height

print(area(3, 4))
print(area(3, 4) + area(2, 2))
output
12
16

Had area printed instead of returned, that second line would be impossible. Printing is what you do with a result; producing the result is a different job.

Flag arguments

A boolean parameter that switches the whole behaviour is usually two functions wearing one name.

def get_price(item, wholesale=False):
    base = 250
    if wholesale:
        return base * 0.7
    return base

print(get_price("chai"))
print(get_price("chai", True))
output
250
175.0

get_price("chai", True) tells the reader nothing at the call site — you have to open the function to learn what True means. Either name it at the call site, or split into retail_price and wholesale_price.

The rule of thumb: if the flag makes the function take a completely different branch, split it. If it only tweaks a detail, keep it and insist on the keyword.

Say no to surprises

A function should not change things the caller did not expect it to change.

def add_tax(prices):
    """Return a NEW list with tax applied."""
    return [round(p * 1.15) for p in prices]

original = [250, 120]
taxed = add_tax(original)

print(original)
print(taxed)
output
[250, 120]
[288, 138]

Compare with a version that modifies in place — prices[i] = ... — which leaves the caller's list silently different. Both are legitimate; the difference is that one has to be documented and the other does not. Prefer the one that does not.

Names that read aloud

# hard to guess
def proc(d, f):

# obvious
def students_above(marks, threshold):

A few conventions that make names carry their weight:

  • Functions are verbs. calculate_total, find_student, send_receipt.
  • Predicates ask a question. is_open, has_paid, can_order — and they return a boolean, always.
  • Getters say what they return. order_total is better than get_total, because get_ adds a word and no information.
  • Match the vocabulary of the domain. If the business says "roll number", the parameter is roll_number, not student_id.

Keep it short

There is no magic line count, but a function you cannot see all of at once is one you cannot reason about at once. When a function grows past a screen, the usual cause is that a chunk of the middle has a name waiting for it.

The same goes for parameters. Three is comfortable; six means the arguments probably belong together in a dictionary or an object.

Type hints, lightly

Annotations describe what goes in and comes out. Python does not enforce them — they are for readers and for tools.

def with_tax(price: float, rate: float = 0.15) -> int:
    """Return price with tax, rounded to the nearest whole rupee."""
    return round(price * (1 + rate))

print(with_tax(250))
print(with_tax(250, 0.2))
output
288
300

Nothing checks those types at runtime — passing a string still fails the same way it always did. What they buy you is an editor that catches the mistake as you type, and a signature that answers "what does this take?" without the docstring.

The checklist

Before you call a function finished:

  • Does the name say what it returns or does, without an "and"?
  • Does it return rather than print?
  • Would a caller be surprised by anything it changes?
  • Is there a docstring saying the thing the code cannot?
  • Can you see the whole thing at once?

Next: when things go wrong, and the exceptions that a well-designed function raises on purpose.

Prefer learning with a teacher?

WasiLearn Academy runs small-group classes covering this material, online and in Karachi.

See the classes