Cours

La programmation orientée objets (POO) #

Un bref historique de la POO #

C’est vers la fin des années 60 que fut inventé le premier langage utilisant le concept de classe: il s’agit du langage Simula 67 (les langages comportent souvent l’année de leur création dans leur nom). Ce langage n’est plus guère utilisé de nos jours, mais il a donné naissance quelques années plus tard au langage Smalltalk 72. Ce dernier a été inventé par un des plus grand informaticien, Alan Kay. Ce brillant chercheur n’est guère connu du grand public, mais il est l’inventeur du terme programmation orientée objet, ainsi que du concept d’interface graphique et de bureau, repris avec le succès que l’on connait par Apple et Microsoft quelques années plus tard. Le langage smalltalk est toujours très utilisé dans les grandes entreprises (notamment les banques et les places financières internationnales), et sa mouture la plus moderne s’intitule pharo.

Le langage smalltalk est certainement le langage orienté objet le plus pur à l’heure actuelle: il n’autorise qu’un seul paradigme de programmation, la POO (pas de programmation fonctionnelle, ni même itérative). En smalltalk, absolument tout est un objet (même une instruction conditionnelle ou bien une boucle est simulée par un objet).

Le succès des langages à objets a véritablement éclaté dans les années 1980, avec l’arrivée du C++ (1983), puis dans les années 1990 avec le langage java (1995) et enfin au début du nouveau siècle avec C# (2002). Ces trois langages restent parmi les langages les plus utilisés dans le monde à l’heure actuelle.

Les langages dynamiques, tels python (1980), sont pour la plupart fortement orientés objets.

Les ingrédients de la POO #

La POO est un paradigme de programmation qui consiste à mettre en relation des objets qui représente (en général) des concepts réls (par exemple: une voiture, un livre, une personne).

Un objet est une structure de donnée qui regroupe en son sein deux types d’entités:

  • des données, appelées des attributs;
  • des fonctions constituant l'interface de cet objet, appelées des méthodes;

C’est ce regroupement qui a constitué une véritable révolution dans les années 60. Il faut le mettre en comparaison avec les paradigmes alors existants, à savoir la programmation impérative et fonctionnelle. Les avantages sont multiples:

  • on regroupe dans une seule entité à la fois les données et les fonctions permettant d’interagir avec ces données;
  • ce regroupement permet notamment de cacher les détails de l’implémentation de l’objet à l’utilisateur, ce qui offre une grande souplesse au programmeur (notamment celle de complètement changer ces détails d’implémentations sans affecter l’interface de l’objet). Ce concept très important s’appelle l'encapsulation.
  • La POO comporte d’autres aspects (polymorphisme, héritage), mais ils ne seront pas étudiés en terminale NSI. Ils ont clairement leur utilité, mais le langage python ne fait que modérément appel à eux dans la pratique (l’aspect très dynamique du langage python offre d’autres avantages).

Le concept de classe #

La plupart des langages à objets utilisent le concept de classe. Une classe est une définition abstraite d’un objet, une sorte de plan qui va servir ensuite à construire des représentations concrètes des objets.

D’autres langages utilisent plutôt le concept de prototype, comme par exemple le très célèbre javascript: pour ces derniers, les objets sont directement construits à partir d’autres objets, dans une sorte de mécanisme de clônage (mais qui peut aussi comporter des mutations pour obtenir de nouvelles fonctionnalités).

En python, les classes décrit précisément la structure des objets qu’elle va représenter:

  • Une méthode particulière __init__, appelée le constructeur, permet notamment d’initialiser l’objet et ses différents attributs;
  • les méthodes qui serviront d’interface pour cet objet sont aussi définies au sein de la classe.
  • on peut aussi définir des méthodes qui n’auront qu’un usage interne à l’objet. Elle ne font techniquement pas partie de l'interface de celui-ci, mais seront très utiles pour son implémentation.

Une classe Rectangle #

On souhaite définir une classe qui représentera un rectangle (au sens mathématique). Plusieurs représentations équivalentes sont possibles, nous choisissons de caractériser un rectangle par ses coordonnées en haut à gauche et en bas à droite.

rectangle

Notre objet comportera donc naturellement 4 attributs correspondant aux abscisses et aux ordonnées de ces deux points.

class Rectangle:
   def __init__(self, x1, y1, x2, y2):
      self.x1 = x1
      self.y1 = y1
      self.x2 = x2
      self.y2 = y2

Nous venons de définir une classe minimaliste: elle ne comporte qu’une unique méthode, le constructeur __init__(self, x1, x2, y1, y2). Le premier paramètre d’une méthode (conventionnelement toujours appelé self, mais ce n’est pas une obligation) désigne toujours l’objet qui sera défini grâce à la classe. La syntaxe self.x1 = x1 permet ici de créer un nouvel attribut x1 pour l’objet self, qui aura sa valeur initiale donnée par le paramètre x1 du même nom passé au constructeur.

Voyons comment on peut créer un objet:

>>> r = Rectangle(3, 4, 12, 9)
>>> r.x1
3
>>> r.x2
12
>>> r.x1 = 5
>>> r.x1
5

On constate que l’on peut créer un objet en appelant sa classe, et en passant tous les paramètres sauf le paramètre self qui sera toujours passé automatiquement.

Examinez plus précisément ce mécanisme à l’aide de python tutor. Rien ne vaut une visualisation dynamique de l’exécution (cliquez sur le lien précédent !), mais voici quelques explications:

  • Lors de la création de l’objet, une nouvelle instance (c’est-à-dire une réalisation physique de la classe) est crée en mémoire, et est référencée par la variable self dans l’environnement d’exécution du constructeur __init__. Remarquez que les coordonnées du rectangle sont bien passées en paramètre, mais les attributs ne sont pas encore créés. Création d’un rectangle 1
  • À la fin du constructeur, les quatre attributs ont été créés. Ils portent le même nom que les paramètres du constructeur (ce n’était d’ailleurs pas une obligation, mais cela ne pose aucune difficulté), mais le fait qu’ils soient préfixés par self. enlève toute ambiguïté. Création d’un rectangle 2
  • Une fois le rectangle créé, les attributs peuvent être manipulés grâce à la syntaxe utilisée depuis bien longtemps en python. Création d’un rectangle 3 À présent on comprend pourquoi dans certains cas des fonctions étaient précédées d’une valeur, comme par exemple pour ma_liste.append(42): il s’agissait en fait de méthodes attachées à un objet particulier (en l’occurence ici un objet de classe list).

La classe que nous venons de créer est extrêmement minimaliste: on se contente de créer des attributs dans le constructeur, et c’est tout. Notons que nous disposons néanmoins d’une nouvelle fonctionnalité intéressante: un objet rectangle, avec quatre attributs portant un nom (que l’on espère) intelligible et identifiable. C’est déjà bien plus lisible que la syntaxe rectangle[0], rectangle[1], rectangle[2], rectangle[3] que nous aurions dû utiliser si nous avions opté pour une liste ou bien un tuple.

Voyons à présent comment étoffer l’interface de notre objet. Pour cela, nous allons devoir lui ajouter de nouvelles méthodes !

Ajout de méthodes à la classe Rectangle #

Quelle genre d’interface pourrions-nous souhaiter pour un rectangle ? On peut notamment vouloir connaître sa longueur, sa hauteur, son périmètre, son aire. Rien de plus simple !

class Rectangle:
   ...

   def longueur(self):
      return self.x2 - self.x1

   def hauteur(self):
      return self.y2 - self.y1

   def perimètre(self):
      return 2*(self.hauteur() + self.longueur())

   def aire(self):
      return self.hauteur()*self.longueur()

Nous n’avons pas reproduit le constructeur ici, puisqu’il n’a pas été modifié.

Nous disposons à présent de quatre nouvelles méthodes, qui permettent d’accéder à certaines fonctionnalités de notre rectangle.

>>> r = Rectangle(3, 4, 12, 9)
>>> r.longueur()
9
>>> r.hauteur()
5
>>> r.périmètre()
28
>>> r.aire()
45

Vous pouvez observer comment ces valeurs sont calculées avec python tutor.

Les quatre méthodes implémentées jusqu’à présent se contentaient d’accéder aux attributs pour en déduire de nouvelles caractéristiques du rectangle.

Notez la présence du paramètre self, toujours situé en première position, et qui est toujours rajouté automatiquement par python lors d’un appel. Concrètement, l’appel r.longueur() est automatiquement traduit par python en Rectangle.longueur(r), et on comprend ainsi que la valeur que pointera self ne sera autre que l’objet r, c’est-à-dire l’objet qui se trouve juste avant le point dans la syntaxe r.longueur().

Mais on peut tout aussi bien modifier les valeurs d’un ou plusieurs attributs.

Ajoutons une méthode translation(dx, dy) qui prend pour paramètre les coordonnées d’un vecteur, et déplace le rectangle suivant la translation définie par ce vecteur:

class Rectangle:
   ...

   def translation(self, dx, dy):
      self.x1 = self.x1 + dx
      self.x2 = self.x2 + dx
      self.y1 = self.y1 + dy
      self.y2 = self.y2 + dy

On se contente ici d’ajouter les coordonnées du vecteur aux deux coordonnées définissant notre rectangle.

>>> r.translation(-5, 3)
>>> r.x1, r.y1
(-2, 7)
>>> r.x2, r.y2
(7, 12)

Une meilleure encapsulation #

Le rectangle ainsi défini à la section précédente est déjà bien fonctionnel, mais son utilisation par le programmeur comporte quelques dangers dont il faut avoir conscience:

  • Idéalement, on ne devrait accéder à l’objet que par l’intermédiaire de son interface. Mais ici, il est tout à fait possible de modifier les attributs en dehors des méthodes de l’objet.
  • Un problème induit par le précédent: nous avons fait l’hypothèse (hasardeuse) que les contraintes $x1 \leqslant x2$ et $y1 \leqslant y2$ seraient toujours respectées. C’est notamment sous ces hypothèse que nous avons implémenté longueur() et hauteur(). Or, n’importe qui peut changer les attributs pour leur donner des valeurs ne respectant pas ces contraintes (voire, et c’est encore pire, des valeurs qui ne seraient même pas des nombres).
  • L’utilisateur de la classe Rectangle est constamment conscient de la présence de ces quatre attributs. Cela peut paraître anodin, mais c’est en fait un lourd handicap: le concepteur de la classe ne pourra plus changer les attributs sans risquer de casser tous les programmes utilisant cette classe. Autrement dit: les attributs font partie de l’interface de l’objet.

Illustrons ces points par un exemple:

>>> r = Rectangle(12, 4, 3, 9):
>>> r.longueur()
-9
>>> r.hauteur()
5
>>> r.périmètre()
-8
>>> r.aire()
-45

Comment pouvons-nous corriger tous ou partie de ces défauts ? Une technique très prisée par les programmeurs utilisant le paradigme objet est de s’astreindre à cacher les attributs. D’ailleurs, la plupart des langages objets imposent ce genre de restriction par défaut (c’est le cas de C++, Java et C# notamment). En python, langage dynamique et très permissif par essence, il est possible de cacher des attributs en suivant certaines conventions.

Il y a essentiellement deux techniques utilisables:

  • On peut ajouter un simple caractère underscore _ devant le nom de l’attribut. Cela n’empêche pas qui que ce soit d’accéder à cet attribut, mais cette convention est universellement respectée par les programmeurs python qui savent ainsi que cet attribut n’est pas destiné à être accédé à la main, et qu’il pourra être modifié voire disparaître à tout moment.
  • Python propose un mécanisme un peu plus sûr, qui consiste à ajouter deux caractères underscore __ devant un nom d’attribut. Le langage bloquera alors toute tentative d’accès à cet attribut (à moins d’utiliser des moyens très alambiqués), ce qui revient à créer des attributs cachés, ou plutôt privés selon la terminologie classique en POO.

Utilisation du simple underscore #

Voyons comment nous pourrions améliorer notre classe Rectangle par la première méthode:

class Rectangle:
   def __init__(self, x1, y1, x2, y2):
      # On teste la validité des paramètres:

      # Les quatre multiplications (ridicules) ci-dessous
      # ont une double et réelle utilité:
      # - elles déclenchent une erreur si les paramètres
      #   ne sont pas numériques (très bien !)
      # - elles assurent que les paramètres soient des
      #   flottants (un type numérique adapté à notre classe)
      x1 = 1.0*x1
      y1 = 1.0*y2
      x2 = 1.0*x2
      y2 = 1.0*y2

      if x1 > x2:
         # Les abscisses sont dans le mauvais ordre:
         # on les échange
         x1, x2 = x2, x1

      if y1 > y2:
         # Les ordonnées sont dans le mauvais ordre:
         # on les échange
         y1, y2 = y2, y1

      self._x1 = x1
      self._y1 = y1
      self._x2 = x2
      self._y2 = y2

   def x1(self):
      return self._x1

   def y1(self):
      return self._y1

   def x2(self):
      return self._x2

   def y2(self):
      return self._y2

   def longueur(self):
      return self._x2 - self._x1

   def hauteur(self):
      return self._y2 - self._y1

   def translation(self, dx, dy):
      self._x1 += dx
      self._x2 += dx
      self._y1 += dy
      self._y2 += dy

   def perimètre(self):
      return 2*(self.hauteur() + self.longueur())

   def aire(self):
      return self.hauteur()*self.longueur()

Les contraintes sont vérifiées lors de la création de l’objet. Certaines pourraient déclencher des erreurs (valeurs non numériques), d’autres seront corrigées automatiquement (coordonnées mal ordonnées).

Quelques points importants:

  • Nous avons rajouté quatre méthodes permettant d’accéder (en lecture mais pas en écriture) aux quatre attributs. Notons qu’il n’y a aucun moyen de savoir si la méthode Rectangle.x1() couvre un attribut ou si elle est calculée par un autre moyen. Nous reviendrons sur ce point un peu plus loin.
  • La seule méthode qui permet de modifier les attributs cachés est translation(dx, dy), et elle ne peut pas casser les contraintes établies au moment de la construction: l’utilisation de cette méthode est donc sûre.

Notons qu’il est toujours possible de ne pas s’en tenir à l’interface proposée, et de continuer à modifier les valeurs des attributs malgré les avertissements implicites du concepteur. Mais ce sera à nos risques et périls !

>>> r = Rectangle(3, 4, 12, 9)
>>> r._x1
3
>>> r._x1 = 21
>>> r.aire()
-45 

Utilisation du double underscore #

En apparence, la modification semble légère:

class Rectangle:
   def __init__(self, x1, y1, x2, y2):
      # On teste la validité des paramètres:

      # Les quatre multiplications (ridicules) ci-dessous
      # ont une double et réelle utilité:
      # - elles déclenchent une erreur si les paramètres
      #   ne sont pas numériques (très bien !)
      # - elles assurent que les paramètres soient des
      #   flottants (un type numérique adapté à notre classe)
      x1 = 1.0*x1
      y1 = 1.0*y2
      x2 = 1.0*x2
      y2 = 1.0*y2

      if x1 > x2:
         # Les abscisses sont dans le mauvais ordre:
         # on les échange
         x1, x2 = x2, x1

      if y1 > y2:
         # Les ordonnées sont dans le mauvais ordre:
         # on les échange
         y1, y2 = y2, y1

      self.__x1 = x1
      self.__y1 = y1
      self.__x2 = x2
      self.__y2 = y2

   def x1(self):
      return self.__x1

   def y1(self):
      return self.__y1

   def x2(self):
      return self.__x2

   def y2(self):
      return self.__y2

   def longueur(self):
      return self.__x2 - self.__x1

   def hauteur(self):
      return self.__y2 - self.__y1

   def translation(self, dx, dy):
      self.__x1 += dx
      self.__x2 += dx
      self.__y1 += dy
      self.__y2 += dy

   def perimètre(self):
      return 2*(self.hauteur() + self.longueur())

   def aire(self):
      return self.hauteur()*self.longueur()

Les remarques formulées à la section précédente sont toujours d’actualité. Mais il y a une différence fondamentale: il n’est plus possible d’accéder (facilement) aux attributs cachés:

>>> r = Rectangle(3, 4, 12, 9)
>>> r.__x1
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'Rectangle' object has no attribute '__x1'

Pourtant, la classe fonctionne parfaitement: les attributs sont accessibles de manière interne, par les méthodes définissant la classe, mais pas depuis l’extérieur de celle-ci.

Nous ne détaillerons pas ici le mécanisme utilisé par python pour réaliser cela, ni comment le contourner.

À noter que la plupart des programmeurs python n’utilisent pas ce mécanisme, préférant le précédent: le fait de cacher des attributs, ou plutôt de les rendre privés, n’est pas dans un but de sécurité mais simplement de mise en garde contre une utilisation non souhaitée de l’objet. On part toujours du principe que les programmeurs feront preuve de bon sens.

En particulier, ces mécanismes (et ce, quel que soit le langage à objet) n’ont absolument pas pour objectif de protéger contre une tentative d’intrusion par un hacker, par exemple. Ils n’offrent absolument aucune protection dans ce domaine.

Changer l’implémentation d’un objet #