UKOnline

Programmation orientée objet

Comme on l'a déjà vu dans le chapitre précédent, en programmation orientée objet, on modélise les objets réels par des objets informatiques. Ces derniers sont l'élément constitutif de ce type de programmation. Cette section présente quelques aspects clés de la programmation orientée objet et la manière de les implémenter en Python.

Voyons avant tout deux nouveaux exemples de classe, présentées au listing de la figure 6. La classe Vector permet de représenter un vecteur dans le plan, composé de deux coordonnées $x$ et $y$. Elle propose une méthode norm permettant de calculer la norme du vecteur, qui vaut, pour rappel, $\sqrt{x^2 + y^2}$. La classe Music représente une musique décrite par un titre, une liste d'artistes et une durée en secondes. Elle propose une méthode hasAuthor qui teste si un artiste spécifié fait partie des artistes de la musique en question.

Le fichier classexamples.py contient deux définitions de classe : Vector représente un vecteur dans le plan et Music représente une musique.

Une fois définies, on peut utiliser ces classes pour en créer des instances. On peut, par exemple, écrire les instructions suivantes :

On suppose que ces instructions sont placées dans un fichier se trouvant dans la même dossier que le fichier classexamples.py, permettant ainsi l'importation des deux classes depuis le module classexamples.

L'exécution de ces instructions affiche donc d'abord la norme du vecteur $\overrightarrow{u} = (1, -1)$ qui vaut $\sqrt{2} \approx 1.4142...$ et teste ensuite si Stromae est un artiste de la musique Easy Come Easy Go ou non, et comme ce n'est pas le cas, affiche False :

1.4142135623730951
False

Représentation d'un objet

Comme on l'a vu précédemment, si on tente d'afficher un objet à l'aide de la fonction print, le résultat montrera le type d'objet ainsi que l'adresse mémoire où il se situe, comme on le voit sur le résultat de l'exécution des instructions suivantes :

<classexamples.Vector object at 0x102898b00>

Ce comportement par défaut n'est pas forcément des plus utiles. Ce qui serait plus intéressant serait de pouvoir obtenir une représentation de l'objet sous forme d'une chaine de caractères. Cette dernière pourrait contenir l'état ou une partie de l'état de l'objet.

En Python, il suffit d'ajouter une méthode nommée __str__ sans paramètre (à part l'obligatoire self puisque c'est une méthode) qui renvoie une chaine de caractères. Voici, par exemple, ce que pourrait être cette méthode pour la classe Vector :

On construit donc une chaine de caractères par concaténation, qui contient les valeurs des coordonnées $x$ et $y$ séparées par une virgule et le tout entouré de parenthèses. Si on exécute de nouveau l'exemple précédent, on obtient maintenant :

(1, -1)

L'idée derrière la méthode __str__ est donc de proposer une représentation textuelle de l'objet. De manière brute, on pourrait se contenter d'inclure les valeurs de toutes les variables d'instance, mais il est évidemment préférable de construire une représentation qui fait sens, par rapport à l'objet représenté.

Par exemple, pour un objet Music, on peut se limiter à inclure le titre de la musique et la liste des artistes, sans utiliser la durée. On obtiendrait ainsi la méthode suivante :

L'exemple suivant, et le résultat de son exécution, montrent ce qu'on obtient avec cette méthode __str__ :

"Si demain" par Bonnie Tyler, Kareen Antonn

Surcharge d'opérateur

Revenons maintenant sur la classe Vector. Une opération que l'on doit pouvoir faire consiste à additionner deux vecteurs entre eux. Le résultat de l'opération est un nouveau vecteur dont les composantes sont les sommes des composantes des vecteurs additionnés. Ajoutons pour cela une méthode add dans la classe Vector. Elle prend un vecteur en paramètre, l'additionne avec celui représenté par l'objet cible et renvoie un nouveau vecteur correspondant au résultat de cette somme :

La méthode renvoie donc un nouvel objet de type Vector dont les composantes $x$ et $y$ s'obtiennent en faisant la somme des composantes de l'objet représenté par self (le vecteur de l'objet cible) avec celles de l'objet représenté par other (le vecteur reçu en paramètre).

Une fois cette méthode ajoutée à la classe, on peut l'utiliser pour additionner deux vecteurs et, par exemple, écrire :

L'exécution de ces trois instructions affiche :

(3, 1)

Un nouvel objet Vector a donc bien été créé, correspondant à la somme des vecteurs $\overrightarrow{u} = (1, -1)$ et $\overrightarrow{v} = (2, 2)$. Comme l'opération que l'on a définie représente une addition, ce serait bien de pouvoir utiliser l'opérateur d'addition (+). En Python, il suffit de définir une méthode __add__ qui accepte un paramètre qui est le vecteur à additionner. On remplace donc simplement la méthode add précédemment définie par :

Grâce à cela, on peut maintenant utiliser directement l'opérateur d'addition pour sommer deux vecteurs :

Cette capacité du langage Python s'appelle la surcharge d'opérateur. La figure 7 reprend les différents opérateurs qu'il est possible de surcharger, avec le nom de la méthode à utiliser.

Opérateur Notation Méthode à définir
Signe positif + __pos__
Signe négatif - __neg__
Addition + __add__
Soustraction - __sub__
Multiplication * __mul__
Division / __truediv__
Exponentiation ** __pow__
Division entière // __floordiv__
Reste de la division entière % __mod__
Égal == __eq__
Différent != __ne__
Strictement plus petit < __lt__
Plus petit ou égal <= __le__
Strictement plus grand > __gt__
Plus grand ou égal >= __ge__
« non » logique not __not__
« et » logique and __and__
« ou » logique or __or__
On peut utiliser plusieurs opérateurs sur des objets nouvellement définis en les surchargeant dans la classe qui définit ces objets, c'est-à-dire en définissant une méthode avec un nom particulier.

Égalité

On a précédemment vu qu'on pouvait vouloir comparer les états ou les identités de deux objets. La comparaison des états se fait avec l'opérateur d'égalité (==) et celle des identités avec l'opérateur d'identité (is).

L'exemple suivant crée deux objets Vector distincts en mémoire, c'est-à-dire qu'ils ont des identités différentes. L'utilisation de is doit donc renvoyer False. Néanmoins, les deux objets représentent exactement le même vecteur, à savoir $(1, 1)$. Ils ont donc le même état et l'utilisation de == doit renvoyer True.

Le code suivant et le résultat de son exécution montrent un souci par rapport aux valeurs attendues pour l'utilisation de is et == :

False
False

Que s'est-il passé ? On a défini un nouveau type d'objet, représentant des vecteurs, mais on n'a jamais décrit ce que ça signifie pour deux objets Vector d'être égaux. Par défaut, l'opérateur == se comporte comme l'opérateur is et compare les identités. Pour définir ce que signifie l'égalité de deux objets, il faut redéfinir l'opérateur == en définissant une méthode __eq__. Dans notre cas, deux vecteurs sont égaux s'ils ont les mêmes valeurs pour leurs composantes respectives. On ajoute donc la méthode suivante dans la classe Vector :

Si on exécute de nouveau l'exemple précédent, on obtient cette fois-ci le résultat attendu :

True
False

Encapsulation

L'implémentation de la classe Vector qu'on a faite précédemment utilise deux variables d'instance pour stocker les composantes $x$ et $y$. Ce n'est évidemment pas la seule possibilité pour implémenter cette classe. On pourrait par exemple utiliser un tuple de deux éléments :

La méthode norm a dû être changée, par rapport à la précédente version, mais elle calcule toujours la même valeur et est toujours appelée de la même manière.

De plus, on constate qu'il y a une différence à faire entre les attributs d'un objet « vus de l'extérieur » (deux coordonnées $x$ et $y$) et les variables d'instance qu'il contient (une seule dans ce cas-ci).

Les détails d'implémentation, à savoir utiliser deux nombres ou un tuple en variables d'instance dans cet exemple, sont cachés de « l'extérieur ». Le code qui utilise la classe Vector ne doit donc pas être changé si on décide de changer la manière avec laquelle on stocke les données concernant le vecteur dans l'objet représentant ce dernier. Ce principe fondamental de la programmation orientée objet est appelé encapsulation.

L'encapsulation consiste à cacher les détails d'implémentation d'une classe, et ne pas les dévoiler en dehors de cette dernière. Le code qui utilise une classe doit être le plus indépendant possible de la manière avec laquelle la classe est implémentée. Suivant ce principe, il faut donc éviter, dans la mesure du possible, d'accéder directement aux variables d'instance d'un objet, en dehors du code de la classe. Prenons, par exemple, les instructions suivantes :

On souhaite translater le vecteur $\overrightarrow{u}$ d'une unité selon l'axe $x$ et stocker le vecteur résultant dans une variable v. Exécuter ce code avec la première version de la classe Vector ne posera aucun problème mais, avec la deuxième version, on fera face à une erreur d'exécution :

Traceback (most recent call last):
  File "program.py", line 2, in 
    v = Vector(u.x + 1, u.y + 1)
AttributeError: 'Vector' object has no attribute 'x'

En effet, dans la deuxième version de la classe Vector, on n'a plus de variable d'instance associée à l'attribut « extérieur » x. Il aurait fallu écrire l'instruction suivante :

Les deux versions de la classe Vector violent donc le principe d'encapsulation, car un code utilisant des objets Vector dépend fortement des choix d'implémentation de cette classe et est sensible à des modifications de cette dernière.

Accesseur

Il est recommandé de ne pas accéder directement aux variables d'instance d'un objet et de limiter les interactions avec ce dernier à des appels de méthodes. Une méthode qui renvoie la valeur d'un attribut d'un objet est un accesseur. Le nom donné à la méthode est celui que l'on souhaite pour l'attribut et cette dernière doit être définie avec la décoration property. Une décoration est une information que l'on attache à une méthode et qui se déclare avant sa définition avec une arobase (@).

On peut, par exemple, définir deux accesseurs permettant d'obtenir les coordonnées $x$ et $y$ d'un vecteur. Pour la deuxième implémentation de la classe Vector, on y ajoute les définitions suivantes :

L'attribut x est donc relié à la valeur self.coords[0] et l'attribut y à la valeur self.coords[1]. Une fois ces attributs définis, ils peuvent être utilisés en dehors de la classe, pour accéder à leurs valeurs :

On appelle donc un accesseur comme s'il s'agissait d'une variable d'instance, c'est-à-dire à l'aide de son nom. L'exécution du code affiche bien les valeurs des deux coordonnées du vecteur :

1, 2

Si on décide de modifier la manière avec laquelle on stocke les coordonnées d'un vecteur, il suffira de changer la définition des deux accesseurs. Le code qui utilise des objets Vector en passant par les accesseurs ne devra pas être modifié, l'encapsulation est respectée.

Un accesseur donne donc accès en lecture à un attribut, comme s'il s'agissait d'une variable d'instance. Il n'est par contre pas possible de modifier la valeur d'un attribut, le compilateur provoquant une erreur d'exécution si vous essayez :

Traceback (most recent call last):
  File "program.py", line 3, in 
    u.x = 42
AttributeError: can't set attribute

Mutateur

Pour pouvoir modifier la valeur d'un attribut, il va falloir définir un mutateur. Il s'agit de nouveau d'une méthode qui porte le même nom que l'attribut désiré et qu'il faut décorer avec le nom de l'attribut suivi de .setter. La méthode accepte un paramètre qui est la nouvelle valeur que l'on désire pour l'attribut.

Pour autoriser la modification des coordonnées d'un vecteur, on ajoute les deux définitions suivantes à la classe Vector :

Comme les tuples sont non modifiables, on affecte un nouveau tuple à la variable d'instance coords, en modifiant la nouvelle coordonnée $x$ ou $y$ et en reprenant l'ancienne valeur pour l'autre composante.

Une fois les mutateurs définis, on va pouvoir modifier un attribut, de nouveau comme si c'était une variable d'instance. On peut donc, par exemple, écrire :

Le résultat de l'exécution de ces trois instructions montre que l'attribut x a bien été modifié :

(42, 2)

Dans une classe, on peut soit uniquement définir un accesseur, soit définir un accesseur et un mutateur, dans lequel cas ce dernier doit être défini après l'accesseur.

Variable privée

À partir du moment où on a défini des accesseurs et des mutateurs pour les attributs, et pour respecter l'encapsulation, on ne devrait plus accéder directement aux variables d'instance en dehors du code de la classe même. Dans la version actuelle de la classe Vector, c'est toujours possible d'écrire les instructions suivantes :

Ce code n'est évidemment pas recommandé puisqu'il viole l'encapsulation. Pour bien faire, il faudrait pouvoir empêcher l'accès direct aux variables d'instance. Une variable d'instance privée ne peut être accédée que depuis le corps de la classe la contenant, à partir de self. Tout autre accès provoque une erreur lors de l'exécution.

Pour rendre une variable d'instance privée, il suffit de préfixer son nom avec __. Modifions la classe Vector en rendant la variable d'instance coords privée :

Si on tente d'accéder à la variable d'instance en dehors de la classe, on aura cette fois-ci une erreur lors de l'exécution, que ce soit un accès en lecture ou en écriture :

Traceback (most recent call last):
  File "program.py", line 4, in 
    u.__coords = (42, u.__coords[1])
AttributeError: 'Vector' object has no attribute '__coords'

Interface publique

On appelle interface publique d'un objet, l'ensemble des fonctionnalités qu'elle expose au public, c'est-à-dire qui sont accessibles à partir d'une variable contenant une référence vers l'objet. Lorsqu'on définit une nouvelle classe, il est important de bien penser cette interface publique. C'est en effet via elle que ses instances seront utilisés et, pour bien faire, elle ne devrait pas être trop souvent modifiée.

La figure 8 résume l'interface exposée par la classe Vector. On y voit tout d'abord la variable d'instance coords qui est privée, ce qu'on note avec le $-$. Viennent ensuite les deux propriétés x et y, publiques comme indiqué par le $+$. Enfin, on a la méthode publique norm() qui permet d'obtenir la norme du vecteur.

Interface publique
La classe Vector expose deux propriétés et une méthode publique et possède une variable d'instance privée.

Composition

Terminons ce chapitre sur la définition de classes avec la notion de composition. L'idée derrière cette notion clé de la programmation orientée objet consiste à construire des objets à partir d'autres objets. Partons d'un exemple pour comprendre cette notion. On va définir une classe qui représente un carré dessiné dans le plan :

Un carré est caractérisé par la coordonnée dans le plan de son coin inférieur gauche, par la longueur de ses côtés et par l'angle qu'il forme avec l'horizontale comme on peut le voir avec le dessin d'une instance de la classe sur la figure 9.

Instance d'un carré
Un carré dans le plan est caractérisé par les coordonnées de son coin inférieur gauche, la longueur de ses côtés et l'angle qu'il forme avec l'horizontale.

Au lieu de représenter les coordonnées de son coin inférieur gauche avec deux valeurs $x$ et $y$, on va plutôt utiliser un objet Vector puisqu'on a déjà défini la classe correspondante. Une instance de la classe Square est donc composée à partir d'une instance de Vector. Voici comment on procède pour construire un objet Square :

On voit donc bien que le premier paramètre passé au constructeur de la classe Square est un objet Vector, le deuxième est la longueur des côtés et on a laissé la valeur par défaut pour l'angle.

Cette façon de travailler permet donc de réutiliser du code existant et évite de la duplication de code. De plus, on va pouvoir utiliser automatiquement tout ce qui est défini pour les objets Vector, comme la méthode norm, par exemple. Ainsi, on pourrait sans problème écrire le code suivant :

On part donc de la variable s qui contient une référence vers un objet Square. Via l'un de ses accesseurs, on obtient une référence vers l'objet Vector qui représente les coordonnées du coin inférieur gauche. Comme c'est une instance de la classe Vector, on peut appeler la méthode norm pour obtenir la norme de ce vecteur et l'afficher pour obtenir :

2.23606797749979