À première vue, les paradigmes de la programmation orientée objet (POO) et de la programmation fonctionnelle (FP) semblent être des opposés absolus. La POO repose sur l'état mutable, l'héritage et l'encapsulation, tandis que la FP prône l'immuabilité, les fonctions pures et les transformations de données. Pourtant, l'objectif fondamental des deux est le même : construire un logiciel maintenable, évolutif et testable. Les principes SOLID, initialement formulés pour la POO, fournissent un cadre robuste pour cet objectif. Appliqués avec discernement, ils s'alignent de manière surprenante sur les concepts fonctionnels, offrant une approche hybride qui maximise les forces des deux mondes.
Principe de responsabilité unique (SRP) : Une fonction, une tâche
En POO, le SRP stipule qu'une classe ne doit avoir qu'une seule raison de changer. En programmation fonctionnelle, cela se traduit par le maintien de fonctions petites et focalisées sur une seule transformation. Au lieu de créer une grande classe avec plusieurs méthodes qui pourraient changer pour différentes raisons, nous décomposons la logique en petites fonctions pures.
// Mauvais : Une fonction qui fait trop de choses
function processOrder(order) {
validateOrder(order);
calculateTax(order);
sendEmail(order.customer);
updateDatabase(order);
}
// Bon : Petites fonctions focalisées et pures
const validateOrder = (order) => order.items.length > 0;
const calculateTax = (amount) => amount * 0.08;
const createInvoice = (order, tax) => ({ ...order, tax });
function processOrder(order) {
if (!validateOrder(order)) throw new Error("Commande invalide");
const tax = calculateTax(order.total);
const invoice = createInvoice(order, tax);
// Les effets de bord sont gérés séparément ou via des monades
return invoice;
}
En veillant à ce que chaque fonction fasse une seule chose bien, nous nous assurons que les modifications de la logique de taxe ne cassent pas la logique de validation. Cette modularité est une interprétation fonctionnelle du SRP.
Principe ouvert/fermé (OCP) : Étendre le comportement sans modification
L'OCP stipule que les entités doivent être ouvertes à l'extension mais fermées à la modification. En FP, nous y parvenons grâce aux fonctions d'ordre supérieur et aux motifs de stratégie implémentés comme paramètres de fonction. Au lieu de sous-classe une classe `Shape` pour ajouter un nouveau `Square` (Carré), nous passons une fonction de calcul comme argument.
// L'approche POO nécessite de modifier la classe de base ou de créer des sous-classes
// L'approche FP : Utiliser la composition et les paramètres de fonction
const areaCalculator = (shape) => {
switch (shape.type) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'square':
return shape.side ** 2;
case 'rectangle':
return shape.width * shape.height;
default:
throw new Error("Forme inconnue");
}
};
// Pour ajouter un 'triangle', vous modifiez la fonction calculatrice.
// Pour respecter strictement l'OCP, nous pouvons utiliser une table de recherche :
const areaStrategies = {
circle: (s) => Math.PI * s.radius ** 2,
square: (s) => s.side ** 2,
triangle: (s) => 0.5 * s.base * s.height // Nouvelle extension
};
const getArea = (shape) => areaStrategies[shape.type](shape);
Bien que la table de recherche nécessite toujours une modification pour ajouter des clés, elle isole le changement à la définition des données plutôt qu'à la logique centrale. Des techniques FP plus avancées consistent à passer la fonction de stratégie elle-même comme dépendance, rendant la fonction `getArea` complètement agnostique quant aux formes spécifiques qu'elle traite.
Principe de substitution de Liskov (LSP) : Sécurité des types et contrats
Le LSP garantit que si `S` est un sous-type de `T`, alors `T` peut être remplacé par `S` sans altérer la correction du programme. Dans les langages fonctionnels, le LSP est souvent satisfait grâce à des systèmes de types forts et à des types de données algébriques. Si une fonction s'attend à un `Animal` et que vous passez un `Dog` (Chien), la fonction doit toujours se comporter correctement car `Dog` satisfait aux exigences structurelles ou nominales de `Animal`.
// Exemple TypeScript illustrant le LSP via le typage structurel
interface Animal {
name: string;
eat(): void;
}
function feed(animal: Animal) {
console.log(`Nourrir ${animal.name}`);
animal.eat();
}
class Dog implements Animal {
name: string;
constructor(name: string) {
this.name = name;
}
eat(): void {
console.log(`${this.name} mange des croquettes`);
}
// Dog peut être passé à feed() en toute sécurité
}
En FP pur, nous évitons les pièges de l'héritage en nous appuyant sur la composition et des contraintes de types explicites. Si une fonction est `f: A -> B`, toute fonction qui accepte `A` et retourne `B` est un substitut valide, à condition qu'elle respecte les mêmes contraintes d'effets de bord.
Principe de ségrégation des interfaces (ISP) : Interfaces fines
L'ISP suggère qu'aucun client ne devrait être forcé de dépendre de méthodes qu'il n'utilise pas. En POO, cela est réalisé en créant des interfaces plus petites. En FP, comme nous n'utilisons pas les interfaces de la même manière, nous atteignons l'ISP par la décomposition des fonctions et l'injection de dépendances de fonctions spécifiques plutôt que de grands objets.
Au lieu de passer un grand objet `UserManager` avec 20 méthodes où seulement 2 sont nécessaires, nous passons les deux fonctions spécifiques requises :
// Mauvais : Couplage à un grand objet
function renderProfile(userManager: UserManager) {
const user = userManager.getUser();
const photos = userManager.getPhotos();
// ...
}
// Bon : Faible couplage via des fonctions spécifiques
function renderProfile(
getUser: () => User,
getPhotos: (userId: string) => Photo[]
) {
const user = getUser();
const photos = getPhotos(user.id);
// ...
}
Cette approche garantit que `renderProfile` ne dépend que du comportement exact dont il a besoin, ce qui le rend plus facile à tester et à modifier.
Principe d'inversion des dépendances (DIP) : Abstraction plutôt qu'implémentation
Le DIP stipule que les modules de haut niveau ne doivent pas dépendre des modules de bas niveau ; tous deux doivent dépendre d'abstractions. En FP, nous y parvenons en dépendant de types (abstractions) et en injectant des implémentations concrètes (fonctions) à l'exécution. C'est le cœur du Développement Piloté par les Tests (TDD) dans les langages fonctionnels.
// Module de haut niveau dépendant d'une abstraction (type)
type DataStore = {
save: (item: Item) => Promise;
fetch: (id: string) => Promise- ;
};
function processItem(store: DataStore, item: Item) {
// Logique qui ne dépend que de l'interface DataStore
return store.save(item);
}
// Modules de bas niveau (implémentations)
const memoryStore: DataStore = {
save: async (item) => { /* ... */ },
fetch: async (id) => { /* ... */ }
};
const dbStore: DataStore = {
save: async (item) => { /* ... */ },
fetch: async (id) => { /* ... */ }
};
// Utilisation de la fonction de haut niveau avec différentes abstractions
processItem(memoryStore, item1);
processItem(dbStore, item2);
En définissant `DataStore` comme un alias de type ou une interface, nous créons une abstraction. La fonction `processItem` dépend de cette abstraction, et non de l'implémentation spécifique. Cela nous permet de remplacer le magasin de données pour les tests ou la production sans modifier la logique métier.
Conclusion : Une approche hybride pour une architecture robuste
L'application des principes SOLID en programmation fonctionnelle ne consiste pas à forcer les motifs POO dans le code FP, mais plutôt à reconnaître les objectifs de conception sous-jacents : modularité, testabilité et maintenabilité. En exploitant les fonctions pures, l'immuabilité et les systèmes de types forts, nous pouvons satisfaire l'esprit de SOLID tout en profitant des avantages du code fonctionnel. Le résultat est un logiciel plus facile à raisonner, à tester et à étendre, quel que soit le paradigme que nous choisissons de privilégier.
Lorsque vous concevez votre prochain système, considérez ces principes non pas comme des règles rigides, mais comme des phares qui vous aident à construire des composants qui fonctionnent ensemble harmonieusement. Que vous utilisiez JavaScript, TypeScript, Scala ou Rust, l'idée centrale reste la même : concevoir pour le changement, et votre code restera résilient face aux exigences évolutives.