C++20에 모듈이 추가되기 전에는 헤더 파일을 이용하여 재사용 가능한 인터페이스를 제공했다. 그런데 헤더 파일은 몇 가지 문제가 있다. 예를 들어 같은 헤더 파일을 여러 번 include하지 않게 하거나 include되는 순서를 정확히 지정하기 힘들다. 또한 #include <iostream>과 같이 include문 한 줄만 적어도 컴파일 과정에서 수천 라인의 코드가 포함되어버린다. 이런 include문을 적은 소스 파일이 늘어날수록 생성되는 바이너리 코드의 크기는 폭발적으로 커지게 된다.
모듈을 이용하면 이런 문제를 해결할 수 있다. 모듈은 import하는 순서가 중요하지 않다. 모듈은 매번 컴파일하는 헤더 파일과 달리 단 한 번만 컴파일한다. 따라서 컴파일 시간도 크게 단축된다. 예를 들어 모듈 인터페이스 파일에 export한 함수의 구현 코드를 수정하는 것처럼 모듈의 일부분을 변경하더라도 이를 사용하는 코드를 다시 컴파일하지 않아도 된다. 모듈은 외부에서 정의한 매크로에 영향을 받지 않으며, 모듈 안에 정의된 매크로는 밖에서 하나도 보이지 않는다.
모듈 인터페이스 파일
모듈 인터페이스 파일(module interface file)은 모듈에서 제공하는 기능에 대한 인터페이스를 정의한다. 모듈 인터페이스 파일은 대부분 .cppm 또는 .ixx이란 확장자를 붙인다. 모듈 인터페이스 파일은 지정한 이름으로 된 모듈을 정의하는 파일임을 선언하는 모듈 선언문(module declaration)으로 시작한다. 모듈 이름은 C++에서 허용하는 식별자 형식을 따르는 모든 값으로 지정할 수 있다. 이름 중간에 점을 포함할 수는 있지만 맨 앞이나 맨 끝에는 넣을 수 없으며, 여러 개의 점을 연달아 적을 수도 없다.
모듈은 export할 대상, 즉 클라이언트 코드에서 그 모듈을 import할 때 볼 수 있는 부분을 명시적으로 지정해야 한다. 예를 들어 클래스, 함수, 상수, 다른 모듈 등이 있다. 이런 export 대상은 export 키워드로 지정한다. 이렇게 명시적으로 export하지 않은 대상은 해당 모듈 내부에서만 볼 수 있다. export한 모든 대상을 모듈 인터페이스(module interface)라 부른다.
예를 들어 다음과 같이 작성된 Person.ixx 모듈 인터페이스 파일이 있다. 모듈 이름은 person이고 Person 클래스를 export하도록 정의했다. 이 모듈은 또한 <string>에서 제공하는 기능을 사용하도록 import하고 있다.
이렇게 정의한 Person 클래스를 사용하려면 다음과 같이 person 모듈을 import하면 된다.
<iostream>, <vector>, <string> 등과 같은 C++ 헤더는 모두 임포트 가능한 헤더(importable headers)라고 부르며, import 선언문을 통해 import할 수 있다. C++에서 C 헤더를 사용할 수 있지만 항상 import할 수 있다고 보장할 수는 없다. C 헤더를 확실히 import하려면 import 선언문보다는 #include 문을 사용하는 것이 좋다. 이런 #include 디렉티브는 글로벌 모듈 프래그먼트(global module fragment)라고 부르는 곳에 둬야 한다. 이 문장은 반드시 이름을 지정한 명명 모듈(named module) 선언문 앞에 나와야 하며, 이름을 지정하지 않은 무명 모듈(nameless module) 선언문으로 시작해야 한다. 글로벌 모듈 프래그먼트는 #include와 같은 전처리 지시자만 담을 수 있다. 이런 글로벌 모듈 프래그먼트나 주석 명명 모듈 선언문 앞에 나올 수 있다. 예를 들어 C 헤더 파일인 <cstddef>에서 제공하는 기능을 사용하려면 다음과 같이 작성한다.
명명 모듈 선언문부터 파일의 끝 사이에 나오는 모든 것을 표준 용어로 모듈 퍼뷰(module purview)라고 부른다.
모듈에서 export할 수 있는 것이라면 거의 대부분 이름이 있다. 예를 들면 클래스 정의, 함수, 프로토타입, 클래스 열거 타입, using 선언문, 디렉티브, 네임스페이스 등이 있다. 네임스페이스를 export 키워드로 명시적으로 export 했다면 그 네임스페이스 안에 있는 대상은 모두 자동으로 export 된다. 예를 들어 다음 코드는 DataModel 네임스페이스 전체를 export한다. 그러므로 클래스나 타입 앨리어스를 별도로 export하지 않아도 된다.
export block으로 여러 선언문을 통째로 export할 수도 있다.
모듈 구현 파일
모듈은 모듈 인터페이스 파일 하나와 모듈 구현 파일(module implementation file) 여러 개로 나눌 수 있다. 모듈 구현 파일은 주로 .cpp라는 확장자를 사용한다. 구현 코드의 어느 부분을 모듈 구현 파일로 옮기고, 어느 부분을 모듈 인터페이스 파일에 남길지는 작성자 마음이다. 한 가지 방법은 함수나 메서드 구현은 모두 모듈 구현 파일로 옮기고, 함수 프로토타입과 클래스 정의와 같은 것들은 모듈 인터페이스 파일에 남길 수 있다. 또는 자잘한 함수나 메서드 구현은 인터페이스 파일에 놔두고, 나머지 함수나 메서드에 대한 구현 코드만 구현 파일로 옮기는 방법도 있다.
모듈 구현 파일도 현재 파일에서 구현하는 대상 모듈에 대한 선언문을 담을 수 있는데, 이때 export 키워드를 사용하지 않는다. 예를 들어 위에서 본 person 모듈은 다음과 같이 인터페이스 파일 하나와 구현 파일 하나로 나눌 수 있다. 먼저 모듈 인터페이스 파일은 다음과 같다.
그러고 나서 구현 코드를 다음과 같이 Person.cpp라는 모듈 구현 파일에 작성한다.
여기서 구현 파일을 보면 person 모듈에 대한 import 선언문이 없다. module person 선언문은 암묵적으로 import person 선언문을 담고 있다. 또한 이 구현 파일에서 <string>에 대한 import 선언문도 적지 않았다. 이것 역시 암묵적인 import person 선언문 때문이다. 또한 이 구현 파일은 동일한 person 모듈의 일부분이기 때문에 위에 나온 모듈 인터페이스 파일에 있는 <string> import 선언문을 암묵적으로 물려받는다. 이에 반해 import person 선언문을 적은 main.cpp는 person 모듈의 일부분이 아니기 때문에 <string> import 선언문을 암묵적으로 물려받지 않아 import <string> 선언문을 따로 적어줘야 한다.
참고로 모듈 인터페이스와 모듈 구현에 나오는 모든 import 선언문은 반드시 파일 맨 앞에 나와야 한다. 이때 명명 모듈 선언문보다는 뒤에 나와야 하고, 그 외 다른 선언문보다는 앞에 나와야 한다. 또한 모듈 구현 파일은 아무것도 export할 수 없다. export는 모듈 인터페이스 파일만 할 수 있다.
모듈 인터페이스 파일과 마찬가지로 모듈 구현 파일도 레거시 헤더 파일에 대한 #include 지시자는 반드시 글로벌 모듈 프래그먼트에 적어야 한다. 문법은 모듈 인터페이스 파일과 동일하다.
인터페이스와 구현의 분리
모듈 대신 헤더 파일을 사용한다면 헤더 파일(.h)에는 선언 코드만 넣고, 구현 코드는 모두 소스 파일(.cpp)에 담는 방식으로 작성해야 컴파일 시간을 절약할 수 있다. 헤더 파일에 구현 코드를 넣으면 나중에 조금이라도 변경했을 때 그 헤더를 include한 소스 파일을 모조리 다시 컴파일해야 하기 때문이다. 헤더파일의 성격에 따라 코드 베이스 전체에 영향을 미쳐서 결국 프로그램을 통째로 다시 컴파일하는 결과를 초래할 수 있다.
모듈은 좀 다르게 처리된다. 모듈 인터페이스(module interface)는 클래스 정의, 함수 프로토타입과 같은 것들만 듬을 수 있다. 함수나 메서드 구현 코드는 담을 수 없다. 설사 구현 코드가 모듈 인터페이스 파일에 직접 담겨 있더라도 그렇다. 따라서 모듈 인터페이스 파일 안에 있는 함수나 메서드에 대한 구현 코드를 수정하더라도 그 모듈을 사용하는 코드는 다시 컴파일 하지 않아도 된다. 물론 인터페이스 자체(함수 이름, 매개변수 리스트, 리턴 타입과 같은 함수 헤더 등)를 변경하면 다시 컴파일해야 한다. 그런데 inline 키워드가 붙은 함수/메서드와 템플릿 정의는 예외다. 이 두 가지 대상은 컴파일러가 사용하는 클라이언트 코드를 컴파일하는 시점에 전체 구현 코드를 컴파일러가 알아야 한다.
인터페이스와 구현을 분리하지 ㅇ낳아도 문법에 어긋나는 것은 아니지만 그렇게 하는 것이 좋다. 이렇게 하는 목적은 인터페이스를 깔끔하고 이해하기 쉽게 만들기 위함이다. 함수 구현 코드를 인터페이스 파일과 함께 둘 때 인터페이스를 모호하게 만들지 않고 함수에서 제공하는 public 인터페이스를 신속하게 파악하기 힘들어지지 않는다면 인터페이스에 넣어도 된다. 예를 들어 모듈에서 제공하는 public 인터페이스가 방대하다면 그 인터페이스가 제공하는 기능을 개략적으로 파악하기 쉽도록 구현 코드를 인터페이스와 분리하는 것이 좋다.
인터페이스와 구현 코드를 분리하는 방법은 여러 가지다. 하나는 위에서 설명한 것처럼 모듈을 인터페이스 파일과 구현 파일로 나누는 것이다. 또 다른 방법은 인터페이스 파일 안에서 인터페이스와 구현을 분리하는 것이다. 예를 들어 다음 코드에 나온 Person 클래스는 모듈 인터페이스 파일에 정의했는데, 구현 코드는 인터페이스와 분리해서 작성했다.
가시성과 도달성
위에서 설명했듯이 person 모듈에 속하지 않은 다른 소스 파일에서 person 모듈을 import하면 person 모듈 인터페이스 파일에서 선언한 <string>을 impot하는 문장은 상속하지 않는다. 따라서 다른 소스 파일에서 <string>에 대한 import 문을 명시적으로 작성하지 않으면 std::string이란 이름을 알 수 없게 되어 다음 코드에서 주석 표시한 부분에서 컴파일 에러가 발생한다.
하지만 <string>을 import하는 문장을 추가하지 않아도 다음 코드는 문제없이 실행된다.
이 코드가 문제없이 처리되는 이유는 C++에서의 가시성(visibility)과 도달성(reachability / 도달 가능성)에 차이가 있기 때문이다. person 모듈을 import하면 <string>에서 제공하는 기능에 접근할 수는 있지만(도달성이 있다) 보이지는 않는다(가시성이 없다). 그런데 도달 가능한 클래스에 속한 멤버 함수는 자동으로 보이게 된다. 따라서 getLastName()의 결과를 auto 타입 변수에 저장하거나 lenght()와 같이 메서드를 호출하는 것처럼 <stirng>에서 제공하는 기능 중 일부는 사용할 수 있게 된다.
std::string을 소스 코드에서 볼 수 있게 하려면 <string>을 명시적으로 import해야 한다. 다음 코드에 나온 것처럼 operator<<와 같은 기능을 사용할 때도 명시적으로 <string>을 import해야 한다. operator<<는 std::string의 메서드가 아니라 <string>을 import해야만 볼 수 있는 비 멤버 함수이기 때문이다.
서브모듈
C++ 표준에는 서브모듈(submodule)에 대한 설명이 따로 없지만 모듈 이름에 점(.)을 사용하는 것이 허용되며, 점을 사용해서 원하는 계층 구조를 만들 수 있다. 예를 들어 다음과 같이 정의된 DataModel namespace가 있다.
Person과 Address 클래스는 둘 다 DataModel namespace와 datamodel 모듈에 속한다. 이를 datamodel.person과 datamodel.address란 두 개의 서브모듈로 정의해서 재구성할 수 있다. datamodel.person 서브모듈에 대한 모듈 인터페이스 파일은 다음과 같다.
datamodel.address에 대한 모듈 인터페이스 파일은 다음과 같다.
마지막으로 앞서 정의한 두 서브모듈을 import하고 곧바로 export하도록 다음과 같이 datamodel 모듈을 정의한다.
서브모듈에 있는 클래스의 메서드 구현을 모듈 구현 파일에서 작성하는 것도 당연히 가능하다. 예를 들어 Address 클래스에 표준 출력으로 문장을 출력하는 디폴트 생성자가 있을 때 코드를 datamodel.address.cpp란 파일에 담을 수 있다.
서브모듈을 이용하여 코드를 구성하면 클라이언트가 모듈 전체를 가져올 필요 없이 일부분만 골라서 import할 수 있다. 예를 들어 Address 클래스만 필요하다면 다음과 같이 import하면 된다.
반면 datamodel 모듈에 있는 모든 대상에 접근해야 할 때는 다음과 같이 import문을 작성한다.
모듈 파티션
모듈을 구성하는 또 다른 방법은 여러 파티션(pertition)으로 나누는 것이다. 서브모듈은 사용자가 원하는 것만 골라 쓰도록 서브모듈의 구조를 드러내는 반면, 파티션은 모듈의 구조를 내부적으로 정의하기 때문에 모듈 사용자는 볼 수 없다. 파티션을 모듈 인터페이스 파티션 파일(module interface pertition file)에 선언했다면 반드시 기본 모듈 인터페이스 파일(primary module interface file)에서 export해야 한다. 기본 모듈 인터페이스 파일은 모듈마다 하나만 있어야 하며, export module name 선언을 작성한다.
모듈 파티션은 모듈 이름과 파티션 이름을 콜론(:)으로 연결하여 선언한다. 파티션 이름은 C++의 식별자 형식을 따르는 값으로 지정할 수 있다. 예를 들어 위에서 정의한 DataModel 모듈을 다음과 같이 파티션으로 구성되도록 변경할 수 있다. DataModel.Person.ixx 모듈 인터페이스 파티션 파일에 선언한 person 파티션은 다음과 같다.
디폴트 생성자가 정의된 address 파티션은 다음과 같다.
하지만 구현 파일에서 파티션을 사용할 때는 파티션 이름 하나에 파일 하나만 존재할 수 있다는 제약사항이 있다. 그러므로 구현 파일 첫 부분에 다음과 같이 선언할 수 없다.
그 대신 datamodel 모듈의 구현 파일에 다음과 같이 주소 파티션 구현을 추가한다.
모듈을 파티션으로 구성할 때 모든 모듈 인터페이스 파티션은 반드시 기본 모듈 인터페이스 파일에서 직접적이든 간접적이든 반드시 export해야 한다. 파티션을 import하려면 파티션 앞에 콜론을 붙이기만 하면 된다. 또한 파티션은 모듈을 내부적으로 구성한다. 따라서 사용자가 특정 파티션만 골라서 import할 수 없고, 모듈 전체를 import해야 한다. 파티션을 소속 모듈 안에서만 import할 수 있다. 따라서 콜론 앞에 모듈 이름을 적으면 중복 표기일 뿐만 아니라 문법에도 어긋난다. 이렇게 구성한 datamodel 모듈에 대한 기본 모듈 인터페이스 파일은 다음과 같다.
위에서 module name과 같은 형식의 선언문은 내부적으로 import name 선언문을 포함한다고 말했다. 하지만 파티션은 이렇게 처리되지 않는다. 예를 들어 datamodel:person 파티션에는 내부적으로 import datamodel 선언문이 없다. 이 예제에서 import datamodel이란 선언문을 datamodel:person 인터페이스 파티션 파일에 명시적을 추가할 수도 없다. 그렇게 하면 datamodel 인터페이스 파일에 import :person 선언문이 있고, datamodel:person 인터페이스 파일에 import datamodel 선언문이 있는 순환 의존이 발생하기 때문이다.
이런 순환 의존 관계를 없애려면 datamodel:person 파티션에 필요한 기능을 datamodel 인터페이스 파일에서 다른 파티션으로 옮겨야 한다. 그러면 datamodel:person 인터페이스 파티션 파일과 datamodel 인터페이스 파일 모두 import할 수 있게 된다.
파티션은 모듈 인터페이스 파티션 파일에서만 선언할 필요가 없다. 모듈 구현 파티션 파일(module implementation partition file)과 일반 소스 코드 파일(.cpp)에서도 선언할 수 있으며, 이 경우 내부 파티션(intermal partition) 또는 구현 파티션(implementation partition)이라고 부른다. 이런 파티션은 기본 모듈 인터페이스 파일에 export되지 않는다.
예를 들어 다음과 같이 작성된 math라는 기본 모듈 인터페이스 파일이 있다.
여기서 더 나아가 math 함수 구현에 필요한 몇 가지 헬퍼 함수가 필요한데, 이를 모듈에서 export할 수 없다고 한다. 이런 헬퍼 함수는 구현 파티션에 작성하면 좋다. 다음은 math_helpers.cpp 파일에 이러한 구현 파티션을 정의한 예다.
다른 math 모듈 구현 파일은 이 구현 파티션을 import하는 방식으로 여기 나온 헬퍼 함수를 사용할 수 있다. 예를 들어 math.cpp라는 math 모듈 구현 파일은 다음과 같다.
물론 여러 소스 파일에서 헬퍼 함수를 사용할 때만 이런 구현 파티션을 사용한다.
헤더 단위
모듈을 import하려면 다음과 같이 import 선언문을 작성한다.
레거시 코드에서 person.h라는 헤더 파일에 Person 클래스를 정의했다면 person.ixx와 같은 적절한 모듈로 변환해서 모듈화부터 한 다음 클라이언트 코드에서 사용할 때는 이에 대한 선언문을 import하는 방식으로 사용할 수 있다. 하지만 이렇게 헤더를 모듈화할 수 없는 경우도 있다. 예를 들어 아직 모듈 기능을 지원하는 않는 컴파일러에서 Person 클래스를 처리할 수 있게 만들어야 하는 경우가 있다. 또는 person.h가 서드파티 라이브러리에 있어서 마음대로 수정할 수 없을 수도 있다. 이럴 때는 다음과 같이 헤더 파일을 직접 import하면 된다.
이렇게 선언하면 person.h 헤더 파일에 담긴 모든 내용이 암묵적으로 import된다. 또한 모듈을 사용할 때와 달리, 이 헤더에 정의된 매크로도 클라이언트 코드에서 볼 수 있다.
헤더 파일을 #include로 include할 때와 달리 import로 처리하면 빌드 속도가 향상된다. person.h 헤더가 내부적으로 모듈로 변환되어 모든 소스 파일에 include하지 않아서 단 한 번만 컴파일되기 때문이다. 따라서 미리 컴파일된 헤더 파일(precomplied header file)이라 부르는 기능을 컴파일러에 종속적인 방식이 아닌 표준 방식으로 적용할 수 있다.
컴파일러는 헤더 파일 이름을 지정하는 import 선언마다 헤더 파일에서 정의한 것과 비슷한 내용의 모듈을 export된 인터페이스로 생성해야 한다. 이를 헤더 단위(header unit)라 부른다. 구체적인 방법은 컴파일러마다 다르기 때문에 헤더 단위를 다루는 방법은 각자 사용하는 컴파일러의 문서를 참조해야 한다.
'Deep Dive Language > : : C++' 카테고리의 다른 글
| [c++] override, const_iterator, noexcept, constexpr 사용하기 (0) | 2024.04.17 |
|---|