C++20 Modules

A detailed overview of C++20 modules - the modern alternative to #include directives. We cover import and export statements, partitions, submodules, how to combine modules with legacy code, and more.
This lesson is part of the course:

Professional C++

Comprehensive course covering advanced concepts, and how to use them on large-scale projects.

812.jpg
Ryan McCombe
Ryan McCombe
Posted

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 an excessively crude approach, effectively copying all the code from one source file and pasting it to another. As we covered previously, this has some issues:

  • A source file can be included multiple times into the same destination, prompting us to always implement header guards like #pragma once
  • An immense amount of code can be pasted into our files, causing them to take longer to compile than necessary. On larger projects, this forces us to do yet more manual workarounds to keep compilation times down, such as maintaining precompiled header files
  • The crudeness of the preprocessor can cause subtle bugs that are difficult to track down, such as our program behaving differently depending on the order of our #include directives

It 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.

Module import Statements

Previously, we imported iostream (and similar headers) using the #include directive as follows:

#include <iostream>

int main() {
  std::cout << "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!";
}

Note, import is a C++ statement rather than a preprocessor directive, so we end it with a semicolon ; like any other statement.

Troubleshooting: Could not find header unit for iostream

Modules are a relatively new addition to the language, added in C++20. As such, tooling support can be slightly 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 is resulting 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

Creating a Module

To create our own 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. So, the namespace, class and everything we defined in the previous example is private - it is not part of the module interface.

Module Exports

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() {
  std::cout << Pi;
  Math::SayHello();

  // Error - not exported
  SomeMathFunction();
}

Module Export Blocks

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(){};

Module Transitive Dependencies

With the #include directive, we saw that the inclusion was recursively. A single #include directive can paste in 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();
  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 magically become visible in the file we imported it to:

// Greetings.cppm
export module Greetings;
import <iostream>;

export void Greet() {
  std::cout << "\nHello!";
}
// main.cpp
import Greetings;

int main() {
  Greet();
  std::cout << "Hello!";
}

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 line 4 works. But, it is not visible, so line 5 is not valid.

For std::cout to be visible, the file where it is defined needs to be imported explicitly:

// main.cpp
import Greetings;
import <iostream>;

int main() {
  Greet();
  std::cout << "\nHello!";
}
Hello!
Hello!

Exporting Module Macros

Preprocessor macros are usable 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 file module file where they’re used:

// 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 importing macros, and also want to use modules, the recommended approach is to move the macros into dedicated files and #include them where they’re needed in the traditional way.

Module Partitions

When we want to break up a large module into smaller chunks, we have two options.

  • Module partitions allow us to break our module across multiple files. But, this is effectively invisible to consumers - from the perspective of any other file, they just import our module as normal
  • Submodules allow us to break larger modules into a hierarchy of any number of child modules. Consumers can choose to import the module as a whole, or just import specific submodules

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:

  1. The name of the module for which it is a partition of
  2. A semicolon, :
  3. A name for the partition.

For example, if our Math module had 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

It would look like this:

// 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 main module interface file for the Math module, which exports the module, as well as imports and re-exports all of the partitions. The syntax looks like this - note for our partitions we omit the part of the name that comes before the semicolon:

// Math.cppm
export module Math;

export import :Algebra;
export import :Geometry;

The partitions are not accessible to consumers. They simply use our module as normal, as if it was not partitioned:

// main.cpp
import Math;
import <iostream>;

int main() {
  std::cout << "Result: " << MilesToKM(100);
}
Result: 161

Submodules

The notion of a submodule is not an official part of the C++ language, but it is something that is 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.

Whilst partitions must use a strict naming pattern, containing the parent module and a semicolon, submodules can have any name. The naming convention of ParentModule.ChildModule is just a common convention.

Unlike partitions, consumers can see this hierarchy of a submodule hierarchy, and interact with it. For example, they can choose to import just Math.Geometry rather than the whole Math module.

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. Note, with partitions, we omitted the part of the name before the : but with the submodule pattern, we must include the full module name in each of our export import statements:

// Math.cppm
export module Math;

export import Math.Algebra;
export import Math.Geometry;

Now, within any other file, our consumers can choose to import just a submodule, or import the parent and get everything:

// main.cpp
import Math.Algebra;
import <iostream>;

int main() {
  std::cout << "Result: " << MilesToKM(100);
}
Result: 161

We're not restricted to one level of hierarchy - we can nest modules as deep as we want. For example, we can have a Math.Geometry.Circles module.

Splitting Interface and Implementation

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, that is no longer a factor. However, many will still want to implement this separation for code organization reasons.

Below, we show two approaches to this. First, we move the interface to the top of the file, with implementations at the bottom:

// Character.cppm
export module Character;
import <string>;
import <iostream>;

// Definition
export class Character {
 public:
  Character(std::string Name);
  void SayHello();
  std::string GetName();

 private:
  std::string Name;
};

// Implementation
Character::Character(std::string Name)
    : Name{Name} {}

void Character::SayHello() {
  std::cout << "Hello there!  The name is "
            << Name;
}

std::string Character::GetName() {
  return Name;
}

We can also split our module into an interface file, and one (or more) implementation files.

In our implementation file(s), we use the module declaration at the top, without the export statement. For example:

module Character;

// Character implementation here

This has the effect of importing the Character module and its dependencies from our interface file, and also declaring that the subsequent code is providing implementations for that module. We then provide implementions in the normal way.

Below is an example of splitting our previous Character module into an interface and implementation file:

// Character.cppm
export module Character;
import <string>;
import <iostream>;

// Definition
export class Character {
 public:
  Character(std::string Name);
  void SayHello();
  std::string GetName();

 private:
  std::string Name;
};
// Character.cpp
module Character;

Character::Character(std::string Name)
    : Name{Name} {}

void Character::SayHello() {
  std::cout << "Hello there!  The name is "
            << Name;
}

std::string Character::GetName() {
  return Name;
}

Internal Partitions / Implementation Partitions

When we split a complex module across many partitions, we’ll often come across scenarios where we want some segments of code to be shared across multiple files.

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 exported as part of the public interface.

A minimal internal partition for a Math module would look something like this:

module Math:Helpers;

constexpr float Pi{3.14};

Compiler Support for Internal Module Partitions

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.

Once we’ve set up our internal partition, we can import and use it in any of our other module files:

export module Math;
import :Helpers;

export class Circle {
 public:
  float Circumference(float Radius) {
    return Radius * 2 * Pi;
  }
};

Header Units: Using Traditional Header Files as Modules

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 proper 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:

// Character.h
#pragma once
#include <iostream>

class Character {
 public:
  void SayHello() { std::cout << "Hello!"; };
};
// main.cpp
import "Character.h";

int main() {
  Character 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.

Using the #include directive within modules

Inevitably, we’ll be tempted to place an #include directive within the purview of our module:

// Character.cppm
export module Character;
#include <SomeLibrary>

// ...

This may not work, but even if it does, it’s not something we should do. Given #include is simply a crude copy-and-paste, dropping 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 Character module. That’s almost never what we intend so, in most compilers, we should at least receive a warning:

'#include <SomeLibrary>' in the purview
of module 'Character' 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 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 Character;  // Named module begins
import <iostream>;

export class Character {
 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 the global module fragment.

Was this lesson useful?

Ryan McCombe
Ryan McCombe
Posted
This lesson is part of the course:

Professional C++

Comprehensive course covering advanced concepts, and how to use them on large-scale projects.

Objects, Classes and Modules
7a.jpg
This lesson is part of the course:

Professional C++

Comprehensive course covering advanced concepts, and how to use them on large-scale projects.

Free, unlimited access!

This course includes:

  • 106 Lessons
  • 550+ Code Samples
  • 96% Positive Reviews
  • Regularly Updated
  • Help and FAQ
Next Lesson

Characters, Unicode and Encoding

An introduction to C++ character types, the Unicode standard, character encoding, and C-style strings
DreamShaper_v7_scientist_Sidecut_hair_black_clothing_fully_clo_2.jpg
Contact|Privacy Policy|Terms of Use
Copyright © 2023 - All Rights Reserved