Python Functions and Classes#
As your analyses grow, you’ll find yourself writing the same few lines again and again — converting units, computing the distance between two lat/lon points, cleaning a column. The fix is to wrap that logic in a reusable function. The guiding principle is DRY — don’t repeat yourself: repetition is tedious and a breeding ground for bugs.
In this notebook we’ll write functions (and learn the subtle ways they interact with the variables around them), then build up to classes — bundles of data and behavior — using a Hurricane as our running example.
Working through this notebook
This page is a Jupyter notebook. Download it using the ⬇ button in the top-right of the page, open it in your environment (JupyterLab on LEAP or Colab), and step through the cells. When you reach a Try it admonition, experiment in your own cells before moving on.
In-class assignment — 10 points
The “Try it” exercises in this notebook are part of your in-class assignment for this section. Complete them in your own copy of the notebook, push it to your week folder, and post the notebook link on the matching Courseworks assignment. (One 10-point assignment covers all the lecture notebooks in this section.)
Functions#
A function is a named block of code that takes some inputs (called arguments), does something, and usually gives back a result (called the return value). Once you’ve defined a function, you can call it as many times as you like with different inputs, instead of copy-pasting the same code (the DRY principle from above).
Defining a function uses the def keyword; calling it uses the function name followed by parentheses with the arguments inside.
# define a function
def say_hello():
"""Return the word hello."""
return 'Hello'
# functions are also objects
type(say_hello)
function
# this doesnt call
say_hello?
# this does
say_hello()
'Hello'
# assign the result to something
res = say_hello()
res
'Hello'
Functions get more useful when they take arguments — values passed in that the function can use:
# take some arguments
def say_hello_to(name):
"""Return a greeting to `name`"""
return 'Hello ' + name
# intended usage
say_hello_to('World')
'Hello World'
If we call this with a non-string argument like an integer, Python can’t concatenate it to 'Hello ' and raises a TypeError:
say_hello_to(10)
---------------------------------------------------------------------------
TypeError Traceback (most recent call last)
Cell In[8], line 1
----> 1 say_hello_to(10)
Cell In[6], line 4, in say_hello_to(name)
2 def say_hello_to(name):
3 """Return a greeting to `name`"""
----> 4 return 'Hello ' + name
TypeError: can only concatenate str (not "int") to str
# redefine the function
def say_hello_to(name):
"""Return a greeting to `name`"""
return 'Hello ' + str(name)
say_hello_to(10)
'Hello 10'
Wrapping the argument in str(...) converts whatever the caller passes into a string before concatenation, so the function now works regardless of the input type.
Arguments can have default values, making them keyword arguments. The caller can leave them out (and get the default) or specify them by name:
# take an optional keyword argument
def say_hello_or_hola(name, spanish=False):
"""Say hello in multiple languages."""
if spanish:
greeting = 'Hola '
else:
greeting = 'Hello '
return greeting + name
print(say_hello_or_hola('Ryan'))
print(say_hello_or_hola('Juan', spanish=True))
Hello Ryan
Hola Juan
A function can also accept a flexible number of arguments using *args — the leading * tells Python to collect any extra positional arguments into a tuple:
# flexible number of arguments
def say_hello_to_everyone(*args):
return ['hello ' + str(a) for a in args]
say_hello_to_everyone('Ryan', 'Juan', 'Xiaomeng')
['hello Ryan', 'hello Juan', 'hello Xiaomeng']
Try it
In a fresh code cell, write a function square(x) that returns x squared, and call it with a few numbers. Then extend it: define a function power(x, n=2) with n as an optional keyword argument so the user can compute other powers. Verify that power(3) gives 9 and power(3, n=3) gives 27.
Pure vs. Impure Functions#
Functions that don’t modify their arguments or produce any other side-effects are called pure.
Functions that modify their arguments or cause other actions to occur are called impure.
Below is an impure function.
def remove_last_from_list(input_list):
input_list.pop()
names = ['Ryan', 'Juan', 'Xiaomeng']
remove_last_from_list(names)
print(names)
remove_last_from_list(names)
print(names)
['Ryan', 'Juan']
['Ryan']
Notice what happened: each call to remove_last_from_list(names) modified the same names list outside the function. After the first call it dropped to ['Ryan', 'Juan']; after the second, to ['Ryan']. The function had a side effect — calling it silently changed the input. That’s what makes it impure.
Here’s a pure version that does the same conceptual job (remove the last item) without modifying the original. It makes a copy of the input, modifies the copy, and returns it:
def remove_last_from_list_pure(input_list):
new_list = input_list.copy()
new_list.pop()
return new_list
names = ['Ryan', 'Juan', 'Xiaomeng']
new_names = remove_last_from_list_pure(names)
print(names)
print(new_names)
['Ryan', 'Juan', 'Xiaomeng']
['Ryan', 'Juan']
Notice the difference: this time names is unchanged after the call — the function returned a new list (new_names) instead of mutating the input. Pure functions are usually safer and easier to reason about, because the inputs you pass in won’t be silently altered somewhere down the line.
Namespaces and scope#
When you create a variable inside a function, it’s local to that function — it doesn’t affect variables of the same name outside. Functions can read variables from the outer (parent) scope, but assigning to a name inside a function creates a fresh local variable that shadows any outer one. This is called scoping, and it’s a common source of confusion for new programmers.
The example below illustrates: print_name_v2 changes name locally without affecting the outer name.
name = 'Ryan'
def print_name():
print(name)
def print_name_v2():
name = 'Kerry'
print(name)
print_name()
print_name_v2()
print(name)
Ryan
Kerry
Ryan
A more complex function: Fibonacci Sequence#
The Fibonacci sequence is 1, 1, 2, 3, 5, 8, 13, … — each number is the sum of the previous two. Here’s a function that returns the first n Fibonacci numbers as a list. It uses list methods (append) and negative indexing ([-1], [-2]) to build the sequence step by step.
def fib(n):
l = [1,1]
for i in range(n-2):
l.append(l[-1] + l[-2])
return l
fib(10)
[1, 1, 2, 3, 5, 8, 13, 21, 34, 55]
Try it
In a fresh code cell, write a function that takes a temperature in Celsius and returns it in Fahrenheit (formula: F = C * 9/5 + 32). Then add an optional keyword argument so the user can choose Kelvin output instead (K = C + 273.15). Call your function with a few different inputs.
(This problem will come back in Assignment 3.)
Classes#
You’ve worked with many built-in object types — strings, lists, dictionaries, and later NumPy arrays and pandas DataFrames. Each has its own attributes and methods.
A class is a template for creating your own kind of object — like a custom version of list or str, tailored to whatever you’re modeling. The class definition specifies what data each object carries (its attributes) and what it can do (its methods). Each object created from the class is called an instance; you can create as many instances as you want, each with its own values.
You might not write many classes early on, but you’ll see them in almost every codebase you read, and as your projects grow you’ll occasionally want to write your own to bundle related data and behavior together. The example below walks through a small Hurricane class so you can follow what’s going on when you meet one in the wild.
A class to represent a hurricane#
Three things to notice in the code below:
The
class Hurricane:line defines the class. Everything indented under it is part of the class.__init__is a special method that runs automatically every time you make a newHurricane— for example, when you writeh = Hurricane('florence')in the next cell. The arguments afterself(here, justname) are the values you put inside those parentheses.selfrefers to the new object being built. The lineself.name = namesays: store thenameyou passed in as an attribute on this object, so later you can read it back ash.name.
class Hurricane:
def __init__(self, name):
self.name = name
h = Hurricane('florence')
h
<__main__.Hurricane at 0x7f103428c210>
Notice that printing h just shows the class name and a memory address — not very useful. We’ll fix this later by adding a __repr__ method.
Our class only has a single attribute so far:
h.name
'florence'
Let’s grow the class. We’ll add two more attributes (category and lon), uppercase the name as it’s stored, and add input validation — a check that raises a ValueError if the longitude is outside the valid range.
class Hurricane:
def __init__(self, name, category, lon):
self.name = name.upper()
self.category = int(category)
if lon > 180 or lon < -180:
raise ValueError(f'Invalid lon {lon}')
self.lon = lon
h = Hurricane('florence', 4, -46)
h
<__main__.Hurricane at 0x7f103428db10>
h.name
'FLORENCE'
Notice the name came back as 'FLORENCE', not 'florence' — that’s because __init__ ran name.upper() and stored the uppercased version as the attribute.
h = Hurricane('ryan', 5, 300)
---------------------------------------------------------------------------
ValueError Traceback (most recent call last)
Cell In[28], line 1
----> 1 h = Hurricane('ryan', 5, 300)
Cell In[25], line 8, in Hurricane.__init__(self, name, category, lon)
4 self.name = name.upper()
5 self.category = int(category)
6
7 if lon > 180 or lon < -180:
----> 8 raise ValueError(f'Invalid lon {lon}')
9 self.lon = lon
ValueError: Invalid lon 300
The validation check inside __init__ (if lon > 180 or lon < -180: raise ValueError(...)) caught the bad longitude and stopped construction with a ValueError. The instance was never created.
Now let’s add a custom method:
class Hurricane:
def __init__(self, name, category, lon):
self.name = name.upper()
self.category = int(category)
if lon > 180 or lon < -180:
raise ValueError(f'Invalid lon {lon}')
self.lon = lon
def is_dangerous(self):
return self.category > 1
f = Hurricane('florence', 4, -46)
f.is_dangerous()
True
f was created with category=4, so self.category > 1 is True and the method returns True.
Magic / dunder methods#
Methods whose names begin and end with double underscores (__init__, __repr__, etc.) — pronounced “dunder” for double-underscore — are special: Python calls them automatically in particular situations. We’ve already met __init__, which Python calls when you create an instance. Another useful one is __repr__, which Python calls when it needs to display the object (e.g., when you type the variable name alone in a cell). By default __repr__ returns the ugly memory address we saw earlier; defining your own gives a nicer display.
class Hurricane:
def __init__(self, name, category, lon):
self.name = name.upper()
self.category = int(category)
if lon > 180 or lon < -180:
raise ValueError(f'Invalid lon {lon}')
self.lon = lon
def __repr__(self):
return f"<Hurricane {self.name} (cat {self.category})>"
def is_dangerous(self):
return self.category > 1
f = Hurricane('florence', 4, -46)
f
<Hurricane FLORENCE (cat 4)>
Now printing f shows the formatted string from our __repr__ (<Hurricane FLORENCE (cat 4)>) instead of the memory-address gibberish we saw earlier. That’s the point of __repr__.
Try it
In a fresh code cell, define a minimal class of your own — pick a domain (e.g. a Station for a weather station, or a Reading for a measurement). Give it an __init__ that takes 2–3 arguments and stores them as attributes. Create an instance and access its attributes. The goal is to build enough familiarity that you can read class definitions confidently when you encounter them.
Recap#
You’ve now seen how to package logic into reusable pieces:
Functions — wrapping repeated logic with
def, passing positional and keyword arguments, and returning results.Pure vs. impure functions — why a function that quietly modifies its input (or relies on outside state) can surprise you.
Namespaces and scope — which variables a function can actually see.
Classes — bundling data and behavior together (our
Hurricane), with__init__,self, and dunder methods like__repr__.
Next we look at how to organize functions and classes across files and projects in Organizing Python Projects.