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é.
- 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. - 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
où 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.
- 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). - L'ordre de déclaration et de dépendance des classes doit respecter les règles compatibles avec la construction du
mroavec 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