Musée des horreurs – Lire et comprendre un énoncé

Quelques raccourcis :

Tout comme les problèmes, les horreurs débutent souvent par une lecture... créative.

Entrée 00 – Une idée qui semblait bonne

La situation : au cours d'un atelier de programmation orientée objet (POO), un étudiant a à effectuer des opérations sur une chaîne de caractères. Ces opérations sont :

Un menu est proposé à l'usager, qui effectuera les opérations dans l'ordre qui lui convient.

Au cours d'un test, l'enseignant commence par essayer d'afficher une chaîne alors qu'aucune n'a encore été lue. Le programme de l'étudiant répond alors "La chaîne est vide. Veuillez entrer une chaîne.".

Ce premier test s'avérant concluant, l'enseignant poursuit à l'aide d'un deuxième test. Il choisit de tenter d'afficher la chaîne inversée, toujours sans avoir entré de chaîne au préalable.

À la surprise de l'étudiant, son programme répond :".enîahc enu rertne zelliueV .ediv tse enîahc aL". L'étudiant et le professeur auraient bien entendu dû voir « s'afficher » une chaîne vide, n'ayant encore entré aucune chaîne pour fins de manipulation.

L'horreur : le mot « horreur » est un peu fort ici (comme dans bien des entrées du musée des horreurs, d'ailleurs; ne nous prenons pas trop au sérieux), mais le constat reste pertinent: l'étudiant a manifestement utilisé la même chaîne pour afficher ses messages d'erreur et pour la paire saisie/ manipulation qui était le propos de son travail pratique.

Rien de très grave ne s'est produit ici, mais une erreur semblable dans un système plus critique aurait pu avoir des conséquences graves – ici, l'emploi d'une seule variable à deux fins distinctes a entraîné un effet de bord résultant en un affichage incorrect. L'histoire ne dit pas si l'erreur résulte d'un souci d'économie d'espace mémoire ou de noms de variables, d'un manque d'expérience pour voir les conséquences du geste, ou simplement d'une inattention. Mais il convient de ressortir quelques grands principes de ce petit incident.

À vocations distinctes, variables distinctes. Là où on cherche à écrire des fonctions générales et des classes abstraites pour couvrir d'un seul concept une grande variété d'opérations, il faut éviter d'utiliser une même variable dans un algorithme pour plusieurs choses différentes. Cela rend le code difficile à gérer.

Ainsi, les deux algorithmes ci-dessous sont semblables mais n'ont pas le même effet :

Bon
void dessiner_sapin(int hauteur)
{
   using namespace std;
   // on va dessiner la 1re ligne
   int cpt_lignes = 1;
   while (cpt_lignes <= hauteur)
   {
      // Dessiner des espaces
      int cpt_espaces = 1;
      const int NB_ESPACES = hauteur - cpt_lignes + 1;
      while (cpt_espaces <= NB_ESPACES)
      {
         cout << ' ';
         ++cpt_espaces;
      }
      // Dessiner des symboles
      int cpt_symboles = 1;
      const int NB_SYMBOLES = cpt_lignes * 2 - 1;
      while (cpt_symboles <= NB_SYMBOLES)
      {
         cout << '*';
         ++cpt_symboles;
      }
      // Changer de ligne
      cout << endl;
      ++cpt_lignes;
   }
}
Vilain
void dessiner_sapin(int hauteur)
{
   using namespace std;
   // on va dessiner la 1re ligne
   int cpt_lignes = 1;
   while (cpt_lignes <= hauteur)
   {
      // Dessiner des espaces
      int cpt = 1;
      const int NB_ESPACES = hauteur - cpt_lignes + 1;
      while (cpt <= NB_ESPACES)
      {
         cout << ' ';
         ++cpt;
      }
      // Dessiner des symboles
      const int NB_SYMBOLES = cpt_lignes * 2 - 1;
      while (cpt <= NB_SYMBOLES)
      {
         cout << '*';
         ++cpt;
      }
      // Changer de ligne
      cout << endl;
      ++cpt_lignes;
   }
}
// forme classique de la répétitive
initialisation
condition
   traitement
   incrémentation

L'algorithme Vilain a deux défauts : il utilise un même compteur à deux fins distinctes dans le même algorithme, ce qui obscurcit son rôle dans la stratégie d'affichage et complique des tâches d'entretien courant comme la prise de statistiques sur les opérations, et il diverge de la répétitive classique de la forme (visible à droite), ce qui le rend un peu plus artisanal et un peu plus difficile à gérer (il néglige d'initialiser le compteur avant la répétitive d'affichage de symboles).

Notez que c'est très différent de l'adage selon lequel un nom doit être significatif dans le contexte où il apparaît sans plus. Si on avait au contraire écrit la fonction suivante :

void afficher_symboles(int nb_symboles, char symbole)
{
   using std::cout;
   int cpt = 1;
   while (cpt <= nb_symboles)
   {
      cout << symbole;
      ++cpt;
   }
}

...et si on en avait fait usage dans l'algorithme pour dessiner un sapin de cette manière :

void dessiner_sapin(int hauteur)
{
   using namespace std;
   // on va dessiner la 1re ligne
   int cpt_lignes = 1;
   while (cpt_lignes <= hauteur)
   {
      // Dessiner des espaces
      const int NB_ESPACES = hauteur - cpt_lignes + 1;
      afficher_symboles(NB_ESPACES, ' ');
      // Dessiner des symboles
      const int NB_SYMBOLES = cpt_lignes * 2 - 1;
      afficher_symboles(NB_SYMBOLES, '*');
      // Changer de ligne
      cout << endl;
      ++cpt_lignes;
   }
}

...alors on aurait utilisé deux fois la même fonction générale, qui utilise à l'interne un compteur pour une fin, mais nous n'aurions pas contrevenu à notre adage. Cette fonction a un rôle clair et précis, et le compteur interne y porte un nom aussi abstrait que le veut son rôle dans l'algorithme. Il n'y est pas réutilisé à plusieurs fins.

Notez que, lorsque votre code présente une répétitive classique, la boucle for est toute indiquée en C++.

À opérations distinctes, fonctions distinctes. Si un programme en arrive à une erreur comme celle relevée ici, il est probable que deux sections du code aient été interreliées de manière imprudente. Les variables servant à l'affichage de messages d'erreur n'ont aucune raison d'interagir aussi directement avec les variables de traitement du programme – en fait, elles devraient intervenir dans des sous-programmes distinctes, et les messages d'erreur devraient être représentés par des constantes!

Ce type d'erreur à l'exécution survient beaucoup moins souvent quand :

Combinés, ces trois principes de base de programmation mènent à du code plus élégant, plus réutilisable, plus facile à gérer et à déboguer, et souvent beaucoup plus rapide à l'exécution parce que plus la portée des optimiseurs.

Pour tout bon programme, penser pervers et tester rigoureusement. Faites des tests. Beaucoup de tests. Soyez perver(es) et pensez comme un attaquant malicieux.

Si vous manipulez des entiers, vérifiez des nombres typiques pour le programme; vérifiez des nombres hors des bornes acceptées, gros comme petits; assurez-vous d'avoir testé des positifs, des négatifs et bien entendu ce chic zéro que tout le monde aime (et pas juste s'il y a des divisions; un new d'un tableau de taille zéro ou négative peut donner des résultats surprenants!).

Si vous manipulez des pointeurs reçus en paramètre, ne négligez jamais de tester un pointeur nul. Pour chaque new, demandez-vous si le delete correspondant sera appelé, et vérifiez bien qu'il n'arrivera pas d'accident comme dans le code ci-dessous.

void trucs_droles(const int nb_iter)
{
   char *p = nullptr;
   int cpt_iter = 0;
   while (cpt_iter < nb_iter)
   {
      p = new char[1000];
      // utiliser p *(peu importe)
      ++cpt_iter;
   }
   delete [] p;
}
// défaut de ce code? Il fait nb_iter fois un
// new d'un tableau de 1000 bytes mais ne
// détruit que le tout dernier d'entre eux

N'oubliez pas que new[], utilisé pour allouer dynamiquement un tableau, nécessite un delete[], pas seulement un delete.

Dans la mesure du possible, essayez d'éviter d'éparpiller la paire allocation/ libération dynamique de mémoire (new/ delete; malloc()/ free()) un peu partout dans un programme. S'il n'est pas possible de faire les deux de manière symétrique dans un même sous-programme – et ça se peut: il arrive souvent qu'on ait justement recours à l'allocation dynamique de mémoire pour que la vie d'une donnée outrepasse les limites de la portée dans laquelle elle est initialisée – alors essayez de localiser ces opérations dans une paire constructeur/ destructeur ou de bien identifier les endroits où ces opérations auront lieu. La même remarque s'applique à toute attribution de ressources (gestion de fichiers; emploi de sockets; accès à une BD; etc.), et appliquer systématiquement une stratégie OO réduit de beaucoup les risques d'erreurs en ce sens, surtout si le langage utilisé garantit la finalisation (C#, C++, mais malheureusement pas Java).

Si vous manipulez du texte, pensez à des chaînes longues, contenant des espaces. Pensez à une chaîne vide. Si vous manipulez des caractères, examinez le cas des accents et des caractères non standards (car le type char peut être signé, ce qui signifie que bien des caractères hors normes comme le é sont des nombres négatifs et font planter bien des fonctions systèmes comme std::isspace()).

Si vous manipulez des objets, souvenez-vous que ceux-ci doivent garantir leur propre intégrité du début à la fin de leur existence. Vérifiez bien que les constructeurs initialisent correctement chaque attribut de l'objet (et initialiser correctement ne signifie pas nécessairement initialiser à zéro; prudence!), qu'ils n'appellent aucune méthode virtuelle (faut éviter ça tant que l'objet n'est pas pleinement construit), et que les destructeurs nettoient bien le système et libèrent bien toutes les ressources attribuées à l'objet durant son existence.

Développez un style reconnaissable et uniforme. Programmer, c'est penser et écrire, peu importe la langue d'usage (pseudocode, morphogramme, C++, Smalltalk, Lisp, etc.). Un texte, quand il est écrit par un auteur mature, est en général signé et reconnaissable comme ayant été produit par cet auteur. Un programme l'est tout autant, et c'est de plus en plus vrai au fur et à mesure que progresse chaque informaticien(ne).

Cela implique qu'on en vient à développer des traits de programmation qui font que les lecteurs savent qui nous sommes à la lecture de nos programmes. Et on en vient à développer des habitudes (les mauvaises langues diront des tics) qui sont elles aussi reconnaissables à l'oeil, et qui deviennent partie intégrante et manifeste de notre pensée.

Sachant cela, vous développerez des manières d'organiser vos répétitives; de disposer votre code; de positionner vos variables; de nommer vos constantes; de choisir vos opérations... et vos programmes deviendront plus faciles à lire et à déboguer si vous procédez de manière soigneuse puisque, après quelques sous-programmes, l'oeil sera habitué à votre façon de faire (si vous êtes clair(e), du moins) et aura tendance à trouver ses repaires plus facilement.

Dans la même veine, respecter les standards locaux fait aussi partie des habitudes gagnantes de programmation. Cela rendra votre code plus familier à une nouvelle lectrice ou à nouveau un lecteur, aplanissant sa courbe d'apprentissage et le/ la rendant plus rapidement opérationnel(le) à travailler avec votre produit. Cela inclut les standards de documentation et de nomenclature. Il est souvent préférable de respecter une manière de faire les choses répandue dans votre entreprise que de produire du code qui vous semblera plus joli mais sera en même temps plus artisanal (du point de vue local) et coûtera, conséquemment, plus cher à l'entreprise en temps de formation et d'adaptation.

Une stratégie gagnante en ce sens est d'utiliser au maximum les algorithmes standards et les fonctions standards du langage de programmation que vous utilisez. Par exemple :


Valid XHTML 1.0 Transitional

CSS Valide !