The three functions we wrote in the previous lesson — show_header(), show_menu() and pause() — have an obvious limitation: they always do exactly the same thing. show_menu() paints that menu and no other; pause() shows that message and no other. They are useful, but rigid. And for that very reason they have not helped us with EasyTask's underlying problem: the four repeated validation loops, which resemble one another but are not identical — each has its own message, its own acceptable values and its own data type.
To extract that, the two missing pieces are needed. The first, telling the function things when you call it: those are the parameters, and they turn a function that does one thing into a function that does a whole family of things. The second, having the function hand you back a result: that is return, and it is what allows you to use the computed value anywhere, store it in a variable or pass it to another function.
With parameters and return, a function stops being a shortcut for typing less and becomes a building block: something with well-defined inputs and outputs that you can assemble with other blocks. This is the most important lesson in the module.
Contents
- Parameters and arguments: two words for two things
- Positional arguments and keyword arguments
- Default values
return: hand back a value and finish- Several
returns and the earlyreturn - Returning more than one value
- Print inside or return?
*argsand**kwargs, just to recognise them- EasyTask: the validation functions
- Common mistakes and tips
- Exercises
- Conclusion
- Parameters and arguments: two words for two things
A parameter is the name that appears between the parentheses of the definition; an argument is the concrete value handed over between the parentheses of the call.
def greet(name): # 'name' is the PARAMETER
print(f"Hello, {name}. Welcome to Alba Studio.")
greet("Marta") # "Marta" is the ARGUMENT
greet("Luis")What happens in the call is exactly this: Python creates, inside the function, a variable called name and assigns it the value "Marta" — a perfectly ordinary assignment, name = "Marta", made automatically for you. That variable lives only while the function is running and disappears when it finishes; the precise reason is the topic of Variable scope.
| Parameter | Argument | |
|---|---|---|
| Where it appears | In the definition (def) |
In the call |
| What it is | A name, a slot to be filled | A concrete value |
| How many there are | Fixed when the function is written | One per parameter, on every call |
| Example | def greet(name): |
greet("Nuria") |
Telling them apart matters because Python's error messages speak in these terms: calling greet() with nothing produces TypeError: greet() missing 1 required positional argument: 'name', that is, "1 required argument is missing and it is called name". If you pass too many, it will say takes 1 positional argument but 2 were given. As soon as you understand the vocabulary, these errors stop being frightening and become rather precise instructions.
A function can have several parameters, separated by commas, and then order rules:
def present_task(title, assignee, days):
print(f"{title} -> {assignee} ({days} days)")
present_task("Book fair poster", "Nuria", 4)
present_task("Nuria", 4, "Book fair poster") # valid, but absurdThe second call raises no error: Python assigns by position without judging whether it makes sense. It is a silent failure, one of the worst kinds, and the next section offers the defence.
- Positional arguments and keyword arguments
So far we have passed positional arguments: the first value goes to the first parameter, the second to the second, and so on. But Python also lets you name the parameter each value goes to. They are called keyword arguments:
present_task(title="Sole Bakery menu", assignee="Luis", days=3)
present_task(assignee="Luis", days=3, title="Sole Bakery menu")The two calls do exactly the same thing: once you state the name, order stops mattering. And, into the bargain, the call reads by itself. Compare create_warning("Book fair poster", True, False, 3) with create_warning("Book fair poster", urgent=True, silent=False, days=3): without the names, to understand the first one you would have to go and look up the definition of create_warning; with them, the call explains itself where it stands.
The two styles can be mixed, with one firm rule: positional arguments always come before named ones.
present_task("Book fair poster", assignee="Nuria", days=4) # correct
present_task(title="Book fair poster", "Nuria", 4) # SyntaxErrorA practical criterion: use keyword arguments when the value is a bare True/False, a number with no obvious units, or when the function has more than two or three parameters; a naked True in a call is a riddle for whoever reads the code a month from now.
- Default values
A parameter can carry a default value, used when the caller does not supply that argument. It is written with an = in the definition:
def warn(message, times=1):
"""Repeat a warning on screen the given number of times."""
for i in range(times):
print(f"[WARNING] {message}")
warn("Check the Vidal account order") # prints 1 line
warn("Delivery tomorrow", times=3) # prints 3 linestimes has become optional: the first call uses the default value, 1, and the second overrides it. That is how you make a function simple in the normal case and flexible in the special one, which is exactly what you want. There is one syntactic rule Python imposes: parameters with a default value go at the end, after all the compulsory ones. That is, def ask_text(message, required=True): is correct, but def ask_text(required=True, message): produces SyntaxError: parameter without a default follows parameter with a default. The reason is common sense: if optional parameters could sit in the middle, Python would not know which parameter the second value of a positional call belongs to.
One warning worth engraving from today, even though its full explanation comes later: never use a mutable object as a default value (a list, for instance). The default value is evaluated just once, when the function is defined, not on every call; if it is mutable and gets modified inside, that modification persists between calls and produces baffling behaviour. With immutable values — numbers, strings, booleans, None — there is no problem at all, and that is all we will use for now. We will come back to it when you meet lists.
return: hand back a value and finish
return: hand back a value and finishreturn does two things at once, and both matter:
- It hands a value to whoever called the function.
- It ends the function immediately: no later line of the body is executed.
def calculate_cost(days, daily_rate):
"""Return the total cost of a task, without printing anything."""
return days * daily_rate
cost = calculate_cost(4, 120.0)
print(f"Estimated cost: {cost:.2f} EUR")
print(f"With VAT: {cost * 1.21:.2f} EUR")Notice what the program has gained. calculate_cost neither knows nor cares what will be done with the number: it hands it over and steps aside. The caller stores it in cost, formats it and reuses it for the VAT. A call to a function that returns a value is an expression, with everything that implies: it can go inside a print, an if, an arithmetic operation or another call.
print(calculate_cost(2, 90.0)) # inside print
if calculate_cost(10, 120.0) > 1000: # inside a condition
print("High quote: requires approval from Marta.")
total = calculate_cost(3, 100.0) + calculate_cost(2, 80.0) # in a sumAnd the second half of return, the finishing part, looks like this: if you write return 42 and a print("...") underneath, that print is dead code and never runs; editors usually grey it out. If one turns up, either it is redundant or the return is in the wrong place.
- Several
returns and the early return
returns and the early returnA function can have as many returns as needed. The first one reached is executed, and everything ends there:
def workload_label(days):
"""Return a workload label based on the estimated days."""
if days <= 2:
return "light"
if days <= 5:
return "medium"
return "heavy"
print(workload_label(1)) # light
print(workload_label(4)) # medium
print(workload_label(9)) # heavyNotice there is neither elif nor else, and yet it works: since every branch ends in a return, if the first condition holds the function has already finished and never reaches the second. This is the guard clause of 03-01 taken to functions: check the special cases first, leave as soon as they are resolved and let the general case sit at the end, unindented. It reads far better than a three-level nested if.
A return with no value — just the word — also exists: it ends the function and returns None. It is useful for leaving a procedure early:
def show_card(has_task, title):
if not has_task:
print("There is no task registered yet.")
return # we leave: there is nothing to paint
print(f"Card for: {title}")Watch out for a common slip: if a branch forgets its return, that branch returns None silently, and the function looks correct until somebody calls it with the value that falls exactly there. Check one by one that every branch returns something.
- Returning more than one value
If you separate several values with commas after return, the function returns them all at once:
def analyse_title(text):
"""Return the normalised title and its number of characters."""
clean = text.strip().capitalize()
return clean, len(clean)
title, length = analyse_title(" book fair poster ")
print(title) # Book fair poster
print(length) # 16What happens under the bonnet is that Python packs the two values into a single object called a tuple, and the line title, length = ... unpacks it into two variables. You can check this by storing the result in a single variable: result = analyse_title("book fair") leaves in result the value ('Book fair', 9), whose type() is <class 'tuple'>. Tuples are the topic of Tuples and nested structures; today it is enough to know that unpacking works and that the number of variables on the left must match the number of values returned, or you will get a ValueError: too many values to unpack.
A piece of design advice: returning two values is fine; returning five is a sign that the function does too much, or that those five values are really one single concept that has not been given a name yet — precisely what happens to EasyTask with title, assignee, priority, days and completed, and what will be sorted out in modules 5 and 7.
- Print inside or return?
This is the most frequent design decision when writing a function, and the right answer is nearly always the same. Compare a printed_cost(days, rate) whose body is print(f"Cost: {days * rate:.2f} EUR") with a returned_cost(days, rate) whose body is return days * rate:
| Criterion | Print inside | Return the value |
|---|---|---|
| Can the result be reused? | No: it has gone to the screen | Yes: it is a value like any other |
| Is it any use for computing something else? | No | Yes (total = a() + b()) |
| Can it be tested without watching the screen? | No | Yes: you compare the returned value |
| Is it any use for a report, a file, another screen? | No: the format is fixed | Yes: the caller decides |
| Who decides the format? | The function, for ever | Whoever uses it, case by case |
Returning is almost always better, and the underlying reason is that a function which prints has made a decision on your behalf that was not its to make. printed_cost is only good for showing euros with two decimals on the screen; the day you want the cost in order to add it up, compare it or store it, it is no use to you. returned_cost is good for all of that and for printing too, because you can always write print(f"{returned_cost(4, 120):.2f} EUR"). The practical consequence is a rule we will take up again in Breaking a program down into functions: functions that compute do not print; those that present do not compute. And the one that should indeed print is precisely the one that talks to the user, such as ask_text, which needs to show the message in order to ask.
*args and **kwargs, just to recognise them
*args and **kwargs, just to recognise themYou will see these two forms everywhere in other people's code, so it is worth recognising them even if you do not use them yet.
A parameter preceded by one asterisk collects all the leftover positional arguments, and one preceded by two asterisks collects all the leftover keyword arguments:
def announce(*messages):
"""Print on screen as many messages as it is given."""
for message in messages:
print(f"[WARNING] {message}")
announce("Meeting at 10", "Deliver the poster", "Call Vidal")The two-asterisk version is defined the same way — def log_event(action, **details): — and called with log_event("task_created", assignee="Nuria", days=4); inside, details associates each name with its value.
The names args and kwargs are pure convention (from arguments and keyword arguments); what does the work are the asterisks. Under the bonnet, *args is a tuple and **kwargs a dictionary, two structures from module 5: you do not need to master them today, only to recognise that def function(*args, **kwargs): accepts anything at all. Use it sparingly: a function with explicit parameters documents itself and is far easier to understand.
- EasyTask: the validation functions
The moment has come to kill the repetition. The four validation loops of v0.6 boil down to three parametrised functions, plus one that classifies.
def ask_text(message, required=True):
"""Ask for a text at the keyboard and return it stripped of 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 and not return it until it is between minimum and maximum."""
text = input(message).strip()
while not text.isdigit() or not minimum <= int(text) <= maximum:
text = input(f"It must be an integer between {minimum} and {maximum}. ").strip()
return int(text)
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"And this is how they are used from the main program:
PRIORITIES = ("high", "medium", "low")
TEAM = ("marta", "luis", "nuria")
title = ask_text("Title : ")
assignee = ask_option("Assignee : ", TEAM).capitalize()
priority = ask_option("Priority : ", PRIORITIES)
days = ask_integer("Days (1-365) : ", 1, 365)
print(f"{title} [{classify_urgency(priority, days)}]")Go over what has happened, because it is a great deal:
- Four repeated validation loops have ended up as three functions, each written just once. If tomorrow you want to change the telling-off message, you change it in one place and it changes everywhere.
- The main program reads like a list of intentions. The machinery of the
whilehas vanished from sight without vanishing from the program. ask_optionserves for the assignee, for the priority and for the menu option, three things that in v0.6 were three separate blocks. That is the power of a parameter:optionsturns one function into a family of functions. Andrequired=Trueleaves the door open to asking for an optional value the day it is needed, without touching any current call.classify_urgencyreturns, it does not print. That is why we can drop it inside an f-string, and tomorrow we could also use it to decide a colour or to sort tasks.
Notice too a detail about the division of responsibilities: ask_option normalises to lower case, but the .capitalize() of the assignee is applied outside, by the caller. The function takes care of validating; presentation format is the program's business. And observe that ask_integer already returns an int, not a text: it converts once, in the only place where the value is known to be convertible, applying the "presence, type, domain" framework of 02-04 from start to finish.
Common Mistakes and Tips
Forgetting the return. The function computes correctly and returns None. Symptom: TypeError: unsupported operand type(s) for +: 'NoneType' and 'int' a little further on, on the line that tries to use the result.
Confusing print with return. Writing print(days * rate) inside the function and then cost = calculate_cost(...) leaves cost at None, even though the right number came out on the screen. Seeing the value in the console is not the same as having it.
Wrong number of arguments. TypeError: ... missing 1 required positional argument or takes 2 positional arguments but 3 were given. The message says how many it expected and how many it got: count them in the definition.
Putting a parameter with a default before a compulsory one. Immediate SyntaxError: the optional ones go at the end, always. And do not use a list as a default value: def f(x, history=[]) shares the same list across every call. You do not handle lists yet; remember it for when 05-01 arrives.
Putting code after the return. It never runs. If you wrote it, you meant something else.
Tip: write the call first. Before implementing, write how you would like to use it: days = ask_integer("Days: ", 1, 365). If that line reads well, the signature is good; if it forces you to explain it, change it before writing the body.
Tip: two or three parameters is the comfortable range. From four upwards, the call becomes a hieroglyph and it is worth using keyword arguments — or asking yourself whether that function is not doing two things.
Exercises
Exercise 1: Predict and explain
Say what each block prints and why. There is a design flaw hidden in there.
def average_days(a, b):
print((a + b) / 2)
def average_days_2(a, b):
return (a + b) / 2
x = average_days(4, 6)
y = average_days_2(4, 6)
print(x)
print(y)
print(y * 2)Exercise 2: A studio quote
Write three functions with a one-line docstring:
task_cost(days, rate=110.0): returns the cost of a task; the default rate is 110 euros per day.apply_discount(cost, percentage=0): returns the cost with the discount applied. If the percentage is not between 0 and 50, it returns the cost untouched.quote_summary(title, days, rate=110.0, discount=0): uses the previous two and returns two values: the summary text and the final amount.
Then work out the quote for "Book fair poster", 4 days, default rate and a 10% discount, and print the summary from the main program (not from inside the functions).
Exercise 3: Repair the function
This function has three defects. Find them, explain them and rewrite it.
def classify(days, priority="high", threshold):
if priority == "high":
print("Urgent")
elif days > 10:
return "Long term"
return "Normal"
print("Classification finished")Solutions
Solution 1.
average_days(4, 6) prints 5.0 from inside and returns None, so x holds None. average_days_2(4, 6) prints nothing but returns 5.0, which is stored in y. Then print(x) shows None, print(y) shows 5.0 and print(y * 2) shows 10.0. The design flaw is average_days: by printing instead of returning, its result cannot be reused. Check that x * 2 would give a TypeError, whereas y * 2 works without a hitch.
Solution 2.
def task_cost(days, rate=110.0):
"""Return the gross cost of a task of the given duration."""
return days * rate
def apply_discount(cost, percentage=0):
"""Return the discounted cost; ignore percentages outside 0-50."""
if not 0 <= percentage <= 50:
return cost
return cost * (1 - percentage / 100)
def quote_summary(title, days, rate=110.0, discount=0):
"""Return the summary text and the final amount of the quote."""
gross = task_cost(days, rate)
final = apply_discount(gross, discount)
text = f"{title}: {days} days x {rate:.2f} = {gross:.2f} EUR"
return text, final
detail, amount = quote_summary("Book fair poster", 4, discount=10)
print(detail) # ... 4 days x 110.00 = 440.00 EUR
print(f"Total with discount: {amount:.2f} EUR") # Total with discount: 396.00 EURThree details: discount=10 is passed by name so as to skip rate without repeating its default value; none of the three functions prints, so all three are reusable; and the chained comparison 0 <= percentage <= 50 is the one from 03-01, inside a guard with an early return.
Solution 3.
| Defect | Explanation |
|---|---|
threshold without a default comes after priority="high" |
SyntaxError when the file is loaded |
| The first branch prints instead of returning | The function returns None when the priority is high |
The final print sits after a return |
Dead code: it never runs |
def classify(days, threshold, priority="high"):
"""Return the classification of a task based on priority and duration."""
if priority == "high":
return "Urgent"
if days > threshold:
return "Long term"
return "Normal"The compulsory parameter moves to the front, the first branch returns like the others, the dead code disappears and, into the bargain, threshold is genuinely used, which in the original did not even appear in the body.
Conclusion
Your functions now hold a conversation. Parameters are the slots you declare in the def; arguments are the values you hand over in the call, and they can go by position or by name, this second form being the one that makes calls with booleans or with a lot of data readable. A parameter can carry a default value, and then it becomes optional, with the rule that optional ones go at the end and are never mutable. On the other side, return hands back a value and ends the function on the spot; there can be several in different branches, it serves as a guard clause for leaving early, and it accepts several values separated by commas that are unpacked on receipt. And the rule that governs everything else: a function that computes returns, it does not print, because the value is good for everything and the screen only for looking at.
EasyTask has taken the biggest leap in its history. The four duplicated validation loops have turned into ask_text, ask_option and ask_integer, three functions written once and used from anywhere, and classify_urgency computes a label without dirtying the screen. The main program has gone from a tangle of whiles to a readable list of intentions. But bringing all those functions together in one file raises new and very concrete questions. Why can ask_option read the constant PRIORITIES without our passing it in? What would happen if inside a function it assigned title = "another": would it change the title of the main program? And why does the variable value of ask_text not exist outside? All of that is a single question — where each name lives — and it has a name of its own: scope. That is the next lesson: Variable scope.
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
