OCP - Open Closed Principle

Poursuivons notre exploration des principes SOLID entamée dans l'article consacré au SRP (Single responsibility principle) avec le second principe: l'Open Closed Principle.

Ce concept est considéré comme l'un des plus importants de la programmation orientée objet par de nombreux auteurs, dont Bertrand Meyer qui l'exprimait ainsi en 1988 dans son livre Object-Oriented Software Construction :

Un module (classe, fonction, etc.) doit être à la fois ouvert (à l'extension) et fermé (à la modification).

Un module est dit ouvert s'il est possible d'étendre son fonctionnement.
Un module est dit fermé s'il peut être étendu sans être modifié.

Le concept même de ce principe est excellent : Il nous impose de concevoir notre projet de telle manière qu'il soit possible d'ajouter de nouvelles fonctionnalités en ajoutant du code, pas en modifiant le code existant 🤯

Il vous est peut-être arrivé, lors de l'ajout d'une nouvelle fonctionnalité à une application, de devoir modifier de vastes portions du code, ces modifications étant d'ailleurs parfois redondantes et quasiment identiques (voir Shotgun surgery).

Cette pratique, symptôme d'un système rigide (et bien souvent fragile) (cf les Design smells de l'inoxydable Uncle Bob), est dangereuse : il est très facile de casser ainsi un module pourtant parfaitement fonctionnel avant modification de sa source.

smart

Il est toujours nocif de modifier un code de production qui est, à priori, testé et éprouvé, alors que le comportement de celui-ci n'est pas directement concerné par le changement.

Il existe pourtant des moyens permettant de modifier le comportement d'un module sans avoir à le modifier.

Si Bertrand Meyer proposait d'utiliser l'héritage pour atteindre cet objectif (ce qui peut introduire un couplage fort si les sous-classes dépendent de détails d'implémentation des classes dont elles héritent), on préfèrera l'utilisation du polymorphisme se basant sur des interfaces pouvant être facilement interchangées : cela ajoute un niveau d'abstraction permettant un couplage faible.

Pour illustrer cela prenons un exemple, en restant dans le cadre de la conception de notre jeu imaginé dans l'article sur les design pattern créationnels: Super bob

Superbob against the evil warlock

Un exemple d'application de l'OCP

Nous souhaitons introduire une nouvelle entité Stable qui peut produire différents types de chevaux pour notre héros, qui en aura bien besoin dans les quêtes épiques qui l'attendent !

Comme nous sommes consciencieux, nous développons cette feature en TDD ce qui nous donne, après quelques itérations, la suite de tests et l'implémentation suivante :

// stable.spec.ts
// ----------------------------------------
import { Stable } from './main';
 
describe('Stable', () => {
  let stable: Stable;
 
  beforeEach(() => {
    stable = new Stable();
  });
 
  describe('makeHorse', () => {
    it('makes a horse from a known type', () => {
      expect(stable.makeHorse('default-horse')).toMatchObject({
        type: 'default-horse',
      });
      expect(stable.makeHorse('rare-horse')).toMatchObject({
        type: 'rare-horse',
      });
    });
 
    it('raises an error for an unknown type of horse', () => {
      const failingOperation = () => {
        stable.makeHorse('donkey');
      };
      expect(failingOperation).toThrow(new Error('Unsupported type of horse'));
    });
  });
});
 
// stable.ts
// ----------------------------------------
export class Stable {
  makeHorse(horseType: string) {
    switch (horseType) {
      case 'default-horse':
        return {
          type: horseType,
          icon: '🐎',
          desc: 'A really common horse, with low stamina and speed',
        };
      case 'rare-horse':
        return {
          type: horseType,
          icon: '🏇',
          desc: 'A fast and sturdy horse',
        };
 
      default:
        throw new Error('Unsupported type of horse');
    }
  }
}

Tout cela est formidable 🎉

Une question se pose maintenant : comment faire évoluer cette classe pour gérer de nouveaux types de chevaux ?

Nous pourrions modifier la méthode makeHorse pour chaque nouveau type, mais cela, en plus d'être fastidieux, viole clairement la règle de l'OCP : le classe doit pouvoir gérer de nouveaux besoins sans être modifiée.

Une manière de faire serait d'introduire une interface HorseMaker qui pourrait avoir plusieurs implémentations concrètes. Ce sont ces différents makers qui sont ensuite passés à la class Stable, devenant ainsi les seules sources de création de chevaux.

// stable.spec.ts
// ----------------------------------------
import { DefaultHorseMaker, RareHorseMaker, Stable } from './stable';
 
describe('Stable', () => {
  let stable: Stable;
 
  beforeEach(() => {
    stable = new Stable(new DefaultHorseMaker(), new RareHorseMaker(), new UnicornMaker());
  });
 
  describe('makeHorse', () => {
    it('makes a horse from a known type', () => {
      expect(stable.makeHorse('default-horse')).toMatchObject({
        type: 'default-horse',
      });
      expect(stable.makeHorse('rare-horse')).toMatchObject({
        type: 'rare-horse',
      });
    });
 
    it('raises an error for an unknown type of horse', () => {
      const failingOperation = () => {
        stable.makeHorse('donkey');
      };
      expect(failingOperation).toThrow(new Error('Unsupported type of horse'));
    });
  });
});
 
// stable.ts
// ----------------------------------------
interface Horse {
  type: string;
  icon: string;
  desc: string;
}
 
interface HorseMaker {
  type: string;
  make(): Horse;
}
 
export class DefaultHorseMaker implements HorseMaker {
  type = 'default-horse';
  make(): Horse {
    return {
      type: this.type,
      icon: '🐎',
      desc: 'A really common horse, with low stamina and speed',
    };
  }
}
 
export class RareHorseMaker implements HorseMaker {
  type = 'rare-horse';
  make(): Horse {
    return {
      type: this.type,
      icon: '🏇',
      desc: 'A fast and sturdy horse',
    };
  }
}
 
export class Stable {
  private horseMakers: Map<string, HorseMaker>;
 
  constructor(...horseMakers: HorseMaker[]) {
    this.horseMakers = horseMakers.reduce((makers, maker) => {
      makers.set(maker.type, maker);
      return makers;
    }, new Map<string, HorseMaker>());
  }
 
  makeHorse(horseType: string) {
    const horseMaker = this.horseMakers.get(horseType);
    if (typeof horseMaker !== 'undefined') {
      return horseMaker.make();
    }
    throw new Error('Unsupported type of horse');
  }
}

Il est désormais possible de faire en sorte que Stable produise d'autres types de chevaux sans toucher à son code !

//stable.spec.ts
// ----------------------------------------
import { UnicornMaker, Stable } from './stable';
 
describe('Stable', () => {
  let stable: Stable;
 
  beforeEach(() => {
    stable = new Stable(new DefaultHorseMaker(), new RareHorseMaker(), new UnicornMaker());
  });
 
  describe('makeHorse', () => {
    it('makes a horse from a known type', () => {
      expect(stable.makeHorse('unicorn')).toMatchObject({
        type: 'unicorn',
      }); // -> ✅
    });
  });
});
 
// stable.ts
// ----------------------------------------
export class UnicornMaker implements HorseMaker {
  type = 'unicorn';
  make(): Horse {
    return {
      type: this.type,
      icon: '🦄',
      desc: 'A fairy horselike animal with a horn',
    };
  }
}
 
// pas de chagement dans la class `Stable`
export class Stable {
  private horseMakers: Map<string, HorseMaker>;
 
  constructor(...horseMakers: HorseMaker[]) {
    this.horseMakers = horseMakers.reduce((makers, maker) => {
      makers.set(maker.type, maker);
      return makers;
    }, new Map<string, HorseMaker>());
  }
 
  makeHorse(horseType: string) {
    if (this.horseMakers.get(horseType)) {
      return this.horseMakers.get(horseType)?.make();
    }
    throw new Error('Unsupported type of horse');
  }
}

Intérêts de l'OCP

L'OCP est capital parce qu'il permet de faire évoluer notre produit tout en :

  • n'ayant pas besoin de modifier de vastes portions de code existant pour y parvenir
  • étant certain que le code existant est protégé de tout effet indésirable puisqu'il ne sera pas touché

Un moyen de réaliser l'utilité et la puissance de l'OCP est de le voir appliqué dans un outil que nous utilisons tous les jours : notre IDE. Que ce soit VSCode (💕), Webstorm, Eclispe ou encore Vim, il est possible d'étendre les fonctionnalités de l'outil sans avoir à en modifier le code via l'utilisation de plugins.

merci_ocp

Comment cela est-il possible ? Ces applications respectent consciencieusement l'OCP qui protège les règles de haut niveau qui les régissent des détails d'implémentation. Elles ont soigneusement géré leurs dépendances, en inversant celles qui franchissaient des limites architecturales importantes dans la mauvaise direction.

Si ce but ultime n'est pas toujours simple (ni même possible) à atteindre, cet article vous aura malgré tout, je l'espère, convaincu de l'intérêt de l'Open Closed Principle !