Defining functions#

Introduction#

A function is a block of code which takes some parameters and uses them to produce or report some result.

In this notebook we will see how to define functions to reuse code, and talk about variables scope.

See also

For a complete course on functions, see Andrea Passerini’s slides A04

Anatomy of a function definition#

A function in Python is an object, meaning it’s a variable like any other. The main way it differs from the other types of variables you’ve seen until now is in the way it is assigned, or defined. For instance, let’s imagine you want to assign a function to a variable named fun. In that case, you wouldn’t simply start writing fun = ..., like a normal variable assignment, but use the reserved keyword def, like so:

def fun():
    "here we enter an indented block"

print(type(fun))
<class 'function'>

So you actually just assigned a function object to the variable fun with this little piece of code.

As you can see, there’s more after the def fun: you open a pair of brackets, close it, add a colon :, and then write more things on the following line, indented by four spaces.

So, first, what are the brackets for? Inside the brackets is where you define the arguments of the function, each separated by a comma ,:

def fun(arg1, arg2):
    "here we enter an indented block"

These are variables that will live inside your function – or, in other words, in its scope, but we’ll get back to that.

You assign values to these variables when you call the function. Similarly to how it’s defined, you call a function by adding a pair of brackets after its name, and passing in them the value of each argument:

fun(1, 2)

Important

Something crucial about function arguments is that when you call a function, all of its arguments must have been assigned some value.

Hence why the following returns an error:

fun(1)
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[5], line 1
----> 1 fun(1)

TypeError: fun() missing 1 required positional argument: 'arg2'

To illustrate, when you call it correctly, like

fun(1, 2)

before executing the code block below the def, the following happens:

arg1 = 1
arg2 = 2
... # whatever code was inside the function

Ok, but for now, we’re not doing anything with our arguments, so what happens when we call the function?

output = fun(1,2)
print(output)
None

It returns something, but in this case it’s just a None. The string we added in the body of our function got lost, apparently.

Important

That’s because, in order to get the value of any variable out of a function, you need to return this variable.

So let’s now introduce a return statement at the end of our function:

def fun(arg1, arg2):
    not_returned = 2
    new_output = 1
    return new_output

print(fun(1, 2))
print(not_returned)
1
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Cell In[7], line 7
      4     return new_output
      6 print(fun(1, 2))
----> 7 print(not_returned)

NameError: name 'not_returned' is not defined

We did not return not_returned, so it got lost, while the value 1 we returned through new_output got printed successfully. The variable new_output itself got lost, though:

print(new_outptut)
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Cell In[8], line 1
----> 1 print(new_outptut)

NameError: name 'new_outptut' is not defined

That’s because only the value of new_output was returned, not the variable itself! So in order to store the result of a function call for later, you should assign this value to a new variable. Let’s see this more clearly with Python tutor:

%%tutor

def fun(arg1, arg2):
    not_returned = 2
    new_output = 1
    return new_output

output = fun(1, 2)

As you can see, when fun is called, a new frame, which is local to the function, is created. This frame, and all the variables it contains, is then completely discarded at the end of the function call. The only thing that gets out of the function is the return value, that we then assign to the variable output that lives in the global frame.

That’s it, we’ve now seen how to define a function. Still, it’s a shame that until now, we’ve passed values to the arguments of a function that does not even use them! So let’s end this part with the first example of a function that would actually make sense:

def fun(arg1, arg2):
    return arg1 + arg2

result_sum = fun(1, 2)
print(result_sum)
3

Why functions?#

We may need functions for a lot of reasons, including:

  1. Reduce code duplication: put in functions parts of code that are needed several times in the whole program, so you don’t need to repeat the same code over and over again;

  2. Decompose a complex task: make the code easier to write and understand by splitting the whole program in several easier functions;

Don’t be shy of creating new variables#

You might be tempted to modify one of the function arguments in a function’s body, in order to avoid typing an extra line of code to assign a new variable. Do that only if your function is actually supposed to modify this input! You’re saving very little time and could create yourself a lot of confusion further down the line.

So, for instance, this is fine:

def append_42_to_list(li):
    li.append(42)

While this is not:

def print_last_char_twice(my_str):
    my_str = my_str[-1]
    my_str = my_str * 2
    print(my_str)
    
s = 'no'
print_last_char_twice(s)
oo

so in this case, prefer:

def print_last_char_twice(my_str):
    last_char = my_str[-1]
    result = last_char * 2
    print(result)

Notice how much clearer this is to read?

What’s in a name#

Functions, like variables, should have a meaningful name! It shouldn’t be too long, but its purpose should be somewhat clear simply from its name

Also, when you allow the forces of evil to take the best of you, you might be tempted to use reserved words like list as a variable for you own miserable purposes:

list("ciao")
['c', 'i', 'a', 'o']
list = ['my', 'pitiful', 'list']

Python allows you to do so, but we do not, for the consequences are disastrous.

For example, if you now attempt to use list for its intended purpose like casting to list, it won’t work anymore:

list("ciao")
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-4-c63add832213> in <module>()
----> 1 list("ciao")

TypeError: 'list' object is not callable

In particular, we recommend to not redefine these precious functions:

  • bool, int,float,tuple,str,list,set,dict

  • max, min, sum

  • next, iter

  • id, dir, vars,help

In case of doubt, most code editors should color these functions as you type their name. By default in Jupyter, they should appear in green, for instance.

Different function kinds#

You can roughly find 5 different function kinds in the wild:

  1. Produces side effects: prints / asks manual input / writes by modifying the environment in some way - examples: printing characters on the screen, asking interactively input from the user, writing into a file

  2. Returns a value, either as new memory region or a pointer to an existing memory region

  3. Modifies the input

  4. Modifies the input and returns it (allows for call chaining)

  5. Modifies the input and returns something derived from it

Let’s try now to understand the differences with various examples.

Side effects#

Only prints / asks interactive input / writes into a file

  • Does not modify the input!

  • Does not return anything!

Example:

%%tutor

def printola(lst):
    """prints the first two elements of the given list
    """    
    print('the first two elements are', lst[0], lst[1])

la = [8,5,6,2]

printola(la)

Return#

Return some value, either as new memory region or a pointer to an existing memory region according to the function text

  • Does not modify the input

  • Does not print anything!

Example:

%%tutor

def returnola(lst):
    """RETURN a NEW list having all the numbers doubled    
    """    
    ret = []
    for el in lst:
        ret.append(el*2)
    return ret   

la = [5,2,6,3]
res = returnola(la)
print("la :", la)
print("res:", res)

Modify#

Modify the input. by modifying, we typically mean changing data inside existing memory regions, limiting as much as possible the creation of new ones.

  • Does not return anything!

  • Does not print anything!

  • Does not create new memory regions (or limits the creation to the bare needed)

Example:

%%tutor

def modifanta(lst):
    """MODIFIES lst by ordering it in-place
    """
    lst.sort()   
    la = [43434]
    
    
la = [7,4,9,8]

modifanta(la)

print("la:", la)

Modify and return#

Modifies the input and returns a pointer to it

  • Does not print anything!

  • Does not create new memory regions (or limits the creation to the bare needed)

Note: allows call chaining

%%tutor

def modiret(lst):
    """MODIFY lst by doubling all its elements, and finally RETURNS it
    """
    for i in range(len(lst)):
        lst[i] = lst[i] * 2
    return lst

la = [8,7,5]
res = modiret(la)
print("res :", res)   # [16,14,10]  RETURNED the modified input
print("la  :", la)    # [16,14,10]  la input was MODIFIED !!

print()
lb = [7,5,6]
modiret(lb).reverse()    # NOTE WE CAN CONCATENATE
print("lb  :", lb)                # [12,10,14] lb input was MODIFIED !!
#modiret(lb).reverse().append(16)  # ... but this wouldn't work. Why?

Modify and return a part#

Modify the input and return a part of it

  • Does not print anything!

%%tutor

def modirip(lst):
    """MODIFY lst by sorting it and removing the greatest element. Finally, RETURN the removed element.    
    """
    lst.sort()
    ret = lst[-1]
    lst.pop()
    return ret    

la = ['b','c','a']
res = modirip(la)     
print("res   :", res)    # 'c'          RETURNED a piece of the input
print("la    :", la)     # ['a','b']    la was MODIFIED!!

Immutable values#

Basic types such integers, float, booleans are immutable, as well as some sequences like strings and tuples : when you are asked to RETURN one of these types, say a string, the only thing you can do is obtaining NEW strings based upon the parameters you receive. Let’s see an example.

Suppose we are asked to implement this function:

Write a function my_upper which RETURNS the passed string as uppercase.

We could implement it like this:

%%tutor

external_string = "sailor"

def my_upper(s):
    ret = s.upper()   # string methods create NEW string
    return ret        
    
result = my_upper(external_string)

print('         result:', result)
print('external_string:', external_string)

Notice some things:

  • the external_string didn’t change

  • we didn’t refer to external_string inside the function body: doing so would have defeated the purpose of functions, which is to isolate them from outside world.

Changing the world: fail / 1#

What if we actually did want to change the assignment of external_string ?

You might be tempted to write something like an assignment s = right inside the function. The following code will not work.

Question

Why? Try to answer before checking execution in Python Tutor.

%%tutor

external_string = "sailor"

def my_upper(s):
    s = s.upper()  
    return s
    
result = my_upper(external_string)

print('         result:', result)
print('external_string:', external_string)

Changing the world: fail / 2#

Let’s see another temptation. You might try to assign external_string = right inside the function. The following code again will not work

Question

Why? Try to answer before checking execution in Python Tutor.

%%tutor

external_string = "sailor"

def my_upper(s):
    external_string = s.upper()  
    return external_string
    
result = my_upper(external_string)

print('         result:', result)
print('external_string:', external_string)

Changing the world: success!#

The proper way to tackle the problem is to create a NEW string inside the function, return it, and then outside the function perform the assignment external_string = result.

%%tutor

external_string = "sailor"

def my_upper(s):
    ret = s.upper()   
    return ret
    
result = my_upper(external_string)   
external_string = result             # reassignes *outside*

print('         result:', result)
print('external_string:', external_string)

Function definition - questions#

For each of the following expressions, try guessing the result it produces (or if it gives error)

  1. def f():
    print('car')
    print(f())
    
  2. def f():
        print('car')
    print(f())
    
  3. def f():
    return 3
    print(f())
    
  4. def f():
        return 3
    print(f())
    
  5. def f()
        return 3
    print(f())
    
  6. def f():
        return 3
    print(f()f())
    
  7. def f():
        return 3
    print(f()*f())
    
  8. def f():
        pass
    print(f())
    
  9. def f(x):
        return x
    print( f() )
    
  10. def f(x):
        return x
    print( f(5) )
    
  11. def f():
        print('fire')        
    x = f()
    print(x)
    
  12. def f():
        return(print('fire'))
    print(f())
    
  13. def f(x):
        return 'x'
    print(f(5))
    
  14. def f(x):
        return x
    print(f(5))
    
  15. def etc():
        print('etc...')
        return etc()
    etc()
    
  16. def gu():
        print('GU')
        ru()
    def ru():
        print('RU')
        gu()        
    gu()
    

Mutable values#

Sequences like lists, sets, dictionaries are mutable objects. When you call a function and pass one of these objects, Python actually gives the function only a reference to the object: a very small pointer which is just an arrow pointing to the memory region where the actual object resides. Since the function only receives a small pointer, calling the function is a fast operation. On the other side, we need to be aware that since no copy of the whole data structure is performed, inside the function it will be like operating on the original memory region which lives outside the function call.

All of this may feel like a bit of a mouthful. Let’s see a practical example in Python Tutor.

Let’s say we need to implement this function:

Write a function which takes a list and MODIFIES it by doubling all of its numbers

Note in the text we used the word MODIFIES, meaning we really want to change the original memory region of the external object we are given.

As simple as it might seem, there are many ways to get this wrong. Let’s see some.

Doubling: fail / 1#

You might be tempted to solve the problem like the following code, but it will not work.

Question

Why? Try to answer before checking execution in Python Tutor.

%%tutor

external_numbers = [10,20,30]

def double(lst):
    for element in lst:
        element = element * 2   
        
double(external_numbers)

Doubling: fail / 2#

You might have another temptation to solve the problem like the following code, but again it will not work.

Question

Why? Try to answer before checking execution in Python Tutor.

%%tutor

external_numbers = [10,20,30]

def double(lst):
    tmp = []
    for element in lst:
        tmp.append(element * 2)   
                                 
    lst = tmp                     
    
double(external_numbers)

Doubling: fail / 3#

You might be tempted to solve the problem also like in the following code, but again it will not work.

Question

Why? Try to answer before checking execution in Python Tutor.

%%tutor

external_numbers = [10,20,30]

def double(lst):
    tmp = []
    for element in lst:
        tmp.append(element * 2)  
                                 
    external_numbers = tmp                                       
    
double(external_numbers)

Doubling: fail / 4#

Let’s see the final temptation, which yet again will not work.

Question

Why? Try to answer before checking execution in Python Tutor.

%%tutor

external_numbers = [10,20,30]

def double(lst):
    tmp = []
    for element in lst:
        tmp.append(element * 2)   
                                 
    return tmp                    
    
external_numbers = double(external_numbers)                                             

Probably you are a bit confused about the previous attempt, which to the untrained eye might look successful. Let’s try to rewrite it with one variable more saved which will point to exactly the same original memory region of external_numbers. You will see that at the end saved will point to [10,20,30], showing we didn’t actually MODIFY the original region.

%%tutor

external_numbers = [10,20,30]
saved = external_numbers   # we preserve a pointer

def double(lst):
    tmp = []
    for element in lst:
        tmp.append(element * 2)                                   
    return tmp                   
    
external_numbers = double(external_numbers) 
print('external_numbers:', external_numbers)  # [20,40,60]
print('           saved:', saved)             # [10,20,30]

Doubling success!#

Let’s finally see the right way to do it: we need to consider we want to refer to original cells, so to do it properly we need to access them by index, and we will need a for in range.

%%tutor

external_numbers = [1, 2, 3, 4, 5]

def double(lst):
    for i in range(len(lst)):
        lst[i] = lst[i] * 2

double(external_numbers)

Notice that:

  • when the function call frame is created, we see an arrow to the original data

  • the external_list actually changed, without ever reassigning it (not even outside)

  • we didn’t reassign lst = inside the function body, as the IV COMMANDMENT prescribes not to reassign parameters

  • we didn’t use return, as the function text told us nothing about returning

  • we didn’t referred to external_list inside the function body: doing so would have defeated the purpose of functions, which is to isolate them from outside world.

In general, in the case of mutable data data isolation is never tight, as we get pointers to data living outside the function frame. When we manipulate pointers it’s really up to us to take special care.

Optional arguments#

Until now, we’ve only seen functions with so-called positional arguments. As we’ve seen earlier, the value of every positional argument must be specified when calling a function. There is another kind of arguments which are optional: they’re known as keyword arguments, or kwargs, in short. This allows you for instance to define the following function multiply, that by default, doesn’t modify the input list:

def multiply(lst, by=1):
    for i in range(len(lst)):
        lst[i] = lst[i] * by

Since we have by=1 in the function definition, even if we do not specify a value for this argument when calling the function, everything goes fine, as it does have a default value:

some_list = [1, 2, 3]
multiply(some_list)
some_list
[1, 2, 3]

Importantly, when you want to pass a value to these arguments, they must be placed after the positional arguments. Else, you’ll get an error:

multiply(by=2, [1, 2])
  Cell In[32], line 1
    multiply(by=2, [1, 2])
                         ^
SyntaxError: positional argument follows keyword argument

Important

Basically, positional arguments are like old people on the bus: nobody’s allowed to take their usual seat.

Now, there’s a good reason we’re seeing keyword arguments after the section on mutable values. There is a very common pitfall in Python that you should be aware of. Let’s imagine we want to define a function concat that concatenates two input lists, with the following requirements:

  • by default, it should return a copy of the first list,

  • otherwise, the output list should first have the values of the second list, followed by those of the first list,

  • the first list should never be mutated.

One possible implementation would be:

%%tutor

def concat(lst, other=[]):
    other.extend(lst)
    return other

input_list = [1, 2, 3]
print(concat(input_list))
print(concat(input_list))

Important

What you’ve seen with Python Tutor happens because a keyword argument is given its default value at the moment the function is defined. So keyword arguments should not be assigned mutable values like lists or dictionaries, because they could actually get mutated on each fuction call!

A very common pattern you can resort to when you actually need to have a default value with a mutable type is as follows:

%%tutor

def concat(lst, other=None):
    if other is None:
        other = []
    other.extend(lst)
    return other

input_list = [1, 2, 3]
print(concat(input_list))
print(concat(input_list))

None is just used as a placeholder here – it could be any other immutable value, actually –, so that other gets initialised as the empty list on each function call, and not only upon function definition!

Modifying parameters - Questions#

For each of the following expressions, try guessing the result it produces (or if it gives error)

  1. def zam(bal):
        bal = 4
    x = 8
    zam(x)
    print(x)
    
  2. def zom(y):
        y = 4
    y = 8
    zom(y)
    print(y)
    
  3. def per(la):
        la.append('è')    
    per(la)
    print(la)
    
  4. def zeb(lst):
        lst.append('d')
    la = ['a','b','c']
    zeb(la)
    print(la)
    
  5. def beware(la):
        la = ['?','?']
    lb = ['d','a','m','n']
    beware(lb)
    print(lb)
    
  6. def umpa(string):
        string = "lompa"
    word = "gnappa"
    umpa(word)
    print(word)
    
  7. def sporty(diz):
        diz['sneakers'] = 2
    cabinet = {'rackets':4,
               'balls': 7}
    sporty(cabinet)
    print(cabinet)
    
  8. def numma(lst):
        lst + [4,5]
    la = [1,2,3]    
    print(numma(la))
    print(la)
    
  9. def jar(lst):
        return lst + [4,5]
    lb = [1,2,3]    
    print(jar(lb))
    print(lb)
    

Exercises - Changing music#

It’s time to better understand what we’re doing when we mess with variables and function calls.

An uncle of ours gave us a dusty album of songs (for some reason tens of years have passed since he last turned on the radio)

album = [
    "Caterina Caselli - Cento giorni",
    "Delirium - Jesahel",
    "Jan Hammer - Crockett's Theme",
    "Sonata Arctica - White Pearl, Black Oceans",
    "Lucio Dalla - 4 marzo 1943.mp3",
    "The Wellermen - Wellerman",    
    "Manu Chao - Por el Suelo",
    "Intillimani - El Pueblo Unido"
]

Songs are reported with the group, a dash - and finally the name. Strong with our new knowledge about functions, we decide to put in practice modern software development practices to analyze these mysterious relics of the past.

In the following you will find several exercises which will ask you to develop functions: making something which seems to work is often easy, the true challenge is following exactly what is asked in function text: take particular care about capitalized words, like PRINT, MODIFY, RETURN, and to the desired outputs, trying to understand to which category the various functions belong to.

Exercises must all be solved following this scheme:

album = ...

def func(songs):
    # do something with songs, NOT with album
    # ....    
    
func(album)  # calls to test, external to function body

Warning

Do not write external variable names inside a function

In particular, here:

  • Do not reassign album =

  • Do not call its methods album.some_method()

A function must be typically seen as an isolated world, which should interact with the outworld ONLY through the given parameters. By explicitly writing album, you would override such isolation bringing great misfortune.

Exercise - show#

Write a function show which, given a list songs, PRINTS the group justified to the right followed by a : and the song name

HINT: to justify the text, use the string method .rjust(16)

album = [
    "Caterina Caselli - Cento giorni",
    "Delirium - Jesahel",
    "Jan Hammer - Crockett's Theme",
    "Sonata Arctica - White Pearl, Black Oceans",
    "Lucio Dalla - 4 marzo 1943.mp3",
    "The Wellermen - Wellerman",    
    "Manu Chao - Por el Suelo",
    "Intillimani - El Pueblo Unido"
]
Hide code cell content
def show(songs):
    for song in songs:
        parts = song.split(" - ")
        print(parts[0].rjust(16) + ":" + " " + parts[1])
res = show(album)
res is None
Caterina Caselli: Cento giorni
        Delirium: Jesahel
      Jan Hammer: Crockett's Theme
  Sonata Arctica: White Pearl, Black Oceans
     Lucio Dalla: 4 marzo 1943.mp3
   The Wellermen: Wellerman
       Manu Chao: Por el Suelo
     Intillimani: El Pueblo Unido
True

Exercise - authors#

Write a function authors which given a list of songs, returns a new list with only the authors.

Hint

In album below, how are authors and titles separated? Using a keyword argument, try to account for the fact that you may want to reuse your function for input albums in which this separation is done with other characters.

album = [
    "Caterina Caselli - Cento giorni",
    "Delirium - Jesahel",
    "Jan Hammer - Crockett's Theme",
    "Sonata Arctica - White Pearl, Black Oceans",
    "Lucio Dalla - 4 marzo 1943.mp3",
    "The Wellermen - Wellerman",    
    "Manu Chao - Por el Suelo",
    "Intillimani - El Pueblo Unido"
]
Hide code cell content
def authors(songs, split_by=" - "):
    ret = []
    for song in songs:
        ret.append(song.split(split_by)[0])
    return ret
print(authors(album))
album
['Caterina Caselli', 'Delirium', 'Jan Hammer', 'Sonata Arctica', 'Lucio Dalla', 'The Wellermen', 'Manu Chao', 'Intillimani']
['Caterina Caselli - Cento giorni',
 'Delirium - Jesahel',
 "Jan Hammer - Crockett's Theme",
 'Sonata Arctica - White Pearl, Black Oceans',
 'Lucio Dalla - 4 marzo 1943.mp3',
 'The Wellermen - Wellerman',
 'Manu Chao - Por el Suelo',
 'Intillimani - El Pueblo Unido']

Exercise - record#

Write a function record which, given two lists songsA and songsB, MODIFIES songsA overwriting it with the content of songsB. If songsB has less elements than songsS, fill the remaining spaces with None

  • ASSUME songsB has at most the same number of songs of songsA

  • DO NOT reassign album (so no album =)

album = [
    "Caterina Caselli - Cento giorni",
    "Delirium - Jesahel",
    "Jan Hammer - Crockett's Theme",
    "Sonata Arctica - White Pearl, Black Oceans",
    "Lucio Dalla - 4 marzo 1943.mp3",
    "The Wellermen - Wellerman",
    "Manu Chao - Por el Suelo",
    "Intillimani - El Pueblo Unido",
]
Hide code cell content
def record(songA, songB):
    for i in range(len(songB)):
        songA[i] = songB[i]
    i += 1
    while i < len(songA):
        songA[i] = None
        i += 1
record(
    album,
    ["Toto Cotugno - L'Italiano vero", "Mia Martini - Minuetto", "Al Bano- Nel sole"],
)
album
["Toto Cotugno - L'Italiano vero",
 'Mia Martini - Minuetto',
 'Al Bano- Nel sole',
 None,
 None,
 None,
 None,
 None]

Exercise - great#

Write a function great which, given a list of songs, MODIFIES the list by uppercasing all the characters, and then RETURNS it

  • DO NOT reassign album (no album =)

album = [
    "Caterina Caselli - Cento giorni",
    "Delirium - Jesahel",
    "Jan Hammer - Crockett's Theme",
    "Sonata Arctica - White Pearl, Black Oceans",
    "Lucio Dalla - 4 marzo 1943.mp3",
    "The Wellermen - Wellerman",
    "Manu Chao - Por el Suelo",
    "Intillimani - El Pueblo Unido",
]
Hide code cell content
def great(songs):
    for i in range(len(songs)):
        songs[i] = songs[i].upper()
    return songs
print(great(album))
print()
album
['CATERINA CASELLI - CENTO GIORNI', 'DELIRIUM - JESAHEL', "JAN HAMMER - CROCKETT'S THEME", 'SONATA ARCTICA - WHITE PEARL, BLACK OCEANS', 'LUCIO DALLA - 4 MARZO 1943.MP3', 'THE WELLERMEN - WELLERMAN', 'MANU CHAO - POR EL SUELO', 'INTILLIMANI - EL PUEBLO UNIDO']
['CATERINA CASELLI - CENTO GIORNI',
 'DELIRIUM - JESAHEL',
 "JAN HAMMER - CROCKETT'S THEME",
 'SONATA ARCTICA - WHITE PEARL, BLACK OCEANS',
 'LUCIO DALLA - 4 MARZO 1943.MP3',
 'THE WELLERMEN - WELLERMAN',
 'MANU CHAO - POR EL SUELO',
 'INTILLIMANI - EL PUEBLO UNIDO']

Exercise - shorten#

Write a function shorten which given a list of songs and a number n, MODIFIES songs so it has only n songs, then RETURNS a NEW list with all the removed elements.

  • if n is too large, returns an empty list without modifying the album

  • USE a parameter name different from album

  • DO NOT reassign album (so no album =)

album = [
    "Caterina Caselli - Cento giorni",
    "Delirium - Jesahel",
    "Jan Hammer - Crockett's Theme",
    "Sonata Arctica - White Pearl, Black Oceans",
    "Lucio Dalla - 4 marzo 1943.mp3",
    "The Wellermen - Wellerman",
    "Manu Chao - Por el Suelo",
    "Intillimani - El Pueblo Unido",
]
Hide code cell content
def shorten(songs, n):
    ret = []
    if n >= len(songs):
        return ret
    for i in range(n):
        ret.append(songs.pop())
    ret.reverse()
    return ret
res1 = shorten(album, 3)
print("returned:\n", res1, "\n")
print("the album is:\n", album, "\n")
res2 = shorten(album, 5)
print("returned:\n", res2, "\n")
print("the album is:\n", album, "\n")
returned:
 ['The Wellermen - Wellerman', 'Manu Chao - Por el Suelo', 'Intillimani - El Pueblo Unido'] 

the album is:
 ['Caterina Caselli - Cento giorni', 'Delirium - Jesahel', "Jan Hammer - Crockett's Theme", 'Sonata Arctica - White Pearl, Black Oceans', 'Lucio Dalla - 4 marzo 1943.mp3'] 

returned:
 [] 

the album is:
 ['Caterina Caselli - Cento giorni', 'Delirium - Jesahel', "Jan Hammer - Crockett's Theme", 'Sonata Arctica - White Pearl, Black Oceans', 'Lucio Dalla - 4 marzo 1943.mp3'] 

Lambda functions#

Lambda functions are functions which:

  • have no name

  • are defined on one line, typically right where they are needed

  • their body is an expression, thus you need no return

Let’s create a lambda function which takes a number x and doubles it:

lambda x: x*2
<function __main__.<lambda>(x)>

As you see, Python created a function object, which gets displayed by Jupyter. Unfortunately, at this point the function object got lost, because that is what happens to any object created by an expression that is not assigned to a variable.

To be able to call the function, we will thus convenient to assign such function object to a variable, say f:

f = lambda x: x*2
f
<function __main__.<lambda>(x)>

Great, now we have a function we can call as many times as we want:

f(5)
10
f(7)
14

So writing

def f(x):
    return x*2

or

f = lambda x: x*2

are completely equivalent forms, the main difference being with def we can write functions with bodies on multiple lines. Lambdas may appear limited, so why should we use them? Sometimes they allow for very concise code. For example, imagine you have a list of tuples holding animals and their lifespan:

animals = [("dog", 12), ("cat", 14), ("pelican", 30), ("eagle", 25), ("squirrel", 6)]

If you want to sort them by lifespan, you can try the .sort method but it will not work:

animals.sort()
animals
[('cat', 14), ('dog', 12), ('eagle', 25), ('pelican', 30), ('squirrel', 6)]

Clearly, this is not what we wanted. To get proper ordering, we need to tell Python that when it considers a tuple for comparison, it should extract the lifespan number. To do so, Python provides us with key parameter, to which we must pass a function that takes as argument the sequence element under consideration (in this case a tuple) and returns a transformation of it which Python will use to perform the comparisons - in this case we want the life expectancy at the 1-th position in the tuple:

animals.sort(key=lambda t: t[1])
animals
[('squirrel', 6), ('dog', 12), ('cat', 14), ('eagle', 25), ('pelican', 30)]

Now we got the ordering we wanted. We could have written the thing as

def myf(t):
    return t[1]

animals.sort(key=myf)
animals
[('squirrel', 6), ('dog', 12), ('cat', 14), ('eagle', 25), ('pelican', 30)]

but lambdas clearly save some keyboard typing.

Warning

Note, however, that except in these kinds of cases in which you need to pass a function once to some function or method, it is highly preferable to define functions with a def.

Notice lambdas can take multiple parameters:

mymul = lambda x,y: x * y

mymul(2,5)
10

Exercise - apply_borders#

✪ Write a function apply_borders which takes a function f as parameter and a sequence, and RETURN a tuple holding two elements:

  • first element is obtained by applying f to the first element of the sequence

  • second element is obtained by applying f to the last element of the sequence

Hide code cell content
def apply_borders(f, seq):
    return (f(seq[0]), f(seq[-1]))
print(apply_borders(lambda x: x.upper(), ["the", "river", "is", "very", "long"]))
print(apply_borders(lambda x: x[0], ["the", "river", "is", "very", "long"]))
('THE', 'LONG')
('t', 'l')

Exercise - process#

✪✪ Write a lambda expression to be passed as first parameter of the function process defined down here, so that a call to process generates a list as shown here:

NOTE: process is already defined, you do not need to change it

def process(f, lista, listb):
    orda = list(sorted(lista))
    ordb = list(sorted(listb))
    ret = []
    for i in range(len(lista)):
        ret.append(f(orda[i], ordb[i]))
    return ret    
Hide code cell content
f = lambda x,y: x.upper() + y
process(f, ["d", "b", "a", "c", "e", "f"], ["q", "s", "p", "t", "r", "n"])
['An', 'Bp', 'Cq', 'Dr', 'Es', 'Ft']