Composition de fonctions, partie 00

Le texte qui suit est pertinent avec C++ 03, mais l'est moins avec C++ 11. Pour un texte plus actuel, voir composition_fonctions.html.


Ce texte est le premier d'une série de trois décrivant une démarche menant à une classe permettant la composition de fonctions et de foncteurs unaires (à un paramètre) sous la forme f(g(x)). J'ai eu besoin de développer cette approche (surtout la version la plus raffinée) pour mes propres fins et, à mon avis, ce à quoi je suis arrivé pourra être utile à d'autres.

Ce premier document est relativement accessible (évidemment, la démarche se corse dans les documents subséquents). Pour comprendre son contenu, assurez-vous d'avoir examiné la programmation générique (mieux : celui-ci, mais c'est plus poussé), les types internes et publics et les traits.

Imaginons un programme comme celui proposé à droite. Ce programme calcule la somme des carrés des valeurs d'un tableau vals à l'aide de deux opérations (génériques, pour fins de souplesse) :

  • la fonction carre(), qui retourne le carré d'une valeur val, et
  • le foncteur cumul, une instance de cumuler<T>, qui accumule une somme de valeurs.

Ce programme est un exemple de cas types d'opérations complexes qu'il peut être intéressant de généraliser et d'intégrer. En effet, de manière plus large, combiner deux opérations (ou plus!) est une tâche susceptible d'être utile dans plusieurs programmes et dans divers contextes.

Par exemple :

  • combiner deux opérations mathématiques, comme dans cet exemple;
  • déréférencer un pointeur puis opérer sur la référence résultante;
  • sauvegarder un objet sur disque, puis le modifier (pour avoir une copie de sauvegarde, juste au cas); etc.

Visiblement, le patron est récurrent, mais les applications sont variées, alors une solution générale, générique et réutilisable est désirable.

template <class T>
   class cumuler
   {
   public:
      using value_type = T;
   private:
      value_type cumul_;
   public:
      cumuler(const value_type &init = {})
         : cumul_{init}
      {
      }
      void operator()(const value_type &val)
         { cumul_ += val; }
      value_type valeur() const
         { return cumul_; }
   };
template <class T>
   T carre(const T &val)
      { return val * val; }
#include <iostream>
int main()
{
   using namespace std;
   int vals[] = { 1, 2, 3, 4, 5 };
   cumuler<int> cumul;
   for (int n : vals)
      cumul(carre(n));
   cout << cumul.valeur() << endl;
}

Dans ce document, nous verrons :

Ce document sera suivi d'autres documents qui pousseront la réflexion plus loin.

Identifier la signature d'une opération unaire

Pour bien construire (et pour bien documenter) notre composition d'opérations, nous voudrons être en mesure de comprendre la signature des opérations qui la composent. En effet si une instance f_g_ d'une hypothétique classe compose_fx_gx(f,g) doit être un foncteur unaire réalisant la composition f(g(x)) pour un x donné, il faut que :

La bibliothèque standard de C++, à travers STL, offre des classes dont le rôle est strictement documentaire. En particulier, la classe std::unary_function, qui décrit par des types internes et publics à la fois le paramètre et le type de la valeur de retour d'un foncteur unaire. Nous écrirons simplement unary_function ci-dessous par souci d'économie.

L'exemple à droite montre comment un foncteur, même générique, comme cumuler<T> peut être documenté à l'aide de classes telles que unary_function.

En effet, en dérivant de unary_function<A,R>, une classe comme cumuler<T> s'injecte les types suivants :

  • le type argument_type, qui correspond au paramètre A de son parent, et
  • le type result_type, qui correspond au paramètre R de son parent.

L'exemple à droite montre comment l'enfant peut utiliser ces types internes et publics pour auto-documenter la signature de ses propres méthodes.

Il n'est pas nécessaire de dériver d'unary_function pour avoir les types argument_type et result_type; avoir simplement recours à des types internes et publics définis à même la classe à documenter (ici, cumuler<T>) suffirait. Toutefois, dériver d'une classe définissant de manière standard ces noms garantit une forme de conformité de facto avec le standard, ce qui simplifie la programmation et accroît la cohésion.

// ...
#include <functional>
using std::unary_function;
template <class T>
   class cumuler
      : public unary_function<T,void>
   {
   public:
      using value_type = T;
   private:
      value_type cumul_;
   public:
      cumuler(const value_type &init = {})
         : cumul_{init}
      {
      }
      result_type operator()(argument_type val)
         { cumul_ += val; }
      value_type valeur() const
         { return cumul_; }
   };
// ...

Unifier la description des foncteurs et des fonctions

Les foncteurs peuvent s'auto-décrire à l'aide de types internes et publics, mais qu'en est-il des fonctions? Après tout, celles-ci n'ont pas accès aux mêmes facilités que leurs cousins OO (les foncteurs) et ne peuvent ni dériver d'un parent standardisé comme unary_function, ni exposer des types internes et publics au monde extérieur.

Heureusement, il est possible en C++ d'intégrer objets et non-objets de manière cohérente à l'aide d'une technique à faible couplage qu'on nomme les traits. Sans aller dans les détails (l'article sur ce sujet le fait déjà), examinons une ébauche d'un fichier d'en-tête maison, nommé functional_extension.h pour des raisons évidentes, qui ajoutera cette strate sémantique homogène à la fois pour les foncteurs unaires et pour les fonctions unaires.

functional_extension.h

Ce fichier décrira quelques classes documentaires utiles.

La classe nullary_function est une spécialisation de unary_function pour les fonctions qui ne prennent pas de paramètres. Les traits décrits par nullary_function_traits montrent comment déduire le result_type de manière homogène à la fois pour un foncteur (cas général) et pour une fonction (cas R(*)()). Ces classes me sont souvent utiles, personnellement.

La classe unary_function_traits est celle qui nous intéresse vraiment ici :

  • le cas général est unary_function_traits<T>, où T est présumé être une classe exposant les types internes et publics result_type et argument_type;
  • le cas spécialisé est unary_function_traits<R(*)(A)> applicable à tout pointeur de fonction unaire retournant un R et prenant en paramètre un A.

Grâce à unary_function_traits, nous aurons une manière homogène de déduire à la fois le type result_type et le type argument_type d'une opération unaire. Cette strate d'homogénéisation syntaxique, en plus d'être générale, légère et faiblement couplée au reste du programme, allégera énormément la syntaxte de notre composition d'opérations.

 

#ifndef FUNCTIONAL_EXTENSION_H
#define FUNCTIONAL_EXTENSION_H
#include <functional>
template <class R>
   struct nullary_function
      : std::unary_function<R, void>
   {
   };
template <class T>
   struct nullary_function_traits
   {
      using result_type = typename T::result_type;
   };
template <class R>
   struct nullary_function_traits<R(*)()>
   {
      using result_type = R;
   };
template <class T>
   struct unary_function_traits
   {
      using argument_type = typename T::argument_type;
      using result_type = typename T::result_type;
   };
template<class R, class A>
   struct unary_function_traits<R(*)(A)>
   {
      using argument_type = A;
      using result_type = R;
   };
#endif

Un foncteur réalisant une composition d'opérations

Nous sommes maintenant en mesure d'exprimer un premier jet d'un foncteur réalisant la composition de deux opérations. Voyons comment nous y arriverons.

compose_fx_gh.h (version 00)

La classe portera un nom clair, soit compose_fx_gx_impl<FX,GX>, pour des types d'opérations FX et GX donnés. Nous réserverons le nom plus utilisable compose_fx_gx() pour une fonction génératrice pour des raisons qui seront explicitées ultérieurement.

Notre classe sera un foncteur unaire, et dérivera donc de unary_function. Les types servant à paramétrer le parent unary_function seront tirés des traits de FX et de GX, ce qui permettra à notre classe d'encapsuler à la fois des fonctions et des foncteurs.

Le constructeur de notre classe notera les opérations à appliquer lors de la composition alors que son opérateur () réalisera la composition demandée.

#ifndef COMPOSE_FX_GX_H
#define COMPOSE_FX_GX_H
#include "functional_extension.h"
#include <functional>
template <class FX, class GX>
   class compose_fx_gx_impl
      : public std::unary_function<
           typename unary_function_traits<GX>::argument_type,
           typename unary_function_traits<FX>::result_type
        >
   {
      FX fx_;
      GX gx_;
   public:
      compose_fx_gx_impl(FX fx, GX gx)
         : fx_{fx}, gx_{gx}
      {
      }
      result_type operator()(argument_type arg)
         { return fx_(gx_(arg)); }
   };
template <class FX, class GX>
   compose_fx_gx_impl<FX, GX> compose_fx_gx(FX fx, GX gx)
      { return compose_fx_gx_impl<FX, GX>{fx, gx}; }
#endif

Exemples de code client

Le code client proposé à droite montre à la fois la force de notre approche et certaines de ses déficiences :

  • parce que notre classe compose_fx_gx_impl manipule ses FX et ses GX par copie, notre foncteur cumuler<T> doit être modifié quelque peu (sinon, le cumul se ferait sur la copie et le cumuler<T> dans main() ne serait jamais modifé). Dénaturer une stratégie pour arriver à un résultat est souvent un signe qu'il y a anguille sous roche;
  • l'écriture du type de compose_fx_gx_impl à utiliser est, pour le moins, douloureuse. Ici, devoir spécifier en détail le type de chaque type FX et GX rend la chose très lourde (la syntaxe des pointeurs de fonctions n'est pas la plus plaisante à utiliser en C++);
  • par contre, une fois instancié, notre composé f_g_ est très simple à manipuler. Tout n'est pas perdu.
#include "compose_fx_gx.h"
#include <functional>
template <class T>
   class cumuler
      : public std::unary_function<T,void>
   {
   public:
      using value_type = T;
   private:
      value_type &cumul_;
   public:
      cumuler(value_type &init)
         : somme_{init}
      {
      }
      result_type operator()(argument_type val)
         { cumul_ += val; }
   };
template <class T>
   T carre(const T &val)
      { return val * val; }
#include <iostream>
#include <algorithm>
int main()
{
   using namespace std;
   int vals[] = { 1, 2, 3, 4, 5 };
   int cumul = 0;
   compose_fx_gx_impl<
      cumuler<int>, int(*)()
   > f_g_(cumuler<int>(cumul),carre<int>);
   for (auto x : vals)
      f_g_(x);
   cout << cumul;
}

Une adaptation simple pour afficher chaque carré plutôt que pour cumuler la somme des carrés serait celle proposée à droite.

Moins lourde que la version pour le cumul, parce qu'elle évite la question de l'entier passé par référence et servant au cumul des valeurs, elle montre tout de même que l'expression de la variable f_g_ servant à la composition pose problème.

#include "compose_fx_gx.h"
#include <functional>
#include <ostream>
class afficher
   : public std::unary_function<int,void>
{
   std::ostream &os_;
public:
   afficher(std::ostream &os)
      : os_{os}
   {
   }
   result_type operator()(argument_type arg)
      { os_ << arg << ' '; }
};
template <class T>
   T carre(const T &val)
      { return val * val; }
#include <iostream>
#include <algorithm>
int main()
{
   using namespace std;
   int vals[] = { 1, 2, 3, 4, 5 };
   compose_fx_gx_impl<afficher, int(*)()> f_g_(afficher{cout},carre<int>);
   for (auto x : vals)
      f_g_(x);
}

C'est en utilisant un algorithme standard tel que le bien connu std::for_each et en remplaçant la déclaration douloureuse de l'instance de compose_fx_gx_impl par une invocation de la fonction génératrice compose_fx_gx() que l'approche appliquée ici montre sa force.

En effet, examinez l'écriture compacte proposée à droite. Elle réalise la même tâche que celle de la version précédente, et affiche le carré de chaque valeur du tableau vals à la sortie standard avec une seule instruction, à la fois compacte et efficace.

// ...
#include <algorithm>
int main()
{
   using namespace std;
   int vals[] = { 1, 2, 3, 4, 5 };
   for_each(begin(vals), end(vals),
            compose_fx_gx(afficher{cout},carre<int>));
}

Pour pousser la réflexion un peu plus loin, vous pouvez poursuivre la lecture...


Valid XHTML 1.0 Transitional

CSS Valide !