Licence CC BY-SA

Types

Il vous est peut-être arrivé de lire qu’en Python tout était objet. Il faut cependant nuancer quelque peu : tout ne l’est pas, une instruction n’est pas un objet par exemple. Mais toutes les valeurs que l’on peut manipuler sont des objets.

À quoi peut-on alors reconnaître un objet ? Cela correspond à tout ce qui peut être assigné à une variable. Ainsi, les nombres, les chaînes de caractère, les fonctions ou même les classes sont des objets. Et ce sont ici ces dernières qui nous intéressent.

Instance, classe et métaclasse

On sait que tout objet est instance d’une classe. On dit aussi que la classe est le type de l’objet. Et donc, tout objet a un type. Le type d’un objet peut être récupéré grâce à la fonction type.

1
2
3
4
5
6
>>> type(5)
<class 'int'>
>>> type('foo')
<class 'str'>
>>> type(lambda: None)
<class 'function'>

Mais si les classes sont des objets, quel est alors leur type ?

1
2
>>> type(object)
<class 'type'>

Le type d’object est type. En effet, type est un peu plus complexe que ce que l’on pourrait penser, nous y reviendrons dans le prochain chapitre.

On notera simplement qu’une classe est alors une instance de la classe type. Et qu’une classe telle que type, qui permet d’instancier d’autres classes, est appelée une métaclasse.

Instancier une classe pour en créer une nouvelle n’est pas forcément évident. Nous avons plutôt l’habitude d’hériter d’une classe existante. Mais dans les cas où nous créons une classe par héritage, c’est aussi une instanciation de type qui est réalisée en interne.

Caractéristiques des classes

Les classes (ou type objects) sont un ensemble d’objets qui possèdent quelques caractéristiques communes :

  • Elles héritent d’object (mise à part object elle-même) ;
  • Elles sont des instances plus ou moins directes de type (de type ou de classes héritant de type) ;
  • On peut en hériter ;
  • Elles peuvent être instanciées (elles sont des callables qui retournent des objets de ce type).
1
2
3
4
5
6
7
8
9
>>> int.__bases__ # int hérite d'object
(<class 'object'>,)
>>> type(int) # int est une instance de type
<class 'type'>
>>> class A(int): pass # on peut hériter de la classe int
>>> int() # on peut instancier int
0
>>> type(int()) # ce qui retourne un objet du type int
<class 'int'>

Et on observe que notre classe A est elle aussi instance de type.

1
2
>>> type(A)
<class 'type'>

Le vrai constructeur

En Python, la méthode spéciale __init__ est souvent appelée constructeur de l’objet. Il s’agit en fait d’un abus de langage : __init__ ne construit pas l’objet, elle intervient après la création de ce dernier pour l’initialiser.

Le vrai constructeur d’une classe est __new__. Cette méthode prend la classe en premier paramètre (le paramètre self n’existe pas encore puisque l’objet n’est pas créé), et doit retourner l’objet nouvellement créé (contrairement à __init__). Les autres paramètres sont identiques à ceux reçus par __init__.

C’est aussi __new__ qui est chargée d’appeler l’initialiseur __init__ (ce que fait object.__new__ par défaut, en lui passant aussi la liste d’arguments).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
>>> class A:
...     def __new__(cls):
...         print('création')
...         return super().__new__(cls)
...     def __init__(self):
...         print('initialisation')
...
>>> A()
création
initialisation
<__main__.A object at 0x7ffb15ef9048>

Nous choisissons ici de faire appel à object.__new__ dans notre constructeur (via super), mais nous n’y sommes pas obligés. Rien ne nous oblige non plus — mise à part la logique — à retourner un objet du bon type.

Le cas des immutables

La méthode __new__ est particulièrement utile pour les objets immutables. En effet, il est impossible d’agir sur les objets dans la méthode __init__, puisque celle-ci intervient après la création, et que l’objet n’est pas modifiable.

Si l’on souhaite hériter d’un type immutable (int, str, tuple), et agir sur l’initialisation de l’objet, il est donc nécessaire de redéfinir __new__. Par exemple une classe Point2D immutable, qui hériterait de tuple.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class Point2D(tuple):
    def __new__(cls, x, y):
        return super().__new__(cls, (x, y))

    @property
    def x(self):
        return self[0]

    @property
    def y(self):
        return self[1]
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
>> p = Point2D(3, 5)
>>> p.x
3
>>> p.y
5
>>> p.x = 10
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: can´t set attribute
>>> p[0] = 10
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'Point2D' object does not support item assignment

Paramètres d'héritage

Quand nous créons une classe, nous savons que nous pouvons spécifier entre parenthèses les classes à hériter.

1
2
class A(B, C): # A hérite de B et C
    pass

Les classes parentes sont ici comme les arguments positionnels d’un appel de fonction. Vous vous en doutez peut-être maintenant, mais il est aussi possible de préciser des arguments nommés.

1
2
class A(B, C, foo='bar', x=3):
    pass

Cette fonctionnalité existait déjà en Python 3.5, mais était assez étrange et se gérait au niveau de la métaclasse. La comportement est simplifié avec Python 3.6 qui ajoute une méthode spéciale pour gérer ces arguments.

Il est donc maintenant possible d’implémenter la méthode de classe __init_subclass__, qui recevra tous les arguments nommés. La méthode ne sera pas appelée pour la classe courante, mais le sera pour toutes ses classes filles.

Pour reprendre notre class Deque, nous pourrions imaginer une classe TypedDeque qui gérerait des listes d’éléments d’un type prédéfini. Nous lèverions alors une exception pour toute insertion de valeur d’un type inadéquat.

 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
class TypedDeque(Deque):
    elem_type = None

    def __init_subclass__(cls, type, **kwargs):
        super().__init_subclass__(**kwargs)
        cls.elem_type = type

    @classmethod
    def check_type(cls, value):
        if cls.elem_type is not None and not isinstance(value, cls.elem_type):
            raise TypeError('Cannot insert element of type '
                            f'{type(value).__name__} in {cls.__name__}')

    def append(self, value):
        self.check_type(value)
        super().append(value)

    def insert(self, i, value):
        self.check_type(value)
        super().insert(i, value)

    def __setitem__(self, key, value):
        self.check_type(value)
        super().__setitem__(key, value)

class IntDeque(TypedDeque, type=int):
    pass

class StrDeque(TypedDeque, type=str):
    pass
 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
>>> d = IntDeque([0, 1, 2])
>>> d.append(3)
>>> list(d)
[0, 1, 2, 3]
>>> d.append('foo')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "init_subclass.py", line 91, in append
    self.check_type(value)
  File "init_subclass.py", line 87, in check_type
    raise TypeError('Cannot insert element of type '
TypeError: Cannot insert element of type str in IntDeque
>>>
>>> d = StrDeque()
>>> d.append('foo')
>>> list(d)
['foo']
>>> d.insert(0, 5)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "init_subclass.py", line 95, in insert
    self.check_type(value)
  File "init_subclass.py", line 87, in check_type
    raise TypeError('Cannot insert element of type '
TypeError: Cannot insert element of type int in StrDeque

Le paramètre cls de la méthode __init_subclass__ correspond bien ici à la classe fille. Il convient d’utiliser super pour faire appel aux __init_subclass__ des autres parents, en leur donnant le reste des arguments nommés. On note aussi qu’__init_subclass__ étant oligatoirement une méthode de classe, l’utilisation du décorateur @classmethod est facultative.

TP : Liste immutable

Nous en étions resté sur notre liste chaînée à l’opérateur d’égalité, qui rendait la liste non-hashable. Je vous disais alors que l’on s’intéresserait à un type de liste immutable (et donc hashable) : chose promise, chose due.

Nous savons que pour créer un type immutable en Python, il faut hériter d’un autre type immutable. Par commodité, nous choisissons tuple puisqu’il nous permet en tant que conteneur de stocker des données.

Pour rappel, notre liste se compose de deux classes : Node et Deque. Nos nouvelles classes se nommeront ImmutableNode et ImmutableDeque.

ImmutableNode est un ensemble de deux éléments : le contenu du maillon dans value, et le maillon suivant (ou None) dans next. On peut aisément représenter cette classe par un tuple de deux éléments. On ajoutera juste deux propriétés, value et next, pour faciliter l’accès à ces valeurs.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class ImmutableNode(tuple):
    def __new__(cls, value, next=None):
        return super().__new__(cls, (value, next))

    @property
    def value(self):
        return self[0]

    @property
    def next(self):
        return self[1]

Passons maintenant à ImmutableDeque. Au final, il s’agit aussi d’un ensemble de deux éléments : first et last, les deux extrêmités de la liste.

Mais ImmutableDeque présente un autre défi, c’est cette classe qui est chargée de créer les maillons, qui sont ici immutables. Cela signifie que le next de chaque maillon doit être connu lors de sa création.

Pour rappel, la classe sera instanciée avec un itérable en paramètre, celui-ci servant à créer les maillons. Il nous faudra donc itérer sur cet ensemble en connaissant l’élément suivant.

Je vous propose pour cela une méthode de classe récursive, create_node. Cette méthode recevra un itérateur en paramètre, récupérera la valeur actuelle avec la fonction next, puis appelera la méthode sur le reste de l’itérateur.create_node retournera un objet ImmutableNode, qui sera donc utilisé comme maillon next dans l’appel parent. En cas de StopIteration (fin de l’itérateur), create_node renverra simplement None.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
class ImmutableDeque(tuple):
    def __new__(cls, iterable=()):
        first = cls.create_node(iter(iterable))
        last = first
        while last and last.next:
            last = last.next
        return super().__new__(cls, (first, last))

    @classmethod
    def create_node(cls, iterator):
        try:
            value = next(iterator)
        except StopIteration:
            return None
        next_node = cls.create_node(iterator)
        return ImmutableNode(value, next_node)

À la manière de notre classe ImmutableNode, nous ajoutons des propriétés first et last. Celles-ci diffèrent un peu tout de même : puisque __getitem__ sera surchargé dans la classe, nous devons faire appel au __getitem__ parent, via super.

1
2
3
4
5
6
7
@property
def first(self):
    return super().__getitem__(0)

@property
def last(self):
    return super().__getitem__(1)

Les autres méthodes (__contains__, __len__, __getitem__, __iter__ et __eq__) seront identiques à celles de la classe Deque. On prendra seulement soin, dans __getitem__, de remplacer les occurrence de Deque par ImmutableDeque en cas de slicing, ou de faire appel au type de self pour construire la nouvelle liste.

Les méthodes de modification (append, insert, __setitem__) ne sont bien sûr pas à implémenter. On remarque d’ailleurs que l’attribut last de nos listes n’a pas vraiment d’intérêt ici, puisqu’il n’est pas utilisé pour faciliter l’ajout d’élements en fin de liste.

Enfin, on peut maintenant ajouter une méthode __hash__, pour rendre nos objets hashables. Pour cela, nous ferons appel à la méthode __hash__ du parent, qui retournera le condensat du tuple.

1
2
def __hash__(self):
    return super().__hash__()

Nous pouvons maintenant passer au test de notre nouvelle classe.

 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
>>> d = ImmutableDeque(range(10))
>>> d
((0, (1, (2, (3, (4, (5, (6, (7, (8, (9, None)))))))))), (9, None))
>>> list(d)
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
>>> d[1:-1:2]
((1, (3, (5, (7, None)))), (7, None))
>>> list(d[1:-1:2])
[1, 3, 5, 7]
>>> 5 in d
True
>>> 11 in d
False
>>> len(d)
10
>>> d[0], d[1], d[5], d[9]
(0, 1, 5, 9)
>>> d == ImmutableDeque(range(10))
True
>>> d == ImmutableDeque(range(9))
False
>>> hash(d)
-9219024882206086640
>>> {d: 0}
{((0, (1, (2, (3, (4, (5, (6, (7, (8, (9, None)))))))))), (9, None)): 0}
>>> {d: 0}[d]
0

Revenons maintenant avec la documentation sur les concepts étudiés dans ce chapitre.

Un autre très bon article, qui revient sur les concepts de classe, d’instance, de métaclasse et d’héritage : http://www.cafepy.com/article/python_types_and_objects