HOME BLOG ARCHIVE TAGS

Another take on C/C++ x-macros

March 31, 2019

X-Macros were introduced as a metaprogramming pattern in tricks of the trade #1 (to avoid duplicating data when defining/declaring some types). Today we’ll generalize their implementations, so other issues can be workarounded.

Let’s start with a simple enum to represent some option:

1
2
3
4
5
6
  enum processing_t
  {
      OPT1 = 1,
      OPT2 = 2,
      OPT3 = 3
  };

Typed enums are valuable. Combined with switch statements, they’re pretty good maintenance helpers. But above definition has too much “structure” (I explicitly used the ordinal values, so it became evident). This is even more apparent here:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
  processing_t opt = ...;

  switch ( opt ) {
  case OPT1:
      // ...
      break;
  case OPT2:
      // ...
      break;
  case OPT3:
      // ...
      break;
  }

Every time processing_t is used, the same “list” is traversed. Be it with case labels, chains of if-else statements, or the values themselves (e.g., when initializing an array). Paul Graham sums it up perfectly: “when I see patterns in my programs, I consider it a sign of trouble”.

To remove the repetitions, processing_t coding “knowledge” is centralized in a unique corresponding list:

1
2
3
4
  #define OPT_(X)      \
      X(OPT1, opt1, 1) \
      X(OPT2, opt2, 2) \
      X(OPT3, opt3, 3)

Now we can do things like:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
  #define E_V_(O_, o_, v_) O_ = (v_),

  enum processing_t {
      OPT_(E_V_)
  };

  #define VALS_(O_, o_, v_) O_,

  const processing_t OPTS[] = {
      OPT_(VALS_)
  };

  #define CASES_(O_, o_, v_) \
    case (O_):               \
        o_ ## _handler();    \
        break;

  switch ( OPTS[n] ) {
      OPT_(CASES_)
  }

  #undef CASES_
  #undef VALS_
  #undef E_V_

The alert reader obviously noticed that we decoupled processing_t case handling from OPT_ explicit occurrences. And the enum itself is derived from the OPT_ list, unifying both implementations (i.e., if the program is changed in one place, everything gets adjusted accordingly).

Important notes:

  1. trailing enum commas were not allowed before C99 and C++11 (which is something strange and asymmetrical to array and structure initialization); for C89 and/or C++98/03 support, a “dummy” last sentinel (after line 4) keeps the compiler happy (a default label may also have to be introduced after line 19, to avoid “enumeration value not handled in switch” compilation warnings/errors);
  2. not all parameters are employed by all invocations; why?
  3. e.g., extra lower-case arguments were introduced; unfortunately, the preprocessor doesn’t allow symbol manipulations besides usual token pasting and/or stringizing; these case combinations allow different naming conventions to be integrated naturally;
  4. opt tokens were not implemented as the concatenation of some prefix and the values; bit masks are very common in C/C++, and their definitions usually don’t fit the names;
  5. the list doesn’t hardcode commas (or semicolons) as separators, so callers can adapt their definitions; sometimes this is not possible, and ugly preprocessor indirections (and/or more params) are necessary to cover all expansion scenarios (like the classic comma sep definition);

C++ macros have a bad (undeserved) reputation. No matter how cool and sophisticated template programming is nowadays, there are situations where only the preprocessor can help.