Class 9A: Programming in Python I#

We will begin soon!

Firas Moosvi
from IPython.display import IFrame
from IPython.display import Markdown

# Additional styling ; should be moved into helpers
from IPython.display import display, HTML

HTML("<style>{}</style>".format(open("rise.css").read()))

Announcements#

  1. Grades and feedback for Labs 1-5 and feedback for LL1 - LL6 is now released; if you got a G or an R, submit a resubmission request on Ed Discussion.

  1. Project Deadlines:

    • Milestone 4 due March 23rd, 2023.

    • Milestone 5 due April 6th, 2023.

    • Milestone 6 due April 11th, 2023.

    • You should be spending at least a few hours every week working on your project, do not leave things to the last minute!

  1. Test 3 will be this Wednesday during class, you MUST be present in class!!

Class Outline#

  • Announcements (2 mins)

  • Functions in Python (40 min)

Programming in Python I#

In this class, we go through a notebook by a former colleague, Dr. Mike Gelbart, option co-director of the UBC-Vancouver MDS program.

If you prefer, you can also watch his recording of the same material.

Attribution#

Functions in Python (40 mins)#

  • Define a function to re-use a block of code with different input parameters, also known as arguments.

  • For example, define a function called square which takes one input parameter n and returns the square n**2.

def square(n):
    n_squared = n**2
      n_squared

# Also possible

def square(n):
    return n**2
  Cell In[2], line 3
    n_squared
    ^
IndentationError: unexpected indent
var = 50

square(n=var)
new_n_squared = square(n=var)
new_n_squared
#n_squared  # not available to us because the "scope" of it is only within square(n)
2500
import numpy as np

np.mean([5, 10, 15, 20])
12.5
square(n=np.mean([5, 10, 15, 20]))
156.25
square(12345)
152399025
  • Begins with def keyword, function name, input parameters and then colon (:)

  • Function block defined by indentation

  • Output or “return” value of the function is given by the return keyword

Null return type#

If you do not specify a return value, the function returns None when it terminates:

def f(x):
    x + 1  # no return!
    if x == 999:
        return


print(f(5))
None

DRY principle, designing good functions#

  • DRY: Don’t Repeat Yourself

  • See Wikipedia article

  • Consider the task of, for each element of a list, turning it into a palindrome

    • e.g. “mike” –> “mikeekim”

names = ["milad", "rodolfo", "tiffany", "Firas"]
# Aside: to reverse a string, use ::-1
name = "mike"
name[::-1]
'ekim'
names_backwards = list()

names_backwards.append(names[0] + names[0][::-1])
names_backwards.append(names[1] + names[1][::-1])
names_backwards.append(names[2] + names[2][::-1])
names_backwards.append(names[3] + names[3][::-1])
names_backwards
['miladdalim', 'rodolfooflodor', 'tiffanyynaffit', 'FirassariF']
  • Above: this is gross, terrible, yucky code

    1. It only works for a list with 4 elements

    2. It only works for a list named names

    3. If we want to change its functionality, we need to change 3 similar lines of code (Don’t Repeat Yourself!!)

    4. It is hard to understand what it does just by looking at it

names_backwards = list()

for name in names:
    names_backwards.append(name + "-" + name[::-1])

names_backwards
['milad-dalim', 'rodolfo-oflodor', 'tiffany-ynaffit', 'Firas-sariF']
# using list comprehensions

[f"{name}-{name[::-1]}" for name in names]
['milad-dalim', 'rodolfo-oflodor', 'tiffany-ynaffit', 'Firas-sariF']

Above: this is slightly better. We have solved problems (1) and (3).

def make_palindromes(names):
    names_backwards = list()

    for name in names:
        names_backwards.append(name + name[::-1])

    return names_backwards

# list comprehension function (equivalent)

def make_palindromes(names):
    return [f"{name}-{name[::-1]}" for name in names]

make_palindromes(names)
['milad-dalim', 'rodolfo-oflodor', 'tiffany-ynaffit', 'Firas-sariF']
  • Above: this is even better. We have now also solved problem (2), because you can call the function with any list, not just names.

  • For example, what if we had multiple lists:

names1 = ["milad", "rodolfo", "tiffany"]
names2 = ["Trudeau", "Scheer", "Singh", "Blanchet", "May"]
names3 = ["apple", "orange", "banana"]
names_backwards_1 = list()

for name in names1:
    names_backwards_1.append(name + name[::-1])

names_backwards_1
['miladdalim', 'rodolfooflodor', 'tiffanyynaffit']
names_backwards_2 = list()

for name in names2:
    names_backwards_2.append(name + name[::-1])

names_backwards_2
['TrudeauuaedurT', 'ScheerreehcS', 'SinghhgniS', 'BlanchettehcnalB', 'MayyaM']
names_backwards_3 = list()

for name in names3:
    names_backwards_3.append(name + name[::-1])

names_backwards_3
['appleelppa', 'orangeegnaro', 'bananaananab']

Above: this is very bad also (and imagine if it was 20 lines of code instead of 2). This was problem (2). Our function makes it much better:

make_palindromes(names1)
['milad-dalim', 'rodolfo-oflodor', 'tiffany-ynaffit']
make_palindromes(names2)
['Trudeau-uaedurT',
 'Scheer-reehcS',
 'Singh-hgniS',
 'Blanchet-tehcnalB',
 'May-yaM']
make_palindromes(names3)
['apple-elppa', 'orange-egnaro', 'banana-ananab']
  • You could get even more fancy, and put the lists of names into a list (so you have a list of lists).

  • Then you could loop over the list and call the function each time:

for list_of_names in [names1, names2, names3]:
    print(make_palindromes(list_of_names))
['milad-dalim', 'rodolfo-oflodor', 'tiffany-ynaffit']
['Trudeau-uaedurT', 'Scheer-reehcS', 'Singh-hgniS', 'Blanchet-tehcnalB', 'May-yaM']
['apple-elppa', 'orange-egnaro', 'banana-ananab']

Designing good functions#

  • How far you go with this is sort of a matter of personal style, and how you choose to apply the DRY principle: DON’T REPEAT YOURSELF!

  • These decisions are often ambiguous. For example:

    • Should make_palindromes be a function if I’m only ever doing it once? Twice?

    • Should the loop be inside the function, or outside?

    • Or should there be TWO functions, one that loops over the other??

  • In my personal opinion, make_palindromes does a bit too much to be understandable.

  • I prefer this:

def make_palindrome(name):
    return name + "-" + name[::-1]

make_palindrome("milad")
'milad-dalim'
  • From here, we want to “apply make_palindrome to every element of a list”

  • It turns out this is an extremely common desire, so Python has built-in functions.

  • One of these is map, which we’ll cover later. But for now, just a comprehension will do:

[make_palindrome(name) for name in names1]
['milad-dalim', 'rodolfo-oflodor', 'tiffany-ynaffit']

Other function design considerations:

  • Should we print output or produce plots inside or outside functions?

    • I would usually say outside, because this is a “side effect” of sorts

  • Should the function do one thing or many things?

    • This is a tough one, hard to answer in general

Optional & keyword arguments#

  • Sometimes it is convenient to have default values for some arguments in a function.

  • Because they have default values, these arguments are optional, hence “optional arguments”

  • Example:

def square(num, verbose = False):
    
    if verbose:
        print(f"You are a wonderful person, the square of the number you provided is {num**2}")
    return num**2


square(7,verbose=True)
You are a wonderful person, the square of the number you provided is 49
49
def repeat_string(s, n=2):
    return s * n
repeat_string("COSC301", 5)
repeat_string("mds-", 5)
repeat_string("mds")  # do not specify `n`; it is optional

Sensible defaults:

  • Ideally, the default should be carefully chosen.

  • Here, the idea of “repeating” something makes me think of having 2 copies, so n=2 feels like a sensible default.

Syntax:

  • You can have any number of arguments and any number of optional arguments

  • All the optional arguments must come after the regular arguments

  • The regular arguments are mapped by the order they appear

  • The optional arguments can be specified out of order

def example(a, b, c="DEFAULT", d="DEFAULT"):
    print(a, b, c, d)


example(
    1,
    2,
)

Using the defaults for c and d:

example(1, 2)

Specifying c and d as keyword arguments (i.e. by name):

example(1, 2, c=3, d=4)

Specifying only one of the optional arguments, by keyword:

example(1, 2, c=3)

Or the other:

example(1, 2, d=4)

Specifying all the arguments as keyword arguments, even though only c and d are optional:

example(a=1, b=2, c=3, d=4)

Specifying c by the fact that it comes 3rd (I do not recommend this because I find it is confusing):

example(1, 2, 3)  # not recommended

Specifying the optional arguments by keyword, but in the wrong order (this is also somewhat confusing, but not so terrible - I am OK with it):

example(1, 2, d=4, c=3)

Specifying the non-optional arguments by keyword (I am fine with this):

example(a=1, b=2)

Specifying the non-optional arguments by keyword, but in the wrong order (not recommended, I find it confusing):

example(b=2, a=1)

Specifying keyword arguments before non-keyword arguments (this throws an error):

example(a=2, 1)
  • In general, I am used to calling required arguments in order, and any optional arguments by keyword.

  • The language allows us to deviate from this, but it can be unnecessarily confusing sometimes.

Advanced stuff (optional):#

  • You can also call/define functions with *args and **kwargs; see, e.g. here

  • Do not instantiate objects in the function definition - see here under “Mutable Default Arguments”

def example(a, b=[]):  # don't do this!
    return 0
def example(a, b=None):  # insted, do this
    if b is None:
        b = []
    return 0

Docstrings (10 mins)#

  • We got pretty far above, but we never solved problem (4): It is hard to understand what it does just by looking at it

  • Enter the idea of function documentation (and in particular docstrings)

  • The docstring goes right after the def line.

def make_palindrome(string):
    """Turns the string into a palindrome by concatenating itself with a reversed version of itself."""

    return string + string[::-1]

In IPython/Jupyter, we can use ? to view the documentation string of any function in our environment.

make_palindrome?
print?

Docstring structure#

  1. Single-line: If it’s short, then just a single line describing the function will do (as above).

  2. PEP-8 style Multi-line description + a list of arguments; see here.

  3. Scipy style: The most elaborate & informative; see here and here.

The PEP-8 style:

def make_palindrome(s):
    """
    Turns the string into a palindrome by concatenating itself
    with a reversed version of itself.

    Arguments:
    s - (str) the string to turn into a palindrome
    """
    return s + s[::-1]
make_palindrome?

The scipy style:

def make_palindrome(s):
    """
    Turn a string into a palindrome.

    Turns the string into a palindrome by concatenating itself
    with a reversed version of itself, so that the returned
    string is twice as long as the original.

    Parameters
    ----------
    s : str
        The string to turn into a palindrome.

    Returns
    -------
    str
        The new palindrome string.

    Examples
    --------
    >>> make_palindrome("abc")
    "abccba"
    """
    return s + s[::-1]
make_palindrome("hello")  # press shift-tab HERE to get docstring!!

Below is the general form of the scipy docstring (reproduced from the scipy/numpy docs):

def function_name(param1, param2, param3):
    """First line is a short description of the function.

    A paragraph describing in a bit more detail what the
    function does and what algorithms it uses and common
    use cases.

    Parameters
    ----------
    param1 : datatype
        A description of param1.
    param2 : datatype
        A description of param2.
    param3 : datatype
        A longer description because maybe this requires
        more explanation and we can use several lines.

    Returns
    -------
    datatype
        A description of the output, datatypes and behaviours.
        Describe special cases and anything the user needs to
        know to use the function.

    Examples
    --------
    >>> function_name(3,8,-5)
    2.0
    """

Docstrings with optional arguments#

When specifying the parameters, we specify the defaults for optional arguments:

# PEP-8 style
def repeat_string(s, n=2):
    """
    Repeat the string s, n times.

    Arguments:
    s -- (str) the string
    n -- (int) the number of times (default 2)
    """
    return s * n
# scipy style
def repeat_string(s, n=2):
    """
    Repeat the string s, n times.

    Parameters
    ----------
    s : str
        the string
    n : int, optional (default = 2)
        the number of times

    Returns
    -------
    str
        the repeated string

    Examples
    --------
    >>> repeat_string("Blah", 3)
    "BlahBlahBlah"
    """
    return s * n

Automatically generated documentation#

  • By following the docstring conventions, we can automatically generate documentation using libraries like sphinx, pydoc or Doxygen.

    • For example: compare this documentation with this code.

    • Notice the similarities? The webpage was automatically generated because the authors used standard conventions for docstrings!

What makes good documentation?#

  • What do you think about this?

################################
#
# NOT RECOMMENDED TO DO THIS!!!
#
################################


def make_palindrome(string):
    """
    Turns the string into a palindrome by concatenating itself
    with a reversed version of itself. To do this, it uses the
    Python syntax of `[::-1]` to flip the string, and stores
    this in a variable called string_reversed. It then uses `+`
    to concatenate the two strings and return them to the caller.

    Arguments:
    string - (str) the string to turn into a palindrome

    Other variables:
    string_reversed - (str) the reversed string
    """

    string_reversed = string[::-1]
    return string + string_reversed



  • This is poor documentation! More is not necessarily better!

  • Why?

    • Very verbose

    • Write documentation about “what it does” and not “how you did it” (that is an implementation detail)

Side effects (careful!)#

  • If a function changes the variables passed into it, then it is said to have side effects

  • Example:

def silly_sum(sri):
    sri.append(0)
    return sum(sri)
silly_sum([1, 2, 3, 4])

Looks good, like it sums the numbers? But wait…

lst = [1, 2, 3, 4]
silly_sum(lst)
silly_sum(lst)
lst
  • If you function has side effects like this, you must mention it in the documentation (later today).

  • In general avoid this!