Fonctions et contextes locaux

In [ ]:
%pylab inline
%load_ext tutormagic
Populating the interactive namespace from numpy and matplotlib

Fonction pure

In [ ]:
a = 4
b = 15

def somme(x, y):
    z = x + y
    return z

c = 100
d = 13

La fonction somme définie ci-dessus est autonome : il n'est pas nécessaire de lire le code qui la précède ou qui la suit pour comprendre ce qu'elle produit comme effet.

In [ ]:
somme(3, 8)
Out[ ]:
11

L'évaluation ci-dessus n'a pas modifié l'état de l'interpréteur : cela n'a pas créé de nouvelles variables et les variables déjà définies n'ont pas été modifiées.

La fonction somme peut être qualifiée de fonction pure :

  • Le comportement de la fonction ne dépend que de la valeur des arguments passés à l'appel de la fonction.
  • L'unique effet de la fonction est de retourner une valeur au code qui appelle la fonction.
  • L'état de l'interpréteur n'a aucun effet sur le comportement de la fonction.
  • L'état de l'interpréteur n'est pas modifié par la fonction.

Les fonctions pures permettent l'écriture de code modulaire où les interactions entre les diverses parties du code sont réduites et bien maîtrisées. Par exemple, on a la garantie qu'un appel à la fonction math.sqrt ne modifiera pas subrepticement une de nos variables. On est également certain que math.sqrt donnera toujours le même résultat quelque soit l'endroit où on utilise cette fonction dans notre code. La lecture et la mise au point du code s'en trouvent simplifiées.

Contexte global d'exécution

L'effet d'une instruction python dépend éventuellement du contexte dans lequel elle est exécutée. Ce contexte peut être vu comme un dictionnaire où chaque nom correspond à une variable associée à une valeur. L'effet d'une affectation est de créer ou modifier une entrée du dictionnaire.

L'interpréteur python qui a été lancé avec le présent notebook dispose d'un contexte global dont l'état actuel peut être visualisé à l'aide de la commande magique %whos :

In [ ]:
%whos
Variable   Type        Data/Info
--------------------------------
a          int         4
b          int         15
c          int         100
d          int         13
pure       function    <function pure at 0x7f1194bffbf8>
somme      function    <function somme at 0x7f1194bffea0>

Contexte local d'une fonction

Lors de l'appel d'une fonction un nouveau contexte d'exécution est créé en mémoire. Ce contexte est initialisé à partir des arguments de la fonction. Des variables locales peuvent être définies dans ce contexte. Lorsque l'exécution de la fonction se termine, ce contexte local disparait.

Ce principe est illustré sur cette page : http://tinyurl.com/gpx4m2m

In [ ]:
%%tutor -t
a = 4
b = 15

def somme(x, y):
    z = x + y
    return z

c = 100
d = 13

c = c * somme(a, b + 1)
print(a, b, c)

La ligne 12 du code ci-dessus conduit en particulier à calculer somme(4, 16). Pour cela, un contexte local est créé et initialisé avec x = 4 et y = 16. Une variable z est créée localement dans ce contexte. L'exécution de la ligne 7 provoque la disparition du contexte. L'évaluation de l'expression de la ligne 12 reprend alors en remplaçant somme(a, b + 1) par la valeur retournée par le contexte qui vient de disparaître.

Effets de bords

Lorsqu'une fonction produit un autre effet que celui de retourner une valeur on dit qu'elle produit des effets de bord. Ces effets secondaires peuvent être de diverses natures.

Lecture d'une variable globale

In [ ]:
def fonctA(x):
    return x + n
In [ ]:
fonctA(3)
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
<ipython-input-22-bbb19bf93133> in <module>()
----> 1 fonctA(3)

<ipython-input-21-ae34a092ac96> in fonctA(x)
      1 def fonctA(x):
----> 2     return x + n

NameError: name 'n' is not defined
In [ ]:
n = 23
In [ ]:
fonctA(5)
Out[ ]:
28

Le comportement de la fonction fonctA dépend de la valeur de n. Le code qui en résulte peut être difficile à lire si les définitions de n et de fonctA sont éloignées l'une de l'autre dans le source.

Modification d'une variable globale

In [ ]:
def fonctB(x):
    n = n + 1
    return x + n
In [ ]:
fonctB(3)
---------------------------------------------------------------------------
UnboundLocalError                         Traceback (most recent call last)
<ipython-input-24-aa2a1ca5f434> in <module>()
----> 1 fonctB(3)

<ipython-input-23-34015a9b0c4b> in fonctB(x)
      1 def fonctB(x):
----> 2     n = n + 1
      3     return x + n

UnboundLocalError: local variable 'n' referenced before assignment

À la lecture du message d'erreur on comprend que Python interprète n comme une variable locale à la fonction fonctB. Or à la ligne 2, on cherche à lire la variable locale n avant de lui avoir donné une valeur.

En écrivant le code ci-dessus, le programmeur souhaite peut être modifier la variable globale n. Par défaut, Python qui est pourtant très permissif n'autorise pas la modification des variables globales par les fonctions. Lorsque l'on écrit un programme de quelques milliers de lignes de code, afin de maîtriser sa complexité il est indispensable de le structurer en composants qui soient les plus indépendants possibles. Si chaque petite fonction peut modifier l'état global de l'interpréteur on aboutit alors à un code spaghetti où chaque instruction peut produire un effet secondaire n'importe où ailleurs dans le programme. Un tel code se révèle rapidement illisible et impossible à déboguer.

Dans la philosophie de Python, on est entre adultes responsables. Vous pouvez ainsi utiliser le mot-clé global pour vous affranchir de ce garde-fou dans une situation particulière. Mais il est fort probable que vous pouvez et devriez faire autrement.

In [ ]:
%%tutor -t
def fonctB(x):
    global n
    n = n + 1
    return x + n

n = 7
r = fonctB(3)
print(n)  # n vaut maintenant 8

Modification des arguments mutables

Certaines valeurs python peuvent être modifiées, d'autres ne le peuvent pas. Les valeurs de type int, float et str sont des types non mutables. Par contre, les list et les np.array sont des types mutables.

Une valeur mutable passée en argument d'une fonction peut être modifiée par celle-ci :

In [ ]:
def square(L):
    for i in range(len(L)):
        L[i] = L[i] ** 2

T = [2, 3, 4]
square(T)
print(T)
[1, 9, 25]

Le code de la fonction square ne contient pas de return. La fonction ne retourne aucune valeur ou plus précisément elle retourne la valeur None. L'effet de la fonction square est de modifier la liste passée en argument.

Ce comportement est illustré sur cette page : http://tinyurl.com/nokggyw.

In [ ]:
%%tutor -t
def square(L):
    for i in range(len(L)):
        L[i] = L[i] ** 2

T = [2, 3, 4]
square(T)
print(T)

La pile d'appels

La fonction tri(L) ci-dessous modifie la liste L de sorte qu'après son exécution les éléments de la liste L sont triés par ordre croissant. Le principe de l'algorithme consiste à rechercher la plus petite valeur de la liste et à échanger cette valeur avec celle de la première case. On recherche ensuite le second plus petit élément pour le placer sur la seconde case. On réitère le processus jusqu'à ce que la liste soit totalement triée. Cette façon de trier s'appelle le tri par sélection.

Pour plus de lisibilité le code a été découpé en sous-fonctions qui réalisent chacunes une tâche bien déterminée.

In [ ]:
def indexMin(L, k, j):
    """ Retourne l'indice k ou j contenant la plus petite valeur."""
    if L[j] < L[k]:
        return j
    else:
        return k

    
def selectMin(L, i):
    """ Retourne l'indice k >= i contenant la plus petite valeur de L[i:]."""
    n = len(L)
    k = i
    for j in range(i + 1, n):
        k = indexMin(L, k, j)
    return k


def tri(L):
    """ Tri la liste L par ordre croissant."""
    n = len(L)
    for i in range(n - 1):
        j = selectMin(L, i)
        L[i], L[j] = L[j], L[i]

    
T = [5, 3, 8, 1]
tri(T)
print(T)
[1, 3, 5, 8]

Il est instructif de suivre l'exécution du code avec pythontutor.

In [ ]:
%%tutor -t
def indexMin(L, k, j):
    if L[j] < L[k]:
        return j
    else:
        return k
    
def selectMin(L, i):
    n = len(L)
    k = i
    for j in range(i + 1, n):
        k = indexMin(L, k, j)
    return k

def tri(L):
    n = len(L)
    for i in range(n - 1):
        j = selectMin(L, i)
        L[i], L[j] = L[j], L[i]
    
T = [5, 3, 8, 1]
tri(T)
print(T)

Pour gérer les appels de fonctions, l'interpréteur Python utilise une structure de données appelée pile d'appels. À chaque appel de fonction, un nouveau contexte local est empilé au-dessus du contexte appelant. Ce contexte est initialisé en liant les noms des arguments tels qu'ils apparaissent dans la définition de la fonction aux valeurs des arguments fournis à l'appel. Le code de la fonction est alors exécuté sur ce contexte. Lorsque la fonction retourne sa valeur, ce nouveau contexte disparait et l'ancien contexte est restauré.

Les contextes locaux s'empilent donc les uns sur les autres à chaque appel de fonction. Le contexte actif est celui qui est situé sur le sommet de la pile. Lorsque l'appel de la fonction en cours se termine, le contexte actif est retiré du sommet de la pile. Le contexte qui était à l'origine de l'appel est à nouveau au sommet. Il récupère la valeur de retour, redevient actif et poursuit son exécution.

Trace d'appels

Quand une erreur se produit lors de l'exécution du code, par exemple une division par 0, le comportement par défaut de Python est d'interrompre l'exécution en affichant la trace d'appels qui fournit des informations au programmeur.

Dans le code ci-dessous, nous avons ajouté l'instruction x = 1 / 0 à la ligne 3. Cette instruction va certainement produire une erreur d'exécution :

In [ ]:
def indexMin(L, k, j):
    """ Retourne l'indice k ou j contenant la plus petite valeur."""
    x = 1 / 0
    if L[j] < L[k]:
        return j
    else:
        return k

def selectMin(L, i):
    """ Retourne l'indice k >= i contenant la plus petite valeur de L[i:]."""
    n = len(L)
    k = i
    for j in range(i + 1, n):
        k = indexMin(L, k, j)
    return k

def tri(L):
    """ Tri la liste L par ordre croissant."""
    n = len(L)
    for i in range(n - 1):
        j = selectMin(L, i)
        L[i], L[j] = L[j], L[i]

T = [5, 3, 8, 1]
tri(T)
print(T)
---------------------------------------------------------------------------
ZeroDivisionError                         Traceback (most recent call last)
<ipython-input-3-75c4510bfc1d> in <module>()
     26 
     27 T = [5, 3, 8, 1]
---> 28 tri(T)
     29 print(T)

<ipython-input-3-75c4510bfc1d> in tri(L)
     21     n = len(L)
     22     for i in range(n - 1):
---> 23         j = selectMin(L, i)
     24         L[i], L[j] = L[j], L[i]
     25 

<ipython-input-3-75c4510bfc1d> in selectMin(L, i)
     13     k = i
     14     for j in range(i + 1, n):
---> 15         k = indexMin(L, k, j)
     16     return k
     17 

<ipython-input-3-75c4510bfc1d> in indexMin(L, k, j)
      1 def indexMin(L, k, j):
      2     """ Retourne l'indice k ou j contenant la plus petite valeur."""
----> 3     x = 1 / 0
      4     if L[j] < L[k]:
      5         return j

ZeroDivisionError: division by zero

La trace d'appels ci-dessus montre l'état de la pile d'appels au moment où l'exception ZeroDivisionError s'est produite :

Contexte Ligne Instruction
global 28 tri(T)
tri 23 j = selectMin(L, i)
selectMin 15 k = indexMin(L, k, j)
indexMin 3 x = 1 / 0

Exercices

Question 1

Qu'imprime l'exécution du code ci-dessous ?

L[0] = 21

def f():
    L[0] = L[0] + 1

print(L[0])

def g(T):
    T[0] = T[0] + 1

print(L[0])

def h(T):
    return T[0] + 1

print(L[0])
f()
print(L[0])
g(L)
print(L[0])
h(L)
print(L[0])
L[0] = h(L)
print(L[0])
In [ ]:
 

Question 2

Que contient L après l'exécution du code ci-dessous ?

def f(L):
    L = L + [3]

L = [1, 2]
f(L)
In [ ]:
 

Question 3

Que contient L après l'exécution du code ci-dessous ?

def f(L):
    L = L + [3]

L = [1, 2]
L = f(L)
In [ ]:
 

Question 4

On considère le code ci-dessous :

def u(n):
    if v(n):
        return n
    else:
        return 0

def v(n):
    if n % 2 == 0:
        return u(n + 1) == 0
    else:
        return False

(a) Ce code est-il syntaxiquement correct ?

(b) Si oui, quel est le résultat du calcul u(8) ?

(c) Plus généralement, quel est le résultat du calcul u(n), si n est un entier naturel ?

Question 5

Que contient la liste L après l'exécution du code ci-dessous ?

L = []

def callmyself(n):
    L.append(n)
    callmyself(n + 1)

callmyself(1)
print(L)
In [ ]: