5 principes de développement : SOLID

applications internet utilitaires

Ce site ne sera plus alimenté de contenu après août 2014. Tous les nouveaux articles seront redigés pour www.waitingforcode.com
Dans le développement on est souvent pressés de faire une chose vite car un tel la demande. Sous la pression du temps on est souvent "tentés" d'oublier de bonnes pratiques de développement en se disant de refactoriser le code plus tard. Le plus tard qui n'arrive jamais d'ailleurs car un autre grand projet "urgent" vient de naître. Pour palier à cette problématique de la pression et de la qualité du code, un acronym a été défini : SOLID.

A travers l'article ci-dessous on verra ce que signifie ce mysterieux acronyme. On le décomposera en 5 notions qui le fondent, en commençant par le principe de responsabilité unique, pour passer sur ouvert/ferme, la substitution de Liskov, la segrégation des interfaces. Pour terminer, on expliquera l'inversion des dépendances. L'illustration de chaque lettre composant l'acronyme sera accompagnée d'un bon et d'un mauvais exemple. Comme exemple, on utilisera un fragment d'une classe de recherche qui va comptabiliser chaque nouvelle recherche pour un mot donné dans un système de fichier.

S : Single responsibility principle
Le mot clé de ce principe est responsabilité. Par résponsabilité on comprend le rôle dont une classe doit jouer. Le rôle est, quant à lui, traduit par l'ensemble de fonctionnalités ayant un sens commun. Cela peut être, par exemple, la gestion d'une connexion à la base de données ou la recherche dans un annuaire. Prenons ce dernier exemple. Le principe de la responsabilité unique est gardé uniquement dans la situation où cette classe ne s'occupe que de faire la recherche. Ceci dit, quand elle se présente de cette manière :

interface SearchCounter {
public void notifySearch(String text);
}

class Search{

public List<String> search(String text){
// make the search
final List<String> results=new ArrayList<String>();
return results;
}


Search s = new Search();
SearchCounter sn = new DatabaseCounter();
sn.notify("test");
s.search("test");

On voit que les fonctionnalités sont ici partagées. L'interface SearchCounter (et ses implémentations) ne s'occupera que d'enregistrer une nouvelle recherche pour un texte donné. La classe Search va se charger d'effectuer la recherche dans la méthode search(String text). Le contre-exemple pourrait se présenter ainsi :


class Search{
public void notify(String text) {
// ...
}

public List<String> search(String text){
this.notify(text);
// make the search
final List<String> results=new ArrayList<String>();
return results;
}
}

Search s = new Search();
s.notify("test");
s.search("test");


Pourquoi le contre-exemple est considéré comme une mauvaise pratique ? En réalité, il privilégie le concept du couplage fort entre deux fonctionnalités. Cela complexifie la maintenance de l'application par la difficulté d'écrire rapidement et clairement les tests automatiques. En outre, la réutilisation de la méthode du formattage du texte est impossible pour un autre composant. Ceci viole un autre principe, celui du Don't Repeat Yourself (DRY). Le code ci-dessus est aussi moins lisible pour les développeurs qui viennent de le découvrir. Comment un nouveau développeut peut savoir que dans la classe Search on enregistre la recherche pour un texte donné ? Vu le nom de la classe, ce n'est pas évident. En outre, si demain on voudra enregistrer ces statistiques dans un fichier, on n'aura qu'à changer la classe appelée avant la méthode s.search("test"). Et on peut être sûr que les modifications y effectuées n'influenceront pas le fonctionnement de la classe Search qui fera son unique travail qui est la recherche.

O : Open/closed principle
Ce principe prône que le rajout de chaque nouvelle fonctionnalité devrait être effectué avec le moins des modifications possibles sur le code déjà existant. C'est pour cela qu'on dit qu'une classe devrait être ouverte aux extensions, mais fermée pour les modifications. Idéalement, toute modification effectuée sur une classe ne devrait être que le correctif d'un bug. Ce principe s'explique très bien par la programmation avec interfaces. Pour résumer, on devrait pouvoir enrichir une classe (ou une méthode d'ailleurs) de nouvelles fonctionnalités en minimalisant les modifications du code existant. Pour cela reprenons notre exemple ci-dessus, mais avec la méthode de notification incluse dans celle de recherche :

interface SearchCounter {
public void notifySearch(String text);
}

class Search{
private SearchCounter counter;

public void setSearchCounter(SearchCounter counter) {
this.counter = counter;
}

public List<String> search(String text){
this.counter.notify(text);
// make the search
final List<String> results=new ArrayList<String>();
return results;
}
}

Search s = new Search();
s.setSearchCounter(new DatabaseCounter());
s.search("test");


Si demain on souhaite que PlainTextSearchCounter enregistre le nombre de recherches pour un mot, il suffira de créer une nouvelle classe implémentant l'interface SearchCounter et de la passer via setSearchCounter(SearchCounter counter) à la classe Search. Aucune modification de la classe Search ne sera alors nécessaire. Regardons maintenant le contre-exemple :

class Search{

public List<String> search(String text){
try {
// get database connection
// put value to the right table
// update the table
// ... and so on
} catch (Exception e) {

}
// make the search
final List<String> results=new ArrayList<String>();
return results;
}
}

Search s = new Search();
s.search("test");

Ici le rajout d'une nouvelle fonctionnalité qui peut être representée par le comptage des recherches dans les fichiers textuels, nécessite les modifications dans la méthode search(String text). Et il ne s'agit pas de modifications mineures comme le rajout d'une protection contre NullPointerException, mais bien d'un grand changement fonctionnel. Un changement fonctionnel qui peut en provoquer d'autres, par exemple dans une application qui s'occupe d'afficher les mots fréquemment recherchés.

L : Liskov Substitution principle
En raccourci, LSP, ce principe veut dire que les sous-classes ne peuvent pas changer fondamentalement le comportement de la classe mère. Autrement dit, elles doivent respecter les contraintes imposées par cette classe. Pour l'illustrer, faisons d'abord violer ce principe dans notre classe du compteur des mots recherchés. Supposons qu'au début de l'application on faisait le comptage sur les fichiers .txt ou .csv. Le code se présentait ainsi et il fonctionnait correctement :

interface SearchCounter {
public void notifySearch(String text);
}

class TextCounter implements SearchCounter {
public void notifySearch(String text) {
// put new data into text file in plaintext format
}
}

class CsvCounter implements SearchCounter {
public void notifySearch(String text) {
// put new data into text file in CSV format (separated by ;)
}
}

class Search{
private SearchCounter counter;

public void setSearchCounter(SearchCounter counter) {
this.counter = counter;
}

public List<String> search(String text){
this.counter.notify(text);
// make the search
final List<String> results=new ArrayList<String>();
return results;
}
}

Search s = new Search();
s.setSearchCounter(new CsvCounter());
s.search("test");

Mais un jour la lecture de gros fichiers est devenue très difficile. Pour plus de simplicité, on a décidé d'utiliser un compteur en base de données. Le code de la nouvelle classe se présente comme suit :

class DatabaseCounter {
public void notifySearch(String text) {
// put new data into database
}
}

Search s = new Search();
s.setSearchCounter(new DatabaseCounter());
s.search("test");


Tout a l'air correct. Tout sauf le fait que pour écrire dans une base de données on doit y être connecté. Et la méthode notifySearch(String text) ne fait qu'enregistrer une nouvelle recherche pour un mot donné. Bien sûr, on peut ouvrir une connexion à la base à l'intérieur de cette méthode. Et cette solution pourrait passer, même si la solution plus propre consisterait à redéfinir l'interface SearchCounter de cette manière :

interface SearchCounter {
public void openResource() throws Exception;
public void notifySearch(String text);
public void closeResource() throws Exception;
}

class Search{
private SearchCounter counter;

public void setSearchCounter(SearchCounter counter) {
this.counter = counter;
}

public List<String> search(String text){
try {
this.counter.openResource();
this.counter.notify(text);
// make the search
final List<String> results=new ArrayList<String>();
return results;
} catch (Exception e) {
} finally {
if (this.counter != null) {
try {
this.counter.closeResource();
} catch (Exception e) {
}
}
}
return null;
}
}

// and for database counter
Search s = new Search();
s.setSearchCounter(counter);
s.search("test");


On peut donc relier le principe de Liskov à la modularité des applications. Une fonctionnalité devrait toujours faire la même chose, indepéndamment du module [classe] qu'on utilise. Le principe de Liskov permet de préciser la cohérence de l'héritage par rapport au besoin fonctionnel. Si la fonctionnalité se comporte d'une manière inattendue, on considère alors que le principe est violé.

I : Interface segregation principle
Ce principe ressemble à celui de la responsabilité unique, mais il se résume exclusivement aux interfaces. Il prône que chaque classe doit éviter d'implémenter les méthodes inutiles. Autrement dit, une classe doit dépendre de sa plus petite interface. Le respect de ce principe permet d'éviter l'écriture des fat interfaces (interfaces grosses) qui regroupent une multitude de fonctions. Une multitude de fonctions parmi lesquelles l'implémentation A peut avoir besoin de toutes tandis que l'implémentation B peut nécessiter la définition de quelques-unes. Le principe de la segrégation des interfaces permet de mieux séparer les fonctionnalités des interfaces.

Contatons cela sur la modification de notre interface SearchCounter et son implémentation DatabaseSearchCounter. Imaginons qu'on a besoin maintenant de notifier un utilisateur de l'enregistrement d'une nouvelle entrée. Mais la notificatio n'est valable que pour les méthodes de sauvegarde à risque, comme dans le fichier texte :

interface SearchCounter {
public void notifySearch(String text);
public void sendEmail(String email);
}

class TextCounter implements SearchCounter {
public void notifySearch(String text) {
// put new data into text file in plaintext format
}

public void sendEmail(String email) {
// send an e-mail
}
}

class CsvCounter implements SearchCounter {
public void notifySearch(String text) {
// put new data into text file in CSV format (separated by ;)
}

public void sendEmail(String email) {
// do nothing
}
}

Le code passera, mais il est une bonne illustration de la violation du principe de segrégation des interfaces.


interface SearchCounter {
public void notifySearch(String text);
}

interface SearchNotifier {
public void sendEmail(String email);
}


class TextCounter implements SearchCounter, SearchNotifier {
public void notifySearch(String text) {
// put new data into text file in plaintext format
}

public void sendEmail(String email) {
// send an e-mail
}
}

class CsvCounter implements SearchCounter {
public void notifySearch(String text) {
// put new data into text file in CSV format (separated by ;)
}
}


Par ce moyen des interfaces grosses sont devenues des roles interfaces (interfaces des rôles). Cela veut dire qu'au lieu de grouper une multitude de fonctionnalités, elles définissent l'ensemble de méthodes propres à un seul group de fonctionnalité. Le système composé d'interfaces des rôles est plus facile à refactoriser et à maintenir, mais aussi à redéployer.

Ce dernier avantage peut sembler bizarre car dans beaucoup de cas, le redéploiement n'est question que des quelques minutes. Mais l'inventeur du principe, Robert C. Martin, l'a défini en étant en consultation chez Xerox. L'entreprise américaine travaillait sur le nouveau système pour les imprimantes. Le système était tellement grand que des redéploiement prenaient environ une heure. L'introduction du principe de segrégation des interfaces (une seule interface a été décomposée en plusieurs d'autres, plus petites) a contribué à la diminution du temps de redéploiement.


D : Dependency inversion principle
Ce dernier principe, l'inversion des dépendances, est en rapport direct avec la règle de Liskov. En fait, l'inversion des dépendances préconise l'utilisation des couches d'abstraction plutôt que des implémentations concrètes. Autrement dit, utilisons des classes abstraites ou des interfaces à la place de ses implémentations. Cette technique de développement a pour but d'éviter les 3 problèmes graves :
- rigidité - chaque modification est difficile à cause des dépendances.
- fragilité - chaque modification peut provoquer des erreurs inattendues.
- immobilité - la réutilisation d'un bout de code d'origine est impossible ou très difficile à cause des dépendances fortes avec le code d'origine

Afin d'illustrer l'inversion des dépendances, on utilise souvent la comparaison avec l'électronique où chaque nouvel apparail se branche à une prise dans le mur grâce à sa prise de courant. La prise de courant de l'appareil (plug en anglais) correspond en fait à une chose abstraite, donc à une interface. La prise dans le mur (socket en anglais) détermine l'élément qui utilise cette chose abstraite. Si l'on regarde bien notre exemple précédent sur la précision du compteur des mots, on comprendra tout de suite l'aspect : prise dans le mur et la prise de courant.

class Search{
private SearchCounter counter;

public void setSearchCounter(SearchCounter counter) {
this.counter = counter;
}

public List<String> search(String text){
try {
this.counter.openResource();
this.counter.notify(text);
// make the search
final List<String> results=new ArrayList<String>();
return results;
} catch (Exception e) {
} finally {
if (this.counter != null) {
try {
this.counter.closeResource();
} catch (Exception e) {
}
}
}
return null;
}
}

Ici, la prise de courant est représentée par l'interface SearchCounter. L'interface qui est consommée par la prise dans le mur, ici la classe Search avec sa méthode setSearchCounter(SearchCounter counter). Ceux qui ont déjà travaillé sur Spring, remarqueront immédiatement que l'implémentation concrète est passée via une interface dans un setter. Ce moyen de définition s'appelle l'injection des dépendances. Très brièvement, ceci s'explique par le fait que les classes dont dépend classe précise, sont créées au dehors de cette classe. Elles lui sont passées soit à travers un setter ou un constructeur.

L'inversion des dépendances se rejoint au principe de Liskov. Elle s'assure que l'application fonctionne sur les couches abstraites dont les modifications n'auront aucun impact négatif sur le fonctionnement global du système.

SOLID est un acronyme dont les mots devraient être connus et compris par les développeurs. Le respect des 5 principes définis dans SOLID aideront à maintenir, mais aussi à réutiliser certains éléments du code déjà écrit. En plus, de nouveaux développeurs pourront se familiariser plus facilement à l'application. Et les anciens auront toujours le même plaisir de travailler avec des classes qu'ils ont écrites il y a des années. Tout cela est fait pour gagner du temps. Du temps qui n'a pas le prix.
Bartosz KONIECZNY 01-05-2014 17:00 applications web
Moi

Développeur d'applications Internet et journaliste passionné par l'adjectif français. Un aigle polonais orienté vers la progression, volant très haut et écoutant du zouk après les matches du foot français.

Vous appréciez mon travail ?

Pour contribuer au développement de ce site, ou pour remercier pour des articles rédigés, vous pouvez faire un don.

Un conseil Android

Comment accéder au localhost ?

A la place de 127.0.0.1 il faut utiliser 10.0.2.2.