Quatrième épisode de la série SOLID. Le I désigne l'Interface Segregation Principle, le principe de ségrégation des interfaces (on parle aussi de « séparation » des interfaces).
La définition
Aucun client ne devrait être forcé de dépendre de méthodes qu'il n'utilise pas.
Robert C. Martin l'a formulé dans les années 1990, alors qu'il intervenait comme consultant chez Xerox. Une seule grosse classe y servait à toutes les tâches d'une imprimante (impression, agrafage…) : la moindre modification obligeait à recompiler et redéployer tout le système. Le remède : donner à chaque client une interface à sa mesure.
Le « client », ici, c'est le code qui utilise une interface, pas celui qui l'implémente.
Une interface fourre-tout
Reprenons le stockage de fichiers de l'article précédent. Au fil du temps, l'interface a grossi :
<?php
interface Stockage
{
public function lire(string $chemin): string;
public function existe(string $chemin): bool;
public function enregistrer(string $chemin, string $contenu): void;
public function supprimer(string $chemin): void;
/** @return list<string> */
public function lister(string $dossier): array;
public function urlPublique(string $chemin): string;
}
Le stockage sur disque sait tout faire. Mais un CDN est en lecture seule, et une archive ZIP n'a pas d'URL publique. Leurs implémentations se remplissent de méthodes qui ne servent qu'à faire semblant :
<?php
final class StockageCdn implements Stockage
{
public function __construct(private string $urlDeBase) {}
public function lire(string $chemin): string
{
$contenu = file_get_contents($this->urlPublique($chemin));
if ($contenu === false) {
throw new RuntimeException("Fichier introuvable : $chemin");
}
return $contenu;
}
public function existe(string $chemin): bool
{
$entetes = get_headers($this->urlPublique($chemin));
return $entetes !== false && str_contains($entetes[0], ' 200');
}
public function urlPublique(string $chemin): string
{
return rtrim($this->urlDeBase, '/') . '/' . ltrim($chemin, '/');
}
// ✗ Trois méthodes imposées par l'interface, inutilisables ici.
public function enregistrer(string $chemin, string $contenu): void
{
throw new LogicException('Le CDN est en lecture seule.');
}
public function supprimer(string $chemin): void
{
throw new LogicException('Le CDN est en lecture seule.');
}
public function lister(string $dossier): array
{
throw new LogicException('Le CDN ne sait pas lister un dossier.');
}
}
On retrouve la violation du principe de Liskov vue dans l'article précédent. Et les problèmes ne s'arrêtent pas là :
- une classe qui importe un fichier CSV dépend de
supprimer()et deurlPublique(), qu'elle n'utilise jamais ; - pour la tester, il faut écrire un faux stockage de six méthodes, dont quatre inutiles ;
- changer la signature de
lister()oblige à modifier toutes les implémentations, même celles qui ne savent pas lister ; - en lisant le constructeur d'une classe, impossible de savoir ce dont elle a vraiment besoin.
Des interfaces de rôle
La solution consiste à découper l'interface selon les besoins des clients. On obtient des interfaces de rôle, petites et cohérentes :
<?php
interface LecteurFichiers
{
public function lire(string $chemin): string;
public function existe(string $chemin): bool;
}
interface EcrivainFichiers
{
public function enregistrer(string $chemin, string $contenu): void;
public function supprimer(string $chemin): void;
}
interface ListeurFichiers
{
/** @return list<string> */
public function lister(string $dossier): array;
}
interface FournisseurUrl
{
public function urlPublique(string $chemin): string;
}
// Le disque sait lire, écrire et lister.
final class StockageDisque implements LecteurFichiers, EcrivainFichiers, ListeurFichiers
{
// …
}
// Le CDN sait lire et fournir une URL. Rien d'autre, et il ne prétend rien d'autre.
final class StockageCdn implements LecteurFichiers, FournisseurUrl
{
// …
}
Chaque implémentation déclare uniquement ce qu'elle sait faire. Plus aucune méthode « non supportée » : le CDN ne promet tout simplement pas d'écrire.
Côté clients, chaque classe demande exactement ce dont elle a besoin. Son constructeur devient une documentation fiable :
<?php
final class ImportAbonnesCsv
{
// Il suffit de lire le constructeur pour savoir que cette classe ne fait que lire.
public function __construct(private LecteurFichiers $fichiers) {}
/** @return list<array{nom: string, email: string}> */
public function importer(string $chemin): array
{
$lignes = explode("\n", trim($this->fichiers->lire($chemin)));
array_shift($lignes); // ligne d'en-tête
return array_map(function (string $ligne): array {
[$nom, $email] = str_getcsv($ligne, ',', '"', '');
return ['nom' => $nom, 'email' => $email];
}, $lignes);
}
}
Et le test n'a besoin que des deux méthodes de LecteurFichiers. Une classe anonyme suffit :
<?php
use PHPUnit\Framework\TestCase;
final class ImportAbonnesCsvTest extends TestCase
{
public function testImporteChaqueLigneDuFichier(): void
{
$fichiers = new class implements LecteurFichiers {
public function lire(string $chemin): string
{
return "nom,email\nAda,ada@example.org\nAlan,alan@example.org";
}
public function existe(string $chemin): bool
{
return true;
}
};
$abonnes = (new ImportAbonnesCsv($fichiers))->importer('abonnes.csv');
$this->assertCount(2, $abonnes);
$this->assertSame(['nom' => 'Alan', 'email' => 'alan@example.org'], $abonnes[1]);
}
}
Recomposer quand c'est utile
Pour les rares clients qui ont besoin de tout, on peut regrouper les rôles dans une interface plus large. En PHP, une interface peut en étendre plusieurs :
<?php
interface Stockage extends LecteurFichiers, EcrivainFichiers, ListeurFichiers
{
}
final class StockageDisque implements Stockage
{
// …
}
La bibliothèque Flysystem, très utilisée en PHP pour manipuler des fichiers, fait exactement cela : FilesystemReader pour la lecture, FilesystemWriter pour l'écriture, et FilesystemOperator qui réunit les deux.
PHP montre l'exemple
Le langage lui-même applique ce principe. Ses interfaces natives sont minuscules : Countable n'a qu'une méthode, count() ; Stringable n'a que __toString() ; JsonSerializable n'a que jsonSerialize(). Une classe les combine selon ce qu'elle sait faire, et une fonction qui veut compter des éléments ne demande qu'un Countable. Les standards PSR suivent la même logique : ContainerInterface (PSR-11) n'expose que get() et has().
Jusqu'où découper ?
Comme pour la responsabilité unique, il ne s'agit pas de créer une interface par méthode. Le bon découpage part des clients : regroupez les méthodes qui sont utilisées ensemble. Si tous vos clients appellent toujours lire() et existe() ensemble, elles vont dans la même interface.
À retenir
- Une interface se conçoit du point de vue de ceux qui l'utilisent, pas de ceux qui l'implémentent.
- Une méthode qui lève « non supporté » ou une implémentation vide signale une interface trop large.
- Plusieurs petites interfaces de rôle valent mieux qu'une grosse interface générale.
- Une interface peut en étendre plusieurs pour les clients qui ont besoin de tout.
Nos classes dépendent maintenant de petites interfaces plutôt que de classes concrètes. C'est exactement l'idée du dernier principe, que nous verrons dans le prochain article : l'inversion des dépendances.
La série SOLID
- S : responsabilité unique
- O : ouvert/fermé
- L : substitution de Liskov
- I : ségrégation des interfaces (vous êtes ici)
- D : inversion des dépendances