Python Functions and Classes

For longer and more complex tasks, it is important to organize your code into reuseable elements. For example, if you find yourself cutting and pasting the same or similar lines of code over and over, you probably need to define a function to encapsulate that code and make it reusable. An important principle in programming in DRY: “don’t repeat yourself”. Repetition is tedious and opens you up to errors. Strive for elegance and simplicity in your programs.

Functions

Functions are a central part of advanced python programming. Functions take some inputs (“arguments”) and do something in response. Usually functions return something, but not always.

# 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?
Signature: say_hello()
Docstring: Return the word hello.
File:      ~/Teaching/rces_book/content/lectures/core_python/<ipython-input-1-5cff6abf0dfe>
Type:      function
# this does
say_hello()
'Hello'
# assign the result to something
res = say_hello()
res
'Hello'
# take some arguments
def say_hello_to(name):
    """Return a greeting to `name`"""
    return 'Hello ' + name
# intended usage
say_hello_to('World')
'Hello World'
say_hello_to(10)
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-8-56dd0f5596c8> in <module>()
----> 1 say_hello_to(10)

<ipython-input-6-a444b0f12be2> in say_hello_to(name)
      2 def say_hello_to(name):
      3     """Return a greeting to `name`"""
----> 4     return 'Hello ' + name

TypeError: must be str, not int
# redefine the function
def say_hello_to(name):
    """Return a greeting to `name`"""
    return 'Hello ' + str(name)
say_hello_to(10)
'Hello 10'
# 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
# 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']

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']

We can do something similar with a pure function.

In general, pure functions are safer and more reliable.

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']

We could spend the rest of the day talking about functions, but we have to move on.

Namespaces

In python, a namespace is a mapping between variable names and python object. You can think of it like a dictionary.

The namespace can change depending on where you are in your program. Functions can “see” the variables in the parent namespace, but they can also redefine them in a private scope.

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 the 1,1,2,3,5,8…, the sum of each number with the preceding one. Write a function to compute the Fibonacci sequence of length n. (Hint, use some list methods.)

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]

Classes

We have worked with many different types of python objects so far: strings, lists, dictionaries, etc. These objects have different attributes and respond in different ways to the built-in functions (len, etc.)

How can we make our own, custom objects? Answer: by defining classes.

A class to represent a hurricane

class Hurricane:
    
    def __init__(self, name):
        self.name = name
h = Hurricane('florence')
h
<__main__.Hurricane at 0x108680940>

Our class only has a single attribute so far:

h.name
'florence'

Let’s add more, along with some input validation:

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 0x1086966a0>
h.name
'FLORENCE'
h = Hurricane('ryan', 5, 300)
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
<ipython-input-28-88629bebad1a> in <module>()
----> 1 h = Hurricane('ryan', 5, 300)

<ipython-input-25-24766bacf597> in __init__(self, name, category, lon)
      6 
      7         if lon > 180 or lon < -180:
----> 8             raise ValueError(f'Invalid lon {lon}')
      9         self.lon = lon
     10 

ValueError: Invalid lon 300

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

Magic / dunder methods

We can implement special methods that begin with double-underscores (i.e. “dunder” methods), which allow us to customize the behavior of our classes. (Read more here). We have already learned one: __init__. Let’s implement the __repr__ method to make our class display something pretty.

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)>