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:
def add(x, y):
result = x + y
return result
add(10, 20)
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:
a = 5
b = 40
add(a, b)
45
We can assign a variable to the result (the value returned with the return
keyword) of a function call:
a = 10
b = 20
c = add(a, b)
c
30
None¶
Technically, using return
inside of a function call is optional. What happens when we don't return a value?
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
:
type(result)
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:
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:
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:
def euclidean_distance(x1, y1, z1, x2, y2, z2): # Takes 6 arguments
return ((x1 - x2) ** 2 + (y1 - y2) ** 2 + (z1 - z2) ** 2) ** 0.5
# These two 3D points have a euclidean distance of 5
euclidean_distance(
1.0, 2.0, 3.0,
4.0, -2.0, 3.0
)
5.0
Creating functions from previous examples¶
Recall our previous code for finding the average of a list:
lst = [1, 2, 4123, 1, 231]
i = 0
n = len(lst)
total = 0
while i < n:
total += lst[i]
i += 1
total / n
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:
def average(lst):
i = 0
n = len(lst)
total = 0
while i < n:
total += lst[i]
i += 1
return total / n
average([1, 2, 10, 20])
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):
def greeting(name):
print(f'Hello, {name}')
greeting('Cagri')
Hello, Cagri
They can even get interactive input using the input()
function, that we have used earlier:
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:
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:
# 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)
:
norm(6, 8, 'L2', False)
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):
norm(6, 8)
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:
norm(3, 4, verbose=True, norm_type='L1')
The L1 is 7
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:
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:
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:
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 to10
, which we see being printed in the first call toprint
.Inside the function
func()
we again initialize a variable calledval
in the function body to20
. When we useprint
to print the value ofval
, that (inside)val
will be shown, instead of theval
that exists outside the function. This is called the local scope.Out of the call to
func()
, we check the value ofval
again, usingprint
. At this point, since we are out of thefunc()
call, the "local"val
variable does not exist anymore, and we access the variableval
in the global scope.
In general, when accessing a variable through its name, the LEGB scoping rule applies (in decreasing priority):
- Local scope (inside a function)
- Enclosing scope (i.e., scope outer function for nested functions)
- Global scope (outside any function definition)
- 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:
g = 9.81
def potential(mass, height):
return mass * g * height
potential(5, 1.5)
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):
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:
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:
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:
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:
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
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
max_number([1, 120, 23, 1e7, -2])
10000000.0
Checking if a list contains a number:
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
elem_in_list(5, [10, 5, 20, 10])
True
elem_in_list(5, [10, 7, 20, 10])
False
Removing non-numeric (i.e., not int, float, bool, complex) elements from a given list (in-place):
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
input_str = [10, 'a', 'asdfjk', 20, 'hello', 100, True, 'sdfkjlasdf', 1+2j]
only_numbers(input_str)
[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:
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.
apply(sum, [1,2, 3])
6
We can even use functions that we have defined ourselves:
apply(average, [1, 2, 3])
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:
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:
numbers_str = [1, '2.5', 3.0, '4.9', '5']
map_func(float, numbers_str)
[1.0, 2.5, 3.0, 4.9, 5.0]
Here's another example that squares every number in a list:
def exp_by_two(x):
return x ** 2
map_func(exp_by_two, [10, 20, -5, 0, 3.5])
[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:
list(map(exp_by_two, [10, 20, -5, 0, 3.5]))
[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:
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]
filter_func(divable_by_eight, lst)
[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.
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])
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:
lambda x, y: x + y
<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:
f = lambda x: print(x)
f('hello')
hello
That's basically equivalent to the following:
def unnamed(x):
print(x)
unnamed('hello')
hello
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.