Faire un peu joujou avec le préprocesseur...


Ça faisait un bail que je n'écrivais pas sur Coder-Studio...
Chose réparée :)

Du 17 au 19 avril 2009, a eu lieu la finale de Cod'INSA, un concours de programmation inter-INSA,
qui s'est déroulée à l'INSA de Toulouse (plus d'infos sur http://codinsa.insa-lyon.fr pour les intéressés).
Faisant partie des organisateurs, j'ai eu à faire l'interfaçage C++/Java (oui parce qu'il y en a qui veulent
participer en Java...allez comprendre :p [/troll]). J'y ai découvert les joies de GCJ et de CNI, ce qui
pourra peut-être être l'objet d'un autre article, selon ma motivation...

Bref, tout ça pour dire qu'il m'a fallu interfacer une librairie écrite en C avec du Java, et qu'il a fallu
automatiser un peu le boulot pour la partie concernant les énumérations.

Dans cet article, je vais illustrer une méthode pour faciliter l'affichage des valeurs des énumérations.
En gros, le but, c'est que si l'on a :

MonEnumeration e = E_VAL_1;
std::cout << e << std::endl;

il s'affiche alors à l'écran "E_VAL_1".

La version non automatisée consiste en l'écriture d'une surcharge de l'opérateur "<<". Programme de démonstration :

#include <iostream>
#include "Enumerate.h"
 
enum MonEnumeration
{
	E_VAL_1,
	E_VAL_2
};
 
std::ostream& operator<<(std::ostream& os, const MonEnumeration& e)
{
	switch(e)
	{
	case E_VAL_1:
		os << "E_VAL_1";
		break;
	case E_VAL_2:
		os << "E_VAL_2";
		break;
	}
	return os;
}
 
int main()
{
	MonEnumeration e = E_VAL_1;
	std::cout << e << std::endl;
	return 0;
}

Ce programme affiche bien "E_VAL_1" à l'écran.

Mais c'est tout de même bien contraignant : la duplication des informations est évidente : entre la
déclaration de l'énumération et l'opérateur "<<", "E_VAL_1" est écrit 3 fois ! C'est évidemment source
d'erreurs (le copier-coller n'est pas le meilleur ami du programmeur, loin s'en faut...), particulièrement
lorsque l'on veut rajouter de nouvelles valeurs à l'énumération. Et puis c'est tout simplement pénible
de devoir maintenir l'énumération et l'opérateur.
Bref, c'est moche, peut mieux faire.

C'est là qu'arrive le préprocesseur.

A partir de là, je suppose que vous utilisez GCC (ou MinGW/Cygwin sous Windows).

Nous allons en particulier nous baser sur 2 opérateurs spéciaux du préprocesseur, qui ne sont pas très
connus : # et ##.

Commençons par # : il permet de rajouter des guillemets autour de l'argument d'une macro.

Démonstration :

#define STRINGIFY(val) #val
 
STRINGIFY(pouet)

Supposons que vous ayez enregistré cela dans "pouet.h" : passez un coup de préprocesseur : "cpp pouet.h"
(ou bien "g++ -E pouet.h", ça revient au même).

Résultat affiché :

# 1 "pouet.h"
# 1 "<interne>"
# 1 "<command-line>"
# 1 "pouet.h"
 
 
"pouet"

Bon, déjà, ça c'est intéressant :) On remarque que cpp écrit des lignes commençant par un #, que le compilateur
qui passe derrière doit probablement interpréter pour les messages d'erreurs afin d'indiquer la ligne de l'erreur.

Passons à la suite : on voudrait une macro qui permette d'automatiser la création de l'opérateur "<<" pour
l'affichage de la valeur de notre énumération.
Essayons d'abord en supposant que notre énumération ne puisse contenir qu'une seule valeur :

#define MAKE_ENUM(enum_name, val)	\
	enum enum_name {	\
		val			\
	};
 
#define MAKE_OPERATOR(enum_name, val)	\
	inline std::ostream& operator<<(std::ostream& os, const enum_name& e)	{\
		switch(e) {	\
			case val:	\
				os << #val;	\
				break;	\
		}	\
		return os;	\
	}
 
 
#define ENUMERATE(enum_name, val)	\
	MAKE_ENUM(enum_name, val)	\
	MAKE_OPERATOR(enum_name, val)
 
ENUMERATE(MonEnumeration,
	E_VAL_1);

On a donc une macro MAKE_ENUM qui s'occupe de déclarer l'énumération et une macro MAKE_OPERATOR qui s'occupe
de définir l'operateur "<<". Notez le "inline" qui sert à éviter tout problème de linkage (si le même fichier .h,
qui décrit l'énumération et l'opérateur, est inclus dans 2 .cpp différents, on se retrouve avec l'opérateur défini 2
fois, et couic !).

Cool, ça marche ! (en même temps c'est un tuto...)

Maintenant, on peut arrêter de contourner la difficulté et s'attaquer au vrai problème : comment gérer le fait
qu'une énumération n'ai pas 1, 2, ou 3 valeurs, mais un nombre variable ??

C'est là qu'arrive une autre astuce du préprocesseur : la macro __VA_ARGS__.

Démonstration, dans notre pouet.h :

#define MA_MACRO(...) __VA_ARGS__
MA_MACRO(a, b, c)

cpp pouet.h donne :

# 1 "pouet.h"
# 1 "<interne>"
# 1 "<command-line>"
# 1 "pouet.h"
 
a, b, c

Pratique, quand on veut des macros avec un nombre variable d'arguments...

On peut dores et déjà remplacer MAKE_ENUM par une version capable de gérer un nombre variable d'arguments :

#define MAKE_ENUM(enum_name, ...)	\
	enum enum_name { __VA_ARGS__ };

Ça, c'est fait. Maintenant, reste le problème de MAKE_OPERATOR...

Si l'on analyse le problème, on voit que l'on a 3 parties :

- une introduction :

inline std::ostream& operator<<(std::ostream& os, const enum_name& e)	{
		switch(e) {

- une partie qui se répète :

		case val:
			os << #val;
			break;

- une conclusion :

		}
		return os;
	}

Il s'agit donc, à partir d'une liste variable d'arguments, de répéter plusieurs fois la partie centrale pour
chaque argument "val". C'est là que j'ai pensé à ces vieux cours de Caml, où pour traiter une liste, on écrivait
une fonction récursive, qui décomposait la liste en t::q (pour tête et queue), la tête étant le 1er élément, et la
queue tout le reste. Désolé pour ceux qui n'ont pas fait de Caml :p

Pour revenir à notre préprocesseur, cela se traduirait par une macro qui prend en argument quelque chose de la forme :
#define MACRO(a, ...) et qui ensuite s'appellerait récursivement jusqu'à ce qu'il n'y ait plus d'argument.

Malheureusement, la récursivité, en préprocesseur ça n'existe pas, ou alors j'ai pas trouvé comment...

Il va donc falloir trouver une parade : il s'agira d'écrire une version de la macro pour chaque appel récursif, i.e.
pour chaque nombre d'arguments :

#define ENUM_CASE_1(val)	\
	case val:	\
		os << #val;	\
		break;
 
#define ENUM_CASE_2(val, ...)	ENUM_CASE_1(val) ENUM_CASE_1(__VA_ARGS__)
#define ENUM_CASE_3(val, ...)	ENUM_CASE_1(val) ENUM_CASE_2(__VA_ARGS__)
#define ENUM_CASE_4(val, ...)	ENUM_CASE_1(val) ENUM_CASE_3(__VA_ARGS__)

Le problème n'est toujours pas totalement résolu : encore faut-il choisir la bonne macro à utiliser...

C'est là qu'arrive l'opérateur ## du préprocesseur :)
Celui-ci permet de concaténer des symboles.

Exemple (dans pouet.h) :

#define CONCAT(a, b) a ## b
CONCAT(foo, bar)

"cpp pouet.h" affiche cette fois :

# 1 "pouet.h"
# 1 "<interne>"
# 1 "<command-line>"
# 1 "pouet.h"
 
foobar

Cela va nous servir à choisir la bonne macro à utiliser :

#define ENUM_CASE_N(nb_vals, ...) ENUM_CASE_ ## nb_vals(__VA_ARGS__)

Le gros inconvénient est bien sûr qu'il nous faut préciser le nombre de valeurs de l'énumération :/

Il semble que l'on ait fait le tour. Voici donc la version finale de tout ça, séparée dans un .h générique
et un .cpp d'exemple :

// Enumerate.h
 
#ifndef ENUMERATE_H
#define ENUMERATE_H
 
#define MAKE_ENUM(enum_name, ...)	\
	enum enum_name { __VA_ARGS__ };
 
#define ENUM_CASE_1(val)	\
	case val:	\
		os << #val;	\
		break;
 
#define ENUM_CASE_2(val, ...)	ENUM_CASE_1(val) ENUM_CASE_1(__VA_ARGS__)
#define ENUM_CASE_3(val, ...)	ENUM_CASE_1(val) ENUM_CASE_2(__VA_ARGS__)
#define ENUM_CASE_4(val, ...)	ENUM_CASE_1(val) ENUM_CASE_3(__VA_ARGS__)
#define ENUM_CASE_5(val, ...)	ENUM_CASE_1(val) ENUM_CASE_4(__VA_ARGS__)
#define ENUM_CASE_6(val, ...)	ENUM_CASE_1(val) ENUM_CASE_5(__VA_ARGS__)
#define ENUM_CASE_7(val, ...)	ENUM_CASE_1(val) ENUM_CASE_6(__VA_ARGS__)
#define ENUM_CASE_8(val, ...)	ENUM_CASE_1(val) ENUM_CASE_7(__VA_ARGS__)
#define ENUM_CASE_9(val, ...)	ENUM_CASE_1(val) ENUM_CASE_8(__VA_ARGS__)
#define ENUM_CASE_10(val, ...)	ENUM_CASE_1(val) ENUM_CASE_9(__VA_ARGS__)
 
#define ENUM_CASE_N(nb_vals, ...) ENUM_CASE_ ## nb_vals(__VA_ARGS__)
 
#define MAKE_OPERATOR(nb_vals, enum_name, ...)	\
	inline std::ostream& operator<<(std::ostream& os, const enum_name& e)	{\
		switch(e) {	\
			ENUM_CASE_N(nb_vals, __VA_ARGS__)	\
		}	\
		return os;	\
	}
 
 
#define ENUMERATE(nb_vals, enum_name, ...)	\
	MAKE_ENUM(enum_name, __VA_ARGS__)	\
	MAKE_OPERATOR(nb_vals, enum_name, __VA_ARGS__)
 
#endif // ENUMERATE_H
// main.cpp
 
#include <iostream>
#include "Enumerate.h"
 
ENUMERATE(3, MonEnumeration,
	E_VAL_1,
	E_VAL_2,
	E_VAL_3);
 
int main()
{
	MonEnumeration e = E_VAL_1;
	std::cout << e << std::endl;
	return 0;
}

Conclusion

Au final, cette méthode présente l'avantage d'éviter la duplication de code, et tous les ennuis qui vont avec.
Il reste cependant plusieurs inconvénients non négligeables :
- On est obligés de préciser le nombre de valeurs de l'énumération
- On est limités dans le nombre de valeurs que l'on peut définir par le nombre de macros que l'on a définis dans Enumerate.h
A la limite, si l'on a besoin de plus de valeurs, il suffit de les rajouter, c'est pas bien grave ^^
- Pour la plupart des IDE, je pense qu'un tel système aboutit à un type "MonEnumeration", ainsi que les valeurs définies,
qui sont inconnus. Ça peut être un problème...
- En déclarant ses énumérations de la sorte, on ne peut pas préciser de valeur numérique pour chaque valeur possible de
l'énumération.

La méthode a ses limites, et je me demande s'il n'y aurait pas moyen d'aller au-delà en utilisant des templates...
Par ailleurs, ce petit article sur le préprocesseur ne fait aucune mention de Boost::Preprocessor, qui, selon Arnaud,
est incroyable à voir...
Je serais curieux de voir s'il pourrait apporter un élément de réponse au problème ^^

Ce sera tout !

, ,

  1. #1 by libjch - juillet 15th, 2009 at 15:45

    Bon tu la met ta version sans le nombre de paramètres ? :P

  2. #2 by Twxs - juillet 15th, 2009 at 16:41

    laisses le un peu bosser, tout le monde n'est pas en vacances :)

  3. #3 by Funto - juillet 15th, 2009 at 20:08

    Merci Twxs :)

    Ça va venir, ça va venir...

  4. #4 by Alp Mestan - juillet 16th, 2009 at 17:05

    Mais euh... C'était pas dans Boost.Preprocessor tout ça déjà ? Je ne suis pas sûr.

    Par contre, c'est clairement intéressant :)

  5. #5 by Funto - juillet 16th, 2009 at 23:23

    Peut-être, j'en sais rien, comme je le dis à la toute fin de l'article, faudrait peut-être parler de Boost.Preprocessor mais je ne connais pas du tout ^^

    Vais me documenter un peu ;)

    Merci sinon :)

  6. #6 by Calvin1602 - juillet 19th, 2009 at 21:00

    Alp -> il ne semblerait pas.
    J'ai retenté plusieurs trucs à partir de ça. Avec des templates, avec boost::preprocessor, ... rien de très fructueux.

    Soit dit en passant, boost::preprocessor gère 3 types de données, donc les tuples, de cette forme ci : (a, b, c) et les listes, de cette forme : (a , (b, (c, BOOST_PP_NIL)))

    Apparemment il est possible de faire des for et des while sur des listes, mais pour des tuples on est obligés de donner la taille. J'ai essayé de gruger en lui prenant en premier paramètre une liste dont le seul et unique élément était tout le tuple de tous les énums, mais il a pas aimé du tout.

    Bref, je reste sur mon idée que Boost::preprocessor c'est méga over puissant mais totalement inutilisable (genre vraiment, quoi), et que le seul moyen de faire ça parfaitement bien ça serait de faire comme Qt : un compilateur à part. Mais bon...

  7. #7 by Calvin1602 - juillet 19th, 2009 at 21:01

    Oh, au fait : quoi qu'il en soit, chouette technique ^^ yaurait pas moyen de faire un tableau indexé par l'enum, plutôt ? A priori si, et je trouverais ça plus stylé et plus rapide.

(will not be published)