Les objets callisthéniques, ou comment coder des trucs plus légers qui durent plus longtemps.

Dimitri Lahaye
16 min readMar 30, 2023

--

La notion d’objet callisthénique a été formulée par Jeff Bay dans le livre “The Thoughtworks Anthology”, paru en 2008.

D’abord, qu’est-ce que la callisthénie ? Il s’agit d’une pratique sportive qui consiste en des séances de musculation se basant exclusivement sur votre poids de corps. (oui, c’est moi qui parle de sport là. ça me fait autant bizarre qu’à vous)

Vous vous demandez sûrement quel est le rapport avec le développement informatique ? (et je vous remercie de poser la question). Eh bien il faut prendre en compte le principe d’entropie logicielle. Plus un projet logiciel perdure dans le temps, plus il subira des ajouts, des modifications, des corrections, plus il perdra en stabilité. Retoucher du vieux code ou en rajouter du neuf peut provoquer des effets de bords. Imaginez ça à l’échelle d’un projet de plusieurs dizaines de milliers de lignes sur lesquelles sont passées des dizaines d’équipes.

Donc voilà nous y sommes, coder des objets callisthéniques signifie renforcer son code en l’allégeant.

La notion d’objets callisthéniques est donc une liste de 9 règles de la Programmation Orientée Objet à garder en tête pour renforcer notre code et participer à éloigner les effets vicieux de l’entropie.

Les 9 règles énumérées par Jeff Bay sont les suivantes :

  1. One level of indentation per method
  2. Don’t use the ELSE keyword
  3. Wrap all primitives and Strings
  4. First class collections
  5. One dot per line
  6. Don’t abbreviate
  7. Keep all entities small
  8. No classes with more than two instance variables
  9. No getters/setters/properties

Je n’aime pas le dogmatisme et je n’ai pas assez de recul pour dire qu’il FAUT respecter ces 9 points à la lettre. Mais je trouve intéressant de prendre le temps de se pencher sur chacun d’eux afin d’observer en quoi ils peuvent renforcer notre code (et donc offrir le meilleur à nos utilisateurs finals).

Prenons donc chacune de ses règles une par une.

One level of indentation per method

Quand vous écrivez une méthode avec un for imbriqué dans un autre for, lui-même dépendant d'une troisième boucle, vous pouvez en conclure que cette méthode fait beaucoup trop de choses. Dans un monde idéal, une méthode ne devrait avoir qu'une seule responsabilité. Ou si vous préférez : une seule raison de subir une modification. Prenons l'exemple suivant :

function getBoatContentsCountByProvider(boat: Boat, provider: Provider): number {
// 1er niveau d'indentation, parfait
let providerShipmentsCount = 0;
for (const container of boat.containers) {
// 2nd niveau d'indentation, meeeh
if (container.provider.id === provider.id) {
// 3ème... ça fait beaucoup là non ?
for (const content of container.contents) {
// on disait quoi déjà ?
shipmentCount += content.length;
}
}
}

return providerShipmentsCount;
}

Au-delà d’enfreindre le principe de séparation des responsabilités, cet exemple de code est très difficile à relire (et donc à maintenir). Il s’y passe des tas de choses techniques et en comprendre la raison d’être fonctionnelle demande un effort de suppositions. Même si ce code aurait pu être encore plus cryptique avec des boucles for(let i = 0...), chaque ligne ajoute un nouveau niveau d'abstraction, de contexte et autant de variables locales qui demandent un effort supplémentaire de traduction. Si vous n'êtes pas convaincus, imaginez ce genre de gymnastique sur une base de code mille fois plus ample. Pour que cette méthode getBoatContentsCountByProvider résiste au temps qui passe, elle ne devrait pas décrire comment elle fait les choses mais plutôt ce qu'elle fait.

Voici comment nous pourrions ré-usiner cet extrait afin d’aller dans le sens de notre première règle :

function getBoatContentsCountByProvider(boat: Boat, provider: Provider): number {
const providerContainers = getProviderContainers(boat.containers, provider);
const containerContents = getContainersContents(providerContainers);

return containerContents.length;
}

function getProviderContainers(containers: Container[], provider: Provider): Container[] {
return containers.filter((container) => container.provider.id === provider.id);
}

function getContainersContents(containers: Container[]): Content[] {
return containers.map((container) => container.contents).flat();
}

Vous voyez, le code final peut encore être amélioré, mais dorénavant chaque méthode et sous-méthode remplit une seule fonction. Cela n’empêchera jamais les effets de bords à 100%, mais dorénavant les composants de votre logique métier sont délimités et le risque pour que votre vous du futur ou vos collègues se méprennent sur le sens de votre algorithme est d’autant plus réduit.

Don’t use the ELSE keyword

Les usages des if / else dans notre code sont légions. Mais si on y regarde de plus près, le else n'est rien d'autre qu'une condition à la marge. C'est la condition qui n'a pas été traitée par toutes celles qui la précèdent dans la chaîne de if / else if. Et lorsque les différentes conditions s'achèvent par un return ou une exception, on peut très facilement les rendre plus lisibles.

Regardons cet exemple basique :

function userCanBuyAlcool(user: User): boolean {
if (user.age < 18) {
return false;
} else {
return true;
}
}

Ce bout de code peut être simplifié de la façon suivante :

function userCanBuyAlcool(user: User): boolean {
if (user.age < 18) {
return false;
}

return true;
}

Résultat : En faisant l’économie du mot clé else, le code s'en retrouve un poil plus rapide / facile à lire. On pourrait même envisager de le simplifier encore un peu :

function userCanBuyAlcool(user: User): boolean {
return user.age >= 18
}

Cet exemple est simple car il était malgré tout facile de déduire quelle était la condition cachée derrière le else. Mais plus la suite de conditions est longue, plus le cerveau doit faire un gros effort pour comprendre quelle condition renfermait le else.

C’est alors qu’il peut être intéressant de se tourner vers le concept de early return. L’idée est de traiter dans notre code en tout premier les conditions négatives puis seulement après, les conditions positives.

L’exemple suivant l’illustre bien :

if (isLogged()) {
if (aCondition()) {
// 1ère ligne de logique
// 2ème ligne de logique
// 3ème ligne de logique
// 4ème ligne de logique
// 5ème ligne de logique
// 6ème ligne de logique
// 7ème ligne de logique
} else if (anotherCondition()) {
// 1ère ligne de logique
// 2ème ligne de logique
// 3ème ligne de logique
// 4ème ligne de logique
}
} else {
throw new Exception();
}

Le code peut être pénible à lire car le cerveau doit traduire 4 conditions. Avec un peu d’huile de code (haha), voici le résultat que l’on peut obtenir en suivant le concept du early return :

// condition négative
if (!isLogged()) {
throw new Exception();
}

// conditions positives (le gros de notre logique métier)
if (aCondition()) {
// 1ère ligne de logique
// 2ème ligne de logique
// 3ème ligne de logique
// 4ème ligne de logique
// 5ème ligne de logique
// 6ème ligne de logique
// 7ème ligne de logique
} else if (anotherCondition()) {
// 1ère ligne de logique
// 2ème ligne de logique
// 3ème ligne de logique
// 4ème ligne de logique
}

S’occuper en premier lieu de la condition qui s’achevait en erreur permet de se focaliser sur le traitement métier. Nous sommes passés à 3 conditions. La première est vite résolue par notre cerveau : c’est un cas à la marge que nous pouvons donc vite laisser de côté pour nous concentrer sur le cœur même de la logique.

Wrap all primitives and Strings

De vous à moi, il n’y a rien qui ressemble plus à un number qu'un autre number. Idem pour les chaînes de caractères. Lire ce genre de code ne m'indique pas forcément ce que l'existence de cette donnée implique d'un point de vue fonctionnel :

class AddArticleUsecase {
execute(height: number) {
//...
const article = new Article(height);
//...
}
}

Ok, un article possède une hauteur. Soit. Cette donnée est de type number, ce qui me permet de confirmer ma première hypothèse. Bref, je sais ce qu'est une hauteur. Mais en fait non, si je regarde le constructor de la classe Article, je comprends qu'il y a une règle métier sous-jacente :

class Article {
private height: number;

constructor(height: number) {
if (height < 10) {
throw new HeightCanNotBeLessThanTen();
}
if (height > 100) {
throw new HeightCanNotBeGreaterThan100();
}
this.height = height;
}
}

Il y a donc une hauteur minimum et une hauteur maximum pour chaque article. Et si je dois implémenter cette notion de hauteur dans d’autres partie du code, si je n’ai pas assez creusé l’existant, il est possible que je passe à côté de cette règle et que je pousse une bien belle régression.

Ainsi, cette 3ème règle nous rappelle que :

  1. Une donnée représentée par une simple primitive ne peut pas porter de logique métier, ce sera toujours à l’appelant de gérer cette logique. D’où un risque de duplication.
  2. Un type string peut être utilisé pour représenter un titre, un prénom, une ville, etc. Un type number peut être utilisé pour représenter un age, un prix, un rang, etc. Quand plusieurs string ou plusieurs number traînent dans un scope de code réduit, il peut être usant de comprendre qui fait quoi à la première lecture.

C’est là que cette 3ème règle arrive en jeu. Encapsuler notre primitive dans un objet va donc permettre de conserver la logique qui lui est liée au même endroit sans la faire déborder sur les appelants (réduction de la duplication) Et elle permettra de donner un sens métier fort à cette donnée (augmentation de la lisibilité). Ré-usinons donc l’exemple de code précédent :

// Primitive contenue dans un objet (aussi appelé Value Object)
class ArticleHeight {
private value: number;

constructor(value: number) {
if (value < 10) {
throw new HeightCanNotBeLessThanTen();
}
if (value > 100) {
throw new HeightCanNotBeGreaterThan100();
}
this.value = value;
}
}

// passage de notre ArticleHeight dans le constructor
class Article {
private height: ArticleHeight;

constructor(height: ArticleHeight) {
this.height = height;
}
}

// eh voilou !
class AddArticleUsecase {
execute({ height }) {
//...
const article = new Article(new ArticleHeight(height));
//...
}
}

En vous entraînant à respecter ce genre de règle, vous renforcerez l’encapsulation de la logique là où elle devrait être : au plus près du propriétaire de la donnée manipulée. Si la règle des limites de hauteur vient à changer, vous n’aurez plus qu’un endroit où modifier le comportement. En faisant ce genre de choses, vous participez au respect du principe Open / Closed (le O de SOLID). Félicitations :)

First class collections

Petite sœur de la règle précédente. Il n’y a rien qui ressemble plus à un Array.filter ou un Array.map qu'un autre Array.filter ou un autre Array.map. Pire encore, avez-vous déjà vu un for(let i = 0;...) suivi d’un for(let j = 0;...)?

On se retrouve avec des méthodes qui bouclent sur des collections passées en arguments, mettent les mains dans la mécanique interne des items contenus dans ces collections puis retournent une nouvelle collection. Si ce n’est pas très clair nous pouvons reprendre l’exemple du bateau et des containers vu plus haut en le simplifiant un brin :

// hey ! Je suis carrément devenu une classe \\\\o/
class Boat {
containers: Container[];

getContainersByProvider(provider: Provider): Container[] {
return this.list.filter((container) => container.provider.id === provider.id);
}
}

Notre classe Boat ne devrait pas avoir à se préoccuper de la structure de sa collection de Container. Faire fuiter la logique au niveau de l'appelant est rarement une bonne idée. Cette 4ème règle proclame qu'un objet possédant une collection en guise de variable d'instance ne devrait pas posséder d'autres variables d'instance. D'où son corollaire : chaque collection devrait être encapsulée dans sa propre classe.

On pourrait donc envisager les choses ainsi :

class Containers {
private list: Container[] = [];

constructor(list: Container[]) {
this.list = list;
}

getByProvider(provider: Provider) {
return this.list.filter((container) => container.provider.id === provider.id);
}
}

class Boat {
containers: Containers;

getContainersByProvider(provider: Provider): Container[] {
return this.containers.getByProvider(provider);
}
}

Je suis un bateau. Ici, mon job c’est de refiler à l’appelant les containers qui appartiennent à un provider donné. La question de savoir comment les containers font pour savoir lequel appartient à qui, ça ne me regarde pas. Encore une fois, l’encapsulation est une bien belle mécanique et évite bien des duplications, effets de bords et autres nœud au cerveau (bateau, nœud, tu l’as ?) Pour finir, l’encapsulation a ça de pratique qu’elle nous encourage à rédiger des noms de méthodes parlants qui renforcent l’intention métier de notre code, tout en allégeant la taille des appelants. Le jour où la structure de votre objet Container vient à changer, vous n'aurez pas à embêter la structure de Boat. Open / Closed, vous vous souvenez ? ;)

One dot per line

La tentation est grande d’écrire ce genre de code :

function getUserFatherName(user: User): string {
return `${user.father.firstname} ${user.father.lastname}`;
}

Et que se passera t’il le jour où la structure du user changera ? Et si un beau jour, au lieu de father, nous avons un objet Parents dans lequel est relégué le père ? Eh bien vous serez obligé de modifier le code que je viens de vous présenter. Pire, vous serez sûrement obligé de le modifier partout où vous avez écrit une telle instruction. Cette 5ème règle nous oblige à respecter la loi de Déméter. Elle est connue par cette phrase "Ne parle qu'à tes amis proches". C'est d’ailleurs ce que dit ma tante Édith, mais elle raconte aussi que le vaccin de la COVID a tué son chat, donc bon... En substance, cette loi s'applique aux fonctions et aux méthodes. Elle dit qu'un objet ne devrait pas avoir accès au contenu interne de ses propres sous-composants.

Dans notre exemple, notre méthode getUserFatherName prend un User en argument et elle va y piocher elle-même le sous-composant Father puis la propriété firstname et lastname. Comme disait mon oncle qui a bossé pour le KGB : cette méthode en sait trop. Comment éviter cela ? Dans l'exemple ci-dessus, l'objet User devrait posséder une méthode qui renvoi directement ce dont getUserFatherName a besoin sans avoir à révéler la complexité de sa propre structure.

class Father {
private firstname: string;
private lastname: string;

getFullName(): string {
return `${this.firstname} ${this.lastname}`;
}
}

class User {
private father: Father;

getFatherFullName(): string {
return this.father.getFullName();
}
}

Ça y est, la méthode getUserFatherName ne sera jamais embêtée par les modifications qui pourraient survenir dans la classe User. Vous pouvez dormir sur vos deux oreilles (tonton, tu peux reposer ton pistolet).

Par contre je vous rassure tout de suite, la loi de Déméter ne vous empêche pas d’utiliser des fluent api et de façon générale le pattern Builder. Contrairement à l’exemple utilisé dans ce chapitre, des librairies comme TypeORM permettent de chaîner des méthodes, ok, mais elles renvoient tout le temps le même objet, tout en gardant bien encapsulée la complexité de son mécanisme interne.

Don’t abbreviate

Cette règle peut vous en apprendre beaucoup sur votre compréhension du métier que vous êtes en train d’implémenter. Écrivez en toutes lettres les différents termes qui constituent le nom de vos méthodes ou de vos variables. Ne laissez planer aucun doute dans l’esprit des collègues qui passeront après vous.

Regardez le code suivant :

const type: AttrType = 'STANDARD';
const attr = this.findAttr(type);

Que signifie “attr" ? Attractivity ? Attraction ? Attribution ? Est-ce que la méthode findAttr est assez claire quant à la nature de la recherche qu'elle implique ? Si la simple lecture d'une méthode ou d'une variable ne vous permet pas de comprendre ce qu'elle implique, et que cet état de fait apparaît un peu partout dans votre projet, mener l'enquête à chaque fois va vite vous épuiser et il vous paraîtra plus aisé de vous contenter de quelques suppositions. Et coder à partir de suppositions, cela revient à coder des bugs. Quand vous codez, privilégiez l'exhaustivité à la brièveté.

Ici on peut corriger l’extrait de code de la façon suivante :

const type: AttributeType = 'STANDARD';
const attributes = this.findAttributesByType(type);
// tadaaaa ! J'étais un attribut \\o/

Non seulement cela permet de redonner du sens métier à notre politique de nommage (on manipule des attributs, et ils possèdent un type), et en plus on comprend mieux la nature de la méthode findAttributesByType : elle retourne une liste d'attributs triés en fonction de leur type.

Si vous avez du mal à trouver un nom suffisamment clair pour votre méthode ou votre variable cela peut signifier que vous avez mal compris la fonctionnalité que vous devez implémenter. D’un autre côté, si vous commencez à écrire un nom de méthode tel que getUserRoleAndNormalizeItOrSkipIt, c'est le signe que vous vous apprêtez à attaquer une méthode qui fait beaucoup trop de choses. Ce sont de bons indices pour envisager un temps de réflexion, voire refacto supplémentaires, qui seraient bienvenus.

Keep all entities small

Bon eh bien cette règle stipule que 50 lignes de code pour un objet c’est déjà beaucoup trop. La limite de 50 lignes est bien sûr très arbitraire mais elle permet de prendre du recul après avoir fini de coder telle ou telle fonctionnalité. Cette prise de recul ne fait jamais de mal, elle permet de se poser la question suivante : n’ai-je pas, au cours de mon petit run de programmation, donné trop de responsabilités à ce composant ? Est-ce qu’il n’y aurait pas trop de duplications ? L’encapsulation est-elle bien respectée ? Etc… En bref, la plupart des conseils que l’on peut trouver dans les notions de Clean Code et ses corollaires vous permettront la plupart du temps de réduire la taille de vos composants de code. Alors qu’à l’inverse, quand un composant grandit encore et encore, cela signifie la plupart du temps que votre code n’est pas si clean que ça.

No classes with more than two instance variables

Oui bon, nous sommes de retour avec une règle soutenue par un chiffre arbitraire. Pas de classes avec plus de deux variables d’instance. Ok.

Alors non, ne vous jetez pas sur votre projet actuel pour compter toutes vos variables d’instance. Prenez ce chiffre de deux comme un baromètre : lorsque votre classe atteint sa troisième variable, puis sa quatrième, sa cinquième… prenez un peu de recul et demandez-vous : est-ce que cet afflux d’information soudain est réellement nécessaire pour comprendre la complexité métier de cette classe ? N’y a t’il pas un peu de refacto à faire ?

Prenons l’exemple suivant, et osez dire que vous n’êtes pas fatigué.e.s en la parcourant :

class User {
private firstName: string; // ok, bah mon user possède un nom et un prénom quoi...
private lastName: string; // bah oui, évidemment...
private street: string; // woooookay, une adresse
private zipCode: string; // ouais j'ai compris, je sais c'est quoi une adresse
private town: string; // non mais c'est bon là
private region: string; // je vais casser des trucs je pense
private country: string; // ... et si je faisais des lasagnes ce soir
}

Et si vous respiriez quelques secondes et alliez à l’essentiel ? Votre user possède un nom et une adresse, voilà. Votre vous du futur et vos collègues n’ont pas besoin d’en lire plus pour saisir le sens métier qu’ils ont sous les yeux.

Imaginez donc qu’on parvienne à atteindre ce niveau de refacto :

class User {
private name: Name; // ok, mon user a un nom + prénom
private address: Address; // ok, mon user a une adresse
}

Et évidemment, pour cette refaco nous pouvons compter sur les Value Objects vus dans la 3ème règle “Wrap all primitives and Strings” :

class Name {
private firstName: string;
private lastName: string;

constructor(firstName: string, lastName: string) {
if (/*conditions de validation*/) {
throw new Error('Invalid Inputs');
}
this.firstName = firstName;
this.lastName = lastName;
}
}

class Address {
private street: string;
private zipCode: string;
private town: string;
private region: string;
private country: string;

constructor(street: string, zipCode: string, town: string, region: string, country: string) {
if (/*conditions de validation*/) {
throw new Error('Invalid Inputs');
}
this.street = street;
this.zipCode = zipCode;
this.town = town;
this.region = region;
this.country = country;
}
}

À l’échelle d’un vrai projet de plusieurs milliers de lignes, votre effort de relecture s’en verra considérablement réduit et votre attention pourra se focaliser sur l’essentiel : le métier ;)

No getters/setters/properties

Sujet ô combien intéressant ! Les getters et les setters ont régné en maîtres sur nos codebases pendant des années et il est temps qu’ils subissent un sort à la Ned Stark !

Mais pourquoi tant de haine ? Eh bien si vous avez tout suivi jusque là, on aime bien que les classes elles-mêmes s’occupent d’instancier leurs propres données internes (snap ! la tête des setters) et on préférera aussi ne communiquer aux appelants que ce dont ils ont réellement besoin (couic ! la tête des getters).

Cette gymnastique permettra donc de ne coder que ce qui est nécessaire pour le métier. Cela renforce l’encapsulation et donc protège la mécanique interne des appelants comme des appelés et cela réduit aussi l’effort de lecture. Parce que entre nous, lire 60 lignes de getters / setters ne me dit rien quant au rôle intrinsèque de la classe que je suis en train de parcourir et très souvent 90% de ces méthodes seront soit inutilisées, soit utilisées à (très) mauvais escient.

Personnellement, si je suis assez formel sur la disparition des setters, je pense qu’il peut être possible de nécessiter des getters : si votre User possède une date de naissance, il est fort probable qu’un appelant en ait la nécessité telle qu’elle. Comme toujours il existe des cas à la marge, mais nous allons y revenir en conclusion.

Conclusion

Les 9 règles pour coder des objets callisthéniques ne sont pas un dogme à suivre à la lettre, évidemment. Prenez ça comme un nuancier auquel vous référer suivant certaines situations (classe devenant trop grande, multipliant les variables d’instances, comprenant beaucoup de duplications, appelant des composants de sous-composants, imbriquant les if et les for, etc.) et vous poussant à vous poser de saines questions.

Vous pourriez néanmoins de temps à autres vous obliger à les respecter afin de vous frotter aux différents concepts que chaque règle sous-entend (SOLID, Value Object, Déméter, etc.) et gagner quelques automatismes pour du refactoring efficace. Ce faisant, vous apprendrez avec le temps à mettre en pratique certains patterns, certains principes qui allégeront l’esprit de vos collègues et permettront à votre code de gagner en efficience en reflétant le métier plutôt qu’une complexité accidentelle.

Pour finir, étant friand d’acronymes (SOLID, DRY, WET, KISS…) j’en ai cherché un pour ces 9 règles (parce que ODWFODKNN, ça veut rien dire). Et comme je suis aussi un peu frileux face au fait de suivre les règles aveuglement, je vous propose mon acronyme pour les objets callisthéniques :

Demeter law has to be respected

One identation level per method

Grab the else

Minimize the number of lines

Avoid getters and setters

Talkative naming convention

Insure primitives into classes

Separate class for each collection

Minimize the instance variables

Ainsi vous pourrez briller durant vos PR lorsque vous parlerez de DOGMATISM avec vos collègues.

Ressources et notions citées dans cet article

--

--