#include
directives. We cover import
and export
statements, partitions, submodules, how to integrate modules with legacy code, and more.For a long time, the #include
directive has been the primary way we import functionality from C++ libraries and organize more significant projects. However, it is a crude approach, effectively copying all the code from one source file and pasting it to another. As we covered previously, this has some issues:
#pragma once
#include
directivesIt is an ongoing mission of the C++ community to reduce the dependency on the preprocessor. In the C++20 spec, an alternative to the #include
directive was introduced. They are called modules.
import
StatementsPreviously, we imported iostream
(and similar headers) using the #include
directive as follows:
#include <iostream>
int main() {
std::cout << "Hello World!";
}
Hello World!
To use the equivalent module, we update our code to use the import
statement, as below:
import <iostream>;
int main() {
std::cout << "Hello World!";
}
Hello World!
Note that import
is a C++ statement rather than a preprocessor directive, so we end it with a semicolon ;
like any other statement.
iostream
Modules are a relatively new addition to the language, added in C++20. As such, tooling support can be limited.
Typically, compilers can take a few years to implement the latest features for a language, and a few more years to enable those features by default.
If attempting to use modules results in compiler errors, you may need to update or tweak the settings of the compiler you’re using.
If you’re using Visual Studio, the following settings are things you may need to change. They are available by navigating to Project > Properties from the top menu bar:
C/C++ > Language > C++ Language Standard: C++20 (or later)
C/C++ > Language > Enable Experimental C++ Standard Library Modules: Yes
To create a module, we need to create a module interface file. This is just a typical file that will contain C++ code, similar to the .cpp
or .h
files we’ve been creating. There is no agreed convention on naming yet, but using the extension .cppm
for a module interface file is becoming common.
On the first line of our module interface file, we use two new keywords, export
, and module
, followed by a name we want to give our module. The naming restrictions for modules broadly follow the same rules as naming variables.
Here, we define a module called Math
:
// Math.cppm
export module Math;
Within this file, we can write C++ code as normal:
// Math.cppm
export module Math;
import <iostream>;
namespace Math {
void SayHello() {
std::cout << "Hello!";
};
};
constexpr float Pi{3.14};
void SomeMathFunction(){};
class SomeClass {};
using Alias = SomeClass;
Unlike the traditional #include
directive, modules support encapsulation natively. We can decide which parts of our module are private, and which parts are public.
By default, everything is private. Therefore, the namespace, class, and everything we defined in the previous example is private - it is not part of the module interface.
Our compiler may not automatically recognize that a file is defining a module and may need help to build our program successfully. How we do this depends on the build tools we’re using.
Within Visual Studio, we can tell the compiler what a file is creating a module by right-clicking it in the Solution Explorer, navigating to Properties > C/C++ > Advanced > Compile As, and selecting C++ Module Code (/interface)
Our module can be selective with what it wants to make public. We do this by adding the export
keyword before anything we want to be part of our public interface.
Pretty much anything with an identifier can be exported - variables, functions, using
statements, classes, structs, namespaces, and more.
Below, we export our Pi
variable and our Math
namespace. When a namespace is exported, all of its members are automatically exported too:
// Math.cppm
export module Math;
import <iostream>;
// Public
export namespace Math {
void SayHello() {
std::cout << "Hello!";
};
};
export constexpr float Pi{3.14};
// Private
void SomeMathFunction(){};
class SomeClass {};
using Alias = SomeClass;
In one of our other files, we can now import
our module and use its exported features:
// main.cpp
import <iostream>;
import Math;
int main() {
// Using exported symbols
std::cout << Pi;
Math::SayHello();
// Compiler error - this is not exported
SomeMathFunction();
}
We can export multiple identifiers at once from our module using an export
block:
export module Math;
import <iostream>;
export {
namespace Math {
void SayHello() {
std::cout << "Hello!";
};
};
constexpr float Pi{3.14};
}
void SomeMathFunction(){};
class SomeClass {};
using Alias = SomeClass;
export void Blah(){};
With the #include
directive, we saw that the inclusion was recursive. A single #include
directive can include a file that itself has yet more #include
directives, and that continues any number of levels deep.
Below, our main.cpp
is using <iostream>
functions even though it is not including them. It is gaining indirect access to them by including a file that happens to include <iostream>
:
// Greetings.h
#pragma once
#include <iostream>
void Greet() {
std::cout << "Hello!";
}
// main.cpp
#include "Greetings.h";
int main() {
Greet();
// This works, even though we're not
// including <iostream> in this file
std::cout << "\nHello!";
}
Hello!
Hello!
Our code having hidden dependencies like this is not a good thing. In complex projects, changes in unrelated files causing other files to stop compiling is a headache.
C++ modules do away with this. When we import
a module, its sub-dependencies don’t become visible in the file we imported it into:
// Greetings.cppm
export module Greetings;
import <iostream>;
export void Greet() {
std::cout << "\nHello!";
}
// main.cpp
import Greetings;
int main() {
Greet();
// Compilation error
std::cout << "Hello!";
}
error C2039: 'cout': is not a member of 'std'
In this example, we have not imported <iostream>
into main.cpp
, so the use of std::cout
is problematic.
The phrases commonly used to describe this distinction are reachable vs visible. Within main.cpp
in this example, std::cout
is reachable, so the call to Greet()
works. But, it is not visible, so we cannot access it directly.
For std::cout
to be visible, the file where it is declared needs to be imported explicitly:
// main.cpp
import Greetings;
import <iostream>;
int main() {
Greet();
std::cout << "\nHello!";
}
Hello!
Hello!
Preprocessor macros can be used within modules, but cannot normally be exported from a module. There is an exception, which we’ll cover later in this lesson, but under normal circumstances, #define
directives are restricted to just the module file where they’re created:
// Greetings.cppm
export module Greetings;
import <iostream>;
#define SayHello
export void Greet() {
#ifdef SayHello
std::cout << "Hello from the Module!";
#endif
}
// main.cpp
import Greetings;
import <iostream>;
int main() {
Greet();
#ifdef SayHello
std::cout << "Hello from main!";
#endif
}
Hello from the Module!
For projects that require preprocessor macros, and also want to use modules, the recommended approach is to move the macros into dedicated files and use the preprocessor #include
directive to access them in the traditional way.
When we want to break up a large module into smaller chunks, we have two options.
The notion of a submodule is not an official part of the language, but it is widely adopted by the community. Module names can include .
characters, and we use this to organize multiple modules into a hierarchy. For example, we can have a module called Math
, with "child" modules called Math.Geometry
, and Math.Algebra
.
To set this up, we create submodules in the normal way, including a .
in their name:
// MathAlgebra.cppm
export module Math.Algebra;
export float MilesToKM(float Miles) {
return Miles * 1.61;
}
// MathGeometry.cppm
export module Math.Geometry;
export constexpr float Pi{3.14};
We then create the parent module, which "rolls up" all the submodules, by both importing and re-exporting them:
// Math.cppm
export module Math;
export import Math.Algebra;
export import Math.Geometry;
Unlike partitions, consumers can see this submodule hierarchy, and interact with it. For example, they can choose to import just Math.Geometry
:
// main.cpp
import Math.Algebra;
import <iostream>;
int main() {
// MilesToKM is from Math.Algebra
std::cout << "Result: " << MilesToKM(100);
// Pi is from Math.Geometry, which we haven't
// imported. So, this would be an error:
// std::cout << "Pi: " << Pi;
}
Result: 161
Or, they could import Math
in its entirety, which will also import every submodule we "rolled up" with export import
statements in Math.cppm
:
// main.cpp
import Math;
import <iostream>;
int main() {
// MilesToKM is from Math.Algebra
std::cout << "Result: " << MilesToKM(100);
// Pi is from Math.Geometry
std::cout << "\nPi: " << Pi;
}
Result: 161
Pi: 3.14
We're not restricted to just one level of hierarchy - we can nest modules as deep as we want. That is, our submodules can have submodules:
// MathGeometry.cppm
export module Math.Geometry;
export import Math.Geometry.Circles;
export constexpr float Pi{3.14};
// MathGeometryCircles.cppm
export module Math.Geometry.Circles;
// ...circle things
A module partition file is implemented in the same way as a regular module. The only difference is that a partition has a semicolon in the name.
A partition name has three parts:
:
For example, if our Math
module has a partition called Geometry
, we would call the partition Math:Geometry
.
In the following example, we create a Math
module with two partitions: Math:Geometry
and Math:Algebra
:
// MathAlgebra.cppm
export module Math:Algebra;
export float MilesToKM(float Miles) {
return Miles * 1.61;
}
// MathGeometry.cppm
export module Math:Geometry;
export constexpr float Pi{3.14};
Within our module, we can import
the partitions as needed. Note, for our partitions we omit the part of the name that comes before the semicolon:
// MathAlgebra.cppm
export module Math:Algebra;
import :Geometry;
export float SomeFunction() {
// Pi is defined in the Geometry partition
return Pi;
}
export float MilesToKM(float Miles) {
return Miles * 1.61;
}
To make our partition content available externally, we additionally need to package it alongside the parent module.
We do that by importing and then exporting it within the purview of our main module - Math
, in this case:
// Math.cppm
export module Math;
export import :Algebra;
export import :Geometry;
Unlike with submodules, partitions are not visible to consumers. They simply use our module as normal, as if it was not partitioned:
// main.cpp
import Math;
import <iostream>;
int main() {
// From Math:Algebra
std::cout << "Result: " << MilesToKM(100);
// From Math:Geometry
std::cout << "\nPi: " << Pi;
}
Result: 161
Pi: 3.14
The MathGeometry.cppm
and MathAlgebra.cppm
files from this section are examples of interface partitions. This is because they contribute to the public interface of our Math
module - that is, they export
things.
It’s also possible to have partitions that do not export anything - these are sometimes called Internal Partitions or Implementation Partitions which we cover in the next section.
When we split a complex module across many partitions, we’ll often come across scenarios where we want to share code among our module partitions, without being part of the public interface.
An internal partition (sometimes called an implementation partition) can help us here. This is a partition that is a repository for shared code to be used across our module, but not available outside of our module.
A minimal internal partition for a Math:Constants
module would look something like this:
// MathConstants.cppm
module Math:Constants;
float Pi{3.14};
Once we’ve created our internal partition, we can import it and use it within any partition of the parent module:
// MathGeometry.cppm
export module Math:Geometry;
import :Constants;
export float Circumference(float Radius) {
return Pi * Radius * Radius;
}
// Math.cppm
export module Math;
export import :Geometry;
import :Constants;
export float GetPi() {
return Pi;
}
We cannot attempt to export
an internal partition, and we cannot access its functionality from outside the module:
// main.cpp
import Math;
#include <iostream>
int main() {
// Using Circumference() From Math:Geometry
std::cout << Circumference(3);
// Using GetPi() From Math
std::cout << GetPi();
// Using Pi from Math:Constants - error - an
// internal partition is not accessible here
std::cout << Pi;
}
error C2065: 'Pi': undeclared identifier
Internal Partitions are another area where compilers do not yet have a shared approach. If the above code does not compile, we need to change some settings. However, the way to do that depends on the compiler being used, so will require some investigation.
If this file does not compile in Visual Studio, we can select it in the Solution Explorer and press Alt + Enter to bring up the properties menu, or right-click it and select Properties from the dropdown.
Under Configuration Properties > C/C++ > Advanced > Compile As, selecting the "Compile as C++ Module Internal Partition" option should resolve any issues.
When working with large projects and the #include
directive, it became important to separate code into header files and implementation files to keep compile times down.
With modules, compile times are a less important consideration. However, many will still want to maintain this separation for code organization reasons.
Below, we show two approaches to this. First, we move the public interface to the top of the file, with implementations at the bottom:
// Player.cppm
export module Player;
import <string>;
import <iostream>;
// Public interface
export class Player {
public:
Player(std::string Name);
void SayHello();
std::string GetName();
private:
std::string Name;
};
// Implementation
Player::Player(std::string Name)
: Name{Name} {}
void Player::SayHello() {
std::cout << "Hello there! The name is "
<< Name;
}
std::string Player::GetName() {
return Name;
}
We can also split our module into an interface file, and one (or more) implementation partitions.
In our implementation partition(s), we use the module declaration at the top, without the export
statement. For example:
module Player;
// Player implementation here
This has the effect of declaring that the subsequent code is providing implementations for that module. We then provide those implementations in the normal way.
Below is an example of splitting our previous Player
module into an interface and implementation file:
// Player.cppm
export module Player;
import <string>;
// Definition
export class Player {
public:
Player(std::string Name);
void SayHello();
std::string GetName();
private:
std::string Name;
};
// Player.cpp
module Player;
import <iostream>;
Player::Player(std::string Name) : Name{Name} {}
void Player::SayHello() {
std::cout << "Hello there! I am " << Name;
}
std::string Player::GetName() {
return Name;
}
Because Player.cpp
is an internal partition, we may need to identify it as such within our build tools. We covered how to do that in the Compiler Support for Internal Module Partitions section above.
It’s still very early in the lifespan of modules. If we want to use them now, we’re going to be mixing them with third-party libraries and other code that is still using traditional header files.
Assuming we can’t convert those files to modules, there are two workarounds. The first is we just use the import
statement, and provide a path to the header file within quotes. This creates what is known as a Header Unit:
// Player.h
#pragma once
#include <iostream>
class Player {
public:
void SayHello() { std::cout << "Hello!"; };
};
// main.cpp
import "Player.h";
int main() {
Player Greeter;
Greeter.SayHello();
}
Hello!
Header units are a stop-gap measure implemented by compilers. In essence, they convert a header file to a module, and everything in that header file (including preprocessor macros) is exported. The exact implementation here varies from compiler to compiler, and it doesn’t always work.
The other option we have for mixing traditional code with modules is to just #include
the older files within our module files. However, we need to be careful there, as we explain in the next section.
#include
Directive in ModulesInevitably, we’ll be tempted to place an #include
directive within the purview of our module:
// Player.cppm
export module Player;
#include <SomeLibrary>
// ...
This may not work, but even if it does, it’s not something we should do. Given that the #include
directive causes a crude copy-and-paste operation, inserting one below our module
declaration is going to cause some issues.
When this code gets preprocessed and sent for compilation, the compiler is just going to see the entire contents of whatever we included as part of our Player
module. That’s rarely what we intend so, in most compilers, we should at least receive a warning:
'#include <SomeLibrary>' in the purview of module 'Player' appears erroneous
But, if we still need to #include
the library, what do we do?
In the world of C++ modules, all code must be attached to a module. Anything that is not explicitly attached to a module (such as the main
function) is part of the global module.
This global module is also where we want the output of our #include
directives to go. When we need to #include
a file within a module, we can specify it as being part of that global module, using a global module fragment. It looks like this:
module; // Global module fragment
#include <SomeLibrary> // Legacy include
export module Player; // Named module begins
import <iostream>;
export class Player {
public:
void SayHello() { std::cout << "Hello!"; };
};
The only use case for global module fragments is to solve these preprocessor-related problems. As such, we can't put any other type of code there - only preprocessor directives are allowed within a global module fragment.
In this lesson, we explored C++20 modules, learning how they provide a modern alternative to traditional header files.
#include
directives and the advantages of using C++20 modules.module
keyword.import
and export
statements to include and share module contents.A detailed overview of C++20 modules - the modern alternative to #include
directives. We cover import
and export
statements, partitions, submodules, how to integrate modules with legacy code, and more.
Comprehensive course covering advanced concepts, and how to use them on large-scale projects.