Le code ci-dessous utilise une technique inspirée directement du Truc Barton-Nackman et de l'idiome CRTP, alors si vous n'êtes pas familières ou familiers avec ces manoeuvres, il est possible que vous souhaitiez leur jeter un coup d'oeil au préalable.
Les équations sur cette page sont présentées avec l'aide de MathJax.
Quelques raccourcis (de la plus ancienne à la plus récente; prenez celle la plus récente qu'acceptera votre compilateur!) :
Le standard C++ 11 offre, dans l'en-tête standard <ratio>, un type rationnel statique très bien fait et pouvant servir entre autres à représenter des unités de mesure.
Cependant, il arrive à l'occasion qu'un rationnel dont le numérateur et le dénominateur sont connus à l'exécution soit pertinent, et ça m'est justement arrivé dernièrement – un truc sur la comparaison de deux séquences composées pour voir à quel point elles se ressemblent, mais pour lesquelles l'imprécision inhérente aux nombres à virgule flottante était inacceptable. Un tel type est esquissé entre autres par Scott Meyers dans son livre bien connu Effective C++ (que vous devriez lire si ce n'est déjà fait) et constitue un exercice de programmation amusant.
Si vous avez toutefois besoin d'un tel type et n'avez pas le temps de le coder vous-mêmes, vous serez peut-être intéressé(e) à ce qui suit. Je vous l'offre sans garanties (je l'ai testé avec une rigueur discutable; le code de test apparaît plus bas) mais ça fonctionne bien en pratique pour les cas que j'ai eu besoin de valider.
Trois versions d'un type rationnel suivent, soit :
Si votre compilateur en est capable, privilégiez la troisième
Tout d'abord, un choix de design : j'aurais pu faire de rationnel une classe générique sur la base d'un type d'entier particulier (rationnel<int> par exemple), ce qui aurait permis d'en faire une classe entièrement localisée dans un seul fichier (rationnel.h), mais j'ai choisi d'en faire un type concret. Ceci signifie que certains de ses services sont soit des fonctions inline, soit carrément définis dans un fichier source à part (rationnel.cpp) pour éviter des violations de la règle ODR. |
|
L'en-tête relation.h définit les types relation::equivalence et relation::ordonnancement, qui permettent de générer certains opérateurs à l'aide de l'idiome CRTP :
#ifndef RELATION_H
#define RELATION_H
namespace relation {
template <class T>
struct ordonnancement {
friend bool operator>(const T &a, const T &b) {
return b < a;
}
friend bool operator<=(const T &a, const T &b) {
return !(b < a);
}
friend bool operator>=(const T &a, const T &b) {
return !(a < b);
}
};
template <class T>
struct equivalence {
friend bool operator!=(const T &a, const T &b) {
return !(a == b);
}
};
}
#endif
Pour le design de ma classe, je me suis basé sur la classe std::ratio. Ainsi, nous assurerons en tout temps le respect d'au moins deux invariants, soit :
Pour simplifier des fractions, il est d'usage d'avoir recours au calcul du plus grand commun diviseur (PGCD), tout comme il est pertinent d'avoir recours au calcul du plus petit commun multiple (PPCM) pour déterminer un dénominateur commun pour certains calculs. Notez que pour le PGCD, j'ai implémenté une λ interne réalisant le calcul (récursif) en tant que tel. Ceci permet de ne pas répéter la validation des intrants. Pourquoi cette validation? Il se trouve que selon plusieurs écoles de pensée, PGCD(0,0) est indéfini... Notez que depuis C++ 17, il existe std::gcd() et std::lcm() en version constexpr. Enfin, absolute_value() est une version constexpr de std::abs(). Avec C++ 20, std::abs() sera officiellement constexpr et ce détour deviendra caduque. |
|
La classe rationnel en tant que telle contiendra un numérateur et un dénominateur, tous deux entiers. Remarquez au passage l'application du truc Barton-Nackman. Notez que j'ai choisi de définir SEP de manière à ce qu'il s'agisse d'un membre de classe servant de séparateur pour un rationnel sérialisé sur un flux. Ce choix est discutable, cependant; procéder à l'aide de traits, pour en arriver à un plus faible couplage entre la représentation d'un rationnel et sa représentation sérialisée, aurait sans doute été préférable. La méthode privée pick_num_sign() sert à la construction pour déterminer le signe du numérateur. J'ai mis à jour ce passage en 2016 suivant un bogue dans l'écriture un peu trop naïve de ce passage (merci à mon ami Victor Ponce du signalement!) |
|
Outre le constructeur de copie, implicite, j'ai choisi d'implémenter deux constructeurs paramétriques :
J'ai rendu impossible la construction d'une rationnel sur la base d'un nombre à virgule flottante, suivant une recommandation intéressante d'Andrzej Krzemieński formulée dans http://akrzemi1.wordpress.com/2014/07/25/inadvertent-conversions/ |
|
Comme la plupart des types, mon rationnel exposera des accesseurs publics. Ici, les plus simples seront ceux par lesquels on pourra consulter le numérateur et le dénominateur d'une instance de ce type. Notez que, du fait que nous devons assurer le maintien de nos invariants, il importe de ne pas exposer les attributs d'une instance de rationnel de manière publique. |
|
Une opération simple à implémenter est celle permettant d'obtenir la négation arithmétiqued'un rationnel – l'opérateur - unaire. En effet, il s'agit simplement ici de générer un rationnel dont le signe est l'inverse de celui du rationnel original. |
|
Il est intéressant de remarquer que la négation arithmétique pourrait être optimisée de manière importante ici. En effet, nous utilisons un constructeur public, celui à deux paramètres, qui est accessible à tous et responsable, par le fait même, de valider ses intrants, simplifier la fraction, etc. Ici, tous ces calculs sont superflus car la fraction d'origine, *this, est déjà correcte et simplifiée.
Plusieurs options sont possibles pour accélérer cette méthode. À votre avis, quelle serait la meilleure manière de procéder?
Pour évaluer l'équivalence de deux instance de rationnel, il suffit de comparer successivement leurs numérateurs et leurs dénominateurs. Pour ordonnancer deux instances rationnel, je compare leurs numérateurs une fois exprimés sur la base d'un dénominateur commun. Notez que je ne passe pas par des instances de rationnel pour ce faire puisque cela pourrait forcer une simplification de l'un ou l'autre des rationnel, effet que nous voulons éviter ici. |
|
Notez qu'il pourrait être pertinent de comparer d'abord les dénominateurs, car du fait que nous tenons pour invariant qu'un rationnel soit toujours en forme simplifiée, si les dénominateurs de deux instances sont distincts, il est certain que les deux instances de rationnel ne sont pas équivalentes. Il pourrait s'agir d'une optimisation intéressante.
À l'origine, j'offrais trois accesseurs utilitaires sont offerts par un rationnel sous la forme d'opérateurs de transtypage, soit les opérateurs de conversion en float, en double et en long double. C'était redondant. J'utilise maintenant un seul opérateur, générique, avec une assertion statique pour m'assurer que le type de destination n'est pas un entier. C'est plus simple et plus général. On pourrait aussi valider que le type de la division d'un T par un T est un nombre à virgule flottante :
|
|
Pour exprimer la somme de deux instances de rationnel, je fais la somme des numérateurs une fois ceux-ci exprimés sur la base d'un dénominateur commun. |
|
Par souci de simplicité, et dans le plus pur respect du principe DRY, j'ai exprimé à travers . C'est simple et efficace, et ça pourrait être plus rapide si nous raffinions . |
|
La multiplication d'un rationnel par un autre suit essentiellement l'expression mathématique usuelle pour cette opération. Pour ce qui est de la division , elle est exprimée par la multiplication . |
|
Enfin, les opérateurs permettant respectivement de projeter un rationnel sur un flux et d'extraire un rationnel d'un flux sont déclarés ici, pour être ensuite définis dans un fichier source (rationnel.cpp, plus bas). Il ne semblait pas pertinent d'exprimer ces fonctions inline. |
|
Examinons maintenant rationnel.cpp, le fichier source définissant certains services déclarés dans rationnel.h.
La projection d'un rationnel sur un flux est toute simple : il suffit de projeter son numérateur, le séparateur (ici, j'ai choisi '/') et son dénominateur, tout simplement. |
|
Comme c'est presque toujours le cas, la consommation d'un rationnel à partir d'un flux est plus complexe que sa contrepartie, devant tenir compte de divers cas d'erreurs possibles. Comme il se doit, l'implémentation utilisée ici ne modifie le rationnel de destination r que si la lecture a fonctionné et si le rationnel « lu » a bel et bien pu être construit. |
|
Reste à examiner le programme de test que j'ai utilisé, maintenant. Ce programme sera tout simple.
La stratégie globale ira comme suit :
Conséquemment, on ne verra des résultats de tests que si au moins deux instances de rationnel ont déjà été lues, et le nombre de résultat affichés croîtra linéairement à chaque itération en fonction du nombre d'instances de rationnel lues. |
|
Chaque test sera réalisé par un foncteur, qui capturera le rationnel nouvellement lu à la construction et qui prendra ensuite chacune des autres instances de rationnel en paramètre à la méthode operator(). Notez qu'un test est fait avant de tenter une division d'un rationnel par un autre, et que le zéro d'un rationnel est ici un rationnel par défaut, tout simplement. |
|
Enfin, le programme en tant que tel s'exprime par une répétitive inscrite dans un bloc try...catch pour fins de gestion d'exceptions. Si une erreur de lecture survient, alors le programme se termine (appel à exit()). Un programme se terminera aussi si un rationnel lu représenterait, une fois construit, une division par zéro : ceci lèvera une exception lors de sa construction. |
|
Pour implémenter un rationnel qui soit constexpr, même au sens de C++ 11, il faut faire un effort de concision. Une partie des explications données pour la version « classique » plus haut ne sera pas répétée ici, par souci de concision.
Les inclusions et les outils mathématiques de base servant à simplifier les fractions demeurent les mêmes que ceux utilisés dans la version classique, à une exception près : l'en-tête relation.h déploiera dans ce cas une version constexpr des relations CRTP (le code de cette version de l'en-tête suit). |
|
#ifndef RELATION_H
#define RELATION_H
namespace relation {
// ... voir plus haut ...
}
namespace constexpr_relation {
template <class T>
struct ordonnancement {
friend constexpr bool operator>(const T &a, const T &b) {
return b < a;
}
friend constexpr bool operator<=(const T &a, const T &b) {
return !(b < a);
}
friend constexpr bool operator>=(const T &a, const T &b) {
return !(a < b);
}
};
template <class T>
struct equivalence {
friend constexpr bool operator!=(const T &a, const T &b) {
return !(a == b);
}
};
}
#endif
Le défi est de construire un rationnel de manière constexpr, donc de simplifier les fractions ainsi représentées à la compilation lorsque les paramètres sont eux-mêmes connus à la compilation. Les outils privés suivants sont aussi constexpr, soit :
Constatez ici que l'implémentation constexpr peut être légèrement inefficace dans le cas de valeurs connues à l'exécution, car les méthodes compute_num() et compute_denom() contiennent des calculs (récursifs!) redondants. Je pense toutefois que le jeu en vaut la chandelle pour un type tel que rationnel. |
|
Le constructeur par défaut est constexpr de manière triviale. Le réel défi est le constructeur paramétrique, qui repose sur les outils privés susmentionnés. Les méthodes num() et denom() sont trivialement constexpr. Il en va de même pour l'opérateur + unaire. L'opérateur - unaire est aussi simple à implémenter de manière constexpr dans la mesure où le constructeur paramétrique l'est aussi. Du fait que le type rationnel proposé ici implémente le signe dans le numérateur, il suffit de construire un autre rationnel en permutant le signe de *this. Enfin, l'opérateur == repose strictement sur une comparaison des numérateurs et des dénominateurs, donc est trivialement constexpr lui aussi. |
|
L'opérateur < est implémenté sur la base d'une méthode privée normalized_less_than(), qui place les deux instances de rationnel en comparaison sur la base de leur plus petit commun multiple, ce qui permet de limiter les appels à ppcm() à un seul. Les deux services sont constexpr, bien sûr. |
|
La conversion en un T se fait sur demande seulement (opérateur de conversion qualifié explicit), dans la mesure où T est un type à virgule flottante, et se résout par une division du numérateur par le dénominateur, tous deux évalués de manière constexpr. |
|
Pour les opérateurs +, -, * et / binaires, l'expression constexpr des opérateurs est triviale dans la mesure où le constructeur paramétrique est constexpr. Je n'ai pas implémenté l'opérateur %, n'en ayant pas besoin, mais je suis ouvert aux suggestions si vous avez envie de vous attaquer à ce problème. Je n'ai pas répété le code des opérateurs de lecture et d'écriture sur un flux, du fait qu'il demeure inchangé. Référez-vous à la version « classique » pour plus de détails. |
|
Parmi les raisons pour faire du type rationnel un type pleinement constexpr, on trouve bien sûr la qualité du code généré, avec une part importante du code résolu de manière statique, mais aussi la capacité de valider le code sur la base d'assertions statiques. Par exemple, en utilisant les assertions statiques de C++ 17 :
#include "rationnel.h"
#include <iostream>
void tester_rationnels(rationnel r0, rationnel r1) {
cout << r0 << " + " << r1 << " == " << (r0 + r1) << endl;
cout << r0 << " - " << r1 << " == " << (r0 - r1) << endl;
cout << r0 << " * " << r1 << " == " << (r0 * r1) << endl;
cout << r0 << " / " << r1 << " == " << (r0 / r1) << endl;
cout << "-" << r0 << " == " << -r0 << endl;
cout << "+" << r0 << " == " << +r0 << endl;
cout << "-" << r1 << " == " << -r1 << endl;
cout << "+" << r1 << " == " << +r1 << endl;
}
int main() {
static_assert(rationnel{ 1 } + rationnel{ 1 } == rationnel{ 2 });
static_assert(rationnel{ 1 } - rationnel{ 1 } == rationnel{ 0 });
static_assert(rationnel{ 2 } - rationnel{ 4 } == rationnel{ -2 });
static_assert(rationnel{ -2 } * rationnel{ -4 } == rationnel{ 8 });
static_assert(rationnel{ 8 } / rationnel{ 4 } == rationnel{ 2 });
static_assert(rationnel{ 8, 4 } == rationnel{ 2 });
static_assert(rationnel{ -3, -1 } == rationnel{ 3 });
static_assert(rationnel{ 3, 5 } * rationnel{ 2 } == rationnel{ 6, 5 });
// ...
}
Ceci transforme les tests unitaires en une compilation : le code ne compile que s'il respecte les attentes placées en lui.
Au fil du temps, je me suis aperçu que, dans mon utilisation de rationnel, les cas rationnel{} (donc rationnel{ 0 }) et rationnel{ 1 } revenaient régulièrement dans mes calculs, mais se mêlaient à des calculs dynamiques. Si vous êtes familière ou familier avec constexpr, alors vous savez qu'une fonction constexpr sera évaluée à la compilation si le contexte le demande, mais que rien ne garantit une évaluation à la compilation sinon – certains optimiseurs évalueront tout de même les calculs à la compilation, mais rien ne leur impose de procéder ainsi.
J'aurais pu me faire des constantes globales représentant ces deux cas particuliers, ce que j'ai fait un certain temps, mais les noms auxquels j'arrivais étaient... boiteux, du moins à mes yeux (voir à droite). J'ai même consulté des ami(e)s sur Twitter pour avoir leurs suggestions; certaines furent intéressantes (voir https://twitter.com/PatriceRoy1/status/1229207494473461760?s=20), mais je demeurais insatisfait. |
|
Un bon matin, en prenant ma douche, j'ai réalisé que mes problèmes disparaîtraient si je pouvais remplacer, quand je tiens mordicus à avoir une évaluation constexpr même si le contexte est dynamique, l'expression rationnel{ 1 } par std::ratio<1,1>, ce dernier étant toujours évalué à la compilation. Pour ce faire, toutefois, il me fallait enrichir rationnel d'opérations entre ce type et std::ratio.
Le type std::ratio est un type structurellement simple, de forme (esquissée) :
template <auto N, auto D> struct ratio{
static constexpr auto num = // ... (N simplifié)
static constexpr auto den = // ... (D simplifié)
};
Les valeurs de N et de D sont donc nécessairement des entiers connus à la compilation, et la fraction est donc aussi simplifiée dès la compilation. Les attributs num et den sont donc tels que mais où num et den modélisent la version simplifiée de cette fraction.
J'ai profité de cette adaptation pour migrer vers std::gcd() et std::lcm() pour les calculs du PGCD et du PPCM. Cela allège quelque peu mon travail. J'ai conservé pour l'instant absolute_value(), dont l'implémentation n'est pas répétée ici.
Premier ajustement : la liste des en-têtes standards inclus s'est enrichie de <ratio> et de <numeric> :
|
|
Pour les fonctions calculant un numérateur et un dénominateur en forme simplifiée, le principal changement apporté est le recours aux fonctions standards pour les calculs du PGCD et du PPCM. |
|
Là où les changements deviennent intéressants est l'introduction d'une classe interne take_as_is_t, servant à titre d'aiguillage et dont nous utiliserons l'instance constexpr nommée take_as_is. Les constructeurs qui prendront take_as_is en paramètre prendront les valeurs qui l'accompagnent à l'appel telles quelles, sans chercher à les simplifier. Ceci permettra entre autres de profiter du fait que pour un std::ratio donné, les attributs num et den sont simplifiés au préalable, et d'éviter des calculs redondants. |
|
Sur cette base, plusieurs constructeurs seront implémentés par délégation vers le constructeur acceptant un take_as_is_t en paramètre. Cela signifie entre autres que construire un rationnel à partir d'un std::ratio, même dans un contexte dynamique, devient trivial (aucune division à calculer). |
|
Pour le reste, presque rien n'a changé, outre la modernisation de certains calculs. |
|
Si vous n'avez pas utilisé les concepts de C++ 20, faites-vous plaisir et plongez : ce mécanisme est l'un des plus agréables qu'offre le langage. Les ajustements que cela permet sont de remplacer des contraintes exprimées à l'aide de voies détournées (avec enable_if) par des idées claires exprimées à même les types génériques impliqués.
Prenez par exemple ce constructeur :
Sans concepts |
|
---|---|
Avec concepts |
|
Clairement, exprimer à même la contrainte à même le type du paramètre est un gain net. La situation est semblable dans le cas suivant :
Sans concepts |
|
---|---|
Avec concepts |
|
On peut même faire une manoeuvre analogue avec l'opérateur de conversion en
T, exprimant à même la signature l'exigence que T
soit un type à virgule flottante :
Sans concepts |
|
---|---|
Avec concepts |
|
En espérant que tout cela vous soit utile!