En examinant les résultats d’analyse des projets de nos clients sur notre plateforme SaaS Cockpit, on voit bien que la stratégie de correction est orientée « Quick Win » : l’équipe traite en priorité les bugs flagrants (comme les comparaisons à virgule flottante, des problèmes de synchronisation) et ce qui est rapide à corriger (code mort, casts redondants, entêtes de documentation manquantes, …).
Parmi les violations qui subsistent semaine après semaine, on trouve généralement les méthodes trop complexes. Ce type de problème est souvent considéré comme compliqué et risqué à résoudre pour un gain assez hypothétique. En consultant leurs tableaux de bord sur le Cockpit, les équipes de développement prennent conscience du problème et améliorent donc les nouveaux développements, mais les méthodes déjà existantes sont rarement corrigées. Or on va voir que le retour sur investissement de ce type de correction est pourtant intéressant : le coût peut être mineur, les risques maîtrisés et les bénéfices élevés.
Complexe ?
Par complexe, on entend surtout ici une méthode assez longue ou alors compliquée au niveau de ses algorithmes. Sur un plan théorique, on traduirait ceci par un nombre d’instructions élevé (typiquement > 100, ce qui équivaut à environ 200/250 lignes) ou une complexité cyclomatique importante (typiquement > 20). D’après nos statistiques, on trouve environ 4% de méthodes trop complexes dans les projets que nous analysons pour la première fois, mais ce faible pourcentage de méthodes représente souvent 10% à 20% du code total de l’application (selon le type de projet et le langage) ! Voici un exemple typique de répartition des méthodes selon leur complexité cyclomatique dans un projet en Java :

Une autre dimension peut participer à la complexité des méthodes, le nombre de dépendances distinctes sur des types/classes externes (notion de couplage efférent). Une méthode utilisant beaucoup de types dans ses traitements est généralement plus difficile à comprendre et à tester. Nous proposerons prochainement une nouvelle règle de qualité incluant cette dimension dans le référentiel de règles du Cockpit (cette règle est en cours de calibrage pour positionner les différents seuils).
Pourquoi simplifier ces méthodes
Il y a de vrais enjeux à simplifier une méthode complexe, l’objectif n’est pas seulement de passer sous des seuils théoriques qu’aurait fixés tel responsable qualité ou tel outil.
- Maintenance : c’est le point le plus évident, une méthode complexe est par définition difficile à comprendre et à faire évoluer
- Testabilité : une méthode complexe ne peut généralement pas être correctement testée à l’aide de tests unitaires. Un test unitaire doit vérifier un traitement… unitaire. Tester une méthode qui combine plusieurs traitements revient à déguster plusieurs vins mélangés dans le même verre : difficile de tirer des conclusions fiables. On touche là à la notion de responsabilité particulièrement importante dans les développements objets.
- Réutilisabilité : il est plus difficile de réutiliser une méthode qui enchaîne différents traitements plus ou moins spécifiques que de piocher dans des méthodes unitaires. D’où encore l’intérêt de la notion de responsabilité.
- Extensibilité : dans le cas d’un héritage, il est préférable de surcharger des méthodes unitaires plutôt que de redéfinir une méthode complexe dans sa totalité, ceci permet d’éviter de dupliquer du code.
- Auto-documentation : les langages modernes disposent généralement de standards de documentation au niveau des commentaires d’entêtes des méthodes, ces commentaires permettant de générer automatiquement des documentations techniques. Eclater une méthode permet de transformer des commentaires internes informels en documentation standard, donc de leur donner de la valeur ajoutée. De plus, le fait-même de nommer les nouvelles méthodes unitaires participe à l’auto-documentation du code.
Y-a-t-il des risques à simplifier une méthode ?
Le bon sens voudrait que la modification d’une méthode complexe entraîne un risque de régression. D’une part parce que le traitement est difficile à appréhender, d’autre part parce qu’il présente plus de possibilité de régression.
On va voir cependant que dans beaucoup de cas, utiliser des outils adéquats permet de diminuer le risque, voire de le supprimer totalement. C’est le principe de la refactorisation, ou refactoring : on modifie la structure du programme sans impacter son comportement.
Comment simplifier une méthode complexe ?
Deux moyens sont possibles : une refactorisation manuelle ou une refactorisation automatique.
Refactorisation manuelle
Prenons l’exemple de la méthode suivante :
public void addToProject(String input) throws InputException
{
// Apply regexp to input string
Matcher matcher = PATTERN.matcher(input);
if (!matcher.matches())
throw new InputException("Input does not match:" + input);
// Retrieve user
String idUser = matcher.group(1);
User user = userService.findUser(idUser);
if (user == null)
throw new InputException("No user for ID:" + idUser);
if (!user.isActive())
return;
String password = matcher.group(2);
if (user.getPassword().equals(password))
throw new InputException("Invalid password for user:" + idUser);
// Retrieve project
String idProject = matcher.group(3);
Project project = projectService.findProject(idProject);
if (project == null)
throw new InputException("No project for ID:" + idProject);
if (project.hasUser(user))
return;
// Add user to project
project.addUser(user);
projectService.saveProject(project);
}
L’objectif de cette méthode est d’ajouter un utilisateur à un projet en procédant au préalable à une vérification des informations fournies, les paramètres étant encodés dans une chaîne de caractères. On identifie facilement 4 traitements successifs dans cette méthode : décodage de la chaîne en entrée, identification de l’utilisateur, identification du projet et rattachement de l’utilisateur au projet. L’ensemble ne peut pas encore être considéré comme complexe, mais on imagine bien la problématique si on doit gérer davantage de paramètres ou de contrôles en entrée.
Un bon réflexe pour simplifier cette méthode consiste à sortir les récupérations de l’utilisateur et du projet dans deux méthodes distinctes. Ce type de refactorisation est généralement proposé par les IDE sous le terme Extract/Introduce method. Mais dans ce cas, 2 difficultés récurrentes apparaissent pour ce type de refactorisation :
- plusieurs points de sortie de natures différentes sont définis : booléens, objets User ou Project, ou void.
- plusieurs variables sont mises à jour dans les traitements externalisés.
Le problème est qu’une méthode Java ou C# ne peut renvoyer qu’un type de retour. On peut parfois résoudre le second problème en passant les variables en paramètres, mais ceci ne fonctionne que si les variables supportent un vrai passage par référence, ce qui n’est pas le cas des types scalaires ou des chaînes de caractères par exemple.
Au moins deux solutions permettent de résoudre ce cas élégamment. Elles reposent toutes deux sur l’introduction d’un objet encapsulant les différentes variables susceptibles d’être mises à jour. Elles sont basées sur des refactorisations connues, notamment popularisées par Martin Fowler
Introduce Parameter Object + Extract Method
Cette solution consiste à regrouper les variables utilisées en entrée et en sortie des traitements dans un nouvel objet puis à passer cet objet de méthode en méthode. Le nouvel objet ne contient généralement que des attributs ; en C#, on l’implémentera typiquement à l’aide d’une classe privée, en Java à l’aide d’une classe privée statique (de préférence avec des commentaires
):
private final static class InputContext
{
private Matcher matcher;
private User user;
private Project project;
private InputContext(Matcher matcher)
{
this.matcher = matcher;
}
public Matcher getMatcher()
{
return matcher;
}
public void setMatcher(Matcher matcher)
{
this.matcher = matcher;
}
public User getUser()
{
return user;
}
public void setUser(User user)
{
this.user = user;
}
public Project getProject()
{
return project;
}
public void setProject(Project project)
{
this.project = project;
}
}
La refactorisation classique Extract Method peut alors être opérée normalement :
public void addToProject(String input) throws InputException
{
// Apply regexp to input string
Matcher matcher = PATTERN.matcher(input);
if (!matcher.matches())
throw new InputException("Input does not match:" + input);
InputContext inputContext = new InputContext(matcher);
// Fill context with user & project
if (!retrieveUser(inputContext))
return;
if (!retrieveProject(inputContext))
return;
// Add user to project
Project project = inputContext.getProject();
project.addUser(inputContext.getUser());
projectService.saveProject(project);
}
protected boolean retrieveUser(InputContext inputContext) throws InputException
{
String idUser = inputContext.getMatcher().group(1);
User user = userService.findUser(idUser);
if (user == null)
throw new InputException("No user for ID:" + idUser);
if (!user.isActive())
return false;
String password = inputContext.getMatcher().group(2);
if (user.getPassword().equals(password))
throw new InputException("Invalid password for user:" + idUser);
inputContext.setUser(user);
return true;
}
protected boolean retrieveProject(InputContext inputContext) throws InputException
{
String idProject = inputContext.getMatcher().group(3);
Project project = projectService.findProject(idProject);
if (project == null)
throw new InputException("No project for ID:" + idProject);
if (project.hasUser(inputContext.getUser()))
return false;
inputContext.setProject(project );
return true;
}
En plus de simplifier la maintenance, on voit que les traitements externalisés peuvent être facilement surchargés par héritage.
Replace Method with Method Object
Cette refactorisation pousse le concept d’encapsulation en introduisant les méthodes externalisées dans la classe du nouvel objet. Car le paradigme objet veut qu’on regroupe données et traitements associés pour proposer des entités cohérentes et autonomes.
Les deux solutions sont pertinentes. A vous de choisir votre école selon que vous préférez une séparation ou un regroupement des données et des traitements. On retrouve d’ailleurs le même choix dans le débat Active Record versus DAO.
Refactorisation automatique
En réalisant une refactorisation manuellement, il y a évidemment des risques à introduire des régressions. Les IDE ont heureusement beaucoup progressé dans ce domaine. Les refactorisations classiques telles que les renommages, les déplacements, ou les introductions de variables existent depuis longtemps. Plus récemment, des refactorisations plus évoluées sont disponibles. IntellijIDEA propose notamment le refactoring Replace Method with Method Object (sous le nom Extract Method Object).
Il suffit de sélectionner le bloc d’instructions à externaliser, pour lancer un Extract Method ; si IntellijIDEA détecte que cette refactorisation n’est pas possible, il propose alors la refactorisation Replace Method with Method Object :

Cette refactorisation se paramètre par IHM :

IntellijIDEA est actuellement le seul IDE à proposer cette refactorisation, les autres n’ont encore que la refactorisation Extract Method (Eclipse ou Netbeans côté Java, addins Visual Studio ReSharper ou Refactor! Pro pour C#).
L’avantage d’une refactorisation automatique réside évidemment dans sa rapidité de mise en oeuvre et dans l’absence de risques de régression (d’où l’importance du choix des outils de développement pour optimiser la productivité). Ici, le seul travail du développeur consiste alors à identifier des blocs d’instructions à externaliser et à les nommer. On peut ainsi parcourir les méthodes les plus complexes d’un projet et les simplifier à la chaîne (si possible en les documentant et en leur associant des tests unitaires).
Conclusion
Comme toujours, il vaut mieux prévenir les problèmes en amont plutôt que de les corriger à postériori. Les développeurs doivent donc veiller à écrire des méthodes unitaires. Savoir comment découper une méthode s’acquiert avec l’expérience, et l’extraction de méthodes devient alors un réflexe. Mais ne négligez pas les gains de productivité offerts pas les IDE modernes, notamment grâce aux fonctions de refactorisation.
