Le nouveau programme de seconde préconise de programmer en écrivant des fonctions. Un des aspects intéressants des fonctions en Python, comme dans la plupart des langages de programmation, est que les variables d’une fonction sont locales à cette fonction. Dans ce billet, j’explique ce que cela signifie et comment les choses se passent lors de l’exécution d’un appel de fonction.

Cela permet également de comprendre les possibilités qui existent lorsqu’on écrit une fonction dont un paramètre est une liste (une structure dite mutable). Bien que l’étude des listes ne soit pas au programme, on pourra, nous dit le document d’accompagnement, être amené à s’en servir ponctuellement. Il est donc judicieux pour le professeur de connaitre les problèmes qui peuvent survenir lors de leur utilisation.

Contextes et tas

Pour comprendre l’exécution d’une fonction, il faut avoir une représentation de la façon dont la mémoire est conceptuellement organisée par Python. Pour aller vite, chacune des différentes valeurs qu’on manipule (l’entier 2, le flottant 2.0, le booléen True, la chaine "True", etc.) est représentée en mémoire par ce que j’appellerai un objet : lorsqu’on calcule 32 + 10, cela donne lieu, in fine, a la création d’un objet qui représente l’entier 42. Ces objets résident dans une partie de la mémoire qu’on appelle le tas. Lorsqu’on procède à une affectation, on convient d’un nom pour désigner une valeur. Ainsi, effectuer x = 32 + 10 consiste d’abord à calculer la valeur de l’expression 32 + 10 (soit un objet qui représente 42) puis à mémoriser que le nom x désigne cette valeur (cet objet). L’ensemble de ces désignations forme un contexte, et les expressions sont évaluées en référence à un contexte. C’est ainsi qu’après avoir effectué l’affectation ci-dessus, l’expression x + 1 s’évalue en l’entier 43. Il y a plusieurs contextes qui peuvent exister ; j’y reviendrai. Il y a notamment un contexte qu’on dit parfois global ou « de la console » : c’est lui qui mémorise les associations découlant des affectations qui sont effectuées directement dans la console Python. En revanche, il n’y a qu’un seul tas. Lorsque je présente cela en cours de MPSI/PCSI, à la mi-septembre, je dessine une petite étoile au tableau dans la partie « Tas » pour chaque objet (avec la valeur qu’il matérialise) et dans la partie « Contexte global » j’inscris le nom de chaque variable avec une flèche qui pointe vers l’étoile correspondante dans le tas (c’est analogue à ce que fait Pythontutor). J’ai la flemme de faire des dessins ici, mais c’est important d’avoir cette représentation en tête (ou sur papier) pour suivre la suite. Quand on fait une nouvelle affectation à la même variable, on raye ou efface l’ancienne flèche pour la remplacer par une nouvelle qui pointe vers la nouvelle valeur.

Contexte d’appel et localité des variables

Considérons la fonction suivante, qui renvoie le nombre de racines réelles du trinôme \(aX^2+bX+c\).
def nb_racines(a, b, c) :
  d = b**2 - 4*a*c
  if d > 0 :
    return 2
  elif d == 0 :
    return 1
  else :
    return 0
Cette fonction présente une variable, locale, dont l’intérêt est d’éviter de calculer deux fois le discriminant, ce qui se produirait si on reproduisait la formule dans la condition du if et dans celle du elif. Que se passe-t-il si, après avoir défini cette fonction, on effectue successivement dans la console les opérations suivantes ?
d = 1792
nb_racines(1, 45, 27)
d
En particulier, est-ce que d vaut toujours 1792, ou a-t-il pris la valeur du discriminant de \(X^2+45X+27\), soit 1917 ? Et d’ailleurs, comment est-ce que le code de la fonction, qu’on a écrit pour des paramètres formels a, b et c, s’exécute avec les paramètres effectifs de notre appel, 1, 45 et 27 ? La réponse à ces deux questions est dans les contextes. L’affectation d = 1792 se fait dans le contexte de la console. Mais pour chaque appel de fonction, Python crée un contexte spécifique (avant cela, il calcule la valeur de chacun des paramètres effectifs). C’est pour ça qu’on peut faire nb_racines(1, 42 + 3, 27) voire, soyons fous nb_racines(nb_racines(1, 2, 1), 42 + 3, 27). Et c’est pour ça que les variables sont locales. Concrètement, un contexte est créé pour l’appel, dans lequel le nom de chaque paramètre formel est automatiquement associé à la valeur de chacun des paramètres effectifs. Et l’appel de fonction s’exécute dans ce contexte, ce qui signifie notamment que l’affectation d = b**2 - 4*a*c se fait dans ce contexte, sans toucher à la variable d du contexte de la console. Quand l’appel se termine, le contexte est détruit. Dans la représentation avec des flèches, vous avez un contexte global par exemple en bleu un contexte spécifique en rouge et le tas en noir. Donc on distingue le d bleu du d rouge et chacun a une flèche qui pointe vers une étoile noire dans le tas (éventuellement la même). La spécificité du contexte d’appel permet d’imbriquer des appels de fonction sans difficulté et permet la localité des variables, qui est très pratique. Comparons la situation avec celles de certaines calculatrices où les variables sont globales : quand on écrit un programme, on doit faire attention à ne pas utiliser les mêmes variables que dans un autre ; quelle idiotie ! En Python, chaque fonction a ses variables, c’est beaucoup plus pratique.

Mutabilité

Introduction

Les listes-de-Python sont une structure qui permet de rassembler plusieurs données. Par exemple, on peut considérer une liste de mesures physiques, ou la liste d’entiers suivante, qu’on nomme L dans le contexte de la console :
L = [1792, 1848, 1871, 1917, 1936, 1968, 1981, 2005]
Entre autres choses, on peut connaitre le nombre d’éléments de la liste en évaluant l’expression len(L) et on peut aussi lire chacune des valeurs contenues grâce à un numéro d’indice qui commence à zéro. Ainsi l’expression L[1] vaut 1848, etc. On peut également modifier le contenu de chaque case (en faisant par exemple L[0] = 1793), et c’est pour cela qu’on dit que la structure est mutable. Dans la représentation graphique de la mémoire, on a une nouveauté : la liste est un objet du tas qui lui même est l’origine de flèches (ici 8) qui pointent vers d’autres objets du tas, et ces flèches peuvent être remplacées par d’autres. C’est très pratique mais les conséquences sont nombreuses : le tas étant commun, on a maintenant des flèches communes, alors qu’avec les contextes chaque appel de fonction avait ses flèches.

Le problème de l’alias

Chaque fois qu’on évalue une expression de la forme [1, 2, 3], on provoque la création dans le tas d’une nouvelle liste, distincte de toutes les autres (même si son contenu peut être identique à celui d’une autre). Ainsi, après avoir effectué
A = [1, 2, 3]
B = [1, 2, 3]
on dispose de deux listes qu’on peut modifier indépendamment l’une de l’autre (graphiquement : dans le contexte global, A et B pointent chacun sur une étoile différente du tas). On peut effectuer A[0] = 1917 et cela ne modifie pas B. Mais si on effectue, à la suite de cela, C = B alors on a donné à C la valeur de l’expression B, c’est-à-dire non pas une nouvelle liste mais telle liste pré-existante. Ainsi B et C sont en alias : graphiquement, les deux flèches pointent vers le même objet du tas. Notez que ce phénomène d’alias n’est pas nouveau : il se produirait aussi si B désignait un entier. Mais tant qu’on est avec des entiers, des flottants ou des structures immuables, on s’en moque, parce qu’il ne peut rien arriver à ces objets une fois qu’ils sont créés. En revanche, pour nos listes, on peut effectuer B[0] = 1936 et cela modifie également C (puisque c’est le même objet !), de sorte qu’ensuite C[0] vaut 1936.

Fonction dont un paramètre est une liste

Le problème d’alias présenté ci-dessus est ridicule : personne ne va sérieusement faire cela. Mais ce phénomène se produit chaque fois qu’on appelle une fonction dont un paramètre est une liste. Considérons la fonction suivante.
def miroir(T) :
  for k in range(0, len(T) // 2) :
    temp = T[k]
    T[k] = T[len(T) - 1 - k]
    T[len(T) - 1 - k] = temp
Si on effectue miroir(L), on n’obtient pas de résultat visible dans la console (en fait, cette fonction renvoie None), mais si par la suite on évalue L, on constate que son contenu est désormais [2005, 1981, 1968, 1936, 1917, 1871, 1848, 1792]. Cela semble violer la localité des variables. En fait pas du tout. Lorsque Python a créé le contexte spécifique pour l’appel miroir(L), il a associé dans ce contexte le nom du paramètre formel T à la valeur du paramètre effectif L. On a donc une situation d’alias : le nom L du contexte global et le nom T du contexte spécifique d’appel désignent le même objet. Encore une fois, ça se produit à chaque appel de fonction avec une variable en paramètre effectif. Mais la nouveauté est qu’ici l’objet commun est mutable : on peut, grâce au nom local T, accéder aux flèches communes de l’objet et les modifier. On dit que la fonction miroir a un effet sur son paramètre, et pour cette raison, on dit que c’est une fonction impure et que l’appel miroir(T) est une expression impure. Il arrive qu’on veuille écrire un algorithme qui modifie sur place une liste tout en ayant quand même une fonction pure. Dans ce cas, on fait une copie au début de la fonction S = T.copy() et on travaille sur la copie. En effet, effectuer S = T ne ferait que créer un nouvel alias pour toujours le même objet, ce qui n’a aucun intérêt.

Conclusion

Encore une fois, tout ceci dépasse très probablement ce qu’on peut étudier au niveau seconde. Cependant, si on manipule des listes, il est possible de provoquer des effets sans le vouloir et cela peut susciter de l’incompréhension chez l’élève ; il ne faudrait pas qu’elle soit partagée par son professeur. Je voudrais en particulier mettre en garde contre l’utilisation de l’opérateur +=. On trouve parfois, et il est tentant (et correct !) d’écrire x += 1 au lieu de x = x + 1. Cependant, lorsqu’on manipule des listes l’opérateur + désigne la concaténation et l’instruction L += M n’est pas du tout équivalente à L = L + M. En effet, la première provoque un effet sur l’objet du tas désigné par L tandis que la seconde calcule une nouvelle liste et change la désignation dans le contexte courant de la variable L. Ainsi, une fonction qui utilise la première est impure, tandis qu’une fonction qui utilise la seconde est pure (dès lors qu’elle ne fait pas d’effet par ailleurs). Comme la première opération peut aussi être réalisée par L.extend(M), l’opérateur += ne sert à rien et peut donc, et à mon sens devrait, être totalement ignoré ; les confusions sont trop faciles pour un gain en nombre de touches frappées trop minime.

3 commentaires on “Python en seconde : localité des variables et mutabilité

  • Ayant la chance d’avoir des élèves de prépa créatifs, il m’est arrivé de tomber sur des cas un peu plus vicieux encore : ces élèves manipulaient des listes de listes et s’étonnaient des résultats de leurs appels de fonctions…

    Si L est une liste de listes, M=L.copy() crée une nouvelle liste M, dans laquelle M[i] et L[i] sont des alias : modifier M[i][j] modifie donc L[i][j]…

    Dans ce cas, il me semble que seule l’utilisation de la fonction deepcopy du module copy permet de délier une fois pour toutes les alias entre les éléments de L et de sa (deep)copie.

    Un petit exemple artificiel (la liste copiée C ne sert absolument à rien) :

    import itertools
    from numpy.random import randint
     
    def transpose(M):
        """
        Calcule la matrice transposée d'une matrice 3x3
        représentée par une liste de listes
     
        Paramètre
        ---------
        M   liste
            M[i][j] : élément d'indice i,j de la matrice 3x3
     
        Résultat
        --------
        N   matrice transposée de M
        """
        N=[[M[i][j] for i in range(3)] for j in range(3)]
        C=M.copy()
        for i,j in itertools.product(range(3),range(3)):
            C[i][j]=N[i][j]
        return N
     
    M=[list(randint(10,size=(3))) for i in range(3)]
    print(M)
    print(transpose(M))
    print(M)

    On peut créer une vraie fonction pure en utilisant copy.deepcopy en remplaçant le début de ce script par :

    import itertools
    import copy
    from numpy.random import randint
     
    def transpose(M):
        """
        Calcule la matrice transposée d'une matrice 3x3
        représentée par une liste de listes
     
        Paramètre
        ---------
        M   liste
            M[i][j] : élément d'indice i,j de la matrice 3x3
     
        Résultat
        --------
        N   matrice transposée de M
        """
        N=[[M[i][j] for i in range(3)] for j in range(3)]
        C=copy.deepcopy(M)
        for i,j in itertools.product(range(3),range(3)):
            C[i][j]=N[i][j]
        return N
    • Merci pour ce commentaire qui illustre le problème de la copie superficielle et présente aussi la syntaxe de création de listes, deux points que j’avais laissés de côté pour ne pas allonger mon billet.

      Notons que concernant la transposée en version fonction pure qui renvoie une nouvelle matrice, il n’est en effet pas nécessaire de faire une copie, puisqu’on peut tout simplement écrire [ [M[i][j] for i in range(0, len(M))] for j in range(0, len(M[0])) ].

      • Il est vrai que la version de Yann Salmon est plus concise et beaucoup moins confuse à mes yeux que l’autre.
        Cependant, après quelques petites recherches, cette petite composée de fonctions semble encore plus intéressante (pour un développeur expérimenté) :
        list(map(list, zip(*M)))
        Cependant, et même si j’utiliserais plus volontiers ce code, elle est bien moins claire au premier abord, donc peu adapté à l’enseignement. Je trouve juste que Python peut se révéler très (trop ?) concis pour exprimer des idées assez complexes, presque magique parfois.

Les commentaires sont fermés.