Une petite classe rationnel

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.

Pour un exemple semblable en C#, voir ceci

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

Implémentation « classique »

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.

#ifndef RATIONNEL_H
#define RATIONNEL_H
#include "relation.h"
#include <type_traits>
#include <cmath>
#include <limits>
#include <functional>
#include <iosfwd>

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.

class division_par_zero {};
template <class T>
   constexpr T raw_pgcd(T a, T b) {
      return b ? raw_pgcd(b, a % b) : a;
   }
template <class T>
   constexpr T pgcd(T a, T b) {
      return !a && !b? throw division_par_zero{} : raw_pgcd(a,b);
   }
template <class T>
   constexpr T ppcm(T a, T b) {
      return a * b / pgcd(a,b);
   }
template <class T>
   constexpr T absolute_value(T val) {
      return val < 0? -val : val;
   }

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!)

class rationnel
   : relation::equivalence<rationnel>,
     relation::ordonnancement<rationnel> {
public:
   using value_type = int;
   static constexpr char SEP = '/';
private:
   value_type num_{ }, denom_{ 1 };
   static constexpr value_type pick_num_sign(value_type num, value_type denom) {
      return num < 0 == denom < 0?
         absolute_value(num) : -absolute_value(num);
   }
public:

Outre le constructeur de copie, implicite, j'ai choisi d'implémenter deux constructeurs paramétriques :

  • Un qui ne prend que le numérateur. Dans ce cas, le code est simple, et tout entier, positif ou négatif, est acceptable. Le dénominateur est alors implicitement . Notez que par souci de conformité avec les autres types, un rationnel par défaut a pour valeur le zéro de son type, soit , et
  • Un autre qui prend un numérateur et un dénominateur, deux entiers. Ici, certains calculs sont requis, soit :
    • éviter les divisions par zéro
    • assurer la présence de signe sur le seul numérateur
    • simplifier la fraction utilisée pour représenter le rationnel. et
    • faire en sorte que, si le numérateur est nul, le dénominateur soit (pour éviter des cas un peu absurdes où )

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/

   rationnel() = default;
   constexpr rationnel(value_type num) : num_{ num } {
   }
   rationnel(float) = delete;
   rationnel(double) = delete;
   rationnel(long double) = delete;
   rationnel(value_type num, value_type denom)
      : num_{ pick_num_sign(num, denom) },
        denom_{ denom? maths_ext::absolute_value(denom) : throw division_par_zero{} } {
      if (num_) {
         if (auto d = pgcd(num_, denom_); d != 1) {
            num_ /= d;
            denom_ /= d;
         }
         num_ = pick_num_sign(num_, denom_);
      } else {
         denom_ = 1;
      }
   }

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.

   constexpr value_type num() const noexcept {
      return num_;
   }
   constexpr value_type denom() const noexcept {
      return denom_;
   }

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.

   constexpr rationnel operator+() const noexcept {
      return *this;
   }
   rationnel operator-() const noexcept {
      return { -num(), denom() };
   }

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.

   constexpr bool operator==(const rationnel &autre) const noexcept {
      return num() == autre.num() && denom() == autre.denom();
   }
private:
   constexpr bool normalized_less_than(const rationnel &autre, value_type d) const noexcept {
      return num() * d / denom() < autre.num() * d / autre.denom();
   }
public:
   constexpr bool operator<(const rationnel &autre) const noexcept {
      return normalized_less_than(autre, ppcm(denom(), autre.denom()));
   }

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 :

static_assert(
   std::is_floating_point_v<T>(decltype(declval<T>() / declval<T>()))
);
   template <class T>
      operator T() const noexcept {
         static_assert(!std::is_integral_v<T>);
         return static_cast<T>(num()) /
                static_cast<T>(denom());
      }
};

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.

   template <class T>
      constexpr operator T() const noexcept {
         using std::numeric_limits;
         static_assert(std::is_floating_point_v<T>);
         return static_cast<T>(num()) / static_cast<T>(denom());
      }
};

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 .

inline rationnel operator+(rationnel a, rationnel b) {
   const auto d = ppcm(a.denom(), b.denom());
   return { a.num() * d / a.denom() + b.num() * d / b.denom(), d };
}
inline rationnel operator-(rationnel a, rationnel b) {
   return a + -b;
}

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 .

inline rationnel operator*(rationnel a, rationnel b) {
   return { a.num() * b.num(), a.denom() * b.denom() };
}
inline rationnel operator/(rationnel a, rationnel b) {
   return a * rationnel{ b.denom(), b.num() };
}

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.

std::ostream& operator<<(std::ostream&, const rationnel &);
std::istream& operator>>(std::istream&, rationnel&);
#endif;

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.

#include "rationnel.h"
#include <iostream>
using namespace std;
ostream& operator<<(ostream &os, const rationnel &r) {
   return os << r.num() << rationnel::SEP << r.denom();
}

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.

istream& operator>>(istream &is, rationnel &r) {
   if (!is) return is;
   rationnel::value_type num, denom;
   if (!(is >> num)) return is;
   if (char c; !(is >> c) || c != rationnel::SEP) {
      is.putback(c);
      return is;
   }
   if (!(is >> denom)) return is;
   r = { num,denom };
   return is;
}

Reste à examiner le programme de test que j'ai utilisé, maintenant. Ce programme sera tout simple.

La stratégie globale ira comme suit :

  • Une répétitive itérera jusqu'à ce qu'une erreur survienne, ce qui pourra se produire lors d'une erreur de lecture ou lors d'une division par zéro
  • À chaque itération, un rationnel sera lu sur l'entrée standard
  • Ce rationnel sera comparé avec tous les autres rationnels consommés précédemment, et le fruit des tests réalisés sur ceux-ci sera projeté sur la sortie standard
  • Enfin, le rationnel nouvellement lu sera ajouté à la liste de ceux ayant été lus et testés

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.

#include "rationnel.h"
#include <vector>
#include <algorithm>
#include <cstdlib>
using namespace std;
int main() {
   vector<rationnel> v;

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.

// ...
   struct test {
      rationnel r;
      test(const rationnel &r) : r{ r } {
      }
      void operator()(const rationnel &autre) const {
         cout << r << " ~= " << static_cast<double>(r) << " et "
              << autre << " ~= " << static_cast<double>(autre)
              << endl;
         if (r == autre)
            cout << r << " == " << autre << endl;
         if (r != autre)
            cout << r << " != " << autre << endl;
         if (r < autre)
            cout << r << " < "  << autre << endl;
         if (r <= autre)
            cout << r << " <= " << autre << endl;
         if (r > autre)
            cout << r << " > "  << autre << endl;
         if (r >= autre)
            cout << r << " >= " << autre << endl;
         cout << r << " + " << autre << " == "
              << r + autre << endl;
         cout << r << " * " << autre << " == "
              << r * autre << endl;
         if (autre != rationnel())
            cout << r << " / " << autre << " == "
                 << r / autre << endl;
      }
   };

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.

   try {
      for(;;) {
         cout << "Entrez un rationnel: " << flush;
         if (rationnel r; cin >> r) {
            for(auto & r : v) test(r);
            v.push_back(r);
         } else {
            exit(0);
         }
      }
   } catch (...) {
      cout << "Au revoir..." << endl;
   }
}

Implémentation constexpr

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 RATIONNEL_H
#define RATIONNEL_H
#include "relation.h"
#include <type_traits>
#include <cmath>
#include <limits>
#include <functional>
class division_par_zero {};
template <class T>
   constexpr T raw_pgcd(T a, T b) {
      return b ? raw_pgcd(b, a % b) : a;
   }
template <class T>
   constexpr T pgcd(T a, T b) {
      return !a && !b? throw division_par_zero{} : raw_pgcd(a,b);
   }
template <class T>
   constexpr T ppcm(T a, T b) {
      return a * b / pgcd(a,b);
   }
template <class T>
   constexpr T absolute_value(T val) {
      return val < 0? -val : val;
   }
#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 :

  • La méthode pick_num_sign(), qui détermine le signe du numérateur sur la base des signes suppléés pour le numérateur et pour le dénominateur
  • La méthode compute_denom(), qui détermine la valeur du dénominateur sur la base des valeurs supplééees pour le numérateur et pour le dénominateur, et
  • La méthode compute_num(), qui détermine la valeur du numérateur sur la base des valeurs supplééees pour le numérateur et pour le dénominateur

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.

class rationnel
   : constexpr_relation::equivalence<rationnel>,
     constexpr_relation::ordonnancement<rationnel> {
public:
   using value_type = int;
   static constexpr char SEP = '/';
private:
   value_type num_{ }, denom_{ 1 };
   static constexpr value_type pick_num_sign(value_type num, value_type denom) {
      return num < 0 == denom < 0? absolute_value(num) : -absolute_value(num);
   }
   static constexpr value_type compute_denom(value_type num, value_type denom) {
      return !denom ? throw division_par_zero{} :
             !num ? 1 : absolute_value(denom) / pgcd(absolute_value(num), absolute_value(denom));
   }
   static constexpr value_type compute_num(value_type num, value_type denom) {
      return !num ? num : pick_num_sign(num, denom) / pgcd(absolute_value(num), absolute_value(denom));
   }

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.

public:
   rationnel() = default;
   constexpr rationnel(value_type num) : num_{ num } {
   }
   rationnel(float) = delete;
   rationnel(double) = delete;
   rationnel(long double) = delete;
   constexpr rationnel(value_type num, value_type denom)
      : num_{ compute_num(num, denom) }, denom_{ compute_denom(num, denom) }
   {
   }
   constexpr value_type num() const noexcept {
       return num_;
   }
   constexpr value_type denom() const noexcept {
      return denom_;
   }
   constexpr rationnel operator+() const noexcept {
      return *this;
   }
   constexpr rationnel operator-() const noexcept {
      return { -num(), denom() };
   }
   constexpr bool operator==(const rationnel &autre) const noexcept {
      return num() == autre.num() && denom() == autre.denom();
   }

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.

private:
   constexpr bool normalized_less_than(const rationnel &autre, value_type d) const noexcept {
      return num() * d / denom() < autre.num() * d / autre.denom();
   }
public:
   constexpr bool operator<(const rationnel &autre) const noexcept {
      return normalized_less_than(autre, ppcm(denom(), autre.denom()));
   }

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.

   template <class T>
      explicit constexpr operator T() const noexcept {
         static_assert(std::is_floating_point_v<T>);
         return static_cast<T>(num()) / static_cast<T>(denom());
      }
};

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.

constexpr rationnel operator+(rationnel a, rationnel b) {
   return {
      a.num() * ppcm(a.denom(), b.denom()) / a.denom() + b.num() * ppcm(a.denom(), b.denom()) / b.denom(),
      ppcm(a.denom(), b.denom())
   };
}
constexpr rationnel operator-(const rationnel &a, const rationnel &b) {
   return a + -b;
}
constexpr rationnel operator*(rationnel a, rationnel b) {
   return { a.num() * b.num(), a.denom() * b.denom() };
}
constexpr rationnel operator/(rationnel a, rationnel b)
   { return a * rationnel{b.denom(), b.num()}; }
#include <iosfwd>
std::ostream& operator<<(std::ostream&, const rationnel&);
std::istream& operator>>(std::istream&, rationnel&);
#endif

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.

Implémentation constexpr interfaçant avec std::ratio

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.

// fonctionne, mais bof pour le nommage
constexpr auto rationnel_0 = rationnel{};
constexpr auto rationnel_1 = rationnel{1};

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.

Comment fonctionne std::ratio (survol)

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> :

  • Dans le cas de <ratio>, la raison est évidente : nous souhaitons ajouter des fonctions interfaçant avec std::ratio, qui y est défini.
  • Dans le cas de <numeric>, la raison est que c'est là que se logent std::lcm et std::gcd.
#ifndef RATIONNEL_H
#define RATIONNEL_H
#include "relation.h"
#include <type_traits>
#include <cmath>
#include <limits>
#include <functional>
#include <ratio>
#include <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.

class rationnel : constexpr_relation::both<rationnel> {
public:
   using value_type = long long;
   static constexpr char SEP = '/';
private:
   value_type num_{ }, denom_{ 1 };
   static constexpr value_type pick_num_sign(value_type num, value_type denom) {
      return num < 0 == denom < 0?
         absolute_value(num) : -absolute_value(num);
   }
   static constexpr value_type compute_denom(value_type num, value_type denom) {
      using std::gcd;
      return !denom ? throw division_par_zero{} :
             !num ? 1 :
             absolute_value(denom) / gcd(absolute_value(num), absolute_value(denom));
   }
   static constexpr value_type compute_num(value_type num, value_type denom) {
      using std::gcd;
      return !num ? num :
             pick_num_sign(num, denom) / gcd(absolute_value(num), absolute_value(denom));
   }

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.

   class take_as_is_t {};
   static constexpr auto take_as_is = take_as_is_t{};
   template <class T>
      constexpr rationnel(T num, T denom, take_as_is_t)
         : num_{ num }, denom_{ denom } {
      }

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).

public:
   rationnel() = default;
   template <class T>
      constexpr rationnel(T num, std::enable_if_t<std::is_integral_v<T>, void*> = {})
         : rationnel{ static_cast<value_type>(num), static_cast<value_type>(1), take_as_is } {
      }
   rationnel(float) = delete;
   rationnel(double) = delete;
   rationnel(long double) = delete;
   template <class T, class U>
      constexpr rationnel(T num, U denom, std::enable_if_t<std::is_integral_v<T>, void*> = {})
         : rationnel{ compute_num(num, denom), compute_denom(num, denom), take_as_is }
      {
      }
   template <auto N, auto D>
      constexpr rationnel(const std::ratio<N,D> &autre)
         : rationnel{ autre.num, autre.den, take_as_is } {
      }

Pour le reste, presque rien n'a changé, outre la modernisation de certains calculs.

   constexpr value_type num() const noexcept {
      return num_;
   }
   constexpr value_type denom() const noexcept {
      return denom_;
   }
   constexpr rationnel operator+() const noexcept {
      return *this;
   }
   constexpr rationnel operator-() const noexcept {
      return { -num(), denom(), take_as_is };
   }
   constexpr bool operator==(const rationnel &autre) const noexcept {
      return num() == autre.num() && denom() == autre.denom();
   }
private:
   constexpr bool normalized_less_than(const rationnel &autre, value_type d) const noexcept {
      return num() * d / denom() < autre.num() * d / autre.denom();
   }
public:
   constexpr bool operator<(const rationnel &autre) const noexcept {
      using std::lcm;
      return normalized_less_than(autre, lcm(denom(), autre.denom()));
   }
   template <class T>
      explicit constexpr operator T() const noexcept {
         static_assert(std::is_floating_point_v<T>);
         return static_cast<T>(num()) / static_cast<T>(denom());
      }
   friend constexpr rationnel operator+(rationnel a, rationnel b) {
      using std::lcm;
      return {
         a.num() * lcm(a.denom(), b.denom()) / a.denom() + b.num() * lcm(a.denom(), b.denom()) / b.denom(),
         lcm(a.denom(), b.denom()),
         take_as_is
      };
   }
};
constexpr rationnel operator-(const rationnel &a, const rationnel &b) {
   return a + -b;
}
constexpr rationnel operator*(rationnel a, rationnel b) {
   return { a.num() * b.num(), a.denom() * b.denom() };
}
constexpr rationnel operator/(rationnel a, rationnel b) {
   return a * rationnel{ b.denom(), b.num() };
}
#include <iosfwd>
std::ostream& operator<<(std::ostream&, const rationnel&);
std::istream& operator>>(std::istream&, rationnel&);
#endif

Implémentation avec concepts

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
template <class T>
   constexpr rationnel(T num, std::enable_if_t<std::is_integral_v<T>, void*> = {})
      : rationnel{ static_cast<value_type>(num), static_cast<value_type>(1), take_as_is } {
   }
Avec concepts
constexpr rationnel(std::integral auto num)
   : rationnel{ static_cast<value_type>(num), static_cast<value_type>(1), take_as_is } {
}

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
template <class T, class U>
   constexpr rationnel(T num, U denom, std::enable_if_t<std::is_integral_v<T>, void*> = {})
      : rationnel{ compute_num(num, denom), compute_denom(num, denom), take_as_is }
   {
   }
Avec concepts
constexpr rationnel(std::integral auto num, std::integral auto denom)
   : rationnel{ compute_num(num, denom), compute_denom(num, denom), take_as_is }
{
}

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
template <class T>
   explicit constexpr operator T() const noexcept {
      static_assert(std::is_floating_point_v<T>);
      return static_cast<T>(num()) / static_cast<T>(denom());
   }
Avec concepts
template <std::floating_point T>
   explicit constexpr operator T() const noexcept {
      return static_cast<T>(num()) / static_cast<T>(denom());
   }

En espérant que tout cela vous soit utile!


Valid XHTML 1.0 Transitional

CSS Valide !