모듈
모듈 (Modules)
헤더 파일을 #include로 끌어오다 보면 매크로 충돌이나 컴파일 시간 증가 같은 문제가 생기곤 해요. C++20의 모듈(modules)은 번역 유닛 간에 선언과 정의를 공유하는 새로운 방법으로, 헤더의 여러 사용처를 대체해요.
출처: cppreference
본문
대부분의 C++ 프로젝트는 여러 번역 유닛(translation unit)을 사용하기 때문에, 그 유닛들 사이에 선언과 정의를 공유해야 해요. 이 목적에 헤더가 두드러지게 사용되고, 예를 들어 표준 라이브러리는 해당 헤더를 포함함으로써 그 선언들을 제공할 수 있어요.
모듈은 번역 유닛 간에 선언과 정의를 공유하기 위한 언어 기능이에요. 헤더의 일부 사용처에 대한 대안이죠.
모듈은 네임스페이스와 직교(orthogonal)해요.
가장 간단한 모듈 예시부터 볼게요.
// helloworld.cpp
export module helloworld; // 모듈 선언
import <iostream>; // import 선언
export void hello() // export 선언
{
std::cout << "Hello world!\n";
}
// main.cpp
import helloworld; // import 선언
int main()
{
hello();
}
helloworld 모듈이 hello()를 내보내고(export), main.cpp가 그 모듈을 import해서 hello()를 호출해요. 헤더 없이 선언을 공유하는 방식이 보이죠.
문법
export(optional) module module-name module-partition(optional) attr(optional) ; (1)
export declaration (2)
export { declaration-seq(optional) } (3)
export(optional) import module-name attr(optional) ; (4)
export(optional) import module-partition attr(optional) ; (5)
export(optional) import header-name attr(optional) ; (6)
module; (7)
module : private; (8)
모듈 선언 (Module declarations)
번역 유닛은 모듈 선언을 가질 수 있으며, 그 경우 모듈 유닛(module unit)으로 간주돼요. 모듈 선언은 제공된다면 번역 유닛의 첫 번째 선언이어야 해요(나중에 다룰 전역 모듈 조각(global module fragment)은 예외). 각 모듈 유닛은 모듈 선언에 제공된 모듈 이름(그리고 선택적으로 파티션)과 연관돼요.
export(optional) module module-name module-partition(optional) attr(optional) ;
모듈 이름은 점으로 구분된 하나 이상의 식별자로 구성돼요(예: mymodule, mymodule.mysubmodule, mymodule2 ...). 점에는 내재된 의미가 없지만, 형식적으로 계층을 나타내는 데 사용돼요.
모듈 이름이나 모듈 파티션의 어떤 식별자가 객체형 매크로(object-like macro)로 정의되어 있으면, 프로그램은 ill-formed예요.
명명된 모듈(named module)은 같은 모듈 이름을 가진 모듈 유닛들의 모음이에요.
선언에 export 키워드가 있는 모듈 유닛을 모듈 인터페이스 유닛(module interface units)이라 하고, 그 외의 모든 모듈 유닛을 모듈 구현 유닛(module implementation units)이라고 불러요.
모든 명명된 모듈에 대해, 모듈 파티션을 지정하지 않는 모듈 인터페이스 유닛이 정확히 하나 있어야 해요. 이 모듈 유닛을 기본 모듈 인터페이스 유닛(primary module interface unit)이라고 불러요. 이것의 내보낸 내용은 해당 명명된 모듈을 import할 때 사용할 수 있게 돼요.
// (각 줄은 별도의 번역 유닛을 나타낸다)
export module A; // 명명된 모듈 'A'의 기본 모듈 인터페이스 유닛 선언
module A; // 명명된 모듈 'A'의 모듈 구현 유닛 선언
module A; // 명명된 모듈 'A'의 또 다른 모듈 구현 유닛 선언
export module A.B; // 명명된 모듈 'A.B'의 기본 모듈 인터페이스 유닛 선언
module A.B; // 명명된 모듈 'A.B'의 모듈 구현 유닛 선언
선언과 정의 내보내기 (Exporting declarations and definitions)
모듈 인터페이스 유닛은 선언(정의 포함)을 내보낼 수 있고, 다른 번역 유닛이 그것들을 import할 수 있어요. 선언을 내보내려면 export 키워드를 앞에 붙이거나, export 블록 안에 두면 돼요.
export declaration
export { declaration-seq(optional) }
export module A; // 명명된 모듈 'A'의 기본 모듈 인터페이스 유닛 선언
// hello()는 'A'를 import하는 번역 유닛에 보일 것이다
export char const* hello() { return "hello"; }
// world()는 보이지 않을 것이다.
char const* world() { return "world"; }
// one()과 zero()는 둘 다 보일 것이다.
export
{
int one() { return 1; }
int zero() { return 0; }
}
// 네임스페이스 내보내기도 동작한다: hi::english()와 hi::french()가 보일 것이다
export namespace hi
{
char const* english() { return "Hi!"; }
char const* french() { return "Salut!"; }
}
export를 붙인 선언만 다른 유닛에 보이고, 붙이지 않은 건 모듈 안에 갇힌다는 점을 확인해 볼게요. export 블록 안에 여러 선언을 모아서 내보낼 수도 있어요.
모듈과 헤더 유닛 import하기 (Importing modules and header units)
모듈은 import 선언(import declaration)을 통해 import돼요:
export(optional) import module-name attr(optional) ;
주어진 명명된 모듈의 모듈 인터페이스 유닛에서 export된 모든 선언과 정의는 import 선언을 사용하는 번역 유닛에서 사용할 수 있게 돼요.
import 선언은 모듈 인터페이스 유닛에서 내보낼 수 있어요. 즉 모듈 B가 A를 export-import하면, B를 import하는 것은 A의 모든 export도 보이게 해요.
모듈 유닛에서는 모든 import 선언(export-import 포함)이 모듈 선언 다음, 다른 모든 선언 앞에 그룹 지어져야 해요.
/////// A.cpp (명명된 모듈 'A'의 기본 모듈 인터페이스 유닛)
export module A;
export char const* hello() { return "hello"; }
/////// B.cpp (명명된 모듈 'B'의 기본 모듈 인터페이스 유닛)
export module B;
export import A;
export char const* world() { return "world"; }
/////// main.cpp (모듈 유닛이 아님)
#include <iostream>
import B;
int main()
{
std::cout << hello() << ' ' << world() << '\n';
}
B가 A를 export-import하기 때문에, main.cpp가 B만 import해도 hello()를 쓸 수 있어요.
#include는 모듈 유닛에서(전역 모듈 조각 밖에서) 사용하면 안 돼요. 포함된 모든 선언과 정의가 모듈의 일부로 간주되기 때문이에요. 대신 헤더를 import 선언으로 헤더 유닛(header unit)으로서 import할 수도 있어요:
export(optional) import header-name attr(optional) ;
헤더 유닛은 헤더에서 합성된 별도의 번역 유닛이에요. 헤더 유닛을 import하면 그 모든 정의와 선언에 접근할 수 있게 돼요. 전처리기 매크로에도 접근할 수 있어요(import 선언은 전처리기가 인식하기 때문에).
하지만 #include와 달리, import 선언 시점에 이미 정의된 전처리 매크로는 헤더의 처리에 영향을 주지 않아요. 일부 경우에는 불편할 수 있는데(일부 헤더는 전처리 매크로를 설정의 한 형태로 사용), 그럴 때는 전역 모듈 조각을 사용해야 해요.
/////// A.cpp (명명된 모듈 'A'의 기본 모듈 인터페이스 유닛)
export module A;
import <iostream>;
export import <string_view>;
export void print(std::string_view message)
{
std::cout << message << std::endl;
}
/////// main.cpp (모듈 유닛이 아님)
import A;
int main()
{
std::string_view message = "Hello, world!";
print(message);
}
전역 모듈 조각 (Global module fragment)
모듈 유닛은 전역 모듈 조각으로 접두될 수 있는데, 헤더 import가 불가능한 경우(특히 헤더가 전처리 매크로를 설정으로 사용할 때) 헤더를 포함하는 데 사용돼요.
module; preprocessing-directives(optional) module-declaration
모듈 유닛에 전역 모듈 조각이 있으면, 첫 선언은 module;이어야 해요. 그다음 전역 모듈 조각에는 전처리 지시자만 나타날 수 있어요. 그다음 표준 모듈 선언이 전역 모듈 조각의 끝과 모듈 내용의 시작을 표시해요.
/////// A.cpp (명명된 모듈 'A'의 기본 모듈 인터페이스 유닛)
module;
// _POSIX_C_SOURCE를 정의하면 POSIX 표준에 따라
// 표준 헤더에 함수들이 추가된다.
#define _POSIX_C_SOURCE 200809L
#include <stdlib.h>
export module A;
import <ctime>;
// 오직 데모용(나쁜 난수 소스)이다.
// 대신 C++ <random>을 사용하라.
export double weak_random()
{
std::timespec ts;
std::timespec_get(&ts, TIME_UTC); // from <ctime>
// POSIX 표준에 따라 <stdlib.h>에 제공된다.
srand48(ts.tv_nsec);
// drand48()은 0과 1 사이의 난수를 반환한다.
return drand48();
}
/////// main.cpp (모듈 유닛이 아님)
import <iostream>;
import A;
int main()
{
std::cout << "Random value between 0 and 1: " << weak_random() << '\n';
}
module;로 시작해서 매크로를 정의하고 헤더를 포함한 뒤, export module A;로 실제 모듈 내용을 시작하는 구조예요.
비공개 모듈 조각 (Private module fragment)
기본 모듈 인터페이스 유닛은 비공개 모듈 조각(private module fragment)으로 접미될 수 있는데, 이걸 쓰면 모듈을 단일 번역 유닛으로 표현하면서도 모듈의 모든 내용을 importer가 접근할 수 있게 만들지 않을 수 있어요.
module : private; declaration-seq(optional)
비공개 모듈 조각은 다른 번역 유닛의 동작에 영향을 줄 수 있는 모듈 인터페이스 유닛의 부분을 끝내요. 모듈 유닛이 비공개 모듈 조각을 포함하면, 그것이 자기 모듈의 유일한 모듈 유닛이 돼요.
export module foo;
export int f();
module : private; // 다른 번역 유닛의 동작에 영향을 줄 수 있는
// 모듈 인터페이스 유닛의 부분을 끝낸다
// 비공개 모듈 조각을 시작한다
int f() // foo의 importer에게 도달할 수 없는 정의
{
return 42;
}
f()의 정의가 비공개 모듈 조각 안에 있어서, importer는 f()가 무엇을 하는지 몰라도 export int f(); 선언만으로 호출할 수 있어요.
모듈 파티션 (Module partitions)
모듈은 모듈 파티션 유닛(module partition units)을 가질 수 있어요. 이것은 모듈 선언이 모듈 파티션을 포함하는 모듈 유닛으로, 파티션은 콜론 :으로 시작하고 모듈 이름 뒤에 놓여요.
export module A:B; // 모듈 'A'의 모듈 인터페이스 유닛, 파티션 ':B'를 선언한다.
모듈 파티션은 정확히 하나의 모듈 유닛을 나타내요(두 모듈 유닛이 같은 모듈 파티션을 지정할 수 없음). 그것들은 명명된 모듈 안에서만 보여요(명명된 모듈 밖의 번역 유닛은 모듈 파티션을 직접 import할 수 없음).
모듈 파티션은 같은 명명된 모듈의 모듈 유닛이 import할 수 있어요.
export(optional) import module-partition attr(optional) ;
/////// A-B.cpp
export module A:B;
...
/////// A-C.cpp
module A:C;
...
/////// A.cpp
export module A;
import :C;
export import :B;
...
모듈 파티션의 모든 정의와 선언은, export 여부와 무관하게, import하는 모듈 유닛에 보여요.
모듈 파티션은 모듈 인터페이스 유닛일 수 있어요(모듈 선언에 export가 있을 때). 그것들은 기본 모듈 인터페이스 유닛이 export-import해야 하고, 모듈이 import될 때 그 export 문장들이 보여요.
/////// A.cpp
export module A; // 기본 모듈 인터페이스 유닛
export import :B; // 'A'를 import할 때 Hello()가 보인다.
import :C; // WorldImpl()이 이제 'A.cpp'에만 보인다.
// export import :C; // ERROR: 모듈 구현 유닛을 export할 수 없음.
// World()는 'A'를 import하는 어떤 번역 유닛에도 보인다.
export char const* World()
{
return WorldImpl();
}
/////// A-B.cpp
export module A:B; // 파티션 모듈 인터페이스 유닛
// Hello()는 'A'를 import하는 어떤 번역 유닛에도 보인다.
export char const* Hello() { return "Hello"; }
/////// A-C.cpp
module A:C; // 파티션 모듈 구현 유닛
// WorldImpl()은 ':C'를 import하는 'A'의 어떤 모듈 유닛에도 보인다.
char const* WorldImpl() { return "World"; }
/////// main.cpp
import A;
import <iostream>;
int main()
{
std::cout << Hello() << ' ' << World() << '\n';
// WorldImpl(); // ERROR: WorldImpl()은 보이지 않음.
}
A:B는 인터페이스 파티션이고 A:C는 구현 파티션이에요. A.cpp는 :B를 export-import해서 Hello()를 밖으로 내보내고, :C는 내부에서만 써서 WorldImpl()은 모듈 안에 숨어요.
모듈 소유권 (Module ownership)
일반적으로, 모듈 유닛에서 모듈 선언 뒤에 나타나는 선언은 그 모듈에 부착(attached)돼요.
엔티티의 선언이 명명된 모듈에 부착되면, 그 엔티티는 그 모듈에서만 정의될 수 있어요. 그런 엔티티의 모든 선언은 같은 모듈에 부착되어야 해요.
선언이 명명된 모듈에 부착되고 export되지 않으면, 선언된 이름은 모듈 연결(module linkage)을 가져요.
export module lib_A;
int f() { return 0; } // f는 모듈 연결을 가짐
export int x = f(); // x는 0과 같음
export module lib_B;
int f() { return 1; } // OK, lib_A의 f와 lib_B의 f는 서로 다른 엔티티
export int y = f(); // y는 1과 같음
엔티티의 두 선언이 서로 다른 모듈에 부착되면 프로그램은 ill-formed예요. 둘 중 하나가 다른 쪽에 도달할 수 없으면 진단이 필요 없어요.
/////// decls.h
int f(); // #1, 전역 모듈에 부착
int g(); // #2, 전역 모듈에 부착
/////// M의 모듈 인터페이스
module;
#include "decls.h"
export module M;
export using ::f; // OK, 엔티티를 선언하지 않음, #1을 내보냄
int g(); // Error: #2와 일치하지만 M에 부착됨
export int h(); // #3
export int k(); // #4
/////// 다른 번역 유닛
import M;
static int h(); // Error: #3와 일치
int k(); // Error: #4와 일치
다음 선언들은 어떤 명명된 모듈에도 부착되지 않아요(따라서 선언된 엔티티는 모듈 밖에서 정의될 수 있음):
- 외부 연결을 가진 네임스페이스 정의.
- 언어 연결(language linkage) 사양 안의 선언.
export module lib_A;
namespace ns // ns는 lib_A에 부착되지 않음
{
export extern "C++" int f(); // f는 lib_A에 부착되지 않음
extern "C++" int g(); // g는 lib_A에 부착되지 않음
export int h(); // h는 lib_A에 부착됨
}
// ns::h는 lib_A에서 정의되어야 하지만, ns::f와 ns::g는
// 다른 곳(예: 전통적인 소스 파일)에서 정의될 수 있다.
참고 사항 (Notes)
| 기능 테스트 매크로 | 값 | 표준 | 기능 |
|---|---|---|---|
__cpp_modules |
201907L | C++20 | 모듈 — 코어 언어 지원 |
__cpp_lib_modules |
202207L | C++23 | 표준 라이브러리 모듈 std와 std.compat |
키워드
private, module, import, export
결함 보고 (Defect reports)
| DR | 적용 대상 | 발표 당시 동작 | 올바른 동작 |
|---|---|---|---|
| CWG 2732 | C++20 | import 가능한 헤더가 import 시점의 전처리기 상태에 반응할 수 있는지 불명확했음 | 반응 없음 |
| P3034R1 | C++20 | 모듈 이름과 모듈 파티션이 객체형 매크로로 정의된 식별자를 포함할 수 있었음 | 금지됨 |
외부 링크
- Chuanqi Xu, 2025 — C++20 Modules: Practical Insights, Status and TODOs
- Chuanqi Xu, 2025 — C++20 Modules: Best Practices from a User's Perspective
더 알아보기
- Preprocessor — 헤더 포함(
#include) - 네임스페이스 — 모듈과 직교하는 개념