FreeCampus Python

Defining Functions and Returning Useful Results

Define and call functions, trace when bodies execute, return reusable values on every intended path, and distinguish returned data from printed output and implicit None.
python-foundations functions-call-behavior functions return-values
Open in Colab
  • Level: Python Foundations · Unit 5
  • Estimated time: 3.5–5 hours
  • You will learn: Define reusable behavior, trace definition and call time, and return useful results consistently without confusing display with data flow.
  • Practice in: Google Colab, JupyterLab, or a local editor

1. Give a repeated calculation one reliable home

An expedition notebook calculates route totals in two places:

day_one = [4, 7, 3]
day_one_total = 0
for distance in day_one:
    day_one_total += distance

day_two = [2, 5]
day_two_total = 0
for distance in day_two:
    day_two_total += distance

The two copies currently agree, but a future repair could reach only one. Put the algorithm behind one function boundary:

def total_distance(distances):
    """Return the sum of the supplied distances."""
    total = 0
    for distance in distances:
        total += distance
    return total


day_one_total = total_distance([4, 7, 3])
day_two_total = total_distance([2, 5])

assert day_one_total == 14
assert day_two_total == 7

The function has one job. Its parameter gives each call an input, its local accumulator holds working state, and return sends the result back.

Questions this lesson will answer

  • What happens when Python reads a function definition?
  • When does the indented body actually run?
  • How is returning a value different from printing it?
  • What returns when execution reaches the end without return?
  • How can every supported branch provide a consistent result?

A call evaluates its argument, creates local parameter state, runs the body, and returns one result to the suspended caller.

flowchart LR
  A[Caller evaluates argument] --> B[Bind local parameter]
  B --> C[Run function body]
  C --> D{Return reached?}
  D -- Yes --> E[Send value to caller]
  D -- No, body ends --> F[Send None to caller]
  E --> G[Caller continues]
  F --> G

2. Defining a function does not run its body

def announce_launch():
    print("Launch sequence started")


print("Definition complete")

Only Definition complete appears. When Python executes the def statement, it creates a function object and binds it to announce_launch. The indented body is stored as the behavior to run later.

Parentheses request a call:

print("Before call")
announce_launch()
print("After call")

The order is:

  1. display Before call;
  2. enter announce_launch and display its message;
  3. finish the body and return to the caller; and
  4. display After call.

A blank line between the definition and the call is style, not control flow. Indentation determines the body; alignment returns to top-level code.

3. Parameters receive a fresh call’s inputs

def route_label(name):
    return f"Route: {name}"


north_label = route_label("Northern Ridge")
south_label = route_label("Southern Caves")

name is a parameter in the definition. "Northern Ridge" and "Southern Caves" are arguments supplied by separate calls. During the first call, local name refers to the first string. That frame finishes before a new frame binds name to the second string.

The parameter name need not match the caller’s name:

selected_route = "Moon Pass"
label = route_label(selected_route)
assert label == "Route: Moon Pass"

Python passes the value produced by evaluating selected_route; it does not connect the two names permanently. Lesson 2 develops complete binding rules.

4. Calling and referring to a function are different

def route_count(stops):
    return len(stops)


function_value = route_count
result_value = route_count(["dock", "ridge"])

assert callable(function_value)
assert result_value == 2

Without parentheses, route_count refers to the function object. With parentheses, Python calls it and the expression becomes its returned result. Lesson 5 uses function objects as configurable behavior. For now, check whether you intend to name the tool or use it.

Checkpoint: definitions and calls

5. A return value remains useful to the caller

def distance_status(total):
    if total >= 20:
        return "long route"
    return "short route"


status = distance_status(14)
assert status == "short route"

return performs two actions:

  1. it ends the current call; and
  2. it makes the return expression’s value become the value of the call expression.

That value can participate in later work:

assert distance_status(25) == "long route"

statuses = [distance_status(14), distance_status(25)]
message = f"First expedition: {distance_status(14)}"

assert statuses == ["short route", "long route"]
assert message == "First expedition: short route"

A function’s result is more reusable than one hard-coded display.

6. Printing is an effect; returning is data flow

This function displays a calculation but does not return it:

def show_total(distances):
    print(sum(distances))


received = show_total([4, 7, 3])
print("Caller received:", received)

The function prints 14, then the caller displays Caller received: None. print() sends a display representation to a human. Its own return value is None, and reaching the end of show_total also returns None implicitly.

A calculation function should normally return the number:

def calculate_total(distances):
    return sum(distances)


received = calculate_total([4, 7, 3])
assert received == 14

The caller chooses whether to display, compare, store, or format it:

print(f"Total: {received} km")

Command-style functions can intentionally return None when their contract is an effect. The problem is not None; the problem is promising useful data and then only printing it.

7. Falling off the end returns None

def find_first_long_route(routes):
    for route in routes:
        if route["distance"] >= 20:
            return route


routes = [{"name": "ridge", "distance": 14}]
result = find_first_long_route(routes)
assert result is None

No route matches. The loop finishes and the body ends without an explicit return, so the call produces None.

Make the boundary visible:

def find_first_long_route(routes):
    for route in routes:
        if route["distance"] >= 20:
            return route
    return None

The behavior is the same, but the explicit line communicates that no match is an expected result rather than an accidental omission.

Checkpoint: return values and None

8. return stops the current call immediately

def boarding_message(has_ticket, gate_open):
    if not has_ticket:
        return "ticket required"
    if not gate_open:
        return "gate closed"
    return "welcome aboard"


assert boarding_message(False, True) == "ticket required"
assert boarding_message(True, False) == "gate closed"
assert boarding_message(True, True) == "welcome aboard"

The first satisfied guard returns immediately. Later conditions are not evaluated in that call. This flat structure can be clearer than nesting every successful step.

Statements after an unconditional return in the same path are unreachable:

def doubled(number):
    return number * 2
    print("This line cannot run")

Python accepts the syntax, but execution leaves before the print(). Remove unreachable code rather than trusting indentation to make it run.

9. Every supported path should honor one result contract

This function mixes a string with an accidental None:

def temperature_label(value):
    if value < 0:
        return "freezing"
    if value > 30:
        return "hot"


assert temperature_label(10) is None

If the contract promises a label for every numeric temperature, add the missing middle result:

def temperature_label(value):
    if value < 0:
        return "freezing"
    if value > 30:
        return "hot"
    return "mild"


assert temperature_label(-1) == "freezing"
assert temperature_label(10) == "mild"
assert temperature_label(31) == "hot"

A deliberate sentinel may still be correct when the domain defines “no result.” Consistency means every path matches the stated contract, not that every function must return a string.

11. Call expressions evaluate from the inside out

def add_tax(price):
    return price * 1.08


def format_price(price):
    return f"${price:.2f}"


label = format_price(add_tax(10))
assert label == "$10.80"

Python must know the argument for format_price, so it first calls add_tax(10). That returns 10.8; then format_price(10.8) runs. Expanded names expose the same order:

taxed_price = add_tax(10)
label = format_price(taxed_price)

Prefer intermediate names when the values matter during explanation or debugging.

Checkpoint: paths and call order

12. Build an expedition summary

Complete the contract before opening support:

def summarize_route(distances):
    """Return total distance, stop count, and route status."""
    pass


source_route = [4, 7, 3]
summary = summarize_route(source_route)

The function must:

  1. accept one collection of non-negative numeric distances;
  2. return a three-item tuple (total, count, status);
  3. return status "no route" for empty input;
  4. otherwise return "short route" below 20 total and "long route" at least 20;
  5. use early return for the empty case;
  6. not print or mutate the source; and
  7. leave no supported path with an implicit result.

Run:

assert summary == (14, 3, "short route")
assert summarize_route([]) == (0, 0, "no route")
assert summarize_route([20]) == (20, 1, "long route")
assert summarize_route([0]) == (0, 1, "short route")
assert source_route == [4, 7, 3]

Then use the returned tuple to build this display outside the function:

3 stops | 14 km | short route
Hint: decide the complete result at each return point

Handle empty input first with one explicit tuple. For non-empty input, calculate total and count, choose status, and return all three values together. Printing belongs to the caller.

Show one complete solution after attempting the lab
def summarize_route(distances):
    """Return total distance, stop count, and route status."""
    if not distances:
        return 0, 0, "no route"

    total = sum(distances)
    count = len(distances)
    if total >= 20:
        status = "long route"
    else:
        status = "short route"
    return total, count, status


source_route = [4, 7, 3]
summary = summarize_route(source_route)
total, count, status = summary
display = f"{count} stops | {total} km | {status}"

The caller owns presentation, so the returned numbers remain useful for later comparisons or calculations.

13. Explain the call boundary

  1. What does Python do at the definition, and what waits until a call?
  2. Why is printed output not the return value received by the caller?
  3. When is implicit None deliberate, and when does it reveal a missing path?
  4. Why can no statement after an executed return affect that call?
  5. In a nested call, why must the inner result exist before the outer body begins?

Key points

TipKey points
  • Executing def creates and binds a function; calling the function runs its body.
  • Parameters are local input names, while arguments are values evaluated by the caller.
  • return ends one call and supplies the call expression’s value.
  • Printing is an observable effect, not a substitute for returning reusable data.
  • Reaching the end returns None; make expected no-result behavior explicit.
  • Every supported path should honor one stated result contract.
  • Small tuples can return several closely related results; caller code can unpack and present them.

References

Back to top