Think (more) Python#

Reference or value#

It’s crucial to understand how values are passed to functions and whether they are passed by value or by reference. In Python, primitive data types (integers, floats, strings, etc.) are passed by value. This means that when you pass a primitive data type to a function, a copy of the value is created, and any modifications made within the function do not affect the original value outside the function. Conversely, complex data types (lists, dictionaries, objects, etc.) are passed by reference. This means that when you pass a complex data type to a function, you are passing a reference to the same memory location, and any modifications made to the object inside the function will affect the original object outside the function. The example, below shows how changing (appending) a list inside a function also changes the list at the outer scope.

def marks(mylist):
    mylist.append([11, 12, 13, 14, 15])
    print("Value inside the function: ", mylist)
    return
mylist = [10,20]
marks(mylist)
print("Value outside the function: ", mylist)
Value inside the function:  [10, 20, [11, 12, 13, 14, 15]]
Value outside the function:  [10, 20, [11, 12, 13, 14, 15]]

Also, object copies are shallow, i.e., only the reference is copied not the value. If you want a deep copy (by value), you need to use the copy() method: deep_copy = original.copy().

original = [4, 6, 8]
shallow_copy = original
shallow_copy[1] = 999
original
[4, 999, 8]

Iterators vs generators#

Iterators are objects that can be iterated over in a loop. Basic iterators are lists, dictionaries, tuples, and character strings. Yes, also character strings behave like lists of individual characters. Try it out.

my_iterator = "Hello"
for i in my_iterator:
    print(i)
H
e
l
l
o

Generators behave like iterators, but they do not store the values in memory. Rather they are functions that generate the iteration sequence on demand, i.e., during the loop. For this class, you do not need to know how to create generators yourself, but you will stumble upon them. For example, Python’s range function creates a generator that generates a sequence of numbers. Since the generator does not store values, you will see the following outputs when you try to print the results.

range(10)
range(0, 10)

To trigger the generator you need to use it in a loop or you can convert it to a list.

list(range(10))
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

Packing and unpacking#

Unpacking refers to the process of breaking up a list or tuple into its individual elements. Packing refers to the process of combining individual elements into a list or tuple. The * operator is used to unpack a list in Python. Here, an example in which list unpacking is used to feed a function with three arguments.

def fun(a, b, c): 
    print(a, b, c) 
  
# parameter as list
my_list = [2, 4, 10] 
  
# Unpacking list into four arguments 
fun(*my_list)
2 4 10

A dictionary is often more useful as input into a function, since its keys are mapped to the argument names of the function. The ** operator is used to unpack a dictionary in Python.

def fun(a=None, b=None, c=None): 
    print(a, b, c) 
  
# A call with unpacking of dictionary 
d = {'c':10, 'a':2, 'b':4} 
fun(**d)
2 4 10

Conversely, when we don’t know how many arguments need to be passed to a python function, we can use packing to pack all arguments in a tuple.

def pack_tuple(*args):
    return args
 
pack_tuple(1, 2, 3, 4, 5)
(1, 2, 3, 4, 5)

Unpacking a Tuple#

We can extract the values of a tuple by assigning the tuple to multiple variables at once.

stats = (0.8, ["A", "B"], {'pred': [1,1,1,0], 'obs': [0,1,1,1]})
rsquare, labels, modout = stats
labels
['A', 'B']

This feature is really handy when writing functions that need to return multiple outputs. See the example below. Note, no need to use parenthesis in the return statement.

def useless_fun():
    rsquare = 0.8
    labels = ["A", "B"]
    modout = {'pred': [1,1,1,0], 'obs': [0,1,1,1]}

    return rsquare, labels, modout

a, b, c = useless_fun()
c
{'pred': [1, 1, 1, 0], 'obs': [0, 1, 1, 1]}

Loop counter#

In Python, a for loop is usually written as a loop over an iterable object. This means you don’t need a counting variable to access items in the iterable. However, sometimes it is useful or needed to also have a counter that reflects the position of the variable in the iterator object. Rather than creating and incrementing a variable yourself, you can use Python’s enumerate() to get a counter and the value from the iterable at the same time.

grades = ["A", "B", "C", "D"]
students = [12, 24, 3, 1]
for i, grade in enumerate(grades):
    print(grade + ": " + str(students[i]))
A: 12
B: 24
C: 3
D: 1