Affectation et appel de fonction – aspects avancés

Contents

10. Affectation et appel de fonction – aspects avancés#

Mis à jour : May 15, 2025, lecture : 16 minutes minimum, PhL.

Nous abordons maintenant les aspects des fonctions qui dépendent des langages de programmation. Nous insistons surtout sur les choix de python3.

Rappel. Le terme paramètre effectif, et son compagnon paramètre formel, sont des termes “langage-indépendant”. En python, le terme argument est utilisé à la place du terme paramètre effectif. Ainsi argument et paramètre effectif sont des synonymes, et seront utilisés comme tel dans ce qui suit.

10.1. Le passage des paramètres : un problème générique#

Le corps de la fonction ne connaît que les paramètres formels.
L’appel de la fonction définit les paramètres effectifs .

  • Comment le paramètre effectif devient-il connu par la fonction appelée ?

  • Qu’est-ce qui est connu dans l’appel, en particulier lorsque le paramètre effectif est une variable ?

    • la valeur du paramètre effectif ?

    • la variable paramètre effectif ?

Se sont les questions du passage de paramètres appelant->appelé

10.1.1. Comment est transmis un paramètre effectif ?#

  • Si c’est une constante : y = f(3)
    OK : la valeur constante est connue et utilisée dans l’appel

  • Si c’est une variable : x = 3 ; y = f(x)

    • f connaît la valeur de x et seulement sa valeur ?

    • f connaît la variable x, ce qui permet de lire sa valeur et aussi de la modifier a priori ?

  • Cette distinction s’étend aux paramètres (formels et effectifs) de type composé comme les tableaux, les listes, les chaînes de caractères, les enregistrements (ou structures) …

10.1.2. Deux modes matériels de passage des paramètres#

  1. passage par valeur ou copie : seules les valeurs des paramètres effectifs sont connus par la fonction.
    Ainsi, les paramètres effectifs, qui sont des variables dans le programme appelant, ne sont pas modifiés. Bien sûr, ce n’est pas le cas du/des paramètre/s de sortie).

  2. passage par adresse ou référence : les adresses des paramètres effectifs sont connues et sont manipulées par la fonction.
    Ces paramètres effectifs sont donc les variables de l’appelant – et aussi de l’appel.
    La fonction peut donc modifier les variables de l’appelant indépendamment du return.

Pros/cons : la copie de la valeur d'une "grosse" variable coûte plus cher que la copie de son adresse (c'est un `int`)

Les modes appliqués dépendent :

  • des langages de programmation

  • du type des paramètres

C-a-d. que les 2 modes peuvent coexister dans un même langage.

10.1.3. Trois modes logiques de passage des paramètres#

Certains langages (Ada par exemple) se sont dégagés des aspects matériels pour définir des modes logiques.
Bien sûr, l’implantation de ces modes logiques repose sur l’une ou l’autre des 2 modes physiques.

  1. mode in : le paramètre est uniquement utilisé en lecture, c-a-d. seule la transmission de sa valeur est nécessaire

  2. mode in-out : le paramètre est utilisé en lecture et en écriture, ici encore son adresse est nécessaire dans l’appel

  3. mode out : le paramètre est uniquement utilisé en écriture, c-a-d. sa valeur est (formellement) inconnue dans la fonction ; cette dernière détermine sa valeur et la transmet (par valeur ou par adresse) à l’appelant.

Ces modes sont facilement vérifiables lors de la compilation. Le mode in correspond aux paramètres d’entrée d’une fonction. Le mode out correspond à la valeur retournée par une fonction. le mode in-out correspond aux paramètres d’une procédure : les paramètres effectifs existent avant l’appel, celui-ci les modifient et ils subsistent au retour dans l’appelant.

10.2. Et pour python ?#

Le tutoriel python.org dit :

Les paramètres effectifs (arguments) d’une fonction sont introduits dans la table de symboles locale de la fonction appelée lorsqu’elle est appelée ; par conséquent, les passages de paramètres se font par valeur, la valeur étant toujours une référence à un objet, et non la valeur de l’objet lui-même.

Rassurez-vous : cette définition est difficile à comprendre en l’état. La suite de ce chapitre va permettre de mieux voir de quoi il s’agit.

Mentionnons qu’en python, le passage de paramètres est en fait similaire à celui de l’affectation. Il nous faut donc détailler ce qu’il y a “derrière” l’affectation en python. C’est l’objet de la section suivante.

Cependant indiquons dès maintenant ce qu’il faut retenir concernant le passage de paramètres.

Le passage des paramètres ( et de l’affectation ) en python dépend du caractère mutable ou non mutable de l’argument (i.e. paramètre effectif) concerné.

Un paramètre effectif non mutable est traité comme une variable locale à la fonction.

  • Ainsi la variable argument est connue dans la fonction mais elle n'est pas modifiée dans l'appelant par le traitement de la fonction
  • Il n'y a pas d'effet de bord de la fonction sur un paramètre effectif non mutable.

Un paramètre effectif mutable est “entièrement et directement accessible” par la fonction, comme une variable globale de l’appelant.

  • Ainsi une variable-argument mutable pourra être modifiée dans l'appelant par le traitement de la fonction appelée.
  • Un paramètre effectif mutable d'une fonction est susceptible d'un effet de bord suite à l'appel de la fonction.

Dans ce dernier cas (argument mutable), une variable-argument peut être modifiée partiellement ou complètement. Par exemple, une liste peut-être allongée, écourtée, concaténée, ses valeurs peuvent être modifiées globalement ; ce qui sera différent de modifications ponctuelles de certaines de ses valeurs.

  • Une modification partielle d'une variable-argument mutable et non concernée par le `return` provoquera un effet de bord malvenu dans l'appelant.
  • Une modification complète de cet argument mutable n'aura pas un tel effet.

En pratique, il est très fortement conseillé d’éviter tout effet de bord d’une fonction qui n’est pas une procédure. Il appartient au programmeur d’être attentif au caractère mutable ou non mutable des paramètres des appels de fonctions – surtout dans le contexte de typage dynamique de python : rappelons que les annotations de type des paramètres ne sont que des indications pour le lecteur ou des outils spécialisés mais sont ignorées de l’interpréteur python.

10.3. (\(\star\)) Variable et affection : aspects avancés#

Cette section relève de l'objectif 20

Commençons par revenir sur les notions de variable et d’affectation. Puis comment ces notions existent en python.

10.3.1. Notions importantes#

Variable, valeur, espace de stockage, adresse en mémoire, référence#

  • une variable permet d’accéder, en lecture ou en écriture, à une valeur qui est stockée en mémoire

    • une variable est définie par son identifiant : son nom de variable : n, t, ma_liste, ...

  • une valeur est stockée à un emplacement en mémoire aussi appelé espace de stockage

    • il est unique et est repéré par son adresse

    • une adresse est (similaire à) un int

    • la taille de l’espace mémoire dépend du type des valeurs à stocker : int (par exemple 32 bits) vs float(64 bits) vs. une list de float

Ainsi l’identifiant d’une variable peut-être vue comme un symbole associé à un espace de stockage.
L’identifiant de la variable est ainsi une référence à un espace de stockage.

Attention :

  • la difficulté de la notion de variable vient du fait que ce terme désigne, selon les cas, la valeur stockée en mémoire ou l’espace de stockage lui-même.

  • la notion de référence prend des sens différemment subtils selon les langage de programmation.

Affectation et évaluation#

L’affectation d’une valeur à une variable correspond à une modification de l’espace de stockage associé à la variable.

  • La valeur affectée est le résultat de l’évaluation du membre du droite de l’affectation.

  • Ce membre de droite peut être :

    • une valeur d’initialisation : x = 3 ou t = [1,2,3],

    • une variable : x = y,

    • une expression : x = y + 5,

    • un appel de fonction x = cos(alpha)

    • ou d’autres combinaisons plus compliquées.

Dans tous les cas, l’évaluation retourne une valeur.

Mais cette valeur peut prendre deux formes très différentes que nous introduisons avec l’exemple de l’affectation

x = yx et y sont deux variables.

L’affectation “variable x <- variable y” peut être en effet réalisée de 2 façons :

  • par copie : la variable x prend comme valeur la valeur de y (la répétition de “valeur” est voulue)

    • la valeur de y est dupliquée à une nouvelle adresse mémoire

    • la valeur à cette nouvelle adresse est maintenant accessible par l’identifiant x.

  • par référence : la variable x prend comme valeur l’adresse de la valeur de y (emplacement mémoire où est stockée la valeur de y)

    • la valeur de y n’est pas dupliquée

    • x et y référencent le même endroit en mémoire.

Dans les 2 cas :

  • la valeur de x avant l’affectation, si il y en avait une, est perdue.

Déclaration vs. affectation#

Dans la plupart des langages de programmation mais pas en Python, une variable doit être déclarée avant d’être utilisée.

Typiquement, la déclaration d’une variable contient la définition de son identifiant, de son type, d’une valeur initiale (l’initialisation) et éventuellement d’autres propriétés diverses.

Une fois déclarée, la variable existe. Affecter une valeur à cette variable est une des premières actions que l’on effectue avec cette variable.

En python :

Pas de déclaration
Une variable est définie (elle commence à exister) par sa première affectation.
Cette première affectation définit le type de cette variable et sa valeur (et donc son caractère mutable ou non mutable).

Une variable python est donc créée par l’affectation d’une valeur.

10.3.2. Variables et affectation en python#

Variables en python : mutable ou non mutable ?#

On a écrit qu’une variable python est une référence. Mais une référence à quoi ? A une valeur ? A une adresse de la mémoire ?

Les deux cas existent en python et dépendent du caractère mutable ou non mutable de la variable.

  • Une variable non mutable est une référence à une valeur.
  • Une variable mutable est une référence à l'adresse d'un espace de stockage.

python :

  • les types non-mutables : int, float, string, tuple

  • les types mutables : list, set, dict(ionnaire)

pythontutor. Illustrons cette importante différence avec cet exemple.

x = 3
t = [1, 2, 3]
  • x est une variable non mutable (c’est un int) et son identifiant correspond bien à la valeur d’initialisation, ici 3.

    • x est bien une (référence à) une valeur : la valeur 3.

  • En revanche, t est une liste python qui représente le tableau 1D de longueur 3 initialisé par les valeurs entières ‘1, 2, 3’.

    • Une liste python est une variable mutable. Ainsi, on voit que la variable t est représentée par une flèche qui désigne une (la) zone mémoire où sont stockés les 3 valeurs du tableau t.

    • t est encore une valeur mais cette fois, cette valeur est une adresse (vers les valeurs de la variable mutable).

Cette différence va prendre tout son sens avec l’affectation (et le passage de paramètre pour les fonctions).

# à essayer aussi dans pythontutor
x = 3
s = "bonjour"
e0 = (1, 2, 3) # un tuple est non mutable
l0 = [1, 2, 3] # une liste est mutable

Dupliquer des valeurs par référence : mutable vs. non mutable#

Cas mutable : l’affectation par référence ne duplique pas la valeur.

Ainsi si cette valeur est déjà associée à une autre variable (cas de l’affectation x = yy a été initialisée précédemment) alors x et y référencent le même emplacement en mémoire.
La modification ultérieure de l’une ou de l’autre des variables nécessite de lever l’ambiguïté suivante.

Effet de bord :

x est une variable introduite par l’affectation x = yy est une autre variable pré-existante.

  • Modifier ultérieurement x a-t-il aussi un effet sur y ?

  • Modifier ultérieurement y a-t-il aussi un effet sur x ?

Un tel effet est appelé un effet de bord ; ce qui peut être choquant d’un point de vue algorithmique.

En python, il est clair maintenant que la réponse à cette question dépend du caractère mutable ou non mutable du type de la variable.

Rmq. La fonction prédéfinie id() est définie au paragraphe suivant. Elle retourne l’adresse “en mémoire” de son argument.

def aff(v1, v2 : None) -> None:
    '''affichage adapté
    Rmq : l'annotation None pour v1 et v2 veut dire Any
    '''
    CAS_VRAI = "emplacements mémoire identiques"
    CAS_FAUX = "emplacements mémoire différents"

    if id(v1) == id(v2):
        print(v1, v2, CAS_VRAI)
    else:
        print(v1, v2, CAS_FAUX)
# les int sont non mutables
x = 3
y = x
aff(x, y)
# rmq. pythontutor n'exhibe pas le même traitement  
# voir plus loin : exemples introspection

y = 5
aff(x, y)

x = 6
aff(x, y)

# gardons ce cas particulier pour plus tard
x = 5
aff(x, y)
# rmq. pythontutor n'exhibe pas le même traitement  
3 3 emplacements mémoire identiques
3 5 emplacements mémoire différents
6 5 emplacements mémoire différents
5 5 emplacements mémoire identiques
# les strings sont non mutables
s1 = "aa"
s2 = s1
aff(s1, s2)

s2 = "bb"
aff(s1, s2)

s1 = "ab"
aff(s1, s2)
aa aa emplacements mémoire identiques
aa bb emplacements mémoire différents
ab bb emplacements mémoire différents
# les listes sont mutables
l = [1,1]
m = l
aff(l, m)

m = [2,2]
print("je modifie complètement m :")
aff(l, m)
[1, 1] [1, 1] emplacements mémoire identiques
je modifie complètement m :
[1, 1] [2, 2] emplacements mémoire différents
print("je recommence avec l = [1,1] et m = l ")
l = [1,1]
m = l
print("mais je modifie partiellement m -- sans penser à l :")
m[0] = 0 
aff(l, m)
je recommence avec l = [1,1] et m = l 
mais je modifie partiellement m -- sans penser à l :
[0, 1] [0, 1] emplacements mémoire identiques

Comme déjà évoqué, le dernier résultat (modification partielle de mutable) est assez surprenant. Je vous laisse imaginer la recherche de bug …

L’affectation python x = y avec des variables mutables ne crée pas un nouvel emplacement mémoire pour x.

Elle créée une nouvelle référence pour x (ou met à jour une référence existante) vers un emplacement qui existait précédemment : celui de y.

Ainsi cet emplacement est accessible par plusieurs références – ici x et y sont des références vers le même emplacement mémoire.

En pratique, l’environnement python connaît le nombre de références qui désignent le même emplacement mémoire. Ce qui lui permet le cas échéant de récupérer les zones qui ne sont plus désignées (mécanisme de garbage collector ou ramasse miettes en français).

10.3.3. Approfondissons par introspection#

Nous verrons plus tard que les “entités python” sont des objets.
python fait partie des langages de programmation appelés langage objet ou langage orienté objet.
Un objet est un ensemble d’attributs (des valeurs nommées) et de méthodes (des fonctions qui s’appliquent à tout ou partie de cet objet), cet ensemble étant accessible par un identifiant (le nom de l’objet).
Nous verrons qu’en fait un objet est (très souvent) associé à une notion plus générale, la notion de classe d’objets.
Une classe d’objets est un modèle d’objets et ainsi un objet d’une classe donnée est aussi appelé une instance de cette classe.

Rmq. Pour l’instant, pas d’énorme différence entre objet et variable telle que présentée jusqu’à présent.

Les fonctions type( ) et id( )#

Parmi les méthodes de tout objet python, certaines permettent l’introspection, c-a-d. permettent d’obtenir des informations sur l’objet et sur son état.

Nous allons présenter ici :

  • les fonctions type() et id() qui retournent, respectivement, le type et l’adresse (mémoire) unique de l’argument.

a = 1
b = 1.1

l = [0 for i in range(10)]
t = [1,1]

print(a, type(a), id(a))
print(b, type(b), id(b))
print(l, type(l), id(l))
print(t, type(t), id(t))
print(id(l[0]), id(l[1]))
1 <class 'int'> 4308380792
1.1 <class 'float'> 4360612144
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0] <class 'list'> 4360760704
[1, 1] <class 'list'> 4360759936
4308380760 4308380760

Autres fonctions d’introspection

D’autres fonctions d’introspection très utiles existent. Essayez-les !

  • la fonction help() déjà connue

  • les fonctions locals() et globals() qui retournent la liste des variables locales, resp. globales, au contexte de leur appel ;

  • la fonction dir()

help(locals)
help(globals)
help(dir)
Help on built-in function locals in module builtins:

locals()
    Return a dictionary containing the current scope's local variables.
    
    NOTE: Whether or not updates to this dictionary will affect name lookups in
    the local scope and vice-versa is *implementation dependent* and not
    covered by any backwards compatibility guarantees.

Help on built-in function globals in module builtins:

globals()
    Return the dictionary containing the current scope's global variables.
    
    NOTE: Updates to this dictionary *will* affect name lookups in the current
    global scope and vice-versa.

Help on built-in function dir in module builtins:

dir(...)
    dir([object]) -> list of strings
    
    If called without an argument, return the names in the current scope.
    Else, return an alphabetized list of names comprising (some of) the attributes
    of the given object, and of attributes reachable from it.
    If the object supplies a method named __dir__, it will be used; otherwise
    the default dir() logic is used and returns:
      for a module object: the module's attributes.
      for a class object:  its attributes, and recursively the attributes
        of its bases.
      for any other object: its attributes, its class's attributes, and
        recursively the attributes of its class's base classes.

Rmq. Redémarrez le noyau avant d’exécuter les cellules suivantes (affichage réduit).

locals() == globals()
#print(locals())
True
dir()
a = 1
print(dir())
l = [1]
print(dir())
#
dir_a = dir(a)
dir_l = dir(l)
print(dir_a == dir_l)
['In', 'Out', '_', '_9', '__', '___', '__builtin__', '__builtins__', '__doc__', '__loader__', '__name__', '__package__', '__spec__', '_dh', '_i', '_i1', '_i10', '_i2', '_i3', '_i4', '_i5', '_i6', '_i7', '_i8', '_i9', '_ih', '_ii', '_iii', '_oh', 'a', 'aff', 'b', 'e0', 'exit', 'get_ipython', 'l', 'l0', 'm', 'open', 'quit', 's', 's1', 's2', 't', 'x', 'y']
['In', 'Out', '_', '_9', '__', '___', '__builtin__', '__builtins__', '__doc__', '__loader__', '__name__', '__package__', '__spec__', '_dh', '_i', '_i1', '_i10', '_i2', '_i3', '_i4', '_i5', '_i6', '_i7', '_i8', '_i9', '_ih', '_ii', '_iii', '_oh', 'a', 'aff', 'b', 'e0', 'exit', 'get_ipython', 'l', 'l0', 'm', 'open', 'quit', 's', 's1', 's2', 't', 'x', 'y']
False

Introspection de l’affectation#

Dans les traitements suivants, retrouvons les comportements décrits précédemment. Etonné ?

# affectation de non-mutables
a = 1
print("a:", a, type(a), id(a))
b = a
print("b:", b, type(b), id(b))
b = 2
print("b:", b, type(b), id(b), "\n")

# affectation de non-mutables
s = "aagskjhdsqgf qkdhqhq "
print('s:', s, type(s), id(s))
s2 = s
print('s2', s2, type(s2), id(s2))
s2 = "a"
print('s2', s2, type(s2), id(s2), "\n")

# affectation et modification de mutables
l = [0,0]
print(l, type(l), id(l))
t = l
print(t, type(t), id(t))
l = [1, 0]
print(l, type(l), id(l))
print(t, type(t), id(t))
t = [1,1]
print(t, type(t), id(t))
t[0] = 0
print(t, type(t), id(t), "\n")
a: 1 <class 'int'> 4308380792
b: 1 <class 'int'> 4308380792
b: 2 <class 'int'> 4308380824 

s: aagskjhdsqgf qkdhqhq  <class 'str'> 4360632592
s2 aagskjhdsqgf qkdhqhq  <class 'str'> 4360632592
s2 a <class 'str'> 4308158008 

[0, 0] <class 'list'> 4360771328
[0, 0] <class 'list'> 4360771328
[1, 0] <class 'list'> 4360771456
[0, 0] <class 'list'> 4360771328
[1, 1] <class 'list'> 4360771712
[0, 1] <class 'list'> 4360771712 

Remarque. Les premiers traitements (repris ici) :

# affectation de non-mutables
a = 1
b = a

s = "aagskjhdsqgf qkdhqhq "
s2 = s

# affectation de mutables
l = [0,0]
t = l

vérifient :

print(
    id(a) == id(b), 
    id(s) == id(s2),
    id(l) == id(t)
     )
True True True

Ah ?!! Ca ne correspond pas à ce qui avait été décrit : l’affectation de non-mutable x = y était supposée créer un nouvel espace mémoire pour stocker la valeur pour x … ????

Ici l’environnement choisit d’économiser de la place en mémoire en ne dupliquant pas les valeurs non mutables : après affectation de l’une vers l’autre, elles ont la même valeur !
Une seule valeur est stockée et elle est référencée par 2 variables :

  • la valeur non modifiée (à droite de l’affectation) est toujours référencée par “sa” variable

  • la valeur modifiée (à gauche de l’affectation) référence aussi cette valeur.

Cette optimisation, similaire au traitement du cas non mutable, est un peu piégeuse pour la compréhension.
Cependant, elle est sans effet sur les instructions suivantes, comme cet exemple l’illustre.

# affectations de non-mutables -- épisode 2
a = 1
b = a
print("a=", a, id(a))
print("b=", b, id(b))
print("pas de nouvel espace mémoire suite à b = a. \n")

a = 2
print("a=", a, id(a))
print("b=", b, id(b))
print("a est modifié donc un nouvel espace mémoire est créé pour a. \n")

a = a + 1
print("a=", a, id(a))
print("a est encore modifié et un nouvel espace mémoire est encore créé.")
print("Un autre choix aurait été possible mais celui-ci est en cohérence avec le premier traitement observé.\n")

a = 5
print("a=", a, id(a))
a += 1
print("a=", a, id(a), "bizarre : a += 1 devrait être effectué en place : cf. plus loin")
print()

a = b + 2
print("a=", a, id(a))
print("b=", b, id(b))
print("pas de surprise")
a= 1 4308380792
b= 1 4308380792
pas de nouvel espace mémoire suite à b = a. 

a= 2 4308380824
b= 1 4308380792
a est modifié donc un nouvel espace mémoire est créé pour a. 

a= 3 4308380856
a est encore modifié et un nouvel espace mémoire est encore créé.
Un autre choix aurait été possible mais celui-ci est en cohérence avec le premier traitement observé.

a= 5 4308380920
a= 6 4308380952 bizarre : a += 1 devrait être effectué en place : cf. plus loin

a= 3 4308380856
b= 1 4308380792
pas de surprise

10.3.4. Affectation en python : compléments#

Modification partielle vs. modification complète d’un mutable#

On retiendra que la modification totale d’un objet mutable conduit à la création d’un nouvel emplacement en mémoire.

En revanche, une modification partielle d’un objet mutable provoque un effet de bord.

Si l’objet est mutable alors sa modification partielle :

  • est effectuée “en place”, c-a-d. à l’adresse mémoire occupée par l’objet.

  • Cette modification est visible “depuis” les autres variables qui référencent (le cas échéant) cet objet.

  • C’est un effet de bord.

# affectation de mutables
l = [0,0]
t = l

print("t = l")
print(l, id(l))
print(t, id(t))

print("modification partielle de l")
l[0] = 2
print(l, id(l))
print(t, id(t))

print("modification totale de l")
l = [4,3]
print(l, id(l))
print(t, id(t))

print("modifications de l par ses méthodes (aspects POO)")
t = l
l.sort()
print(l, id(l))
print(t, id(t))
l.reverse()
print(l, id(l))
print(t, id(t))
l.insert(1,11)
print(l, id(l))
print(t, id(t))
t = l
[0, 0] 4360771840
[0, 0] 4360771840
modification partielle de l
[2, 0] 4360771840
[2, 0] 4360771840
modification totale de l
[4, 3] 4360772992
[2, 0] 4360771840
modifications de l par ses méthodes (aspects POO)
[3, 4] 4360772992
[3, 4] 4360772992
[4, 3] 4360772992
[4, 3] 4360772992
[4, 11, 3] 4360772992
[4, 11, 3] 4360772992

Comment dupliquer (re-copier) un objet mutable et obtenir 2 objets différents en mémoire ?#

Il faut :

  • réaliser une copie avec la fonction copy() du module copy

  • modifier l’ensemble de l’objet :

    • en créant des tranches complètes de listes : m[:] = l ou m[0,len(l)] = l

La duplication est motivée pour distinguer des traitements différents de l’un ou de l’autre objet.

import copy
# affectations et modifications d'objets mutables
# une liste est mutable
l = [1, 2, 3]

# initialisation par référence -> pas de duplication
print("par référence")
m = l
print("l=",l, " == m=",m)

# la modification (partielle) de l impacte aussi m
l[0] = 5
print("l=",l, " == m=",m)

# la modification complète de l n'impacte pas m
# rmq: c'est une initialisation qui créé un nouvel emplacement mémoire
m = [5, 5, 5]
print("l=",l, " =/ m=",m)


# affectation avec copie explicite 
print("par copie")
# par tranches complètes
m = l[0:len(l)]
mm = l[:]

# par copy
m_copy = copy.copy(l)

print("l=",l,"== m=",m," == mm=",mm," == m_copy=",m_copy)

l[0]=0
print("l=", l,"=/ m=",m," == mm=",mm," == m_copy=",m_copy)
l = [0, 0, 0]
print("l=",l, "=/ m=",m," == mm=",mm," == m_copy=",m_copy)
par référence
l= [1, 2, 3]  == m= [1, 2, 3]
l= [5, 2, 3]  == m= [5, 2, 3]
l= [5, 2, 3]  =/ m= [5, 5, 5]
par copie
l= [5, 2, 3] == m= [5, 2, 3]  == mm= [5, 2, 3]  == m_copy= [5, 2, 3]
l= [0, 2, 3] =/ m= [5, 2, 3]  == mm= [5, 2, 3]  == m_copy= [5, 2, 3]
l= [0, 0, 0] =/ m= [5, 2, 3]  == mm= [5, 2, 3]  == m_copy= [5, 2, 3]
# les strings sont non-mutables
# les lists sont mutables

un_dalton = "Jo" #string
print(id(un_dalton), un_dalton)
print()

les_dalton = []    #list
les_dalton.append(un_dalton)
print(id(les_dalton), les_dalton)
print(id(les_dalton[0]), les_dalton[0])
print()

print("affectation par référence (pas de duplication)")
les_freres_dalton = les_dalton #affectation par reference (pas de duplication)
les_freres_dalton.append('Jack')
un_dalton = 'Averell'

print(id(les_dalton), les_dalton)
#print(id(les_dalton[0]), les_dalton[0])
print(id(les_freres_dalton), les_freres_dalton)
print(id(un_dalton), un_dalton)
print()

print (un_dalton, les_dalton, les_freres_dalton)
4360716016 Jo

4360769856 ['Jo']
4360716016 Jo

affectation par référence (pas de duplication)
4360769856 ['Jo', 'Jack']
4360769856 ['Jo', 'Jack']
4360719856 Averell

Averell ['Jo', 'Jack'] ['Jo', 'Jack']
un_dalton = "Jo" #string
print(id(un_dalton), un_dalton)
les_dalton = []    #list
les_dalton.append(un_dalton)  # rmq : modification SANS d'affectation
print(id(les_dalton), les_dalton)
print(id(les_dalton[0]), les_dalton[0])
print()

print("affectation avec duplication")
les_freres_dalton[:] = les_dalton # [:] permet de dupliquer !
print(id(les_dalton), les_dalton)
print(id(les_freres_dalton), les_freres_dalton)
print()

les_freres_dalton.append('Jack')
un_dalton = "Averell"
les_dalton.append(un_dalton) # rmq : modification SANS d'affectation
print(id(les_dalton), les_dalton)
print(id(les_freres_dalton), les_freres_dalton)

print (un_dalton, les_dalton, les_freres_dalton)
4360716016 Jo
4360770432 ['Jo']
4360716016 Jo

affectation avec duplication
4360770432 ['Jo']
4360769856 ['Jo']

4360770432 ['Jo', 'Averell']
4360769856 ['Jo', 'Jack']
Averell ['Jo', 'Averell'] ['Jo', 'Jack']

Des subtilités pour x += 1 vs. x = x + 1#

La composition d’une affectation et d’un opérateur binaire (ici celui associé au symbole ‘+’) est appelé une affectation augmentée.

Le manuel de référence du python3 indique en section 7.2.1 :

An augmented assignment expression like x += 1 can be rewritten as x = x + 1 to achieve a similar, but not exactly equal effect. In the augmented version, x is only evaluated once. Also, when possible, the actual operation is performed in-place, meaning that rather than creating a new object and assigning that to the target, the old object is modified instead.

Nous n’avons pourtant pas pu constater cette modification en place pour a += 1 dans l’exemple plus haut. Mystère ?

L’exemple suivant montre encore que le caractère mutable ou non d’une variable modifie tout ce qui comporte une affectation.

# a et b non mutables
a = 0
b = 11
print(id(a), id(b))

a += 1
b = b + 1
print(id(a), id(b))

# a et b mutables
a = [0]
b = [11]
print(id(a), id(b))

a += [1]
b = b + [1]
print(id(a), id(b))
4308380760 4308381112
4308380792 4308381144
4360766656 4360597248
4360766656 4360766016

L’affectation augmentée de la liste (mutable) a est bien effectuée en place. Le traitement sur la liste b, “similaire mais pas exactement égal” (!) utilise un nouvel espace de stockage pour la nouvelle valeur de b.

Ces traitements sont “exactement égaux” (!) lorsqu’ils sont appliqués à des entiers (non mutables) : création d’un nouvel espace de stockage.

Et tout ceci est bien cohérent si on relit bien ce qu’indique le manuel :

Also, when possible, the actual operation is performed in-place, …

Comme nous l’avons rappelé en Section 1.3.2, la modification d’une variable non mutable implique la création d’un nouvel emplacement mémoire, i.e. d’une nouvelle variable mais de même identifiant. Ce qui n’est pas le cas pour une modification partielle d’une variable mutable.

(\(\star)\) Rmq. Il faudrait aller encore un peu plus loin dans l’analyse de l’affectation augmentée.

En effet, que penser de x += f(x)f(x) lui-même peut modifier x, voire même le modifier avec une affectation augmentée ?

Les exemples suivants illustrent différents cas de ce type. Attendre la section 1.4 suivante qui clarifie le passage de paramètres selon le caractère mutable ou non de l’argument avant de les regarder plus en détail.

On retiendra surtout cet extrait de la documentation python (version française, section 1.7.2) :

Au contraire des assignations normales, les assignations augmentées évaluent la partie gauche avant d’évaluer la partie droite. Par exemple, a[i] += f(x) commence par s’intéresser à a[i], puis Python évalue f(x), effectue l’addition et, enfin, écrit le résultat dans a[i].

def f(t : int or list) -> int or list:
    '''t en modifié localement mais pas "returné"'''
    u = t
    t = t + t
    print("id dans f -- t:", id(t), ", u: ", id(u) )
    return u

a = 2
print(a, id(a))
a += f(a)
print(a, id(a))

l = [10]
print(l, id(l))
l += f(l)
print(l, id(l))
2 4308380824
id dans f -- t: 4308380888 , u:  4308380824
4 4308380888
[10] 4360766656
id dans f -- t: 4360587648 , u:  4360766656
[10, 10] 4360766656
def f(t : int or list) -> int or list:
    '''t en modifié localement et renvoyé'''
    u = t
    t = t + t
    print("id dans f -- t:", id(t), ", u: ", id(u) )
    return t

a = 2
print(a, id(a))
a += f(a)
print(a, id(a))

l = [10]
print(l, id(l))
l += f(l)
print(l, id(l))
2 4308380824
id dans f -- t: 4308380888 , u:  4308380824
6 4308380952
[10] 4360767552
id dans f -- t: 4360766464 , u:  4360767552
[10, 10, 10] 4360767552

10.4. (\(\star\)) Fonctions : aspects avancés#

Cette section relève de l'objectif 20

Nous allons étudier plus en détail le passage des paramètres de fonction en python.

On a écrit :

python : passage \(\Leftrightarrow\) affectation

En effet, le passage de paramètre correspond à l’affectation :

paramètre formel = paramètre effectif

On a détaillé que le mécanisme de ce type d’affectation (copie vs. référence) dépendait du caratère mutable (référence) ou non-mutable (copie) des objets.

Rappel

Le tutoriel python.org dit :

Les paramètres effectifs (arguments) d’une fonction sont introduits dans la table de symboles locale de la fonction appelée lorsqu’elle est appelée ; par conséquent, les passages de paramètres se font par valeur, la valeur étant toujours une référence à un objet, et non la valeur de l’objet lui-même.

Ce traitement est similaire à celui de l’affectation en python décrit plus haut. Ce qui donne les 2 cas suivants.

10.4.1. Le paramètre est de type non-mutable#

  • transmission de la valeur de l’argument effectif

  • argument formel similaire à une variable locale de la fonction

  • à l’extérieur de la fonction : pas de modification de la variable passée en argument

# fonctions et paramètres de type non-mutable
def f(x : int) -> int:
    '''retourne une variable locale'''
    r = x + 1 
    return r

def g(x : float) -> float:
    '''retourne le paramètre formel modifié'''
    x = x + 1
    return x

u = 0
y = 0.0

print("avant les appels:", "u=",u, ", y=", y)
print()

print("f(u)=", f(u))
print("g(y)=", g(y))
print()

print("après les appels:", "u=",u, ", y=", y)
print()
avant les appels: u= 0 , y= 0.0

f(u)= 1
g(y)= 1.0

après les appels: u= 0 , y= 0.0
  • Les valeurs de u et y sont bien inchangées dans l’appelant bien que y soit un paramètre d’entrée modifié et retourné dans g. Il faut voir ces paramètres formels comme des variables locales.

  • Bien sûr, il manque dans l’appelant une affectation du retour de la fonction.

y = 0.0
print(y)
y = g(y)
print(y)
print(y == g(y))
0.0
1.0
False
  • Attention au print(appel de f) !

Exercice. Expliquer pourquoi c’est normal !

10.4.2. Le paramètre de type mutable#

  • transmission de l’adresse de l’argument effectif

  • l’argument effectif est ainsi la variable extérieure à la fonction

    • il peut donc y avoir modification de la valeur dans l’appelant de la variable passée en argument même si celle-ci n’est pas retournée par la fonction. C’est un effet de bord.

# fonction et paramètres de type mutable
def f(x : list) -> list:
    '''extension d'une liste avec duplication de ses valeurs
    l'utilisation pythonique du symbole * est quand même maladroite pour ce type de traitement
    '''
    x = x * 2
    return x

l = [0,1]
ll = l   # même référence
#m = []  # création
m[:] = l # duplication 
mm = l[:] # création+duplication

print("avant appel:")
print("l=",l, "ll=",ll, ", m=", m,  "mm=", mm)
print("id : l=", id(l), ", ll=",id(ll), ", m=", id(m), ", mm=", id(mm))
print()

l = [0,1]
print("appel avec affectation l = f(l):")
l = f(l)
print("l=",l, "ll=",ll, ", m=", m)
print("id : l=",id(l), ", ll=",id(ll), ", m=", id(m))
print()
avant appel:
l= [0, 1] ll= [0, 1] , m= [0, 1] mm= [0, 1]
id : l= 4360770240 , ll= 4360770240 , m= 4360768704 , mm= 4360759936

appel avec affectation l = f(l):
l= [0, 1, 0, 1] ll= [0, 1] , m= [0, 1]
id : l= 4360725312 , ll= 4360770240 , m= 4360768704

Exercice

  1. Dans le premier appel (avec affectation vers l), pourquoi ll qui est (était) une référence vers l n’est pas modifiée ?

L’appel avec affectation (dans l) n’a pas eu d’effet (de bord) sur l’autre référence (ll), ni bien sûr sur les variables dupliquées. C’est pourtant bien la référence de l’argument effectif qui est connue de l’appel de la fonction.

Ici, la fonction modifie complètement ce paramètre mutable. Une nouvelle variable locale est donc créée et renvoyée. L’affectation (même dans la variable fournie comme argument d’entrée) effectue la mise à jour de la variable affectée.

Attention

Les écritures approximatives de fonction (sans return) ou les appels sans affectation du résultat (à la procédure) peuvent être dangereux.

pythontutor. L’exécution suivante est une fois de plus instructive.

Un comportement similaire est obtenu lorsque la fonction manipule un paramètre objet par ses méthodes. L’exemple suivant illustre ce cas.

# procedure : methode et paramètres de type mutable
def g(x : list) -> None:
    '''appel d'une méthode sur un objet mutable'''
    x.append(1)
    #return None

l = [0,0]
ll = l   # même objet
m = l[:] # duplication

#observons
s = (l,ll,m)
print("l, ll, m")
for x in iter(s):
    print(x, type(x), id(x))
print()

print("appel sans affectation du retour: g(l)")
print("print g(l)=", g(l))
print("l=",l, "ll=",ll,", m=", m)
print("id=",id(l), "id=",id(ll), ", id=", id(m))
print()

print("appel avec affectation: l = g(l)")
l = g(l)
print("l=",l, "ll=",ll, ", m=", m)
print("id=",id(l), "id=",id(ll), ", id=", id(m))
print()


print("appel avec affectation m = g(l):")
l = [0,0]
m = g(l)
print("l=",l, "ll=",ll, ", m=", m)
print("id=",id(l), "id=",id(ll), ", id=", id(m))
print()
l, ll, m
[0, 0] <class 'list'> 4360845888
[0, 0] <class 'list'> 4360845888
[0, 0] <class 'list'> 4360845824

appel sans affectation du retour: g(l)
print g(l)= None
l= [0, 0, 1] ll= [0, 0, 1] , m= [0, 0]
id= 4360845888 id= 4360845888 , id= 4360845824

appel avec affectation: l = g(l)
l= None ll= [0, 0, 1, 1] , m= [0, 0]
id= 4308379280 id= 4360845888 , id= 4360845824

appel avec affectation m = g(l):
l= [0, 0, 1] ll= [0, 0, 1, 1] , m= None
id= 4360846208 id= 4360845888 , id= 4308379280

ATTENTION : c’est bien piégeux !

Retenons pour l’instant, c-a-d. tant qu’on ne manipule pas d’objet.

  • Le passage de paramètre s’effectue :

    • par valeur pour des non-mutables

    • par adresse pour des mutables,

  • et ce conformément aux mécanismes d’affectation :

    • copie pour des non-mutables

    • référence pour des mutables.

  • Les paramètres formels sont équivalents à des variables locales.

  • L’affectation avec return doit (devrait) être le seul effet dans l’appelant.

Attention aux modifications partielles de paramètres effectifs mutables.

Rmq. Sur ce point, on trouve de tout sur internet et même les ouvrages (dont certains sont dispos à la BU). J’ai lu :

  • c’est du passage par référence

  • c’est du passage par valeur

  • c’est un mélange des 2

  • c’est aucun des deux …

Exemple. Retour sur une fonction de permutation de 2 entiers … ?

a = 1
b = 11

print("avant : a,b =", a, b)
t = a
a = b
b = t
print("après : a,b =", a, b)
avant : a,b = 1 11
après : a,b = 11 1
def permuter(x, y: int) -> None:
    ''' attention : 
    les print dans cette fonction sont uniquement à but pédagogique
    '''
    print("dans permuter, avant x,y :", x, y)
    t = x
    x = y
    y = t
    print("dans permuter, après x,y :", x, y)
    #return x, y
    
# marche pas !
a = 1
b = 11
print("avant fonction : a,b =", a, b)
permuter(a, b)
print("après fonction : a,b =", a, b)
avant fonction : a,b = 1 11
dans permuter, avant x,y : 1 11
dans permuter, après x,y : 11 1
après fonction : a,b = 1 11

10.4.3. (Optionnel) Avoir les id claires !#

Reprenons les observations précédentes effectuées grâce à pythontutor avec un focus particulier sur cette fonction id(). Cette fonction peut être très utile pour debugger un développement qui ne fonctionne pas correctement.

Utilisons les id() avec les mutables#

# fonction et paramètres de type mutable
def f(x: int) -> int:
    print("  f avant : @=", id(x))
    x = x * 2
    print("  f après : @= ", id(x))
    return x

l = [0,0]
ll = l   # même référence
m = l[:] # duplication

print("avant les appels:")
print("l=",l, "ll=",ll, "m=", m)
print("@l=",id(l), "@ll=",id(ll), "@m=",id(m))
print()

print("appel sans affectation du retour:")
print("main: @l=", id(l))
print("print f(l)=", f(l), "@f(l)=", id(f(l)))
print("l=",l, "ll=",ll,", m=", m)
print("@l=",id(l), "@ll=",id(ll), "@m=",id(m))
print()

print("appel avec affectation l = f(l):")
l = f(l)
print("main: @l(après l=f(l))=", id(l))
print("l=",l, "ll=",ll, ", m=", m)
print("@l=",id(l), "@ll=",id(ll), "@m=",id(m))
print()
avant les appels:
l= [0, 0] ll= [0, 0] m= [0, 0]
@l= 4360837504 @ll= 4360837504 @m= 4360834432

appel sans affectation du retour:
main: @l= 4360837504
  f avant : @= 4360837504
  f après : @=  4360884864
  f avant : @= 4360837504
  f après : @=  4360521856
print f(l)= [0, 0, 0, 0] @f(l)= 4360521856
l= [0, 0] ll= [0, 0] , m= [0, 0]
@l= 4360837504 @ll= 4360837504 @m= 4360834432

appel avec affectation l = f(l):
  f avant : @= 4360837504
  f après : @=  4360846208
main: @l(après l=f(l))= 4360846208
l= [0, 0, 0, 0] ll= [0, 0] , m= [0, 0]
@l= 4360846208 @ll= 4360837504 @m= 4360834432

Utilisons les id() avec les non mutables#

# fonctions et paramètres de type non-mutable
# f avec variable intermédiaire explicite
u = 0

def f(x: int) -> int:
    print("  f_r: avant @=", id(x))
    r = x + 2
    print("  f_r: après @=", id(x))
    return r

def g(x: int) -> int:
    print("  f_x: avant @=", id(x))
    x = x + 2
    print("  f_x: après @=", id(x))
    return x

def h(x: int) -> int:
    print("  f_=: avant @=", id(x))
    x += 2
    print("  f_=: après @=", id(x))
    return x

print("avant les appels:")
print("u=",u, "@u=", id(u))
print()

print("appel sans affectation du retour:")
print("f(u)=", f(u), "@f(u)=", id(f(u)))
print("u=", u, "@u=", id(u))
print()

print("appel avec affectation du retour:")
u = f(u)
print("u après u=f(u)=", u, "@u=", id(u))
avant les appels:
u= 0 @u= 4308380760

appel sans affectation du retour:
  f_r: avant @= 4308380760
  f_r: après @= 4308380760
  f_r: avant @= 4308380760
  f_r: après @= 4308380760
f(u)= 2 @f(u)= 4308380824
u= 0 @u= 4308380760

appel avec affectation du retour:
  f_r: avant @= 4308380760
  f_r: après @= 4308380760
u après u=f(u)= 2 @u= 4308380824
# fonctions et paramètres de type non-mutable
# f sans variable locale explicite
u = 1

def f(x: int) -> int:
    print("  f_x: avant @=", id(x))
    x = x + 2
    print("  f_x: après @=", id(x))
    return x

print("avant les appels:")
print("u=",u, "@u=", id(u))
print()

print("appel sans affectation du retour:")
print("f(u)=", f(u), "@f(u)=", id(f(u)))
print("u=", u, "@u=", id(u))
print()

print("appel avec affectation du retour:")
u = f(u)
print("u après u=f(u)=", u, "@u=", id(u))
avant les appels:
u= 1 @u= 4308380792

appel sans affectation du retour:
  f_x: avant @= 4308380792
  f_x: après @= 4308380856
  f_x: avant @= 4308380792
  f_x: après @= 4308380856
f(u)= 3 @f(u)= 4308380856
u= 1 @u= 4308380792

appel avec affectation du retour:
  f_x: avant @= 4308380792
  f_x: après @= 4308380856
u après u=f(u)= 3 @u= 4308380856
# fonctions et paramètres de type non-mutable
# f avec affectation composée
u = 0

def f(x : int) -> int:
    print("  f_=: avant @=", id(x))
    x += 2
    print("  f_=: après @=", id(x))
    return x

print("avant les appels:")
print("u=",u, "@u=", id(u))
print()

print("appel sans affectation du retour:")
print("f(u)=", f(u), "@f(u)=", id(f(u)))
print("u=", u, "@u=", id(u))
print()

print("appel avec affectation du retour:")
u = f(u)
print("u après u=f(u)=", u, "@u=", id(u))
avant les appels:
u= 0 @u= 4308380760

appel sans affectation du retour:
  f_=: avant @= 4308380760
  f_=: après @= 4308380824
  f_=: avant @= 4308380760
  f_=: après @= 4308380824
f(u)= 2 @f(u)= 4308380824
u= 0 @u= 4308380760

appel avec affectation du retour:
  f_=: avant @= 4308380760
  f_=: après @= 4308380824
u après u=f(u)= 2 @u= 4308380824

10.5. Les fonctions en python : derniers compléments faciles#

Deux compléments faciles (arguments nommés et argument avec valeur par défaut) ainsi qu’une courte liste de ce qu’il reste à découvrir … pour les plus curieux.

10.5.1. Arguments nommés#

Lors de l’appel, nommer les paramètres formels et la valeur correspondante de l’argument (effectif), permet un ordre quelconque de ceux-ci.

def trois_mots(mot1, mot2, mot3 : str) -> str:
    return mot1 + mot2 + mot3

m1 = 'Bonjour '
m2 = 'étudiantes et '
m3 = 'étudiants !'

#appel par position
m_classique = trois_mots(m1,m2,m3)

# appel par nom
m = trois_mots(mot3=m3, mot1=m1, mot2=m2) 

print(m_classique)
print(m)
Bonjour étudiantes et étudiants !
Bonjour étudiantes et étudiants !

10.5.2. Argument avec valeur par défaut#

Les derniers paramètres formels peuvent bénéficier de valeurs par défaut

  • derniers : tous à partir d’un rang arbitraire

  • les précédents sont sans valeur par défaut

Lors de l’appel, ces valeurs sont utilisées en cas d’absence des paramètres effectifs.

Syntaxe :

  • Ces valeurs sont définies avec une affectation après l’annotation de type.

  • L’annotation raccourcie de types (une annotation commune à tous les paramètres) ne peuvent être utilisées en cas d’argument avec valeur par défaut.

En pratique, limiter les valeurs par défaut à des paramètres non-mutables : int, bool, float, str, tuple

def incrementer(x : int, y : int = 1) -> int:
    return x + y

def tournure_polie(mot1 : str, mot2 : str = " Madame, ", mot3 : str = "Monsieur." ) -> str:
    return mot1 + mot2 + mot3

a = 0
print("a=",a)
a = incrementer(a)
print("a=",a)

print(incrementer(a))
print(incrementer(a, 7))

print(tournure_polie('Bonjour'))
print(tournure_polie('Au revoir', ' très cher '))
print(tournure_polie('Au revoir', mot3 ='et très cher Monsieur'))
print(tournure_polie('Au revoir ', "les uns,", ' et les autres.'))
a= 0
a= 1
2
8
Bonjour Madame, Monsieur.
Au revoir très cher Monsieur.
Au revoir Madame, et très cher Monsieur
Au revoir les uns, et les autres.

10.5.3. Ce qu’il reste à voir sur les fonctions en python#

  • Argument de type fonction

  • Nombre variable d’arguments (tuple et dictionnaire)

  • Fonction comme valeur de première classe : lambda fonction

10.6. Synthèse#

10.6.1. Avoir les idées claires#

  • Les principes du passage de paramètres appelant <-> appelé

    • “matériel” : par valeur vs. par adresse

    • logique : in vs. out vs. inout

    • aide : différencier fonction vs. procédure

  • L’effet de bord est néfaste

    • possible pour le passage par adresse, et les variables globales (à proscrire)

  • L’affectation et le passage de paramètres en python

    • pas de surprise pour les non-mutables (= variables locales)

    • attention aux mutables : danger d’effet de bord si modification partielle

    • ne pas prendre de risque : return dans la fonction et affectation dans l’appelant

10.6.2. Savoir-faire en python#

  • Fonctions avec arguments nommés, avec paramètres par défaut.

Objectif 20

  • Affectation en python

    • non-mutables : (référence à) la valeur

    • mutables : (référence à) l’adresse

    • attention aux mutables : modification partielles vs. duplication (par copy.copy ou par “tranches complètes”)

    • penser “objet” aide pour le comportement des mutables

  • Fonction et passage d’argument appelant/appelé en python :

    • similaire à l’affectation : distinguer mutable vs. non mutable

  • Savoir effectuer des introspections (fonction id())