Il n’est pas dans ce chapitre question de régler la succession de votre grand-tante par alliance, mais de nous intéresser à l’extension de classes.
Imaginons que nous voulions définir une classe Admin
, pour gérer des administrateurs, qui réutiliserait le même code que la classe User
.
Tout ce que nous savons faire actuellement c’est copier/coller le code de la classe User
en changeant son nom pour Admin
.
Nous allons maintenant voir comment faire ça de manière plus élégante, grâce à l’héritage. Nous étudierons de plus les relations entre classes ansi créées.
Nous utiliserons donc la classe User
suivante pour la suite de ce chapitre.
1 2 3 4 5 6 7 8 9 10 11 12 | class User: def __init__(self, id, name, password): self.id = id self.name = name self._salt = crypt.mksalt() self._password = self._crypt_pwd(password) def _crypt_pwd(self, password): return crypt.crypt(password, self._salt) def check_pwd(self, password): return self._password == self._crypt_pwd(password) |
- Hériter en toute simplicité
- La redéfinition de méthodes, c'est super !
- Une classe avec deux mamans
- TP : Fils de discussion
Hériter en toute simplicité
L’héritage simple est le mécanisme permettant d’étendre une unique classe. Il consiste à créer une nouvelle classe (fille) qui bénéficiera des mêmes méthodes et attributs que sa classe mère. Il sera aisé d’en définir de nouveaux dans la classe fille, et cela n’altèrera pas le fonctionnement de la mère.
Par exemple, nous voudrions étendre notre classe User
pour ajouter la possibilité d’avoir des administrateurs.
Les administrateurs (Admin
) possèderaient une nouvelle méthode, manage
, pour administrer le système.
1 2 3 | class Admin(User): def manage(self): print('I am an über administrator!') |
En plus des méthodes de la classe User
(__init__
, _crypt_pwd
et check_pwd
), Admin
possède aussi une méthode manage
.
1 2 3 4 5 6 7 8 9 10 | >>> root = Admin(1, 'root', 'toor') >>> root.check_password('toor') True >>> root.manage() I am an über administrator! >>> john = User(2, 'john', '12345') >>> john.manage() Traceback (most recent call last): File "<stdin>", line 1, in <module> AttributeError: 'User' object has no attribute 'manage' |
Nous pouvons avoir deux classes différentes héritant d’une même mère
1 2 | class Guest(User): pass |
Admin
et Guest
sont alors deux classes filles de User
.
L’héritage simple permet aussi d’hériter d’une classe qui hérite elle-même d’une autre classe.
1 2 | class SuperAdmin(Admin): pass |
SuperAdmin
est alors la fille de Admin
, elle-même la fille de User
. On dit alors que User
est une ancêtre de SuperAdmin
.
On peut constater quels sont les parents d’une classe à l’aide de l’attribut spécial __bases__
des classes :
1 2 3 4 5 6 | >>> Admin.__bases__ (<class '__main__.User'>,) >>> Guest.__bases__ (<class '__main__.User'>,) >>> SuperAdmin.__bases__ (<class '__main__.Admin'>,) |
Que vaudrait alors User.__bases__
, sachant que la classe User
est définie sans héritage ?
1 2 | >>> User.__bases__ (<class 'object'>,) |
On remarque que, sans que nous n’ayons rien demandé, User
hérite de object
.
En fait, object
est l’ancêtre de toute classe Python. Ainsi, quand aucune classe parente n’est définie, c’est object
qui est choisi.
Sous-typage
Nous avons vu que l’héritage permettait d’étendre le comportement d’une classe, mais ce n’est pas tout. L’héritage a aussi du sens au niveau des types, en créant un nouveau type compatible avec le parent.
En Python, la fonction isinstance
permet de tester si un objet est l’instance d’une certaine classe.
1 2 3 4 5 6 7 8 | >>> isinstance(root, Admin) True >>> isinstance(root, User) True >>> isinstance(root, Guest) False >>> isinstance(root, object) True |
Mais gardez toujours à l’esprit qu’en Python, on préfère se référer à la structure d’un objet qu’à son type (duck-typing), les tests à base de isinstance
sont donc à utiliser pour des cas particuliers uniquement, où il serait difficile de procéder autrement.
La redéfinition de méthodes, c'est super !
Nous savons hériter d’une classe pour y insérer de nouvelles méthodes, mais nous ne savons pas étendre les méthodes déjà présentes dans la classe mère. La redéfinition est un concept qui permet de remplacer une méthode du parent.
Nous voudrions que la classe Guest
ne possède plus aucun mot de passe. Celle-ci devra modifier la méthode check_pwd
pour accepter tout mot de passe, et simplifier la méthode __init__
.
On ne peut pas à proprement parler étendre le contenu d’une méthode, mais on peut la redéfinir :
1 2 3 4 5 6 7 8 9 | class Guest(User): def __init__(self, id, name): self.id = id self.name = name self._salt = '' self._password = '' def check_pwd(self, password): return True |
Cela fonctionne comme souhaité, mais vient avec un petit problème, le code de la méthode __init__
est répété.
En l’occurrence il ne s’agit que de 2 lignes de code, mais lorsque nous voudrons apporter des modifications à la méthode de la classe User
, il faudra les répercuter sur Guest
, ce qui donne vite quelque chose de difficile à maintenir.
Heureusement, Python nous offre un moyen de remédier à ce mécanisme, super !
Oui, super
, littéralement, une fonction un peu spéciale en Python, qui nous permet d’utiliser la classe parente (superclass).
super
est une fonction qui prend initialement en paramètre une classe et une instance de cette classe. Elle retourne un objet proxy1 qui s’utilise comme une instance de la classe parente.
1 2 3 4 5 | >>> guest = Guest(3, 'Guest') >>> guest.check_pwd('password') True >>> super(Guest, guest).check_pwd('password') False |
Au sein de la classe en question, les arguments de super
peuvent être omis (ils correspondront à la classe et à l’instance courantes), ce qui nous permet de simplifier notre méthode __init__
et d’éviter les répétitions.
1 2 3 4 5 6 | class Guest(User): def __init__(self, id, name): super().__init__(id, name, '') def check_pwd(self, password): return True |
On notera tout de même que contrairement aux versions précédentes, l’initialisateur de User
est appelé en plus de celui de Guest
, et donc qu’un sel et un hash du mot de passe sont générés alors qu’ils ne serviront pas.
Ça n’est pas très grave dans le cas présent, mais pensez-y dans vos développements futurs, afin de ne pas exécuter d’opérations coûteuses inutilement.
-
Un proxy est un intermédiaire transparent entre deux entités. ↩
Une classe avec deux mamans
Avec l’héritage simple, nous pouvions étendre le comportement d’une classe. L’héritage multiple va nous permettre de le faire pour plusieurs classes à la fois.
Il nous suffit de préciser plusieurs classes entre parenthèses lors de la création de notre classe fille.
1 2 3 4 5 6 7 8 9 10 | class A: def foo(self): return '!' class B: def bar(self): return '?' class C(A, B): pass |
Notre classe C
a donc deux mères : A
et B
. Cela veut aussi dire que les objets de type C
possèdent à la fois les méthodes foo
et bar
.
1 2 3 4 5 | >>> c = C() >>> c.foo() '!' >>> c.bar() '?' |
Ordre d’héritage
L’ordre dans lequel on hérite des parents est important, il détermine dans quel ordre les méthodes seront recherchées dans les classes mères. Ainsi, dans le cas où la méthode existe dans plusieurs parents, celle de la première classe sera conservée.
1 2 3 4 5 6 7 8 9 10 11 12 13 | class A: def foo(self): return '!' class B: def foo(self): return '?' class C(A, B): pass class D(B, A): pass |
1 2 3 4 | >>> C().foo() '!' >>> D().foo() '?' |
Cet ordre dans lequel les classes parentes sont explorées pour la recherche des méthodes est appelé Method Resolution Order (MRO).
On peut le connaître à l’aide de la méthode mro
des classes.
1 2 3 4 5 6 7 8 | >>> A.mro() [<class '__main__.A'>, <class 'object'>] >>> B.mro() [<class '__main__.B'>, <class 'object'>] >>> C.mro() [<class '__main__.C'>, <class '__main__.A'>, <class '__main__.B'>, <class 'object'>] >>> D.mro() [<class '__main__.D'>, <class '__main__.B'>, <class '__main__.A'>, <class 'object'>] |
C’est aussi ce MRO qui est utilisé par super
pour trouver à quelle classe faire appel.
super
se charge d’explorer le MRO de la classe de l’instance qui lui est donnée en second paramètre, et de retourner un proxy sur la classe juste à droite de celle donnée en premier paramètre.
Ainsi, avec c
une instance de C
, super(C, c)
retournera un objet se comportant comme une instance de A
, super(A, c)
comme une instance de B
, et super(B, c)
comme une instance de object
.
1 2 3 4 5 6 7 8 9 10 11 | >>> c = C() >>> c.foo() # C.foo == A.foo '!' >>> super(C, c).foo() # A.foo '!' >>> super(A, c).foo() # B.foo '?' >>> super(B, c).foo() # object.foo -> méthode introuvable Traceback (most recent call last): File "<stdin>", line 1, in <module> AttributeError: 'super' object has no attribute 'foo' |
Les classes parentes n’ont alors pas besoin de se connaître les unes les autres pour se référencer.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | class A: def __init__(self): print("Début initialisation d'un objet de type A") super().__init__() print("Fin initialisation d'un objet de type A") class B: def __init__(self): print("Début initialisation d'un objet de type B") super().__init__() print("Fin initialisation d'un objet de type B") class C(A, B): def __init__(self): print("Début initialisation d'un objet de type C") super().__init__() print("Fin initialisation d'un objet de type C") class D(B, A): def __init__(self): print("Début initialisation d'un objet de type D") super().__init__() print("Fin initialisation d'un objet de type D") |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | >>> C() Début initialisation d'un objet de type C Début initialisation d'un objet de type A Début initialisation d'un objet de type B Fin initialisation d'un objet de type B Fin initialisation d'un objet de type A Fin initialisation d'un objet de type C <__main__.C object at 0x7f0ccaa970b8> >>> D() Début initialisation d'un objet de type D Début initialisation d'un objet de type B Début initialisation d'un objet de type A Fin initialisation d'un objet de type A Fin initialisation d'un objet de type B Fin initialisation d'un objet de type D <__main__.D object at 0x7f0ccaa971d0> |
La méthode __init__
des classes parentes n’est pas appelée automatiquement, et l’appel doit donc être réalisé explicitement.
C’est ainsi le super().__init__()
présent dans la classe C
qui appelle l’initialiseur de la classe A
, qui appelle lui-même celui de la classe B
.
Inversement, pour la classe D
, super().__init__()
appelle l’initialiseur de B
qui appelle celui de A
.
On notera que les exemple donnés n’utilisent jamais plus de deux classes mères, mais il est possible d’en avoir autant que vous le souhaitez.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | class A: pass class B: pass class C: pass class D: pass class E(A, B, C, D): pass |
Mixins
Les mixins sont des classes dédiées à une fonctionnalité particulière, utilisable en héritant d’une classe de base et de ce mixin.
Par exemple, plusieurs types que l’on connaît sont appelés séquences (str
, list
, tuple
). Ils ont en commun le fait d’implémenter l’opérateur []
et de gérer le slicing.
On peut ainsi obtenir l’objet en ordre inverse à l’aide de obj[::-1]
.
Un mixin qui pourrait nous être utile serait une classe avec une méthode reverse
pour nous retourner l’objet inversé.
1 2 3 4 5 6 7 8 9 | class Reversable: def reverse(self): return self[::-1] class ReversableStr(Reversable, str): pass class ReversableTuple(Reversable, tuple): pass |
1 2 3 4 5 6 7 | >>> s = ReversableStr('abc') >>> s 'abc' >>> s.reverse() 'cba' >>> ReversableTuple((1, 2, 3)).reverse() (3, 2, 1) |
Ou encore nous pourrions vouloir ajouter la gestion d’une photo de profil à nos classes User
et dérivées.
1 2 3 4 5 6 7 8 9 10 11 12 13 | class ProfilePicture: def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.picture = '{}-{}.png'.format(self.id, self.name) class UserPicture(ProfilePicture, User): pass class AdminPicture(ProfilePicture, Admin): pass class GuestPicture(ProfilePicture, Guest): pass |
1 2 3 | >>> john = UserPicture(1, 'john', '12345') >>> john.picture '1-john.png' |
TP : Fils de discussion
Vous vous souvenez de la classe Post
pour représenter un message ?
Nous aimerions maintenant pouvoir instancier des fils de discussion (Thread
) sur notre forum.
Qu’est-ce qu’un fil de discussion ?
- Un message associé à un auteur et à une date ;
- Mais qui comporte aussi un titre ;
- Et une liste de posts (les réponses).
Le premier point indique clairement que nous allons réutiliser le code de la classe Post
, donc en hériter.
Notre nouvelle classe sera initialisée avec un titre, un auteur et un message.
Thread
sera dotée d’une méthode answer
recevant un auteur et un texte, et s’occupant de créer le post correspondant et de l’ajouter au fil.
Nous changerons aussi la méthode format
du Thread
afin qu’elle concatène au fil l’ensemble de ses réponses.
La classe Post
restera inchangée.
Enfin, nous supprimerons la méthode post
de la classe User
, pour lui en ajouter deux nouvelles :
new_thread(title, message)
pour créer un nouveau fil de discussion associé à cet utilisateur ;answer_thread(thread, message)
pour répondre à un fil existant.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 | import crypt import datetime class User: def __init__(self, id, name, password): self.id = id self.name = name self._salt = crypt.mksalt() self._password = self._crypt_pwd(password) def _crypt_pwd(self, password): return crypt.crypt(password, self._salt) def check_pwd(self, password): return self._password == self._crypt_pwd(password) def new_thread(self, title, message): return Thread(title, self, message) def answer_thread(self, thread, message): thread.answer(self, message) class Post: def __init__(self, author, message): self.author = author self.message = message self.date = datetime.datetime.now() def format(self): date = self.date.strftime('le %d/%m/%Y à %H:%M:%S') return '<div><span>Par {} {}</span><p>{}</p></div>'.format(self.author.name, date, self.message) class Thread(Post): def __init__(self, title, author, message): super().__init__(author, message) self.title = title self.posts = [] def answer(self, author, message): self.posts.append(Post(author, message)) def format(self): posts = [super().format()] posts += [p.format() for p in self.posts] return '\n'.join(posts) if __name__ == '__main__': john = User(1, 'john', '12345') peter = User(2, 'peter', 'toto') thread = john.new_thread('Bienvenue', 'Bienvenue à tous') peter.answer_thread(thread, 'Merci') print(thread.format()) |