Update in 2025-3-21
See [RFC] Extensions to export macros/(preprocessor states) for C++20 modules - #62 by ChuanqiXu for the updated proposal.
Abstract
C++20 modules was designed to not leak macro definitions (or preprocessor states). But clang’s implementation limits users to use C++20 modules for existing and complex projects in the real world efficiently. (See discussion below). To ease such limitations, we implemented an extension to export macros/(preprocessor states) for C++20 modules in the downstream. It works very well. It helped us to adopt C++20 modules in existing projects with over 7M LoC. It helped our products to C++20 modules in scale stably for more than 3 years. Our experience using this extension makes us believe that exporting macros for C++20 modules is very helpful.
The problems
I concluded the challenges, from implementor’s perspective, to use modules without exporting macros/preprocessors as the following problems:
- Force user to introduce duplicated declarations between module units.
- The import-before-include problem.
- Handling 3rd party libraries.
- The source location space.
The problem [1] is the key point. We can think problem[2] is a problem of QoI. The problem [3] is a result of problem[1] and problem [2]. The problem [4] is already existing but C++20 modules make it more explicit.
I heard MSVC and GCC has similar problems on [2]. But I am not sure if they have the same reasons. I’ll focus on clang here.
Problem 1: Duplicated declarations between module units
The idea to use macros in modules is easy: if you want it, include the corresponding header in GMF.
But in practice, many existing library may define macros with other declarations in the same header. This implies when we include a header to introduce a macro, we might probably introduced the declarations we don’t intended introduce. Then we might not be in an optimal state (see Standard C++ Modules — Clang 22.0.0git documentation for details). We wasted space and we will pay more at compilation time due to our logics to handle redeclarations.
This is unfortunate especially we’re dealing with 3rd party libraries that we can’t touch really.
While we can argue all other problems here are QoI, I do think this issue is the key point that we need to export macros. Otherwise, the modules won’t get best performance. Although modules have many other advantages, the compilation performance is the killing point.
Problem 2: The import-before-include problem
The problem is, the compiler may not be able to handle the following code well:
import a; // containing #include <a.h> directly or indirectly.
#include <a.h>
There were a lot such issue reports. I’ve fixed some of them. But I never say the issue is fixed. Since I think the issue may be a long standing one.
The logic is, when we inlcude-before-import, or we have multiple import contain the same declaration, we need to handle the declaration merging in ASTReader. But when we import-before-include, we would handle the declaration merging in Sema. So we’re doing 2 similar things in 2 different path. So we might do well in ASTReader but we might not do well in Sema due to the using practice in vendors.
So I assume the problem exist if we still have such divergence.
By exporting macro/preprocessor states to C++20 modules, we can avoid such redeclarations naturally.
Problem 3: Wrapping 3rd party libraries
Another issue to use modules in complex real world project is, the 3rd party libraries. Then it will be a problem if these 3rd party libraries doesn’t provide a module.
If we wrap them simply, like:
module;
#include <thirdparty>
export module thirdparty;
...
It will introduce declarations not belong to thirdparty to the module file of third party. At least, generally, all libraries will dependent on std module. Simply, we will meet the problem[1], the duplicated declaration in different module units.
With exporting macros, we can wrapping third party modules super easily in the most efficient way:
module;
import std;
#include <thirdparty>
export module thirdparty;
...
And also we can introduce an export-all extension so that we don’t need to write export using ... for third party declarations, if we want. That is much easier than this. (while this extension is already simple enough)
Problem 4: the source location space
Currently we use 32 bits to represent the source locations in the current compilation. One bit is used to represent the location are valid/invalid. So we have 31 bits, about 2G space for the source locations in a compilation.
And with modules, the problem is, the same header included by different module units, have different entry values in different module module, then when we imported these module files, the source locations for the same header will occupy N times than before (N is the number of imported modules includes the header). Then it is much easier than before to reach the hard limitations.
By exporting macros, we can avoid a lot of unnecessary include. So that we have more chance to not face the limitations.
It is another topic to increase the limitation though.
Implementation and Maintainance cost
Clang header modules, header units and C++20 modules share the same serialization/deserialization framework. Given both clang headers modules and header units can export macros, it should be natural to expect that C++20 modules can share macro from the same framework easily.
And also, given the preprocessor system is pretty stable, the maintainance cost is expected to be pretty low.
In fact, in the downstream, after I made it in early 2022, I rarely maintain it and it works consistently well.
This is important. There are a lot of things good. But we can’t make all of them due to the implementation pressure. And if we can have a pretty simple solution that works well, let’s adopt it.
Impacts
As an extension, it must be opt-in. It won’t affect anything if this is not enabled explicitly.
If this is enabled, it will affect:
- Build systems (tools) scanning.
- Preprocess
For scanning, this is the major concern for the past few years when we discuss whether or not export macros from named modules.
The question is:
// a.cc
import A;
#ifdef M
import B;
#endif
If A is allowed to export macros, how can the build system to decide whether or not the current TU may dependent on module B?
The pre-assumption is, the CURRENT scanning model used by build systems now can scan all source files in parallel. Then we can find the ability to export macros from named modules breaks the above model. Although technically we can solve it, there were deep concerns about the scanning performance.
Then what’s the proposed solution here? None. In fact, we just ignored the issue. And it worked very well in practice. Here are some thoughts:
- The problem has a pretty clear boundary that users can understand well. The new implicit rule is simple. And in downstream, we’ve already banned to put
importin headers and#ifblocks. (e.g., we can get import-relation ship by a grep command) - In the worst case, the exported macro affects dependency relationship, it is very likely to be a compilation error. The chance to make it a runtime error should be pretty rare in practice.
- It leaves space for tools to progress later. My feeling for the states of modules over the years is: this is a big (not precise) egg-and-chicken problem. Different parts of the ecosystem are waiting for each other to take the first step. By implementing the extension, the tools can have a chance to experiment the new scanning model in practice instead of imaging with a paper.
My conclusion for the problem is, this is a real problem but not a blocking one.
For preprocessor problem, we can illustrate it as:
// A.cppm
export module A;
#define A_VALUE = 43
// a.cc
import A;
int func() {
return A_VALUE;
}
then what is the expected preprocessing output for a.cc? Previously, we can preprocess a.cc without building A.cppm first. But after this extension, we have to build A.cppm first to preprocess a.cc.
Is this really bad? I feel it might not be the case as I realize it in the first place. Since after introducing modules, the TUs have dependencies on each other naturally. All tools that need to deal with semantics have to deal with the dependency somehow. And now we just add preprocess to these classes.
Header Units
Readers may feel that this extension take the place of header units. It is true for the Ideal header units. But not true for header units today.
Except for the lack support from build systems, the key problem is still the duplicated declarations from different units. The problem is:
// a.h
#include "base.h"
// b.h
#include "base.h"
// a.cc
import "a.h"
import "b.h"
...
(If you know the implicit transition of header include to header import, ignore it temporarily)
And let’s compile it with
clang++ a.h ... -o a.pcm
clang++ b.h ... -o b.pcm
clang++ a.cc -fmodule-file=a.pcm -fmodule-file=b.pcm ...
In the current implementation today, the a.pcm and b.pcm may contain duplicated declaration from base.h, it wastes space. In the last compilation, the compiler may also waste time to deal with redeclarations from different .pcm files. As we stated before, this is inefficient.
In the dream of header units in early days, we can solve the above problem by an action called “implicit transition”. It means, when the vendor see an inclusion, the vendor is allowed to treat the inclusion as if an import. The condition to do the transition is unspecified.
So for the above example, the build system were expected to build .pcm file for base.h and pass base.pcm to the compilation of a.pcm and b.pcm and hope the compiler to can transit the inclusion of base.h to base.pcm.
But now, the build system don’t build base.pcm for base.h. (In fact, there are rare build systems support header units) and the compiler won’t transit inclusion of base.h to base.pcm in headers. Also there are many unexplored areas and possible questions. e.g, what if we can’t use single .pcm file to present a header in different context. It seems complex. And I believe it is a long way to go.
And if we wish compilers to do all these jobs, we fall back to the discussion of implicit clang header modules VS explicit clang header modules. It seems there are different voices. But it seems not ended and complex.
After all, this thread is not about header units. I want to say the path to land header units is much longer. Personally, from implementor’s point of view, it is even better to ignore header units, to make the community to be more focus, given the resources are really limited.
User interface
Initially I want to make things simple. So I’ll send what we implemented and used. We can make more complex construct later.
The proposed interface consists of the producer side and the consumer side.
In the producer side, now we can offer an option -fmodules-export-macros for importable module unit to emit macros. Or we can add a pragma #pragma clang module "export-macros" in the source code.
In future, maybe we can introduce construct like #pragma clang module "export-macro-begin" and #pragma clang module "export-macro-end" to export macros conditionally.
In the consumer side, now we can use an option like -try-load-module-file-when-preprocess. Without this, the compiler can save a lookup-and-load process when the option is not enabled.
In the future, maybe we can have some construct like:
import A [[clang::import_macro]]; // import macro from A;
import B; // Don't import macro from B.
Summary
Without exporting macros for C++20 modules, it is hard to use C++20 modules in real world project efficiently. We proposed an extension to address this. The proposed extension have been implemented and used in production over years. We strongly believe this will help users of modules and potential users of modules significantly.