Cet article, sans être très sophistiqué du point de vue du contenu, repose sur des observations mises de l'avant à travers les recherches et réflexions sur la métaprogrammation, telle que mise en pratique par la portion STL de la bibliothèque standard de C++.
Imaginons une classe représentant un système de calcul du temps écoulé et dont chaque instance serait capable de retourner la durée d'un intervalle de temps selon plusieurs seuils de précision distincts (disons en secondes, en millisecondes et en microsecondes).
Imaginons aussi que cette classe soit telle que nous la souhaitions portable au sens où le même code source devrait être utilisable sur toute plateforme sans perte d'efficacité ou de généricité. Ceci n'implique pas que les implémentations soient identiques sur toutes les plateformes mais bien que les interfaces soient identiques (aux constantes près!).
Plusieurs approches sont possibles, et plusieurs sont d'ailleurs tout à fait raisonnables. J'offrirai ci-dessous (et pour chaque cas) une ébauche de solution. Je me limiterai d'ailleurs à une ébauche, du fait que le standard C++ 03 offre de manière portable la fonction std::clock() de <ctime> qui retourne un std::clock_t représentant le nombre de CLOCKS_PER_SEC depuis le lancement d'un programme; il se trouve que CLOCKS_PER_SEC est souvent 1000 ce qui implique que std::clock() soit habituellement précis à la milliseconde près seulement.
Avec C++ 11, de meilleurs outils sont offerts à travers la bibliothèque <chrono>, mais je n'ai pas réécrit l'article puisque ces outils ne sont qu'un exemple, pas le coeur du propos.
Presque toutes les plateformes offrent des stratégies locales et non portables de plus haute précision pour mesurer le temps écoulé dans un programme. Je ne souhaite pas faire une inventaire de telles fonctionnalités mais bien faire la démonstration d'une approche efficace et élégante pour sélectionner l'un de ces mécanismes. Remplacez l'implémentation de ces ébauches par ce qui convient à la plateforme de votre choix.
Une première approche, simple, est d'exposer une méthode au nom distinct par seuil de précision souhaité. L'exemple de la classe MesurerTemps proposé à droite suit cette approche et expose quatre méthodes pour calculer des intervalles de temps écoulé :
|
|
Remarquez que millisecondes() utilise secondes() qui utilise à son tour intervalle_brut(). Contrairement à ce qu'on pourrait penser, ceci n'entraîne pas de coût à l'exécution – ces méthodes sont toutes déclarées à même l'interface de la classe MesurerTemps et le compilateur peut, de manière banale, remplacer les appels par le code appelé (la technique bien connue du Method Inlining).
Le principal défaut de cette approche est qu'elle est un peu moche sur le plan de l'esthétique. La multiplicité des noms pour une même stratégie demande au code client de mettre en place ses propres mécanismes pour choisir adéquatement les bonnes méthodes à appeler. Ça fonctionne bien, c'est rapide, mais ça ne fait pas vibrer la fibre esthétique des informaticiennes et des informaticiens.
Une alternative fréquemment rencontrée et un peu plus élégante du point de vue du code client est d'implémenter une seule méthode (un goulot d'étranglement pour les appels) et de suppléer une constante pour dicter la précision désirée. Cette alternative est toutefois plus coûteuse que la précédente; payer de la performance pour gagner en élégance n'est pas nécessairement souhaitable. Petite remarque : on pourrait améliorer la performance de ecoule(), mais une forme d'évaluation dynamique et de validation demeurerait et nous empêcherait de revenir au seuil de performance de l'approche précédente qui, elle, reposait sur un processus de sélection de stratégie strictement statique. |
|
À remarquer dans cette stratégie :
Effet secondaire non négligeable aussi côté performance: l'évaluation dynamique de la précision demandée qui est faite par l'instance de MesurerTemps risque de compenser négativement tout effort d'optimisation fait par le code client.
En effet, si ce dernier applique une technique comme l'optimisation par savoir discret, ses efforts ne lui permettront pas de retrouver un seuil de performance comparable à celui obtenu par l'approche 0 à cause de la lenteur implicite de l'approche 1.
Une approche envisageable mais qu'il vaut probablement mieux éviter serait de remplacer les valeurs énumérées du type Precision par des constantes qui pourraient être utilisées directement dans les calculs de temps écoulé (pensez à 1 pour les secondes et à 1000 pour les millisecondes dans notre exemple).
Bien que cela soit en apparence susceptible de simplifier la tâche de MesurerTemps, il est surtout probable que cela complique la tâche de la validation des données. Toute constante positive devient alors implicitement valide (à part zéro, probablement) et le contrôle de qualité passe alors pleinement du côté du clode client – l'utilité réelle de MesurerTemps est alors singulièrement réduite.
La portabilité du code souffre alors immédiatement, du moins si les valeurs en question changent selon la plateforme : n'oublions pas que std::clock() n'est utilisé ici qu'à titre d'illustration et qu'il est hautement probable que des mécanismes locaux soient privilégies, à l'interne, dans une implémentation sérieuse de MesurerTemps... d'autant plus que CLOCKS_PER_SEC varie d'une plateforme à l'autre! Au mieux, il faudrait modifier la vocation de la classe MesurerTemps et la ramener à celle, simplifiée, d'offrir une précision brute homogène.
Ce que je vous propose ici est une solution hybride : un seul nom de méthode pour trois implémentations distinctes (incluant une espèce d'implémentation « par défaut », pourquoi pas?) et une sélection de stratégie qui soit statique plutôt que dynamique.
Examinons le code proposé à droite. Ce qu'il faut remarquer :
|
|
L'approche 2 est donc à la fois aussi élégante que l'approche 1 pour le code client et aussi efficace que l'approche 0 d'un point de vue performance.
La jonction entre les deux mondes se fait en passant d'un descriptif dynamique (les constantes, évaluées par le programme alors qu'il s'exécute) à un descriptif statique (les types, connus dès la compilation) et par le remplacement de valeurs numériques par l'instanciation de concepts purs (classes vides).
Résultat : un code simple, compact, extrêmement rapide, élégant et qui reste lisible. C'est quand même pas si mal...
Le très éveillé André Caron m'a envoyé ce petit message :
Ceci aurait plusieurs avantages : Une idée comme ça : si le besoin se fait vraiment ressentir d'utiliser l'alternative à éviter (utiliser les dénominateurs comme valeurs pour l'énumération), on peut toujours le faire comme ceci (à droite).
|
|
Voici donc pour une alternative OO plus près des habitudes de la majorité. Notez cependant que le risque demeure qu'une précision invalide (disons 0) soit soumise par le code client, ce qui implique du traitement d'exceptions avec tout ce que cela comporte (quoique ce ne soit pas vraiment un risque du fait que le constructeur, ici, est privé). Ça reste une technique qui a plus de potentiel que l'approche 1 du fait qu'il est possible de contrôler la validité d'une précision à la construction d'une instance de MesurerTemps::Precision.
Le tout aussi éveillé Félix C. Morency, réagissant à la suggestion d'André (ci-dessus), m'écrit ces quelques mots :
Salut! Je ne suis pas tout à fait d'accord avec le compromis d'André. En effet, la précision du compteur étant fixée à la construction, nous perdons toute la flexibilité du code (qui est ma foi le but de l'article). De plus, comme mentionné, la division peut entraîner une perte de précision allant de faible à très grande, ce qui est à mon avis non négligeable. De plus, la division sur certains types de processeur est plutôt lente. Si, par exemple, nous nous situons sur la planète Bidule, que notre intervalle de temps brut contienne la valeur 2000000.0000000000456456 et que notre dénominateur possède la valeur 1.999999999999999900000000000 (car les Biduliens ont un format de temps très particulier et peu pratique), eh bien nous obtiendrons une immense erreur de calcul. P.-S. : désolé pour le formalisme de l'exemple en Python, mais le résultat est le même en C++ (seulement plus long à écrire). |
|
L'ami André Caron propose cette réplique :
Salut Pat,
J'ai vu que quelqu'un a répondu à mon ajout. C'est drôle parce que ça m'a pris un bout de temps pour comprendre pourquoi il parlait de perte de flexibilité. C'est parce que MesurerTemps::Precision hérite de Incopiable. Ceci implique donc que l'usager doit faire :
MesurerTemps chrono;
chrono.getValeur(MesurerTemps::millisecondes);
...et ne peut pas faire :
MesurerTemps chrono;
MesurerTemps::Precision precision = MesurerTemps::millisecondes;
chrono.getValeur(precision);
...ce qui nous ramène au point de départ où on avait trois méthodes.
Je me permets une petite parenthèse, tout de même, pour rappeler qu'il aurait été possible de déclarer precision comme étant un MesurerTemps::Precision& ou un auto& avec C++ 11, tout de même.
Laissons André poursuivre :
Ce qui est drôle, c'est que ce sur quoi moi j'avais accroché en le lisant est ceci : « Imaginons aussi que cette classe soit telle que nous la souhaitions portable au sens où le même code source devrait être utilisable sur toute plateforme sans perte d'efficacité ou de généricité. Ceci n'implique pas que les implémentations soient identiques sur toutes les plateformes mais bien que les interfaces soient identiques (aux constantes près!). ».
Donc, je propose une nouvelle version qui permet la flexibilité (j'ai juste enlevé le parent Incopiable, on peut bien faire des copies des précisions énumérées, ce sont des objets constants et le constructeur est privé donc seul MesurerTemps ou Precision pourra les instancier avec des nouvelles valeurs. Et puis, juste pour le plaisir, j'ai changé la façon dont MesurerTemps::ecoule() calcule le résultat, ce qui prouve qu'on peut facilement changer l'implémentation sans changer l'interface, ce que moi j'avais perçu comme étant le but de l'article.
class MesurerTemps {
// voir les exemples plus haut
public:
// voir les exemples plus haut
class Precision {
friend class MesurerTemps;
Precision(const value_type MultipleSecondes)
: m_MultipleSecondes{MultipleSecondes}
{
}
const value_type m_MultipleSecondes;
};
// instanciée dans MesurerTemps.cpp avec 0.001
static const Precision millisecondes;
// instanciée dans MesurerTemps.cpp avec 0.000001
static const Precision microsecondes;
// et ainsi de suite pour les précisions souhaitées
value_type ecoule(const Precision &precision) const {
return intervalle_brut()*precision.m_MultipleSecondes;
}
};
Plus récemment, toujours de l'ami André...
Salut Pat,
je viens de réaliser qu'il y a un bogue dans la nouvelle version de la classe MesurerTemps::Precision... Puisque le multiple est constant, les usagers ne pourront pas utiliser l'affectation pour modifier les attributs de cette classe (et cette opération est précisément celle pour laquelle nous avons supprimé le parent Incopiable...)!
Il y a une solution simple à ce problème... La voyez-vous?
Quelques liens pour enrichir le propos.
// ...
template <class T = double>
class MesurerTemps
{
// ...
public:
using value_type = T;
// ...
};
[0]Le code pour y arriver est tout simple et peut être vu dans l'encadré à droite :
Le reste du code de la classe demeure inchangé. Évidemment, les attentes face au type T, donc face à MesurerTemps::value_type, devront alors être définies clairement (ici, T doit se comporter comme un type arithmétique au sens usuel du terme).