17 déc. 2009

template<typename T> struct do_not_misuse

En C++, être capable d'abstraire un type de données pour qu'il puisse être utilisé sous une forme paramétrée est à la fois tentant, amusant, difficile et très souvent inutile. Je vais laisser de coté le dernier point (j'y reviendrait peut-être dans un article futur, et ceux qui lisent ce blog régulièrement savent que j'ai déjà traité le sujet dans un billet datant de 2007) pour me concentrer sur le précédent : la difficulté intrinsèque liée à la programmation générique en C++.

Mesdames et messieurs, venez vous assoir - le show va commencer...

Je code générique, donc je suis

Comme cette magnifique et chaleureuse introduction l'annonce, utiliser des techniques de méta-programmation ou de programmation générique en C++ a quelque chose d'étrangement attirant. On voit bien que le code peut devenir excessivement complexe, certains points de détails nous échappent royalement (dans de nombreux cas, je soupçonne les développeurs de ne même pas en avoir conscience), et bizarrement, c'est ce coté extrêmement technique de l'écriture de template en C++ qui fait aussi sa popularité. Au programmeur débutant, il semble que les templates peuvent tout faire. Au programmeur intermédiaire, il apparaît que les templates sont une solution possible à nombre de ses propres problèmes. Au programmeur chevronné, les templates sont un outil d'une puissance incomparable qu'il convient d'utiliser de manière innovante pour résoudre des problèmes qui, soyons honnêtes, n'existent pas forcément dans d'autres langages... Et finalement, à l'ethnologue qui observe ces trois programmeurs, les templates sont une sorte de déité sortie tout droit de l'inconscient de ses adorateurs.

Il est vrai que la complexité des templates est un véritable challenge pour un programmeur - quel que soit son niveau. Plutôt que de vous dire que vous ne devriez pas vous y frotter, cet article va plutôt s'attacher à vous lister quelques points auxquels vous devez penser lorsque vous touchez aux templates en C++ - autrement appelé des Bonnes Pratiques[1].

Redéfinir les types paramètre

Un type de donnée quelconque utilisé dans la définition d'une classe template et faisant partie de l'interface publique de cette classe doit être redéfini sous la forme d'un type de donnée appartenant à la classe template.

Exemple:

template <class T>
struct type
{
  typedef T value_type;
  bool operator()(const value_type& a, const value_type& b)
  { ... }
};

Il y a plusieurs raisons pour ça : la première est que cette redéfinition est une abstraction dont vont dépendre les détails de l'implémentation. Dans la définition précédente, si je change T, je n'ai qu'une ligne à changer. Si je n'ai pas défini un type alias alors une modification de T pourrait avoir un impact sur toute la définition de l'interface. Deuxièmement, si type<T> est utilisé à son tour comme argument d'une classe template, il peut m'être impossible de déterminer T et donc de baser mes traitements sur les particularités de ce type. Ce n'est plus le cas dans notre définition, car je peux retrouver T via type<T>::value_type.

C'est vrai pour tous les types de données qui peuvent être utilisés dans l'interface publique de la classe.

template <class T>
class t_collection
{
public:
  typedef std::vector<T> vector_type;
  typedef typename std::vector<T>::iterator iterator_type;
private: vector_type m_vector;
public: t_collection(const vector_type& in) { ... } t_collection(iterator_type first, iterator_type end) { .. } iterator_type begin() { ... } iterator_type end() { ... } };

N'oubliez pas que dans certains cas, vous pouvez avoir besoin d'utiliser le mot-clef typename

N'oubliez pas les exceptions

Votre code template a l'obligation d'être résistant aux exceptions. En termes simples, cela signifie qu'une exception générée par votre code (et donc, bien évidemment, par le code que vous appelez) ne doit pas mettre en péril l'invariant de votre classe ni créer de resource leak. La manière la plus simple de procéder et de faire en sorte de n'effectuer aucune modification de l'état interne de votre classe tant qu'il est encore possible qu'une exception soit levée.

Cet exemple n'est pas résistant aux exceptions :

template <class T>
class type
{
public:
  typedef T value_type;
  typedef const value_type& cr_value_type;
  typedef value_type* p_value_type;
private: p_value_type m_array; std::size_t m_size;
public: type() : m_array(NULL), m_size(0) { }
void add(cr_value_type value) { p_value_type array = new value_type[m_size + 1]; array[m_size] = value; ++m_size; delete m_array; m_array = array; } };

Le code de add() est presque correct. Lu de cette manière, il n'y a guère de doute qu'il fonctionne. Que se passe-t-il si on utiliser cette classe misuse en lieu et place du paramètre T ?

struct misuse
{
...
  misuse& operator=(const misuse& other)
  {
    if (!other.allow_copy()) throw std::runtime_error("instance does not allow copy");
    ...
    return *this;
  }
...
};

Vous avez bien lu, une exception est générée par operator=(). Tout à coup, un problème se pose dans le code de type<T>::add() : au cas ou une exception est générée par la seconde ligne, alors la mémoire allouée à la première ligne est perdue. Et maintenant, la question que vous vous posez est : comment éviter le problème ? Vous n'avez pas accès au code de misuse, donc vous ne devez modifier que type<T>. La solution la plus simple est de récupérer les exceptions (dans un bloc try/catch), nettoyer ce qui doit l'être avant de relancer l'exception reçue. Honnêtement, ce n'est pas particulièrement beau.

L'autre solution est d'utiliser des techniques telle que RAII en conjonction avec d'autre mécanismes de protection tel que par exemple l'idiome swap.

Ainsi, par exemple :

template <class T>
class type
{
public:
  typedef T value_type;
  typedef const value_type& cr_value_type;
  typedef value_type* p_value_type;
private: class array_container { p_value_type m_array; std::size_t m_size; public: array_container(std::size_t n) : m_array(new value_typen), m_size(n) { } ~array_container() { delete m_array; } void swap(array_container& other) throw () { std::swap(m_array, other.m_array); std::swap(m_size, other.m_size); } std::size_t size() const { return m_size; } };
array_container m_array;
public: type() : m_array(NULL), m_size(0) { }
void add(cr_value_type value) { array_container other_array(m_array.size() + 1); other_array[m_array.size()] = value; other_array.swap(m_array); } };

Que se passe-t-il dans ce cas ? Si une exception est générée au moment de la copie de value, type<T>::array_container::swap() n'aura pas lieu. Le contenu de m_array n'est donc pas modifié (l'invariant est donc conservé). De plus, le destructeur de type<T>::array_container sera appelé pour détruire l'instance other. La mémoire allouée sera donc libérée.

Élargissez l'utilisation de votre template au maximum

Est-ce que votre classe template ne fonctionne que sur un nombre réduit de type ? Si oui, peut-être est-ce parce que c'est un mauvais candidat à la généralisation. Une autre raison est que vous avez mis des contraintes trop fortes sur le type de donnée qui peut être passé en paramètre. Dans la plupart des cas, c'est parce que vous utilisez les connaissances que vous possédez sur la classe paramètre.

template <class T>
struct type
{
  typedef T value_type;
void call_func(const value_type& value) const { value.func(); } };

Vous en avez parfaitement le droit. Au moment de l'instanciation, le compilateur va vérifier que la classe T possède bien une méthode T::func() et va générer une erreur dans le cas contraire. Cependant, toutes les classes ne définissent pas cette méthode T::func() dont vous avez pourtant besoin.

La solution est de passer par des type traits et des policies[2].

Dans cet exemple, on peut faire :

template <class T, class P = policy<T> >
struct type
{
  typedef P policy_type;
  typedef T value_type;
void call_func(const value_type& value) const { // erreur - voir les commentaires ci-après (merci Florian !) // policy_type<T>::call_func(value); policy_type::call_func(value); } };

Et on défini la classe policy:

// par défaut, ne défini rien...
template <class T> class policy { };
template <> class policy<mon_type_a> { static void call_func(const mon_type& value) { value.function_a(); } };
template <> class policy<mon_type_b> { static void call_func(const mon_type& value) { value.function_b(); } };

Pour permettre à type<T> de manipuler un type T qui ne contient pas de méthode func, il suffit de créer la policy correspondante.

Respectez le principe de moindre surprise

Les template permettent, par le biais de spécialisations et autre amusements divers, de redéfinir des comportements qui sont parfois très différent selon les paramètres qui sont utilisés pour l'instanciation du template. Un exemple avec la fonction std::advance, qui avance un itérateur d'un certain nombre de pas. Si l'itérateur en question est un itérateur à accès aléatoire (par exemple std::vector<>::iterator), std::advance va simplement utiilser son operator+(). Si l'itérateur ne propose qu'un accès séquentiel (par exemple std::list<>::iterator), std::advance va boucler sur l'appel de l'operator++().

Plus généralement, évitez de changer du tout au tout le comportement d'une classe en fonction des paramètres qui lui sont donnée, à moins que ce changement notable de comportement ne soit induit par le design lui-même.

Notes

[1] Attention: des fois, ça pique les yeux.

[2] Faute de meilleur terme...

Commentaires

1. Le vendredi, avril 30 2010, 15:39 par Florian

@@policy_type<T>::call_func(value);
@@
Le <T> ne serait-il pas en trop ?

Merci pour cet article, je n'avais jamais compris l'intérêt de toujours redéfinir les types, si ce n'est que c'est quand même plus lisible.

2. Le lundi, mai 3 2010, 10:26 par Emmanuel Deloget

Le <T> est peut-être en trop. Je vais regarder ce que ça implique de le laisser ou de l'enlever (j'ai peu qu'à ce niveau, ça soit de l'ordre de la subtilité ; que se passe-t-il lorsque P n'est pas un type template ?)

Et merci pour le remerciement ! :)

3. Le vendredi, mai 7 2010, 16:17 par Florian

Je disais qu'il était en trop car policy_type n'est pas un template (P=policy<T>), j'ai testé (sous GCC), et il me dit que le T est en effet en trop (et que c'est struct pas strut).
J'ai peur de ne pas comprendre ta réponse, "P n'est pas un type template", P peut-il être un template ?

4. Le mercredi, mai 12 2010, 10:21 par Emmanuel Deloget

Dans l'exemple que je donne, P est par défaut du type policy<T>, spécialisé en policy<mon_type_a> ou policy<mon_type_b> ; il s'agit bien d'un type template. On peut bypasser complètement la définition par défaut, pour lui donner par exemple le type policy_pour_type_a, qui n'est pour le coup pas un type générique.

Par conséquent, policy_type étant un alias sur P, quelle que soit la définition de P, l'expression policy_type<T> n'est pas valide.

Je m'en vais corriger le billet pour refléter ça.

Encore merci pour ton aide !

5. Le lundi, juillet 5 2010, 14:24 par Ekinox

policy == classe de politique ; non ?

Sinon, une petite question : Si je ne me trompe pas, il est possible de définir un template comme ça : template < typename policy < typename T > > (ou quelque chose d'approchant). Qu'est-ce que ça veut dire exactement ?

Dans tous les cas, merci pour ce blog :) (et je suis aussi heureux de constater que les images ont réapparu depuis quelques posts, c'est quand même bizarre qu'elles aient disparu avant ...)

6. Le lundi, juillet 5 2010, 14:29 par Ekinox

Précision que j'allais oublier : Le titre de l'article est bizarre, il m'avait fait penser à :
template < typename T >
struct do_not_misuse
{
BOOST_CONCEPT_ASSERT((EqualityComparable<T>));
};

M'enfin, ce n'est pas exactement la même chose ...

7. Le mercredi, août 21 2013, 12:57 par Will

@Ekinox plutôt : template < template < typename T > class P > (note : le "T" est ignoré ici). P est un "paramètre template template".

Soit la classe suivante :

template< typename T >
class Policy {
    static void f() {} // ...
};

On peut définir (comme dans l'article)

template< typename T, typename P = Policy< T > >
struct Foo {
    typedef P policy_type; // P
    // On peut utiliser policy_type::f()
};

mais également

template< typename T, template< typename > class Q = Policy >
struct Bar {
    typedef Q< T > policy_type; // Q<T>
    // On peut utiliser policy_type::f()
};

On peut les instancier ainsi :

Foo< int, Policy< int > > foo;
Foo< int > foo2;
Bar< int, Policy > bar;
Bar< int > bar2;

La seconde forme (Bar, avec le paramètre template template) est restrictive. Par exemple, avec cette autre policy :

template< typename T, typename U = T >
class OtherPolicy {
    static void f() {} // ...
};

on peut faire

Foo< int, OtherPolicy< int > > other_foo;

mais pas

Bar< int, OtherPolicy > other_bar; // error

car Bar demande un Q avec exactement 1 paramètre template, et OtherPolicy en a 2 (même si le second a une valeur par défaut, ça ne marche pas).

Ajouter un commentaire

Les commentaires peuvent être formatés en utilisant une syntaxe wiki simplifiée.

Fil des commentaires de ce billet