Extension et héritage

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é

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.


  1. 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())