Quelques raccourcis :
Ce qui suit liste quelques horreurs en lien avec l'utilisation et la surcharge d'opérateurs.
La situation : supposons que vous souhaitiez vous construire une petite classe nommée ListeMots. Chaque instance de cette classe sera une experte à la tâche de fournir des mots sur demande à un programme.
Vous commencez par préparer votre stratégie à l'aide d'un petit programme de test comme celui-ci, et tout fonctionne à merveille. Le programme affiche "Premier", "Second" et "Troisième", dans l'ordre et sur trois lignes consécutives, tel qu'attendu, et quelques tests divertissants vous démontrent que répéter les opérations répète aussi le texte de manière cyclique. L'algorithme vous semble bon.
#include <iostream>
#include <string>
int main()
{
using namespace std;
string mots[] = { "Premier", "Second", "Troisième" };
enum { NB_MOTS = sizeof(mots)/sizeof(*mots) };
int cpt = 0;
cout << mots[cpt] << endl;
cpt = (cpt + 1) % NB_MOTS;
cout << mots[cpt] << endl;
cpt = (cpt + 1) % NB_MOTS;
cout << mots[cpt] << endl;
cpt = (cpt + 1) % NB_MOTS;
}
Vous procédez ensuite à insérer le tableau, le compteur, leur initialisation et l'opération d'obtention d'un mot à l'intérieur de la classe ListeMots, que vous prenez (évidemment) soit de tester tout de suite pour vérifier que le tout soit impeccable :
#include <string>
class ListeMots
{
enum { NB_MOTS = 3 };
std::string mots_[NB_MOTS];
int cur_;
public:
ListeMots()
: cur_{}
{
mots_[0] = "Premier";
mots_[1] = "Second";
mots_[2] = "Troisième";
}
std::string prochain()
{
auto mot = mots_[cur_];
cur_ = (cur_ +1) % NB_MOTS;
return mot;
}
};
#include <iostream>
int main()
{
using namespace std;
ListeMots lm;
cout << lm.prochain() << endl;
cout << lm.prochain() << endl;
cout << lm.prochain() << endl;
}
Tout fonctionne à merveille, et le programme de test affiche encore ici "Premier", "Second" et "Troisième", dans l'ordre et sur trois lignes consécutives, tel qu'attendu. Vous voilà ravi(e)!
Dans un souci d'élégance et d'économie d'espace, vous entreprenez ensuite de réduire les trois séries d'applications de l'opérateur << sur le std::ostream qu'est std::cout en une seule, comme c'est la coutume. Évidemment, ceci n'implique aucun changement à votre classe ListeMots, puisque tout ce que vous changez est l'opération d'affichage des mots dans leprogramme principal.
Vous obtenez alors le programme de test suivant :
#include <string>
class ListeMots
{
enum { NB_MOTS = 3 };
std::string mots_[NB_MOTS];
int cur_;
public:
ListeMots()
: cur_{}
{
mots_[0] = "Premier";
mots_[1] = "Second";
mots_[2] = "Troisième";
}
std::string prochain()
{
auto mot = mots_[cur_];
cur_ = (cur_ +1) % NB_MOTS;
return mot;
}
};
#include <iostream>
int main()
{
using namespace std;
ListeMots lm;
cout << lm.prochain() << endl
<< lm.prochain() << endl
<< lm.prochain() << endl;
}
Vous testez le tout, évidemment, bien que seulement par principe. Qu'est-ce qui pourrait clocher, après tout? Mais voilà, à votre grande surprise, que ce programme affiche plutôt "Troisième", "Second" et "Premier", dans l'ordre et sur des lignes distinctes. L'ordre d'affichage semble avoir été inversé!
Mais que peut-il donc s'être produit?
L'horreur : votre programme est soudainement devenu dépendant de l'ordre de priorité des opérateurs. Normalement, quand on emboîte les appels à l'opérateur << sur std::cout, comme c'est le cas à droite, les opérateurs << sont évalués de gauche à droite, dans l'ordre, et tout fonctionne comme sur des roulettes.
#include <iostream>
int main()
{
using std::cout;
using std::endl;
const int NB_DOIGTS = 10,
NB_ORTEILS = 10;
cout << "J'ai " << NB_DOIGTS
<< " doigts et" << NB_ORTEILS
<< " orteils" << endl;
}
Quand on utilise des fonctions comme c'est le cas ici, on obtient aussi un affichage tout à fait correct. Toutefois, cet affichage cache un piège qui ne devient pas immédiatement apparent ici. Ce piège est l'ordre selon lequel sont évaluées les fonctions.
Le compilateur évalue les opérations selon un ordre connu et documenté. En C++, les appels de fonction ont, et c'est compréhensible, le même niveau de priorité que les parenthèses, ce qui concorde avec l'utilisation de parenthèses pour le passage de paramètres, et ce niveau de priorité est de beaucoup supérieur à celui des opérateurs << et >>.
#include <iostream>
int nb_doigts()
{ return 10; }
int nb_orteils()
{ return 9; } // oups! accident de travail...
int main()
{
using std::cout;
using std::endl;
cout << "J'ai " << nb_doigts()
<< " doigts et " << nb_orteils()
<< " orteils" << endl;
}
Ce constat seul ne suffit pas pour expliquer la problématique à laquelle nous faisons face. Pour vraiment comprendre ce qui se passe ici, nous allons ajouter une opération d'affichage dans chacune de nos deux fonctions.
#include <iostream>
int nb_doigts()
{
using namespace std;
cout << "nb_doigts()" << endl;
return 10;
}
int nb_orteils()
{
using namespace std;
cout << "nb_orteils()" << endl;
return 9; // oups! accident de travail...
}
int main()
{
using namespace std;
cout << "J'ai " << nb_doigts()
<< " doigts et " << nb_orteils()
<< " orteils" << endl;
}
Le programme ainsi modifié affichera, sur certains compilateurs, lorsqu'on l'exécute :
nb_orteils()
nb_doigts()
J'ai 10 doigts et 9 orteils
Force est d'admettre que, du moins avec ce compilateur bien précis, les appels de fonctions sont résolus de droite à gauche, même si leurs résultats sont utilisés de gauche à droite (l'utilisation des résultats des appels de fonctions est résolu dans l'ordre donné par les opérateurs << et n'a rien à voir avec les appels de fonctions en soi). La priorité des opérateurs est décrite par la norme du langage, mais l'ordre d'évaluation de deux expressions de même priorité, lui, dépend du compilateur.
Ce qu'il faut noter – et retenir! – ici est qu'écrire des programmes qui dépendent de l'ordre selon lequel sont réalisées des opérations de même niveau de priorité est une pratique dangereuse, qu'il faut éviter.
Les exemples de combinaisons à éviter abondent. Il est probable que la plupart d'entre vous n'auraient même pas pensé les essayer, et tant mieux! N'empêche, voici quelques exemples (en gras) de cas horribles et odieux pour lesquels le comportement attendu est indéfini dans la norme du langage, et que tout informaticien(ne) devrait éviter à tout prix (pour plus de détails, voir http://www.research.att.com/~bs/bs_faq2.html#evaluation-order; voir aussi ce document pour savoir ce que signifie le Undefined Behavior) :
void f(int, int);
int main()
{
int i = 0;
// f(0,0)? f(1,0)? f(0,1)? f(1,2)? f(2,1)? f(2,2)?
f( i++, i++ ); // on tombe dans le undefined behavior
i++ = i++; // que vaudra i après ça? undefined behavior
// attention: ce qui suit est légal en C et en C++
char *src = "surprise";
char dest[9] = { 0 };
// copie de src dans dest... subtil, mais question
// classique dans bien des entrevues...
char *p = src, *q = dest;
while (*q++ = *p++);
int v[10];
for (int n = 0; n < 10; n++)
v[n] = n;
i = 3;
v[i] = i++; // on tombe ici encore dans le undefined behavior
}
Dans le cas qui nous intéresse (utilisant la classe ListeMots), le fait que les appels de méthodes soient résolus de droite à gauche mais que l'afichage soit fait avec les résultats apparaissant de gauche à droite fait que l'ordre de l'affichage soit l'inverse de celui auquel on se serait attendu.
Le bon usage : il n'y aura pas de recettes miracle ici. La solution pour ne pas vivre de telles situations est d'éviter toute forme de code qui soit dépendante de l'ordre dans lequel les opérations seront résolues sauf si cet ordre est clairement défini par la norme du langage. Ainsi, là où ceci est légal est correct :
int main()
{
// * a priorité sur + et -
// + et - sont faits de gauche à droite
int val = 3 + 5 * 6 - 2;
}
... on considérera (à juste titre!) ce qui suit comme dangereux et à éviter :
int f(int,int);
int main()
{
int v[] = { 1, 2, 3, 4, 5 };
int i = 3;
// qu'est-ce qui sera passé en paramètre à f()?
f(v[i], i++);
}
La situation : l'opérateur « ternaire » (j'utilise les guillemets ici parce que ce nom est parfois critiqué, mais je les escamoterai par la suite par souci de simplicité) est une bête particulière. Présent dans plusieurs langages (C, bien sûr, mais aussi C++, Java, C# et bien d'autres), il permet d'exprimer de manière compacte le choix entre deux expressions sur la base de l'évaluation d'une expression booléenne. Par exemple, il permet de remplacer le programme C++ à gauche par celui à droite :
Sans opérateur ternaire | Avec opérateur ternaire |
---|---|
|
|
La règle est relativement simple : l'opérateur est utilisé selon la forme (cond) ? exprV : exprF où cond est une expression pouvant être traitée comme un booléen et où exprV et exprF sont du même type (ou peuvent toutes deux être rapportées à un type commun – par exemple). Le résultat de l'évaluation de l'opérateur ternaire sera exprV si cond est vrai, et exprF si cond est faux.
Il est évidemment possible d'abuser de cet opérateur, par exemple en imbriquant des ternaires dans des ternaires dans... Mais le problème en est alors un de lisibilité plus que de logique, et peut être résolu par une utilisation plus saine de fonctions et une indentation plus raisonnable :
Sans opérateur ternaire | Avec opérateurs ternaires imbriqués | Avec opérateurs ternaires imbriqués (présentation alternative) |
---|---|---|
|
|
|
Évidemment, la lisibilité est une chose subjective, alors adaptez vos pratiques en fonction de vos règles et exigences comme de celles de votre entreprise.
L'horreur (version 0) : il m'arrive de rencontrer des instructions comme celle-ci (en C++ 11) :
enum class Direction : char { Est, Nord, Ouest, Sud };
enum { NB_DIRECTIONS = static_cast<int>(Direction::Sud) + 1 };
Direction pivoter_gauche(Direction courante)
{ return static_cast<Direction>((static_cast<int>(courante) + 1) % NB_DIRECTIONS); }
Direction pivoter_droite(Direction courante)
{ return static_cast<Direction>((static_cast<int>(courante) + NB_DIRECTIONS - 1) % NB_DIRECTIONS); }
Direction lire_direction(); // laissé à votre imagination...
bool lire_choix(); // laissé à votre imagination...
int main()
{
Direction d = lire_direction();
//
// L'horreur suit
//
lire_choix()? d = pivoter_gauche() : d = pivoter_droite(); // <-- quelqu'un n'a pas compris...
//
// ...
//
}
Cette horreur en est une de compréhension, qu'on voit typiquement dans le code de personnes qui utilisent l'opérateur ternaire comme une alternative (un if) compacte combinant deux énoncés plutôt que comme une expression concise pouvant être utilisée dans une expression plus complexe. Le code est légal, et donnera (dans ce cas-ci) le résultat attendu, mais ne respecte pas les usages et complique inutilement le code.
La solution : la solution est simple, et ne demande qu'une réécriture plus près du rôle attendu de cet opérateur :
enum class Direction : char { Est, Nord, Ouest, Sud };
enum { NB_DIRECTIONS = static_cast<int>(Direction::Sud) + 1 };
Direction pivoter_gauche(Direction courante)
{ return static_cast<Direction>((static_cast<int>(courante) + 1) % NB_DIRECTIONS); }
Direction pivoter_droite(Direction courante)
{ return static_cast<Direction>((static_cast<int>(courante) + NB_DIRECTIONS - 1) % NB_DIRECTIONS); }
Direction lire_direction(); // laissé à votre imagination...
bool lire_choix(); // laissé à votre imagination...
int main()
{
Direction d = lire_direction();
//
// Mieux:
//
d = lire_choix()? pivoter_gauche() : pivoter_droite(); // <-- ah, je comprends!
//
// ...
//
}
Plutôt que de faire deux affectations sur une même variable, on a ici recours à une seule affectation, mais du résultat de l'évaluation de l'opérateur ternaire. Cette écriture est à la fois plus concise et plus compréhensible pour la majorité des individus, respectant ainsi le principe de moindre surprise.
L'horreur (version 1) : mon illustre collègue Vincent Echelard m'a rapporté, en 2013, avoir rencontré cette expression dans la correction d'un programme C# :
RotationY = RotationY == false ? RotationY = true : RotationY = false;
Si on se réfère à la priorité des opérateurs en C#, on constate que l'affectation est de priorité de l'affectation est inférieure à celle de l'opérateur ternaire, ce qui est raisonnable. Conséquemment, on pourrait réécrire la même expression de la manière suivante :
RotationY = (RotationY == false ? RotationY = true : RotationY = false);
...ce qui est déjà plus clair. La première horreur dans ce cas-ci est donc une horreur de lisibilité.
L'horreur de lisibilité ne se termine pas là, cela dit : on trouve une affectation dans RotationY à la fois dans l'expression évaluée si la condition est vraie, dans l'expression évaluée si la condtiion est fausse, et suite à l'évaluation de l'opérateur ternaire dans son ensemble. Les deux écritures suivantes sont donc équivalentes, bien qu'on préférera celle de droite, plus près des usages de cet opérateur, à celle de gauche, qui ressemble plus à une alternative (un if) mal écrit :
À éviter | À préférer |
---|---|
|
|
Pour le reste, si nous examinons ce qui apparaît à droite de l'affectation, nous constatons ceci : si RotationY est vraie, alors RotationY devient fausse, sinon (donc si RotationY est fausse), alors RotationY devient vraie.
Solution : une analyse simple montre que dans ce cas, on peut remplacer RotationY == false? true : false par RotationY == false tout simplement, donc cette instruction peut simplement être remplacée par :
RotationY = RotationY == false;
...ce qui peut évidemment être remplacé par l'instruction (nettement plus lisible) suivante :
RotationY = !RotationY;
Ici, améliorer la lisibilité a pour effet secondaire de simplifier l'expression, de même que de l'alléger et d'en accélérer l'évaluation. Un investissement qui rapporte.