02 Functions
In this module we will learn about how to reuse code by writing parts of our scripts as functions. We’ll spend a lot of time on the differences between defining a function and calling it and the associated concept’s of a function’s scope. In fact, we’ll go over it in over and over again to make sure we get it.
We’ll learn about how to set default values for some of the values we want to use in our functions and some additional annotation we can add to aid our future selves. By the end of the module we’ll be able to write useful functions that do things inside of our scripts according to modern Python style standards.
This will probably be the longest module as it introduced a huge number of concepts that can be pretty confusing. As always, we encourage experimentation and deviation from the guide to better understand how everything works.
Our first function
Functions allow us to easily reuse code. We’ve already used a couple of functions that come
built-in to Python—print() and type(). There are many, many functions that come with Python
we’ll see thoughout these guides and later we will use additional packages to get access to even
more functions.
The best way to learn about functions is to make our own.
Lets create a new script called functions.py with the following content.
# functions.py
def greet(name):
print(f"Hello {name}")
Let’s go through what all of this means. The first line is called a function signature which
always starts with def. It tells Python the basic details of our function like its name and what
arguments it takes.
Arguments are values that we pass into the function so that the function can do something with
them. Our function takes a single argument that we’ve called name but we could call it anything
we want and we can have as many as we want.
def is a special keyword that tells python that we’re defining a function. Much like with
variables, we can give our function a memorable name for when we use it later. In this case the
function’s name is greet.
Following the signature is the function body. A function’s body—like many soecific contexts in Python—needs to be indented relative to where we defined it. Indentation is how Python knows which lines of code belong to the function and which belong to the rest of the script.
We can indent our code with the tab key (a good editor will convert these tabs into spaces behind
the scenes). All our function body does is use print to show a little greeting in our terminals,
we’ll see much more complicated functions later on.
Calling our function
So far our script doesnt actually do anything. We’ve defined a function but we haven’t invoked or called it. We’ve told Python that we want to be able to use the function without actually using it. It’s like getting a hammer out of a tool box. We’re ready to use the tool but that doesn’t mean we’ve bonked in any nails yet. Let’s change that.
# functions.py
def greet(name):
print(f"Hello {name}")
greet("DeepMay")
$ uv run function.py
Hello DeepMay
Now we’ve called our function and provided a value "DeepMay" for our argument name. How this
works can be conceptually tricky so we’ll go through it in excruciating detail.
Broadly, when we call a function Python executes the code inside the function body using the
arguments as variables. Because our function takes a single argument (called name) and we’ve
provided a single value ("DeepMay") Python infers that inside the function name = "DeepMay".
Then, Python starts executing the function body line by line the same as any other code. In the
case of our function, it inserts name into the f-string and prints it.
To recap, inside of the function name = "DeepMay" but outside of the function name is not
defined. We call the set of variables that exist inside of the function but not outside of it, the
variable’s scope. Every time we call the function, the name name will be assigned whatever value we pass
in—each call of the function will have its own scope with its own unique definitions.
Argument names, variable names, and scope
In the last module we talked about variables. These allowed us to store values with memorable names. Arguments are similar in that they let us connect values to names but only in the context of our functions. Thus far, when we’ve called our new functions we’ve passed literal values in but most of the time we’ll probably want to use variables instead. At times this can be confusing, but variable names and argument names are completely independent.
Let’s change our script again so that instead of passing in a literal value, we’re passing in variables.
# functions.py
def greet(name):
print(f"Hello {name}")
my_name = "DeepMay"
greet(my_name)
# this line will fail
print(name)
$ uv run functions.py
Hello DeepMay
Traceback (most recent call last):
File ".../functions.py", line 10, in <module>
print(name)
^^^^
NameError: name 'name' is not defined
Ok, so now we’ve got names in two different places which can be kind of confusing! Let’s take a look at each of the lines we’ve changed so we can see what is different.
First, we added a line defining a variable called my_name to store the value "DeepMay". In the
next line we passed this value into the function. That means inside the function name will be
my_name (which is "DeepMay"). Note that it works exactly the same as if we had passed in the
literal "DeepMay".
Once passed into the function, Python doesn’t care what the variable is
called on the outside. Inside the function, as far as Python is concerned, my_name is just name.
In the final line we wrote some code that didn’t work. We tried to print name outside of the
greet function and Python told us that “name name is not defined. That’s because name exists
only within the function—inside the function’s scope.
If we wanted to, we could have called our variable name instead of my_name but usually we want
to avoid naming our variables the same thing as an argument. The reason is very simple, its just
confusing! If we had said name = "DeepMay" instead of calling it my_name than we would be
allowed to print name in the final line which might make us think we’re printing the same name
as the one inside the function. The name inside the function exists only inside the function. In
this case, its equivalent to the name outside the function, but its not really the same.
Returning values
So far our function does stuff but doesn’t really interact with the rest of our code in a meaningful wau. Most of the time, we want a function to give us something back. Let’s write another function that does the calculations for our line equation from the variables module.
# functions.py
def greet(name):
print(f"Hello {name}")
def line_equation(x, slope, intercept):
return slope * x + intercept
# same x value as our sctipt
y = line_equation(7.8, 2, 4.0)
print(f"Using the same x value as in our script our y value is {y}")
$ uv run functions.py
Using the same x value as in our script our y value is 19.6
First let’s look at our greet(name) function. Notice that we don’t see a greeting in the output
even though we still have the function definition in our script. That’s because we haven’t actually
called it. Remember that we need to call our function in order for it to do anything.
Ok, now let’s look at the signature of our new function. We’ve called it line_equation and it
takes three arguments x, slope, and intercept. Notice that the order here is a little
different than when we made a script dedicated to our line equation. Its good practice to put
variables that are likely to be different for different calls to our function earlier in the order.
With a line we often want to know how $y$ changes with different values for $x$ for a given slope
and intercept so we put x first in the order of our arguments.
Finally, in our function body we see another Python keyword, return. This keyword tells Python
that we want our function to give us back a value specified by the expression that follows
return. When we call the function, Python executes the code in the body and in this case
evaluates slope * x + intercept to some value. The function line_equation is equivalent to
`slope * x + intercept. We’ve turned our line equation into a portable, easily reusable tool!
In fact, we’ve already used functions both with and without return values. print doesn’t return
anything, we’ll never use a statement like my_val = print("hello world") because print doesn’t
evaluate to a value. We can the effects of functions that are not part of their return value side
effects. The side effect of print is to put some text in our terminals.
On the other hand type does return a value. When we have used statements like
print(type(my_val)) we are really condensing two steps into one. It works from the inside out, we
call type on my_val which evaluates to a value (something like <class 'float'> in this case)
and then we pass that value into print!
Default values
When we define a function, we can include default values for arguments that will be used if no
value is provided in the function call. Let’s add some default values to our line_equation
function so that we don’t have to keep reentering the slope and intercept values every time we
call the function.
# functions.py
def greet(name):
print(f"Hello {name}")
def line_equation(x, slope = 2, intercept = 4.0):
return slope * x + intercept
# using the default values
y1 = line_equation(7.8)
print(f"We can count on default values to keep our y value as {y1}")
# we can also override the default value if we want to by providing a value
y2 = line_equation(7.8, intercept=12.0)
print(f"changing only one of the defailts gives us {y2}")
$ uv run functions.py
Hello DeepMay
We can count on default values to keep our y value as 19.6
Changing only one of the defaults gives us 27.6
We add default values with = by modifying the function signature. When we call the function, it
will check for the positions (or names) of the arguments as usual. If Python doesn’t find one or more
of the expected arguments, it will check for default values. This can save us a lot of of headache
if we’re making (or just calling) complicated fuctions with lots of arguments.
Type hints
Theres just one last thing to cover and this feature doesn’t even do anything! Python for the most part doesn’t care about variable types very much. We, on the other hand, might.
One of the main qualities that distinguishes good code from bad is readability. If we look back
on code we’ve previously written we ought to be able to understand it without too much trouble.
Python has a feature called type hints that can help us write readable code. These are little
annotations we add to function signatures that label what type of thing we expect each argument to
be. Here’s what our functions.py script looks like with type hints.
# functions.py
def greet(name: str):
print(f"Hello {name}")
def line_equation(x: float,
slope: int = 2,
intercept: float = 4.0) -> float:
return slope * x + intercept
# using the default values
y1 = line_equation(7.8)
print(f"We can count on default values to keep our y value as {y1}")
First of all, notice that our output didn’t change at all. It is not a joke that type annotations don’t do anything, Python literally ignores them when executing our scripts.
Ok, let’s get into it. Let’s Look at the signature for the greet function. We’ve changed name to
name: str. All this says is that we expect name to be a string. This <argument>: <type> syntax is how
we write type hints for arguments. Python would be perfectly happy to let us put in something like greet(7) but
when we’re looking back over our code, type hints allow us to see that name is really meant to be
a string.
The signature for line_equation is not split across multiple lines. We can basically always split
up lines in python if the split comes between parentheses as it does in our function signature.
Basically python sees that we have ( without a closing ) on the same line and will go ahead and
treat all of the stuff between the two as a single line.
More interestingly, in the signature for line_equation we’ve added type hints. Notice also in the
signature for line_equation that it now ends with -> float: rather than simply :. This just
reminds us that the value coming out of line_equation from the return keyword is a float. For
return values, the syntax for type hints is -> <type>. Specifying the type of a return value is
nice because we know at a glance that if we call line_equation we will get some float value
back.
Summary
Functions allow us to reuse code very easily, changing some parts of the values each time its
reused (e.g. by setting x, slope or intercept) while leaving the overall logic the same
return slope * x + intercept. We spent a lof of time on arguments and scope—the conceptual set
of variables that “belong” to a function. We’ve seen many syntactical variations some of which
matter to Python (like default values), and some are just for us like type hints. We will use
functions constantly—most of which will have return values—and often write our own as well.