La programmation par politiques est une approche de programmation flexible et relativement simple, qui permet de déterminer le comportement d'une classe à partir d'une combinaison de comportements, elles aussi représentées par des classes, le tout sur une base générique (rapide, simple, économique).
Faire de la programmation par politiques, c'est essentiellement joindre aux types des politiques comportementales à appliquer. Pensez par exemple à une classe dont les instances sont responsables d'un pointeur : cette responsabilité peut signifier « partager lors d'une copie », « copier lors d'une copie », « cloner lors d'une copie », etc. Par politique, on pourrait (ceci n'est qu'une esquisse; écrire une sorte de pointeur intelligent est intéressant, mais il s'agit d'un exercice très subtil) écrire ceci :
template <class T, class POL_COPIE, class POL_DESTRUCTION>
class responsable_de_ptr {
T *p;
POL_COPIE copie;
POL_DESTRUCTION destruction;
public:
responsable_de_ptr(T *p) : p{p} { // ceci suppose des constructeurs par défaut pour les pol...
}
responsable_de_ptr(const responsable_de_ptr &r) : p{} {
p = copie(r.p); // par exemple
}
~responsable_de_ptr() {
destruction(p); // par exemple
}
// etc.
};
Ce n'est qu'une illustration, évidemment. On indique habituellement une politique par défaut dans ces classes à même le template, pour que le code intuitif du client n'ait à spécifier que le type pointé, T, dans la majorité des cas. Ici, POL_COPIE est un foncteur choisi par le code client qui réalise une copie du pointé, et POL_DESTRUCTION est un foncteur qui assure une politique de nettoyage appropriée.
Un exemple de POL_COPIE dans ce cas serait, pour des types non-polymorphiques, ceci :
struct copier {
template <class T>
T* operator()(const T *p) {
return new T(*p); // construction par copie
}
};
Un exemple de POL_DESTRUCTION dans ce cas serait, pour des types non-polymorphiques, ceci :
struct meurs {
template <class T>
void operator()(T p) noexcept { // T est un pointeur
delete p;
}
};
Un client pourrait écrire ceci :
{
responsable_de_ptr<int,copier,meurs> ri{ new int(3) };
responsable_de_ptr<int,copier,meurs> rj = ri; // rj.copie_ duplique ri.p
} // ici, ri.destruction détruit ri.p (idem pour rj)
... ou encore mieux, en utilisant des fonctions génératrices pour alléger la syntaxe :
// ...
template <class T, class C, class D>
responsable_de_ptr<T, C, D> creer_ptr_responsable(const T &p, C c = {}, D d = {}) {
return { p, c, d };
}
// ...
{
auto ri = creer_ptr_responsablenew int(3));
auto ri = ri; // rj.copie_ duplique ri.p_
} // ici, ri.destruction_ détruit ri.p (idem pour rj)
À titre illustratif, voici un petit exemple fonctionnel mais un peu simple montrant quand même à quoi la programmation par politiques peut servir, à l'aide d'un cas concret (afficher des données avec un préfixe et un suffixe). En gros :
#include <ostream>
struct null_wrapper { // un Null Object (politique par défaut)
std::ostream& operator()(std::ostream &os) const
{ return os; }
};
struct open_paren { // exemple cool pour un préfixe
std::ostream& operator()(std::ostream &os) const
{ return os << '('; }
};
struct close_paren { // exemple cool pour un suffixe
std::ostream& operator()(std::ostream &os) const
{ return os << ')'; }
};
class tabify { // autre exemple pour un préfixe
char c;
public:
tabify(char c = '-') : c{c} {
}
std::ostream& operator()(std::ostream &os) const
{ return os << '\n' << c << '\t'; }
};
//
// variante de tabify
//
template <char C>
struct tabify_fixed {
std::ostream& operator()(std::ostream &os) const
{ return os << '\n' << C << '\t'; }
};
//
// Exemple de classe par politiques
//
template <class T, class PRE = null_wrapper, class POST = null_wrapper>
class print_wrapper {
PRE pre;
POST post;
T val;
public:
//
// C'est une bonne idée de permettre au code client de passer
// ses propres politiques à la construction, s'il le souhaite
// (il est possible que les politiques aient des constructeurs
// paramètriques auxquels il pourrait vouloir avoir recours).
//
print_wrapper(const T &val, PRE pre = {}, POST post = {})
: val{val}, pre{pre}, post{post}
{
}
//
// Application des politiques
//
friend std::ostream &operator<<(std::ostream &os, const print_wrapper &pw) {
pw.pre(os);
os << pw.val;
pw.post(os);
return os;
}
};
#include <algorithm>
#include <iostream>
#include <iterator>
int main() {
// ... using ...
//
// Application des politiques par défaut: « enrobage vide »
//
print_wrapper<int> pw0{3};
cout << pw0 << endl; // affiche 3
//
// Application de politiques au choix du code client
//
print_wrapper<int, open_paren, close_paren> pw1{3};
cout << pw1 << endl; // affiche (3)
//
// Application d'une combinaison de politiques, au choix du code client
//
print_wrapper<print_wrapper<int, open_paren, close_paren>, tabify> pw2{3};
cout << pw2 << endl;
int tab[] = { 2, 3, 5, 7, 11 };
//
// Affiche les éléments de tab, une par ligne, précédés
// de '-' et d'une tabulation
//
copy(begin(tab), end(tab), ostream_iterator<print_wrapper<int, tabify>>{cout});
cout << endl;
//
// Affiche les éléments de tab, une par ligne, précédés
// de '*' et d'une tabulation
//
copy(begin(tab), end(tab), ostream_iterator<print_wrapper<int, tabify_fixed<'*'>>>{cout});
cout << endl;
//
// Affiche les éléments de tab, entourés de parenthèses et séparés
// l'un de l'autre par un espace
//
copy(begin(tab), end(tab), ostream_iterator<print_wrapper<int, open_paren, close_paren>>{cout, " "});
cout << endl;
}
En utilisant une petite classe par politiques telle que print_wrapper, il est manifestement possible d'enrichir des comportements comme ceux d'un objet standard tel que ostream_iterator, et à très peu de frais.
La plupart des classes représentant des politiques sont sans états, ce qui signifie que leurs instances tendent à occuper peu ou pas d'espace en mémoire. En utilisant des techniques comme l'enchaînement de parents, par exemple, il est possible (dans certains cas) de réaliser des classes par politiques profitant de l'optimisation EBCO, donc pour lesquelles les politiques deviennent les parents de la classe qui les utilisent et, n'ayant pas d'états en propre, n'occupent aucun espace dans leur enfant.
Le résultat est un schéma flexible (le client peut choisir les politiques de son choix à la compilation du code), extensible (ajouter des politiques est possible et tend à être une tâche simple), économique en espace (avec ou sans EBCO) et rapide (la programmation générique permet d'appliquer l'inlining aux invocations des services des politiques). Enfin, puisque les politiques sont typiquement des types pour lesquels la Sainte-Trinité est implicitement correcte, ils ne demandent pas d'effort de gestion de la part de la classe qui les utilise.
Quelques liens pour enrichir le tout.