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]
squared_numbers = square(numbers)
print numbers
print squared_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]
squared_numbers = square(numbers)
print numbers
print squared_numbers
```

The original `numbers`

list has changed also.
An other example:

In [3]:

```
def square(n):
n = n * n
return n
number = 5
squared_number = square(number)
print number
print squared_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.

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 = 6
print x, y
```

In [5]:

```
L1 = [1, 5, 2]
L2 = L1
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[:]
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[:]
M2[0][0] = 5
print M1
print M2
```

In [8]:

```
M1 = [[1, 2], [3, 4]]
M2 = M1[:]
M2[0] = [5, 2]
print M1
print M2
```

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

In [9]:

```
import copy
M1 = [[1, 2], [3, 4]]
M2 = copy.deepcopy(M1)
M2[0][0] = 5
print M1
print M2
```

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.

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

In [10]:

```
L = [1,2,5]
def erase(L):
L = []
erase(L)
print L
```

This would be a correct erase:

In [11]:

```
def erase(L):
while len(L) > 0:
del L[0]
erase(L)
print L
```

It is also a bit more tricky for immutable objects. Even if you get a reference to a string, the `str`

type is immutable,
meaning that you cannot change its value, even if it is a reference.

In [12]:

```
def MakeCapital(s):
s[0] = s[0].upper() # it works neither inside a function nor outside
a = "abc"
MakeCapital(a)
print a
```

In [13]:

```
def passed(students, limit):
L = []
for name in students:
if students[name] >= limit:
L.append(name)
return L
students = {'ABCDEF': 50, 'BATMAN': 23, '123ABC': 67}
print passed(students, 40)
```

You can set the `limit`

parameter to default `40`

In [14]:

```
def passed(students, limit=40):
L = []
for name in students:
if students[name] >= limit:
L.append(name)
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 [15]:

```
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, 40, False)
```

The following is wrong, because you set the `limit`

to `False`

not the `sort`

parameter.

In [16]:

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

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

In [17]:

```
print passed(students, sort=False)
print passed(students, sort=False, limit=40)
```

In [18]:

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

Lets take a function that multiples numbers.

In [19]:

```
def product(L):
result = 1
for i in L:
result *= i
return result
print product([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 or 3 parameters.

In [20]:

```
def product2(x=1, y=1, z=1):
return x*y*z
print product2()
print product2(1)
print product2(1, 2)
print product2(1, 2, 3)
```

Instead, you can make a function with **arbitrary many parameters**. variadic function

See that the only difference is the `*`

In [21]:

```
def product3(*L):
result = 1
for i in L:
result *= i
return result
print product3(1,2,3)
print product3(1, 2, 3, 4, 5, 6)
```

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

In [22]:

```
def variadic(*x):
return type(x)
print variadic(3,2)
```

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

This is not correct:

In [23]:

```
L = [1, 2, 3]
print product3(L)
```

In [24]:

```
# Correct
print product3(*L)
# equivalent to this
print product3(L[0], L[1], L[2])
```

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 = 0
for i in L:
i = i+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 can be seen anywhere in the code: **global**.

In [28]:

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

Mind that an `if`

branch can introduce a variable and some variables can be present in some cases and undefined in other cases.

In [29]:

```
def f(x):
if x:
i = 0
return i
print f(True)
print f(False)
```

In [ ]:

```
```