Classes, objets et héritage

1. La déclaration de classes

En Python les classes se définissent avec le mot clé class comme illustré ci-dessous, suivi du nom de la classe.

class Personne:

    def méthode(self):
        pass

    ageMax : int = 135

Dans le bloc de la classe, on définit des méthodes et des attributs. La syntaxe de la déclaration des méthodes est à tout point identique à celle de la déclaration de fonctions, et il est possible d'utiliser le type hinting pour indiquer le type des arguments et de la valeur de retour. La seule différence consiste en la présence de l'argument self en première place. Sa présence et son positionnement en tant que premier argument sont obligatoires. Il joue le même rôle que this dans d'autres langages et contient la référence à l'objet sur lequel la méthode est invoquée.

Dans l'exemple ci-dessus, l'attribut ageMax est un attribut de classe (parfois désigné par le mot-clé staticdans des langages comme C++ ou PhP) les attributs d'instance sont abordés dans la section suivante.

On y accède avec la syntaxe suivante : Personne.ageMax = 150 ou a = Personne.ageMax.

2. Les constructeurs et attributs d'instance

Les constructeurs portent systématiquement le nom __init__ (contrairement à d'autres langages où le constructeur porte le nom de la classe) et comme l'ensemble des méthodes, a forcément un argument nommé self en première position, suivi d'éventuels autres arguments.

Une classe ne peut avoir qu'un seul constructeur. De façon générale, Python ne permet pas la déclaration de fonctions avec le même nom dans une même portée (similairement qu'avec des variables). En cas de multiples déclarations, seule la dernière sera retenue.

Les attributs d'instance sont définis dans le constructeur, comme dans l'exemple ci-dessous, ou name et year sont des attributs d'instance.

class Personne:

    def __init__(self, nom: str, annee: int):
        self.name = nom
        self.year = annee

Ensuite, pour créer une instance d'une classe et provoquer l'invocation du constructeur, la syntaxe est la suivante (on notera l'absence de mot clé particulier comme new tel qu'il peut exister dans d'autres langages) :

p = Personne("Dupont", 2022)

On remarque que l'argument self présent dans la déclaration n'apparaît pas lors de l'appel à la méthode. Cela vaut autant pour les constructeurs que pour les autres méthodes de la classe.

Pour accéder aux attributs et méthodes, on utilise la notation pointée classique :

print(p.name, p.year)

Dupont 2022

Les attributs d'instance vus précédemment peuvent également être accédés avec la même notation :

class Personne:
    ageMax = 135

    def __init__(self, nom: str, annee: int):
        self.name = nom
        self.year = annee

p = Personne("Dupont", 2022)

print(p.ageMax)
print(Personne.ageMax)

Dans cet exemple, les deux dernières lignes sont équivalentes à la différence près que la première requiert qu'un objet Personne soit créé.

3. Héritage simple

Python permet de définir des classes dérivées par héritage. Comme dans les autres langages orientés objet, une classe dérivée hérite de tous les attributs et méthodes de ses classes parents et n'a pas besoin de les redéfinir. Elle peut néanmoins le faire si elle le souhaite.

Il existe néanmoins des particularités très spécifiques à Python.

La dérivation s'exprime avec des parenthèses comme suit :

class Parent:
    pass

class Enfant(Parent):
    pass

La première particularité propre à Python est que les attributs d'instance sont définis dans le constructeur et que la classe enfant doit explicitement appeler le constructeur parent pour que ses attributs d'instance soient bien hérités.

Par conséquent, le code suivant provoquera une AttributeError.

class Parent:
    def __init__(self):
       self.a = 10

class Enfant(Parent):
    pass

if __name__ == '__main__':
    e = Enfant()
    print(e.a)

Il existe deux façons pour invoquer explicitement des méthodes de la classe parent : soit en nommant la classe en question, soit en utilisant la fonction super() qui renvoie l'instance parent dont hérite l'objet appelant.

Dans l'exemple suivant, et considérant l'héritage simple, les deux approches (celle d'Enfant1 et celle d'Enfant2) peuvent être considérées équivalentes :

class Parent:
   def __init__(self, v):
      self.a = v

class Enfant1(Parent):
   def __init__(self):
      Parent.__init__(self, 1)

class Enfant2(Parent):
   def __init__(self):
      super().__init__(2)

if __name__ == '__main__':
   e1 = Enfant1()
   e2 = Enfant2()
   print(e1.a, e2.a)

On notera que l'usage de Parent.__init__() demande de passer self en argument, tandis que l'appel à super() ne le demande pas.

4. Accès aux attributs

Python n'offre pas la notion d'attributs (ni méthodes) privés ou protégés. On peut donc accéder de façon non-contrainte à l'ensemble des attributs ou méthodes.

Il existe néanmoins deux astuces pour simuler ou approcher les notions privé et protégé.

  1. La convention de nommage consistant à faire commencer le nom d'un attribut avec un _ indique que l'attribut doit être considéré comme protégé et donc ne devrait pas être utilisé directement par un appelant en dehors de l'arbre d'héritage. Il s'agit ici juste d'une indication. Un appelant peut toutefois utiliser les attributs commençant par un _ s'il le souhaite et on ne peut pas le contraindre de ne pas le faire.
  2. Lorsqu'on fait commencer le nom d'un attribut par __ Python procède à de l'obfuscation de nom (name mangling) rendant ainsi l'attribut inaccessible à l'extérieur de la classe par nommage direct. Il reste néanmoins possible d'accéder à l'attribut en utilisant son nom obfusqué. Lorsqu'une classe dérivée tente d'affecter une valeur à un attribut commençant par __ cela créera un nouvel attribut, propre à la classe dérivée.

Example:

class Personne:
    _ageMax = 135

    def __init__(self, a = 0):
        self.__age = a

    def getAge(self):
        return self.__age

class Autre(Personne):

    def __init__(self):
        super().__init__()
        self.__age = 100

if __name__ == '__main__':

    p = Personne(10)
    a = Autre()

    print(a._ageMax)
    # print(a.__age) # Provoque une AttributeError
    print(p.getAge())
    print(a.getAge()) # On observe que la valeur de __age vaut bien 0, car il s'agit de l'instance du parent

5. Les méthodes prédéfinies

__str__() et __repr()__

La méthode __str__() est une méthode prédéfinie dans Python indiquant comment un objet doit se comporter lorsqu'on souhaite le transformer en chaîne de caractères (notamment pour l'affichage). La signature de la fonction est

__str__(self) -> str

Vous pouvez vous référer à la documentation de Python pour plus de détails. L'utilisation de cette méthode est implicite dans la plupart des cas et on l'invoque rarement. Par exemple, dans le code suivant, print va automatiquement faire appel à __str__ pour l'affichage.

Une version plus générique de cette méthode est __repr__(self) -> str (documentation)

class MaClasse:

    def __str__(self) -> str:
        return "MaClasse"

if __name__ == "__main__":
    c = MaClasse()
    print(c)

6. Héritage multiple

6.1 Le principe de base

Une classe peut dériver de plusieurs parents. Dans ce cas, on parle d'héritage multiple. Par exemple, dans les animaux, on peut d'une part avoir les mammifères, oiseaux, reptiles, etc. mais également avoir des animaux terrestres, aquatiques et aériens, ou encore les herbivores, omnivores et carnivores.

On pourrait donc imaginer la hiérarchie de classes suivante :

class Animal:
   pass

class Mammifere(Animal):
   pass

class AnimalTerrestre(Animal):
   pass

class Carnivore(Animal):
   pass

class Tigre(Mammifere, AnimalTerrestre, Carnivore):
    pass

Tigre hérite à la fois des attributs et méthodes de Mammifere, d'AnimalTerrestre et de Carnivore ainsi que d'Animal par transitivité.

6.2 Initialisation par Parent.__init__(self)

Comme dans le cas de l'héritage simple, le deux méthodes d'initialisation restent valables. Nous regarderons d'abord l'initialisation par appel explicite et nommé de la classe parent.

class Animal:
    def __init__(self):
        print('Animal')
        self.nbPattes = 0
        self.regime = ''

class Mammifere(Animal):
   def __init__(self):
       Animal.__init__(self)
       print('Mammifère')
       self.nbPattes = 4

class AnimalTerrestre(Animal):
    def __init__(self):
       Animal.__init__(self)
       print('AnimalTerrestre')

class Carnivore(Animal):
    def __init__(self):
        Animal.__init__(self)
        print('Carnivore')
        self.regime = 'viande'

class Tigre(Mammifere, AnimalTerrestre, Carnivore):
    def __init__(self):
        Mammifere.__init__(self)
        AnimalTerrestre.__init__(self)      
        Carnivore.__init__(self)
        print('Tigre')

if __name__ == "__main__":
    t = Tigre()
    print('\n', t.nbPattes, t.regime)

L'exécution du code précédent donne :

Animal
Mammifere
Animal
AnimalTerrestre
Animal
Carnivore
Tigre

0 viande

On constate que le constructeur de la classe Animal finit par être exécuté trois fois et que certaines valeurs sont écrasées (le nombre de pattes est mis à zéro).

Lorsqu'on combine héritage multiple et initialisation par appel explicite aux parents nommés il est impossible d'éviter les appels multiples au constructeur à la racine sans artéfacts peu élégants comme ci-dessous, où l'on exploite l'attribute privé __seen et son nom obfusqué _Animal__seen.

Pour cette raison, il est globalement déconseillé d'utiliser l'initialisation par appel explicite aux parents nommés dans le cas d'héritage multiple. Mieux vaut utiliser la méthode décrite plus loin utilisant super()/

class Animal:
    def __init__(self):
        # On teste si l'attribut self.__seen existe déjà
        # (ce qui indiquerait que l'objet a déjà été construit et qu'il
        # s'agit donc d'un second appel au constructeur)
        if not hasattr(self,'_Animal__seen'):
            print('Animal')
            self.nbPattes = 0
            self.regime = ''
            self.__seen = True

6.3 Ordre d'appel des méthodes

Un autre cas important concerne la levée d'ambiguïté dans l'appel de méthodes portant le même nom dans la hiérarchie d'héritage. Par exemple, la méthode estEnCompetition de l'exemple ci-dessous. Dans cet exemple, on ajoute une classe AnimalMarin et on définit une méthode estEnCompetition dans chacune des classes intermédiaires.

class Animal:
    pass

class Mammifere(Animal):
    def estEnCompetition(self, autre: Animal) -> bool:
        print(f'Mammifère en compétition avec {autre} ?')
        return False

class AnimalTerrestre(Animal):
    def estEnCompetition(self, autre: Animal) -> bool:
        print(f'AnimalTerrestre en compétition avec {autre} ?')
        return False

class AnimalMarin(Animal):
    def estEnCompetition(self, autre: Animal) -> bool:
        print(f'AnimalMarin en compétition avec {autre} ?')
        return False

class Carnivore(Animal):
    def estEnCompetition(self, autre: Animal) -> bool:
        print(f'Carnivore en compétition avec {autre} ?')
        return False

class Tigre(Mammifere, AnimalTerrestre, Carnivore):
    pass

class Dauphin(AnimalMarin, Mammifere, Carnivore):
    pass

if __name__ == "__main__":
    t = Tigre()
    d = Dauphin()

    print(t.estEnCompetition(d))
    print(d.estEnCompetition(t))

Dans cet exemple, Tigre et Dauphin sont à la fois Mammifere et Carnivore. L'un est en plus AnimalTerrestre, l'autre AnimalMarin. Que se passe-t-il lorsqu'on fait appel à la méthode estEnCompetition() qui est définie (et différente) dans chacune des classes parents ?

Pour décider quelle méthode utiliser, Python applique l'algorithme C3 décrit dans la documentation.

Pour faire simple, Python prendra la version la plus proche en remontant l'arbre d'héritage jusqu'à la racine en tenant compte de l'ordre d'apparition des classes parents dans la déclaration de la classe enfant en cas d'héritage multiple.

L'attribut de classe __mro__ (MRO = Method Resolution Order) permet de connaître l'ordre de recherche exact.

Dans l'exemple précédent

print(Tigre.__mro__)
print(Dauphin.__mro__)

Donnera

Tigre, Mammifere, AnimalTerrestre, Carnivore, Animal
Dauphin, AnimalMarin, Mammifere, Carnivore, Animal

6.4 Initialisation par super().__init()__

Pour éviter le problème du double appel au constructeur lors de l'initialisation par appel explicite au parent nommé, il est possible, comme dans le cas de l'héritage simple, d'utiliser super().

L'utilisation correcte de super() impose néanmoins quelques règles en apparence contre-intuitives.

  1. Chacune de l'ensemble des classes de la hiérarchie d'héritage doit faire appel à super().__init__() (même la classe racine, tout en haut de l'hiérarchie).
  2. L'ordre de déclaration et de dépendance des classes doit respecter les règles compatibles avec la construction du mro avec l'algorithme C3.
class Animal:
    def __init__(self):
        super().__init__()
        print('Animal')

class Mammifere(Animal):
    def __init__(self):
        super().__init__()
        print('Mammifère')

class AnimalTerrestre(Animal):
    def __init__(self):
       super().__init__()
       print('AnimalTerrestre')

class AnimalMarin(Animal):
    def __init__(self):
       super().__init__()
       print('AnimalMarin')

class Carnivore(Animal):
    def __init__(self):
        super().__init__()
        print('Carnivore')

class Tigre(Mammifere, AnimalTerrestre, Carnivore):
    def __init__(self):
        super().__init__()
        print('Tigre')

class Dauphin(AnimalMarin, Mammifere, Carnivore):
    def __init__(self):
        super().__init__()
        print('Dauphin')

if __name__ == "__main__":
    t = Tigre()
    d = Dauphin()

L'exécution de cet exemple affiche

Animal Carnivore AnimalTerrestre Mammifere Tigre
Animal Carnivore Mammifere AnimalMarin Dauphin

Ce qui correspond exactement au mro (cf. section précédente). Cela signifie que stricto sensu, l'appel à super() ne renvoie pas nécessairement à la classe hiérarchiquement parente de la classe appelante, mais à la suivante dans le mro.

Ainsi, pour Tigre le super() appelé dans la classe Mammifere renvoie à AnimalTerrestre, tandis que pour Dauphin, le super() de la même classe Mammifere renvoie à Carnivore, de sorte à respecter l'ordre de déclaration des classes parents dans les deux cas :

class Tigre(Mammifere, AnimalTerrestre, Carnivore):
    pass

class Dauphin(AnimalMarin, Mammifere, Carnivore):
    pass

super().methode() et passage d'arguments

Nous venons de voir que l'appel à super() permet de gérer de façon élégante l'accès aux classes parentes. En contre-partie, on ne peut plus savoir avec précision quelle classe parente sera invoquée. Ceci pose nécessairement des problèmes si les paramètres d'appel sont différents d'un parent à un autre.

En général le problème se pose essentiellement avec __init()__. Les autres méthodes ont généralement des signatures et des arguments identiques pour raison de respect du polymorphisme.

La solution est d'utiliser uniquement des arguments nommés dans les diverses classes et de systématiquement transférer l'ensemble des arguments contenus dans **kwargs lors de l'appel à super().__init__().

À voir aussi : les décorateurs

Surcharge d'opérateurs

Il est possible, dans Python, de surcharger le comportement des opérateurs classiques +, - ... afin de faciliter l'écriture et d'éviter des lourdeurs du code qui passerait uniquement par des appels de méthodes, comme dans l'exemple ci-dessous

obj3 = obj1.plus(obj2)
obj3 = obj1 + obj2

Il est également possible de surcharger des opérateurs plus avancés comme [] voir même l'opérateur () se comportant comme un appel de fonction. La liste complète des opérateurs qui peuvent être surchargés, et les noms des méthodes associées sont accessibles dans la documentation de Python.

Exemple de surcharge de l'opérateur +

class Entier:
    def __init__(self, x: int = 0):
        self._x = x

    def valeur(self) -> int:
        return self._x

    def __add__(self, other):
        return Entier(self.valeur() + other.valeur())

if __name__ == '__main__':
    a = Entier(10)
    b = Entier(20)
    c = a + b
    print(c.valeur())

Exemple de surcharge de l'opérateur []

L'opérateur [] permet à une classe de se comporter (partiellement) comme un type énumérable tel que list ou tuple en fournissant un accès par index à ses contenus. En revanche, la classe n'est pas réellement énumérable car elle ne permet pas les itérations de type for i in C.

class Couple:
    def __init__(self, a, b):
        self._premier = a
        self._second = b

    def __getitem__(self, item):
        if item not in [0, 1]:
            raise IndexError

        if item == 1:
            return self._premier
        else:
            return self._second

if __name__ == '__main__':
    c = Couple(10, 20)
    print(c[0], c[1])

Notez le fait que l'on est libre de faire dans la fonction ce qu'on veut. Ici [0] renvoie le second élément.

Exemple de surcharge de l'opérateur ()

A l'instar de [] il est également possible de surcharger l'appel de fonction par l'opérateur () comme le montre l'exemple ci-dessous.

class Func:
    def execution(self, *args):
        for arg in args:
            print(arg, end='/')
        print()

    def __call__(self, *args):
        return self.execution(*args)

if __name__ == '__main__':
    f = Func()
    f()
    f('a','z')

Fonctions "magiques"

Python réserve les noms de variables, fonctions ou méthodes qui commencent et terminent par __ pour des usages particuliers. Nous avons déjà vu __init__(), __str__() et __repr()__, par exemple, ou ceux évoqués dans la section consacrée à la surcharge des opérateurs.

Il en existe beaucoup d'autres.

Itérateurs

  • __iter__()
  • __next__()

À venir

  • @staticmethod
  • @classmethod
  • @property
  • @property-name.setter
  • destructeurs : __del__ et @property-nom.deleter
  • méthodes abstraites
  • package abc
  • ajout dynamique d'attributs et de méthodes
  • fabriques d'objets, metaclass