La programmation orientée objet

La programmation orientée objet (POO) est un paradigme, c’est-à-dire une façon particulière de concevoir des systèmes logiciels. La POO envisage un logiciel comme des interactions entre une myriade d’entités élémentaires que l’on nomme des objets. Un objet est défini par un état qui est constitué de propriétés et par des comportements que l’on appelle des méthodes.

Un des apports importants de la POO est de permettre de concevoir un système logiciel en des termes directement issus de la problématique que le système est sensé résoudre. Par exemple, si le système est sensé gérer des comptes utilisateur, il est possible de créer des objets qui modélisent le comportement et les états d’un compte utilisateur. Par exemple, l’état d’un compte utilisateur pourra inclure un numéro de compte et une date de création. Il pourra avoir des comportements permettant de le verrouiller ou de le valider.

La POO est donc une façon de répondre à une problématique avec un modèle qui est plus proche des utilisateurs que de la représentation interne de l’information. Il est également plus aisé d’appréhender des systèmes complexes comme étant des composés d’objets aux comportements plus simples.

La classe et les instances

Un objet suit un modèle (un patron) qui définit ses propriétés et ses comportements. Ce modèle est appelé une classe et se définit en Python avec le mot-clé class.

class MaClasse:
    pass

Une classe est définie par un nom et un bloc précisant la définition de la classe. Si on ne veut rien préciser de particulier dans le bloc, il faut utiliser le mot-clé pass.

Note

En Python, le nom des classes commence par convention par une lettre en majuscule. Les différents mots sont également indiqués par une lettre majuscule suivant le principe de l’écriture en dromadaire (camel case). Cette convention a évolué au cours du temps et donc il est possible de trouver des anciennes classes dont le nom commence par une lettre minuscule.

Pour créer un objet, il suffit d’appeler la classe :

mon_objet = MaClasse()

La variable mon_objet référence l’objet crée. On dit que l’objet est une instance de MaClasse. Une classe définit un nouveau type dans le langage. La POO est d’abord un modèle de programmation qui permet d’ajouter dans le langage des nouveaux types d’objet. En Python, il existe deux fonctions standards intéressantes pour connaître et tester le type d’un objet :

type(o)

Retourne le type de l’objet passé en paramètre.

>>> type(mon_objet)
<class '__main__.MaClasse'>
isinstance(o, cls)

Retourne True si l’objet passé en premier paramètre est une instance de la classe passée en deuxième paramètre.

>>> isinstance(mon_objet, MaClasse)
True

Note

Nous verrons dans un chapitre ultérieur, que pour Python, tout est objet. Ainsi les fonctions type(o) et isinstance(o, cls) sont utilisables même avec des nombres :

>>> nombre = 1
>>> type(nombre)
<class 'int'>
>>> isinstance(nombre, int)
True

Les attributs

Un objet est défini par son état interne formé par l’ensemble de ses attributs. Supposons que nous voulions représenter la notion de vecteur dans un espace à deux dimensions dans notre système. Nous allons déclarer une classe Vecteur.

class Vecteur:
    pass

Nous pouvons maintenant créer autant de vecteurs que nous le souhaitons en leur attribuant à chacun une valeur pour ses attributs x et y :

vec1 = Vecteur()
vec1.x = 2
vec1.y = 3

vec2 = Vecteur()
vec2.x = -12
vec2.y = vec1.x

print(f"Vecteur 1 : ({vec1.x}, {vec1.y})")
# Affiche Vecteur 1 : (2, 3)
print(f"Vecteur 2 : ({vec2.x}, {vec2.y})")
# Affiche Vecteur 2 : (-12, 2)

L’opérateur . permet d’accéder à un attribut, soit pour le consulter, soit pour le modifier. Chaque objet possède ses propres valeurs pour ses attributs. C’est pour cela que l’on dit que les attributs permettent de définir l’état interne de l’objet.

Prudence

Si vous essayez d’accéder à un attribut qui n’existe pas pour en consulter la valeur, l’opération échoue avec une erreur de type AttributeError.

>>> vec3 = Vecteur()
>>> vec3.x = 1
>>> vec3.y = 2
>>> x = vec3.x
>>> y = vec3.y
>>> z = vec3.z
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'Vecteur' object has no attribute 'z'

On peut supprimer un attribut grâce au mot-clé del.

>>> vec = Vecteur()
>>> vec.x = 1
>>> vec.y = -3
>>> del vec.x
>>> vec.x
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'Vecteur' object has no attribute 'x'

Il est possible pour un programme de découvrir et de manipuler les attributs d’un objet grâce aux fonctions hasattr(o, n), getattr(o, n), setattr(o, n, v) et delattr(o, n) qui permettent respectivement de savoir si un objet dispose d’un attribut, d’obtenir la valeur de cet attribut, de positionner la valeur d’un attribut et de supprimer un attribut.

>>> vec = Vecteur()
>>> hasattr(vec, "x")
False
>>> setattr(vec, "x", 1)
>>> hasattr(vec, "x")
True
>>> getattr(vec, "x")
1
>>> delattr(vec, "x")
>>> hasattr(vec, "x")
False

L’intérêt de ces fonctions est qu’elles permettent de manipuler un attribut en donnant son nom sous la forme d’une chaîne de caractères. Cela ouvre la possibilité à un programme d’accéder aux attributs sans avoir réellement besoin de disposer de la déclaration de la classe au moment de l’écriture du programme. En Python, il est donc possible de manipuler les types comme des données et on parle alors de programmation réflexive ou de réflexivité.

Note

La fonction getattr(o, n) accepte également un troisième paramètre qui donne la valeur à retourner si l’attribut n’existe pas plutôt que de produire une erreur de type AttributeError.

>>> vec = Vecteur()
>>> getattr(vec, "attribut_qui_n_existe_pas", "Valeur par défaut")
'Valeur par défaut'

Les attributs implicites

Il existe des attributs implicites (c’est-à-dire pour lesquels il n’est pas nécessaire de fournir une valeur) pour chaque objet.

__doc__

Cet attribut contient la documentation de la classe. Pour renseigner cet attribut, il suffit d’ajouter une chaîne de caractères directement après la ligne de déclaration du nom de la classe :

class Vecteur:
    """Un vecteur à deux dimensions"""
    pass

La documentation peut être affichée dans la console Python grâce à la fonction help(). Pour sortir du mode d’aide, il faut généralement appuyer sur la touche Q (pour quit).

>>> v = Vecteur()
>>> help(v)

On peut aussi simplement accéder à l’attribut __doc__ :

>>> v.__doc__
'Un vecteur à deux dimensions'

__class__

Cet attribut contient l’objet qui décrit la classe d’un objet. En Python même les classes sont représentées par des objets ! C’est cet attribut qui est notamment utilisé pour les fonctions type() et isinstance().

__module__

Cet attribut contient le nom du module auquel l’objet appartient.

__dict__

Cet attribut est le dictionnaire des attributs de l’objet.

>>> v = Vecteur()
>>> v.x = 2
>>> v.y = 3
>>> v.__dict__
{'y': 3, 'x': 2}

Cet attribut est notamment utilisé par la fonction vars() qui permet de créer un dictionnaire à partir d’un objet :

>>> vars(v)
{'y': 3, 'x': 2}

Cet attribut permet également de mettre à jour tous les attributs à partir d’un dictionnaire.

>>> v = Vecteur()
>>> v.__dict__ = {'y': 8, 'x': 3}
>>> v.x
3
>>> v.y
8

En Python, il est donc assez facile de passer d’un objet à un dictionnaire et d’un dictionnaire à un objet.

Les méthodes

Les méthodes représentent les comportements des objets. Elles sont décrites dans la classe en suivant les mêmes règles d’écriture que les fonctions.

Si on désire ajouter la possibilité de calculer la norme du vecteur, on peut ajouter la méthode calculer_norme.

import math

class Vecteur:

    def calculer_norme(self):
        return math.sqrt(self.x**2 + self.y**2)

Pour calculer la norme d’un vecteur, nous avons besoin de la fonction sqrt() que nous obtenons en important le module math. La méthode calculer_norme prend un premier paramètre qui est appelé par convention self. self représente l’objet dans le corps de la méthode. Il est donc possible d’accéder aux attributs self.x et self.y pour réaliser le calcul :

>>> v = Vecteur()
>>> v.x = 2
>>> v.y = 3
>>> v.calculer_norme()
3.605551275463989

Pour appeler une méthode, on utilise l’opérateur . entre la variable qui désigne l’objet et la méthode.

Note

Pour simplifier, nous pourrions dire qu’une méthode est une fonction qui appartient à l’espace de nom de la classe et dont le premier paramètre est l’objet lui-même. Ainsi l’écriture avec l’opérateur . est une manière plus simple et raccourcie d’appeler une méthode mais nous pouvons tous aussi bien écrire en Python :

>>> v = Vecteur()
>>> v.x = 2
>>> v.y = 3
>>> Vecteur.calculer_norme(v)
3.605551275463989

On comprend mieux ainsi la présence obligatoire du paramètre self dans la déclaration d’une méthode pour représenter l’objet.

En plus du paramètre self, une méthode peut avoir d’autres paramètres exactement comme une fonction. Nous pouvons ainsi ajouter une méthode pour calculer le produit scalaire entre le vecteur courant et un autre vecteur passé en paramètre :

import math

class Vecteur:

    def calculer_norme(self):
        return math.sqrt(self.x**2 + self.y**2)

    def calculer_produit_scalaire(self, v):
        return self.x * v.x + self.y * v.y
>>> v1 = Vecteur()
>>> v1.x = 2
>>> v1.y = 3
>>> v2 = Vecteur()
>>> v2.x = 1
>>> v2.y = -1
>>> v1.calculer_produit_scalaire(v2)
-1

Une méthode peut se contenter de modifier l’état interne d’un objet (ses attributs) et ainsi agir comme une procédure. Par exemple, la méthode normaliser pour la classe Vecteur ne produit pas de résultat mais modifie les attributs x et y pour garantir que la norme du vecteur est 1.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
import math

class Vecteur:

    def calculer_norme(self):
        return math.sqrt(self.x**2 + self.y**2)

    def calculer_produit_scalaire(self, v):
        return self.x * v.x + self.y * v.y

    def normaliser(self):
        norme = self.calculer_norme()
        self.x /= norme
        self.y /= norme

Note

Notez comment la méthode normaliser() appelle la méthode calculer_norme() à la ligne 12.

>>> v = Vecteur()
>>> v.x = 2
>>> v.y = 3
>>> v.normaliser()
>>> v.x
0.5547001962252291
>>> v.y
0.8320502943378437
>>> v.calculer_norme()
1.0

Le constructeur

L’implémentation de notre classe Vecteur a un inconvénient majeur. Toutes ses méthodes utilisent les attributs x et y pour réaliser leur traitement. Que se passe-t-il si on oublie de donner des valeurs pour les attributs x et y ?

>>> v = Vecteur()
>>> v.calculer_norme()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
    return math.sqrt(self.x**2 + self.y**2)
AttributeError: Vecteur instance has no attribute 'x'

Dans l’exemple ci-dessus, l’appel à la méthode calculer_norme() produit une erreur de type AttributeError car l’objet n’a pas les attributs attendus. Cela signifie que si un développeur veut utiliser correctement la classe Vecteur, il doit affecter une valeur pour les attributs x et y pour chacun des objets de type Vecteur.

Un constructeur est une méthode spéciale qui est appelée au moment de la création de l’objet. Il permet de garantir que l’objet est dans un état cohérent dès sa création. En Python, le constructeur s’appelle __init__() est prend comme premier paramètre l’objet en cours de création.

import math

class Vecteur:

    def __init__(self):
        self.x = 0
        self.y = 0

    def calculer_norme(self):
        return math.sqrt(self.x**2 + self.y**2)

    def calculer_produit_scalaire(self, v):
        return self.x * v.x + self.y * v.y

    def normaliser(self):
        norme = self.calculer_norme()
        self.x /= norme
        self.y /= norme

Le constructeur de notre classe Vecteur positionne à 0 les attributs nécessaires aux objets de ce type. Nous avons maintenant corrigé notre problème.

>>> v = Vecteur()
>>> v.x
0
>>> v.y
0
>>> v.calculer_norme()
0

Même si un constructeur est une méthode un peu particulière, il peut accepter autant de paramètres que nécessaire. La valeur de ces paramètres est donnée au moment de la création de l’objet.

import math

class Vecteur:

    def __init__(self, x=0, y=0):
        self.x = x
        self.y = y

    def calculer_norme(self):
        return math.sqrt(self.x**2 + self.y**2)

    def calculer_produit_scalaire(self, v):
        return self.x * v.x + self.y * v.y

    def normaliser(self):
        norme = self.calculer_norme()
        self.x /= norme
        self.y /= norme
>>> v = Vecteur()
>>> v.x, v.y
(0, 0)
>>> v = Vecteur(1, 2)
>>> v.x, v.y
(1, 2)
>>> v = Vecteur(y=6, x=12)
>>> v.x, v.y
(12, 6)

Les propriétés

Les attributs permettent de définir l’état d’un objet mais il existe d’autres données qui peuvent être représentatives de cet état. Pour reprendre notre exemple du vecteur, la norme d’un vecteur est également une donnée qui est simplement calculée à partir des coordonnées x et y. L’ensemble de ces données sont appelées les propriétés de l’objet. Si on souhaite créer une propriété calculée, on peut le faire grâce au décorateur @property.

import math

class Vecteur:

    def __init__(self, x=0, y=0):
        self.x = x
        self.y = y

    @property
    def norme(self):
        return math.sqrt(self.x**2 + self.y**2)

    def calculer_produit_scalaire(self, v):
        return self.x * v.x + self.y * v.y

    def normaliser(self):
        norme = self.norme
        self.x /= norme
        self.y /= norme

En transformant le calcul de la norme en une propriété norme, il est possible d’y accéder comme s’il s’agissait d’un attribut :

>>> v = Vecteur(2, 3)
>>> v.norme
3.605551275463989

Par défaut, une propriété créée grâce à ce décorateur n’est pas modifiable. Pour indiquer les méthodes qui doivent être appelées si on souhaite modifier ou supprimer la propriété, il faut utiliser le même nom de méthode et ajouter des décorateurs de la forme <nom>.setter et <nom>.deleter.

class Personne:

    def __init__(self, prenom, nom):
        self.prenom = prenom
        self.nom = nom

    @property
    def nom_complet(self):
        """Prénom et nom de la personne"""
        return f"{self.prenom} {self.nom}"

    @nom_complet.setter
    def nom_complet(self, nom_complet):
        self.prenom, self.nom = nom_complet.split(maxsplit=1)

    @nom_complet.deleter
    def nom_complet(self):
        self.nom = self.prenom = ""
>>> p = Personne("David", "Gayerie")
>>> p.nom_complet
'David Gayerie'
>>> p.nom_complet = "Eric Gayerie"
>>> p.prenom
'Eric'
>>> del p.nom_complet
>>> p.nom_complet
' '

Les attributs de classe

Une classe peut également avoir des attributs. Pour cela, il suffit de les déclarer dans le corps de la classe. Les attributs de classe sont accessibles depuis la classe elle-même et sont partagés par tous les objets. Si un objet modifie un attribut de classe, cette modification est visible de tous les autres objets.

class ClasseAvecCompteur:

    nb_instances = 0

    def __init__(self):
        ClasseAvecCompteur.nb_instances += 1
>>> ClasseAvecCompteur.nb_instances
0
>>> c1 = ClasseAvecCompteur()
>>> c2 = ClasseAvecCompteur()
>>> c3 = ClasseAvecCompteur()
>>> ClasseAvecCompteur.nb_instances
3

Dans cet exemple simple, l’attribut de classe nb_instances est incrémenté à chaque fois que le constructeur de la classe est appelé. Donc cela permet de compter le nombre d’objets de ce type créés par le programme.

Prudence

Notez comment l’attribut de classe nb_instances est modifié dans le constructeur de la classe ClasseAvecCompteur. On utilise le nom de la classe et non self car sinon il s’agirait de l’attribut de l’objet. Il est possible d’utiliser self pour lire le contenu d’une variable de classe mais cela est fortement déconseillé car cela peut donner lieu à une ambiguïté à la lecture du code.

Les attributs de classe sont le plus souvent utilisés pour représenter des constantes.

Méthodes de classe

Tout comme il est possible de déclarer des attributs de classe, il est également possible de déclarer des méthodes de classe. Pour cela, on utilise le décorateur @classmethod. Comme une méthode de classe appartient à une classe, le premier paramètre correspond à la classe. Par convention, on appelle ce paramètre cls pour préciser qu’il s’agit de la classe et pour le distinguer de self.

class ClasseAvecCompteur:

    nb_instances = 0

    @classmethod
    def reinitialiser_compteur(cls):
        cls.nb_instances = 0

    def __init__(self):
        ClasseAvecCompteur.nb_instances += 1
>>> c1 = ClasseAvecCompteur()
>>> c2 = ClasseAvecCompteur()
>>> c3 = ClasseAvecCompteur()
>>> ClasseAvecCompteur.nb_instances
3
>>> ClasseAvecCompteur.reinitialiser_compteur()
>>> ClasseAvecCompteur.nb_instances
0

Méthodes statiques

Une méthode statique est une méthode qui appartient à la classe mais qui n’a pas besoin de s’exécuter dans le contexte d’une classe. Autrement dit, c’est une méthode qui ne doit pas prendre le paramètre cls comme premier paramètre. Pour déclarer une méthode statique, on utilise le décorateur @staticmethod. Les méthodes statiques sont des méthodes utilitaires très proches des fonctions mais que l’on souhaite déclarer dans le corps d’une classe.

class UneClasse:

    @staticmethod
    def une_methode():
        print("appel de la méthode")
>>> UneClasse.une_methode():
# Affiche appel de la méthode

Visibilité des attributs et des méthodes

Certains langages de programmation proposent des mécanismes pour gérer la visibilité des attributs et des méthodes. Il arrive souvent qu’une classe déclare des méthodes et des attributs qui ne sont pas destinés à être directement appelés par un programme. On dit qu’il s’agit de méthodes et d’attributs privés, c’est-à-dire destinés à être utilisés uniquement par l’objet lui-même. Par exemple, un objet peut stocker dans un attribut une connexion vers une système externe (base de données, serveur web…) avec pour objectif d’offrir une interaction simplifiée avec ce système. Cet attribut n’est pas sensé être accédé depuis l’extérieur de l’objet. En le rendant privé, on évite au reste du programme d’y accéder inutilement. Cela permet également une meilleure évolutivité du programme en évitant de trop exposer les données internes d’un objet. C’est ce que l’on appelle le principe d’encapsulation.

En Python, il n’existe pas de mécanisme dans le langage qui nous permettrait de gérer la visibilité. Par contre, il existe une convention dans le nommage. Une méthode ou un attribut dont le nom commence par _ (underscore) est considéré comme privé. Il est donc déconseillé d’accéder à un tel attribut ou d’appeler une telle méthode depuis l’extérieur de l’objet.

class ClientSystemeExterne:

    def ouvrir_connexion(self):
        self._connexion = ConnexionInterne()

    def fermer_connexion(self):
        if self._connexion is not None:
            self._connexion.fermer()
            self._connexion = None

Fermer la liste des attributs

Nous avons vu au début de ce chapitre que nous pouvons créer autant d’attributs que nous le souhaitons pour un objet. Cela peut conduire à des bugs ou des difficultés de compréhension du code. Si nous reprenons notre classe Vecteur, nous pouvons écrire :

1
2
3
4
>>> v = Vecteur(1, 0)
>>> v.z = 4
>>> v.norme
1.0

À la ligne 2, le programme positionne la valeur d’un nouvel attribut z. Un lecteur pourrait penser que le vecteur est en fait un vecteur à trois dimensions. Mais il n’en est rien. La classe Vecteur n’a pas été conçue pour prendre en charge cette troisième dimension et la norme du vecteur sera bien de 1.

Pour éviter ce genre de situation, il est possible déclarer la liste finie des attributs. Pour cela, on déclare une attribut de classe appelé __slots__.

import math

class Vecteur:

    __slots__ = ('x', 'y')

    def __init__(self, x=0, y=0):
        self.x = x
        self.y = y

    @property
    def norme(self):
        return math.sqrt(self.x**2 + self.y**2)

    def calculer_produit_scalaire(self, v):
        return self.x * v.x + self.y * v.y

    def normaliser(self):
        norme = self.norme
        self.x /= norme
        self.y /= norme

Si maintenant un programme essaie de positionner la valeur d’un attribut qui ne fait pas partie de la définition de la classe, l’interpréteur génère une erreur AttributeError.

1
2
3
4
5
>>> v = Vecteur(1, 0)
>>> v.z = 4
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'Vecteur' object has no attribute 'z'

Note

L’attribut __slots__ a également une utilité pour l’optimisation du code. Comme on indique à l’interpréteur le nombre exact d’attributs, il peut optimiser l’allocation mémoire pour un objet de ce type.

Créer des objets avec des attributs constants

Lorsque l’on veut créer des objets qui contiennent uniquement des attributs en lecture seule, il est possible d’utiliser la classe collections.namedtuple. Elle fournit une solution à la déclaration de constante en Python.

>>> from collections import namedtuple
>>> Intervalle = namedtuple('Intervalle', ('min', 'max'))
>>> intervalle = Intervalle(min=0, max=100)
>>> intervalle.min
0
>>> intervalle.max
100
>>> intervalle.min = 12
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: can't set attribute

La classe namedtuple attend en paramètre le nom du type à créer ainsi que la liste des noms des attributs. L’objet construit a les mêmes comportements qu’un tuple, c’est-à-dire que ses données ne peuvent pas être modifiées mais elles peuvent être accessibles comme des attributs. Si on tente de modifier la valeur d’un des attributs, cela produit une erreur de type AttributeError.