Using Types to Avoid Cross-Cutting
June 26, 2010
Consider the following code.
void Log( char const *const s )
{
#ifdef DEBUG
printf( "%s\n", s );
#endif
}
Doesn’t look too bad, eh? I mean, nobody really likes to rely on the preprocessor but you gotta do what you gotta do, right? I see two problems: firstly, it mixes cross-cutting concerns, namely logging and conditional execution; secondly, it conditionally compiles. The former problem makes the code harder to read and maintain whilst the second facilitates “it compiles on MY machine” syndrome.
Rather than sprinkling #ifdefs through code, consider defining “twin” classes, one containing the conditional code and the other being a “null” class containing *NO* code whatsoever. Then use the #ifdef to define which type to use. The client code is then clean i.e. no #ifdefs, release builds should elide the null class completely, and both the conditional code AND the null class are always compiled.
struct LoggerNull
{
static void Insert( char const* )
{
}
};
struct LoggerImpl: public LoggerNull
{
static void Insert( char const* const s )
{
printf( "%s\n", s );
}
};
#ifdef DEBUG
typedef LoggerImpl Logger;
#else
typedef LoggerNull Logger;
#endif
void Log( char const* const s )
{
Logger::Insert( s );
}
Does this code look cleaner to you? Overwrought? Let’s step through it.
LoggerNull provides the client interface and an empty implementation. LoggerImpl derives from LoggerNull so that it only need hide those methods that it needs to. Note that LoggerImpl does not override LoggerNull’s methods. It hides them. There is no vtable involved here and that is intentional: the intent of the original Log() method was to have zero overhead in release builds. Note also that there is no guarantee at the language level that LoggerImpl is actually hiding a LoggerNull method. Function signatures must match precisely. There is no way to explicitly tell C++ of the relationship we desire between LoggerNull and LoggerImpl. Finally, look at how apparent the responsibilities of LoggerNull and LoggerImpl are: there are no cross-cutting concerns present.
The #ifdef DEBUG shows up when we’re selecting our Logger type. It deals with a single concern, that being which type to use as Logger in different build configurations. It does not control compilation of those types.
The final implementation of Log() is syntactic sugar.
Now, consider what happens when LoggerNull and LoggerImpl get out of sync. By this I mean that one type implements a method that the other does not. As soon as you flip the configuration switch, you get a compile error. Best time in the world to catch an error, right? Then the coder who causes it to look closely at his new Logger method and how it’s used. He can decide whether the new functionality is debug-only or is necessary for release. It forces him to address that question before he can check in. “It compiles on MY machine” syndrome can still happen, but it should be far less frequent since both types are always compiled.
This toy example may be insufficient to demonstrate all the benefits to this technique. When I produce some hobby code that uses this technique in earnest, I’ll post it.
June 28, 2010 at 12:59 pm
And all this time I’ve never considered the cross-cutting. Thanks for letting me see the light.
July 27, 2010 at 11:42 am
I like this method, this would reduce a lot of the #ifdefs in the codebase I am currently working on.