15 mar. 2007

Refactoring - la remise à plat d'une architecture logicielle perturbée

Allez savoir pourquoi, je me suis pris d'affection pour Refactoring[1], le célèbre livre du non moins célèbre Martin Fowler. Cet ouvrage fait la part belle au bon sens et formalise les techniques de réécriture incrémentales du code, proposant ainsi des techniques permettant l'amélioration de l'architecture d'un logiciel.

Reste à tester la puissance de ces idées sur un projet réel. Je l'ai fait pour vous.

Notes

[1] Martin Fowler - Refactoring - Improving the Design of Existing Code, Addison Wesley, ISBN-13: 978-0201485677

Nous avons tous, à un moment ou à un autre, effectué un refactoring[1] - sans toutefois lui donner ce nom. Nous avons tous extrait une méthode à partir de code dupliqué, ou renommé une fonction. La force du livre de Fowler, c'est de formaliser cette approche en un ensemble de techniques simples à utiliser, de manière à rendre cette opération au combien délicate plus sûre. Les modifications de l'architecture se font alors en deux phases distinctes : la modification elle même, petite et précise, et la série des tests unitaires associés, qui permet de s'assurer de la validité de la modification et de son impact.

Bien évidemment, ce livre n'a d'intérêt que si il peut être mis en pratique. L'occasion s'est présenté à moi de l'utiliser lorsqu'on m'a demandé d"implémenter une nouvelle fonctionnalité dans le projet sur lequel je travaille actuellement - en effet, l'implémentation nécessite non seulement d'écrire du code, mais de modifier légèrement l'architecture du code existant - j'avais donc une cible de test idéale pour expérimenter les techniques de Fowler.

Avant toute chose, veuillez noter que ce billet comporte peu de code, et reste relativement vague - pour deux raisons :

  • il s'agit avant tout d'un compte rendu de mon expérience propre, et les détails du code n'apportent que très peu à ce compte rendu
  • vous en connaissez beaucoup des clients qui accepterait que soit divulgué à la surface du monde leur architecture logicielle et leur code, même si une petite partie uniquement est postée ? Moi non :)

La première étape a été de remettre le code source en forme, pour pouvoir mieux m'y retrouver. Indentation, commentaires obsolètes, ajout de lignes vides - tout a été fait pour que le code existant deviennent plus lisible, la seule limité étant de ne pas toucher au code lui même, juste à sa mise en forme. La seconde étape n'a pas eu d'impact sur le code non plus : j'ai étudié les faiblesses de l'architecture actuelle et j'ai simplement planifié mes changements. Il s'agit dans mon cas de supprimer quelques classes qui ne remplissent pas leur fonction et de remplacer des algorithmes statiques par une collection d'algorithmes choisis à l'exécution.

Le véritable travail de refactoring commence à l'étape 3: les algorithmes statiques actuels sont l'une des stratégies, donc il est logique de commencer par eux.

Tests unitaires

Puisque les tests unitaires sont d'une importance capitale, je commence par écrire ceux-ci, en utilisant un framework léger que j'ai développé pour tester mon implémentation du TR1. Le framework se base sur une simple classe, unit_test, qui doit être dérivée pour implémenter un test.

class unit_test
{
  static unsigned int failed_test;
  std::string  test_name;
  virtual bool do_test_internal() = 0;
public:
  static unsigned int get_failed_test_count() 
  { return failed_test; }
unit_test(const std::string& name) : test_name(name) { std::cout << test_name << " - "; };
virtual ~unit_test() { }
void do_test() { if (do_test_internal()) { std::cout << "pass" << std::endl; } else { failed_test++; std::cout << "FAIL" << std::endl; } } };
template <class test_class> void do_test(const std::string& test_name) { test_class(test_name).do_test(); }
template <class test_class, class T1> void do_test(const std::string& test_name, T1 param1) { test_class(test_name, param1).do_test(); }
template <class test_class, class T1, class T2> void do_test(const std::string& test_name, T1 param1, T2 param2) { test_class(test_name, param1, param2).do_test(); }

Cette classe s'utlise très simplement - ainsi que le montre ce code, extrait de ma bibliothèque de tests du TR1:

#include "unit_test.h"
#include "tr1/memory"
class test_bad_weak_ptr : public unit_test { bool do_test_internal() { try { throw std::tr1::bad_weak_ptr(); } catch (std::tr1::bad_weak_ptr e) { return (e.what() == std::string("bad_weak_ptr")); } }
public: test_bad_weak_ptr(const std::string& name) : unit_test(name) { } };

L'instanciation et l'exécution des tests est effectué via l'utilisation des fonctions libres do_test<>() - la fonction main() est alors une suite d'appels à cette fonction.

Mais revenons à nos moutons.

Succession des améliorations

Pour simplifier, j'utilise dans cette partie les notations suivantes:

  • S est ma classe stratégie
  • A est la classe qui utilisera (à terme) les algorithmes publiés par S
  • B est une classe d'aide, actuellement utilisée par A, mais qui n'aura plus de raison d'être à la fin de ces modifications.

Dans un premier temps, j'ai déplacé certaines méthodes de comparaison d'objet et des méthodes de transformation (texte <--> objet) de B vers S, en utilisant le protocole du refactoring Move Method. La stratégie devient responsable de l'exécution de tous les algorithmes utilisés dans A, même les plus simples (car tous peuvent être redéfinis par des stratégies subséquentes). J'ai reporté ces modifications dans A, et j'ai testé de manière intensive le résultat, afin de vérifier que le comportement de A n'avait pas été changé.

Ensuite, j'ai extrait les algorithmes restant de A vers des méthodes de A en utilisant Extract Method. Encore une fois, les tests réalisés mettent en évidence de légers problèmes, vite corrigés. Les méthodes ainsi extraites sont alors placées dans S (Move Method).

A ce moment, ma classe stratégie est complète, et tous les algorithmes de A qui devront être implémentés différemment dans le futur s'y trouvent. De cette stratégie, je peux maintenant extraire l'interface en utilisant le refactoring Extract Interface. Les tests réalisés à ce point sont extrêmement simples puisque cette modification n'a pas d'impact sur A. Une fois cette tâche effectuée, je peux créer une classe proxy SP (qui effectue la sélection de la stratégie) et j'applique une version augmentée de Replace Type Code with State/Strategy et Replace Conditional with Polymorphism[2].

Toutes les fonctionnalités dont j'avais besoin sont maintenant implémentées. Il ne me reste qu'à supprimer la classe B qui ne m'est plus utile et la première partie de mon travail est terminée. Le travail restant a effectué est l'implémentation des autres stratégies souhaitées - au rythme de une par jour environ, avec les tests nécessaires.

Dans l'ensemble, le processus a été très simple à mettre en oeuvre, et le résultat est à la hauteur de mes espérance. Je me suis prouvé à moi même que d'une part, l'utilisation du catalogue de refactoring de Martin Fowler était extrêmement aisé et utile, et d'autre part que modifier et améliorer un design existant ne prends pas plus de temps (sinon moins) que d'augmenter ce design pour implémenter les différentes fonctions souhaitées. C'est la promesse du livre de Fowler - promesse qui est donc tenue.

Notes

[1] et nous avons hélas tous effectué un refuctoring, mais la plupart du temps, nous évitons d'en parler

[2] Mes recherches n'ont pas véritablement mis en évidence un refactoring particulier à utiliser. Je discuterai de ce point particulier dans un billet subséquent, ou dans une mise à jour de ce même billet.

Commentaires

1. Le jeudi, juin 14 2007, 16:58 par Christophe MOUSTIER

Manu...
Je vois que tu as passé pas mal de temps à écrire ton framework de test unitaires.
As-tu essayé TUT ? tut-framework.sourceforge...

Par ailleurs, en lisant ton article, je voulais savoir si Kent Beck (Mr eXtreme Programming) n'avait pas l'antériorité sur cette approche du refactoring bordée par les tests unitaires automatisés ; en fait, il fait partie des auteurs du livre mentionné ;-)

2. Le jeudi, juin 14 2007, 19:05 par Emmanuel Deloget

Beck n'est pas seulement l'un des auteurs du livre, c'est actuellement la personne à laquelle Fowler s'est référé pour construire le livre et ses arguments. Fowler ne s'en cache pas d'ailleurs. L'idée de refactoring est très "méthode agiles", ça n'a rien de vraiment étonnant. Le gros avantage du livre de Fowler est que c'est un double catalogue - il présente à la fois des process de refactoring simples mais puissants et (écrit en partenariat avec Beck) les fameux code smell, source d'innombrables heures de franche rigolade (waaaaah! ton code il pue!)

En ce qui concerne le framework de test unitaire, je ne dirais pas que j'ai travaillé longtemps pour le réaliser (il m'a fallut environ une heure). En fait, comme d'habitude avec moi, il s'agissait surtout de comprendre ce qu'était un test unitaire, et comment les écrire de manière à ne pas perdre de temps. Je dois avouer que je ne suis pas au bout de mes recherche - mais je pense que les problèmes que je rencontre sont lié à l'ontologie des tests unitaires: un test unitaire prends du temps à écrire, point barre. Tous les tests unitaires sont différents, point barre.

Ce qui ne m'empêche pas bien entendu de m'interesser à d'autres frameworks de tests unitaires. Je remarque quelques erreurs de design dans TUT mais rien de grave (c'est quoi ce test_runner_singleton qu'il faut instancier explicitement pour pourvoir l'utiliser ? Pourquoi est-ce que je dois écrire mes operator<<() ? etc). Je vais le tester un peu mieux d'ici la fin de la semaine :)

Sur ce, ravi de te voir de retour !

Ajouter un commentaire

Les commentaires peuvent être formatés en utilisant une syntaxe wiki simplifiée.

Fil des commentaires de ce billet