8 분 소요

  • (C++20~) 모듈이 추가되어 전처리 사용 방식을 개선하여 컴파일 속도를 향상시키고, #include 순서에 따른 종속성 문제, 선언과 정의 분리 구성의 불편함, 기호 충돌 문제를 해결했습니다.

개요

C++ 는 다음의 전처리, 컴파일, 링크의 3가지 과정을 거쳐서 프로그램을 빌드합니다.

image

이중 전처리 과정은 다음의 문제점이 있습니다.

  1. 용량 비대화에 따른 컴파일 속도 문제

    #include를 하고 나면 소스 파일은 엄청 거대해집니다.

    1
    2
    3
    4
    5
    6
    7
    
     // helloworld.cpp
     #include <iostream>
    
     int main() {
         std::cout << "Hello World" << std::endl;
         return 0;
     }
    

    상기 코드에는 coutendl이 있어서 #include <iostream> 이 필요합니다. 이에 전처리를 해야 하는데요,

    다음과 같이 -E 옵션으로 전처리 결과를 출력할 수 있고, 해당 내용을 -o 옵션으로 파일에 저장할 수 있습니다.

    1
    
     F:\Data\language_test\test\module>g++ -E helloworld.cpp -o helloworld.out
    

    helloworld.out 파일의 결과는 놀랍게도 943kbyte나 됩니다. 대략 1000배가 늘었습니다. 이를 다 일일이 기계어로 번역하다 보니, 단순히 Hello World만 출력하는 별것 아닌 코드이지만 컴파일 시간이 좀 걸리게 됩니다.

    아마도 iostream이 또다른 파일을 #include 하고, #include한 파일에서 또 다른 파일들을 #include 하다 보니 벌어진 일이겠죠. 파일 구성에서 말씀드린 것처럼, 헤더 파일에서 다른 헤더 파일을 #include하는 것을 최소화 해서 해결하면 되겠지만, 아무래도 한계가 있습니다.

  2. #include 순서에 따른 종속성 문제

    #define이나 #include는 해당 위치에 소스 코드를 대체하는 것이기 때문에, 어떤 것을 먼저 대체했는지에 따라 다른 결과가 나올 수 있습니다. 또한 정적 변수의 초기화 순서와 같은 문제가 있을 수도 있습니다.

  3. 선언과 정의를 분리하는 파일 구성의 불편함

    컴파일 속도 향상을 위해 선언과 정의를 분리하는데요, 소스 코드 관리가 상당히 번거롭습니다. 컴파일 속도와 구현 코드의 은닉이 더 중요하니 어쩔 수 없이 참고 사용하죠.

  4. 기호 충돌

    정의가 중복되면 충돌이 납니다. 따라서 전역 변수extern으로 선언한다던지, 함수를 inline으로 정의한다던지 해야 합니다.

C++20 부터는 모듈이 추가되어 전처리 사용 방식을 개선하여 컴파일 속도를 향상시키고, #include 순서에 따른 종속성 문제, 선언과 정의 분리 구성의 불편함, 기호 충돌 문제를 해결했습니다.

모듈을 사용하는 방식은 컴파일러마다 다릅니다. 자세한 내용은 컴파일러 도움말을 참조 하세요.

항목 MSVC CLang GCC
모듈 파일 확장자 ixx cppm 또는 cpp 특별한 확장자 없음
컴파일 옵션 /interface -emit-module-interface -fmodules-ts

다음은 MyMoudule.cppmain.cpp을 분리한 예입니다.

MyMoudule.cpp에서는 내부적으로 cout을 사용하기 위해 #include <iostream>를 사용하며, 외부에서 사용할 수 있도록 Func_20()함수를 export로 내보내기 합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ----
// MyModule.cpp 파일에서
// ----
module; // 전역 모듈 조각

#include <iostream> // 모듈에서 사용하는 헤더 파일

export module MyModule_20; // 내보내기할 모듈 선언

void Print() {
    std::cout << "MyModule_20 : Print()" << std::endl;
}
export void Func_20() { // 내보내기 선언
    Print();
}

main.cpp에서는 import을 이용하여 MyModule_20 모듈을 가져오며, 내보내기된 Func_20()함수를 사용합니다. 이때 Print()함수는 내보내기 되지 않았으므로 사용할 수 없습니다.

1
2
3
4
5
6
7
8
9
10
// ----
// main.cpp 파일에서
// ----
import MyModule_20; // 가져오기 선언

int main() {
    Func(); // 모듈에서 Export한 함수를 사용합니다.
    // Print(); // (X) 컴파일 오류. export 한 함수가 아닙니다.
    return 0;
}

GCC에서는 다음과 같이 빌드합니다.

1
F:\Data\language_test\test\module>g++ -std=c++20 -fmodules-ts MyModule.cpp main.cpp -o MyModule  

export(모듈 내보내기) 와 import(모듈 가져오기)

모듈 선언인 export module Module_20; 이하에 export로 내보낼 항목을 작성합니다. 개별로 내보낼 수도 있고, export {}로 그룹지어 내보낼 수도 있으며, 네임스페이스를 통째로 내보낼 수도 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// ----
// Test_ExportImport.cpp 파일에서
// ----
export module MyModule_20; // 내보내기할 모듈 선언

// 개별 내보내기
export void Func1() {}

// 그룹 내보내기
export {
    void Func2() {}
    void Func3() {}
}

// 네임스페이스 내보내기
export namespace MyLib {
    void Func4() {}
    void Func5() {}
}

// 템플릿 함수 내보내기
export template<typename T>
void Func6(T val) {}

// 클래스 내보내기
export class T {};

가져오기시에는 import모듈만 가져오면, 모듈에서 export한 모든 항목들을 사용할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// ----
// Test_ExportImport_main.cpp 파일에서
// ----
import MyModule_20; // 가져오기 선언

int main() {
    Func1(); 
    Func2(); 
    Func3(); 
    MyLib::Func4(); 
    MyLib::Func5(); 
    Func6<int>(10);
    Func6<double>(10.0);
    T obj;

    return 0;
}

import 헤더 파일

특별히 #includeimport로 변경할 수 있습니다.

(GCC 12.3.0 에서 컴파일되지 않습니다. 뭐가 잘못됐는지 좀더 확인해 봐야 합니다. https://build2.org/blog/build2-cxx20-modules-gcc.xhtml#header-units 참고)

1
2
3
4
5
6
7
8
// #include <iostream>
import <iostream>;

int main() {
    std::cout << "Test Import header" << std::endl;

    return 0;
}

모듈 인터페이스와 구현 분리

export로 외부로 내보낼때 인터페이스와 구현을 분리할 수 있습니다.

다음의 Test_Interface.cpp에서 Func()함수의 선언만 내보내고,

1
2
3
4
5
// ----
// Test_Interface.cpp 파일에서
// ----
export module MyModule_20;
export void Func(); // 인터페이스 부분은 함수 선언만 합니다.

Test_Implement.cpp에서 Func()함수를 정의합니다. 이때 MyModule_20Func()에는 export를 붙이지 않습니다.

1
2
3
4
5
// ----
// Test_Implement.cpp 파일에서
// ----
module MyModule_20; // export를 붙이지 않았습니다.
void Func() {} // export를 붙이지 않습니다.

다음과 같이 MyModule_20import하여 사용할 수 있습니다.

1
2
3
4
5
6
7
8
9
// ----
// Test_Interface_main.cpp 파일에서
// ----
import MyModule_20;

int main() {
   Func(); // 모듈에서 Export한 함수를 사용합니다.
   return 0;
}

전역 모듈 조각과 개인 모듈 조각

전역 모듈 조각모듈의 상단에 module;로 표시하며, 모듈에서 사용하는 헤더 파일을 #include합니다.

개인 모듈 조각모듈의 하단에 module : private;로 표시하며, 모듈 인터페이스와 구현을 하나의 파일에 작성할 수 있게 해줍니다. 즉, 함수 선언에는 export를 작성하고, module : private;에 실제 구현을 합니다.

전체적인 모듈 파일 레이아웃은 다음과 같습니다.(GCC 12.3.0 에서 module : private; 은 아직 구현되지 않았다는 컴파일 오류가 발생합니다.)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// ----
// Test_Fragment.cpp 파일에서
// ----
module; // 전역 모듈 조각(Global module fragment)

#include<iostream> // 모듈에서 사용하는 헤더 파일

export module MyModule_20; // 모듈 선언
export void Func(); // 인터페이스만 작성합니다.

module : private; // 구현을 작성합니다.
void Print() {
    std::cout << "MyModule_20 : Print()" << std::endl; 
}
void Func() {
   Print();
} 

다음과 같이 MyModule_20import하여 사용할 수 있습니다.

1
2
3
4
5
6
7
8
9
// ----
// Test_Fragment_main.cpp 파일에서
// ----
import MyModule_20; 

int main() {
    Func(); // 모듈에서 Export한 함수를 사용합니다.
    return 0;
}

모듈 분할

하나의 모듈을 여러개로 나누어 관리할 수 있습니다.

다음예는 MyModule_20Part1, Part2, Part3으로 나누어 관리하는 예입니다. Part1Part2는 외부에서도 사용하고, Part3MyModule_20 내부에서만 사용합니다.

Test_Part.cpp모듈의 인터페이스로서 Part1Part2import한뒤 export합니다. 다른 파트를 표현할때 :Part1;와 같이 모듈명 없이 작성합니다.

1
2
3
4
5
6
7
// ----
// Test_Part.cpp 파일에서
// ----
 export module MyModule_20;

 export import :Part1; // 각 파트를 import한뒤 export 합니다.
 export import :Part2;

Test_Part1.cpp, Test_Part2.cpp는 각 파트를 정의하고 export합니다.

이때 Part1에서 Part3을 사용하는데요, import :Part3;하여 가져오기를 합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// ----
// Test_Part1.cpp 파일에서
// ----
export module MyModule_20:Part1;
import :Part3;
export void Part1() {
    Part3(); 
}

// ----
// Test_Part2.cpp 파일에서
// ----
export module MyModule_20:Part2;
export void Part2() {}

Part3은 외부에 내보내지 않고 MyModule_20내애서만 사용할 건데요, 모듈의 각 파트간에는 서로 사용할 수 있기 때문에 굳이 export를 하지 않아도 됩니다. 따라서, module MyModule_20:Part3;와 같이 export없이 작성합니다.

1
2
3
4
5
// ----
// Test_Part3.cpp 파일에서
// ----
module MyModule_20:Part3; // MyModule_20에서만 사용하므로 export 하지 않습니다.
void Part3() {} // MyModule_20에서만 사용하므로 export 하지 않습니다.

다음과 같이 MyModule_20import하여 사용할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
// ----
// Test_Part_main.cpp 파일에서
// ----
import MyModule_20; // MyModule_20.Part1, MyModule_20.Part2를 사용할 수 있습니다.

int main() {
    Part1(); 
    Part2(); 
    return 0;
}

컴파일할 때 순서에 주의해야 합니다. 종속성 있는 파트들이 먼저 컴파일되어야 합니다. 따라서 Test_Part3.cppTest_Part1.cpp보다 먼저 컴파일되어야 하고, Test_Part.cppTest_Part3.cpp Test_Part1.cpp Test_Part2.cpp보다 나중에 컴파일 되어야 합니다.

1
F:\Data\language_test\test\module>g++ -std=c++20 -fmodules-ts Test_Part3.cpp Test_Part1.cpp Test_Part2.cpp Test_Part.cpp Test_Part_main.cpp -o Test_Part_main  

하위 모듈

모듈의 이름에는 .을 사용할 수 있는데요, 이를 이용해서 관용적으로 하위 모듈처럼 관리할 수 있습니다. MyModule_20.Sub1, MyModule_20.Sub2, MyModule_20.Sub3처럼요. 하지만 그냥 이름이 다른 모듈들일 뿐입니다.

다음은 모듈 분할에서의 예제를 하위 모듈로 변경한 예입니다. 서로 다른 모듈간에 사용하려면 export되어야 하기 때문에, MyModule_20.Sub3export 되었으며, 이에 따라 main()에서도 MyModule_20.Sub3 import을 하여 사용할 수 있습니다. 은닉성을 위해 모듈 분할을 사용하는게 더 좋습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// ----
// Test_Sub.cpp 파일에서
// ----
 export module MyModule_20;

 export import MyModule_20.Sub1;
 export import MyModule_20.Sub2;

// ----
// Test_Sub1.cpp 파일에서
// ----
export module MyModule_20.Sub1;
import MyModule_20.Sub3; 
export void Sub1() {
    Sub3();
}

// ----
// Test_Sub2.cpp 파일에서
// ----
export module MyModule_20.Sub2;
export void Sub2() {}

// ----
// Test_Sub3.cpp 파일에서
// ----
export module MyModule_20.Sub3; // (△) 비권장. Sub1에서 사용하기 위해 export를 합니다.
export void Sub3() {} // (△) 비권장. Sub1에서 사용하기 위해 export를 합니다.
1
2
3
4
5
6
7
8
9
10
11
12
// ----
// Test_Sub_main.cpp 파일에서
// ----
import MyModule_20; // MyModule_20.Sub1, MyModule_20.Sub2를 사용할 수 있습니다.
import MyModule_20.Sub3; // (△) 비권장. export되었기에 main에서도 사용할 수 있습니다.

int main() {
    Sub1();
    Sub2(); 
    Sub3(); // (△) 비권장. export되었기에 main에서도 사용할 수 있습니다.
    return 0;
}

모듈간 충돌

모듈에서 내보내는 개체가 동일하면 무얼 사용해야 할지 모호하기 때문에 컴파일 오류가 발생합니다.

1
2
3
4
5
6
7
8
9
10
11
// ----
// Test_My.cpp 파일에서
// ----
export module MyModule_20;
export void Func() {}

// ----
// Test_Your.cpp 파일에서
// ----
export module YourModule_20;
export void Func() {} // MyModule_20의 Func() 과 동일합니다.
1
2
3
4
5
6
7
8
9
10
// ----
// Test_MyYour_main.cpp 파일에서
// ----
import MyModule_20; 
import YourModule_20; 

int main() {
    Func(); // (X) 컴파일 오류. MyModule_20 과 YourModule_20 모듈에 모두 있습니다.
    return 0;
}

따라서, 네임스페이스를 사용하시는게 좋습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// ----
// Test_My.cpp 파일에서
// ----
export module MyModule_20;
export namespace MyLib {
    void Func() {}
}

// ----
// Test_Your.cpp 파일에서
// ----
export module YourModule_20;
export namespace YourLib {
    void Func() {} 
}

// ----
// Test_MyYour_main.cpp 파일에서
// ----
import MyModule_20; 
import YourModule_20; 

int main() {
    MyLib::Func();
    YourLib::Func();
    return 0;
}

태그:

카테고리:

업데이트:

댓글남기기