Python

For Loops

names = ['Marcus', 'Christine', 'Dan', 'Joe', 'Jenny']
for name in names:
print(name)

The for-in structure above loops through each item in a collection and returns the individual item in the name variable given between for and in. Substituting any collection or iterable given in place of names achieves the same result. This can include the range function call which instead gives a number range.

for num in range(1, 10):
print(num)

As you can see any function call that returns a collection or iterable (iterable in the case of range) can be used. Here range which returns an iterable describing the numbers, spaced by 1 whole integer by default when no third positional argument is given from 1 to one step below the second number, in this case 9, 10 is excluded.

Read Lines of File

If you're going to read a file with lines representing an entry, open is the primary function to use. In this example from Advent of Code there are lines of input given. Each line is a pair of number ranges in need of processing.

2-4,6-8
2-3,4-5
5-7,7-9
2-8,3-7
6-6,4-6
2-6,4-8

To read each line in the range, do something like this:

try:
with open(self.file_path) as file:
for line in file:
# Remove any whitespace
pair_str = line.split()[0]
pair = ShipAssignmentPair(pair_str)
assignments.append(pair)
except IOError as e:
print(f"ShipAssignment file open error:\n{e}")
except:
print("Unknown Error during file ShipAssignment opening!")
finally:
self.assignments = assignments
file.close()

The open function takes a file path. The with-as structure creates a closure to safely open a file then perform some actions on the file then close it when done. The try-except-finally syntax handles any errors associated with reading or processing the file contents. The line, except IOError as e, will raise an IOError exception if the file can't be opened or read. The empty except: line will handle any other exceptions. The finally line opens a block of code to be performed after the file is read and processed in assignments.append(pair).

The for-in loop involving the file object opened will iterate every line of the file, including any newline characters indicating the end of the line. It will probably be necessary to split() any whitespace, including any newline characters out, otherwise they end up in the results.

Get Multiple Numbers from String

To extract the numbers from a string, in order, this list comprehension should suffice.

txt = "h3110 23 cat 444.44 rabbit 11 2 dog"
print([int(s) for s in txt.split() if s.isdigit()])
# output: [23, 11, 2]

The for s in txt.split() sets up a loop to get every whitespace separated substring. Any other split delimiter could be used instead as its first argument. Then, the if s.isdigit() will determine if the substring has a number. Finally, the int(s) turns it into a number, float could be used as well. When the list comprehension is done, the list [23, 11, 2] should be returned.

Classes

Basics

A Python class is a template for creating new object types. To do so, use they keyword class followed by the name of the class and a colon.

class Contact:
a = 5

my_contact = Contact()

The variable a is a class variable set to 5. Creating an object of this class is done by calling the class name as a function. This is known as a constructor and each class can have one. Calling the constructor for Contact and assigning it to the variable my_contact creates an object of the class Contact. Then to access the class variable a of the object my_contact do this:

print(my_contact.a)
# Output: 5

This was a very basic class, let's update it to be more complex.

class Contact:
def __init__(self, name, email):
self.name = name
self.email = email

def shout(self):
print(self.name)

p1 = Contact("Mary", "mary@mit.edu")
p1.shout() # Output: Mary

Pay attention to the __init__ function. The underscore characters typically indicate a special function in Python. The underscores are also known as dunder or double underscore. The constructor function that gets called to create a Contact object is actually calling the __init__ function to set up the members of the object before the reference to that object is returned.

The self parameter is a reference to the object itself. The __init__ function must always have self as its first parameter. Python will automatically pass the reference to the object as the first argument. This allows us to assign values to the object's members using the dot operator. Which we do by taking the name and email parameters and assigning them to the name and email members of the object.

The shout function is known as a method, or a function that is a member of a class. They should always have self as their first parameter just as the constructor. Because they are members of a class, they can access the specific properties of the object they are called on. Which is why when p1.shout() is called, the self.name member of the object p1, or Mary gets printed.

Unpacking Operator

The unpacking operator * can be used to unpack a list or tuple. Also known as the destructure operator. It will take the elements of a list or tuple and assign them to the variables on the left of the operator as if they are individual variables. Let's look at what happens without the unpacking operator.

a = [1, 2, 3]
print(a) # Output: [1, 2, 3]

As expected, the list [1, 2, 3] is printed. If you want to unpack the list into individual variables, you can use the * operator ahead of the collection variable.

a = [1, 2, 3]
b, c, d = *a # Each element of a is assigned to b, c respectively
print(*a) # Output: 1 2 3

Where this is useful is when you want a single part of the collection. Say you want only the first part, you can manually destructure the first part and then have a destructure operation assign the rest of the list to a variable.

a = [1, 2, 3]
X, *rest = a # X gets the first element, rest gets the rest
print(X) # Output: 1
print(rest) # Output: [2, 3]

It's also useful to combine elements of two collections into a single collection.

a = [1, 2, 3]
b = [4, 5, 6]
c = [*a, *b] # Combine the elements of a and b into a single list
print(c) # Output: [1, 2, 3, 4, 5, 6]

This will destructure the elements of a and b and when assigned as the first and last parts of the list respectively, the elements in a are put before the elements in b.

NOTE: the unpacking operator only destructure to lists. If you have a tuple to destructure, the rest of the elements will be a list.

a = (1, 2, 3)
X, *rest = a # X gets the first element, rest gets the rest as a list
print(X) # Output: 1
print(rest) # Output: [2, 3]

Advanced Function Arguments

Unnamed Arguments

Unnamed arguments are arguments that are passed to a function without a name. This is the typical way to pass arguments to a function and has already been covered. Unnamed arguments are also known as positional arguments. Positional because the arguments are referenced by their position in the function call. They have a name within the function, but that name is not used when calling the function.

def add(a, b):
return a + b
print(add(1, 2)) # Output: 3

As should be clear already, add adds the two positional arguments 1 and 2 and returns 3. If we wanted to reorder them or refer to the arguments by name, we use named arguments.

Arbitrary Arguments

Using the *args unpacking operator, we can pass any generic number of arguments to a function. These are not keyword arguments, but rather arbitrary arguments that get handled according to the function logic.

def add(*args):
total = 0
for arg in args:
total += arg
return total
print(add(1, 2, 3, 4, 5)) # Output: 15

As you can see, the number of arguments passed to add is arbitrary. The unpacking operator in the typical *args syntax, which is a convention and not a requirement, will take the arguments and put them into a list. Then the loop just goes through all of them and adds them together.

Like unnamed arguments, or positional arguments, these are positional as well, but are treated as an arbitrary list of them when called. This is also different from just passing a list to the function, because you're still calling the function with commas separating the arguments.

Named Arguments

Named arguments or keyword arguments are arguments that are passed to a function with a name. By convention, these are known as kwargs or **kwargs. Note the double unpacking operator ** in the syntax. This syntax is there to deal with the dictionary like nature of keyword arguments. They're named so dealing with them is a bit like working with dictionaries.

def add(*args, **kwargs):
total = 0
for arg in args:
total += arg
for key, value in kwargs.items():
total += value
return total
print(add(1, 2, 3, 4, 5, a=6, b=7, c=8)) # Output: 36

You pass arbitrary named arguments using the key=value syntax inside the function call parentheses. The unpacking operator **kwargs will take the arguments as if they were dictionary key value pairs. So calling kwargs.items() will return a list of tuples, where the first element is the key and the second element is the value. This allows us to iterate through them and include them in the total.

Also note you can have both unnamed arguments and named arguments together in the same function call. By convention, unnamed arguments or *args are always listed first.

Further Reading

Real Python has a great article covering all of the different ways to pass arguments to functions.

Closure

Wrappers

Functions that wrap other functions are called decorators. To demonstrate why you might want to wrap a function using a decorator, let's see an example without decorators.

def hello():
return "Hello World!"

print(hello()) # Output: Hello World!

Simple function, it merely returns the string "Hello World!". Let's say you wanted to expand on the functionality of this function.

def wrapper(func):
greeting = "Wrapper says "
return f"{greeting} {func()}!!!"

print(wrapper(hello)) # Output: Wrapper says Hello World!!!!

The wrapper function takes a function as an argument and returns a template string with the function call of the function passed in. So the result of the print statement is the string "Wrapper says Hello World!!!!".

Decorators

Decorators are a bit more elegant than wrappers. Decorators are functions that take a function as an argument, and return a function. Let's try this example.

def hello():
return "Hello World!"

def my_deco(func):
def wrapper():
greeting = "Wrapper says "
return f"{greeting} {func()}!!!"
return wrapper

my_hello = my_deco(hello)
print(my_hello()) # Output: Wrapper says Hello World!!!!

It might be hard to see the elegance of this approach, but it's a bit more concise and easier to read when you know what's going on. The my_deco function takes a function as an argument, and returns a function. The returned function is the wrapper function.

The wrapper function takes no arguments, but is within the scope of the my_deco function so it can access the func argument. The wrapper function returns a string with the func argument called. The my_deco function returns the wrapper function.

The my_hello variable is assigned the return value of the my_deco function when it's called with the hello function as an argument. The resulting function my_hello is then called and prints the string "Wrapper says Hello World!!!!".

Note that the wrapper wrapper function is not called directly. Rather it gets defined and returned by the my_deco function with all of the scope of the my_deco function enclosed in it. Closures are a bit beyond the scope of this tutorial, but they expand on decorators and wrappers in a really useful way.

To further illustrate how decorators add functionality to functions, let's try another example, this time with the arbitrary named and unnamed arguments from before.

def add(*args, **kwargs):
result = 0
for arg in args:
result += arg
for k, v in kwargs.items():
result += v
return result

def my_deco(func):
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
return f'my decorated result: {result}!!!'
return wrapper

my_add = my_deco(add)
print(my_add(1, 2, 3, a=4, b=5, c=6)) # Output: my decorated result: 21!!!

We pass in arbitrary unnamed and named arguments to the add function, just like before by using the conventional *args and **kwargs and unpacking syntax. Again, the my_deco function takes a function as an argument, and returns a function. But this time the returned function is the wrapper function that takes arbitrary unnamed and named arguments as well. The wrapper function however still returns a template string with the result of calling the func argument with the unnamed and named arguments.

Note it's important to stress this, the wrapper function is still within the scope of the my_deco function, and it is merely being defined and returned, not called.

When my_add gets assigned, my_deco is decorating the add function with the wrapper function. In this case it's the add function that takes arbitrary named & unnamed arguments that's being decorated. So when my_add is called with arbitrary unnamed and named arguments, those arguments get passed to the add function which adds them together, then the decorating wrapper function returns the template string my decorated result: 21!!! with 21 being the result of the add function.

Decoration Syntax

There's an even more elegant way to decorate functions. The @ symbol, along with a function signature, is used to decorate whatever function is below it. Let's rework the previous example to show how much cleaner the code is:

def my_deco(func):
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
return f'my decorated result: {result}!!!'
return wrapper

@my_deco
def add(*args, **kwargs):
result = 0
for arg in args:
result += arg
for k, v in kwargs.items():
result += v
return result

print(add(1, 2, 3, a=4, b=5, c=6)) # Output: my decorated result: 21!!!

This does exactly the same thing as the previous example, but it's much cleaner and easier to read. The @my_deco when decorating the add function, will pass the function below the @my_deco as an argument to the my_deco function. Then the same process is done for any calls of the add function later.

TODO: Write about Why this Example Works

def add_values(*args):
result = 0
for val in args:
result = result + val
return result

ans5a = None
ans5b = None

def my_add_values(fn, *args):
result = f"Added decoration: {fn}"
return result


# YOUR CODE HERE
ans5a = my_add_values(add_values(1,2,3,4,5,6,7,8,9,10))
ans5b = my_add_values(add_values(40, 60, 70, 100))
print(f"ans5a: {ans5a}\nans5b: {ans5b}")

Closure

TODO:!

Type Checking/Annotation

For more information check out this handy guide

Common Libraries

One of the best things about Python is not just it's large and practical Standard Library, but also the fact that it has one of the largest ecosystems of libraries around. Quickly, a library aka a module is a reusable collection of code made for specific tasks. It's possible to download such libraries using pip and incorporating this code for your purposes.

How to Import Libraries

Generally, libraries get imported at the beginning of a piece of code. This helps avoid mistakes that make programs less time efficient. For example, by avoiding importing a module twice.

import numpy

Aliasing Libraries

Another standard practice with importing libraries is aliasing it by using a shorter name. This makes code more readable and keeps the width of code files narrower. Note that the Python community has evolved some standard abbreviations for these aliases so pay attention to the ones they use in your own work. To alias a library, simply follow up the import statement with an as statement and then the alias for that library.

import numpy as np

NumPy

NumPy, short for Numerical Python, is a library that adds support for multi-dimensional arrays, matrices and tensors. NumPy also offers a large collection of high-level mathematical functions, particularly in the fields of linear algebra, statistics and scientific computing.

Statistics in Python

Python over the years has become one of the preferred ways that we analyze data. Statistics is of course one of the primary ways that we look at data, and python is full of modules that can make this easier on us. In the notes on statistics using python

References

Note References

Web/Article References