Bizarre, bizarre ce python3 — Les-mathematiques.net The most powerful custom community solution in the world

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 ?
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))

Réponses

  • Avec ce code tu comprendras pourquoi :
    L=10*[[]]
    M=[[] for i in range(10)]
    print('Est-ce que L=M ?', L==M)
    print('Est-ce que L[0]=L[1] ?', L[0] is L[1])
    print('Est-ce que M[0]=M[1] ?', M[0] is M[1])
    
  • C'est pareil en Python2. Version light du même phénomène.
    L = 2*[['a']]
    print(L)
    L[0].append('b')
    print(L)
    
    (Renvoie « 'a', 'b'], ['a', 'b' ».)

    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 ?
    import random
    print(10*[random.randint(1,8)])
    print([random.randint(1,8) for _ in range(10)])
    
  • Quand Python évalue :
    10*[[]]
    
    il part d'une liste contenant un objet $\ell$ qui est une liste vide et fabrique une liste de 10 éléments chacun égal (au sens de 'is') à cet objet. Ce ne sont pas des copies.
    >>> L = 10*[[]]
    >>> L[0] is L[1]
    True
    >>> L[0] is []
    False
    >>> L[0] == []
    True
    
    En revanche, avec :
    M = [ [] for i in range(10) ]
    
    chaque itération évalue [] et ajoute le résultat à la liste temporaire. Du fait qu'évaluer plusieurs fois [] ne donne pas le même objet :
    >>> [] is []
    False
    
    il en résulte que les éléments de M ne sont pas les mêmes objets Python :
    >>> M = [ [] for i in range(10) ]
    >>> M[0] is M[1]
    False
    >>> M[0] == M[1]
    True
    
    Pour des raisons de performance, Python travaille énormément par références (terminologie C/C++). Un nom de variable n'est autre qu'une référence vers un objet Python. L'exemple de GaBuZoMeu peut ainsi être reproduit avec des noms de variables :
    >>> a = []
    >>> b = a
    >>> b.append(1)
    >>> a
    [1]
    
    Pour éviter cela, on peut remplacer b = a (qui stocke dans b la même référence que dans a) par b = list(a) ou b = a.copy() (instructions qui stockent dans b une référence vers une nouvelle liste, copie de a).

    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 :
    >>> import random
    >>> print(10*[random.randint(1,8)])
    [6, 6, 6, 6, 6, 6, 6, 6, 6, 6]
    
    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.
  • Dans le même genre :
    a=list(range(10))
    b=list(a)
    b[1]=10
    print(a)
    c=a
    c[1]=10
    print(a)
    
    Algebraic symbols are used when you do not know what you are talking about.
            -- Schnoebelen, Philippe
  • Merci pour les informations, mais je trouve les trucs comme ça durs à avaler :
    L=3*[[]]
    L[1]=L[1]+['a']
    print(L)
    M=3*[[]]
    M[1].append('a')
    print(M)
    
    [[], ['a'], []]
    [['a'], ['a'], ['a']]
    
  • Ben, c'est normal... Dans chacun des deux cas, au départ, c'est trois fois le même objet qui est référencé. Lorsque tu fais :
    L[1]=L[1]+['a']
    
    tu réassignes le slot 1 pour pointer vers un nouvel objet qui est le résultat de l'évaluation de L[1]+. Les slots 0 et 2 restent inchangés. Donc après ça, L contient deux références vers un même objet (slots 0 et 2) et une référence vers un autre objet (slot 1).

    En revanche :
    M[1].append('a')
    
    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 :
    >>> id([])
    140252900877008
    >>> id([] + [])
    140252900880048
    >>> [] + []
    []
    >>> [] is [] + []
    False
    >>> [] == [] + []
    True
    
  • Rions un peu.
    >>> id([]+[])
    140480262750280
    >>> id([]+[])
    140480263192008
    
  • Héhé... je trouve ceci plus rigolo :
    >>> l = [set(), set()]
    >>> list(map(id, l))
    [140252900461696, 140252900461936]
    >>> l = [id(set()), id(set())]
    >>> l
    [140252900461456, 140252900461456]
    
  • C’est normal, dans le cas de mutables, l’id dépend de l’adresse, pas du contenu.
    a
    [0, 10, 2, 3, 4, 5, 6, 7, 8, 9]
    a=list(range(10))
    id(a)
    3076766184
    a[1]=9
    id(a)
    3076766184
    
    Mais on a le même phénomène avec… un tuple qui n’est pas mutable si on le crée avec tuple(range(10)), pas si on le crée avec (0,1,2,3,4,5,6,7,8,9).
    Mais id((1,2,3)) renverra toujours la même chose lors d’une session. Mais :
    c=tuple(range(3))
    id(c)
    3076967400
    c=(0,1,2)
    id(c)
    3076967368
    
    Algebraic symbols are used when you do not know what you are talking about.
            -- Schnoebelen, Philippe
  • C'est en effet différent avec un objet non mutable.
    >>> id(())
    140480286068808
    >>> id(())
    140480286068808
    
    PS : Je ne comprends pas ce qui suit, en revanche.
    >>> id(140151165898544)
    140004729962288
    >>> id(140004729962288)
    140004729962288
    >>> id(2)
    10853664
    >>> id(10853664)
    140004707430672
    >>> id(140004707430672)
    140004729962288
    >>> id(140004729962288)
    140004729962288
    >>> id(140151165898544)
    140004729962288
    
  • De mon téléphone : les "petits entiers" ne sont pas traités de la même façon que les plus grands. Ce commentaire permet d'expliquer ce que tu viens de constater ainsi que mon dernier exemple (que Nicolas a lu trop vite, à mon avis). La question cruciale lorsque deux objets différents semblent avoir le même id : existent-ils au même moment avec ce même id ?
  • Merci à tous, j'ai appris grâce à vous des choses sur python (que je pratique en amateur). En tant que matheux, je suis profondément choqué de voir deux objets déclarés égaux et dont les images par une même procédure sont complètement différents. J'ai du mal à voir l'avantage d'un tel comportement.
  • Il faut peut-être que tu (re-)donnes un exemple précis de ces « objets égaux » pour que je ne réponde pas à côté. Je vais quand même essayer. Le ==, c'est un test d'égalité « pratique ». Par exemple, 3.0 == 3 retourne True, bien que les objets soient différents. Quand on compare deux listes avec ==, Python compare les éléments deux à deux avec ==, d'où :
    >>> [3.0, 4] == [3, 4.0]
    True
    
    Et on peut récurser (pardon) avec des types composés (listes de listes, listes de set() ou de frozenset(), set() de set(), etc.). Il y a des milliards d'applications où un == tolérant/approximatif peut être pratique (si on définit ses propres classes, on peut surcharger l'opérateur comme en C++, par exemple pour considérer comme égaux des objets « à quelque chose près » et ainsi implémenter un test « sont dans la même classe d'équivalence pour la relation machin »).

    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 :
    l = [2,3]
    l2 = l[:]   # copie de l; modifier l'une n'affecte pas l'autre
    
    ]

    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 :
    >>> def f(a=[]):
    ...   return a
    ... 
    >>> l = f()
    >>> l2 = f()
    >>> l2.append("Aïe !")
    >>> l
    ['Aïe !']
    
    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 :
    >>> def f(a=None):
    ...   a = a or []
    ...   return a
    ... 
    >>> l = f()
    >>> l2 = f()
    >>> l2.append("C'est bon.")
    >>> l
    []
    >>> l2
    ["C'est bon."]
    
    (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 :
    return a[:]
    
    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é.
  • Ma question est venue à propos d'une procédure pour produire une liste de listes, en initialisant toutes les listes à la liste vide et en ajoutant au fur et à mesure un objet à une ou plusieurs des listes. J'ai été surpris de voir que si j'initialisais avec L=10*[[]], je me retrouvais à la fin avec une liste de listes toutes identiques, ce qui n'était bien sûr pas du tout ce que je voulais.
    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.
  • Certes, il y a des choses qui peuvent surprendre. Mais avant de (virtuellement) porter plainte contre le créateur de Python — qui, sauf erreur, n'est plus aux commandes —, étudie un peu le C++. Tu vas voir : en terme de complexité, exceptions, pièges, possibilités de se tromper, idiomes à apprendre... c'est carrément autre chose. Il y a bien sûr quelques bénéfices, d'un autre côté.

    (« on peut faire pire » n'est pas un super argument, je l'admets volontiers...)
  • Je n'en doute pas, mais je n'ai aucune envie d'étudier le C++. À vrai dire, ma motivation pour bidouiller avec python est que je suis utilisateur de SageMath, et ça suffit à mon bonheur.
  • Je ne partage pas l'avis de brian, on peut faire du C-- (c'est-a-dire du C avec la librairie standard C++) sans risquer de tomber dans ce type de piege.
    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.
  • Hum, facile d'interpréter des phrases assez générales d'une façon particulière dans le but de contredire. Pour "Le passage par reference ou par valeur est explicite dans les arguments d'une fonction", rappelons qu'en vertu de la conversion automatique de tableau vers pointeur, les string literals et les arrays C sont passés par référence sans qu'il y ait besoin de mettre un & (heureusement, les premiers sont des "arrays of n const char"). Quant à la contradiction relative à la pile, elle n'est valable que pour les types qui allouent dynamiquement la mémoire (certes, les containers de la lib std fonctionnent comme ça), et encore que pour cette portion-là de la mémoire occupée par le type (avez-vous déjà entendu parler de SSO? Eh oui, les "petites" std::string sont entièrement dans la pile si l'on s'amuse à les passer par valeur). Mais un type qui contient des membres volumineux ou nombreux dans sa définition prend beaucoup de place sur la pile lorsqu'il est passé par valeur, c'est un fait.

    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.
  • @brian: je me permets de vous faire remarquer que c'est vous qui avez fait une critique de C++ (me semble-t-il en reaction au fait que quelqu'un avait mis en evidence un piege de Python). Or j'estime cette critique injustifiee, d'ou mon message sur ce fil.

    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.
  • Je n'ai pas mentionné C++ pour le critiquer — j'ai mieux à faire — mais pour essayer d'aider les lecteurs à comprendre ce que fait Python, car il existe certains points sur lesquels son comportement n'est pas très intuitif, comme ce fil l'a bien montré. Ceux qui ont fait un peu de C++ comprennent ce qu'est le passage d'arguments par référence, c'est pour ceux-là que j'ai mentionné C et C++ : pour aider à me faire comprendre.

    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.
  • Oui, le passage d'arguments en C++ necessite d'ecrire souvent const & si on veut optimiser son code, mais ce n'est pas indispensable. En particulier pour un debutant, il est plus simple de commencer a passer des arguments par valeur. On enseigne ensuite le passage par reference lorsqu'une fonction a besoin de modifier ses arguments (par exemple quand on calcule les coefficients de Bezout en arithmetique), puis par reference constante quand on veut optimiser (typiquement en algebre lineaire).

    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.
Connectez-vous ou Inscrivez-vous pour répondre.
Success message!