Classes abstraites

Nous avons durant ce cours étudié diverses interfaces (conteneurs, itérables, hashables, etc.). Nous allons maintenant voir comment les classes abstraites peuvent nous permettre de reconnaître ces interfaces.

Je ne reviendrai pas ici sur la notion même de classe abstraite, présentée dans cet autre cours, dont il est conseillé de prendre connaissance avant de passer à la suite.

Module abc

Pour rappel, le module abc donne accès à la classe ABC qui permet par héritage de construire une classe abstraite, et au décorateur abstractmethod pour définir des méthodes abstraites.

Une autre classe importante réside dans ce module, ABCMeta. ABCMeta est la métaclasse de la classe ABC, et donc le type de toutes les classes abstraites. C’est ABCMeta qui s’occupe de référencer dans l’ensemble __abstractmethods__1 les méthodes abstraites définies dans la classe.

Mais outre le fait de pouvoir spécifier les méthodes à implémenter, les classes abstraites de Python ont un autre but : définir une interface. Vous connaissez probablement isinstance, qui permet de vérifier qu’un objet est du bon type ; peut-être moins issubclass, pour vérifier qu’une classe hérite d’une autre.

1
2
3
4
5
6
7
8
>>> isinstance(4, int) # 4 est un int
True
>>> isinstance(4, str) # 4 n'est pas une str
False
>>> issubclass(int, object) # int hérite d'object
True
>>> issubclass(int, str) # int n'hérite pas de str
False

Ces deux fonctions sont en fait des opérateurs, qui font appel à des méthodes spéciales, et sont à ce titre surchargeables, comme nous le verrons par la suite.

J’ai utilisé plus haut le terme « hérite » pour décrire l’opérateur issubclass. C’est en fait légèrement différent, issubclass permet de vérifier qu’une classe est une sous-classe (ou sous-type) d’une autre.

Quand une classe hérite d’une autre, elle en devient un sous-type (sauf cas exceptionnels2). Mais elle peut aussi être sous-classe de classes dont elle n’hérite pas.

C’est le but de la méthode register des classes ABC. Elle sert à enregistrer une classe comme sous-type de la classe abstraite.

Imaginons une classe abstraite Sequence correspondant aux types de séquences connus (str, list, tuple)3. Ces types sont des builtins du langage, il ne nous est pas pas possible de les redéfinir pour les faire hériter de Sequence. Mais la méthode register de notre classe abstraite Sequence va nous permettre de les enregistrer comme sous-classes.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
>>> import abc
>>> class Sequence(abc.ABC):
...     pass
...
>>> Sequence.register(str)
<class 'str'>
>>> Sequence.register(list)
<class 'list'>
>>> Sequence.register(tuple)
<class 'tuple'>
>>> isinstance('foo', Sequence)
True
>>> isinstance(42, Sequence)
False
>>> issubclass(list, Sequence)
True
>>> issubclass(dict, Sequence)
False

  1. L’ensemble __abstractmethods__ est ensuite analysé pour savoir si une classe peut être instanciée, le constructeur d’object levant une erreur dans le cas échéant. 

  2. Voir à ce propos la section « issubclass ». 

  3. Pour rappel, une séquence est un objet indexable et sliceable

isinstance

Nous venons de voir que isinstance était un opérateur, et qu’il était surcheargable. Nous allons ici nous intéresser à la mise en œuvre de cette surcharge.

Pour rappel, la surchage d’opérateur se fait par la définition d’une méthode spéciale dans le type de l’objet. Par exemple, il est possible d’utiliser + sur le nombre 4 parce que 4 est de type int, et qu’int implémente la méthode __add__.

isinstance est un opérateur qui s’applique à une classe (la classe dont on cherche à savoir si tel objet en est l’instance). La surcharge se fera donc dans le type de cette classe, c’est-à-dire dans la métaclasse.

La méthode spéciale correspondant à l’opérateur est __instancecheck__, qui reçoit en paramètre l’objet à tester, et retourne un booléen (True si l’objet est du type en question, False sinon).

On peut par exemple imaginer une classe ABCIterable, qui cherchera à savoir si un objet donné est itérable (possède une méthode __iter__). On teste pour cela si cet object a un attribut __iter__, et si cet attribut est callable.

1
2
3
4
5
6
class ABCIterableMeta(type):
    def __instancecheck__(self, obj):
        return hasattr(obj, '__iter__') and callable(obj.__iter__)

class ABCIterable(metaclass=ABCIterableMeta):
    pass
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
>>> isinstance([], ABCIterable)
True
>>> isinstance((0,), ABCIterable)
True
>>> isinstance('foo', ABCIterable)
True
>>> isinstance({'a': 'b'}, ABCIterable)
True
>>> isinstance(18, ABCIterable)
False
>>> isinstance(object(), ABCIterable)
False

Quelques dernières précisions sur isinstance : l’opérateur est un peu plus complexe que ce qui a été montré.

Premièrement, isinstance peut recevoir en deuxième paramètre un tuple de types plutôt qu’un type simple. Il regardera alors si l’object donné en premier paramètre est une instance de l’un de ces types.

1
2
3
4
5
6
>>> isinstance(4, (int, str))
True
>>> isinstance('foo', (int, str))
True
>>> isinstance(['bar'], (int, str))
False

Ensuite, la méthode __instancecheck__ n’est pas toujours appelée. Lors d’un appel isinstance(obj, cls), la méthode __instancecheck__ est appelée que si type(obj) n’est pas cls.

On peut s’en rendre compte avec une classe dont __instancecheck__ renverrait False pour tout objet testé.

1
2
3
4
5
6
7
8
9
>>> class NoInstancesMeta(type):
...     def __instancecheck__(self, obj):
...         return False
...
>>> class NoInstances(metaclass=NoInstancesMeta):
...     pass
...
>>> isinstance(NoInstances(), NoInstances)
True

En revanche, si nous héritons de notre classe NoInstances :

1
2
3
4
5
>>> class A(NoInstances):
...     pass
...
>>> isinstance(A(), NoInstances)
False

Pour comprendre le fonction d’isinstance, on pourrait grossièrement réécrire l’opérateur avec la fonction suivante.1

1
2
3
4
5
6
def isinstance(obj, cls):
    if type(obj) is cls:
        return True
    if issubclass(type(cls), tuple):
        return any(isinstance(obj, c) for c in cls)
    return type(cls).__instancecheck__(cls, obj)

  1. Voir à ce propos la fonction PyObject_IsInstance du fichier Objects/abstract.c des sources de CPython. 

issubclass

Dans la même veine qu’isinstance, nous avons donc l’opérateur issubclass, qui vérifie qu’une classe est sous-classe d’une autre.

La surcharge se fait là aussi sur la métaclasse, à l’aide de la méthode spéciale __subclasscheck__. Cette méthode est très semblable à __instancecheck__ : en plus de self (la classe courante), elle reçoit en paramètre la classe à tester. Elle retourne elle aussi un booléen (True si la classe donnée est une sous-classe de l’actuelle, False sinon).

Reprenons ici l’exemple précédent des itérables : notre classe ABCIterable permet de tester si une classe est un type d’objets itérables.

1
2
3
4
5
6
class ABCIterableMeta(type):
    def __subclasscheck__(self, cls):
        return hasattr(cls, '__iter__') and callable(cls.__iter__)

class ABCIterable(metaclass=ABCIterableMeta):
    pass
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
>>> issubclass(list, ABCIterable)
True
>>> issubclass(tuple, ABCIterable)
True
>>> issubclass(str, ABCIterable)
True
>>> issubclass(dict, ABCIterable)
True
>>> issubclass(int, ABCIterable)
False
>>> issubclass(object, ABCIterable)
False

Cet exemple est d’ailleurs meilleur que le précédent, puisque comme Python il vérifie que la méthode __iter__ est présente au niveau de la classe et pas au niveau de l’instance.

Comme isinstance, issubclass peut recevoir en deuxième paramètre un tuple de différentes classes à tester.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
>>> issubclass(int, (int, str))
True
>>> issubclass(str, (int, str))
True
>>> class Integer(int):
...     pass
...
>>> issubclass(Integer, (int, str))
True
>>> issubclass(list, (int, str))
False

En revanche, pas de raccourci pour éviter l’appel à __subclasscheck__, même quand on cherche à vérifier qu’une classe est sa propre sous-classe.

1
2
3
4
5
6
7
8
9
>>> class NoSubclassesMeta(type):
...     def __subclasscheck__(self, cls):
...         return False
...
>>> class NoSubclasses(metaclass=NoSubclassesMeta):
...     pass
...
>>> issubclass(NoSubclasses, NoSubclasses)
False

Le cas des classes ABC

Pour les classes abstraites ABC, c’est-à-dire qui ont abc.ABCMeta comme métaclasse, une facilité est mise en place. En effet, ABCMeta définit une méthode __subclasscheck__ (qui s’occupe entre autres de gérer les classes enregistrées via register).

Pour éviter de recourir à une nouvelle métaclasse et redéfinir __subclasscheck__, la méthode d’ABCMeta relaie l’appel à la méthode de classe __subclasshook__, si elle existe. Ainsi, une classe abstraite n’a qu’à définir __subclasshook__ si elle veut étendre le comportement d’issubclass.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
>>> import abc
>>> class ABCIterable(abc.ABC):
...     @classmethod
...     def __subclasshook__(cls, subcls):
...         return hasattr(subcls, '__iter__') and callable(subcls.__iter__)
...
>>> issubclass(list, ABCIterable)
True
>>> issubclass(int, ABCIterable)
False

On notera que la méthode __subclasshook__ sert aussi à l’opérateur isinstance.

1
2
>>> isinstance([1, 2, 3], ABCIterable)
True

Contrairement à __subclasscheck__, __subclasshook__ ne retourne pas forcément un booléen. Elle peut en effet retourner True, False, ou NotImplemented.

Dans le cas où elle retourne un booléen, il sera la valeur de retour de isinstance/issubclass. Mais dans le cas de NotImplemented, la main est rendue à la méthode __subclasscheck__ d’ABC, qui s’occupe de vérifier si les classes sont parentes, ou si la classe est enregistrée (register).

Nous allons donc réécrire notre classe ABCIterable de façon à retourner True si la classe implémente __iter__, et NotImplemented sinon. Ainsi, si la classe hérite d’ABCIterable mais n’implémente pas __iter__, elle sera tout de même considérée comme une sous-classe, ce qui n’est pas le cas actuellement.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
>>> class ABCIterable(abc.ABC):
...     @classmethod
...     def __subclasshook__(cls, subcls):
...         if hasattr(subcls, '__iter__') and callable(subcls.__iter__):
...             return True
...         return NotImplemented
...
>>> issubclass(list, ABCIterable)
True
>>> issubclass(int, ABCIterable)
False
>>> class X(ABCIterable): pass
...
>>> issubclass(X, ABCIterable)
True

Collections abstraites

Nous connaissons le module collections, spécialisé dans les conteneurs ; et abc, dédié aux classes abstraites. Que donnerait le mélange des deux ? collections.abc !

Ce module fournit des classes abstraites toutes prêtes pour reconnaître les différentes interfaces du langage (Container, Sequence, Mapping, Iterable, Iterator, Hashable, Callable, etc.).

Assez simples à appréhender, ces classes abstraites testent la présence de méthodes essentielles au respect de l’interface.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
>>> import collections.abc
>>> isinstance(10, collections.abc.Hashable)
True
>>> isinstance([10], collections.abc.Hashable)
False
>>> issubclass(list, collections.abc.Sequence)
True
>>> issubclass(dict, collections.abc.Sequence)
False
>>> issubclass(list, collections.abc.Mapping)
False
>>> issubclass(dict, collections.abc.Mapping)
True

Outre la vérification d’interfaces, certaines de ces classes servent aussi de mixins, en apportant des méthodes abstraites et des méthodes prêtes à l’emploi.

La classe MutableMapping, par exemple, a pour méthodes abstraites __getitem__, __setitem__, __delitem__, __iter__ et __len__. Mais la classe fournit en plus l’implémentation d’autres méthodes utiles aux mappings : __contains__, keys, items, values, get, __eq__, __ne__, pop, popitem, clear, update, et setdefault.

C’est-à-dire qu’il suffit de redéfinir les 5 méthodes abstraites pour avoir un type de dictionnaires parfaitement utilisable.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
class MyMapping(collections.abc.MutableMapping):
    def __init__(self, *args, **kwargs):
        super().__init__()
        self._subdict = dict(*args, **kwargs)

    def __getitem__(self, key):
        return self._subdict[key]

    def __setitem__(self, key, value):
        self._subdict[key] = value

    def __delitem__(self, key):
        del self._subdict[key]

    def __iter__(self):
        return iter(self._subdict)

    def __len__(self):
        return len(self._subdict)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
>>> m = MyMapping()
>>> m['a'] = 0
>>> m['b'] = m['a'] + 1
>>> len(m)
2
>>> list(m.keys())
['b', 'a']
>>> list(m.values())
[1, 0]
>>> dict(m)
{'b': 1, 'a': 0}
>>> m.get('b')
1
>>> 'a' in m
True
>>> m.pop('a')
0
>>> 'a' in m
False

Dans un genre similaire, on notera aussi les classes du module numbers : Number, Complex, Real, Rational, Integral. Ces classes abstraites, en plus de reconnaître l’ensemble des types numériques, permettent par héritage de créer nos propres types de nombres.

TP: Reconnaissance d'interfaces

Je vous propose dans ce TP de nous intéresser à la reconnaissance d’interfaces comme nous l’avons fait dans ce chapitre. Nous allons premièrement écrire une classe Interface, héritant de abc.ABC, qui permettra de vérifier qu’un type implémente un certain nombre de méthodes. Cette classe sera destinée à être héritée pour spécifier quelles méthodes doivent être implémentées par quels types. On trouvera par exemple une classe Container héritant d’Interface pour vérifier la présence d’une méthode __contains__.

Les méthodes nécessaires pour se conformer au type seront inscrites dans un attribut de classe __methods__. Notre classe Interface définira la méthode __subclasshook__ pour s’assurer que toutes les méthodes de la séquence __methods__ sont présentes dans la classe.

La méthode __subclasshook__ se déroulera en 3 temps :

  • Premièrement, appeler l’implémentation parente via super, et retourner False si elle a retourné False. En effet, si la classe parente dit que le type n’est pas un sous-type, on est sûr qu’il n’en est pas un. Mais si la méthode parente retourne True ou NotImplemented, le doute peut persister ;
  • Dans un second temps, nous récupérerons la liste de toutes les méthodes à vérifier. Il ne s’agit pas seulement de l’attribut __methods__, mais de cet attribut ainsi que celui de toutes les classes parentes ;
  • Et finalement, nous testerons que chacune des méthodes est présente dans la classe, afin de retourner True si elle le sont toutes, et NotImplemented sinon.

Le deuxième point va nous amener à explorer le MRO, à l’aide de la méthode de classe mro, et de concaténer les attributs __methods__ de toutes les classes (via la fonction sum). Afin de toujours récupérer une séquence, nous utiliserons getattr(cls, '__methods__', ()), qui nous retournera un tuple vide si l’attribut __methods__ n’est pas présent.

Quant au 3ème point, la builtin all va nous permettre de vérifier que chaque nom de méthode est présent dans la classe, et qu’il s’agit d’un callable et donc d’une méthode.

Notre classe Interface peut alors se présenter comme suit.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import abc

class Interface(abc.ABC):
    # Attribut `__methods__` vide pour montrer sa structure
    __methods__ = ()

    @classmethod
    def __subclasshook__(cls, subcls):
        # Appel au __subclasshook__ parent
        ret = super().__subclasshook__(cls, subcls)
        if not ret:
            return ret
        # Récupération de toutes les méthodes
        all_methods = sum((getattr(c, '__methods__', ()) for c in cls.mro()), ())
        # Vérification de la présence des méthodes dans la classe
        if all(callable(getattr(subcls, meth, None)) for meth in all_methods):
            return True
        return NotImplemented

Nous pouvons dès lors créer nos nouvelles classes hérités d’Interface avec leurs propres attributs __methods__.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
class Container(Interface):
    __methods__ = ('__contains__',)

class Sized(Interface):
    __methods__ = ('__len__',)

class SizedContainer(Sized, Container):
    pass

class Subscriptable(Interface):
    __methods__ = ('__getitem__',)

class Iterable(Interface):
    __methods__ = ('__iter__',)

Et qui fonctionnent comme prévu.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
>>> isinstance([], Iterable)
True
>>> isinstance([], Subscriptable)
True
>>> isinstance([], SizedContainer)
True
>>> gen = (x for x in range(10))
>>> isinstance(gen, Iterable)
True
>>> isinstance(gen, Subscriptable)
False
>>> isinstance(gen, SizedContainer)
False

Pour terminer, un dernier tour par la documentation et ses pages intéressantes.