Classroom Glossary Public page

Lab 3: Functional Refactor

887 words

~90 minutes. 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 that read like an outline.


Goal: rewrite Lab 2's lab-2-guess.py so that the same behavior is implemented as a main() function calling three smaller named functions. Each function has a one-line docstring.

Estimated time: 90 minutes

Prerequisites: Lab 2 complete and committed. Week 3 lecture (functions, scope, docstrings).


Setup

mkdir -p ~/fnd-102/lab-3
cd ~/fnd-102/lab-3
cp ../lab-2/lab-2-guess.py lab-3-guess.py

Open lab-3-guess.py.


Part A: Identify the three functions (15 min)

Read your Lab 2 code. Notice that it does three distinct things:

  1. Setup. Pick a random secret number; print the welcome message; initialize the try counter.
  2. The guess loop. Repeatedly ask the player for a guess; compare to the secret; respond; track tries.
  3. The summary. After the loop, decide whether the player won or lost; print the result; choose the exit code.

Each of these is a function candidate. Sketch the three functions on paper before writing code:

  • pick_secret_number() -> int
  • play_guess_loop(secret, max_tries) -> bool (True if the player won, False if they lost)
  • summarize_and_exit(won, secret, max_tries) -> exits the program

The main() function then orchestrates:

def main():
    print('Welcome to guess-the-number!')
    secret = pick_secret_number()
    won = play_guess_loop(secret, max_tries=7)
    summarize_and_exit(won, secret, max_tries=7)

if __name__ == '__main__':
    main()

The if __name__ == '__main__': idiom is new. It means "run main() when this file is executed directly, but not when it is imported as a module." You will use this pattern in every CLI tool from now on. The official Python docs explain the mechanism at https://docs.python.org/3/library/__main__.html.


Part B: Write the three functions (45 min)

For each function, write the signature, the docstring, and the body. Use this structure:

def pick_secret_number():
    """Return a random integer from 1 through 100 inclusive."""
    return random.randint(1, 100)


def play_guess_loop(secret, max_tries):
    """Play the guess loop. Return True if the player guessed correctly within max_tries, False otherwise."""
    tries_left = max_tries
    while tries_left > 0:
        try:
            guess = int(input(f'Your guess ({tries_left} tries left): '))
        except ValueError:
            print('Please type a number.')
            continue
        if guess < 1 or guess > 100:
            print('Out of range; try 1-100.')
            continue
        if guess == secret:
            return True
        if guess < secret:
            print('Too low.')
        else:
            print('Too high.')
        tries_left -= 1
    return False


def summarize_and_exit(won, secret, max_tries):
    """Print the win or lose summary and exit with status 0 (win) or 1 (loss)."""
    if won:
        print('You got it!')
        sys.exit(0)
    else:
        print(f'You ran out of tries. The number was {secret}.')
        sys.exit(1)

Notice three things:

  1. Each function has ONE job. pick_secret_number picks a number. It does not print. It does not loop. If you find yourself writing a function that does several unrelated things, split it.
  2. Docstrings are imperative voice. "Return..." not "Returns...". "Play..." not "Plays...". PEP 257 codifies this; the consistency makes a codebase scan faster.
  3. Return values are explicit. play_guess_loop returns True or False; pick_secret_number returns an int. The function's signature + docstring tell you everything you need to know to call it without reading the body.

Part C: The main() function (10 min)

Write main() as a six-line orchestrator:

def main():
    print('Welcome to guess-the-number!')
    max_tries = 7
    secret = pick_secret_number()
    won = play_guess_loop(secret, max_tries)
    summarize_and_exit(won, secret, max_tries)


if __name__ == '__main__':
    main()

The main() function reads like an outline of what the program does. A reader who lands on this file for the first time can understand the game's structure in 10 seconds without reading the function bodies.


Part D: Test by reading help() (10 min)

In a Python REPL from your ~/fnd-102/lab-3 directory:

>>> import lab_3_guess
# Note: hyphens are not allowed in Python module names; rename the file to lab_3_guess.py first
# (or just open the REPL inside the file's directory and use `from lab_3_guess import ...`)
>>> help(lab_3_guess.pick_secret_number)
>>> help(lab_3_guess.play_guess_loop)
>>> help(lab_3_guess.summarize_and_exit)

Each should print the function's docstring as the help output. If help() prints nothing useful, your docstring is missing or in the wrong place (it must be the first statement in the function body).


Part E: Commit your work (10 min)

cd ~/fnd-102/lab-3
git add lab-3-guess.py  # or lab_3_guess.py if you renamed
git commit -m "lab-3: refactor guess-the-number into pick / play / summarize functions with docstrings"

Optionally, make a second commit that improves a docstring or extracts one more helper. The Lab 10 PR submission expects multi-commit history; practicing here costs nothing.


Expected output / artifact

lab-3-guess.py (or lab_3_guess.py) should:

  • Behave identically to Lab 2 (same game, same exit codes, same UX polish)
  • Have three named functions: pick_secret_number, play_guess_loop, summarize_and_exit
  • Have a main() function of ~6 lines
  • Have the if __name__ == '__main__': block at the bottom
  • Every function has a one-line imperative-voice docstring
  • help(function_name) in the REPL shows the docstring for each

The file is committed to your Git repo at ~/fnd-102/lab-3/lab-3-guess.py.


What's the failure mode?

This refactor's likely failure modes:

  1. You break the game while refactoring. The most common refactoring bug: the new code does not do what the old code did. Always run the new version through one full win and one full loss before committing. If the game now lets you guess forever, you broke the try-counter wiring during the move into play_guess_loop.
  2. You accidentally make a function do two things. pick_secret_number() that also prints "Welcome to guess-the-number!" is a function with two jobs. Welcome belongs in main(). If you find yourself naming a function with and in the name (pick_secret_and_print_welcome), split it.
  3. You over-extract. A function print_too_high() that contains only print('Too high.') is a function called from one place that adds nothing. Inline calls of one line do not need extraction.

Common pitfalls

  • Forgetting if __name__ == '__main__':. Without it, your main() runs as a side effect of importing the module. The result: anyone who imports your file (the test suite in week 13 will) accidentally starts a game.
  • Mutable state in a function. If play_guess_loop modifies a global tries_left, you have leaked state out of the function. Use parameters and return values; do not touch globals.
  • return inside if/elif/else that has no else. A function with if x > 0: return ... but no fallback returns None for the missing case. Either add an else or make sure the function genuinely should return None there.
  • Naming the file with hyphens. lab-3-guess.py is fine to RUN (python3 lab-3-guess.py) but cannot be IMPORTED (import lab-3-guess is a syntax error; hyphens become minus signs). Use underscores for module names: lab_3_guess.py. This rule applies to every Python file you may want to import. Rename Lab 1 + Lab 2 retroactively if you plan to test them in week 13.

Stretch (optional)

  1. Add a fourth function: read_max_tries_from_args() that returns the max-tries value parsed from the command line (default 7). Use sys.argv for now; week 6 introduces argparse and you will rewrite this then.

  2. Add an optional seed parameter to pick_secret_number(seed=None) that, if provided, calls random.seed(seed) before generating. This makes the function testable: with seed=42, the same number always comes back. Forward-pointer to week 13's pytest.

  3. Extract one more function from the body of play_guess_loop. Candidates: prompt_for_guess(tries_left) -> int | None (returns the parsed guess or None on invalid input); compare_guess_to_secret(guess, secret) -> str (returns "too_high", "too_low", or "correct"). Both improve testability; both also risk over-extraction. Pick one and try it. Notice the trade-off.


Lab 3 v0.1.