modifié le

Concepts de la Programmation Orientée Objet

1. Introduction

Au début de l’informatique (avec les langages de programmation historiques comme Fortran ou C), un programme est une juxtaposition de données et de fonctions agissant sur ces données, les données constituant la partie passive du programme, et les fonctions la partie active.

Dans cette approche traditionnelle, largement encore utilisée de nos jours, programmer consiste alors grosso modo à :

  • initialiser un certain nombre de variables,

  • écrire des fonctions pour les manipuler,

sans particulièrement associer explicitement les unes aux autres.

Soit par exemple à modéliser le comportement d’une tortue sur un plan (ou un pointeur de souris dans une fenêtre, c’est la même chose). L’état d’une tortue se résume à sa position, soit un couple (x,y). Pour le représenter, on peut utiliser un dictionnaire {"x":x,"y":y}. C’est la partie passive de la tortue.

Une tortue peut se déplacer et donner sa position. C’est la partie active de la tortue. Elle va être implantée sous forme de 2 fonctions : avance()) et localise() :

def avance(une_tortue,dx,dy):
  """ Avance une tortue de (dx,dy) """
  une_tortue["x"] += dx
  une_tortue["y"] += dy

def localise(une_tortue):
  """ Donne la position d'une tortue """
  print(une_tortue["x"],une_tortue["y"])

Dans cette approche, exécuter un programme consiste à créer des variables et à appeler quelques fonctions, qui en appelleront de proche en proche d’autres, en leur fournissant les données nécessaires à l’accomplissement de leurs tâches.

Exemple, pour créer une tortue, écrire sa position initiale, la déplacer, et écrire sa nouvelle position :

ma_tortue = { "x": 1, "y": -1 }

localise(ma_tortue)         # (1,-1)
avance(ma_tortue,10,10)
localise(ma_tortue)         # (11,9)

2. Modulariser

On peut modulariser ce code afin d’en accroitre la réutilisabilité. En Python, on va créer un module pour y mettre les fonctions relatives à la tortue :

module tortue
# fichier tortue.py

def avance(une_tortue,dx,dy):
  une_tortue["x"] += dx
  une_tortue["y"] += dy

def localise(une_tortue):
  print(une_tortue["x"],une_tortue["y"])

On utilise alors le module comme une bibliothèque de fonctions :

programme principal
# fichier main.py

import tortue

ma_tortue = { "x": 0, "y": 0 }

tortue.localise(ma_tortue)
tortue.avance(ma_tortue,10,10)
tortue.localise(ma_tortue)

print(type(ma_tortue))               # <class 'dict'>, le type de ma_tortue
print(type(ma_tortue).__name__)      # 'dict', le nom du type de ma_tortue

Le module peut être vu comme un entrepôt de fonctions, les données restant en dehors de cet entrepôt : il n’y a pas de couplage fort entre les données et les fonctions qui les manipulent.

On remarque que l’on peut se servir de ces fonctions avec des tortues de structure un peu différente :

import tortue

ma_tortue2 = { "x": 0, "y": 0, "couleur": "rouge" }

tortue.localise(ma_tortue2)
tortue.avance(ma_tortue2,10,10)
tortue.localise(ma_tortue2)

print(type(ma_tortue2).__name__)      # 'dict'

On remarque que les fonctions du module traitent correctement ma_tortue2 qui n’a pourtant pas la même structure que ma_tortue. Coup de chance, car si la modification est trop importante, cela ne marche plus. Exemple avec une tortue définie par :

ma_tortue3 = { ('x','y'): (0,0), "couleur": "bleu"}

Et pourtant, ces 3 variables sont de même type :

print(type(ma_tortue3.__name__)) # 'dict'

On dit que ma_tortue1, ma_tortue2 et ma_tortue3 sont des instances du type dict.

L’approche objet va consister à assembler les données et les fonctions qui les manipulent, en les encapsulant dans une même entité : l’objet. Elle va également typer plus finement les données.

3. Approche objet

Dans l’approche objet, un programme est constitué d’objets, chacun disposant de ses propres données et des fonctions les manipulant. Les données sont mémorisées dans des variables internes à l’objet, appelées attributs : l’état d’un objet est caractérisé par les valeurs de ses attributs. Le comportement de l’objet est exprimé au travers de ses fonctions, appelées méthodes. Attributs et méthodes sont appelés membres de l’objet.

Un objet se caractérise par son type (une classe), dont il est une instance. Une classe représente donc un ensemble d’objets de même nature, ayant les mêmes membres : les mêmes attributs (mais pas les mêmes valeurs de ces attributs) et les mêmes méthodes. Une classe sert de modèle pour créer des instances.

Tout au long de l’exécution du programme, les objets naissent, interagissent entre eux, et meurent. Programmer consiste à concevoir des classes, puis à créer des instances, qui créent de proche en proche d’autres instances, qui interagissent entre elles.

Par exemple, étant donnée une classe Tortue modélisant une tortue sachant se localiser et se déplacer, le code suivant crée une instance ma_tortue, lui demande de se localiser, puis de se déplacer, puis à nouveau de se localiser :

ma_tortue = Tortue(0,0)
ma_tortue.localise()
ma_tortue.avance(10,10)
ma_tortue.localise()

On a toujours une variable globale ma_tortue, qui est ici un objet possédant un état (sa position) et un comportement (ses méthodes avance() et localise()). La tortue sait où elle se trouve, et est capable de se déplacer et de donner sa position.

ma_tortue est ici de type class 'Tortue', et non plus de type dict.

Pour manipuler des tortues colorées dont la position est exprimée par un tuple, il suffit de créer une nouvelle classe, et de créer des instances de cette classe :

ma_tortue3 = TortueColoree((2,2),"rouge")

Et rien n’empêche cette nouvelle classe d’avoir les mêmes méthodes que la classe Tortue :

ma_tortue3.localise()
ma_tortue3.avance(10,10)
ma_tortue3.localise()

Mais ma_tortue et ma_tortue3 sont cette fois de type différent. Les méthodes localise et avance sont spécifiques à chacun de ces 2 types. Elles portent le même nom, visent à la même finalité, mais l’atteignent de façon différente, puisqu’elles agissent sur des objets de structure différente : c’est le polymorphisme. Il permet d’écrire :

for t in (ma_tortue,ma_tortue3):
  t.avance(2,3)

Ici, ma_tortue et ma_tortue3 sont des objets de type différent, Pourtant, on les fait avancer avec la même expression t.avance(2,3). Mais quand on regarde dans le détail, la méthode appelée n’est pas la même dans les 2 tours de boucle : c’est la méthode avance() de Tortue au premier tour, puisque ma_tortue est de type Tortue, et c’est la méthode avance() de TortueColoree au second tour, puisque ma_tortue3 est de type TortueColoree,

4. Encapsulation

Code et données sont donc encapsulés dans une même entité appelée objet. Cette encapsulation favorise la réutilisation, puisqu’en récupèrant un objet, on récupère d’un seul coup les structures de données et les traitements qui les manipulent.

Il est nécessaire ici de distinguer 2 types de développeurs : le concepteur de la classe d’une part, et l’utilisateur de la classe d’autre part (qui est lui aussi un développeur, mais un développeur d’autre chose). Le concepteur fixe les attributs de la classe, et implémente les méthodes. Il a bien entendu accès à tous les membres de l’objet, puisque c’est lui qui les conçoit. L’utilisateur de la classe, quant à lui, crée des instances de cette classe. Ce qui l’intéresse, ce sont les fonctionnalités de l’objet : ce qu’il est capable de faire. Comment ses fonctionnalités sont réalisées, ce n’est pas son problème : il fait confiance. Il n’a donc pas besoin de voir tous les membres de l’objet, car il n’a pas à comprendre le détail de son fonctionnement.

5. Abstraction des données

C’est un principe de conception consistant à protéger le coeur d’un système des accès délibérés venant de l’extérieur (à l’image de la capsule spaciale qui fonctionne en autonomie en limitant au minimum les échanges avec le monde extérieur).

La métaphore de la capsule spaciale

Elle fonctionne le plus possible en autonomie, mais doit néanmoins échanger (le moins possible) avec le monde extérieur : capter l’énergie du soleil et recevoir/émettre les ondes radio. Il n’est pas possible depuis la Terre, d’agir sur le comportement intérieur de la capsule : il faut demander aux occupants de le faire eux-mêmes : définir le cap, la vitesse, régler la température, ouvrir la porte, etc.

+ Traduction : un objet est une capsule spaciale. Son interface est composée de ses panneaux solaires et de son antenne de communication. Son impémentation se compose de ses attributs (son cap, sa vitesse, la température intérieure et la porte) et de méthodes privées (modifier le cap, ajuster le vitesse, fixer la température, ouvrir la porte, fermer la porte).

L’idéal serait que l’état d’un objet ne soit modifiable que par lui-même.
Interface & implémentation

Un objet possède une interface et une implémentation. L’interface est la partie publique de l’objet. C’est ce qui est visible de l’extérieur de l’objet. Cela correspond aux services offerts par l’objet au monde extérieur, généralement un ensemble de méthodes. L’implémentation est la partie privée, elle n’est pas visible de l’extérieur de l’objet. Elle contient généralement les attributs de l’objet, ainsi que des méthodes à usage interne. Les utilisateurs de l’objet ne peuvent (ne doivent) accéder qu’à son interface. Donc chaque membre de l’objet se situe soit dans l’interface, soit dans l’implémentation.

Respecter ces règles permet à l’auteur de l’objet de garder toute liberté pour modifier l’implémentation de son objet sans impacter ses utilisateurs.
Boîte noire

L’objet devient une boîte noire pour ses utilisateurs, qui de fait, s’abstraient de sa représentation interne. Ainsi, toute modification de cette représentation n’a aucun impact sur les utilisateurs.

En regroupant code et données, et en donnant la possibilité d’interdir un accès direct aux structures de données, la POO favorise la conception de composants logiciels réutilisables et évolutifs.

La métaphore de la capsule spaciale

Elle fonctionne le plus possible en autonomie, mais doit néanmoins échanger (le moins possible) avec le monde extérieur : capter l’énergie du soleil et recevoir/émettre les ondes radio pour communiquer. Il n’est pas possible depuis la Terre, d’agir sur le comportement intérieur de la capsule : il faut demander aux occupants de le faire eux-mêmes : définir le cap, la vitesse, régler la température, ouvrir la porte, etc.

Traduction

Un objet est une capsule spaciale. Son interface est composé de ses panneaux solaires et de son antenne de communication. Son impémentation se compose de ses attributs (son cap, sa vitesse, la température intérieure et la porte) et de méthodes privées (modifier le cap, ajuster le vitesse, fixer la température, ouvrir la porte, fermer la porte).

Cas de C++, Java, C#

Les membres sont par défaut privés. Pour les rendre publics, une interface doit être définie explicitement.

Cas de PHP

Les membres sont publics par défaut, mais peuvent être explicitement déclarés privés si besoin.

Cas de Python

Tout est public, il n’y a pas de notion d’implémentation. Une convention stipule que si le nom d’un membre commence par _, c’est qu’il ne faut pas y accéder de l’extérieur. Donc un attribut nommé _x est supposé privé (mais aucun contrôle n’est réalisé par l’interpréteur).

6. Relation de composition (ou d’aggrégation)

Un objet peut avoir des composantes qui sont elles-mêmes des objets. C’est le cas, par exemple, d’un objet segment de droite représenté par 2 points, qui sont eux-mêmes 2 objets. On a affaire ici à une relation de composition : un segment se compose de 2 points. Au niveau de la classe Segment, cela se traduit par le fait qu’elle dispose de 2 attributs de type Point.

7. Relation d’héritage

Une classe B peut-être un cas particulier d’une classe A. L’héritage permet alors de définir B en spécifiant uniquement ses différences par rapport à A. Cela permet de factoriser le code commun à A et B dans A.

Exemple

Soit la classe Container modélisant une structure de données pouvant contenir des objets. Les containers de ce type disposent d’une méthode size() renvoyant le nombre d’objets qu’ils contiennent, et insert(objet) insérant un nouvel objet.

Une liste est un container particulier. Par conséquent Liste hérite de Container. Un objet liste dispose donc (en plus des siennes propres) des 2 méthodes size() et insert().

De même, un dictionnaire est un container particulier. Par conséquent Dictionnaire hérite de Container. Un objet dictionnaire dispose donc lui aussi (en plus des siennes propres) des 2 méthodes size() et insert().

Donc les méthodes size() et insert(), dont bénéficient les classes Liste et Container, sont factorisées dans la classe Container.

L’héritage

L’héritage est un mécanisme de transmission des caractéristiques d’une classe (attributs et méthodes) vers une sous-classe. C’est une relation inter-classes qui exprime la relation "est une sorte de" (a kind of).

Dans l’exemple ci-dessous, Inscrit_en_master est une sorte d' Etudiant, Etudiant une sorte de Bachelier, etc.

poo heritage

L’héritage permet de concevoir des classes générales, puis progressivement de créer de nouvelles classes plus spécialisées, en procédant par ajouts ou différences.

Vocabulaire

Si A, B et C sont 3 classes, si C hérite de B et B hérite de A, alors :

  • A et B sont des super-classes de C,

  • B est une super-classe directe_ de C,

  • B et C sont des sous-classes de A.

Héritage simple

L’héritage est simple lorsqu’une classe a au plus une classe de base directe. On obtient alors un arbre d’héritage :

poo hsimple
Héritage multiple

L’héritage est multiple lorsqu’une classe peut avoir plusieurs classes de base directes. On obtient alors un graphe d’héritage :

poo hmultiple
Classe abstraite

Une classe destinée uniquement à servir de classe de base pour concevoir de nouvelles classes est appelée une classe abstraite. C’est en général une classe dont l’implémentation est insuffisante pour représenter complètement un objet réel. Une classe abstraite ne peut pas être instanciée. Dans l’exemple ci-dessus, Forme est une classe abstraite, car elle ne contient pas assez d’information pour y implémenter une méthode tracer().

Polymorphisme

Le polymorphisme permet d’utiliser de façon homogène des objets de différents types, malgré leurs comportements spécifiques. Par exemple, si toutes les classes ci-dessus disposent d’une méthode tracer(), alors le polymorphisme permet d’écrire :

for f in [cercle1, carre1, losange1, carre2, trapeze1, cercle2]:
  f.tracer()

bien que tracer() ait une implémentation propre pour chaque type de formes.