Classroom Glossary Public page

Week 3: Functions, Scope, Docstrings

1,723 words

The first organizing tool. You learn to write named functions with parameters, return values, and docstrings. The lab refactors your Lab 2 guess-the-number game so the same code is split into three named functions and the game itself reads like an outline.


Theme

A program of more than ~50 lines that is one long script becomes unreadable. Not all at once; gradually, as you add features. The way out is to break the program into named pieces called functions. A function is a chunk of code with a name, a defined input (parameters), and a defined output (return value). Once you have functions, the program's "main" code reads like a recipe: do this thing; then do this thing; then this.

This week's lecture is small in syntax (one new keyword: def) and large in habit (when to extract a function; how to name it; what a docstring should say). The lab takes Lab 2's guess-the-number game and refactors it: the random-number pick becomes a function, the guess loop becomes a function, the win/lose summary becomes a function, and the script's main code is six lines that call those three functions in sequence.

By the end of week 3 you can: write a function definition with def, distinguish parameters from arguments, distinguish return values from side effects, write a one-line docstring that explains what a function does without reading its body, and recognize when a function is "too big" (a heuristic: ~20 lines is the upper limit before extraction helps).

You will also encounter the scope rules. A variable defined inside a function is local to that function; the same name outside is a different variable. This is the source of the most common "but it worked when I tested it!" surprise in week 3.

Reading list (~1 hour)

  1. Matthes, Python Crash Course 2nd ed., Ch 8 ("Functions"). Matthes' chapter is the most beginner-friendly walkthrough of def, parameters, return values, and the difference between positional and keyword arguments.
  2. Sweigart, Automate the Boring Stuff with Python 2nd ed., Ch 3 ("Functions") at https://automatetheboringstuff.com/2e/chapter3/. Free online. Sweigart's section on local vs global scope ("The Local and Global Scopes") is the clearest in print.
  3. Allen B. Downey, Think Python 2nd ed., Ch 3 ("Functions") at https://greenteapress.com/thinkpython2/html/thinkpython2004.html. Free online. Downey's framing of "fruitful" (returns a value) vs "void" (returns None) functions, in his §6.1, is a useful distinction the FND-102 lecture borrows.
  4. PEP 257 (Docstring Conventions) at https://peps.python.org/pep-0257/. Short read (~10 min). The "what should a docstring say" answer. FND-102 uses one-line docstrings for short functions; multi-line for anything with non-obvious arguments.

Lecture outline (~1.5 hours, 2 sessions of ~50 min)

Session 1: Defining and calling functions

Section 1.1: The shape of a function

  • def introduces a function definition:
    def greet(name):
        print(f'hello, {name}')
    
  • The line def greet(name): is the signature. greet is the function name; name is the parameter.
  • The indented block below is the body.
  • The function is defined but not called until you invoke it: greet('jamie').
  • Definitions create the function object; they do not run the body. The body runs each time the function is called.

Section 1.2: Parameters vs arguments

  • A parameter is the name in the function definition (name in def greet(name):).
  • An argument is the value you pass in when you call ('jamie' in greet('jamie')).
  • Multiple parameters are comma-separated: def add(a, b): return a + b.
  • Call with positional arguments: add(2, 3). Or keyword arguments: add(a=2, b=3). Or mix (positional must come first): add(2, b=3).
  • Default values let you make arguments optional: def greet(name, greeting='hello'): print(f'{greeting}, {name}'). Now greet('jamie') works; greet('jamie', 'hi') also works.
  • A famous gotcha: do NOT use a mutable default value. def f(x=[]): x.append(1); return x creates a list ONCE at function-definition time; every call mutates the same list. Use def f(x=None): x = x or [] instead.

Section 1.3: Return values

  • return ends the function and hands a value back to the caller:
    def square(n):
        return n * n
    
  • A function with no return (or with return and no value) returns None. print returns None; that is why result = print('hi') makes result be None.
  • A function can have multiple return statements: the first one reached wins.
    def absolute(n):
        if n < 0:
            return -n
        return n
    
  • "Return early" vs "single exit point": both are legal styles. FND-102 prefers early-return because it reduces nesting; some style guides prefer single-exit. Pick a style per project and stay consistent.

Section 1.4: Side effects

  • A function that returns a value but does not modify the outside world is pure. square(n) is pure.
  • A function that modifies the outside world is impure; the modification is a side effect. print(...) has a side effect (output to the terminal). Functions that write files, mutate global state, or modify their arguments all have side effects.
  • Side effects are not bad; they are how programs interact with the world. The discipline is to notice them and document them. A function called compute_total that secretly writes a file is a future debugging headache.

Session 2: Scope and docstrings

Section 2.1: Local scope

  • Variables defined inside a function are local to that function. They do not exist outside.
    def f():
        x = 5
        print(x)  # prints 5
    
    f()
    print(x)  # NameError: x is not defined
    
  • This is intentional and good. Local scope means functions cannot accidentally mess with each other's variables.
  • A function can read variables defined outside (in the enclosing module). It cannot assign to them without the global keyword (which you should rarely use; see below).

Section 2.2: Reading vs writing

  • Reading an enclosing variable:
    greeting = 'hello'
    
    def greet(name):
        print(f'{greeting}, {name}')  # reads `greeting` from outer scope; this works
    
    greet('jamie')  # prints "hello, jamie"
    
  • Writing to an enclosing variable (without global):
    count = 0
    
    def increment():
        count += 1  # UnboundLocalError: `count` is treated as local because of the assignment
    
    increment()  # crashes
    
  • The fix: def increment(): global count; count += 1. This is technically correct but a code smell. Better: pass count as a parameter and return the new value.
  • The mental rule: "If a function reads a value, prefer to pass it as a parameter. If a function changes a value, prefer to return the new one."

Section 2.3: Docstrings

  • The first string literal in a function body is a docstring. It is part of the function (accessible via function.__doc__ and via help(function)).
    def square(n):
        """Return the square of n."""
        return n * n
    
    help(square)  # prints the docstring
    
  • A docstring is different from a comment: a comment is for the reader of the source; a docstring is for the user of the function. help() displays docstrings; it does not display comments.
  • Convention (PEP 257):
    • One-line: imperative voice. """Return the absolute value of n.""" not """Returns the absolute value.""" or """This returns the absolute value."""
    • Multi-line: first line is a one-line summary; blank line; then more detail.
    def parse_log(path):
        """Parse a log file and return a list of (timestamp, level, message) tuples.
    
        Raises FileNotFoundError if `path` does not exist.
        Lines that do not match the expected format are silently skipped.
        """
    
  • A function without a docstring is acceptable for one-line helpers. Anything longer or anything called from multiple places should have one.

Section 2.4: When to extract a function

  • Three signals it is time to extract:
    1. You repeated yourself. The same 4 lines appear in two places; extract into a function called once from each place.
    2. You wrote a comment explaining what the next 10 lines do. That comment is the function's name and docstring.
    3. The current function is more than ~20 lines. Not a hard rule; a sign the function is doing more than one thing.
  • Counter-rule: do not extract just to extract. A function called from one place that is only 3 lines is sometimes harder to read than 3 inline lines.

Labs (~90 minutes)

Lab 3: Functional Refactor (labs/lab-3-functional-refactor.md)

  • Goal: take your Lab 2 guess-the-number game and refactor it into named functions with docstrings. The script's main code should be ~6 lines long.
  • Time: ~90 minutes
  • Artifact: lab-3-guess.py in ~/fnd-102/lab-3/, committed to Git

Independent practice (~4 hours)

  1. Function-extraction drill (45 min). Take any tutorial Python script you can find online (Real Python, GeeksforGeeks, Sweigart's free book). Read 50 lines of a script. Identify three opportunities to extract a function. Write down (no code yet) what the function would be called, what its parameters would be, and what it would return.

  2. Pure vs impure (30 min). Write a Python module containing exactly two functions:

    • read_temperature_csv(path): reads temps.csv and returns a list of floats
    • print_temperature_summary(temps): prints min, max, mean

    The first is impure (it reads a file). The second is also impure (it prints). Now refactor: write a third function compute_summary(temps) that returns a dict {'min': ..., 'max': ..., 'mean': ...} and is pure. The print function calls it. The point: pure functions are easier to test.

  3. Default arguments (30 min). Write a function greet(name, greeting='hello', punctuation='!') that returns the formatted string. Call it five ways: all defaults, override greeting, override punctuation, override both, all by keyword. Notice the readability difference between positional and keyword arguments.

  4. Scope exploration (30 min). In a .py file, write this and predict the output:

    x = 10
    
    def f():
        x = 20
        print(f'inside: x = {x}')
    
    def g():
        print(f'inside g: x = {x}')
    
    f()
    g()
    print(f'outside: x = {x}')
    

    Then change f to use global x and re-predict. Then change g to assign to x and observe the UnboundLocalError.

  5. Docstring practice (30 min). Take three of your Lab 3 functions and rewrite the docstring three ways: too short ("does the thing"); just right (one imperative sentence); too long (a 5-line essay). Submit the just-right version. Notice how different from "code comments" a docstring is.

  6. Read help() on a stdlib function (15 min). In the REPL: import os; help(os.path.join). Notice the docstring format. Notice the parameter names. This is what a good docstring looks like in the wild.

  7. Optional stretch (60 min). Write a "calculator" function that takes an operator string ('+', '-', '*', '/') and two numbers, returns the result. Handle division by zero (return None or raise an exception; pick one and document the choice in the docstring). Add a match statement (Python 3.10+) version and compare with the if/elif version.

Reflection prompts (~30 minutes)

  1. Before this week, would you have extracted a function for a 4-line repeated block? Why or why not? Did your view change?
  2. Your Lab 2 was probably 30-50 lines. Your Lab 3 refactor is the same logic in three functions plus six lines of main code. Which version do you find easier to read? Which would you find easier to modify in a month?
  3. A pure function (no side effects, only returns a value) is easier to test. Your pick_secret_number() from Lab 3 calls random.randint(...), which means it is not pure (it depends on the random state). What would you have to change to make it testable? (You will revisit this in week 13.)
  4. Docstrings are part of the function; comments are not. Did you write any comments in Lab 3 that could have been docstrings instead? Move them.
  5. One thing from this week you want to know more about?

Tool journal (week 3)

  • def: define a function
  • return: hand a value back to the caller
  • Parameters with default values (def f(x, y=0):)
  • Positional vs keyword arguments
  • Docstrings: """...""" as the first line of a function body
  • help(): print a function's docstring
  • function.__doc__: access the docstring programmatically
  • global keyword: for the rare case you must write to an outer-scope variable

What comes next

Week 4 introduces the standard collection types: lists, dictionaries, tuples, sets. Your Lab 3 functions probably pass simple values (one number, one string) around. Real programs pass structured data: a list of guesses, a dictionary of player scores, a set of unique words from a file. Week 4's lab is a class-roster tool that reads a CSV and groups students by grade; exactly that pattern.