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 :
- comment identifier les types des paramètres et des valeurs de retour
d'opérations unaires (à un seul paramètre);
- comment utiliser ce savoir pour bien documenter un foncteur composant deux
opérations;
- comment exprimer la combinaison de deux opérations; et
- comment utiliser la classe résultante.
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 :
- l'expression f_g_(x) admette en paramètre
un x du même type que ce que
g() admettrait; et que
- l'expression f_g_(x) retourne une valeur du
même type que ce que f() retournerait;
- de même, implicitement, il faut que f() prenne
en paramètre une valeur du type retourné par
g(x). Ceci peut, en pratique, habituellement être laissé
entre les mains du compilateur (sinon, voir
ceci).
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...