You already have the three tools: you know how to define and call functions (04-01), you know how to give them parameters and make them return values (04-02), and you know what they should depend on in order to be self-contained pieces (04-03). What is missing is the judgement: faced with a hundred-line program written in one go, where do you cut? How many functions? Of what size? What goes inside each one?
That question has no mechanical answer, but it does have solid, checkable criteria, and this lesson goes through them one by one. At the end we will apply them to the case we have been dragging along since module 3: turning the monolithic wall of easytask.py v0.6 into version 0.7, a program organised into functions with a main() you can read in twenty seconds. What we are about to do has a name of its own: refactoring, that is, reorganising the code without changing what it does. When we finish, EasyTask will behave exactly the same as far as the user is concerned; what changes is everything else — how easy it is to understand, fix and extend.
Contents
- One function, one responsibility
- Size and level of abstraction
- Top-down design: write the
main()first - The call tree
- Separating input/output from logic
- Names that describe the effect
- DRY and the opposite excess
- The
if __name__ == "__main__":block - EasyTask v0.7: the complete refactor
- Common mistakes and tips
- Exercises
- Conclusion
- One function, one responsibility
The main criterion is this: a function should do one single thing and do all of it. The practical test is to describe it in a short sentence, with no conjunctions. If you need an "and", you have two functions.
| Description of the function | Verdict |
|---|---|
| "Asks for an integer between two limits" | One responsibility |
| "Paints the card of a task" | One responsibility |
| "Asks for the data and saves it and warns on screen" | Three responsibilities |
| "Validates the title and updates the state" | Two responsibilities |
The advantage of respecting it is not aesthetic, it is economic. A function with a single responsibility has a single reason to change. The day Alba Studio decides to accept days with decimals, you touch ask_integer and nothing else. If that same function also painted the card, changing the card's format would force you to touch validation code, with the risk of breaking it without noticing.
- Size and level of abstraction
From the previous rule a typical size follows: between three and twenty lines. It is not a law, it is a consequence: a function that takes up sixty lines is nearly always doing several things. And there is a very reliable sign that it has gone too far: if to explain it to somebody you have to say "first it does this, then this other thing, and at the end that one", those three steps want to be three functions. The second criterion, subtler but very powerful, is a uniform level of abstraction: within one function, every line should tell the story at the same height. Compare these two versions of the same thing:
def process_task(): # MIXED levels
title, assignee, priority, days = register_task() # high level
print("=" * 46) # low level
print(f"{'Title':<14}{title:>32}") # low level
print("-" * 46) # low level
def process_task(): # UNIFORM level
title, assignee, priority, days = register_task()
show_card(title, assignee, priority, days, False)
pause()The second reads like a summary of what happens; the first forces you to read formatting code to find out the plot. The mnemonic rule: a function should read like a table of contents, not like a novel.
- Top-down design: write the
main() first
main() firstTop-down design consists of writing the main program first as if the functions you need already existed, and only then implementing them. It is counter-intuitive and works surprisingly well, because it forces you to decide what you need before getting lost in the how:
def main():
"""Main loop of the application."""
while True:
show_menu()
option = ask_option("Choose an option (1-5): ", OPTIONS)
if option == "1":
register_task()
elif option == "5":
break
pause()None of those functions exists yet, and yet you have already taken the important decisions: how many pieces there are, what they are called and what each one receives. That sketch is at once a work plan and a to-do list. The practical trick for not getting stuck is to fill the gaps with empty functions containing only a pass or a provisional print. That way the program runs from the very first minute and you can replace those skeletons with real implementations one at a time, checking after each step. It is far better than writing the whole thing and running it for the first time at the end, when any error could be anywhere.
- The call tree
Breaking things down produces a layered structure: main() calls action functions and these call helper functions. Drawing it helps to spot imbalances:
flowchart TD
M["main()"] --> MM["show_menu() and pause()"]
M --> PO["ask_option()"]
M --> RT["register_task()"]
M --> MF["show_card()"]
M --> CP["change_priority()"]
M --> MC["mark_completed()"]
RT --> PT["ask_text()"]
RT --> PO
RT --> PE["ask_integer()"]
CP --> PO
MF --> CU["classify_urgency()"]
MC --> CF["confirm()"]
Three things this tree tells you at a glance. First, that there are two clear levels: actions at the top and utilities at the bottom. Second, that ask_option is used by three different functions: it is the most reused piece in the program, and that confirms it was worth extracting. And third, that no branch goes deeper than three levels, a sign that the program is not over-fragmented.
- Separating input/output from logic
This is the rule that improves a program most per line of effort: functions that compute do not print and do not ask. The work is shared out among three families:
| Family | What it does | Examples in EasyTask |
|---|---|---|
| Input | Asks the user and validates | ask_text, ask_integer, confirm |
| Logic | Computes and decides, in silence | classify_urgency |
| Output | Presents results | show_menu, show_card |
Why does it matter so much? Because a pure logic function is checkable: you give it some inputs, look at the returned value and know whether it is right, without typing anything or looking at the screen. classify_urgency("high", 2) must return "CRITICAL", and that can be verified a thousand times in a second; if that function printed instead of returning, the only way to check it would be to run the application and read with your eyes. This idea is the basis of automated testing. There is also a more immediate benefit: if tomorrow EasyTask acquires a graphical interface or starts producing a report in a file, all the logic is saved and only the input and output functions change.
- Names that describe the effect
A function's name is a contract with whoever reads it, and there are four signals that are easy to apply. The first is prefixes: show_* prints and returns nothing useful, get_* or calculate_* return and do not print, ask_* questions the user; sticking to them means the reader guesses right without opening the function. The second: names with "and" give away two responsibilities, so validate_and_save() is not a long name but a diagnosis — split it into validate() and save(). The third: avoid generic names such as process, manage or data, because if you cannot find a concrete name it is usually because the function has no concrete purpose. And the fourth: the name must warn about the effects; a function called calculate_total that also resets a counter is lying, and whoever uses it will get a nasty surprise.
- DRY and the opposite excess
DRY — Don't Repeat Yourself — is the principle stating that every piece of knowledge must live in one single place in the program. It is what we have been applying since 04-01. The practical way to apply it is to ask yourself, faced with two similar chunks, what makes them different. If the difference is a value — a message, a range, a catalogue — that value wants to be a parameter and the two chunks want to be one function; if the difference is structural, perhaps they are not the same thing and forcing them together makes both of them worse. Because the opposite excess also exists, over-fragmentation: splitting things into tiny functions that contribute nothing, such as a def add_one(n): return n + 1 or a def is_zero(n): return n == 0. These functions make the program longer and force the reader to jump from place to place only to discover they did the obvious. A function deserves to exist if at least one of these three conditions holds: it is used more than once, its name explains something the code does not say by itself, or it hides a complexity that would clutter the caller. classify_urgency meets the second; ask_integer meets all three; add_one meets none.
- The
if __name__ == "__main__": block
if __name__ == "__main__": blockYou will have seen these two lines at the end of almost any Python program, after all the definitions:
__name__ is a variable Python creates automatically in every file. When the file is run directly (python easytask.py), its __name__ holds "__main__" and the condition is true, so main() starts. When the file is imported from another one to make use of its functions, __name__ holds instead the name of the file ("easytask"), the condition is false and the application does not launch itself. That is what you want: to be able to reuse classify_urgency from another program without the menu popping up on you. The complete theory of importing is Modules, packages and imports; for now adopt the form, which is always the same: define main(), put it at the end alongside the other definitions, and close the file with that two-line block.
- EasyTask v0.7: the complete refactor
Let us recall the starting point. v0.6 was a single block of almost a hundred consecutive lines, of this shape:
# v0.6 (abridged): everything in one single block
has_task = False # and five more state variables
while True:
print("\n" + "=" * WIDTH) # 8 consecutive lines of menu
option = input("Choose an option (1-5): ").strip()
while option not in OPTIONS: # validation of the option
option = input("Invalid option. Choose 1-5: ").strip()
if option == "1":
... # 15 lines: confirmation + FOUR validations
elif option == "2":
if not has_task: # repeated check (1 of 3)
...
elif option == "3":
if not has_task: # repeated (2 of 3) + duplicated validation
...
elif option == "4":
if not has_task: # repeated check (3 of 3)
...
input("\nPress Enter to return to the menu...")And here is the complete v0.7, with the functions grouped by families:
# easytask.py - Alba Studio
# Version 0.7: program broken down into functions
WIDTH = 46
PRIORITIES = ("high", "medium", "low")
TEAM = ("marta", "luis", "nuria")
OPTIONS = ("1", "2", "3", "4", "5")
# --- Input: they ask and validate ---
def ask_text(message, required=True):
"""Ask for a text and return it without leftover spaces."""
value = input(message).strip()
while required and value == "":
value = input("It cannot be empty. " + message).strip()
return value
def ask_option(message, options):
"""Ask for a value until it is one of the accepted options."""
value = input(message).strip().lower()
while value not in options:
value = input(f"Not valid. Allowed: {', '.join(options)}. ").strip().lower()
return value
def ask_integer(message, minimum, maximum):
"""Ask for an integer in the given range and return it converted."""
text = input(message).strip()
while not text.isdigit() or not minimum <= int(text) <= maximum:
text = input(f"Integer between {minimum} and {maximum}. ").strip()
return int(text)
def confirm(message):
"""Return True only if the user answers 'y'."""
return input(f"{message} (y/n): ").strip().lower() == "y"
# --- Logic: it computes, it does not print ---
def classify_urgency(priority, days):
"""Return the urgency label that corresponds to the task."""
if priority == "high" and days <= 2:
return "CRITICAL"
if priority == "high":
return "Urgent"
if priority == "medium" and days <= 3:
return "Attention"
return "Normal"
# --- Output: they print, they do not compute ---
def show_menu():
"""Paint the header and the five available options."""
print("\n" + "=" * WIDTH)
print(f"{'EasyTask v0.7 - Alba Studio':^{WIDTH}}")
print("=" * WIDTH)
print(" 1. Register / replace the task")
print(" 2. View the task card")
print(" 3. Change the priority")
print(" 4. Mark as completed")
print(" 5. Quit")
print("-" * WIDTH)
def show_card(title, assignee, priority, days, completed):
"""Paint the full card of the given task."""
status = "Completed" if completed else "Pending"
print("-" * WIDTH)
print(f"{'Title':<14}{title:>{WIDTH - 14}}")
print(f"{'Assignee':<14}{f'{assignee} ({priority})':>{WIDTH - 14}}")
print(f"{'Days / status':<14}{f'{days} / {status}':>{WIDTH - 14}}")
print(f"{'Urgency':<14}{classify_urgency(priority, days):>{WIDTH - 14}}")
def pause():
"""Halt execution until the user presses Enter."""
input("\nPress Enter to return to the menu...")
# --- Menu actions ---
def register_task():
"""Ask for the data of a task and return the four fields."""
title = ask_text("Title : ")
assignee = ask_option("Assignee : ", TEAM).capitalize()
priority = ask_option("Priority : ", PRIORITIES)
days = ask_integer("Days (1-365) : ", 1, 365)
return title, assignee, priority, days
def change_priority(title, priority):
"""Ask for a new priority and return the one now in force."""
new_priority = ask_option("New priority : ", PRIORITIES)
print(f"Priority of '{title}': {priority} -> {new_priority}")
return new_priority
def mark_completed(title, completed):
"""Return the completed status after asking the user."""
if completed:
print("The task was already completed.")
return True
if confirm(f"Mark '{title}' as completed?"):
print("Task completed. Good work.")
return True
print("Operation cancelled.")
return False
def main():
"""Run the main loop of the application."""
has_task = False
title = ""
assignee = ""
priority = ""
days = 0
completed = False
while True:
show_menu()
option = ask_option("Choose an option (1-5): ", OPTIONS)
if option == "5":
if confirm("Are you sure you want to quit?"):
print("See you later. EasyTask is closing.")
break
elif option == "1":
if has_task and not confirm(f"'{title}' already exists. Replace it?"):
print("Operation cancelled.")
else:
title, assignee, priority, days = register_task()
completed = False
has_task = True
print("Task registered successfully.")
elif not has_task:
print("There is no task registered yet.")
elif option == "2":
show_card(title, assignee, priority, days, completed)
elif option == "3":
priority = change_priority(title, priority)
elif option == "4":
completed = mark_completed(title, completed)
pause()
if __name__ == "__main__":
main()What has changed, point by point:
- The main loop fits in twenty lines and reads like a decision table. There is no longer a single
inputor a formattingprintinside it: only calls to named functions. if not has_task:was written three times; now it is written once. The trick is theelif not has_task:placed after options 5 and 1 — the only two that work without a registered task — and before options 2, 3 and 4. If there is no task, theelifchain stops there and none of the three runs.- The four duplicated validations are now three functions, and
ask_optionserves for the menu, for the assignee and for the priority.confirm(message)also unifies the three confirmations and locks into a single place the decision that only"y"confirms. register_task()returns four values that are unpacked in one go (04-02), andchange_priorityandmark_completedreturn the new value instead of modifying it on their own account: no function touches state that does not belong to it, as 04-03 demands.
| v0.6 | v0.7 | |
|---|---|---|
| Total lines | ~95 | ~150 |
| Lines in the main loop | ~75 | 22 |
| Longest function | 75 (the whole loop) | 10 (show_menu) |
| Validation loops written | 5 | 3 (in functions) |
not has_task checks |
3 | 1 |
| Functions reusable outside the program | 0 | 8 |
Yes: the file has more lines than before. That is normal in a refactor and it is not a bad bargain, because what is measured is not how much space the program takes up but how much you have to read to understand one part of it: before, to know what option 3 did, you had to read the seventy-five lines of the loop; now you read four. There is, mind you, one loose end left, and it is a big one. Look at the header of show_card: five parameters that are always the same five, travelling together from function to function. And look at the beginning of main(): six loose variables declared one after another. Those six variables are one single thing — a task — that still has no way of being one single thing in our code. When you meet tuples and nested structures you will be able to group them, and when you reach classes you will be able to give them a name of their own and behaviour. Until then, they travel hand in hand.
Common Mistakes and Tips
Extracting functions that still depend on globals. Moving ten lines into a def is not breaking down if those lines carry on reading and writing variables of the main program. Check every function with the cut-out test from 04-03. And functions that do two things usually give themselves away in the name (validate_and_save) or when you describe them: split them, even if each half ends up very short.
Mixing levels of abstraction. A main() with a print("=" * 46) in the middle of high-level calls: that print belongs in show_menu. The opposite defect is over-fragmenting: twenty two-line functions used only once are as hard to read as a block of two hundred. Refactoring and adding functionality at the same time. It is the recipe for not knowing what broke what. First reorganise leaving the behaviour identical, check that everything still works the same, and only then add the new thing. And do not forget the if __name__ == "__main__":, nor leave loose code at the left margin between the definitions: it will run the moment somebody imports the file.
Tip: refactor in small steps and run after each one. Extract a function, run, check; if something fails, you know exactly which step caused it. And write the main() the way you would like it to be: if reading it lets you understand the program without opening any other function, the breakdown is a good one, and into the bargain you have the best documentation your program will ever have.
Exercises
Exercise 1: Break down a script
This program works but is written in one go. Identify its responsibilities, propose the names of the functions you would split it into — stating the parameters and what each one returns — and write the resulting main().
name = input("Client: ").strip()
while name == "":
name = input("It cannot be empty. Client: ").strip()
hours = input("Hours: ").strip()
while not hours.isdigit() or int(hours) < 1:
hours = input("Integer greater than 0. Hours: ").strip()
hours = int(hours)
amount = hours * 55.0
if hours > 40:
amount = amount * 0.9 # 10% volume discount
print("=" * 40)
print(f"Client: {name}")
print(f"Amount: {amount:.2f} EUR")Exercise 2: Diagnose a breakdown
A colleague has split their program into these three functions. Point out four design defects, each with the criterion from the lesson that it breaks, and propose an alternative.
def process(): # declares 'global total'
data = input("Value: ") # asks the user for a value
total = total + int(data) # updates the global
print(f"Total: {total}") # prints it
return total # and returns it as well
def add_two(a, b):
return a + b
def validate_and_show_client(name): # if empty it warns; otherwise it prints it
...Solutions
Solution 1. There are four responsibilities: ask for the validated name, ask for the validated hours, compute the amount with its discount and present the receipt. The discount logic must be kept apart from the input and the output.
RATE = 55.0
WIDTH = 40
def ask_text(message):
"""Ask for a compulsory text and return it cleaned up."""
value = input(message).strip()
while value == "":
value = input("It cannot be empty. " + message).strip()
return value
def ask_positive_integer(message):
"""Ask for an integer greater than zero and return it converted."""
text = input(message).strip()
while not text.isdigit() or int(text) < 1:
text = input("Integer greater than 0. " + message).strip()
return int(text)
def calculate_amount(hours, rate=RATE):
"""Return the amount, with a 10% discount above 40 hours."""
amount = hours * rate
if hours > 40:
return amount * 0.9
return amount
def show_receipt(name, amount):
"""Paint the framed receipt for the client."""
print("=" * WIDTH)
print(f"Client: {name}")
print(f"Amount: {amount:.2f} EUR")
def main():
"""Ask for the job details and show the receipt."""
name = ask_text("Client: ")
hours = ask_positive_integer("Hours: ")
show_receipt(name, calculate_amount(hours))
if __name__ == "__main__":
main()Notice that calculate_amount neither prints nor asks: you can check by hand that calculate_amount(40) gives 2200.0 and calculate_amount(41) gives 2029.5, without running the program. And that the main() has three lines that read like the statement of the problem.
Solution 2.
| Defect | Criterion broken | Alternative |
|---|---|---|
process uses global total |
A function must not depend on changing global state (04-03) | def accumulate(total, value): return total + value |
process asks, computes, prints and returns |
One function, one responsibility | Split into ask_integer, accumulate and show_total |
add_two is over-fragmentation |
Not reused, clarifies nothing, hides no complexity | Write a + b wherever it is needed |
validate_and_show_client has "and" in the name |
Two responsibilities: validating and presenting | def is_valid_name(name): return name != "" plus show_client(name) |
Besides, the name process is generic and gives no warning that it modifies the global state. An honest name for what it does would be ask_value_and_accumulate_it_into_total: its very ugliness proves the function is badly split.
Conclusion
Breaking a program down is not chopping it into pieces: it is finding its parts. The criteria are few and checkable: one function, one responsibility, verifiable with the test of describing it without using "and"; a size that rarely goes beyond twenty lines; a uniform level of abstraction within each function; input, logic and output in separate families, because silent logic is the only kind that can really be checked; names that announce the effect; DRY so as not to repeat yourself, and common sense so as not to over-fragment. Top-down design — writing the main() as if the functions already existed — is the most comfortable way to apply all of that, and the if __name__ == "__main__": block closes the file so it can be either run or imported.
EasyTask has reached version 0.7. It does exactly the same as v0.6 — not one new feature as far as Marta is concerned — but its main loop has gone from seventy-five lines to twenty-two, the duplication has disappeared and eight of its functions can be reused as they are in another program. The task's state, mind you, still lives in six loose variables that have to be passed from function to function: that is the debt modules 5 and 7 will come to collect. One last surprise remains before we close the module. So far functions have been things you call; but in 04-01 you saw in passing that pause without parentheses is a value with its own type(). If a function is a value, it can be stored in a variable, passed as an argument to another function and returned from another. That opens up a different way of programming, and it is the module's closing piece: Functions as values: lambda and higher order.
Fundamentals of Programming
Module 1: Introduction to Programming
- What is programming?
- History of programming
- Programming languages
- Development environments
- From problem to algorithm
Module 2: Core Concepts
- Variables and data types
- Operators and expressions
- Input and output
- Type conversion and data validation
Module 3: Control Structures
Module 4: Functions and Procedures
- Defining and using functions
- Parameters and return values
- Variable scope
- Breaking a program down into functions
- Functions as values: lambda and higher order
Module 5: Data Structures
- Lists and arrays
- Strings
- Dictionaries and sets
- Tuples and nested structures
- Saving data to files: text, CSV and JSON
Module 6: Basic Algorithms
Module 7: Objects and Code Organisation
- From data to objects: classes and instances
- Attributes, methods and the constructor
- Collections of objects
- Modules, packages and imports
Module 8: Good Practices and Tools
- Documentation and comments
- Debugging and error handling
- Version control
- Automated testing
- Style, readability and refactoring
