I have several game-related classes like StateManager
, Window
, EventManager
, and so on.
These classes are in separate modules. Module Events
contains the EventManager
class, module States
contains the StateManager
class and related classes, the Window
class is in a separate Window
module, and so on.
There are always cases where one of these systems needs access to some functionality or state of one of the other systems (for example, StateManager
might inform EventManager
, that the current StateType
changed). To allow this access and exchange between all these different systems, in "pre-modules" times I used a lightweight SharedContext
object that just held some pointers to all relevant classes:
#pragma once
class Window;
class EventManager;
class TextureManager;
class GUI_Manager;
struct SharedContext{
Window* m_window = nullptr;
EventManager* m_eventManager = nullptr;
TextureManager* m_textureManager = nullptr;
GUI_Manager* m_gui_manager = nullptr;
};
This SharedContext
object gets created once, loads all references to the pre-existing objects into its member variables, and then gets passed around, so all objects receiving SharedContext
can access the attached classes such as Window
, EventManager
, TextureManager
, GUI_Manager
, etc.
This approach is not compatible with modules at all. The main problem is that I am not allowed to declare a class name once it has been declared in a module. So the forward-declarations here are ill-formed and the code will not compile, since all forward-declared classes already exist in modules.
SharedContext
does not belong logically to any module, but making it a module (so I could export the forward-declared classes) does not work, either.
The following minimal example complains with an error using GCC 14.2:
"error: reference to 'EventManager' is ambiguous"; note: candidates are: 'class EventManager@SharedContext' ... note: 'class EventManager@Events'
SharedContext.cppm
export module SharedContext;
export class EventManager;
export struct SharedContext{
EventManager* m_eventManager = nullptr;
};
Events.cppm
export module Events;
export class EventManager{
};
main.cpp
import Events;
import SharedContext;
int main() {
EventManager manager;
SharedContext context;
context.m_eventManager = &manager;
return 0;
}
Importing all modules instead of forward-declaring the needed classes into the SharedContext
file could introduce circular dependencies, because when I import module States
to be able to create a pointer to a StateManager
object, a lot of other classes get imported too, which are part of the States
module. So, when only one of these classes needs SharedContext
, I create a circular dependency (SharedContext
imports the module States
, which in one of its module partitions may import SharedContext
).
So, am I out of luck here, and this is just something that doesn't work when using modules?
With C++20 modules, there's a few ways to break circular dependencies, or mitigate the problems that comes with them.
C++ modules are not equivalent to headers. Contrary to headers, module names and what they export is not part of the API you expose to other parts of your program. Generally, modules are much bigger than headers, and importing bigger but fewer modules is faster than importing many small modules.
The first solution is to all tightly coupled types in the same module. Think about it, is there a way you can use only SharedContext
and not States
? Is there any use case from importing one and not the other? If the answer is no, then that part of your program should export only one module.
What advantages does it gives us? You can now use partitions, which share ownership of the exported symbols. This consequently allow forward declarations.
export module SharedState:SharedContext;
struct Window;
struct EventManager;
struct TextureManager;
struct GUI_Manager;
struct SharedContext{
Window* m_window = nullptr;
EventManager* m_eventManager = nullptr;
TextureManager* m_textureManager = nullptr;
GUI_Manager* m_gui_manager = nullptr;
};
Then in other partitions:
export module SharedState:Window;
export struct Window {
// ...
};
export module SharedState:TextureManager;
export struct TextureManager {
// ...
};
Then finally, in the primary interface unit:
export module SharedState;
export import :SharedContext;
export import :Window;
export import :TextureManager;
// ...
A better solution might to just design the code in a way that don't require a bag of all types that need the lifetime, while also making those types require that bag (bag being SharedState).
If no types declared in shared state uses shared state, then you have no circular dependencies.
How would you do this? Simply use dependency injection, or passing the specific state as parameter to your functions. That way, you won't need a type like SharedState
, since only the types that you need are passed by parameter. This is a superior solution and makes the code easier to read.
Just pass what you need as parameter!! Too combersome? Keep a reference of what you need in a class, and pass it in constructors. That's it.
I wouldn't recommend this, but using the global module does allow for forward declarations.
The problem with this approach is that all types used in SharedContext
have to be aware that they need to export global module symbols, and allows for ODR violations. Basically, you open the unsafe escape hatch.
extern "C++" {
class Window;
class EventManager;
class TextureManager;
class GUI_Manager;
}
struct SharedContext {
Window* m_window = nullptr;
EventManager* m_eventManager = nullptr;
TextureManager* m_textureManager = nullptr;
GUI_Manager* m_gui_manager = nullptr;
};
export module Window;
extern "C++" {
export class Window { /* ... */ };
}
And declare everything in the global module using the extern "C++"
escape hatch.
Doing this you will loose most of the benefits of using module. You no longer have proper componentization, and your code is no longer ODR safe. You might still get slightly better compile time, but you loose most of the benefits that modules bring.