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

Free, Unlimited Access
Abstract art representing computer programming
Ryan McCombe
Ryan McCombe
Updated

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:

  • 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!";
}
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.

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

Creating a Module

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.

Troubleshooting - could not find module

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)

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

  // Compiler error - this is 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 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!

Exporting Module Macros

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.

Submodules

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

  • 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
  • Module partitions allow us to split 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

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

Module Interface Partitions

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 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};

Importing Partitions

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;
}

Exporting Partitions

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.

Module Internal Partitions

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

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.

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, 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;
}

Using Implementation Partitions

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.

Header Units

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.

The #include Directive in Modules

Inevitably, 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.

Summary

In this lesson, we explored C++20 modules, learning how they provide a modern alternative to traditional header files.

Main Points Learned

  • The drawbacks of the #include directives and the advantages of using C++20 modules.
  • How to create modules using the module keyword.
  • Syntax and usage of import and export statements to include and share module contents.
  • The distinction between module partitions and submodules, and how they can be used to organize large modules.
  • Understanding the concept of internal (implementation) partitions for private module code sharing.
  • Strategies for integrating modules with legacy code using header units.
  • Troubleshooting common issues with compiler support for C++20 modules.

Was this lesson useful?

Next Lesson

Characters, Unicode and Encoding

An introduction to C++ character types, the Unicode standard, character encoding, and C-style strings
Abstract art representing computer programming
Ryan McCombe
Ryan McCombe
Updated
A computer programmer
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
Objects, Classes and Modules
Next Lesson

Characters, Unicode and Encoding

An introduction to C++ character types, the Unicode standard, character encoding, and C-style strings
Abstract art representing computer programming
Contact|Privacy Policy|Terms of Use
Copyright © 2024 - All Rights Reserved