15 fév. 2007

La Loi de Demeter

En plus des différents principes d'architecture orienté objet qui ont déjà été discutés dans des billets antérieurs, la boite à outil de l'architecte logiciel contient bien d'autres choses, et en particulier un certain nombre de "lois".

Bien évidemment, ces lois ne sont pas des lois au sens strict. Il faut les voir comme des guides dont l'utilisation intelligente permet de simplifier le code ou l'architecture du logiciel en cours de construction. Pour la plupart, ces lois ne peuvent même pas être appliquées à 100%, et énoncent souvent des restrictions logiques en ce qui concerne leur application. C'est le cas, comme nous le verrons plus tard, de la Loi de Demeter.

Et qu'est-ce donc que cette Loi de Demeter ? Pour en savoir plus, un peu d'histoire s'impose.

Le système Demeter

En 1988, Lieberherr, Holland et Riel ont publié un article présentant le résultat de leurs travaux sur le système Demeter, un environnement qui a pour but de faciliter l'évolution de hiérarchies de classes. Leur idée était de trouver le moyen de faire évoluer un système par développement successifs plutôt que par réécriture importante ou totale du code déjà conçu[1]. Au cours de leur recherche, ils se sont posé les questions suivantes: Quand peut-on dire qu'un programme objet a été écrit dans un bon style ?, Existe-t-il une formule ou une règle qu'on peut suivre dans le but d'écrire des bon programmes orienté objet ?, Quelle métrique peut-on appliquer à un programme orienté objet pour déterminer si il est "bon" ? et Quelles sont les caractéristiques d'un bon programme orienté objet ?.[2]. L'un des résultat de cette recherche a été appelé la Loi de Demeter.

Enoncé de la Loi de Demeter

Deux énoncés ont été formulés par Lieberherr et al. Le premier, dans l'article de 1988, a été jugé imprécis et trop restrictif par une partie importante de la communauté utilisatrice. En 2002, Lieberherr et Holland ont donc proposé une autre formulation[3], plus précise mais aussi plus complexe - c'est celle là que nous allons étudier.

Le premier point important est que l'énoncé de cette loi est accompagné par un grand nombre de définitions, qui définissent les termes employés dans l'énoncé lui-même.

Définition : un B-objet est un objet appartenant à la classe B.

Définition : le protocole de la classe C est l'ensemble des messages[4] qui peuvent être envoyé à C et à ses instances.

Exemple : dans l'exemple ci-dessous, a est un B-objet ; m1() et m2() forment le protocole de B.

struct B
{
  A a;
  void m1();
  void m2();
};

Définition : Une méthode utilise le protocole de la classe C si dans cette méthode un message est envoyé à un C-objet, à C ou si une instance de la classe C est utilisée ou modifiée.

Définition : la méthode M est un client de la classe B et B est un fournisseur de la méthode M si un message est envoyé de M à un B-objet ou à B elle même ou si une instance de B est utlisée ou modifiée.

Définition : la classe B est appelée fournisseur préféré potentiel de la méthode M (attachée à la classe C) si l'une de ces conditions est satisfaite:
1) B est la classe d'une variable de C ou
2) B est une classe d'un argument de M, y compris C, ou
3) B est une classe d'objets crée dans M (directement ou indirectement via un appel à une méthode qui crée et retourne un nouvel objet) ou
4) B est la classe d'une variable globale utilisée dans M

Là, une petite explication de texte s'impose. Le premier point concerne les classes des C-objets. Le second concerne les classe des variables passées en argument à la méthode M. La classe C est l'une de ces classes - à cause du passage par paramètre implicite de l'objet lui-même (this en C++ ou en C#, ou self dans d'autres langages). Le troisième point concerne les objets qui sont créés dans la méthode M - soit par construction directe (objet créé sur la pile ou directement alloué sur le tas), soit par construction indirecte (via l'utilisation, par exemple, des patrons de conception factory method ou abstract factory). Le dernier point couvre les classes des différentes variables globales qui peuvent être utilisées dans la méthode M. Toutes ces classes sont alors collectivement appelée 'fournisseurs préférés potentiels' de la méthode M.

Le corolaire de cette définition est évident.

Définition : la méthode M est appelée client potentiel préféré de la classe B si la classe B est un fournisseur préféré potentiel de la méthode M.

Grâce à cette définition, nous pouvons maintenant énoncer la loi elle-même.

Loi de Demeter :
Dans toute méthode M, on utilise uniquement le protocole des fournisseurs potentiel préférés de la méthode M.

Bien évidemment, même en ayant les définitions sous les yeux cette loi reste quand même un peu obscure de par sa formulation. On peut la simplifier pour en tirer la citation célèbre de Peter Van Rooijen

  • Vous pouvez jouer avec vous même
  • Vous pouvez jouer avec vos propres jouets (mais vous ne pouvez pas les casser)
  • Vous pouvez jouer avec les jouets qu'on vous donne
  • Vous pouvez jouer avec les jouets que vous avez créés.
  • Et vous pouvez jouer avec les jouets qui appartiennent à tout le monde[5]

Cette formulation impertinente peut à son tour être reformulée :

  • Une méthode d'une classe B peut appeler les autres méthodes de B directement
  • Une méthode d'une classe B peut appeler les méthodes des variables membre de B, mais pas les méthodes des variables membres des variables membres[6]
  • Si la méthode a des paramètres, elle peut appeler les méthodes des classes des différents paramètres
  • Si la méthode crée des objets localement, elle peut appeler les méthode de ces objets.
  • La méthode peut appeler les méthodes des instances globales de classes.

Ce que la définition implique

Le point le plus important est l'interdiction forte qui découle de cette loi, à savoir qu'il est interdit dans la méthode M de la classe C d'utiliser le protocole des classes qui ne sont pas les fournisseurs potentiels préférés (FPP) de M. Si on reprends la définition d'un FPP, on s'aperçoit que la liste est relativement réduite :

  • les classes des objets passés en paramètre, y compris la classe de this (C)
  • les classes des C-objets
  • les classes des objets instanciés dans M (quelque soit le type d'instanciation, même indirecte)
  • les classes des objets dont la portée est globale

Le protocole des C-objets est donc accessible à M, mais qu'en est-il du protocole des objets membres de C-objets ? Vous l'avez deviné, M n'a pas le droit d'y accéder. Concrètement, celà signifie que je n'ai pas le droit d'écrire le code suivant :

// b, of type B, is a C-object 
A a = b.getA();
a.doSomething();
// or b.getA().doSomething();

La première raison à cette restriction est que ce code implique une connaissance structurelle de la classe B. Cependant, la structure de B est partie intégrante de son implémentation. Un changement de cette structure implique donc un changement dans le code de C, ce qui n'est pas souhaitable.

La seconde raison pour laquelle ce code est mal formé est que si le code précédent a du sens, alors b.doSomething() a lui aussi du sens, et doit être préféré à la forme précédente - parce qu'ainsi, on respecte une encapsulation claire, telle que définie sur ce journal dans un billet antérieur. Si b.doSomething() n'a pas de sens, c'est que l'encapsulation elle même n'a pas de sens et en toute logique l'objet de type A ne devrait donc pas être un B-objet.

De manière générale, la Loi de Demeter interdit la composition de fonctions de type accesseur (elle définit une fonction accesseur comme étant une fonction qui retourne un objet qui existant avant l'appel de cette fonction). Mais quid d'une fonction B::createA() ? Dans le cas d'une fonction de création, la classe de l'objet créé fait explicitement partie des FPP de la méthode M - on peut donc utiliser son protocole. Ainsi :

A a = b.createA();
a.doSomething();

Est un construction qui respecte la Loi de Demeter, à condition que B::createA() instancie (directement ou indirectement) un objet de la classe A.

Les plus de la Loi de Demeter

Les points forts les plus important de la Loi de Demeter sont résumé dans l'article de 2002 (c.f. notes pour les références) .

  • si le protocole de la classe A est renommé (c.a.d. si le nom d'une méthode de A est modifié), alors seuls les clients potentiels préférés de la classe A et les sous classes de A seront potentiellement modifiés. La raison en est simple : si la Loi est respectée, ce nom n'est utilisé par personne d'autre.
  • si la structure de A change (sans que le protocole de A ne soit modifié) alors seules les méthode de A et les sous-classes de A seront modifiée. là encore, la raison est simple : si le protocole de A n'est pas modifié, alors tous les changements sont complètement privés et n'ont pas d'impact sur les clients de A. C'est le but ultime de l'encapsulation.
  • si la protocole de A est modifié, seuls les clients potentiels préférés de A, la classe A est ses sous-classes seront modifiés. De plus, seules des méthodes qui font partie de l'ensemble des clients potentiels préférés de A ou de ses sous-classes auront besoin d'être ajouté.

On voit clairement ce que le respect de la Loi de Demeter entraine comme effets sur la maintenance du systèmes : les modifications deviennent extrêmement locales, ce qui implique bien évidemment une plus grande sureté dans les changements apportés - moins ils impactent le code, plus le risque d'introduire de nouveaux bogues ou des régressions diminue.

Les cas particuliers

Un certain nombre de cas particuliers sont définis pour ne pas bloquer le programmeur dans sa tâche.

Le premier cas est celui des structure triviales (par exemple les types POD en C++) qui ne définissent que des membres publics. Il est évident qu'imposer l'utilisation de la Loi de Demeter sur des structures aussi simples est un non-sens - même si on se rends compte que c'est dans certains cas préférable.

Le second cas particulier concerne les conteneurs. Cela n'a pas de sens de traiter le conteneur comme étant un C-objet - et donc interdire l'accès aux objets contenus sans passer par une méthode de la classe du conteneur. Cela reviendrait à nier qu'il est préférable d'avoir des conteneurs génériques. En fait, un conteneur n'encapsule que très peu d'informations structurelles, et surtout pas l'existence d'objets de type A contenus. Ainsi, si le conteneur est un C-objet (ce qui permet à une méthode M de C d'appeler les méthodes du conteneur), les objets de classe A contenus dans le conteneur peuvent eux aussi être traités comme des C-objets.

Les critiques de la Loi de Demeter

Parmi les critiques les plus souvent citées de cette loi, on a :

  • La Loi de Demeter est trop restrictive : la plupart du temps, cette critique provient d'une incompréhension de la Loi et de ses cas particuliers. La prise en compte de ces cas particuliers élimine l'aspect restrictif de la Loi de Demeter.
  • La Loi de Demeter est difficile à comprendre : il est vrai que lorsqu'on regarde la formulation de cette Loi, on remarque rapidement que le jargon utilisé est à l'antipode de la simplicité qu'on pourrait attendre d'une telle loi. En fait, la principale difficulté provient de la refactorisation nécessaire à l'application de cette Loi. L'exemple donné dans la page web sus-citée est assez explicite :
// dans la méthode M
edgeGroups.add(edge.getDirection().combine(edges));  // incorrect
edgeGroups.add(combine(edge.getDirection(), edges)); // incorrect
edgeGroups.add(edge.combineInSameDirection(edges));  // CORRECT

Le fait est que le code correct donne l'impression qu'on transmet à la classe Edge une responsabilité qui normalement devrait relever de la class Direction. Le fait est que, si on reprend le Principe de Responsabilité Unique, une responsabilité est définie comme étant une source possible de changement. Du fait que Edge dépends déjà de Direction, il n'y a pas d'ajout de responsabilité dans la classe Edge à l'issue de la refactorisation proposée - du moins, si on prends la définition au sens stricte. Par contre, toujours en prenant la définition au sens stricte, on introduit une dépendance supplémentaire dans le code appelant, et donc une raison supplémentaire de changement - clairement, la méthode M hérite d'une responsabilité qui n'est pas souhaité. Mieux, si il est de la responsabilité de Edge de gérer une instance de la classe Direction, alors il est plus que logique que Edge soit aussi responsable des opérations appliquées à cette classe Direction.

Bien évidemment, la méthode Edge::combineInSameDirection() est extrêmement simple.

Conslusion

Il y a encore des points dont je n'ai pas discuté : ainsi, en C++, il est possible de définir des fonctions globales, n'appartenant à aucune classe, et des fonction dites friend qui peuvent avoir un impact sur l'application de la Loi de Demeter.

Bien évidemment, appliquer stricto sensu la Loi est souvent difficile, et n'est pas nécessairement une chose à faire. Comme tout outil, elle doit être comprise avant d'être appliquée, sans quoi le programmeur fera face à des contradictions dans son architecture. Car il ne faut pas se voiler la face : la Loi de Demeter a un effet non négligeable sur l'architecture d'un logiciel - elle n'agit pas seulement au niveau d'une refactorisation du code, puisqu'elle permet, dans une sorte de corolaire, de mieux voire quels sont les liens que les objets doivent entretenir.

Notes

[1] c'est de cette idée que vient le nom du projet: Demeter est la déesse grecque de la nature et donc des plantes, qui ont pour particularité de grandir lentement, mais surement.

[2] K. Lieberherr, H. Holland, A. Riel - Object-Oriented Programming: an Objective Sense of Style, OOPSLA'88 Proceedings; PDF disponible ici

[3] K. Leiberherr, I. Holland - Preventive Maintenance of Objet-Oriented Software; fichier PostScript disponible ici

[4] rappel: dans la terminologie objet, un message est une fonction qui cible un objet ; en C++, on utilise les noms méthode ou fonction membre

[5] celle ci n'est pas de Van Rooijen et provient de l'extension de la loi après la mise à jour de 2002

[6] ce n'est pas une répétition mal corrigée

Ajouter un commentaire

Les commentaires peuvent être formatés en utilisant une syntaxe wiki simplifiée.

Fil des commentaires de ce billet