Functions¶

Functions are re-usable pieces of code for performing tasks. They receive input via the given arguments$\small{\text{*}}$, execute a set of statements to process their input, and return the result as their output.

The syntax looks like this:

def function_name(var1, var2):
    <statement-1>
    <statement-2>
    ...
    <statement-N>
    return <value>

$\tiny{\text{*: Technically, input variables of functions in the definition are called "parameters" (such as "var1" above).}} \\ \tiny{\text{When calling a function, the values provided to be substituted for the parameters are called "arguments".}} \\ \tiny{\text{The two terms are more-or-less interchangeable for our discussion, though.}} $

Here's a function that adds two numbers, following this syntax:

In [1]:
def add(x, y):
    result = x + y
    return result

add(10, 20)
Out[1]:
30

This function receives two arguments, and assigns x and y to these arguments. The arguments do not have to be called x and y when given to the function:

In [2]:
a = 5
b = 40
add(a, b)
Out[2]:
45

We can assign a variable to the result (the value returned with the return keyword) of a function call:

In [3]:
a = 10
b = 20

c = add(a, b)
c
Out[3]:
30

None¶

Technically, using return inside of a function call is optional. What happens when we don't return a value?

In [4]:
def f(a, b):
    c = a + b

result = f(10, 20)
result

Nothing gets printed, but result is assigned something. That value is something called None:

In [5]:
type(result)
Out[5]:
NoneType

None represents "nothing". We encounter it from time to time, it is generally used to represent edge cases where returning anything else does not make a lot of sense.

One such case is when we try to get an element from a dictionary using a key that does not exist:

In [6]:
grades = {'math': 80, 'english': 90, 'italian': 50}
print(grades.get('history'))
None

As a final note, you can check if a value is None using the is None / is not None syntax:

In [7]:
val = None
l = [10, 20, None, False, [], 'hi']

for element in l:
    if element is None:
        print('Found a None')
Found a None

Aside from that, None isn't too important for our discussion of functions.

Functions can take many arguments, as many as we define in the function signature:

In [8]:
def euclidean_distance(x1, y1, z1, x2, y2, z2): # Takes 6 arguments
    return ((x1 - x2) ** 2 + (y1 - y2) ** 2 + (z1 - z2) ** 2) ** 0.5
In [9]:
# These two 3D points have a euclidean distance of 5
euclidean_distance(
    1.0, 2.0, 3.0,
    4.0, -2.0, 3.0
)
Out[9]:
5.0

Creating functions from previous examples¶

Recall our previous code for finding the average of a list:

In [10]:
lst = [1, 2, 4123, 1, 231]

i = 0
n = len(lst)
total = 0
while i < n:
    total += lst[i]
    i += 1
total / n
Out[10]:
871.6

We can wrap this algorithm into a function quite easily. This function only needs to know the list as its input, so we can define it like this:

In [11]:
def average(lst):
    i = 0
    n = len(lst)
    total = 0
    while i < n:
        total += lst[i]
        i += 1
    return total / n
In [12]:
average([1, 2, 10, 20])
Out[12]:
8.25

Functions are more flexible then the functions that we define in mathematics. For example, functions can receive and return other types of data, including non-numeric values (e.g., strings):

In [13]:
def greeting(name):
    print(f'Hello, {name}')
In [14]:
greeting('Cagri')
Hello, Cagri

They can even get interactive input using the input() function, that we have used earlier:

In [15]:
def kinetic():
    mass = float(input('Mass: '))
    velocity = float(input('Velocity: '))
    # Calculate kinetic energy
    return 0.5 * mass * velocity * velocity

# Try it below by un-commenting the following line:
# kinetic()

Finally, you can define functions inside of functions. The inner function can use initialized variables from the outer function, too:

In [16]:
def foo(val1):
    val2 = 10
    def bar(val3):
        return val1 * val2 * val3
    result = bar(20)
    return result

print(foo(40))
8000

Default parameters¶

Python lets us define functions with some parameters that will have automatic values if we do not provide any. These are called default parameters.

It's best to show by example:

In [17]:
# Some kind of general-purpose norm calculating function
def norm(x, y, norm_type='L2', verbose=False):
    result = None
    if norm_type == 'L2':  # Norm using euclidean distance
        result = (x ** 2 + y ** 2) ** 0.5
    if norm_type == 'L1':  # Norm using manhattan distance
        result = abs(x) + abs(y)
    if verbose:
        print(f'The {norm_type} is {result}')
    return result

The function above can compute the L1 or L2 norm) depending on the value of the norm_type parameter. Moreover, it can print a helpful message if the verbose parameter is True.

Here's how we can use it to silently compute the L2 norm of a 2D vector (6, 8):

In [18]:
norm(6, 8, 'L2', False)
Out[18]:
10.0

To achieve the same result, the last two parameters can be left empty, as by default they have the values L2 and False (as seen in the function definition):

In [19]:
norm(6, 8)
Out[19]:
10.0

While calling the function, we can refer to these parameters by name, and we can even switch up the order of parameters with default values:

In [20]:
norm(3, 4, verbose=True, norm_type='L1')
The L1 is 7
Out[20]:
7

Finally, "normal" variables are also called "positional" arguments. In Python, positional arguments have to come before named arguments in order to not cause confusion:

In [21]:
norm(norm_type='L2', 5, 12)
  File "<ipython-input-21-4e1d71ffa2e3>", line 1
    norm(norm_type='L2', 5, 12)
                         ^
SyntaxError: positional argument follows keyword argument

Scoping¶

If every variable (or function) could be accessed from everywhere, then we would have a lot of problems. For example, a variable you created could overwrite another variable of the same name used in a library that you imported, causing confusion.

That is why when a variable is created, Python introduces some specific rules for accessing and modifying that variable. This is called the scope of a variable.

As an example, let's initialize a variable inside a function, and then try to access it outside of that function:

In [22]:
def some_function():
    some_value = 123
    print('Inside:', some_value)
some_function()
print('Outside:', some_value)
Inside: 123
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
<ipython-input-22-995394ff9c2e> in <module>
      3     print('Inside:', some_value)
      4 some_function()
----> 5 print('Outside:', some_value)

NameError: name 'some_value' is not defined

As we can see, when inside the some_function() call, we can access and use some_value inside the function body. However, when are out of the function, trying to access that variable does not work (it has been destroyed).

As a slightly more complicated example, we can see what happens when a we define a variable with the same name both inside and outside of a function:

In [23]:
val = 10
print('[outside] val before the func() call is:', val)
def func():
    val = 20
    print('[inside] val inside the func() call is: ', val)

func()
print('[outside] val after the func() call is: ', val)
[outside] val before the func() call is: 10
[inside] val inside the func() call is:  20
[outside] val after the func() call is:  10

In the previous cell, we see an example of local-vs-global scoping:

  • A variable called val exists outside of any function definition (i.e., in the global scope), and is initialized to 10, which we see being printed in the first call to print.

  • Inside the function func() we again initialize a variable called val in the function body to 20. When we use print to print the value of val, that (inside) val will be shown, instead of the val that exists outside the function. This is called the local scope.

  • Out of the call to func(), we check the value of val again, using print. At this point, since we are out of the func() call, the "local" val variable does not exist anymore, and we access the variable val in the global scope.

In general, when accessing a variable through its name, the LEGB scoping rule applies (in decreasing priority):

  1. Local scope (inside a function)
  2. Enclosing scope (i.e., scope outer function for nested functions)
  3. Global scope (outside any function definition)
  4. Built-in scope (where built-in utilities reside).

As always, check the textbook for more details.

global¶

When we are in a function, we can use variables from outside of the function quite easily:

In [24]:
g = 9.81

def potential(mass, height):
    return mass * g * height
In [25]:
potential(5, 1.5)
Out[25]:
73.575

However, we can't assign an outside variable to another value when we are inside a function. When we try to do that, we just create a variable with the same name in the local scope (as we did before):

In [26]:
N = 10

def f():
    N = 20
    print('N (local) is', N)
    return N + 10
f()
print('N (global) is', N)
N (local) is 20
N (global) is 10

If you need to assign a new value to a global variable from inside a function, you can use the global keyword to do so:

In [27]:
counter = 0
def increment():
    global counter
    counter += 1

print(f'Value of counter: {counter}')
increment()
print(f'Value of counter: {counter}')
increment()
print(f'Value of counter: {counter}')
Value of counter: 0
Value of counter: 1
Value of counter: 2

Immutable values (numbers, strings, etc.) are copied when passed to a function, so their value does not change when their copies are modified inside the function:

In [28]:
def f(a):
    a = 5

# Does b change?
b = 10
f(b)
print(b)
10

However with mutable values (lists, etc.), aliasing applies (like we saw in previous weeks) and input data may be modified inside the function:

In [29]:
lst = [10, 2, 5]
def f(lst):
    lst[0] = 5

print(lst)
f(lst)
# Did lst change?
print(lst)
[10, 2, 5]
[5, 2, 5]

Here's another example showing the effects of aliasing with mutable arguments to functions:

In [30]:
def f(lst):
    lst[1], lst[0] = lst[0], lst[1]
lst = [1,2, 3, 4]
print(lst)
f(lst)
print(lst)
[1, 2, 3, 4]
[2, 1, 3, 4]

Examples of useful functions¶

Below: finding the maximum number of a list of numbers

In [31]:
def max_number(lst):
    if len(lst) == 0:
        return 'None!'
    elif len(lst) == 1:
        return lst[0]
    else:
        current_max = lst[0]
        for el in lst:
            if el > current_max:
                current_max = el
    return current_max
In [32]:
max_number([1, 120, 23, 1e7, -2])
Out[32]:
10000000.0

Checking if a list contains a number:

In [33]:
def elem_in_list(num, lst):
    num_in_list = False
    for el in lst:
        if el == num:
            num_in_list = True
            break
    return num_in_list
In [34]:
elem_in_list(5, [10, 5, 20, 10])
Out[34]:
True
In [35]:
elem_in_list(5, [10, 7, 20, 10])
Out[35]:
False

Removing non-numeric (i.e., not int, float, bool, complex) elements from a given list (in-place):

In [36]:
def only_numbers(lst):
    i = 0
    n = len(lst)
    while i < n:
        if type(lst[i]) not in [int, float, complex, bool]:
            del lst[i]
            i -= 1  # Since the next element is removed
            n -= 1  # Since the list decreased in length
        i += 1
    return lst
In [37]:
input_str = [10, 'a', 'asdfjk', 20, 'hello', 100, True, 'sdfkjlasdf', 1+2j]
only_numbers(input_str)
Out[37]:
[10, 20, 100, True, (1+2j)]

Higher-order functions¶

A very nice property of Python is that functions can be used just like numbers and strings. They can be assigned, stored in data-types, passed to functions and composed. This gives us lots of fun stuff to do with functions, and also lets us apply some general functional programming principles.

As a simple example, we can define a simple apply function that gets a func parameter and applies it to given data:

In [38]:
def apply(func, data):
    result = func(data)
    return result

apply doesn't do anything by itself, but it works as a kind of glue for combining functions with data.

In [39]:
apply(sum, [1,2, 3])
Out[39]:
6

We can even use functions that we have defined ourselves:

In [40]:
apply(average, [1, 2, 3])
Out[40]:
2.0

One common idea with functional programming is to apply a function to every element in a sequence or container. Here's how we could define such a function:

In [41]:
def map_func(func, lst):
    return [func(el) for el in lst]

Now, map_func applies the given function to all elements of a list, and creates a new list containing the results.

As an example, we can use it to convert every element of a list into a float like below:

In [42]:
numbers_str = [1, '2.5', 3.0, '4.9', '5']
map_func(float, numbers_str)
Out[42]:
[1.0, 2.5, 3.0, 4.9, 5.0]

Here's another example that squares every number in a list:

In [43]:
def exp_by_two(x):
    return x ** 2

map_func(exp_by_two, [10, 20, -5, 0, 3.5])
Out[43]:
[100, 400, 25, 0, 12.25]

Our map_func is basically the same as the built-in map utility. map returns an iterator, so if we wrap it with list() they will give the same result:

In [44]:
list(map(exp_by_two, [10, 20, -5, 0, 3.5]))
Out[44]:
[100, 400, 25, 0, 12.25]

Another useful idea is a filtering function, where we remove all elements in a sequence or container that do not return True for a given boolean function.

Here's our implementation of such a concept:

In [45]:
lst = [5, 7, 10, 48, 0, 8, 20, 30, 24]

def divable_by_eight(x):
    return x % 8 == 0

def filter_func(func, data):
    return [el for el in data if func(el) == True]
In [46]:
filter_func(divable_by_eight, lst)
Out[46]:
[48, 0, 8, 24]

filter_func when equipped with the divable_by_eight function will filter out all elements that are not divisible by 8, as we saw.

Many other fun and useful functions (such as reduce) can be found under the functools module, if one is interested.

In [47]:
import functools

def smaller(x, y):
    if x < y:
        return x
    else:
        return y

# Find the smallest number using reduce
functools.reduce(smaller, [8, 2, 4, 5, 3, 7])
Out[47]:
2

Lambda¶

Occasionally, we need to define small and simple functions and we don't want to find a new name for it (or we're too tired).

For such cases, we can use something called lambda functions, which serve this purpose. They look like this:

In [48]:
lambda x, y: x + y
Out[48]:
<function __main__.<lambda>(x, y)>

The lambda above has two parameters, x and y, and returns x + y. This is the same as our add() function, but it consists of a single statement and does not have a name.

If we assign a variable to a lambda definition, we can use it just like how we use a function:

In [49]:
f = lambda x: print(x)

f('hello')
hello

That's basically equivalent to the following:

In [50]:
def unnamed(x):
    print(x)
unnamed('hello')
hello
In [51]:
f('hello')
hello

Recursion¶

We talked about recursion for 5 minutes, but I've removed this part to not cause confusion. We'll continue with recursion next week.