40

Better Macros, Better Flags

 3 years ago
source link: https://www.tuicool.com/articles/ENV3UfZ
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

Published May 28, 2019

Today’s guest post is written by guest author Foster Brereton . Foster is a 20-year C++ veteran at Adobe, and a Senior Computer Scientist on Photoshop. He is also a contributor to the Adobe Source Libraries and stlab.cc . He can be reached at @phostershop on Twitter.

Once thought of as a handy tool in the programmer’s toolbelt, macros have more recently attained the title of preprocessor pariah. Though the number of sharp edges with macros are numerous, there are still many problems for which they remain the best solution. In this article, we’ll be focusing on their use as compile-time feature flags. We’ll also be talking about how best to structure compile-time flags to maximize correctness and expressiveness in your code.

The Macro as a Compile-Time Flag

Compile-time flags should be nothing new. Early in their C++ careers, developers learn to leverage them as such when wrapping headers:

#ifndef FOO_HPP
#define FOO_HPP
// ... Contents of foo.hpp
#endif // FOO_HPP

This, of course, keeps symbols from being multiply-defined in a translation unit. The pattern depends on the preprocessor being able to check for the existence of a token, and conditionally compile the code one way or another.

Unintentionally Undefined Macros

Any project with a sense of scale leverages this capability to compile (or omit) code based on compile-time flags. However, macros in C/C++ are notorious for tripping up developers and snarling projects. We’ve all seen this in production code:

#if DO_MY_THING
    // Do the things
#endif // DO_MY_THING

Simple enough: if DO_MY_THING is nonzero, the code will be compiled, otherwise it will be omitted. A large, hairy, dragon-shaped gotcha about the above technique comes when the macro is unintentionally undefined for the translation unit. Consider:

// my_header.hpp
#define DO_MY_THING 1
//...// my_source.cpp
// The source does _not_ include my_header.hpp
#if DO_MY_THING
    // Do the things
#endif // DO_MY_THING

The things are not done! If the switch is not globally defined for all translation units, it is easy for code inclusion/omission to be inconsistent across the project.

“Ah!” comes the retort. “You should check only for the existence of a token, not its value .” Okay, let’s try that. The code changes very little to accommodate:

#ifdef DO_MY_THING // also expressed as #if defined(DO_MY_THING)
    // Do the things
#endif // DO_MY_THING

Despite the best intents, the situation has not improved. In an aged code base, for example, it is not uncommon to observe the following:

// my_header.hpp
#define DO_MY_THING 0
//...// my_source.cpp
#include "my_header.hpp"
#ifdef DO_MY_THING
    // Do the things
#endif // DO_MY_THING

Now the things are done, when they should not be! By most accounts, the intent of the code is to omit the code block, but you won’t know for sure without some hair-pulling.

Even with the above problem out of the way, the most devious one still remains. If a macro is unintentionally undefined, your code will compile one way when it was meant to compile another. The compiler is perfectly happy whether your macro exists in a translation unit or not.

Finally, whether you use #if or #if defined() , compile-time macro checks are also susceptible to inclusion ordering bugs. Say you have a feature flag defined in one header, but checked in three:

#include "first.hpp" // checked but not defined - OK
#include "second.hpp" // defined - Uhh...
#include "third.hpp" // checked and defined - Yikes!

Again, it is hard to discern exactly what the developer’s intent is here without some costly introspection.

Software is unmaintainable and does not scale when its compile-time infrastructure is riddled with these kinds of issues.

The Function-Like Macro

Fortunately, the solution to all of these problems is a short hop from where we currently are. Function-like macros differ from their problematic cousins in that their definition is required by the compiler when they are used . Consider:

// my_header.hpp
#define DO_MY_THING() 1
//...// my_source.cpp
// The source does _not_ include my_header.hpp
#if DO_MY_THING()
    // Do the things
#endif // DO_MY_THING

Without defining DO_MY_THING first, you will end up with an error that looks like the following:

Function-like macro 'DO_MY_THING' is not defined

The compiler is very helpful here by calling out the oversight made by the developer. This ensures that the macro is defined everywhere it is used, and that the value will be consistent across all translation units.

It is hard to overstate the value of this change. A whole class of macro-based sharp edges are immediately smoothed out with this reconfiguration. With the compiler as their enforcer, developers can be confident that a macro is meaningfully defined when it is used.

Macro Prefixing

It is worth calling out the global scope of macros and our need to smooth out yet another sharp edge. Because they are unfettered in their ability to propagate, it is important to prefix your macros to make them unique. This is especially valuable at scale when you have multiple components or libraries that have their own suite of compile-time flags.

For the purposes of this article, we’ll prefix all our macros with BMBF_ (after the title.) It is recommendedthat a prefix be at least two characters to facilitate uniqueness.

Macro Categorization

With function-like macros we can pass parameters through our preprocessor expressions, giving us a remarkable boost in the readability of our code. Consider a suite of feature-flag macros thusly defined:

#define BMBF_TARGET_OS(X) BMBF_##X()
#define BMBF_MAC() 1
#define BMBF_WINDOWS() 0
#define BMBF_LINUX() 0void clear_temp_directory() {
#if BMBF_TARGET_OS(MAC)
    // Mac-specific code
#elif BMBF_TARGET_OS(WINDOWS)
    // Windows-specific code
#elif BMBF_TARGET_OS(LINUX)
    // Linux-specific code
#else
#error Unknown target OS.
#endif
}

With this pattern, we can also have separate macro categories that accomplish different compile-time intents:

#define BMBF_WITH_FEATURE(X) BMBF_##X()
#define BMBF_FANCY_GRAPHICS() 0
#define BMBF_NEW_SOUNDS() 1
#define BMBF_PERFORMANCE_IMPROVEMENTS() 1void my_function() {
#if BMBF_WITH_FEATURE(PERFORMANCE_IMPROVEMENTS)
    // More performant code
#endif
}

“Beware!” comes the retort. “There’s nothing stopping me from crossing between these categorizations! The expression #if BMBF_TARGET_OS(NEW_SOUNDS) would be well-formed though ill-intended, no?” As it turns out, we can improve the categorizations to keep something like that from happening:

#define BMBF_TARGET_OS(X) BMBF_TARGET_OS_PRIVATE_DEFINITION_##X()
#define BMBF_TARGET_OS_PRIVATE_DEFINITION_MAC() 1
#define BMBF_TARGET_OS_PRIVATE_DEFINITION_WINDOWS() 0
#define BMBF_TARGET_OS_PRIVATE_DEFINITION_LINUX() 0#define BMBF_WITH_FEATURE(X) BMBF_WITH_FEATURE_PRIVATE_DEFINITION_##X()
#define BMBF_WITH_FEATURE_PRIVATE_DEFINITION_FANCY_GRAPHICS() 0
#define BMBF_WITH_FEATURE_PRIVATE_DEFINITION_NEW_SOUNDS() 1
#define BMBF_WITH_FEATURE_PRIVATE_DEFINITION_PERFORMANCE_IMPROVEMENTS() 1

Category prefixing at macro definition time yields a number of benefits. For one, the as-used code is the same:

#if BMBF_TARGET_OS(MAC) // Still nice and terse
    // ...
#endif

Secondly, macro category cross-over yields a compiler error:

#if BMBF_TARGET_OS(NEW_SOUNDS) // Error: Function-like macro 'BMBF_TARGET_OS_PRIVATE_DEFINITION_NEW_SOUNDS' not defined

Platforms and Products and Features, Oh My

When working on a large code base such as Photoshop, the source code has to thrive in a dizzying number of environments and phases of development. Because of this, we have had to structure our compile-time flags to keep things maintainable and correct. For Photoshop, we define three categories of configuration macros and have established an explicit relationship between them.

Platform Macros

Platform macros denote operating-system- or machine-level features. They are automatically derived based on built-in preprocessor definitions defined at compile-time. Platform macros are used like so:

#if BMBF_CURRENT_PLATFORM(MACOS)
    // Code for macOS-based systems
#endif

It is common to have more than one platform defined per translation unit, for two reasons. One, some platforms are specializations others (e.g., MacOS contains POSIX support). Second, we consider optional, large-scale OS technologies as separate platforms (like Metal, Neon, or SSE). For example:

#if BMBF_CURRENT_PLATFORM(MACOS)
// Code for macOS-based systems
#elif BMBF_CURRENT_PLATFORM(IOS)
// Code for iOS-based systems
#endif
 
#if BMBF_CURRENT_PLATFORM(APPLE)
    // Code for both macOS- and iOS-based systems
#endif#if BMBF_CURRENT_PLATFORM(POSIX)
    // Code for all POSIX-based systems (Apple, Android, etc.)
#endif

Product Macros

Product macros denote what product (target) is being built. There is exactly one product defined per translation unit. The product macro is defined at the project level, and must precede any preprocessing. For example, you would specify the product at the command line:

clang++ -DBMBF_CURRENT_PRODUCT_CONFIG=DESKTOP

Then check the product in your code with the BMBF_CURRENT_PRODUCT macro:

#if BMBF_CURRENT_PRODUCT(DESKTOP)
    // Code for Desktop only
#endif

Feature Macros

Feature macros define what application-level features should be included in the compilation. The set of features is always derived from a combination of the target product and platform(s):

Product ∩ Platforms → Features

For example:

#define BMBF_WITH_FEATURE_PRIVATE_DEFINITION_EXTRA_LARGE_METAL_SHADERS() BMBF_CURRENT_PRODUCT(DESKTOP) && BMBF_CURRENT_PLATFORM(METAL)

There are any number of features defined per translation unit. Features are checked in code with the BMBF_WITH_FEATURE macro:

#if BMBF_WITH_FEATURE(EXTRA_LARGE_METAL_SHADERS)
    // Extra large Metal shaders
#endif

Best Practices

When looking to block code in your source file(s), it is best practice to block based on a feature, not a platform or product. Because of the established relationship between the three macro types, it is the feature macros that are the most configurable, and therefore should be preferred. If you must, you can block on per-product or per-platform, as long as a single token is completely sufficient. The intent here is to minimize the amount of cognitive overhead imposed on a developer who is trying to read blocked-out code.

Also, it is generally bad practice to negate a feature flag. When that happens, the developer is implicitly creating a new feature flag that should be explicit:

#if !BMBF_TARGET_OS(MACOS) // Bad: This is implicitly a new (mystery) target OS
    // ...
#endif#if BMBF_TARGET_OS(WINDOWS)
    // ...
#elif BMBF_TARGET_OS(MAC)
    // ...
#else
    #error Unknown OS. // No surprises
#endif

Converting to Function-like Macros

So the question arises: given the finicky nature of old macros, how do you reliably replace them with function-like counterpart across an entire project? Let’s walk through an example.

It is important to correctly decide if the new macro is to be a product, platform, or feature. These definitions should not be mixed, as they each make a distinct contribution to a project.

In order to do the macro migration, we can leverage the compiler to catch instances of the old macro, and keep that old macro from reappearing with subsequent merges from older branches.

The first thing to do is create a new macro next to the old macro’s definition:

#define OLD_MACRO_FANCY_GRAPHICS //...
#define BMBF_WITH_FEATURE_PRIVATE_DEFINITION_FANCY_GRAPHICS() 1

Next, we redefine – don’t remove! – the old compiler flag to something that will cause the compiler to emit an error:

// Poisoned YYYY-MM-DD. Use BMBF_WITH_FEATURE(FANCY_GRAPHICS) instead.
#define OLD_MACRO_FANCY_GRAPHICS POISONED_OLD_FANCY_GRAPHICS()
#define BMBF_WITH_FEATURE_PRIVATE_DEFINITION_FANCY_GRAPHICS() 1

By leaving BMBF_POISONED_OLD_FANCY_GRAPHICS intentionally undefined, we have turned what was once a weakness into a strength: the compiler catches instances where the old macro is used in our project, and we can go in and replace them one-by-one with the new macro. The poisoned macro should remain in place for some duration of time while older branches receive the change (in case those branches added uses of the old macro.)

Hopefully, we have managed to restore some honor to the macro as a useful C++ capability. Macros are a powerful compile-time tool to make code conditionally compile across a variety of environments and targets. When designed and composed correctly, macro categories add a level of clarity and robustness to compile-time flags. This reduces the mental time taken from developers to discern exactly how the code is being compiled, making the entire project easier to work in.

Finally…

A companion set of sources that demonstrate these ideas are available as a GitHub gist .

A huge thank you to Nick DeMarco and Sean Parent for helping me refine the ideas presented here.

Notes

[1] Yes, I’ve heard of #pragma once . Despite it’s near-universal support across most modern compilers, it is not standard C++.[2] The only thorn left in this bed of roses is found in legacy tools. Rez, the deprecated resource compiler for macOS, does not support function-like macros. Neither does RC, the resource compiler on Windows. If you have headers that need to be used there, you are stuck with non-function macros.[3] For more excellent recommendations, see http://stlab.cc/tips/library-coding-style.html [4] In practice, the more characters, the better. At Adobe, both Photoshop and PostScript are abbreviated “PS”, so within Photoshop’s sources we went with the more verbose prefix of PHOTOSHOP_

.

You will also like

Share this post!&nbspDon't want to miss out ?

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK