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

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

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

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

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:

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.

In [4]:

```
x = 5
y = x
y = 111
print(x, y)
```

In [5]:

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

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

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

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

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

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.

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

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

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

In [14]:

```
print(passed(students, False))
```

The order is important! Here was `limit=False, sort=True`

, and `limit=False`

means `limit=0`

.

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

In [16]:

```
print("P({x}, {y})".format(y=3, x=4))
```

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

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

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

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

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

In [22]:

```
# Correct
l = [1, 2, 3]
print(product3(5, *l))
# equivalent to this
print(product3(5, l[0], l[1], l[2]))
```

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

Here the arguments are copied into a **dictionary**:

In [24]:

```
def article(**journal):
print(type(journal))
article(author="Euler", year=1780)
```

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

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

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

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

In [29]:

```
def f(x):
i = 0 # this creates a local i
print(i)
i = 10
f(None)
```

In [30]:

```
i
```

Out[30]:

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

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

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

In [37]:

```
bubble(l, decreasing)
```

Out[37]:

In [ ]:

```
```