Que signifie être OO?

Ce qui suit traite de l'approche OO dans une acception large et multilingue; pour appuyer ce point, voici la définition que C++ donne d'un objet :

« An object is a region of storage » ([intro.object] dans le standard)

Cette définition sied bien C++. Visiblement, nous serons plus exigeants dans ce qui suit.

Une partie du discours qui suit sera enrichie, du moins en C++, par l'avènement des concepts.

Le toujours brillant André Louis Caron m'a pointé vers ce texte qui, lui aussi, discute des variétés de lecture de l'idée d'être OO. Vous pouvez aussi examiner ce texte-ci et ce texte-là qui offrent d'autres points de vue sur la question.

La question posée par le titre de cette section est une question simple, importante et pas banale. En effet, après avoir suivi au moins un cours de POO (ce qui est sans doute votre cas), pouvons-nous déterminer ce que signifie être OO? Pouvons-nous délimiter les frontières de ce que signifient des idées comme :

Pourtant, ces formules font office de boulets dans plusieurs guerres de mots, qui sont trop souvent des guerres idéologiques, dont les fondements tendent à être boueux, arbitraires.

Le problème que constitue définir ce que signifie être OO contribue à la complexité de problèmes tels que celui de la persistance des objets : qu'est-il pertinent de conserver pour fins de récupération ultérieure? Cette information est-elle transférable à d'autres langages ou à d'autres modèles?

En fait, les idées qu'on associe à être OO sont nombreux, et aucun langage – à ma connaissance – ne les embrasse toutes pour le moment. En fait, dans certains cas, introduire l'une de ces idées résulterait en une forme d'antagonisme face à d'autres, alors que chacune, prise individuellement, serait qualifiée d'idée importante du modèle OO par bien des expert(e)s.

Vous remarquerez, à la lecture de cet article, que tout cours (incluant, clairement, les miens) fait des choix parmi ceux proposés dans la liste des idées OO qui suit. Ces choix sont pragmatiques, et sont guidés à la fois par un fort sens des priorités (dans mes cours, l'encapsulation, le polymorphisme et l'abstraction sont pris comme fondements les plus importants de l'approche OO) et d'esthétique (traiter plus directement de l'équivalence opérationnelle des types que de la réflexivité, par exemple, à cause de l'élégance relative des implémentations possibles dans les deux cas).

Malgré l'évidence de l'énoncé, soulignons que ces choix sont, d'abord et avant tout, des choix; un autre professeur, guidé par d'autres critères, aurait pu faire des choix différents dans certains cas. N'en faites pas, je vous prie, une guerre de clochers.

Chaque sous-section ci-dessous décrit une condition nécessaire, au moins aux yeux de certain(e)s expert(e)s, pour être OO, mais aucune n'est suffisante lorsque prise isolément. La matière à débats est de déterminer le sous-ensemble qui sera à la fois nécessaire et suffisant, ce qui n'est pas une mince tâche. Évidemment, cette section est écrite à la première personne, alors elle exprime la vision de l'auteur et n'engage que lui.

L'objet comme unité organisationnelle

Que le modèle OO propose d'abord et avant tout un formalisme rigoureux du concept de type est un indice fort à l'effet qu'un bon modèle OO devrait offrir un système de types solide et opérationnellement homogène.

La première caractéristique fondamentale d'un langage OO est qu'il permette de regrouper sous un même nom, dans une même entité, les données et les opérations sur ces données.

La définition traditionnelle d'un type de données (pas nécessairement d'un objet au sens contemporain du terme) nous dit qu'un type de données est fait, comme son nom l'indique, de données et d'opérations sur ces données, sans toutefois exiger que les deux ne fassent partie d'un tout intégré. Les langages OO qui se respectent doivent formaliser le tout et regrouper les deux sous un même chapiteau.

C'est l'une des raisons qui explique mes réserves personnelles quant à un usage du mot objet qui ne s'appliquerait qu'aux instances et pas aux classes, comme le suggèrent entre autres UML et plusieurs autres, incluant les penseurs derrière Java et C++ – c'est une définition restrictive et qui ne respecte par l'historique de son objet d'étude (désolé pour le jeu de mots), mais je suis le premier à accepter qu'elle soit plus simple et qu'elle facilite ainsi l'accès des idées du monde OO à plus de gens. J'accepte, dans la pratique, qu'une définition problématique sur le plan du fondement soit en contrepartie efficace sur le plan commercial... mais souvenons-nous tout de même que, bien que pragmatique, cette définition est souvent insuffisante en pratique (aussi ironique cette phrase soit-elle).

Avant même de faire état de classes et d'instances, un langage OO doit permettre le regroupement de données et d'opérations mutuellement apparentées. Les objets sont d'abord et avant tout des unités cohérentes d'organisation.

La plupart des langages OO aujourd'hui proposent aussi une saveur ou l'autre de l'idée de métaclasse, une abstraction dont les instances sont elles-mêmes des classes.

La séparation de ces regroupements en classes et en instances de ces classes, pour distinguer modèle et actualisation du modèle, est une manière d'implémenter le regroupement de manière utile.

Cette catégorisation est semblable à cette qui distingue les types des variables dans les langages procéduraux.

On en vient presque systématiquement à conférer aux classes comme à leurs instances la dualité données/ opérations sur ces données. Ceci met de l'avant l'acception traditionnelle des objets, selon laquelle une classe est un objet instanciable et qu'une instance est un objet instancié.

Encapsulation et regroupement

L'encapsulation présume le regroupement, au moins logique, mais le regroupement et l'encapsulation sont deux sujets distincts. Le terme anglais Data Hiding est une partie de l'encapsulation (l'idée que certains membres d'un objet aient des qualifications d'accès différentes de celles attribuées à d'autres membres du même objet) mais ne suffit pas àdéfinir l'encapsulation prise dans son acception pleine et entière.

Encapsulation

À mon sens, il serait difficile de qualifier de véritablement OO un langage qui n'offrirait pas un support complet de l'encapsulation.

Par encapsulation, j'entends la capacité d'un objet de garantir son intégrité et, dans la mesure de ses possibilités, celle du système auquel il appartient, du début à la fin de son existence. Souvenons-nous que garantir son intégrité, pour un objet, signifie assurer le respect de ses invariants entre chaque invocation de ses méthodes.

J'ajoute ici celle du système auquel il appartient pour mentionner cette réalité qu'un objet convenablement encapsulé devrait non seulement garantir l'intégrité de ses propres données, mais devrait fermer les fichiers qu'il aura ouvert, terminer les threads qu'il aura lancés, fermer les liens de communication qu'il aura ouverts et, lorsque l'existence de l'objet aura atteint sa fin, laisser le système dans lequel il évolue dans un état aussi proche que possible de celui dans lequel ce système aurait été à ce moment si l'objet en question n'avait jamais existé. À mon sens, donc, un objet devrait être un bon citoyen des systèmes dans lesquels il est utilisé.

Exprimé autrement : à mon sens, la finalisation fait partie intégrante de l'encapsulation.

Ces considérations sont moins souvent discutées que celles comme garder privé ce qui est privé et encapsuler l'accès aux attributs, habituellement (et correctement) associées à l'idée d'encapsulation, en partie parce que plusieurs produits commeriaux n'offrent pas de réel support complet à l'encapsulation et laissent cette responsabilité à la discipline du programmeur, une stratégie pas du tout OO. Que cet aspect de l'encapsulation soit moins connu ne l'empêche pas d'être important, voire fondamental.

Par conséquent, pour implémenter correctement le concept d'encapsulation, un langage OO se doit d'offrir :

Polymorphisme

Je sais que certains estiment le polymorphisme comme moins dépendant de l'héritage que je ne le laisse paraître ici, mais la distinction entre une classe parent dont une classe enfant spécialise les méthodes virtuelles ou implémente les méthodes abstraites et une interface qui serait implémentée par une classe donnée est une distinction artificielle, faite dans des langages dont les concepteurs ont choisi de restreindre les options des programmeurs en fonction d'un biais philosophique

 L'héritage d'interface est, fondamentalement, plus une technique qu'un concept – l'interface est un schéma de conception, après tout – ce qui ne l'empêche pas d'être quelque chose d'extrêmement important.

Le polymorphisme est une autre clé fondamentale du modèle OO. En fait, on pourrait dire sans crainte de se tromper que la POO sans polymorphisme perd beaucoup de son intérêt. Bien que dépendant du support de l'héritage, qui peut être subdivisé de plusieurs manières (héritage d'implémentation, héritage d'interfaces, héritage simple ou multiple), le polymorphisme est plus important que l'héritage au modèle OO du fait qu'il est plus ardu à bien simuler dans les langages qui n'en permettent pas directement l'expression. La capacité d'obtenir, à partir d'un lien indirect sur une abstraction, un comportement spécialisé sans avoir à inférer le type effectif de l'objet réellement référé est, à mon avis, l'un des plus importants atouts opérationnels du modèle OO.

Scott Meyers, dans les diapositives d'une de ses formations en ligne, utilise cette définition du polymorphisme, définition à la fois large et puissante : Polymorphism is the use of multiple implementations through a single interface. La beauté de cette définition est qu'elle s'ouvre à des implémentations indépendantes même de l'héritage, et va à l'essence de l'idée.

Le polymorphisme est aussi l'une des clés les plus difficiles à bien assimiler du modèle OO. Par exemple, la capacité de différencier la signature d'une méthode par autre chose que par son nom est-elle conceptuellement équivalente au polymorphisme dynamique à travers une abstraction? La distinction entre les mots anglais Overriding et Overloading est importante (même si je n'utilise jamais ces termes dans mes cours parce que, n'étant pas anglophone, je n'arrête pas de les mêler!). Il est trop facile de décomposer le mot en ses racines pour plusieurs et pour formes et d'omettre la caractéristique dynamique à la racine de ce mécanisme.

En général, Overriding est associé au polymorphisme et Overloading à la capacité de distinguer des opérations par la signature plutôt que par leur seul nom.

Si vous tombez sur un volume qui parle de Run-Time Binding pour parler de polymorphisme (pas mal en soi) et de Polymorphism pour parler de la capacité pour plusieurs méthodes d'avoir le même nom (beaucoup moins correct), méfiez-vous. Le concept est beaucoup plus riche que ne le suggère ce maigre sucre syntaxique.

Bien que ce ne soit pas le sujet de cet article, notez que l'un des sujets les plus excitants du monde de la programmation depuis la fin du XXe siècle est la programmation générique, supportée selon plusieurs colorations par la plupart des langages OO pragmatiques (voir entre autres cet article sur les templates de C++, mais Java et C# offrent aussi maintenant leurs saveurs respectives de programmation générique.

La programmation générique permet une forme orthogonale de polymorphisme à la compilation, ce qui ouvre des voies de pensée complètement novatrices. Examinez si vous en avez envie les articles (corsés, alors prudence) sur les expressions λ, le concept de concept, les classes incopiables, les traits, les types internes et publics, le truc Barton et Nackman, le sélecteur de classes parents et l'enchaînement de parents, entre autres, pour voir quelques possibilités. On parle même parfois de polymorphisme statique (j'ai un texte à ce sujet mais pas encore de version en ligne...).

Héritage

La plupart des langages OO supporteront aussi l'héritage. Souvent, on parlera d'héritage pour parler d'héritage d'implémentation, et on écrira spécifiquement héritage d'interfaces lorsqu'on voudra faire la distinction entre les deux. Certains concepteurs de langages OO reconnus prétendent même que l'héritage au sens d'héritage d'implémentation est une erreur qu'ils corrigeraient s'ils pouvait reculer dans le temps.

À mon avis, c'est là aussi une réaction dogmatique et malheureuse. Le problème de fond, selon moi, est que les langages à héritage simple seulement (dont Java et C# font partie) ramènent toutes les abstractions fondamentales à une seule et même racine (habituellement une classe Object, mère directe ou indirecte de toutes les autres – celle de .NET et celle de Java se ressemblent, d'ailleurs, sans être identiques).

La programmation générique compense beaucoup pour cette faiblesse. Depuis l'avènement de generics en Java et en C#, il est devenu beaucoup moins « salaud » de programmer dans ces langages, au sens où les bris d'encapsulation résultant des conversions explicites de types y sont devenus nettement moins fréquents.

Ces racines uniques tendent à devenir un peu trop grasses pour leur propre santé, et utiliser une seule abstraction primitive entraîne un abus des conversions explicites de types, donc une perte d'abstraction causée par un des bris systématiques d'encapsulation.Ce problème est aujourd'hui compensé en partie par l'injection de généricité dans plusieurs de ces langages.

De même, l'héritage simple seul force des prises de position improductives et un peu arbitraires[1], qu'on peut heureusement compenser en partie par l'introduction d'interfaces – la compensation est opératoire; l'héritage simple strict implique par définition l'introduction de code redondant lorsque le second parent (ou plus) doit être simulé par d'autres techniques. En bout de ligne, tous les langages restreignant héritage d'implémentation au seul héritage simple compensent pour leur refus d'introduire l'héritage multiple, permis entre autres en C++ et en Eiffel.

Conceptuellement, l'héritage multiple permet des hiérarchies plus horizontales et un découpage plus propre des fonctionnalités. C'est un choix plus difficile à implémenter pour les concepteurs d'un langage, mais plus flexible sur le plan de la représentation pour les programmeurs. Offrir une hiérarchie d'objets sérieuse est chose plus élégante quand il est possible de construire sans faire une fourche parfois arbitraire dans une hiérarchie à héritage simple seulement.

Les idées de regroupement, de spécialisation et de hiérarchisation qui accompagnent le trio encapsulation, héritage polymorphisme forment deux espèces de trinités de la perception traditionnelle de ce qu'est un langage OO.

Composition, agrégation et association

Je considère implicite la capacité de procéder à la conception d'objets par composition ou par agrégation. Si un langage offre l'héritage sans offrir la possibilité de décrire des composés ou des agrégats, je serai bien curieux de lire la philosophie conceptuelle qu'il véhicule[2].

La relation d'association est aussi une exigence de tout système OO pragmatique. Qu'un langage OO ne propose aucun mécanisme permettant aux objets d'interagir réduirait drastiquement, c'est le moins qu'on puisse dire, son potentiel commercial.

Abstraction

L'abstraction, prise au sens technique de capacité pour un langage d'exposer des interfaces pures ou des classes abstraites, est une marque de commerce des langages OO ayant eu du succès, sans être une nécessité du modèle. Sincèrement, je ne m'en passerais pas.

Personnellement, je trouve frustrant un langage se disant OO mais me refusant la capacité de définir des abstractions pures quand j'en sens le besoin intellectuel. Du point de vue de l'élégance expressive, à mon sens, l'abstraction est une conséquence du polymorphisme : le constat que, parfois, une classe (ou une interface) puisse parfois servir de stricte garantie polymorphique pour le code client.

Pour offrir un support convenable de l'abstraction, il n'est pas nécessaire d'offrir un mot clé pour les interfaces, les méthodes abstraites et les classes abstraites, mais il est nécessaire de permettre la mise en place d'un équivalent opérationnel direct de ces idées.

Équivalence opérationnelle des types

Dans certains langages, par exemple Smalltalk, il n'y a pas de types primitifs du tout. Tous les types y sont des objets, même le plus humble booléen. On obtient alors un système de types très homogène. Malheureusement, ce choix a les défauts de ses qualités, et on paie souvent en performance le gain d'élégance ainsi réalisé.

Si l'équivalence opérationnelle des types n'est pas une nécessité de l'approche OO, cela reste une marque par laquelle on peut reconnaître le caractère hybride ou non d'un langage. L'équivalence opérationnelle permet d'offrir, si cela s'avère pertinent, les mêmes services aux classes et aux types primitifs.

La surcharge d'opérateurs s'inscrit dans cette veine, tout comme les traits; le premier permet de définir des classes qui s'utilisent comme des types primitifs (pensez à une classe rationnel, par exemple, dont chaque instance représente un nombre rationnel), alors que le second permet d'enrichir un programme par des algorithmes applicables autant à des objets munis de méthodes qu'à des entités primitives, et ce à partir d'une strate d'abstraction non intrusive.

L'équivalence opérationnelle est aussi un atout pour la véritable généricité, applicable à tous les types (pas seulement aux classes). Pouvoir initialiser ou copier de manière homogène une donnée de quelque type que ce soit est un atout inestimable dans la rédaction de code de qualité.

Au sens de cette rubrique, Java et C# sont présentement plus hybrides que C++. En effet :

En Java et en C#, à divers degrés, il y a véritablement deux catégories de citoyens : les primitifs et les objets. Les deux sont soumis à des lois différentes.

D'un autre point de vue, C++ et C# sont mutuellement plus hybrides l'un que l'autre. C# traite les types primitifs comme des classes (des struct, en terminologie .NET) limitées allouées sur la pile, mais offre en retour un support incomplet et arbitraire des opérateurs qu'il doit compenser par des éléments de langage qui en alourdissent de beaucoup le vocabulaire (pensez par exemple à la syntaxe spéciale pour offrir une notation d'accès à l'aide de []). C++ ne supporte pas (du moins pas avant C++ 11) une syntaxe homogène pour l'initialisation des séquences : les tableaux ont droit à un traitement de faveur en comparaison avec les autres conteneurs, mais dans certains cas ils ont droit à un traitement de moindre qualité, alors que C# offre de son côté une syntaxe élégante et homogène.

Notez que les langages ne sont pas seuls responsables de ce manque. Sous .NET, par exemple, plusieurs langages interagissent sur le plan binaire à travers une même machine virtuelle, et implémenter la constance sur une base objet demande un soutien de la machine virtuelle elle-même (pas un petit changement, c'est le moins qu'on puisse dire).

Une conséquence de l'équivalence opérationnelle des types est la capacité qu'ont les programmeurs de faire de la véritable programmation générique – pensez aux templates de C++ – s'appliquant à la fois aux classes et aux types primitifs.

Autre élément fondamental de l'équivalence opérationnelle des types : la capacité pour une instance de tout type, sans exception, d'être utilisée comme constante. Devoir implémenter la constance par immuabilité, donc par type, est contraire à l'idée même d'équivalence opérationnelle des types car cela crée une dichotomie entre ce qui est possible à l'aide de types primitifs et ce qui est possible pour les classes.

Conséquence de la programmation générique, elle-même conséquence de l'équivalence opérationnelle des types : la capacité de faire de véritables conteneurs génériques applicables à tous les types sans exception, et sans avoir recours à de la magie de la part du compilateur (le Boxing de C# et de Java, qui introduit implicitement au besoin une conversion de type primitif à équivalent objet et inversement).

Aucune opération sans objet

Plusieurs langages se disant plus OO que d'autres ont la caractéristique de n'offrir aucune fonction globale, donc de n'offrir que des méthodes, et ce même s'ils font le choix pragmatique d'offrir à la fois des types primitifs et des objets.

Si on prend cette optique, alors Java est plus OO que C++ ou que C# (qui offre un hybride sous la forme de délégués). Il n'est pas possible, en Java, d'écrire un sous-programme qui ne soit pas une méthode. En C# non plus, mais on peut utiliser des délégués pour fins polymorphiques ce qui introduit un niveau conceptuel intermédiaire dans le portrait. C++ considère les fonctions globales comme des ajouts externes acceptables à l'interface d'un objet, et utilise ce mécanisme pour enrichir, par exemple, la gamme de types auxquels s'appliquent les opérateurs sur des flux.

Cela dit, si on oublie la fonction main() de C++, il est possible en C++ de faire du code identique à du code Java, sans la moindre fonction globale. L'option que des méthodes n'est pas la plus pragmatique[3], et certaines opérations usuelles (le opérations min(a,b) et max(a,b) sont des exemples classiques en ce sens) ne sont fondamentalement pas des opérations subjectives, ce qui mène à des implémentations OO où les objets n'ont vraiment comme seule qualité que celle de jouer un rôle de regroupement[4], comme le font les espaces nommés ou les modules traditionnels de langages comme Modula ou Pascal.

Sérialisation et persistance

La capacité de projeter un objet sur un flux ou d'extraire un objet d'un flux est étroitement associée à la capacité d'utiliser des objets avec des bases de données. Il s'agit d'un attrait non négligeable pour des objets.

En bref, la sérialisation est une problématique pour laquelle il existe une solution subjective (un objet est tout à fait en mesure de savoir comment se décrire sur un flux, et peut donc projeter sa représentation sur un média). Le problème de fond est que la désérialisation est une opération qui n'est fondamentalement pas subjective parce que, dans sa définition, elle opère sur un objet qui n'existe pas encore.

Voir cet article pour plus de détails quant à une implémentation complète en C++.

Un langage OO qui ne permettrait pas de projeter des objets sur un flux sous une forme reconnaissable (ou reconstituable), puis de récupérer ces objets ultérieurement empêcherait la rédaction de plusieurs programmes fondamentalement utiles; la sérialisation sur un flux est donc un problème pour lequel il existe des solutions viables dans la plupart des langages OO.

Les bases de données OO constituent un problème d'un ordre différent, de par les différences philosophiques à l'égard de ce qui est un objet : si un objet conçu à partir d'une philosophie est entreposé dans une telle base de données, quelle forme prendra-t-il s'il est extrait à l'aide d'un langage dans lequel la philosophie est différente?

Réflexivité

La sérialisation est parfois simplifiée dans un moteur qui offre un plein support à la réflexivité. Il en va de même pour la POA.

La réflexivité, en bref, est la capacité qu'ont les objets de se décrire eux-mêmes et d'être conceptualisés, structurellement, à partir du langage. Cette description se fait habituellement de manière dynamique, à l'aide d'objets disponibles dans les programmes : une classe représentant l'idée de classe, une classe représentant l'idée de méthode, des facilités pour instancier une classe par son nom, et ainsi de suite.

Il ne faut pas confondre réflexivité et introspection; cette dernière n'est qu'un élément de la réflexivité mais est parfois utilisée (à tort) comme nom français. Je rendrai disponible en ligne mes notes de cours sur le sujet quand j'aurai quelques minutes.

Bjarne Stroustrup et Gabriel Dos Reis ont produit quelques articles montrant comment accorder C++ et sa philosophie (on ne paie que pour ce qu'on utilise; maximiser le travail fait à la compilation; généricité et efficacité avant tout; etc.). Je vous invite à lire celui-ci et celui-là si le sujet vous intéresse. Ce sont des textes passionants.

La réflexivité est souvent dynamique : au prix d'un ralentissement à l'exécution, un programme pourra décortiquer la structure d'une classe et l'instancier sans la connaître a priori. Avec C++[5], l'accent est mis sur le travail fait à la compilation pour éliminer tout impact qui ne soit pas explicitement souhaité sur le comportement du programme à l'exécution, mais une forme de réflexivité statique est possible à l'aide de techniques de programmation générique ou de métaprogrammation.

Les langages offrant un support complet de la réflexivité dynamique permettent aux programmes de procéder à une forme d'introspection sur leur propre code et sur le langage en soi. Certains vont même jusqu'à permettre à un programme de modifier le sens de certains éléments du langage à l'aide duquel il est écrit.


[1] Après tout, comment prétendre être en mesure de mettre en place un seul point de vue structurel sur une classe et garantir que ce point de vue soit bon pour tous? C'est pourtant ce qu'implique l'héritage simple strict.

[2] Je n'ose pas m'avancer plus loin : les concepteurs de langages sont des esprits pervers et il se trouve peut-être quelqu'un, quelque part qui trouve que c'est une bonne idée et y a investi suffisamment de temps pour la transformer en fondement philosophique d'un langage tout entier!

[3] La position philosophique de C++ est que l'approche OO n'est pas toujours la meilleure solution, alors en ce sens C++ est un hybride pragmatique par choix.

[4] Par exemple à des trucs comme Math.min(a,b), alors que le concept de min() rejoint celui de relation d'ordre génériques et transcende celui des mathématiques au sens arithmétique auquel s'attendent les gens qui explorent la classe Math de plus près.

[5] Il y a bien le Run Time Type Information, ou RTTI, principalement fait de dynamic_cast et typeid, mais ces outils entraînent un coût à l'exécution. On peut toutefois vérifier plusieurs choses sur un objet dès sa compilation. La réflexion au sens traditionnel entre un peu en conflit avec la philosophie de base de C++. Certaines approches novatrices sont présentement explorées, en particulier l'approche SELL, pour Semantically Enhanced Library Language, qui préconise la génération de deux sorties pour un compilateur donné, l'une étant du code optimisé et l'autre contenant des métadonnées d'accompagnement.


Valid XHTML 1.0 Transitional

CSS Valide !