Énigme #3: Modèle objet de Python

La façon dont les objets sont crées en Python diffère assez des modèles objets « classiques » et apporte certains effets de bords intéressants ou même surprenants.

Par exemple, si je compare ces deux programmes, à priori identiques, l’un en Python, l’autre en Java:

class Foo:
    x = {}
 
    def __init__(self, id):
        self.x[id] = id
 
f1 = Foo(5)
print(f1.x)   # {5:5}, as expected...
 
f2 = Foo(6)
print(f2.x)   # {5:5, 6:6}, wtf ?!?
 
print(f1.x)   # {5:5, 6:6}, wtf too ?!?
import java.io.*;
import java.util.*;
 
public final class Foo {
    public Dictionary x = new Hashtable();
 
    public Foo(int id) {
	x.put(id, id);
    }
 
    public static void main(String[] args) {
	Foo f1 = new Foo(5);
	System.out.println(f1.x); // {5=5}, as expected...
 
	Foo f2 = new Foo(6);
	System.out.println(f2.x); // {6=6}, as expected...
 
	System.out.println(f1.x); // {5=5}, as expected...
    }
}

Le résultat de l’exécution donne pourtant des différences assez marquées… Le plus surprenant c’est que si on prend le programme Python suivant, tout se passe comme nous le souhaiterions à priori:

class Bar:
    x = 3
 
    def __init__(self, id):
        self.x = id
 
 
f1 = Bar(5)
print(f1.x)   # 5, as expected...
f2 = Bar(6)
print(f2.x)   # 6, as expected...

Alors, comment expliqueriez-vous ce comportement pour le moins étrange de Python ? Et quelles règles de programmation, spécifique à Python, en déduiriez-vous pour éviter des erreurs ?

Cette entrée a été publiée dans Énigme, Programmation. Vous pouvez la mettre en favoris avec ce permalien.

14 réponses à Énigme #3: Modèle objet de Python

  1. corossig dit :

    Le « problème » viens de la résolution de portée des variables, en python on ne déclare pas de variable comme en C mais on les définit. Dans le premier cas, la table de hachage n’est pas définit dans l’objet mais dans la classe, python va donc chercher en premier si une variable x est définit dans l’objet, mais comme il n’en trouve pas il va utiliser la table de hachage de la classe.
    Dans le deuxième cas, x a été définit, donc l’objet a une variable x et la classe aussi.

    • Samuel dit :

      Heu, non, x est aussi défini dans le premier cas. Un autre cas « étrange » qui devrait aiguiller:

      x = {}
      y = x
      x[0] = 0
      print(y) -> {0:0}
      • corossig dit :

        Je suis d’accord sur le fait que x soit définit, mais il est définit au niveau de la classe pas de l’objet, les objets f1 et f2 utilisent la table de hachage de la classe car ils n’ont pas redéfinit self.x .

        • Samuel dit :

          Non, x est vraiment défini au niveau de l’objet. C’est sa valeur qui est définie au niveau de la classe. Je ne trouve pas dans Python d’opérateur « & » qui permettrait de le prouver (id() ne travaille que sur la valeur, pas sur le membre de la classe), mais c’est bien ce qui se passe.

          • Samuel dit :

            J’insiste là-dessus parce que c’est ça qui fait justement la différence entre {} et 3, les portées sur les noms de membres de classe etc. sont vraiment les mêmes dans les deux cas: dans les deux cas c’est bien le x propre à l’objet dont le contenu est récupéré.

        • Samuel dit :

          Oups, d’après le commentaire plus bas, c’est effectivement bien pire que ce que je pensais, je retire ce que j’ai dit, i.e. en utilisant self.x, on écrase la visibilité du membre de classe ? quelle horreur…

  2. Cet article explique, entre autres, comment les classes ont été conçues dans Python, et les problèmes engendrés par le fait qu’on ne déclare pas comme en C. C’est ce qui a donné self, le premier argument de toutes les méthodes, et l’unique moyen de faire référence à des attributs d’instance. L’absence de ce premier argument entraînerait des problèmes de masquage insolubles (sans passer par une construction syntaxique particulière, ce que le créateur du langage veut éviter ici). D’ailleurs, on notera que self n’est même pas un mot-clé du langage (ce qui rend obligatoire sa présence dans la liste des arguments), et qu’on peut très bien l’appeler comme on veut :

    class Pouet:
        def __init__(prout, id):
            prout.x = id
     
    pd = Pouet(3)
    print(pd.x) # 3

    Ceci était une petite parenthèse. Toujours à propos de self, on remarquera que si l’attribut n’est pas trouvé dans le dictionnaire de l’instance (instance.__dict__), la recherche continue dans le dictionnaire de la classe (Classe.__dict__).

    Ceci nous montre pourquoi le premier bout de code marche. En effet, si on ne déclare pas, en Python, on ne peut quand même pas se permettre de faire des dictionnaires rien qu’en ajoutant un élément à un futur dictionnaire :

    >>> caca[5] = 3
    Traceback (most recent call last):
      File "", line 1, in 
    NameError: name 'caca' is not defined

    Il faut d’abord définir caca comme un dictionnaire vide, par exemple, avant de pouvoir accéder à un de ses éléments potentiels :

    >>> caca = {}
    >>> caca[5] = 3
    >>> caca
    {5: 3}

    Dans le premier bout de code, la ligne suivante ne va pas définir un dictionnaire nommé x qui sera rangé dans le dictionnaire de l’instance, mais va rajouter une entrée au dictionnaire nommé x déjà présent dans le dictionnaire de la classe Foo :

            self.x[id] = id

    Par contre, la ligne suivante définit la variable d’instance x :

            self.x = id

    Et instance.x fera toujours référence à la variable x présente dans le dictionnaire de l’instance instance.__dict__.

    Le premier exemple donne un comportement inattendu car l’existence de la variable de classe x, qui est un dictionnaire, nous empêche de nous prendre une erreur en pleine tronche. Et les erreurs, c’est bien ! Ce qu’il faut donc faire lorsqu’on programme, c’est (1) de penser à utiliser exclusivement la syntaxe Classe.attributdeclasse pour accéder à un attribut de classe, et (2) de systématiquement déclarer un dictionnaire avec la syntaxe entre accolades (même s’il est vide) avant de l’utiliser en accédant à des clés inexistantes.

    De manière générale, on retiendra que Python est multiparadigme, que l’orienté objet a été rajouté un peu plus tard, et que c’est fait selon la philosophie Python. Dans la plupart des langages orientés objet, on prend l’habitude de déclarer ou même de définir des variables d’instance directement dans le corps de la classe, mais rappelons-nous qu’il faut un mot-clé (généralement static) pour indiquer une variable de classe. Dans Python, la manière de faire est différente mais plus en accord avec le « style Python ».
    La seule manière d’accéder à des variables d’instance est d’être dans une méthode et d’utiliser self (ou équivalent), et les variables de classe peuvent être définies au niveau de la portée de la classe, ou bien dans les blocs du niveau en-dessous (les méthodes), grâce à Classe.variable, ou self.variable (si aucune variable d’instance ne porte ce nom). Je ne saurais pas vous faire sentir cette « adéquation avec le style Python » autrement, mais « c’est plus logique » et « ça se voit » : ce sont les mêmes règles de portée qui doivent présider pour tous les aspects du langage et on résout les problèmes que cela crée pour les classes autrement (avec Classe.variable et self.variable), alors que dans les langages comme Java, int x = 5; n’a pas la même signification lorsqu’on est dans une classe (déclaration et définition au niveau des instances de la classes) et lorsqu’on est dans un autre type de bloc (déclaration et définition au niveau du bloc/méthode, simplement), ce qui fait qu’on doit avoir recours à des static pour dire ce qu’on veut (évidemment, les concepteurs de ces langages n’ont pas eu affaire aux mêmes problèmes, rien que parce qu’en Python on élude le typage fort et la déclaration). Corrigez-moi si je me trompe, je ne suis pas trop un expert de Python…

    • J’oubliais de dire que mon dernier paragraphe est un mélange entre une interprétation personnelle et une explication ésotérique de Mme Irma, alors je vous invite à ne pas prendre ça pour une parole d’évangile :) .

    • Samuel dit :

      Hum, ça serait pire que ce que je pensais alors ? J’entends par là le code suivant:

      d = {}
       
      class Foo:
          def __init__(self, x):
              self.x = x
          def add(self, i):
              self.x[i] = i
       
      f1 = Foo(d)
      f1.add(0)
      d
  3. Fleury dit :

    Ok, les réponses sont un peu dans tous les sens. Presque tout a été dit mais je crois que c’est Jonathan qui a donné la meilleure réponse. Cependant, voici une petite synthèse de ce qu’il aurait fallu dire.

    Tout d’abord, il est assez évident que x dans la classe Foo était une « variable de classe » alors que l’intention du programmeur était de créer une « variable d’instance » (aka « variable statique« ). Voilà pour ce qui est de l’origine du problème.

    La question qui vient immédiatement à l’esprit est: « Comment créer des variables d’instances pures en Python ? »

    Une première approche pourrait être de simplement faire:

    class Foo:
        x = {}
     
        def __init__(self, id):
            self.x = { id: id }
     
     
    f1 = Foo(5)
    print(f1.x)   # {5:5}, as expected...
     
    f2 = Foo(6)
    print(f2.x)   # {6:6}, as expected...
     
    print(f1.x)   # {5:5}, as expected...
     
    print(Foo.x)  # {}, wtf ?!?

    Mais, on se retrouve avec un x hybride qui est, selon le contexte, soit une variable de classe, soit une variable d’instance…

    Je propose, donc, la solution suivante:

    class Foo:
     
        def __init__(self, id):
            self.x = { id: id }
     
        def test_visibility(self):
            print(self.x)
     
     
    f1 = Foo(5)
    print(f1.x)   # {5:5}, as expected...
     
    f2 = Foo(6)
    print(f2.x)   # {6:6}, as expected...
     
    print(f1.x)   # {5:5}, as expected...
     
    f1.test_visibility() # {5:5}, as expected...
    f2.test_visibility() # {6:6}, as expected...
     
    print(Foo.x)  # AttributeError: class Foo has no attribute 'x'

    Comme on le voit dans ce dernier exemple, non seulement x joue bien le rôle d’une variable d’instance (qui se propage bien à toutes les fonctions appelées après le constructeur) mais il y a bien une erreur lorsqu’on essaye de l’utiliser en tant que variable de classe.

    Personnellement, ce que je retire de cette petite énigme est qu’en Python:

    1. Les variables de classes se déclarent dans le corps de celles-ci.
    2. Les variables d’instances se déclarent dans le(s) constructeur(s) de la classe.

    Évidemment, si vous avez de nombreux constructeurs, vous aurez beaucoup de travail (merci Python!). :)

  4. Fleury dit :

    Ah, question subsidiaire ! Expliquez le comportement de la fonction suivante:

    def func(l = []):
        l.append(42)
        print(l)
     
    func()   # [42], as expected...
    func()   # [42, 42], wtf ?!?
  5. paul dit :

    Visiblement c’est parce que c’est le même paramètre par défaut qui est utilisé, même si il y a plusieurs appels de fonction.

    • Et cela viendrait peut-être du fait que les noms de variables en Python ne sont que des espèces de références à des objets, et ici la liste vide, valeur par défaut pour le paramètre l, est créée lorsque la fonction est créée. Lors des appels à cette fonction, si le paramètre l est omis, alors il référencera cette liste (au départ vide) bien précise puisque, par défaut, l la référence (tautologie inside).
      Quelques images expliquent mieux le principe des noms de variables en Python, et si vous lisez trop loin il y a même une solution pour éviter de wtf!?er sur ce dernier problème.

      • Fleury dit :

        Oui, c’est exactement ça.

        La « philosophie » de Python concernant les variables est de les considérer comme des tags sur des objets et non comme des cellules mémoires dans lesquelles on stocke une valeur. Le lien que tu donnes, réponds exactement à la problématique posée ici. Surtout dans la section « Default Parameter Values » qui explique en détails les implications de cette philosophie sur les paramètres par défaut (et particulièrement lorsque le paramètre par défaut est un « mutable« ).

        Quoiqu’il en soit, Python reste un langage un peu à part car il a des paradigmes qui sont parfois un peu difficiles à saisir par des gens qui ont l’habitude de langages impératifs classiques (C, Fortran, …) ou même de langages objets (Java, C#, …). Ignorer ces différences avec les langages classiques, ne pas s’intéresser aux mécanismes internes de Python amène très souvent à des catastrophes assez monumentales… Je suis d’ailleurs toujours surpris par la méconnaissance des utilisateurs de Python sur leur langage de prédilection chaque fois que je parle avec quelqu’un qui se prétend calé sur le sujet (mais je suppose que je suis trop exigeant…).