Cet article est la suite de la série Notions Avancées - Collection, n'hésitez pas à aller voir les autres articles de la série si vous les avez raté !
Aujourd'hui, nous allons voir de nouvelles notions de programmation orientées objet, notamment les classes abstraites, puis nous verrons également comment faire des classes plus complexes avec des méthodes de classe, des méthodes statiques, ou encore des méthodes d'accès aux attributs.
Les classes abstraites sont au cœur de la programmation orientée objet. Ce sont des classes qui ne sont pas instanciables, et qui doivent forcément être dérivées pour être utilisables. Cela vous apparaît peut-être comme une contrainte, mais c'est en fait tout à fait logique et bien pratique. On va prendre un exemple concret, vous allez comprendre.
Imaginons que vous souhaitez modéliser des animaux. Il existe une multitude d'animaux, par exemple les chiens, les chats, les éléphants, les vautours, les lézards, etc... Ces animaux partagent évidemment des choses en commun. Ils peuvent par exemple se déplacer, manger, communiquer. Ils ont également un certain nombre de pattes, d'yeux, une certaine couleur.
Si vous avez suivi l'article Bases de Python - Programmation Orientée Objet (POO), vous pensez tout naturellement à faire une classe de base Animal
, que vous allez dériver en une classe pour chaque animal. Cela est correct ! En revanche, la classe Animal
doit être abstraite. En effet, cette classe ne doit pas être instanciable, on ne peut pas créer un Animal
, ça n'a pas de sens. En revanche, on peut créer un Chien
, c'est un animal qui existe. Par exemple, si on avait choisi de modéliser des véhicules, on n'aurait pas pu créer un objet Véhicule
, ça n'existe pas un véhicule sans spécification particulière. Par contre, un Train
, un Avion
, une Voiture
, etc... sont des objets qui existent.
L'abstraction permet donc de définir une classe générique, qui sert de base à d'autres classes similaires.
On va essayer de coder notre exemple des animaux. En fait, on sait déjà quasiment tout faire, puisque vous avez normalement déjà des notions d'héritage. Ce qu'il faut faire en plus, c'est rendre notre classe abstraite, et définir des méthodes abstraites. Les méthodes abstraites sont des méthodes qui ne sont pas implémentées dans la classe abstraite, mais qui DOIVENT l'être dans les classes enfant. Si elles ne sont pas implémentées, il sera impossible de créer un objet de la classe enfant ne l'implémentant pas.
On va commencer par créer notre classe Animal
, en précisant à Python qu'elle est abstraite. Pour cela, il faut la faire hériter de la classe abc.ABC
(qui est donc présente dans le module abc
, présent dans la bibliothèque standard de Python).
import abc
class Animal(abc.ABC):
def __init__(self, nb_pattes, nb_yeux, cri):
self.nb_pattes = nb_pattes
self.nb_yeux = nb_yeux
self.cri = cri
animal = Animal(4, 2, "groarrr")
Voilà, notre classe est abstraite. En revanche, on peut l'instancier pour l'instant, ce qui n'a pas vraiment de sens. C'est car nous n'avons aucune méthode abstraite.
Quelles méthodes doivent-être abstraites dans notre exemple ? D'abord, le constructeur, car chaque animal se "construit" d'une façon spécifique. Par exemple, pour "construire" un chien, il faut lui mettre 4 pattes, 2 yeux et un museau. Pour une araignée, ce sera aussi un autre constructeur.
Pour déclarer une méthode abstraite, on utilise le décorateur abc.abstractmethod
. On va donc ajouter ce décorateur à notre constructeur :
@abc.abstractmethod
def __init__(self, nb_pattes, nb_yeux, cri):
...
Essayez maintenant d'instancier un animal, et vous verrez, votre interpréteur n'est pas d'accord et lève une erreur. C'est car il faut créer une classe dérivée d'Animal
et lui implémenter la méthode __init__
. Avant de créer une classe dérivée, on va écrire quelques autres méthodes dans notre classe Animal
.
def get_anatomy(self):
return {'nb_pattes': self.nb_pattes, 'nb_yeux': self.nb_yeux}
def crier(self):
print(self.cri)
@abc.abstractmethod
def attaquer(self):
pass
Les deux premières méthodes sont classiques, et n'auront pas besoin d'être modifiées par les classes dérivées. En revanche, regardez l'implémentation de la méthode attaquer
: elle est abstraite, donc il faudra l'implémenter dans chaque classe dérivée, et aussi elle ne fait rien. En effet, puisque cette méthode est très spécifique à chaque Animal
, on n'a pas besoin de fournir d'implémentation, et on se contente d'écrire pass
pour ne rien faire.
Notre classe Animal
est terminée, on va maintenant passer aux classes dérivées. Ici, c'est de l'héritage simple, comme vous avez déjà vu. Sauf qu'il y a des méthodes que nous sommes obligés de réécrire, sous peine de ne pas pouvoir instancier nos classes.
class Chien(Animal):
def __init__(self):
super().__init__(nb_pattes=4, nb_yeux=2, cri="waf waf")
def attaquer(self):
print("Grrrr!!!")
class Chat(Animal):
def __init__(self):
super().__init__(nb_pattes=4, nb_yeux=2, cri="miaou")
def attaquer(self):
print("Miaouuuu!!!")
On définit simplement nos animaux en implémentant toutes les méthodes nécessaires. On va tester maintenant :
chocolat = Chien()
felix = Chat()
chocolat.crier()
# waf waf
felix.attaquer()
# Miaouuuu!!!
OK ça marche !
Mais à quoi ça sert ? Si on n'avait pas précisé que la classe et ses méthodes devaient être abstraites ça aurait marché aussi ?
Alors oui, ça aurait marché. En revanche, l'abstraction permet d'être sûr qu'un animal possède toutes les méthodes dont on a besoin. Imaginons qu'on souhaite créer un zoo qui permet de stocker des animaux. A un moment, on souhaite que chaque animal du zoo crie. Eh bien, on est sûr que chaque animal va crier, puisqu'on est sûr que chaque animal possède la méthode crier
. Par contre, si cette méthode n'était pas abstraite, rien n'empêche de créer un animal qui ne la possède pas, du coup que se passe-t-il lorsqu'on veut faire crier chaque animal de notre zoo ?
class Zoo:
def __init__(self, animaux):
self.animaux = animaux
def faire_du_bruit(self):
for animal in self.animaux:
animal.crier()
chocolat = Chien()
felix = Chat()
animaux = [chocolat, felix]
zoo = Zoo(animaux)
zoo.faire_du_bruit()
# waf waf
# miaou
On est assurés que chaque animal puisse crier grâce à l'abstraction, donc peut importe les animaux qu'on met dans notre zoo, ce code ne lèvera jamais d'erreurs, voici toute la force des classes abstraites.
Avec Python, il est possible de définir des méthodes "spéciales". Vous allez comprendre ce que j'entends par "spéciales" en lisant la suite.
Une méthode de classe est une méthode qui appartient à la classe, et non pas à l'objet instancié à partir de la classe.
Par exemple, imaginons une classe Joueur
. Un joueur est sur une carte et a donc une position, tous les joueurs peuvent se déplacer n'importe où sur la carte, par contre tous les joueurs partent du même endroit. Chaque joueur aura donc une position différente, en revanche la position initiale est commune à chaque joueur, puisqu'ils partent tous du même point. Dans ce cas, la position initiale est un attribut de classe (puisque liée à la classe), et la position est un attribut d'instance (puisque liée à l'objet).
Pour les méthodes, c'est la même chose, on peut créer des méthodes d'instances, et des méthodes de classe, qui opèrent non pas sur l'instance, mais sur la classe. Dans ce cas, on peut appeler les méthodes sans instancier d'objet.
Pour déclarer un attribut de classe, on le déclare simplement dans le corps de la classe, en dehors de toute méthode (y compris le constructeur du coup). Pour déclarer une méthode de classe, on utilise le décorateur @staticmethod, dans ce cas le premier paramètre de notre fonction est automatiquement la classe (de la même manière que le premier paramètre d'une méthode d'instance est self
)
Voici notre exemple avec les joueurs en Python :
class Joueur:
x_position = 0
y_position = 0
def marcher(self, x, y):
self.x_position += x
self.y_position += y
def get_position(self):
return self.x_position, self.y_position
@classmethod
def get_position_initiale(cls):
return cls.x_position, cls.y_position
joueur = Joueur()
joueur.marcher(3, 5)
print(joueur.get_position())
print(Joueur.get_position_initiale())
print(joueur.get_position_initiale())
# (3, 5)
# (0, 0)
# (0, 0)
On commence par déclarer les position en x et en y initiales, en dehors de toute méthode. Ce sont donc des attributs de classe.
Ensuite, regardez notre méthode get_position_initiale
. Elle est déclarée comme une méthode de classe, le premier paramètre est donc cls
qui est en fait notre classe. A partir de cls
, on peut donc accéder à n'importe quelle méthode ou attribut de classe. Dans ce cas, on accède à nos positions initiales.
Finalement, regardez la méthode get_position
. Le code est exactement le même que celui de get_position_initiale
, sauf que ce n'est pas une méthode de classe, et que le premier paramètre est self
, donc l'instance de la classe. Quand on utilise cette méthode, on récupère donc la position de notre objet, en revanche avec l'autre méthode, on récupère la position initiale.
On peut très bien utiliser notre méthode de classe avec une instance, ou avec notre classe directement, c'est ce que j'ai fait dans le test.
En fait, les méthodes de classe permettent de partager un état et des méthodes communes entre les instances. Regardez cet exemple :
class Counter:
instances = 0
def __init__(self):
self._add_instance()
@classmethod
def _add_instance(cls):
cls.instances += 1
counter1 = Counter()
counter2 = Counter()
print(counter1.instances)
print(counter2.instances)
# 2
# 2
On déclare une méthode de classe qui ajoute 1 à un attribut de classe lorsqu'une nouvelle instance est créée. Cela nous permet en fait de compter les instances d'une classe. Regardez notre test : la variable instances
s'est mise à jour pour nos deux instances, même après la création de counter1
. C'est normal, car la variable appartient à la classe, et non aux instances, donc quand on la met à jour, elle est mise à jour dans la classe, et les instances se contentent d'accéder à cette variable. On partage donc un état commun entre toutes nos instances.
Les méthodes statiques sont des méthodes n'appartenant pas non plus aux instances. La différence avec les méthodes de classe, c'est que ces méthodes n'ont aucune connaissance de la classe. En fait, c'est comme si ces méthodes étaient déclarées en dehors de la classe et ne savaient pas que la classe existait.
Mais quelle utilité ?
Pour des raisons de logique et d'organisation du code, il peut être utilise de se servir de telles méthodes. Pour les déclarer, on utilise le décorateur @staticmethod. Voici un exemple :
class Person():
def __init__(self, name, age):
self.name = name
self.age = age
@staticmethod
def from_csv_row(row):
return Person(row[0], row[1])
Cela nous permet de créer des objets sans passer par le constructeur. Dans ce cas, l'utilisation de @staticmethod est donc judicieuse. On aurait également pu faire avec @classmethod, mais on n'a pas besoin de connaître la classe dans notre cas, donc on peut utiliser @taticmethod.
On peut donc écrire ceci :
with open('input_data.csv', newline='') as f:
for row in f:
p = Person.from_csv_row(row)
L'utilisation de @staticmethod est assez controversée en Python. On préfère généralement utiliser des méthodes de classe plutôt que des méthodes statiques, car si une méthode est statique, cela signifie potentiellement qu'elle n'a rien à faire dans la classe. Je ne suis pas d'accord avec ça, je trouve que cela a du sens de faire appartenir des méthodes à une classe sans qu'elles n'aient besoin de modifier son état, et je trouve que cela permet une bonne organisation du code, plutôt que de s'efforcer à externaliser les méthodes statiques en dehors de la classe, voire dans d'autres modules.
Les dernières méthodes que nous allons voir sont les méthodes de propriété. Elles permettent de définir des comportements particuliers lorsqu'on souhaite accéder ou modifier un attribut d'une classe. Imaginons une classe Rectangle
. Un rectangle possède une longueur et une largeur. Sauf que ces valeurs doivent être positives, en effet ça n'a pas de sens de définir un rectangle avec une longueur ou une largeur négative. On va donc définir des propriétés qui vont lever une erreur si les dimensions sont négatives.
Pour définir une propriété qui permet d'accéder à l'attribut, on utilise le décorateur @property. Pour définir une propriété qui permet de modifier un attribut, on utilise le décorateur @nom_attribut.setter
Voici notre exemple du rectangle :
class Rectangle:
def __init__(self, longueur, largeur):
self.longueur = longueur
self.largeur = largeur
@property
def longueur(self):
print("Récupération de la longueur")
return self._longueur
@longueur.setter
def longueur(self, value):
if value <= 0:
raise ValueError
self._longueur = value
@property
def largeur(self):
print("Récupération de la largeur")
return self._largeur
@largeur.setter
def largeur(self, value):
if value <= 0:
raise ValueError
self._largeur = value
r1 = Rectangle(3, 5)
print(r1.largeur)
r2 = Rectangle(-3, 5)
# Récupération de la largeur
# 5
# ValueError
Et voilà ! On aurait également pu utiliser des getter ou des setter, mais ils ne permettent pas de modifier le comportement en accédant à un attribut avec la notation instance.attribut.
Dans cet article, vous avez appris un nouveau concept de POO, qui est l'abstraction. Vous avez également pu découvrir de nouvelles méthodes, et comment les utiliser.
Si l'article vous a plu, n'hésitez-pas à laisser un retour en commentaire, ou en me contactant ! De même si vous avez des questions ou des interrogations à propos de l'article.
On se retrouve prochainement pour la découvert de nouvelles notions avancées de Python !