Classes

Les classes permettent des créer des objets appelés instances qui partagent des caractéristiques communes. Une classe est en fait un gabarit qui nous permet de créer un certain type d’objets.

Structure d’une classe

Les objets d’une classe partagent des caractéristiques communes à la classe:

  1. des attributs, des variables propres à aux instances;

  2. des méthodes, des fonctions propres aux instances et qui agissent par exemple sur leurs attributs.

Définition d’une classe et création d’objets

Pour définir une classe, la syntaxe est la suivante:

class MaClasse:
    ...

Lorsqu’on lance l’exécution d’un module Python, l’interpréteur exécute le contenu du corps de toutes les classes qu’il rencontre pour la première fois. Ainsi si l’on exécute :

class Foo:
    print('bar')

La console affichera bar. Pour créer une instance de classe, il faut appeler la classe. Appeler une classe revient à appeler son , composé d’un créateur d’objet et d’un initialiseur. En reprenant la classe Foo:

>>> Foo # évaluation de la classe Foo
<class '__main__.Foo'>
>>> Foo() # création d'un objet Foo
<__main__.Foo object at 0x7f193dd99630>

Attributs

Créons un objet :

>>> foo = Foo()

Cet objet est pour l’instant vide, on peut lui donner un attribut:

>>> foo.attr = 1
>>> foo.attr
1

Les informations concernant les attributs d’une instance sont stockées dans le dictionnaire de l’instance, un attribut spécial nommé __dict__.

>>> foo.__dict__
{'attr': 1}

Lorsqu’on accède un attribut avec la syntaxe foo.attr, cette syntaxe est par défaut équivalente à:

>>> foo.__dict__['attr']
1

Pour modifier la valeur d’un attribut, on lui assigne tout simplement une nouvelle valeur:

>>> foo.attr = 456
>>> foo.attr
456
>>> foo.__dict__['attr'] = 789 # équivalent
>>> foo.attr
789

Une classe peut également définir des attributs de classe:

class Foo:
    class_attr = "I'm a class attribute."

Toutes les instances y ont accès:

>>> Foo.class_attr
"I'm a class attribute."
>>> foo = Foo()
>>> foo.class_attr
"I'm a class attribute."

Pourtant, cet attribut n’est pas dans foo.__dict__. En effet, lorsqu’on accède à un attribut, Python va le rechercher dans le dictionnaire de l’instance, mais aussi de sa classe s’il ne l’a pas trouvé. Ainsi:

  • Modifier un attribut de classe pour une instance ajoutera une entrée dans son dictionnaire (on n’accède ainsi par la suite plus à l’attribut de classe mais au nouvel attribut d’instance, on peut dire qu’on l’a surchargé).

  • Modifier un attribut de classe via la classe le modifie pour toutes les instances qui ne l’ont pas surchargé, ainsi que pour toutes les instances qui seront créées ensuite.

>>> foo.class_attr = 'New value'
>>> objet.__dict__
{'class_attr': 'New value'}
>>> bar = Foo()
>>> bar.__dict__
{}
>>> Foo.class_attr = "I just got a new value."
>>> bar.class_attr
"I just got a new value."
>>> foo.class_attr
'New value'

Méthodes

Les méthodes se définissent comme des fonctions dans le corps de la classe, elles agissent en général sur les instances de la classe. Python leur passe toujours l’instance sur laquelle elles sont appliquées en premier paramètre. Par convention, il est noté self.

class MaClasse:

    def methode(self, arg1, arg2):
        print(locals())

Ensuite on les appelle de la manière suivante:

>>> objet = MaClasse()
>>> objet
<__main__.MaClasse object at 0x7f13337e50f0>
>>> objet.methode(arg1, arg2)
{'self': <__main__.MaClasse object at 0x7f13337e50f0>, 'arg1': 123, 'arg2': 'ABC'}

C’est grâce à cette variable self que l’on peut agir sur l’instance.

Initialiseur

L’initialiseur est une méthode spéciale appelée __init__(), il est appelé lorsqu’une instance vient d’être créée et permet d’en initialiser les attributs. L’exemple suivant permet d’initialiser deux attributs:

class MaClasse:

    def __init__(self, att1, att2):
        """Initialiseur"""
        self.attribut1 = att1
        self.attribut2 = att2

Ces deux attributs sont initialisés lorsqu’on crée un nouvel objet. Les valeurs initiales des attributs sont automatiquement passés à __init__() lorsqu’on appelle la classe pour instancier:

>>> objet = MaClasse(123, 'ABC')
>>> objet.__dict__
{'attribut1': 123, 'attribut2': 'ABC'}

Héritage

Principe

L’héritage est un moyen de créer des classes dérivées (classes filles) d’une classe de base (classe mère). Une classe fille hérite de toutes les méthodes et attributs de sa classe mère. Pour indiquer les classes parentes d’une nouvelle classe, on les indique en paramètres lors de sa définition. L’héritage en action dans un exemple on ne peut plus simple:

>>> class Mere:
...     attr = 1
...
>>> class Fille(Mere):
...     pass
...
>>> Fille().attr
1

Il est possible de surcharger (d’écraser) une méthode héritée en la redéfinissant dans la classe fille. Si on veut accéder à une méthode héritée alors qu’on l’a redéfinie dans la classe fille, on utilise la fonction super() qui permet d’appeler la méthode de la classe mère de la classe présente (sans l’argument self).

Exemple :

class Meuble:
    def __init__(self, couleur, materiau):
        self.couleur = couleur
        self.materiau = materiau

class Bibliotheque(Meuble):
    def __init__(self, couleur, materiau, n):
        super().__init__(couleur, materiau)
        self.nb_livres = n

On peut utiliser deux fonctions pour vérifier l’héritage: isinstance renvoie True si l’objet est une instance de la classe ou de ses classes filles ; issubclass permet de voir si une classe est fille d’une autre.

>>> bibli = Bibliotheque('blanc', 'vert', 150)
>>> bibli.__dict__
{'couleur': 'blanc', 'materiau': 'vert', 'nb_livres': 150}
>>> isinstance(bibli, Meuble)
True
>>> isinstance(bibli, Bibliotheque)
True
>>> issubclass(Bibliotheque, Meuble)
True
>>> issubclass(Meuble, Bibliotheque)
False
>>> isinstance(bibli, int)
False
>>> isinstance(bibli, object)
True

Ordre de résolution de méthode

Classe mère object

On a dit précédemment que le constructeur était composé d’un initialiseur et d’un créateur d’instance ; cependant l’exemple ne définissait pas de créateur d’instance : c’est parce qu’il est défini dans une classe object. Toutes les classes en Python 3 héritent implicitement de cette classe. Elle définit de nombreuses méthodes, notamment les méthodes dites spéciales, que l’on peut surcharger pour les personnaliser.

>>> object
<class 'object'>
>>> help(object)
Help on class object in module builtins:

class object
 |  The most base type

>>> class Foo:
...    pass
...
>>> issubclass(Foo, object)
True

Propriétés

Les propriétés représentent en Python le principe d’encapsulation. Elles sont utiles si on souhaite contrôler l’accès à un attribut ou si on veut que le changement d’une valeur d’un attribut engendre des modifications sur d’autres attributs. Du côté utilisateur, ce mécanisme est complètement transparent car il permet de garder la syntaxe classique inst.attr = valeur. Les propriétés sont un cas particulier des descripteurs.

On crée les propriétés en utilisant des décorateurs. Elles contiennent un accesseur, un mutateur, un destructeur et une aide (docstring de l’accesseur). Dans certains cas, il n’est pas nécessaire d’avoir un attribut associé, un simple calcul suffit:

class Temperature:
    def __init__(self, celsius):
        self.celsius = celsius

    @property
    def fahrenheit(self):
        """Propriété 'fahrenheit'."""
        return self.celsius * 1.8 + 32

    @fahrenheit.setter
    def fahrenheit(self, value):
        self.celsius = (value - 32) / 1.8

Ainsi, on peut écrire:

>>> temp = Temperature(20)
>>> temp.fahrenheit
68.0
>>> temp.fahrenheit = 69
>>> temp.celsius
20.555555555555554

Parfois, il est nécessaire d’ajouter des attributs cachés , par exemple si l’on veut aussi contrôler le changement de température en degrés Celsius, pour éviter une récursivité infinie:

class Temperature:
    def __init__(self, celsius):
        self.celsius = celsius

    @property
    def celsius(self):
        """Propriété 'celsius'.

        Vérifie si la température est supérieure à -273°C avant d'assigner."""
        return self._celsius

    @celsius.setter
    def celsius(self, value):
        if value < -273:
            raise ValueError("Une température en degrés Celsius doit être supérieure à -273°C.")
        self._celsius = value

    @property
    def fahrenheit(self):
        """Propriété 'fahrenheit'."""
        return self.celsius * 1.8 + 32

    @fahrenheit.setter
    def fahrenheit(self, value):
        self.celsius = (value - 32) / 1.8

Dans d’autres cas, on peut stocker le résultat de calculs dans un attribut caché. Ici, le calcul des degrés Fahrenheit est rapide, mais il peut s’avérer utile de stocker le résultat pour ne pas avoir à recalculer à chaque fois.

On utilise la propriété de la manière suivante:

>>> help(Temperature.celsius)
Help on property:

    Propriété 'celsius'.

    Vérifie si la température est supérieure à -273°C avant d'assigner.

>>> temp.celsius = -300
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File ".../*.py", line 18, in celsius
    raise ValueError("Une température en degrés Celsius doit être supérieure à -273°C.")
ValueError: Une température en degrés Celsius doit être supérieure à -273°C.
>>> temp.fahrenheit = -462 # ça marche aussi car cette propriété fait appel à celle des celsius !
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File ".../*.py", line 18, in celsius
    raise ValueError("Une température en degrés Celsius doit être supérieure à -273°C.")
ValueError: Une température en degrés Celsius doit être supérieure à -273°C.

Méthodes statiques et méthodes de classes

Méthode statique

Les méthodes que l’on a vues jusqu’à maintenant agissent sur les instances des classes : elles prennent toujours en premier argument le mot clé self qui correspond à l’instance elle même. Lorsque l’on appelle une telle méthode sur une instance comme ceci : instance.methode(*args, **kwargs) Python exécute en fait type(instance).methode(instance, *args, **kwargs).

En fait, ces deux objets sont différents. Classe.methode est une simple fonction, alors que instance.methode est une méthode partiellement évaluée sur l’instance (méthode liée, en anglais bound method), c’est-à-dire que l’instance est mise en premier argument.

Parfois, on écrit des méthodes qui n’ont pas d’incidence sur les instances de la classe. Si l'on reprend la classe Temperature, on peut envisager d'externaliser le calcul de la température (ici le calcul est simple, mais on peut extrapoler l'exemple à des choses plus complexes).

class Temperature:
    def __init__(self, celsius):
        self.celsius = celsius

    @property
    def celsius(self):
        """Propriété 'celsius'.

        Vérifie si la température est supérieure à -273°C avant d'assigner."""
        return self._celsius

    @celsius.setter
    def celsius(self, value):
        if value < -273:
            raise ValueError("Une température en degrés Celsius doit être supérieure à -273°C.")
        self._celsius = value

    @property
    def fahrenheit(self):
        """Propriété 'fahrenheit'."""
        return self.celsius_to_fahrenheit(self.celsius)

    @fahrenheit.setter
    def fahrenheit(self, value):
        self.celsius = self.fahrenheit_to_celsius(value)

    def celsius_to_fahrenheit(celsius):
        return celsius * 1.8 + 32

    def fahrenheit_to_celsius(fahrenheit):
        return (fahrenheit - 32) / 1.8

Si l'on esssaie cette nouvelle définition, on fait face à une erreur :

>>> t = Temperature(21)
>>> t.fahrenheit
TypeError: celsius_to_fahrenheit() takes 1 positional argument but 2 were given

Le problème étant que Python a en fait exécuté : Temperature.celsius_to_fahrenheit(t, 21)

Pour remédier à cela, il existe le décorateur @staticmethod. Il va permettre d'indiquer à Python que cette méthode ne prend pas l'instance en premier paramètre.

class Temperature:
   # ...

    @staticmethod
    def celsius_to_fahrenheit(celsius):
        return celsius * 1.8 + 32

    @staticmethod
    def fahrenheit_to_celsius(fahrenheit):
        return (fahrenheit - 32) / 1.8

On peut ainsi écrire sans crainte :

>>> t = Temperature(21)
>>> t.fahrenheit
69.80000000000001

Méthode de classe

Parfois, on veut pouvoir agir sur la classe et non sur l’instance. Dans ce cas, la méthode de classe prend en premier paramètre cls (la classe) au lieu de self (l’instance).

>>> class Foo:
...     CONSTANT = "Bar"
...     def print_constant(cls):
...         print(cls.CONSTANT)
...
>>> Foo().print_constant()
Bar

Deux problèmes surviennent. Tout d’abord, même si le premier paramètre s’appelle cls, c’est encore l’instance qui est mise en paramètre.

>>> foo = Foo()
>>> foo.CONSTANT = "Baz"
>>> foo.print_constant()
Baz # On veut Bar !

Deuxième problème: on ne peut pas appeler la méthode de classe sur la classe (même problème que pour les méthodes statiques):

>>> Foo.print_constant()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: print_constant() missing 1 required positional argument: 'cls'

Pour remédier à cela, on utilise le décorateur @classmethod.

>>> class Foo:
...     CONSTANT = "Bar"
...     @classmethod
...     def print_constant(cls):
...         print(cls.CONSTANT)
...
>>> foo = Foo()
>>> foo.CONSTANT = "Baz"
>>> foo.print_constant()
Bar # Youpi !
>>> Foo.print_constant()
Bar # Youyoupi !

Cas de l’héritage

En résumé:

  1. Les méthodes statiques sont des fonctions reliées à des classes, mais qui n’agissent pas sur celles-ci.

  2. Les méthodes de classe sont des fonctions qui prennent la classe en paramètre.

Une classe qui hérite d’une classe mère hérite de toutes les méthodes de celle-ci. Les méthodes statiques restent donc inchangées, tandis que les méthodes de classe s’adaptent à la nouvelle classe, car elles la prennent en premier argument.

Exemple : Un exemple d’utilisation de méthodes statiques et de classe sont la création de constructeurs alternatifs. On s’aperçoit de la différence des deux notions.

class Personne:
    def __init__(self, nom, age):
        self.nom = nom
        self.age = age

    @staticmethod
    def par_date_de_naissance(nom, date):
        return Personne(nom, 2018-date)

    @classmethod
    def par_date_de_naissance2(cls, nom, date):
        return cls(nom, 2018-date)

class Homme(Personne):
    sexe = 'homme'
>>> homme1 = Homme.par_date_de_naissance('Jean', 1997)
>>> homme2 = Homme.par_date_de_naissance2('Jean', 1997)
>>> type(homme1)
<class '__main__.Personne'>
>>> type(homme2)
<class '__main__.Homme'>

Pour avoir homme1 de type Homme, il faut redéfinir la méthode statique dans la classe fille.