Objectification des données

La programmation orientée objet

Après avoir étudié la gestion de la mémoire, intéressons nous maintenant aux abstractions que proposent les langages de programmation pour réprésenter les données qui vont remplir cette mémoire. On dit de Java ou de C++ qu’ils sont orientés objet. Mais en quoi cela consiste? La réponse à cette question permet d’expliquer les outils modernes qui aident les développeurs à écrire des programmes complexes.

Objets, instances et classes

Pour ceux qui ne sont pas familiers avec ce concept, la programmation orientée objet consiste à regrouper les données que l’on manipule en objets, qui comportent des champs et des méthodes. Pour décrire cette abstraction, utilisons une fois encore une analogie, cette fois-ci avec une machine à café. Dans l’objet machine à café, on a le champ « réserve de café en poudre » et le champ « café liquide produit »; les méthodes sont « faire du café » et « remettre du café en poudre ». Dans le concept d’objet, on distingue la classe de ses instances. Dans notre exemple, la classe est la description de la machine à café, ses champs et ses méthodes, tandis que les instances sont toutes les machines à café physiques qui correspondent à la description de la classe.

Ce qui fait l’intérêt de la programmation objet, c’est le fait que les méthodes d’un objet agissent sur ses champs. La méthode « faire du café » de l’objet diminue la réserve de café en poudre et augmente le niveau de café liquide dans la cafetière. Ainsi, une méthode appelée sur des instances différentes produira un résultat différent : faire du café avec une réserve de café en poudre vide soulève une erreur. Grâce à cela, on peut organiser les données de son programme, qui ne sont au final que des nombres dans la mémoire, en entités logiques, les objets, que l’on peut mettre en correspondance avec ce que le programme veut réaliser.

Par exemple, si l’on veut programmer un jeu en trois dimensions, chaque personnage va être représenté par un objet dont les champs contiendront les coordonnées \(x\), \(y\) et \(z\). L’objet « personnage » va être doté de diverses méthodes aux noms explicites telles que « sauter », « courir », etc. Dès lors, le code d’un programme devient lisible et intelligible! En effet, même si le langage C avait popularisé la notion de struct qui est un regroupement de champs, la complémentarité entre champs et méthodes permet pour la première fois de faire un lien fort entre ce que le programme cherche à réaliser (des personnages se déplacent dans un espace en trois dimensions) et l’implémentation de ce programme (l’objet « personnage »). Le concept d’objet est tellement englobant que la plupart du temps, il est très facile de modéliser ce que le programme fait en termes d’objets, de champs et de méthodes.

Objets en mémoire

Le concept d’objet est une abstraction offerte par les langages de programmation, qui doit être traduite par le compilateur ou l’interpréteur. Comment un objet est-il traduit vers l’assembleur? Expliquons ce qui se passe lorsque l’on déclare une struct en C:

struct Personnage {
    float x;
    float y;
    int stats[5]
};

Pour ceux qui ne connaissent pas le C, ceci veut dire que notre objet Personnage a trois champs : ses coordonnées dans le plan x et y qui sont des nombres flottants, et le tableau de 5 nombres entiers stats qui décrit des charactéristiques du personnage : ses points de vie, etc. La première chose que fait le compilateur quand on lui présente cet objet est de calculer sa disposition en mémoire. En effet, le compilateur sait quelle taille en mémoire prend un nombre entier (4 octets) et un nombre flottant (4 également). Il peut donc calculer qu’une instance de la classe Personnage va prendre au total \(4+4+5\times4=28\) octets en mémoire. Plus précisément, il va associer chaque champ de l’objet à un décalage par rapport au début de l’objet en mémoire.

Rappelons-nous : dans le billet précédent, nous avons vu que la mémoire était un grand tableau d’octets dont chaque case avait une adresse. Ainsi, si une instance p de l’objet Personnage est en mémoire à l’adresse \(a\), l’objet remplit toutes les cases entre \(a\) et \(a+28\). Mais le compilateur peut également déduire où sont les différents champs :

  • x est entre \(a\) et \(a+4\) (exclus);
  • y est entre \(a+4\) et \(a+8\) (exclus);
  • stats est entre \(a+8\) et \(a+28\).

Ainsi, la manière en assembleur d’accéder au champ y de l’objet est de lire les octets en mémoire entre \(a+4\) et \(a+8\). Et c’est par cela que le compilateur va traduire l’accès p.y. Pareillement, p.stats[2] qui note l’accès à la deuxième case du tableau stats (en réalité troisième case car les index commencent à zéro) va être traduit par : « lire la mémoire entre \(a+20\) et \(a+24\) ». Le compilateur cache totalement ces calculs d’adresses, permettant au développeur de se focaliser sur la logique de son programme. Si ce petit exemple est relativement simple à expliquer, traduire complètement un langage orienté objet vers de l’assembleur est un projet d’ampleur industrielle dont l’explication est hors de portée de ce modeste blog.

Encapsulation

Après cette parenthèse bas-niveau, prenons un peu de hauteur. La programmation orienté objet nous permet d’introduire un concept essentiel, l’encapsulation. Revenons à notre exemple de machine à café. Supposons que nous avons deux classes « machine à café à poudre » et « machine à café à capsule ». Ces deux classes possèdent une méthode « faire du café », comment formaliser ce point commun entre classes? La solution orientée objet à ce problème est la notion de classe abstraite ou d’interface. Une interface est un contrat que peut remplir une classe et qui s’exprimer en termes de méthodes possédées : ici la méthode « faire du café ». Ainsi, si l’on veut pouvoir faire du café chez soi, on peut simplement demander à avoir un objet réalisant l’interface « faire du café ».

Mais cet objet peut très bien être un kit pour broyer ses grains de café et un feu de bois pour faire bouillir l’eau, ce qui n’est pas très pratique. Pour s’assurer que l’objet est bien une machine, on définit une classe abstraite « machine à café » qui possède un champ « alimentation électrique ». Cette classe est dite abstraite car aucune instance ne correspondra à cette classe; elles seront soit des « machine à café en poudre » ou « machine à café en capsule ». Ces deux dernières classes seront donc des implémentations de la classe abstraite.

Interfaces et classes abstraites sont donc des concepts dont le but est de formaliser le partage de champs ou de méthodes par différentes classes d’objet. Grâce à cette méthode, on peut maintenant faire de l’encapsulation : on cache la manière dont un ensemble de données est représentée, tout en offrant un ensemble de moyens d’interagir avec lui. Dans notre exemple, la différence capsule/poudre pour nos machines est encapsulée et disparaît dans l’objet « machine à café ».

L’encapsulation est un outil extrêmement puissant pour le développeur puisqu’il permet de modulariser ses programmes, c’est à dire définir précisément les échanges d’information entre les différentes parties de son programme. En effet, cacher la représentation des données permet de n’exposer que ce qui est nécessaire au moment où l’on en a besoin, et ceci permet d’éviter de nombreux bugs voire d’offrir des garanties de sécurité. Pour illustrer ceci, supposons que l’on veuille réaliser un transfert d’argent entre deux comptes en banque. On peut définir une méthode « transférer » qui prend en argument les deux comptes en banque et le montant à transférer de l’un à l’autre. Cette méthode « transférer » est implémentée dans le logiciel de la banque et cette implémentation est de confiance. Avec l’encapsulation, on peut déclarer cette méthode « publique » et rendre le champ « solde du compte » privé, c’est à dire caché en dehors de l’implémentation de l’objet. D’autres programmes extérieurs vont ensuite pouvoir appeler la méthode « transférer » lorsqu’ils manipuleront des comptes en banque, sans pouvoir modifier directement le champ « solde du compte ».

Sans encapsulation, c’est tout ou rien : la banque ne peut pas laisser des logiciels extérieurs manipuler des objets compte en banque sans leur permettre de modifier à leur guise le champ « solde du compte », ce qui est une énorme brèche de sécurité. Cette problématique est encore d’actualité et l’encapsulation est ardue à implémenter correctement. En effet, récemment, une attaque informatique contre la monnaie virtuelle Ethereum avait pour origine le fait qu’il était possible d’appeler une certaine fonction dans un contexte où cela n’aurait pas du être possible.

Limitations de l’orienté objet

Devenue extrêmement populaire à la fin des années 90, la programmation orientée objet est devenue justement l’objet d’une sorte de culte dans le petit monde des langages de programmation, certains y voyant là la forme ultime d’organisation des données et d’abstraction dans les programmes informatiques. Il est vrai que la programmation orienté objet, enrichie de ses mécanismes d’héritage statique ou dynamique que je ne développerai pas ici, est un formidable outil qui a fait rentrer la programmation dans une nouvelle ère, faisant passer le code de cryptogramme obscur rempli de références à la machine à un texte clair et lisible dans lequel il est possible de faire apparaître le raisonnement sous-jacent du programme.

Cependant la programmation orientée objet n’est qu’une extrémité d’un nouvel axe de différentiation des langages de programmation, dont l’autre extrémité, la programmation fonctionnelle, sera l’objet d’un prochain article. En effet, il existe des situations pour lesquelles la représentation en objet est lourde : par exemple, un objet ne contenant qu’un seul champ, ou une seule méthode. Cette sur-objectification mène à un code source extrêmement verbeux, dans lesquels les déclarations de classes sont pléthoriques et qui peut donner une sensation de lourdeur. J’en veux pour exemple l’essor des IDE (Environnement de Développement Intégré), logiciels de traitement de texte aux stéroïdes spécialisés dans l’écriture de code; une IDE Java finit par écrire au moyens de modèles pré-définis la moitié du code à la place du développeur, ce qui est un indice concluant d’une usine à gaz potentielle.

Mais prenons encore plus de recul. La représentation en objets correspond à la situation où l’on veut regrouper ensemble des données. Mais comment alors exprimer le fait qu’une donnée peut être ou bien quelque chose, ou bien autre chose? La solution de la programmation objet qui utilise des classes abstraites est très verbeuse et maladroite; nous verrons que la programmation fonctionnelle adopte une approche beaucoup plus équilibrée de l’organisation des données, dans laquelle le regroupement et la différenciation des données sont deux notions complémentaires.

Pour aller plus loin

  • Le Wikilivre sur Java décrit les principaux mécanismes d’héritage évoqués dans l’article.

  • Une explication très haut niveau des trésors d’ingénierie déployés par Java pour traduire le langage vers de l’assembleur, mélange de compilation statique et d’interprétation dynamique.

  • Un article très imagé qui fait bien ressortir cette assymétrie dans la structuration des données introduites par la programmation orientée objet.