Musée des horreurs – Nombres

Quelques raccourcis :

Manipuler des nombres par programmation est une excellente occasion de se mettre les pieds dans les plats...

Entrée 00 – Validation des bornes

La situation : un individu se donne le mandat de permettre à l'utilisateur d'entrer un entier, et veut valider que l'entier entré diffère de 9999999999.

L'horreur : l'individu soumet le code suivant, voué à être utilisé avec le compilateur C++ accompagnant l'environnement de travail Visual Studio .NET (testé avec la version 2003) d e Microsoft :

Horreur 00.0
const int MAX_INT = 2^15–1;
const int MIN_INT = –(2^15);
int nb;
do
{
   cout << endl << "Entrez un nombre: ";
   cin >> nb;
}
while(nb > MAX_INT || nb < MIN_INT);

Plusieurs problèmes apparaissent, que nous adresserons un à un :

Confusion des types

Le code proposé utilise un int pour lire un nombre. Le mandat officiel de l'extrait de programme est d'empêcher que l'entier entré soit 9999999999. Nous avons d'ores et déjà un problème :

Qu'aurait-on pu faire?

Les types C++ standards pouvant supporter la valeur attendue (9999999999) ne sont pas légion. En fait, avec le compilateur suggéré ici, le type entier standard de plus grande capacité (long) est aussi un type signé sur 32 bits, et ne permet pas de résoudre le problème.

Depuis C++ 11, le type long long est un type standard du langage. Aussi, pour des types entiers de taille connue, envisagez utiliser <cstdint>.

Des types non-standards peuvent, selon les plateformes, permettre de résoudre le problème localement. Dans notre cas, le type long long rejoint le type __int64 de l'API de Win32 et permettrait de représenter cette valeur, au prix de manoeuvres implémentées à l'interne par le compilateur.

Une manière relativement simple de faire en sorte que le programme utilise un type suffisamment grand pour la plateforme choisie est de définir un type entier64 qui corresponde au type entier local à la plateforme et capable de représenter des entiers codés sur 64 bits, puis d'assurer la bonne définition de type pour la plateforme à l'aide de directives d'inclusion conditionnelle. Une fois cette étape franchie, on n'a qu'à inclure le fichier d'en-tête définissant le type entier64 nouvellement créé et à utiliser ce type dans le programme.

#ifdef _WIN32

typedef long long entier64;

#elif defined (__sgi)
// on présume, pour les fins de l'exemple, que le compilateur
// C++ choisi sur SGI encode le type long sur 64 bits
typedef long entier64;

// #elif ... un par plateforme supportée

#else
// etc.
#endif

Évidemment, depuis C++ 11, on utiliserait using plutôt que typedef ici. Mieux encore, on pourrait écrire une classe à part entière. Je ne le ferai pas ici, faute de temps, mais ce serait la solution à privilégier.

Confusion des opérations

Le code proposé plus haut utilise des constantes, qui sont déclarées comme ceci :

const int MAX_INT = 2^15–1;
const int MIN_INT = –(2^15);

Une erreur typique des programmeuses et des programmeurs C, C++, Java, C#, ... est de confondre pseudocode et programme. On utilise l'opérateur ^ en pseudocode pour représenter l'exponentiation, mais dans plusieurs langages, cet opérateur représente le ou exclusif bit à bit. En C ou en C++, aucun opérateur d'exponentiation n'est fourni, et les élévations à une puissance doivent être pris en charge par des sous-programmes.

Clairement, si l'idée était ici de représenter un très gros entier positif et un très gros entier négatif, une autre erreur est survenue ici : on a tenté de représenter et , or on voulait probablement représenter et . Ces erreurs sur les valeurs des constantes auraient entraîné des erreurs de logique.

Pour les intéressé(e)s, une programmeuse ou un programmeur Fortran aurait probablement eu(e) le réflexe d'écrire l'exponentiation ** plutôt que ^

Qu'aurait-on pu faire?

Il est important d'agir prudemment, et de choisir ses types et ses opérations avec soin. Un bon truc est de commenter la déclaration de chaque variable, chaque constante, chaque type, chaque sous-programme, ce qui force à relire prudemment la déclaration en question et prête à relever les erreurs naissant d'une écriture prise trop à la légère.

Dans ce cas, les déclarations auraient pu s'écrire comme suit :

#include <cmath>
using std::pow;
const int MAX_INT = pow(2, 31)–1;
const int MIN_INT = –pow(2, 31);

Je vous invite à examiner aussi La négligence côté recherche, plus bas, pour d'autres remarques dans la même veine.

Absence de cohérence entre le problème et la logique du code proposé

Ce problème est plus grand, mais touche beaucoup de gens qui commencent à programmer : on exprime un problème mais on écrit pour le résoudre un programme qui tente en fait de résoudre un autre problème que celui proposé.

On voulait, selon notre énoncé, empêcher l'usager d'entrer une valeur précise. On propose pourtant un extrait de code qui empêche l'usager d'entrer une valeur qui ne se situerait pas entre deux bornes. Ces deux problèmes ne sont pas équivalents.

Qu'aurait-on pu faire?

Il est important d'être prudent(e) dans ce qu'on exprime, que ce soit dans l'énoncé d'un problème ou dans le programme proposé en solution. Selon l'énoncé initial, on aurait dû s'attendre à voir un programme comme :

#include "entier64.h"
const entier64 VALEUR_PROSCRITE = 9999999999;
//
// ...
//
entier64 nb;
do
{
   cout << endl << "Entrez un nombre: ";
   cin >> nb;
}
while(nb == VALEUR_PROSCRITE);

Le reste de cette discussion reviendra sur le code proposé précédemment pour valider qu'une valeur entrée se situe entre deux bornes, même si ce n'est pas là une solution au problème original.

Confusion logique relative à la nature même du problème

On devine aussi dans le code proposé en solution (celui validant que l'entier entré se situe entre deux bornes) une confusion logique quant à la nature même du problème, c'est à dire quant à la validation des bornes d'un nombre primitif.

Exprimé simplement : aucun nombre entier ne peut prendre des valeurs hors des bornes imposées par le nombre de bits utilisés dans sa représentation. Concrètement, un entier signé encodé sur 32 bits ne peut pas prendre une valeur hors des bornes ou, exprimé sous forme d'un intervalle à demi ouvert, . Un entier signé sur 16 bits ne peut prendre des valeurs hors de l'intervalle ; un entier non signé sur 8 bits ne peut prendre des valeurs qui ne soient contenues dans l'intervalle .

Sachant cela, il est inutile de vérifier si un int se trouve entre les bornes INT_MIN et INT_MAX inclusivement, puisqu'il est impossible pour un entier de ne pas être entre ces bornes. La condition nb >INT_MAX||nb<INT_MIN est une tautologie, une expression nécessairement vraie pour toute valeur possible de nb dans la mesure où nb est un int, où INT_MAX est le plus gros int possible et où INT_MIN est le plus petit int possible.

Qu'aurait-on pu faire?

Si l'usager est susceptible d'entrer des valeurs situées hors des bornes d'un int, alors il est préférable (a) de lire la valeur dans un type de plus grande capacité (p. ex. : un long si sizeof(long)>sizeof(int) sur le compilateur choisi) puis de faire une coercition dans le type désiré, ou (b) de lire la valeur entrée à l'aide d'une chaîne de caractères (un std::string) puis d'essayer de convertir cette chaîne en entier, ou encore de vérifier si la lecture s'est faite sans erreurs :

#include <iostream>
int main()
{
   using namespace std;
   int n;
   cin >> n;
   if (cin.fail())
      cout << "Poche";
}

ou encore (ce qui serait mieux) :

#include <iostream>
int main()
{
   using namespace std;
   int n;
   if (!(cin >> n))
      cout << "Poche";
}

Une lecture erronnée d'une entier sur un flux signifie souvent une erreur dans le format ou les bornes de cet entier. Pour savoir comment récupérer des erreurs de lecture, je vous invite à consulter cet article.

Petite parenthèse : Bjarne Stroustrup a proposé une forme de conversion réalisant une coercition d'une valeur d'un type à un autre (un transtypage) telle qu'une perte de précision à l'exécution lève une exception. Vous la trouverez détaillée ici.

Négligence côté recherche

Enfin, une petite recherche aurait exposé qu'il existe déjà des déclarations correctes à même le standard du langage pour les bornes limites de chaque type primitif. Utiliser ces déclarations standards élimine le besoin de reconstruire lesdites constantes manuellement.

Qu'aurait-on pu faire?

Une version de ces déclarations se trouve dans le fichier standard <climits> de C++, qui correspond pour l'essentiel au fichier <limits.h> de C. Les bornes minimale et maximale pour un int y sont déclarées sous les noms INT_MIN et INT_MAX, respectivement. Ce sont habituellement des macros.

#include <climits>
const int MAX_INT = INT_MAX;
const int MIN_INT = INT_MIN;

Prudence toutefois, car certains fichiers d'en-tête (surtout ceux du langage C) définissent une macro max(a,b) et une macro min(a,b) ce qui a pour impact de remplacer l'appel de la méthode max() ou min() par le texte de la macro correspondante.

Il se peut donc que l'utilisation de std::numeric_limits<T>::min(), par exemple, doive être précédé d'une définition de macro:

#ifdef max
#undef max
#endif
#ifdef min
#undef min
#endif

Les macros, comme c'est presque toujours le cas en C++, causent plus de tort que de bien. Évitez-les ou remplacez-les par des templates le plus possible.

Mieux (beaucoup mieux!) encore : C++ définit le fichier d'en-tête standard <limits>, qui définit entre autres les bornes des types primitifs dans des traits. À l'aide de ce fichier, les bornes minimale et maximale d'un int apparaissent comme résultat des méthodes std::numeric_limits<int>::min() et std::numeric_limits<int>::max(), respectivement.

#include <limits>
using std::numeric_limits;
//
// ...
//
const int MAX_INT = numeric_limits<int>::max();
const int MIN_INT = numeric_limits<int>::min();

Cette représentation des bornes à l'aide de techniques de programmation générique est la technique des traits, sur laquelle je vous invite à lire par vous-même. Le sujet est passionant. Notez que, dans les cas où les valeurs de fonctions comme std::numeric_limits<T>::max() sont des constantes connues à la compilation, C++ 11 permet de qualifier les valeurs retournées de constexpr, ce qui permettra par exemple d'utiliser ces valeurs, dans le cas d'entiers, comme capacité de tableau alloué sur la pile.

Entrée 01 – Compter les positions

La situation : un programme doit identifier le nombre de positions dans un entier écrit sous une forme donnée (décimale dans ce cas-ci) pour réaliser un affichage élégant, par exemple dans un tableau où les nombres sont alignés à droite.

L'horreur : la stratégie suivante, appliquée à un entier présumé positif (pour alléger le propos), qui donne des frissons dans le dos.

Horreur 01.0
short compter_positions(const int n)
{
   short nb_pos;
   if (n >= 10000)
      nb_pos = 5;
   else if (n >= 1000)
      nb_pos = 4;
   else if (n >= 100)
      nb_pos = 3;
   else if (n >= 10)
      nb_pos = 2;
   else
      nb_pos = 1;
   return nb_pos;
}

Cas évident de problème : le nombre de positions retourné est invalide pour tout nombre supérieur ou égal à 10000. Autre irritant, plus conceptuel : cette solution est terriblement inélégante, et doit être révisée chaque fois que la taille maximale d'un int change (ce qui dépend de la plateforme en C++ et, conséquemment, exige des réécritures fréquentes de code).

Qu'aurait-on pu faire?

Les caractéristiques d'une solution acceptable à un tel problème sont :

Le truc ici est donc qu'il nous faut une solution qui ne tienne pas compte de seuils de valeur (100, 1000, 10000, etc.) du fait qu'on peut toujours en avoir inséré un nombre insuffisant.

Une mauvaise solution serait :

Horreur 01.1
short compter_positions(const int n)
{
   short nb_pos = 1;
   for(int seuil = 10; n >= seuil; seuil += 10)
      ++nb_pos;
   return nb_pos;
}

Cette solution est perverse mais risque de ne pas fonctionner pour de grandes valeurs de n, du fait que la dernière multiplication par 10 risque de causer un débordement en dépassant la capacité maximale d'un int. De plus, cette solution est subtile à décoder (pourquoi le seuil initial est-il de 10 si le nombre de positions, lui, commence à 1?).

Au contraire, il nous faut une solution qui ait la particularité d'être immédiate, peu importe la valeur dont on veut connaître le nombre de positions, ou encore d'être itérative ou récursive et de réduire graduellement la valeur étudiée, jusqu'à ce que cette valeur devienne nulle.

La solution itérative irait comme suit (elle n'est pas optimale; je vous laisse y penser).

Solution 01.0 (itérative)
short compter_positions(int n)
{
   int nb_pos = 1;
   while (n / 10 >= 1)
   {
      n /= 10;
      ++nb_pos;
   }
   return nb_pos;
}

Remarquez que le paramètre n'est plus constant du fait qu'on le modifie dans le sous-programme. Étant donné qu'il s'agit d'un type primitif passé par valeur, cela ne pose pas vraiment de problème.

Une solution récursive serait la suivante (elle aussi pourrait être raffinée).

Solution 01.1 (récursive)
short compter_positions(const int n)
   { return 1 + ((n / 10 >= 1)? compter_positions(n / 10) : 0); }

La solution récursive plus lente que la version itérative, parce que plus gourmande en ressources, mais est légèrement plus élégante et pourrait être qualifiée de constexpr.


Valid XHTML 1.0 Transitional

CSS Valide !