Clean Code
Chapitre 6 / 14 · 11 min de lecture

Objets et structures de données

Objets qui cachent leurs données contre structures qui les exposent, loi de Déméter et trains de wagons.

On nous apprend très tôt à rendre nos variables privées et à les exposer par des getters et des setters. Le réflexe est si ancré qu'on le confond avec de l'encapsulation. Mais entourer une variable de deux fonctions qui se contentent de la lire et de l'écrire ne cache rien du tout : cela revient à laisser la porte grande ouverte tout en prétendant l'avoir fermée à clé.

Ce chapitre démonte ce réflexe et révèle une anti-symétrie fondamentale entre deux familles de code que l'on mélange trop souvent : les objets, qui cachent leurs données et exposent un comportement, et les structures de données, qui exposent leurs données et n'ont presque pas de comportement. Comprendre cette opposition — et savoir laquelle choisir — change radicalement la souplesse de vos systèmes.

Cacher l'implémentation, pas seulement les variables

Comparez ces deux façons de représenter un point dans le plan. Toutes deux modélisent la même donnée, et pourtant l'une expose son implémentation tandis que l'autre la masque complètement.

// ❌ Avant : la structure interne est exposée
class Point {
	x: number;
	y: number;
}
// ✅ Après : l'implémentation est totalement cachée
interface Point {
	getX(): number;
	getY(): number;
	setCartesian(x: number, y: number): void;
	getR(): number;
	getTheta(): number;
	setPolar(r: number, theta: number): void;
}

La beauté de la seconde version, c'est qu'il est impossible de savoir si le point est stocké en coordonnées rectangulaires ou polaires. Peut-être même ni l'un ni l'autre. L'interface décrit la nature de la donnée, pas sa forme concrète.

Mais elle fait plus que cacher : elle impose une politique d'accès. On peut lire chaque coordonnée indépendamment, mais on doit les écrire ensemble, comme une opération atomique. La version concrète, elle, trahit son implémentation rectangulaire et nous force à manipuler x et y séparément.

À retenir

Cacher l'implémentation, ce n'est pas glisser une couche de fonctions entre l'extérieur et les variables. C'est une affaire d'abstraction. Une classe ne pousse pas ses variables vers l'extérieur via des getters/setters : elle expose des interfaces abstraites qui laissent manipuler l'essence de la donnée sans en connaître la forme.

Prenez un véhicule dont on veut connaître le carburant. La version concrète parle en termes de capacité et de litres ; la version abstraite parle en pourcentage.

// ❌ Avant : on devine que ce sont de simples accesseurs
interface Vehicle {
	getFuelTankCapacityInLiters(): number;
	getLitersOfGasoline(): number;
}
// ✅ Après : aucune idée de la forme réelle de la donnée
interface Vehicle {
	getPercentFuelRemaining(): number;
}

La seconde est préférable. On ne veut pas exposer les détails de nos données ; on veut les exprimer en termes abstraits. Cela ne s'obtient pas mécaniquement avec des interfaces ou des accesseurs. La pire option est d'ajouter naïvement des getters et des setters. Il faut réfléchir sérieusement à la meilleure façon de représenter ce que l'objet contient.

L'anti-symétrie objet / structure de données

Ces exemples révèlent la différence entre objets et structures de données. Relisez ces deux définitions, car elles sont rigoureusement opposées :

  • Les objets cachent leurs données derrière des abstractions et exposent des fonctions qui opèrent sur ces données.
  • Les structures de données exposent leurs données et n'ont pas de fonction porteuse de sens.

Cette différence peut sembler triviale. Elle a pourtant des conséquences profondes. Pour les voir, prenons l'exemple emblématique du livre : la géométrie de quelques formes.

Note

Le livre est écrit en Java. Les exemples ci-dessous sont traduits en TypeScript idiomatique, mais l'esprit reste identique.

La version procédurale

Ici, les formes sont de simples structures de données sans comportement. Tout le comportement vit dans une classe Geometry qui opère sur elles.

type Square = { topLeft: Point; side: number };
type Rectangle = { topLeft: Point; height: number; width: number };
type Circle = { center: Point; radius: number };

type Shape = Square | Rectangle | Circle;

class Geometry {
	area(shape: Shape): number {
		if ("side" in shape) {
			return shape.side * shape.side;
		} else if ("width" in shape) {
			return shape.height * shape.width;
		} else {
			return Math.PI * shape.radius * shape.radius;
		}
	}
}

Un développeur orienté objet froncerait le nez : « c'est du procédural ». Il aurait raison, mais le mépris n'est pas justifié. Demandez-vous ce qui se passe si l'on ajoute une fonction perimeter() à Geometry. Les classes de formes ne changent pas. Tout ce qui dépend des formes ne change pas non plus. En revanche, si j'ajoute une nouvelle forme, je dois modifier toutes les fonctions de Geometry pour la prendre en compte.

La version orientée objet

Ici, la méthode area() est polymorphe. Aucune classe Geometry n'est nécessaire.

interface Shape {
	area(): number;
}

class Square implements Shape {
	constructor(private topLeft: Point, private side: number) {}
	area(): number {
		return this.side * this.side;
	}
}

class Rectangle implements Shape {
	constructor(
		private topLeft: Point,
		private height: number,
		private width: number,
	) {}
	area(): number {
		return this.height * this.width;
	}
}

class Circle implements Shape {
	constructor(private center: Point, private radius: number) {}
	area(): number {
		return Math.PI * this.radius * this.radius;
	}
}

Le comportement est exactement inverse. Si j'ajoute une nouvelle forme, aucune fonction existante n'est touchée. Mais si j'ajoute une nouvelle opération (par exemple perimeter()), je dois modifier toutes les classes de formes.

La dichotomie fondamentale

On retrouve la nature complémentaire des deux définitions. Elles sont des opposés parfaits, ce qui expose la dichotomie centrale du chapitre :

Le code procédural (qui utilise des structures de données) facilite l'ajout de nouvelles fonctions sans modifier les structures existantes. Le code OO, lui, facilite l'ajout de nouvelles classes sans modifier les fonctions existantes.

Le complément est tout aussi vrai :

Le code procédural rend difficile l'ajout de nouvelles structures de données, car toutes les fonctions doivent changer. Le code OO rend difficile l'ajout de nouvelles fonctions, car toutes les classes doivent changer.

Ce que l'on veut faire évoluerChoisir…Pourquoi
Ajouter souvent de nouveaux typesObjets / OOLes fonctions existantes ne bougent pas
Ajouter souvent de nouvelles opérationsStructures + procéduresLes types existants ne bougent pas

Autrement dit, ce qui est difficile en OO est facile en procédural, et inversement. Dans tout système complexe, il y aura des moments où l'on voudra ajouter des types : les objets sont alors les plus adaptés. D'autres fois, on voudra ajouter des fonctions : les structures de données et le procédural seront plus appropriés.

Astuce

L'idée que « tout est objet » est un mythe. Les développeurs expérimentés savent que l'on veut parfois, vraiment, de simples structures de données avec des procédures qui opèrent dessus.

La loi de Déméter

Une heuristique bien connue, la loi de Déméter, dit qu'un module ne devrait pas connaître les entrailles des objets qu'il manipule. Puisque les objets cachent leurs données et exposent des opérations, un objet ne devrait pas exposer sa structure interne via des accesseurs — ce serait précisément l'exposer au lieu de la cacher.

Plus précisément, une méthode f d'une classe C ne devrait appeler que les méthodes :

  • de C elle-même ;
  • d'un objet créé par f ;
  • d'un objet passé en argument à f ;
  • d'un objet détenu en attribut de C.

En revanche, elle ne devrait pas invoquer de méthode sur un objet renvoyé par l'une de ces fonctions autorisées. La formule à retenir :

Parlez à vos amis, pas aux étrangers.

Trains de wagons

Le code suivant viole la loi de Déméter : il appelle getScratchDir() sur le retour de getOptions(), puis getAbsolutePath() sur le retour de getScratchDir().

// ❌ Avant : un « train de wagons »
const outputDir = ctxt.getOptions().getScratchDir().getAbsolutePath();

On appelle souvent cela un train wreck (« déraillement »), car la chaîne ressemble à des wagons accrochés les uns aux autres. C'est généralement considéré comme négligé. La première intuition est de découper :

// Découpé, mais le problème de fond demeure
const opts = ctxt.getOptions();
const scratchDir = opts.getScratchDir();
const outputDir = scratchDir.getAbsolutePath();

Ces deux fragments violent-ils Déméter ? La fonction appelante sait que ctxt contient des options, qui contiennent un répertoire de travail, qui possède un chemin absolu. Cela fait beaucoup de connaissances pour une seule fonction, qui apprend à naviguer à travers de nombreux objets.

Piège courant

Découper un train de wagons en variables intermédiaires ne corrige rien : c'est de la cosmétique. Le couplage à la structure interne reste exactement le même.

La réponse dépend de la nature de ctxt, options et scratchDir. Si ce sont des objets, leur structure interne devrait être cachée, et y naviguer viole clairement Déméter. Si ce sont de simples structures de données sans comportement, elles exposent naturellement leur structure, et Déméter ne s'applique pas.

Ce sont les accesseurs qui brouillent la frontière. Si le code avait été écrit ainsi, on ne se poserait probablement pas la question :

// Avec des structures de données : pas de violation de Déméter
const outputDir = ctxt.options.scratchDir.absolutePath;

Tout serait plus clair si les structures de données avaient simplement des variables publiques et aucune fonction, et les objets des variables privées et des fonctions publiques. Hélas, certains standards (les « beans ») exigent des accesseurs même pour de simples structures.

Les hybrides : le pire des deux mondes

Cette confusion engendre des hybrides, mi-objets mi-structures de données. Ils ont des fonctions qui font des choses importantes, mais aussi des variables publiques (ou des accesseurs qui rendent les variables privées publiques de fait), invitant le code extérieur à les utiliser comme une structure de données procédurale.

Ajouter une fonctionAjouter un type
Objet purDifficileFacile
Structure de données pureFacileDifficile
HybrideDifficileDifficile

Les hybrides cumulent les inconvénients : difficiles à étendre par des fonctions et par des types. Ils trahissent un design confus, dont les auteurs ne savent pas — ou pire, ignorent — s'ils ont besoin de se protéger des fonctions ou des types. Évitez d'en créer.

Cacher la structure

Que faire si ctxt, options et scratchDir sont de vrais objets avec du comportement ? On ne devrait pas pouvoir naviguer à travers eux. Comment obtenir alors le chemin absolu du répertoire de travail ? Les deux réflexes échouent :

ctxt.getAbsolutePathOfScratchDirectoryOption(); // explosion de méthodes
ctxt.getScratchDirectoryOption().getAbsolutePath(); // suppose un DTO

Le premier conduit à une explosion de méthodes dans ctxt. Le second présume que getScratchDirectoryOption() renvoie une structure de données, pas un objet. Aucun ne satisfait.

La bonne question est : pourquoi voulait-on ce chemin absolu ? Pour en faire quoi ? La suite du module le révèle :

const outFile = `${outputDir}/${className.replace(".", "/")}.class`;
const stream = createFileOutputStream(outFile);

L'intention réelle était de créer un fichier temporaire d'un nom donné. Au lieu de demander à ctxt ses entrailles, disons-lui de faire le travail :

// ✅ Après : on dit à l'objet quoi faire, on ne l'interroge pas
const stream = ctxt.createScratchFileStream(classFileName);

Voilà une chose raisonnable pour un objet. Cela permet à ctxt de cacher ses internes et évite à la fonction appelante de naviguer à travers des objets qu'elle ne devrait pas connaître. C'est l'essence de Déméter : dire, ne pas demander (tell, don't ask).

DTO, beans et Active Record

Objets de transfert de données (DTO)

La forme quintessentielle de la structure de données est une classe avec des variables publiques et aucune fonction : un Data Transfer Object (DTO). Les DTO sont très utiles, notamment pour communiquer avec une base de données ou parser des messages réseau. Ils constituent souvent le premier étage d'une série de traductions qui transforment des données brutes en objets applicatifs.

// DTO : que des données, aucun comportement
type AddressDto = {
	street: string;
	streetExtra: string;
	city: string;
	state: string;
	zip: string;
};

La variante « bean » est plus répandue : des variables privées manipulées par getters et setters. Cette quasi-encapsulation rassure certains puristes de l'OO, mais n'apporte généralement aucun autre bénéfice que le confort moral.

Active Record

Les Active Records sont une forme particulière de DTO. Ce sont des structures de données à variables publiques (ou accédées via beans), mais dotées de méthodes de navigation comme save ou find. Ce sont typiquement des traductions directes de tables de base de données.

class UserRecord {
	id!: number;
	email!: string;
	passwordHash!: string;

	async save(): Promise<void> {
		/* écrit la ligne en base */
	}
	static async find(id: number): Promise<UserRecord> {
		/* lit la ligne */
		return null as any;
	}
}

Le piège est récurrent : les développeurs traitent ces structures comme des objets en y greffant des règles métier. On retombe alors sur l'hybride mi-structure mi-objet, le pire des deux mondes.

Attention

Mettre une règle métier (canCheckout(), applyDiscount()) directement dans un Active Record crée un hybride. La solution : traiter l'Active Record comme une structure de données, et créer des objets métier séparés qui contiennent les règles et cachent leurs données (lesquelles ne sont souvent qu'une instance de l'Active Record).

Choisir selon ce qu'on veut faire évoluer

Il n'y a pas de camp à défendre. Tout dépend de l'axe d'évolution que vous voulez privilégier :

  • Les objets exposent un comportement et cachent leurs données. On ajoute facilement de nouveaux types sans toucher aux comportements existants, mais difficilement de nouveaux comportements aux objets existants.
  • Les structures de données exposent leurs données et n'ont pas de comportement significatif. On ajoute facilement de nouveaux comportements sur des structures existantes, mais difficilement de nouvelles structures aux fonctions existantes.

Dans un système réel, on voudra parfois la souplesse d'ajouter des types — on préférera les objets pour cette partie. Ailleurs, on voudra la souplesse d'ajouter des comportements — on préférera les structures et les procédures. Les bons développeurs comprennent ces enjeux sans préjugé et choisissent l'approche la mieux adaptée à la tâche.

À retenir

  • Cacher l'implémentation, c'est abstraire, pas empiler des getters/setters sur chaque variable. La pire option est d'exposer naïvement des accesseurs.
  • Objets et structures de données sont des opposés : les uns cachent les données et exposent du comportement, les autres exposent les données et n'ont pas de comportement.
  • Le procédural facilite l'ajout de fonctions ; l'OO facilite l'ajout de types. Choisissez selon l'axe que vous voulez faire évoluer.
  • Loi de Déméter : parlez à vos amis immédiats, pas aux étrangers. Évitez les trains de wagons (a.getB().getC()...) en disant à l'objet quoi faire plutôt qu'en l'interrogeant.
  • Les hybrides mi-objet mi-structure cumulent tous les défauts : difficiles à étendre par fonctions comme par types. Bannissez-les.
  • Les DTO et Active Records sont de bonnes structures de données ; gardez les règles métier dans des objets séparés.