Bizarre, bizarre ce python3
Je voulais écrire une procédure en python3, et elle ne faisait pas ce que j'attendais.
En creusant un peu, j'ai isolé une bizarrerie.
Trouverez-vous la même bizarrerie en exécutant le code ci-dessous ?
En creusant un peu, j'ai isolé une bizarrerie.
Trouverez-vous la même bizarrerie en exécutant le code ci-dessous ?
L=10*[[]] M=[[] for i in range(10)] print('Est-ce que L=M ?', L==M) def procedure(ldl): for j in range(len(ldl)): if j%2==0 : ldl[j].append(j) return ldl print('procedure(L) =',procedure(L)) print('procedure(M) =',procedure(M))
Connectez-vous ou Inscrivez-vous pour répondre.
Réponses
Tentative d'explication : la liste L est une liste d'adresses vers le même objet, qui est la liste []. Il y a une seule copie de cette liste pour chaque élément de L. Quand tu "appendes", tu modifies chaque fois cet objet. À l'affichage, la liste L est dépliée et donne 10 fois la liste « longue » (où 10 est écrit en base dix pour toi, deux pour la version ci-dessus).
A contrario, la liste M est obtenue en créant 10 copies de la liste vide.
NB : un phénomène analogue ?
Une liste ne contient pas vraiment ses éléments, ou du moins, ça dépend ce qu'on appelle ses éléments. Chaque slot de la liste est une référence vers un objet Python. Pour la liste $L$ ci-dessus, chaque slot pointe vers le même objet (une liste vide). Lorsqu'on modifie cet objet en place (ce qui n'est possible que pour les objets mutables), tous les endroits qui référencent cet objet voient la modification.
Ceci ne peut se produire avec des objets non mutables tels que str. Toute opération qui, par exemple, ajoute quelque chose à une str, crée un nouvel objet (adresse différente ; les références pointant vers la chaîne d'origine pointent toujours vers la même chaîne).
Edit: Math Coss a été plus rapide. Oui : c'est la même chose : on fabrique une liste dont chaque slot pointe vers le même objet, l'entier retourné par random.randint(1,8), expression évaluée une seule fois ici.
-- Schnoebelen, Philippe
En revanche : modifie en place la liste référencée par M[1] (j'ai bien dit que les éléments d'une liste, tout comme les noms de variables, sont des références !). Cette modification ne change pas l'adresse de la liste modifiée (sinon, ça ne serait pas une modification en place). Donc la liste M, après ça, contient toujours trois références égales vers la liste modifiée.
Identité (a priori l'adresse) d'un objet Python :
Mais id((1,2,3)) renverra toujours la même chose lors d’une session. Mais :
-- Schnoebelen, Philippe
Lorsque l'approximation potentielle (dépendant des types) de == n'est pas souhaitée, on doit pouvoir s'en sortir la plupart du temps avec 'is', quitte à l'appliquer à tous les couples $(a,b)$ où $a$ appartient au premier contenant et $b$ au second (par exemple, des listes).
L'autre problème vu dans ce fil, c'est la conséquence du fait que Python travaille beaucoup par références. Avec les objets non mutables, cela ne pose normalement pas de souci. Avec les objets mutables, il faut faire attention. Dès que l'on fait $a$ = objet mutable puis $b = a$, il y a une lumière rouge qui doit clignoter dans le cerveau : toute modification de l'objet via la référence $a$ sera visible via $b$ et inversement, puisque les deux variables pointent vers le même objet, objet qui peut être modifié en place car il est mutable. Si ce n'est pas souhaité, il faut faire une copie.
[ D'ailleurs, il y a une troisième méthode pour copier une liste que je n'ai pas mentionnée hier, le slicing : ]
En C, ceci revient à travailler avec des pointeurs. En C++, on évite les références non 'const' précisément pour ne pas se faire piéger bêtement (il y a tant d'autres façons de se tirer dans le pied...). Une « référence const » (const Machin&) est comparable à l'utilisation d'une référence vers un objet Python non mutable.
Il faut faire très attention lorsqu'un objet mutable est utilisé comme valeur par défaut d'un argument de fonction : La valeur par défaut du paramètre $a$ n'est évaluée qu'une seule fois : au moment où la ligne « def f(a=[]): » est exécutée par Python (jusqu'à la fin de la définition), i.e., lorsque la variable $f$ est assignée. Python enregistre la valeur par défaut du paramètre $a$ comme lors d'une affectation de variable : par référence. Donc plusieurs appels de $f$ sans argument retournent le même objet. Comme celui-ci est mutable, une modification de l'objet est visible partout où cet objet est référencé. Un idiome pour éviter cet écueil : (ou bien, plus strict au niveau de l'entrée : a = a if a is not None else [])
None est non mutable et bool(None) renvoie False, donc pas de surprise possible. La ligne « a = a or [] » est exécutée chaque fois que la fonction $f$ l'est, donc c'est une nouvelle liste vide qui est créée à chaque appel de $f$ sans argument. Deux appels de $f$ sans argument ne peuvent donc renvoyer le même objet.
Dans cet exemple fabriqué et artificiel, j'ai traité le cas de la valeur par défaut, mais si l'argument $a$ n'est pas vide, il sera renvoyé par référence. Pour éviter les problèmes d'éventuelle modification en place de la valeur de retour par la suite, on voudra sans doute faire une copie avant de retourner : Python est simple et intuitif pour beaucoup de choses, mais il y a des pièges liés à cette histoire de références. Tous les outils puissants ont leurs subtilités, on n'y coupe pas. Quel est l'intérêt de cette façon de travailler ? Dans énormément de cas, les objets renvoyés par des fonctions, stockés dans des variables, etc., ne seront que lus. On n'a alors qu'une instance de l'objet en mémoire, le reste n'est que petits pointeurs. Ceci économise de la mémoire et rend les appels de fonctions plus rapides (juste un pointeur à passer au lieu de copier sur la pile une potentiellement grosse valeur pour chaque paramètre... pile qui pourrait alors déborder, piège des langages tels que C et C++ qui, par défaut, passent beaucoup de choses par valeur).
Moralité : toujours faire attention avec les objets mutables. Se poser la question : vais-je avoir besoin de « copies » et si oui, doivent-elles toutes référencer le même objet ou bien être de vraies copies, indépendantes ? Et ne pas mettre d'objet mutable comme valeur par défaut d'un argument de fonction (voir idiome ci-dessus), sauf si l'on est certain que l'objet ne pourra être modifié.
Brian, tu cites le cas de 3.==3
OK, mais
1°) On voit physiquement que 3 et 3. ne sont pas le même objet et ne sont pas du même type. Ce n'est pas le cas quand on fait afficher 10*[[]] et [[] for i in range(10)].
2°) Pour faire une opération sur les nombres qui renvoie des résultats déclarés différents sur 3 et sur 3., il faut y aller assez fort, du genre 3.*10**30==3*10**30 (je ne parle bien sûr pas de type(3)==type(3.)). Tandis que là, c'est juste une petite manip de base sur les listes qui renvoie des résultats complètement différents.
Bon, j'ai compris, j'espère que je ne me ferai plus avoir.
(« on peut faire pire » n'est pas un super argument, je l'admets volontiers...)
L'equivalent d'une liste d'objets de type T, c'est un vector<T>, une liste de liste avec 10 elements c'est vector< vector<T> > m(10), et si on fait m[0].push_back(valeur) (l'equivalent de append) cela modifie m[0], et pas les autres elements de m.
Le passage par reference ou par valeur est explicite dans les arguments d'une fonction, ca oblige bien sur a comprendre la difference, mais il n'y a pas de risques de se faire pieger.
Enfin, je signale une imprecision dans le message de brian: le passage par valeur d'un vector< vector<> > ne prend pas plus de place sur la pile qu'une variable "normale", c'est sur le tas que la copie des elements de la liste de listes est faite, le tas a beaucoup plus de memoire disponible que la pile (ca se compte en Go, alors que sur la pile c'est plutot en centaines de Ko). Evidemment il est preferable de passer ce type de variable par reference (reference constante si on ne la modifie pas) pour economiser de la memoire (et le temps de la copie), mais si on ne le fait pas, le programme continuera a marcher sans surprises.
D'autre part, ces allocations sur le tas ne sont pas gratuites, ça prend du temps de copier un std::string ou un std::vector<Machin> pour le passer à une fonction qui va seulement le lire. C''est d'ailleurs pour ça qu'on va plutôt passer ça sous la forme de const std::string& ou const std::vector<Machin>& en pareil cas, ce qui n'est certes pas la mer à boire, mais il serait quand même plus simple de pouvoir se contenter d'écrire std::string ou std::vector<Machin> pour obtenir l'équivalent (sauf l'assurance const) de ce que fait Python sans rien avoir à indiquer de particulier.
Comme bien souvent en C++, il y a plein de façons de faire telle ou telle chose, et la principale difficulté revient à les connaître et à choisir la ou les bonnes façons en fonction de la situation.
Concernant votre dernier message, je vous fais aussi remarquer que j'ai bien precise que la copie d'un vector< vector<> > prenait du temps et de la memoire supplementaire sur le tas, il vaut bien sur mieux l'eviter, mais le point sur lequel je voulais insister est que ca ne provoquera pas d'erreurs, c'est juste moins efficace (un aspect qui n'est pas en general au sommet des preoccupations des utilisateurs de langage interprete). Enfin, je ne vois pas bien l'interet de votre remarque sur les SSO, si une chaine de 20 octets est stockee par optimisation sur la pile au lieu d'etre allouee sur le tas, elle n'occupe guere plus de place que la variable de type string elle-meme (sizeof(std::string)==8), ce n'est pas cela qui risque de provoquer un stack overflow.
Vous avez tiqué sur ma phrase « juste un pointeur à passer au lieu de copier sur la pile une potentiellement grosse valeur pour chaque paramètre... pile qui pourrait alors déborder, piège des langages tels que C et C++ qui, par défaut, passent beaucoup de choses par valeur ». Le terme « piège » ne vous a visiblement pas plu, soit. Mais je maintiens ceci : par défaut, presque tous les types sont passés par valeur en C++. C'est un fait. Ça s'apprend et on évite le problème évident qui en découle en utilisant des « const Machin& » à tour de bras dans les signatures des fonctions. Ça fait le boulot correctement, mais il faut l'écrire — ce n'est pas le type de passage par défaut. Cela contribue donc à la verbosité des déclarations des fonctions.
Quant au std::string qui serait de taille 8, ça dépend de l'implémentation et il semblerait que les libs std modernes utilisent plutôt 24 ou 32 octets. Peu importe. L'éventualité d'un débordement de pile dépend de la taille totale des arguments poussés, donc de leur nombre et de leurs types, en comptant toute la profondeur de la pile d'appels. Si cette profondeur est importante, il faut faire attention à ce qu'on met sur la pile — quel que soit le langage.
Sur la pile, si une variable de type string occupe 24 ou 32 octets, alors une taille de 20 octets pour une petite chaine allouee sur la pile se verra proportionnellement moins. Personnellement, je n'ai rencontre de vrais problemes de taille de pile que sur certaines calculatrice (la hp39gii et la Numworks pour etre precis), et plutot a cause du nombre de variables locales a la fonction (il fallait alors utiliser une meme variable pour plusieurs taches, avec tous les risques d'erreur que cela comporte). Sur PC, les erreurs de taille de pile que j'ai rencontrees provenaient toutes de bugs dans des recursions, et dans ce cas, c'est aussi bien si ca se produit apres 1000 stack frames plutot que 5000.