Todo

Combine the Functions and writing functions and extend a bit more on the classes. rajathkmp will be helpful for this.

01_functions_classes

Open on binder: Binder

This notebook is based on the tutorial series by Rajath Kumar M P. Visit his GitHub to find out more: https://github.com/rajathkmp

What you will learn

During the course of this tutorial you will learn:

  • What list/dictionary comprehensions are and how to use them to write clean code.

  • What functions are and how to write your own functions.

  • What lambda functions are and how to use them.

  • What classes are and how to write your own classes.

Comprehensions

Oftentimes you will use for-lopps to fill lists with some data. Let’s say we want all square numbers up to and including 20 (that means we have to use range(21), because python is right exclusive) in a list.

[1]:
squares = []

for i in range(21):
    squares.append(i ** 2)

print(squares)
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81, 100, 121, 144, 169, 196, 225, 256, 289, 324, 361, 400]

With a so-called list-comprehension you could abbreviate this code to one line:

[2]:
squares = [i ** 2 for i in range(21)]
print(squares)
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81, 100, 121, 144, 169, 196, 225, 256, 289, 324, 361, 400]

Normal list comprehensions follow a style of

[expression for item in iterator]

and can be extended with if statements to filter a list:

[expression for item in iterator if filter-expression]

and with if-else statements like so:

[expression if filter-expression else different-item for item in iterator]

If we only want to print those squared numbers, that contain ‘2’ we can filter the list comprehension like so:

[3]:
squares_only_2 = [i ** 2 for i in range(21) if '2' in str(i ** 2)]
print(squares_only_2)
[25, 121, 225, 256, 289, 324]

Fizz-Buzz

Let’s play a game of fizz-buzz using list comprehension. Fizz-buzz is a standard exercise for new programmers. Oftentimes recruiters from software-firms use this to check the proficiency of people in a respective programming language. In the game of fizz-buzz, you count upwards from 1. When you reach a number, that is divisible by 3, you let the program print “Fizz”, If the number is divisible by 5, you let the program print “Buzz”, if the number is divisible by both “FizzBuzz”. In all other cases the number itself should be printed. Here’s what the code looks like without list comprehension.

[4]:
for n in range(1, 101):
    response = ""
    if n % 3 == 0:
        response += "Fizz"
    if n % 5 == 0:
        response += "Buzz"

    print(response or n)
1
2
Fizz
4
Buzz
Fizz
7
8
Fizz
Buzz
11
Fizz
13
14
FizzBuzz
16
17
Fizz
19
Buzz
Fizz
22
23
Fizz
Buzz
26
Fizz
28
29
FizzBuzz
31
32
Fizz
34
Buzz
Fizz
37
38
Fizz
Buzz
41
Fizz
43
44
FizzBuzz
46
47
Fizz
49
Buzz
Fizz
52
53
Fizz
Buzz
56
Fizz
58
59
FizzBuzz
61
62
Fizz
64
Buzz
Fizz
67
68
Fizz
Buzz
71
Fizz
73
74
FizzBuzz
76
77
Fizz
79
Buzz
Fizz
82
83
Fizz
Buzz
86
Fizz
88
89
FizzBuzz
91
92
Fizz
94
Buzz
Fizz
97
98
Fizz
Buzz

And here it is with list comprehension:

[5]:
[("Fizz"*(not i%3) + "Buzz"*(not i%5) or i) for i in range(1, 101)]
[5]:
[1,
 2,
 'Fizz',
 4,
 'Buzz',
 'Fizz',
 7,
 8,
 'Fizz',
 'Buzz',
 11,
 'Fizz',
 13,
 14,
 'FizzBuzz',
 16,
 17,
 'Fizz',
 19,
 'Buzz',
 'Fizz',
 22,
 23,
 'Fizz',
 'Buzz',
 26,
 'Fizz',
 28,
 29,
 'FizzBuzz',
 31,
 32,
 'Fizz',
 34,
 'Buzz',
 'Fizz',
 37,
 38,
 'Fizz',
 'Buzz',
 41,
 'Fizz',
 43,
 44,
 'FizzBuzz',
 46,
 47,
 'Fizz',
 49,
 'Buzz',
 'Fizz',
 52,
 53,
 'Fizz',
 'Buzz',
 56,
 'Fizz',
 58,
 59,
 'FizzBuzz',
 61,
 62,
 'Fizz',
 64,
 'Buzz',
 'Fizz',
 67,
 68,
 'Fizz',
 'Buzz',
 71,
 'Fizz',
 73,
 74,
 'FizzBuzz',
 76,
 77,
 'Fizz',
 79,
 'Buzz',
 'Fizz',
 82,
 83,
 'Fizz',
 'Buzz',
 86,
 'Fizz',
 88,
 89,
 'FizzBuzz',
 91,
 92,
 'Fizz',
 94,
 'Buzz',
 'Fizz',
 97,
 98,
 'Fizz',
 'Buzz']

Let’s understand this list comprehension. - The mapping expression is a tuple with the or boolean operator. - This tuple is either the expression left of the or or the expression right of the or. - If the left expression is an empty string (which equals the boolean value False in python) the right expression is printed. - If the string is not empty, the string itself is printed. - The string itself is the result of the string addition of "Fizz"*(not i%3) and "Buzz"*(not i%5). - The (not i%3) negates the result of the modulo operator of i and three. - If i is divisible by three the modulo equals 0. - The integer 0 equals a boolean value of False in python. Due to the negation this becomes True. - If this is True the string is multiplied with True which gives the string itself. - For all other integers the (not i%3 gives a False. False multiplied with a string gives an empty string.

Nested list comprehensions

You can also use nested for loops in list comprehensions. This might look something like this:

[expression for nested_iterable in iterable for expression in nested_iterable]
[6]:
[27*z for i in range(50) if i==27 for z in range(1,11)]
[6]:
[27, 54, 81, 108, 135, 162, 189, 216, 243, 270]

Dictionary comprehensions

Similarly, you can write dictionary comprehensions. Let’s say we have a shop with items and prices for said items. We want to raise the prices by 10%. We use the dict objects built-in items() method to get a tuple iteration of key-value pairs.

[7]:
base_prices = {'apple': 1, 'milk': 1.5, 'banana': 0.5, 'potatoes': 2.5}

for key, value in base_prices.items():
    print(f"{key} costs {value:.02f}$")
apple costs 1.00$
milk costs 1.50$
banana costs 0.50$
potatoes costs 2.50$
[8]:
new_prices = {k: v + v * 0.1 for k, v in base_prices.items()}

for key, value in new_prices.items():
    print(f"{key} costs {value:.02f}$")
apple costs 1.10$
milk costs 1.65$
banana costs 0.55$
potatoes costs 2.75$
fruits = ['apple', 'banana']
[9]:
base_prices = {'apple': 1, 'milk': 1.5, 'banana': 0.5, 'potatoes': 2.5}
fruits = ['apple', 'banana']

new_prices = {k: v + v * 0.1 if k in fruits else v for k, v in base_prices.items()}

print(new_prices)
{'apple': 1.1, 'milk': 1.5, 'banana': 0.55, 'potatoes': 2.5}

Functions

In 01_python_basics we talked about built-in functions. For example, the print() function is a built-in function, meaning it is always included. A function groups a set of statements, so they can be run more than once. Functions are an alternative for copying and pasting code segments you might often call. Functions are the most basic program structure python provides to maximize code reuse.

Functions are called by their name and parentheses (). Functions are defined by the def or the lambda statement.

def func_name(arg1, arg2, ..., argN):
    """This is the documentation to that func."""
    Indented code belonging to the function
    More indented code also belonging to the function

lambda_func = lambda a : a + 3

Return statements

There are two return statements which send results to the function call. return and the more advanced yield.

[10]:
def some_func():
    return 'awesome'

print('This function is', some_func())
This function is awesome

Variables in functions

Variables assigned in functions are generally not accessible outside of these functions. Consider the following example:

[11]:
def add_two_numbers(a):
    """Adds a number to the argument."""
    y = 3
    return a + y
add_two_numbers(3)
print(y)
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Cell In[11], line 6
      4     return a + y
      5 add_two_numbers(3)
----> 6 print(y)

NameError: name 'y' is not defined

The value of the variable y in this function is not accessible to code outside of the function. Variables from functions can be made available to the outside code via a global statement.

[12]:
x = 'old'
def change_x():
    global x
    x = 'new'
print(x)
change_x()
print(x)
old
new

Here are some important infos about functions:

  • def is executable code. The function is created when python reaches the def statement. Thus, different functions can be defined in if-else statements. This differentiates python functions from C functions, which are compiled before they are executed.

  • def creates an object and assigns it to a name. There’s nothing special about that function. It can be added to lists, it can also be assigned to other names.

  • lambda creates an object but returns it as a result. lambda can be used in places, where def can’t be used syntactically.

  • return sends a result object to the caller. Python only returns with the remaining code, when the function is finished. This differentiates return from

  • yield which sends a result object, but remembers where it left off. This can be used to make code faster.

  • global defines variables which should be taken from outside the variable.

  • nonlocal defined enclosing function variables that are to be assigned. It allows a nested function to access the variables of the above functions and change them.

[13]:
def outer():
    x = 'old'
    def changer():
        nonlocal x
        print(x)
        x = 'new'
    changer()
    print(x)
outer()
old
new

Arguments

  • Arguments are passed by assignment. This means that we use the assignment (=) to pass arguments.

[14]:
def a_func(arg1, arg2, arg3):
    return arg1 + (arg2 * arg3)

print(a_func(arg1=1, arg2=2, arg3=3))
7
  • Arguments are passed by position unless you say otherwise.

[15]:
print(a_func(1, 2, 3))
print(a_func(1, arg3=1, arg2=5))
7
6

Keyword Arguments

You can define default arguments by assigning them inside the parentheses of the def expression.

[16]:
def powers(a, power=2):
    """Calculates powers to an argument.
    If not specified otherwise the power
    of 2 will be calculated.
    """
    return a ** power
print(powers(2))
print(powers(2, 5))
4
32

Nested functions

Functions can be nested in other statements.

[17]:
test = True
if test:
    def func():
        return "The test is true. I am a function."
else:
    def func():
        return "The test is false. Sad times. I am still a function."
print(func())
The test is true. I am a function.

Docstrings

As you have already seen some of the previous functions have segments with triple quotes. These are there for documentation purposes. If you want to help other people (and your future self) write a short summary of what your function does. These docstrings can be accessed via the built-in function help().

[18]:
help(powers)
Help on function powers in module __main__:

powers(a, power=2)
    Calculates powers to an argument.
    If not specified otherwise the power
    of 2 will be calculated.

Writing functions

With this we can go straight to some more advanced function stuffs.

[19]:
print_hello_world()
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Cell In[19], line 1
----> 1 print_hello_world()

NameError: name 'print_hello_world' is not defined

To access the docstring you can call the built-in function help() on the function itself. Note, how the function is not called. The parentheses are omitted.

[20]:
help(print_hello_world)
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Cell In[20], line 1
----> 1 help(print_hello_world)

NameError: name 'print_hello_world' is not defined

Return values

Let’s assign the output of the function to a variable and check that variable out.

[21]:
out = print_hello_world()
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Cell In[21], line 1
----> 1 out = print_hello_world()

NameError: name 'print_hello_world' is not defined
[22]:
print(out)
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Cell In[22], line 1
----> 1 print(out)

NameError: name 'out' is not defined

See, how the function returned a None? That’s because in python all functions return something. If not further specified, they return None. Let’s specify the return value of our function. By executing the next cell, you overwrite the previously defined function. Be careful not to overwrite built-in functions. They are lost, until you restart the program.

[23]:
def print_hello_world():
    """Prints "Hello World!"
    and returns a string.
    """
    print("Hello World!")
    return "Python is awesome!"
    # Return is not a function, that's why it is shown in bold letters.
    # It does not need the parentheses like print() does.
[24]:
out = print_hello_world()
print(out)
Hello World!
Python is awesome!

Arguments

Let’s add some arguments to our function.

[25]:
def print_hello_user(username):
    print(f"Hello {username}!")
print_hello_user("Keith")
Hello Keith!

Let’s use a user provided input to print the message.

[26]:
name = input("Please enter your name: ")
---------------------------------------------------------------------------
StdinNotImplementedError                  Traceback (most recent call last)
Cell In[26], line 1
----> 1 name = input("Please enter your name: ")

File /opt/hostedtoolcache/Python/3.8.16/x64/lib/python3.8/site-packages/ipykernel/kernelbase.py:1181, in Kernel.raw_input(self, prompt)
   1179 if not self._allow_stdin:
   1180     msg = "raw_input was called, but this frontend does not support input requests."
-> 1181     raise StdinNotImplementedError(msg)
   1182 return self._input_request(
   1183     str(prompt),
   1184     self._parent_ident["shell"],
   1185     self.get_parent("shell"),
   1186     password=False,
   1187 )

StdinNotImplementedError: raw_input was called, but this frontend does not support input requests.
[27]:
print_hello_user(name)
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Cell In[27], line 1
----> 1 print_hello_user(name)

NameError: name 'name' is not defined

You can also have multiple arguments, separated by comma.

[28]:
def print_hello_user(greeting, username):
    print(f"{greeting} {username}!")
print_hello_user("Nice to see you,", "Keith")
Nice to see you, Keith!

Order of arguments matter

The order of arguments matter when functions are called.

[29]:
name = 'Kurt'
greet = 'Good afternoon,'
print_hello_user(name, greet)
Kurt Good afternoon,!

But when you specifically type the name of the arguments, order does not matter anymore.

[30]:
print_hello_user(username=name, greeting=greet)
Good afternoon, Kurt!

Return multiple values

Let’s recap the return statement at the end of functions. Instead of returning a more or less static string it would be beneficial to return something that has been calculated inside the function.

[31]:
def multiplication(x, y):
    """Instead of assigning a result to a useless variable return it directly"""
    return x * y

Sometimes you want your function to return multiple values. However ,the return statement breaks the execution of the function. Instead of two separate return statements, you should separate the return values with a comma.

[32]:
examplelist = [10,50,30,12,6,8,100]
def exfunc(examplelist):
    highest = max(examplelist)
    lowest = min(examplelist)
    first = examplelist[0]
    last = examplelist[-1]
    return highest
    return lowest
    return first
    return last

exfunc(examplelist)
[32]:
100
[33]:
examplelist = [10,50,30,12,6,8,100]
def exfunc(examplelist):
    highest = max(examplelist)
    lowest = min(examplelist)
    first = examplelist[0]
    last = examplelist[-1]
    return highest, lowest, first, last

exfunc(examplelist)
[33]:
(100, 6, 10, 100)
[34]:
print(type(exfunc(examplelist)))
<class 'tuple'>

The return values are packed in a tuple. Which makes sense, because you want to have the result be immutable as to not accidentally scramble it. The return tuple can be unpacked like this:

[35]:
rval_one, rval_two, rval_three, rval_four = exfunc(examplelist)
print(rval_two)
6

More about packing and unpacking below.

Nested functions

Functions can also be nested. In fact that’s one of the beautiful aspects of python. By stacking functions, you can create increasingly complex programs relying on a number of easy functions. Let me show it to you.

[36]:
def convert_12_h_to_24_h(time):
    timevalue = int(time.split()[0]) # split the string into a list, use 0th element and make it an integer
    if 'pm' in time:
        timevalue = timevalue + 12
    return timevalue

def return_greeting(time):
    # convert time string to numbers
    if 'am' in time or 'pm' in time:
        time = convert_12_h_to_24_h(time)
    # decide on greeting
    if time >= 0 and time < 6:
        greeting = "Sleep tight"
    elif time >= 6 and time < 12:
        greeting = "Good morning"
    elif time >= 12 and time < 18:
        greeting = "Good afternoon"
    elif time >= 18 and time < 22:
        greeting = "Good evening"
    else:
        greeting = "Good night"
    return greeting


def print_hello_user_time(username, time):
    greeting = return_greeting(time)
    print(f"{greeting} {username}!")
[37]:
print_hello_user_time("Sandra", "12 am")
Good afternoon Sandra!

Keyword arguments

By explicitly stating the argument in your function, you can change the order in which you pass arguments to a function.

[38]:
def subtract(x, y):
    """Subtracts y from x."""
    return x - y

one = subtract(5, 4)
two = subtract(y=5, x=4)
print(one, two)
1 -1

In the same fashion you can set the defaults for a functions arguments, when defining the function. In that case you only need to provide as many arguments as there are non-keyword arguments.

[39]:
def subtract(x, y=5):
    """Subtracts y from x.
    If not specified otherwise 5 will be subtracted from x."""
    return x - y

print(subtract(10))
print(subtract(10, 20))
5
-10

Lambda functions

As previously stated, lambda functions are functions returned by the lambda keyword. They are often used as throwaway functions, that are only used a single time. They can come in quite useful, when something needs to be a function, but you want to make your code more readable. The keyword lambda returns a function that is now accessible under the assigned variable name.

myfunc = lambda x: x * 2

Besides the lambda keyword, you can give arguments and use them in the expression after the colon (:). In this case, the input argument x is multiplied by 2. Multiple arguments can also be given like so:

myfunc = lambda x, y: x + y
[40]:
myfunc = lambda x: x * 2
myfunc(5)
[40]:
10

Use case: filter

The built-in filter() function can filter an iterable based on boolean values. It expects as the first argument a function (more on that later) and an iterable as the second. Let’s say we have a list of ints and want to remove all ints below 10 and above 25

[41]:
mylist = [i for i in range(50)]

filtered = list(filter(lambda x: x >= 10 and x <= 25, mylist))
print(filtered)
[10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25]

The lambda function was directly supplied without writing a function with def. You have no way of using that function again, because it wasn’t assigned to a variable. This makes the code easily to read. However, if you use this function (check whether greater than 9 and smaller than 26) often consider putting it into it own function.

def in_range(x):
    return x >= 10 and x <= 25

Because now, you can use this function repeatedly and a change in this function changes all code that uses this function.

filtered = list(filter(in_range, [i for i in range(50)]))
filtered = list(filter(in_range, [i ** 2 - 50 for i in range(50)]))
filtered = list(filter(in_range, [i * 2 - 10 for i in range(50)]))

Functions as arguments

To further elaborate on functions as arguments. Keep in mind: Everything in python is an object and as such there is principally no difference between int, str and function.

[42]:
def apply_arithmetic(x, y, operation=lambda x, y: x + y):
    return operation(x, y)

print(apply_arithmetic(2, 5))
7
[43]:
print(apply_arithmetic(2, 5, lambda x, y: x * y))
10

Classes

Classes is where python starts to shine. Welcome to object-oriented programming. At first we want to get some terminology right. First we need to know the difference between class and the instance of a class. If we define a class like so (The pass statement can be used to do nothing here).

[44]:
class MyClass:
    pass

After running this cell the class MyClass now is available to us, and we do stuff with it.

[45]:
print(MyClass)
<class '__main__.MyClass'>

A class can be filled with class variables

[46]:
class MyClass:
    greeting = 'Hello'
    name = 'John'

print(MyClass.greeting)
Hello

Classes can also be filled with methods. Methods are just like functions, except they belong to a class.

[47]:
class MyClass:
    greeting = 'Hello'
    name = 'John'

    def greet(self):
        return f'{self.greeting} {self.name}!'

MyClass.greet(MyClass)
[47]:
'Hello John!'

You probably wonder, what the self inside the greet method does. That’s because of instances. What you do normally with a class, is instantiate it by calling MyClass(). The self in the method refers to the current instance. Because we didn’t instantiate the class, we don’t have an instance and have to do the MyClass.greet(MyClass) workaround.

[48]:
instance = MyClass()
instance.greet()
[48]:
'Hello John!'

And this is where classes become interesting. Consider this:

[49]:
john = MyClass()
jane = MyClass()
jane.greeting = 'Goodbye'
jane.name = 'Jane'

print(john.greet())
print(jane.greet())
Hello John!
Goodbye Jane!

We have two instances of one class, both doing something similar, but with different variables. Overwriting the variables if an instance like this:

jane.greeting = 'Goodbye'
jane.name = 'Jane'

What you would normally do is, using a special method called __init__(), which is called when the class is instantiated and set the variables of the instance like in the next cell. The self in the class refers to a current instance of that class. More on that in 03_intermediate_python.ipynb.

(Normally you would also not set the variables greeting and name as class variables but only as instance variables.)

[50]:
class MyClass:

    def __init__(self, name, greeting):
        self.greeting = greeting
        self.name = name

    def greet(self):
        return f'{self.greeting} {self.name}!'

    def change_greeting(self, new_greet=''):
        if not new_greet:
            self.greeting = input(f"Give {self.name} a new greeting:")
        else:
            self.greeting = new_greet
[51]:
john = MyClass('John', 'Hello')
jane = MyClass('Jane', 'Goodbye')
[52]:
print(john.greet())
print(jane.greet())
Hello John!
Goodbye Jane!

You can also try to interactively change Jane’s greeting by calling:

jane.change_greeting()
[53]:
jane.change_greeting('Hi')
[54]:
print(john.greet())
print(jane.greet())
Hello John!
Hi Jane!

Moving on

The last two notebooks basics_00_datatypes.ipynb and this one (basics_01_functions_classes.ipynb) have brought you most of what you need to know to work yourself through some code. At this point you can choose, whether you want to continue with the python tutorials, which progressively become more advanced (intermediate_02_OOP.ipynb). You could also take a look at the builtin notebook introducing you to some of python’s built-in modules (builtins.ipynb), or you take a look at the package-specific notebooks: