Today’s post is about C++20 modules and my own journey of converting an existing codebase into modules.
After years of waiting, I can finally say the attempt was successful. Back in 2022, trying to use modules in Visual Studio always ended in ICEs. But after three years, the MSVC team has fixed most of the serious issues. During my latest conversion effort, I hit only one ICE, and it was a harmless one (although, please fix it).
So, what are modules? I won’t explain the basics here – there are already great introductions here, here, and here. Instead, I’ll share the main problems I ran into while converting my own project into a full module-based solution.
Jumping ahead, I’ll say that in some cases I couldn’t connect the modules without modifying the third-party code. I should also mention right away that I did this for my own pet project, so while this post does share my experience converting to modules, it’s mainly meant to be entertaining.
I also ran an experiment – first I manually converted all the code into modules, and then I did the same thing using Cursor in Visual Studio. More on that in the Part 2 of the article. The fastest way I found to do the conversion was using Cursor. With other tools, I got much worse results. If you’ve had different experiences, let me know – I’d be curious to compare.
The Starting Point
My project is a 3D mesh editor depending on several libraries: boost, glm, Eigen, OpenSceneGraph, and ImGui.
It’s a mix of headers, .cpp files, and both static and dynamic libraries. There are 30 projects in the solution and around 100k lines of code.
The goal was to turn everything into modules – that is, into .ixx files. Here, unfortunately, I don’t address the topic of importing modules into headers. I tried it before, and it never worked (a plethora of ICEs) with Visual Studio compiler, but now it might be better, I guess.
Step 1: GLM
I started with GLM. To my surprise, GLM already has experimental module support (glm.ixx). It turned out to be the only library that provided such support out of the box 😦
I didn’t run into major issues, except one strange case: inline namespaces didn’t seem to work properly in MSVC. For example:
export namespace glm {
#ifdef GLM_GTC_INLINE_NAMESPACE
inline
#endif
namespace gtc {
using glm::translate;
}
}
I ended up manually re-exporting the missing symbols:
export namespace glm {
using glm::translate;
}
If anyone knows why MSVC fails to handle inline namespaces here, please share in comments.
So, as you can see, my first step was pretty brutal – I had to change code in the library. That’s not ideal, but I wanted to move fast.
Step 2: Math module
Next, I took my frequently used math module, which contains a bunch of primitives like Segment, Line, Plane and operations on them. I converted each .h/.cpp file into .ixx with the help of AI. My prompts were roughly like this: “Take each class from the Math namespace and convert it into a C++20 module, ensuring that each module contains the full implementation of the class. Place every module in a separate .ixx file. If there are any #include <std...> directives, replace them with import std;” Also, I’ve added prompts that improves my code to the latest C++20 and C++23 standards and checking for rule of 5, etc. So basically while converting everything into modules I did also some kind of improvements on my code.
For example, a Segment class written as a traditional header/implementation pair turned into one beautiful and clean implementation of the class:
export module Segment;
import glm;
export namespace Math {
class Segment {
public:
Segment() = default;
Segment(const glm::dvec3& A, const glm::dvec3& B)
: A(A)
, B(B)
{}
void setSource(const glm::dvec3& newSource) {
A = newSource;
}
void setTarget(const glm::dvec3& newTarget) {
B = newTarget;
}
[[nodiscard]] const glm::dvec3& source() const {
return A;
}
[[nodiscard]] const glm::dvec3& target() const {
return B;
}
[[nodiscard]] double length() const {
return glm::length(B - A);
}
[[nodiscard]] glm::dvec3 at(double param) const {
return source() * (1.0 - param) + target() * param;
}
// other methods...
private:
glm::dvec3 A{ 0.0, 0.0, 0.0 };
glm::dvec3 B{ 0.0, 0.0, 0.0 };
};
}
This is part of example of the module I use for intersections ended up looking like.
Notice the use of export and how the required modules are imported:
export module Intersect;
import glm;
import Box2;
import Segment2;
import <cassert>;
import <cmath>;
namespace Math {
[[nodiscard]] bool refineClipping(double p,
double q,
double eps,
double& t0,
double& t1)
{
assert(eps >= 0.0 && "eps must not be negative");
// Segment direction nearly parallel to boundary
if (std::abs(p) <= eps) {
return q >= 0.0;
}
const double t = q / p;
if (p < 0.0) {
if (t > t1) return false;
if (t > t0) t0 = t;
} else {
if (t < t0) return false;
if (t < t1) t1 = t;
}
return true;
}
// Main exported intersection function
export [[nodiscard]] bool checkIntersection(const Box2& box,
const Segment2& seg,
double tolerance,
double eps)
{
assert(tolerance >= 0.0 && "tolerance must be non-negative");
assert(eps >= 0.0 && "eps must be non-negative");
const auto A = seg.source();
const auto B = seg.target();
const double minX = box.min().x - tolerance;
const double maxX = box.max().x + tolerance;
const double minY = box.min().y - tolerance;
const double maxY = box.max().y + tolerance;
assert(minX <= maxX && minY <= maxY && "Invalid box extents");
if ((A.x < minX && B.x < minX) ||
(A.x > maxX && B.x > maxX) ||
(A.y < minY && B.y < minY) ||
(A.y > maxY && B.y > maxY))
{
return false;
}
double tStart = 0.0;
double tEnd = 1.0;
const double dx = B.x - A.x;
const double dy = B.y - A.y;
const bool ok =
refineClipping(-dx, A.x - minX, eps, tStart, tEnd) &&
refineClipping( dx, maxX - A.x, eps, tStart, tEnd) &&
refineClipping(-dy, A.y - minY, eps, tStart, tEnd) &&
refineClipping( dy, maxY - A.y, eps, tStart, tEnd) &&
tStart <= tEnd;
assert(!ok ||
(tStart >= 0.0 && tStart <= 1.0 &&
tEnd >= 0.0 && tEnd <= 1.0) &&
"Clipping interval exceeds [0, 1]");
return ok;
}
}
Note, that refineClipping is not exported. This was made intentionally. This function is private (internal) to the entire world and world is just interested in checkIntersection function which is exported. Also note that you can’t put static keyword to the module function. It simply doesn’t make any sense! And it’s good. Finally static is what it meant to be.
Note, import <cassert> is not part of import std; which is quite understandable since assert is macro and can’t be exported;
The final structure of my “Math” module set looks like this:
Math.ixx // aggregator (re-exports everything)
Segment.ixx
Segment2.ixx
Line.ixx
Plane.ixx
Ray.ixx
Intersect.ixx
...
The aggregator makes it easy for clients to import everything with a single line:
// Math.ixx
export module Math;
export import Segment;
export import Line;
export import Plane;
export import Intersection;
export import glm;
// ... other modules
Step 3
Next, I had three small header-only libraries: sigslot, uuid, and picojson.
They’re the easiest to convert to modules: move each into a single .ixx, explicitly export only the public API, and keep everything that normally lives under namespace detail non-exported.
export module uuid;
import std;
import <cstdint>;
import <cwchar>;
namespace uuids
{
namespace detail
{
template <typename TChar>
constexpr unsigned char hex2char(TChar const ch)
{
if (ch >= static_cast<TChar>('0') && ch <= static_cast<TChar>('9'))
return ch - static_cast<TChar>('0');
if (ch >= static_cast<TChar>('a') && ch <= static_cast<TChar>('f'))
return 10 + ch - static_cast<TChar>('a');
if (ch >= static_cast<TChar>('A') && ch <= static_cast<TChar>('F'))
return 10 + ch - static_cast<TChar>('A');
return 0;
}
///...
}
export enum class uuid_version
{
none = 0,
time_based = 1,
dce_security = 2,
name_based_md5 = 3,
random_number_based = 4,
name_based_sha1 = 5
};
export class uuid
{
public:
using value_type = uint8_t;
//...
};
export constexpr bool operator==(uuid const& lhs, uuid const& rhs) noexcept
{
return std::equal(std::begin(lhs.data), std::end(lhs.data), std::begin(rhs.data));
}
// ..
};
I didn’t try to include headers in a module or wrap them – I just mechanically ported the sources to modules. Yeah, I know it’s not the most practical approach, but I wanted the whole project fully moved to modules, with no headers getting in the way.
Step 4. OpenMesh: the stumbling block
This is where I “broke my leg” at the very beginning: OpenMesh.
Unlike small header-only libs (like uuid, picojson, sigslot), OpenMesh is a large, macro-heavy library with deep template machinery and a lot of #include-chained internals.
When you try to simply wrap it in a C++20 module, you run into several issues:
- Macros everywhere
OpenMesh uses macros for configuration (OM_STATIC_BUILD,OM_TEMPLATED, etc.). Macros don’t export well through modules – they exist only at compile-preprocessing time. - Template-heavy design
It relies on CRTP andTriMesh_ArrayKernelTtemplates with deeply included traits. You can’t justexportthese symbols without bringing in their full include-tree. - Intertwined includes
Many headers include each other in a way that doesn’t match module boundaries. If you try toexport import <OpenMesh/...>, the compiler complains about duplicated or redefined entities.
The pragmatic workaround
So I had to compromise: instead of forcing OpenMesh into a module, I created a header and #include it wherever OpenMesh is needed inside my modules.
Example – the OpenMeshSubcomponents module:
module;
#include "OpenMeshDefinitions.h"
export module OpenMeshSubcomponents;
import std;
export std::set<OpenMesh::FaceHandle> getConnectedFaces(const TriMeshKernel& heMesh, OpenMesh::FaceHandle startFace) {
std::set<OpenMesh::FaceHandle> faces{ startFace };
std::deque<OpenMesh::FaceHandle> queue{ startFace };
while (!queue.empty()) {
auto fh = queue.front();
queue.pop_front();
for (auto f_it = heMesh.cff_ccwbegin(fh); f_it != heMesh.cff_ccwend(fh); f_it++) {
if (faces.find(*f_it) != faces.end())
continue;
faces.insert(*f_it);
queue.push_back(*f_it);
}
}
return faces;
}
// ... other methods
And the OpenMeshDefinitions.h looks like this one:
#pragma once
#include <OpenMesh/Core/IO/MeshIO.hh>
#include <OpenMesh/Core/Mesh/TriMesh_ArrayKernelT.hh>
#include <OpenMesh/Core/Mesh/TriConnectivity.hh>
#include <OpenMesh/Tools/Utils/MeshCheckerT.hh>
using TriMeshKernel = OpenMesh::TriMesh_ArrayKernelT<OpenMesh::DefaultTraitsDouble>;
Step 5. OSGEngine
The OSGEngine module is a wrapper around OpenSceneGraph. It compiles into DLL. Because it’s a DLL, the module interface files (.ifc/BMI) must be explicitly placed in BMIDebug/BMIRelease folders via Module Output File Name, and the consuming project (in my MeshEditor it’s called App, which produces exe) must specify these folders in Additional BMI Directories so the compiler can find the BMI when it encounters import OSGEngine;
This is different from static libraries: when a project has a ProjectReference to a static library that exports a module, MSBuild automatically resolves the BMI location without requiring Additional BMI Directories to be configured.
OSGEngine.ixx
module;
#include <osg/AutoTransform>
#include <osg/Geode>
#include <osg/Geometry>
#include <osgText/Text>
//...
export module OSGEngine;
import OSGRenderSystem;
export CORE_API std::unique_ptr createRenderSystem() {
return std::make_unique<OSGRenderSystem>();
}
CORE_API is the old trick:
#ifdef _WIN32
# ifdef CORE_EXPORTS
# define CORE_API __declspec(dllexport)
# else
# define CORE_API __declspec(dllimport)
# endif
#elif
# define CORE_API
#endif
For OSGEngine.ixx, Configuration Properties -> C/C++:
Module Output File Name = $(SolutionDir)BMIRelease\OSGEngine.ifc
And in App.vcxproj, Configuration Properties -> C/C++ -> General:
Additional BMI Directories = $(SolutionDir)BMIRelease
Additional Module Dependencies = %(AdditionalModuleDependencies)
Summary
Overall, for my not-so-large project, the migration to modules went relatively smoothly, and with the help of AI it was done quite quickly (plus, along the way, I was also trying to improve the code). Part 2 will compare manual vs. AI-powered conversion, and of course compile time.
Leave a comment