More about functions

Value vs. reference

What happens if you call a function? A familiar example:

In [1]:
def square(l):
    new_l = []
    for i in l:
        new_l.append(i*i)
    return new_l

numbers = [5, 1, 8]
sq_numbers = square(numbers)

print(numbers)
print(sq_numbers)
[5, 1, 8]
[25, 1, 64]

Here we make a new list out of the squared values of the original list.

What happens if you square the elements in the original l directly?

In [2]:
def square(l):
    for i in range(len(l)):
        l[i] = l[i] * l[i]
    return l

numbers = [5, 1, 8]
sq_numbers = square(numbers)

print(numbers)
print(sq_numbers)
[25, 1, 64]
[25, 1, 64]

The original numbers list has changed also. An other example:

In [3]:
def square(n):
    n = n * n
    return n

number = 5
sq_number = square(number)

print(number)
print(sq_number)
5
25

Now the original number is unchanged. To understand this mechanism, we have to go deeper.

Value or reference is stored

Some data types are stored as a concrete value, like int(5), float(3.14). They are the primitive data types.

  • int
  • bool
  • float

Other data types are stored as a reference to an actual memory location.

  • list
  • dict
  • str
  • any other class

In this case: l = [1, 2, 3] the variable l is a reference to the actual triplet of numbers.

To better understand the Python language we can use the python tutor:

1st example

2nd example

3rd example

Copy

All of this becomes interesting when you want to copy an object.

If you make a copy of a primitive type, then a new number is created which is the copy of the original. Modifying the copy does not effect the original one.

If you copy of a reference, only the reference itself is copied and both the original and the new reference refer to the same thing. In this case modifying the copy modifies the original too.

Primitive

In [4]:
x = 5
y = x
y = 111

print(x, y)
5 111

Reference

In [5]:
l1 = [1, 5, 2]
l2 = l1      # copy the reference only
l2.sort()

print(l1)
print(l2)
[1, 2, 5]
[1, 2, 5]

You can force a copy of a list in this way:

In [6]:
l = [1, 2, 3]
m = l[:]    # shallow copy
m[1] = 9
print(l)
print(m)
[1, 2, 3]
[1, 9, 3]

But it's more complicated in deeper structures. Here I only copy the outermost list, not the inner lists.

In [7]:
m1 = [[1, 2], [3, 4]]
m2 = m1[:]   # shallow copy: copy the reference and the first level
m3 = m1[:]

m2[0] = [55, 22]
print(m1)
print(m2)

m3[0][0] = 555
print(m1)
print(m3)
[[1, 2], [3, 4]]
[[55, 22], [3, 4]]
[[555, 2], [3, 4]]
[[555, 2], [3, 4]]

Look at it in the tutor.

To copy this properly, you need to copy it recursively. But you have a function for that.

In [8]:
import copy

m1 = [[1, 2], [3, 4]]
m2 = copy.deepcopy(m1) # deep copy: copy all level
m3 = copy.deepcopy(m1)

m2[0] = [55, 22]
m3[0][0] = 555

print(m1)
print(m2)
print(m3)
[[1, 2], [3, 4]]
[[55, 22], [3, 4]]
[[555, 2], [3, 4]]

Pass argument by value

In python (and many other languages) the function gets a copy of its arguments.

But this means different things in case of primitive and reference types.

In the second square example the list l was a copy of a reference, therefore we were able to modify the original numbers also.

In the third square example the number was a primitive type, so the copy n does not interfere with the original one.

If you don't want to modify the original variables then it is a good practice to deepcopy them inside a function and modify the copies. Or create a whole new variable like in the first square example.

Note that modifying the reference itself does not change the refered object, only that the reference will refer to some new object.

In [9]:
l = [1, 2, 5]

def erase(l):
    l = []

erase(l)
print(l)
[1, 2, 5]
In [10]:
l = [1, 2, 5]
del l[:]
print(l)
[]

Remember! Strings and tuples are immutables, meaning that you cannot change their elements, even if they are references.

Extra function arguments

Default parameter value

Write a program, which lists the Neptun codes of the students reached the given limit of a test.

In [11]:
def passed(students, limit):
    l = []
    for neptun in students:
        if students[neptun] >= limit:
            l.append(neptun)
    return l

students = {'ABCDEF': 50, 'BATMAN': 23, '123ABC': 67}
passed(students, 40)
Out[11]:
['ABCDEF', '123ABC']

You can set the limit parameter to default 40

In [12]:
def passed(students, limit=40):
    l = []
    for neptun in students:
        if students[neptun] >= limit:
            l.append(neptun)
    return l

students = {'ABCDEF': 50, 'BATMAN': 23, '123ABC': 67}
print(passed(students))
print(passed(students, 60))
['ABCDEF', '123ABC']
['123ABC']

You can specify more optional parameters but only from the right.

In [13]:
def passed(students, limit=40, sort=True):
    l = []
    for name in students:
        if students[name] >= limit:
            l.append(name)
    if sort:
        return sorted(l)
    else:
        return l

students = {'ABCDEF': 50, 'BATMAN': 23, '123ABC': 67}
print(passed(students))
print(passed(students, 20, False))
['123ABC', 'ABCDEF']
['ABCDEF', 'BATMAN', '123ABC']
In [14]:
print(passed(students, False))
['123ABC', 'ABCDEF', 'BATMAN']

The order is important! Here was limit=False, sort=True, and limit=False means limit=0.

Pass parameter by name

You can substitute the optional parameters by name, not only by place.

In [15]:
print(passed(students, sort=False))
print(passed(students, sort=False, limit=20))
['ABCDEF', '123ABC']
['ABCDEF', 'BATMAN', '123ABC']
In [16]:
print("P({x}, {y})".format(y=3, x=4))
P(4, 3)

Arbitrary number of arguments: *args (variadic function)

Lets take a function that multiplies some numbers, and add an other one to the product.

In [17]:
def product(c, l):
    p = 1
    for i in l:
        p *= i
    return p + c

print(product(1, [1, 2, 3]))
7

You can make a multi-variate function instead of a function with one list parameter. You can call this function with either 0, 1, 2, 3 or 4 parameters.

In [18]:
def product2(c=0, x=1, y=1, z=1):
    return x*y*z + c

print(product2())
print(product2(1))
print(product2(1, 1))
print(product2(1, 1, 2))
print(product2(3, 1, 2, 3))
1
2
2
3
9

Instead, you can make a function with arbitrary many parameters. See variadic function in Wikipedia.

See that the only difference is the *

In [19]:
def product3(c, *x):
    p = 1
    for i in x:
        p *= i
    return p + c

print(product3(5))
print(product3(5, 1, 2, 3, 4, 5, 6))
print(product3(5, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10))
6
725
3628805

In this case the parameters are copied into a tuple, that can be any long.

In [20]:
def variadic(*t):
    return type(t)

print(variadic(3, 2))
<class 'tuple'>

List as a parameter to a variadic function

Let's say you have a list but you wan to multiply them with a variadic function.

In [21]:
# Not correct
c = 5
l = [1, 2, 3]
print(product3(c, l))
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-21-7231f86e3272> in <module>
      2 c = 5
      3 l = [1, 2, 3]
----> 4 print(product3(c, l))

<ipython-input-19-dbea9e78c155> in product3(c, *x)
      3     for i in x:
      4         p *= i
----> 5     return p + c
      6 
      7 print(product3(5))

TypeError: can only concatenate list (not "int") to list
In [22]:
# Correct
l = [1, 2, 3]
print(product3(5, *l))

# equivalent to this
print(product3(5, l[0], l[1], l[2]))
11
11

Arbitrary number of keyword arguments: **kwargs

Print out some library data of a scientific article!

In [23]:
def article(**journal):
    for i in journal:
        print(i, "=", journal[i], end=", ")
    print()
        
article(author="Endre Szemerédi", title="On Sets of Integers Containing...", year=1975)
author = Endre Szemerédi, title = On Sets of Integers Containing..., year = 1975, 

Here the arguments are copied into a dictionary:

In [24]:
def article(**journal):
    print(type(journal))

article(author="Euler", year=1780)
<class 'dict'>

Scope

wikipedia

The scope of a variable determines the portion of the program where you can access it. There can be more than one variable with the same name, but it matters where.

In [25]:
def function(l):
    # i is different
    for i in l:
        if i != 0:
            return True
    return False

i = [0, 1, -1]
print(function(i))
True
In [26]:
def something(l):
    # i is mixed up
    i = 11
    for i in l:
        i = 5*i
    return i

print(something([1, 2, 3]))
15
In [27]:
def something2(l):
    i = 0
    for j in l:
        i = i + j
    return i

# i is unchanged outside of the function
i = 10
print(something2([1, 2, 3]))
print(i)
6
10

A variable defined inside a function is local to that function and does not interfere with the same named variable outside of the function.

If you call that function many times, the values between runs are not connected in any way.

If you define a variable outside of any function, then it is global, so it can be seen anywhere in the code. Except if it meets a local variable with the same name.

In [28]:
def f(x):
    # i is global here
    print(i)
    
i = 10
f(None)
10
In [29]:
def f(x):
    i = 0 # this creates a local i
    print(i)
    
i = 10
f(None)
0
In [30]:
i
Out[30]:
10

Mind that an if branch can initialize, so it can be present in some cases and undefined in other cases. But in this situation, the global variable with the same name is not in the scope.

In [31]:
def f(x):
    """i is local here even if x is False
       when you get an UnboundLocalError: 
       local variable 'i' referenced before assignment
    """
    if x:
        i = 0
    return i

i = 11
print(f(True))    # i is assigned here
print(f(False))   # i has no value = unbound
0
---------------------------------------------------------------------------
UnboundLocalError                         Traceback (most recent call last)
<ipython-input-31-4e0c6e430ee1> in <module>
     10 i = 11
     11 print(f(True))    # i is assigned here
---> 12 print(f(False))   # i has no value = unbound

<ipython-input-31-4e0c6e430ee1> in f(x)
      6     if x:
      7         i = 0
----> 8     return i
      9 
     10 i = 11

UnboundLocalError: local variable 'i' referenced before assignment

Function reference

Yes, function names also can be parameters in a function call!

Take the program of the bubble sort algorithm:

In [32]:
def bubble(l):
    newl = l[:]
    for i in range(len(newl) - 1):
        for j in range(len(newl) - i - 1):
            if newl[j] > newl[j + 1]:
                newl[j], newl[j+1] = newl[j+1], newl[j]
    return newl
In [33]:
l = [1, 4, 2, 3, 8, 6, 9, 7]
bubble(l)
Out[33]:
[1, 2, 3, 4, 6, 7, 8, 9]

Change the code for any ordering which defined in an other function! First let us define some ordering functions by defining the result of compairing:

In [34]:
def increasing(a, b):
    if a < b:
        return False
    else:
        return True
    
def decreasing(a, b):
    if a > b:
        return False
    else:
        return True
In [35]:
def bubble(l, order=increasing):
    newl = l[:]
    for i in range(len(newl) - 1):
        for j in range(len(newl) - i - 1):
            if order(newl[j], newl[j + 1]):  # ordering
                newl[j], newl[j+1] = newl[j+1], newl[j] 
    return newl
In [36]:
bubble(l)
Out[36]:
[1, 2, 3, 4, 6, 7, 8, 9]
In [37]:
bubble(l, decreasing)
Out[37]:
[9, 8, 7, 6, 4, 3, 2, 1]
In [ ]: