[Effective C++] 5. 구현 [3/3]
이 글은 제 개인적인 공부를 위해 작성한 글입니다.
틀린 내용이 있을 수 있고, 피드백은 환영합니다.
항목 30 : 인라인 함수는 미주알고주알 따져서 이해해 두자
인라인 함수, 아무리 생각해도 훌륭한 아이디어이다. 함수처럼 보이고 함수처럼 동작하는데다가, 매크로보다 훨씬 안전하고 쓰기 좋다. 함수 호출 시 발생하는 오버헤드도 걱정할 필요가 없다. 여기에 뭘 더 바랄 게 있을까?
인라인 함수에는 우리도 잘 모르는 이점이 하나 숨겨져 있다. 함수 호출 비용이 면제되는 것은 눈에 보이는 부분에 불과하다는 것이다. 대체적으로 컴파일러 최적화는 함수 호출이 없는 코드가 연속적으로 이어지는 구간에 적용되도록 설계되었기 때문에, 인라인 함수를 사용하면 컴파일러가 함수 본문에 대해 문맥별(comtext-specific) 최적화를 걸기가 용이해 진다. 실제로 대부분의 컴파일러는 ‘아웃라인’ 함수 호출에 대해 이런 최적화를 적용하지 않는다.(아웃라인 = 일반적인 함수 호출을 뜻한다)
그러나 공짜는 없듯이 프로그래밍에서도 인라인 함수 역시 예외는 아니다. 인라인 함수의 아이디어는 함수 호출문을 그 함수의 본문으로 바꿔치기하자는 것이어서, 목적 코드의 크기가 커질 게 뻔하다. 메모리가 제한된 컴퓨터에서 아무 생각 없이 인라인을 남발했다가는 프로그램 크기가 그 기계에서 쓸 수 있는 공간을 넘어버릴 수도 있다. 가상 메모리를 쓰는 환경일지라도 인라인 함수로 인해 부풀려진 코드는 성능의 걸림돌이 되기 쉽다. 페이징 횟수가 늘어나고, 명령어 캐시 적중률이 떨어질 가능성도 높아진다. 수행 성능은 이런 문제들과 사이좋게 얽히면서 타격을 입는 것이다.
반대의 경우도 있다. 본문의 길이가 굉장히 짧은 인라인 함수를 사용하면, 함수 본문에 대해 만들어지는 코드의 크기가 함수 호출문에 대해 만들어지는 코드보다 작아질 수도 있다. 바로 이런 경우에는 상황이 바뀐다. 목적 코드의 크기도 작아지며 명령어 캐시 적중률도 높아진다.
다들 알다시피 inline은 컴파일러에 대해 ‘요청‘을 하는 것이지, ‘명령’이 아니다. 이 요청은 inline을 붙이지 않아도 그냥 눈치껏 되는 경우도 있고 명시적으로 할 수도 있다. 우선 암시적인 방법부터 알아보자. 클래스 정의 안에 함수를 바로 정의해 넣으면 컴파일러는 그 함수를 인라인 함수 후보로 찍는다.
1
2
3
4
5
6
7
8
9
class Person
{
public:
...
int age() const { return theAge; }
...
private:
int theAge;
};
이런 함수는 대개 멤버 함수이지만, 항목 46을 보면 프렌드 함수도 클래스 내부에서 정의될 수 있다는 내용이 나오니 참고하자. 어쨌든 이런 경우에는 암시적으로 인라인 함수로 선언된다.
인라인 함수를 선언하는 명시적인 방법은 함수 정의 앞에 inline 키워드를 붙이는 것이다. 한 예로, 표준 라이브러리의 MAX 템플릿은 대개 다음과 같이 구현되어 있다.
1
2
3
4
5
template <typename T>
inline const T& std::max(const T& a, const T& b)
{
return a < b ? b : a;
}
max가 템플릿이라는 점 때문에 ‘인라인 함수와 템플릿은 대개 헤더 파일 안에 정의한다’라는 이야기가 생각나기도 한다. 이 점을 오해해서 함수 템플릿은 반드시 인라인 함수이어야 한다고 생각하면 안된다.
인라인 함수는 대체적으로 헤더 파일에 들어 있어야 하는 게 맞다. 왜냐하면 대부분의 빌드 환경에서 인라인을 컴파일 도중에 수행하기 때문이다. 인라인 함수 호출을 그 함수의 본문으로 바꿔치기하려면, 일단 컴파일러는 그 함수가 어떤 형태인지 알고 있어야 하기 때문이다. 템플릿 역시, 대체적으로 헤더 파일에 들어 있어야 맞다. 템플릿이 사용되는 부분에서 해당 템플릿을 인스턴스로 만들려면 그것이 어떻게 생겼는지 컴파일러가 알아야 하기 때문이다.
그런데 템플릿 인스턴스화는 인라인과 완전히 별개로, 하등의 관련이 없다. 어떤 템플릿을 만들고 있는데 이 템플릿으로부터 만들어지는 모든 함수가 인라인 함수였으면 싶은 경우에 그 템플릿에 inline을 붙여 선언하는 것이고, 그게 끝이다. 위에서 본 std::max가 바로 이 경우이다. 만들고 있는 함수 템플릿이 굳이 인라인될 이유가 없다면 그 템플릿을 인라인 함수로 선언하지 않아도 된다. 인라인은 분명 비용을 동반하는 동작이다. 아무렇지도 않게 일어나도록 내버려 둬도 괜찮은 성질의 것이 아니라는 것이다. 인라인이 끌고 오는 비용이 바로 코드 비대화라는 점은 이미 앞에서도 이야기한 바 있지만, 이것 말고도 물어야 하는 비용이 더 있으니 큰일 났다. 이에 대해서는 잠시 후에 이야기하겠다.
비용 문제도 문제이지만 하던 이야기를 마무리하자면 “inline은 컴파일러 선에서 무시할 수 있는 요청이다”라는 이야기를 계속 하고 있었다. 대부분의 컴파일러의 경우, 아무리 인라인 함수로 선언되어 있어도 자신이 보기에 복잡한 함수는 절대로 인라인 확장의 대상에 넣지 않는데다가 (루프가 들어 있다거나 재귀 함수인 경우가 이런 예이다), 정말 간단한 함수라 할지라도 가상 함수 호출 같은 것은 절대로 인라인해 주지 않는다. 사실 두 번째 부분은 그다지 놀랄 일도 아닌 것이, virtual의 의미가 “어떤 함수를 호출할지 결정하는 작업을 실행 중에 한다”라는 뜻이고 inline의 의미가 “함수 호출 위치에 호출된 함수를 끼워 넣는 작업을 프로그램 실행 전에 한다”라는 뜻이니 말이 안되는 것도 아니다. 주어진 시점에 호출할 함수가 무엇이 될지를 컴파일러도 알 수 없다면 어쩔 수 없는 것이다.
결론은, 인라인 함수가 실제로 인라인되느냐 안 되느냐의 여부는 전적으로 개발자가 사용하는 빌드 환경에 달렸다는 것이다. 그 중에서도 컴파일러가 중요하다. 한 가지 다행스러운 사실은, 우리가 요청한 함수에 대한 인라인이 실패했을 경우에 경고 메세지를 내주는 진단 수준 설정 기능이 대부분의 컴파일러에 들어 있다는 점이다.
완벽한 인라인 조건을 갖추었는데도 컴파일러가 인라인 함수의 본문에 대해 코드를 만드는 경우가 있다. 예를 들어 어떤 인라인 함수의 주소를 취하는 코드가 있으면, 컴파일러는 이 코드를 위해 아웃라인 함수 본문을 만들 수밖에 없을 것이다. 있지도 않은 함수에 대해 어떻게 포인터를 가지고 오겠는가? 게다가, 인라인 함수로 선언된 함수를 함수 포인터를 통해 호출하는 경우도 대개 인라인되지 않는다. 종합해 보면, 확실한 인라인 함수도 ‘어떻게 호출하느냐’에 따라 인라인되기도 하고 안 되기도 한다는 이야기이다.
1
2
3
4
5
6
7
8
9
10
11
12
// 이 f의 호출은 컴파일러가 반드시 인라인해 준다고 가정하자.
inline void f() { ... }
// pf는 f를 가리키는 함수 포인터이다.
void (*pf)() = f;
...
// 이 호출은 인라인될 것이다. "평범한" 함수 호출이다.
f();
// 이 호출은 인라인되지 않을 것이다. 함수 포인터를 통한 호출이기 때문이다.
pf();
인라인되지 않는 인라인 함수는 우리가 함수 포인터를 전혀 사용하지 않아도 우리 주변을 떠돌며 괴롭힐 수 있다. 프로그래머에게만 함수 포인터가 필요하다고 생각하면 안된다. 컴파일러가 인라인으로 선언된 생성자 및 소멸자에 대해 아웃라인 함수 본문을 만들 수도 있다. 어떤 배열의 원소가 객체일 경우가 대표적인 예인데, 배열을 구성하는 객체들을 생성하고 소멸시킬 때 생성자/소멸자의 함수 포인터를 얻어내려면 함수 본문이 반드시 필요하다.
이야기가 나왔으니 말인데, 생성자와 소멸자는 인라인하기에 그리 좋지 않은 함수이다. 대충 훑어보고 말할 수 있는 정도보다 사실 더 안 좋다. 예를 하나 들어 보자. 아래의 Derived 클래스에 들어 있는 생성자를 보고 가만히 생각해 보자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Base
{
public:
...
private:
std::string bm1, bm2; // Base 클래스의 멤버 bm1과 bm2
};
class Derived : public Base
{
public:
Derived() {} // Derived 생성자가 비어 있는데, 진짜 비어 있을까?
...
private:
std::string dm1, dm2, dm3; // Derived 클래스의 멤버 dm1, dm2, dm3
};
Derived의 생성자는 생김새부터 인라인하기에 딱 좋아 보이는 인상이다. 아무 코드도 안 들어 있으니까! 하지만 우리는 우리 눈에 속은 것인다.
C++는 객체가 생성되고 소멸될 때 일어나는 일들에 대해 여러 가지 보장을 준비해 놓았다. 예를 들자면, 우리가 new를 하면 이때 동적으로 만들어지는 객체를 생성자가 자동으로 초기화해 주는 것이 그렇고, delete를 하면 이에 대응되는 소멸자가 호출되는 것도 C++가 깔아둔 보장이다. 어떤 객체를 우리가 생성하면 그 객체의 기본 클래스 부분과 그 객체의 데이터 멤버들이 자동으로 생성되며, 그 객체가 소멸될 때 이에 반대되는 순서로 소멸 과정이 저절로 이루어지는 것도 마찬가지다. 또한 C++는 객체가 생성되는 도중에 예외가 던져지더라도, 이미 생성이 완료된 부분만큼은 우리의 손길 없이도 말끔히 소멸되도록 보장한다.
하지만 지금 말한 시나리오에서 잊으면 안 되는 부분이 있다. 바로 C++는 ‘무엇을’ 해야 하는지는 정해 두었지만 그것을 ‘어떻게’ 해야 하는지는 정하지 않았다는 점이다. 이 부분은 전적으로 컴파일러 구현자에게 달려 있지만, 어쨌든 이런 일들이 자기들 스스로 일어나지 않는다는 점만은 명확히 알아두어야 한다. 즉, 우리 눈에 보이지 않지만 이런 일을 가능하게 하는 어떤 코드가 우리의 프로그램에 포함되더야 하고, 이 코드(다시 말해 컴파일러가 만들어서 컴파일 도중에 우리의 소스 코드에 삽입하는 코드)가 소스 코드 어딘가에 들어가 있어야 한다는 결론이 나오는 것이다. 때에 따라서는 바로 그 장소가 생성자와 소멸자일 수도 있는 것이기도 하다. 그러니까, 비어 있다고 생각되던 Derived 생성자는 사실 어떤 구현환경에서는 다음과 같은 코드로 만들어질 것이라고 상상해 볼 수 있다.
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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// "비어 있는" Derived 생성자가 실제로 구현된다면 이런 모습일 수 있다.
Derived::Derived()
{
// Base 부분을 초기화
Base::Base();
try
{
// dm1의 생성을 시도
dm1.std::string::string();
}
catch (...)
{
// 생성 도중에 dm1에서 예외를 던지면, 기본 클래스 부분을 소멸시키고 그 예외를 전파
Base::~Base();
throw;
}
try
{
// dm2의 생성을 시도
dm2.std::string::string();
}
catch (...)
{
// 생성 도중에 dm2에서 예외를 던지면, dm1과 기본 클래스 부분을 소멸시키고 그 예외를 전파
dm1.std::string::~string();
Base::~Base();
throw;
}
try
{
// dm3의 생성을 시도
dm3.std::string::string();
}
catch (...)
{
// 생성 도중에 dm3에서 예외를 던지면, dm2와 dm1과 기본 클래스 부분을 소멸시키고 그 예외를 전파
dm2.std::string::~string();
dm1.std::string::~string();
Base::~Base();
throw;
}
}
솔직히 말해서 진짜 컴파일러가 실제로 만들어내는 코드도 이렇다고 보기는 힘들다. 진짜 컴파일러의 경우에는 예외 처리를 위처럼 무식하게 하지 않기 때문이다. 그렇다고는 해도 Derived의 텅 빈 생성자가 실제로 어떤 동작을 하는지에 대한 사항은 정확하게 나와 있으니 잘 봐 두도록 하자. 여기서 컴파일러가 어떤 세련된 방법으로 예외를 처리하도록 구현됐는지는 사실 중요하지 않다. 어쨌든 Derived 클래스의 생성자는 최소한 자신의 데이터 멤버와 기본 클래스 부분에 대해 생성자를 호출해 주어야 하고, 이들 생성자를 호출해야 하기 때문에 인라인이 사뭇 난감해지는 거다.
Base 생성자의 경우에 대해서도 똑같이 생각하면 된다. 그러니까 Base 생성자가 인라인되면, 이 함수에 삽입되어 있던 코드들도 전부 Derived 생성자에 끼어들어가야 한다. 그리고 string 생성자마저도 어쩌다가 인라인되면, Derived 생성자는 똑같은 함수 본문을 다섯 개나 갖게 되는 것이다. 바로, Derived 객체가 갖는 다섯 개의 string 데이터 멤버에 대한 생성자다. (이 중 두 개는 Base로부터 물려받은 것이고, 세 개는 Derived 안에서 선언한 것이다.) 이제 대충 감이 올 것이다. Derived 생성자를 인라인해야 하나 말아야 하나를 고민하는 것은 절대로 밥 먹고 할 일 없어서가 아니다. Derived 소멸자의 경우에도 사정은 비슷하다. 어떻게 하든 Derived 생성자가 초기화시킨 객체가 제대로 소멸되도록 만들 의무는 Derived 소멸자에게 있으니까이다.
라이브러리를 설계하는 사람이라면 함수를 인라인으로 선언할 때 그 영향에 대해 많은 고민을 해야 한다. 사용자의 눈에 뻔히 보이는 인라인 함수에 대해서는 라이브러리 차원에서 바이너리 업그레이드를 제공할 수 없기 때문이다. 어떤 라이브러리에 f라는 인라인 함수가 있고, 이 라이브러리를 쓰는 사용자가 f의 본문을 컴파일해서 응용프로그램을 만들었다고 가정해 보자. 나중에 이 라이브러리 개발자가 f의 내부를 바꾸겠다고 결정했다면, f를 썼던사용자들은 슬프지만 죄다 각자의 소스를 다시 컴파일해야 할 것이다. 반대로 f가 인라인 함수가 아닌 보통 함수라고 하면, f가 바뀌었을 때 사용자들은 링크만 다시 해 주면 끝난다. f를 제공하는 라이브러리가 동적 링크 방식을 취하고 있으면, 사용자 측에서는 ‘어, 그냥 바뀌었네?’라고 생각할 정도로 개발 작업이 완벽하게 투명해질 것이다.
그러고 보니 인라인 함수도 참 따질 게 많다. 프로그램을 개발하기 위해서는 이번에 공부한 것들을 전부 머리 속에서 따져 보고 가는 게 중요하겠지만, 실제로 코딩하는 사람의 입장에서 보면 한 가지 사실 때문에 다른 나머지가 별로 크게 다가오지 않는다. 그 사실이란, 대부분의 디버거가 무척이나 곤란해 하는 비호감 대상이 바로 인라인 함수라는 점이다. 있지도 않는 함수에 중단점을 걸 수 없지 않는가? 물론 어떤 빌드 환경은 인라인 함수의 디버깅을 어떻게든 지원해 주긴 하지만, 대다수의 환경에서는 디버그 빌드에 대해 인라인을 비활성화해 주는 정도만으로 황송할 따름이다.
사정이 이렇다 보니, 어떤 함수를 인라인으로 선언해야 하고 또 어떤 것을 선언하지 말아야 하는지에 대한 기본 전략이 턱 하니 나오게 된다. 우선, 아무것도 인라인하지 말자. 아니면 꼭 인라인해야 하는 함수(항목 46 참조) 혹은 정말 단순한 함수에 한해서만 인라인 함수로 선언하는 것으로 시작하자. 인라인을 주의해서 사용하는 버릇을 들여서, 디버깅하고 싶은 부분에서 우리의 디버거를 제대로 쓸 수 있게 만들고, 정말 필요한 위치에 인라인 함수를 놓도록 하자. 수동 최적화인 셈이다. 인라인이 좋다고 해서, 동서고금의 경험에서 얻어진 불멸의 법칙인 ‘80-20 법칙’까지 잊어버리면 안 된다. 정말 중요한 법칙이다. 이 법칙은 제품의 성능을 올릴 수 있는 20%의 코드를 찾아내어야 하는 소프트웨어 개발자인 우리의 전의를 불태워 주는 스팀팩이기 때문이다. 그리고 가장 중요한 것은 함수이다. 출시의 그 날이 오기 전에는 함수를 인라인하든 어떤 일도 할 수 있겠지만, 함수 자체가 똑바로 만들어져 있지 않으면 나중에 삽질했다는 말밖엔 나오지 않을 것이다.
함수 인라인은 작고, 자주 호출되는 함수에 대해서만 하는 것으로 묶어두자. 이렇게 하면 디버깅 및 라이브러리의 바이너리 업그레이드가 용이해지고, 자칫 생길 수 있는 코드 부풀림 현상이 최소화되며, 프로그램의 속력이 더 빨라질 수 있는 여지가 최고로 많아진다.
함수 템플릿이 대개 헤더 파일에 들어간다는 일반적인 부분만 생각해서 이들을 inline으로 선언하면 안 된다.
항목 31 : 파일 사이의 컴파일 의존성을 최대로 줄이자.
오랜만에 C++ 프로그램에서 클래스 하나를 살짝 손보았다고 해보자. 인터페이스도 아니고, 구현부에 있는 코드 몇 줄인데다가, 외부에 노출되지도 않는 부분이었다. 이제 수정을 마친 후에 프로그램을 다시 빌드하기로 한다. 기껏해야 몇 초 안 걸릴 것 같다. 어쨌든 클래스 하나만 바뀌었을 뿐이니까. 빌드 도구 버튼을 클릭하고 기다려 보는데, 느낌이 이상하다. 건들지도 않은 다른 동네들까지 몽땅 다시 컴파일되고 다시 링크가 되는 것이다. 우리는 이런 일이 생길 때 짜증만 내고 끝내면 안된다.
문제의 핵심은 C++가 인터페이스와 구현을 깔끔하게 분리하는 일에 별로 일가견이 없다는데 있다. C++의 클래스 정의는 클래스 인터페이스만을 지정하는 것이 아니라 구현 세부사항까지 상당히 많이 지정하고 있다. 이런 예는 어지간한 경우가 아니면 정말 쉽게 접할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Person
{
public:
Person(const std::string& name, const Date& birthday, const Address& addr);
std::string name() const;
std::string birthDate() const;
std::string address() const;
...
private:
std::string theName; // 구현 세부사항
Date theBirthDate; // 구현 세부사항
Address theAddress; // 구현 세부사항
};
위의 코드만 가지고 Person 클래스가 컴파일될 수는 없다. Person의 구현 세부사항에 속하는 것들, 다시 말해 string, Date, Address가 어떻게 정의됐는지를 모르면 컴파일 자체가 불가능하다. 결국 이들이 정의된 정보를 가져 와야 하고, 이때 쓰는 것이 #include 지시자이다. 따라서 Person 클래스에 정의하는 파일을 보면 대개 아래와 비슷한 코드를 발견하게 되는 것이다.
1
2
3
#include <string>
#include "date.h"
#include "address.h"
유감스럽지만 이 녀석들이 바로 이 골칫덩이이다. 위의 #include 문은 Person을 정의한 파일과 위의 헤더 파일들 사이에 컴파일 의존성(compilation dependency)이란 것을 엮어 버린다. 그러면 위의 헤더 파일 셋 중 하나라도 바뀌는 것은 물론이고 이들과 또 엮여 있는 헤더 파일들이 바뀌기만 해도, Person 클래스를 정의한 파일들은 코 꿰이듯 컴파일러에게 끌려가야 한다. 심지어 Person을 사용하는 다른 파일들까지 몽땅 다시 컴파일되어야 한다. 꼬리에 꼬리를 무는 이런 식의 컴파일 의존성이 있으면 프로젝트가 말도 못 할 정도로 고통스러워진다.
어째서 C++는 구현 세부사항이 클래스 정의문 안에 들어가는 것을 내버려 두어 왔는지, 심히 불만스러울 수도 있다. 그러니까, Person 클래스를 정의할 때 구현 세부사항을 따로 떼어서 지정하는 식으로 하면 안 되냐는 것이다. 아래처럼 말이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
namespace std
{
class string;
}
class Date;
class Address;
class Person
{
public:
Person(const std::string& name, const Date& birthday, const Address& addr);
std::string name() const;
std::string birthDate() const;
std::string address() const;
...
};
이런 코드가 된다면 Person 사용자는 Person 클래스의 인터페이스가 바뀌었을 때만 컴파일을 다시 하면 되니 얼마나 좋을까. 희망사항이란 점이 슬플 따름이다.
아이디어만 놓고 보면 기특하기 이를 데 없다만 문제가 두 가지나 있다. 첫 번째, string은 사실 클래스가 아니라 typedef로 정의한 타입동의어이다(basic_string
#include로 헤더만 잘 포함시켜 놓으면 그걸로 끝이지 않는가. 표준 라이브러리 헤더는 어지간한 경우만 아니면 컴파일 시 병목요인이 되진 않는다. 특히 우리가 쓰는 빌드 환경이 사전 컴파일 헤더(precompiled header)를 쓸 수 있는 환경이면 더 그렇다. 표준 헤더를 구문분석하는 단계가 진짜로 문제가 되면, 인터페이스 설계를 고치는 방법밖에는 없다. 표준 라이브러리의 구성요소 중에 원치 않는 #include가 생기게 하는 것들을 사용하지 않게끔 우리가 직접 손을 봐야 한다는 거다.
필요한 요소들을 모두 전방 선언할 때의 두 번째 문제는(첫 번째 것보다 훨씬 중요하다.), 컴파일러가 컴파일 도중에 객체들의 크기를 전부 알아야 한다는 데 있다. 아래의 예제를 가지고 설명해보자.
1
2
3
4
5
6
int main()
{
int x;
Person p(params);
...
}
컴파일러는 x의 정의문을 만나면 일단 int 하나를 담을 충분한 공간을 할당해야(대게 스택에) 한다는 것을 알고 있다. 컴파일러라면 int의 크기가 얼마나 되는지 모르진 않을 테니까 여기까지는 아무 문제가 없다. 하지만 컴파일러가 p의 정의문을 만나면 이야기가 달라진다. 역시 Person 하나를 담을 공간을 할당해야 한다는 것은 알고 있지만, 대체 Person 객체 하나의 크기가 얼마인지를 컴파일러가 알아야 한다는 것이다. 이 Person 클래스가 정의된 정볼르 보는 수밖에 방법이 없다. 그런데 만약 클래스 정의에서 구현 세부사항을 빠뜨려도 적법하다고 C++ 명세서에 적혀 있었다면, 컴파일러는 자신이 할당할 공간을 정확히 파악할 수 없을 것이다.
스몰토크 및 자바의 경우로 눈을 돌려보면 지금의 고민은 고민거리조차 안 된다. 이들 언어에서는 객체가 정의될 때 컴파일러가 그 객체의 포인터를 담을 공간만 할당한다. 다시 말하자면 위의 코드는 아래와 같이 쓰인 것으로 보인다는 것이다.
1
2
3
4
5
6
int main()
{
int x;
Person* p;
...
}
물론 이 코드도 적법한 C++ 코드이다. 제대로 돌아간다는 이야기다. 그러니까 ‘포인터 뒤에 실제 객체 구현부 숨기기’ 놀이를 직접 C++를 가지고도 할 수 있다. 말이 나온 김에 Person 클래스에 대해 이렇게 하는 방법 한 가지를 알아보도록 하자. 우선 주어진 클래스를 두 클래스로 쪼개자. 한쪽은 인터페이스만 제공하고, 또 한쪽은 그 인터페이스의 구현을 맡도록 만드는 것이다. 구현을 맡은 클래스의 이름이 PersonImpl이라고 하면, Person 클래스는 다음과 같이 정의할 수 있을 것이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <string> // 표준 라이브러리 구성요소는 전방 선언을 하면 안 된다.
#include <memory> // shared_ptr을 위한 헤더
class PersonImpl; // Person의 구현 클래스에 대한 전방 선언
class Date; // Person 클래스 안에서 사용하는 것들에 대한 전방 선언
class Address;
class Person
{
public:
Person(const std::string& name, const Date& birthday, const Address& addr);
std::string name() const;
std::string birthDate() const;
std::string address() const;
...
private:
std::shared_ptr<PersonImpl> pImpl; // PersonImpl 객체에 대한 스마트 포인터
};
위의 코드를 보면 주 클래스(Person)에 들어 있는 데이터 멤버라고는 구현 클래스(Person-Impl)에 대한 포인터이다. 이런 설계는 거의 패턴으로 굳어져 있을 정도여서 pimpl 관용구 (pointer to implementation)라는 이름도 있는데, 이때 포인터의 이름은 대개 pImpl이라고 붙이는 것이 일반적이다.
어쨌든 이렇게 설계해 두면, Person의 사용자는 생일, 주소, 이름 등의 자질구레한 세부사항과 완전히 갈라서게 된다. Person 클래스에 대한 구현 클래스 부분은 생각만 있으면 마음대로 고칠 수 있지만, 그래도 Person의 사용자 쪽에서는 컴파일을 다시 할 필요가 없다. 게다가 Person이 어떻게 구현되어 있는지를 들여다볼 수 없기 때문에, 구현 세부사항에 어떻게든 직접 발을 걸치는 코드를 작성할 여지가 사라진다. 그야말로 인터페이스와 구현이 뼈와 살이 분리되듯 떨어지는 것이다.
이렇게 인터페이스와 구현을 둘로 나누는 열쇠는 ‘정의부에 대한 의존성(dependencies on definitions)’을 ‘선언부에 대한 의존성(dependencies on declarations)’으로 바꾸어 놓는 데 있다. 이게 바로 컴파일 의존성을 최소화하는 핵심 원리이다. 즉, 헤더 파일을 만들 때는 실용적으로 의미를 갖는 한 자체조달(self-sufficient) 형태로 만들고, 정 안 되면 다른 파일에 대해 의존성을 갖도록 하되 정의부가 아닌 선언부에 대해 의존성을 갖도록 만드는 것이다. 매우 간결한 전략이지만 이 외의 나머지 전략들은 이것을 축으로 해서 흘러가게 되어 있다. 자, 그럼 정리해보자.
- 객체 참조자 및 포인터로 충분한 경우에는 객체를 직접 쓰지 않는다.
어떤 타입에 대한 참조자 및 포인터를 정의할 땐느 그 타입의 선언부만 필요하다. 반면, 어떤 타입의 객체를 정의할 때는 그 타입의 정의가 준비되어 있어야 한다.
- 할 수 있으면 클래스 정의 대신 클래스 선언에 최대한 의존하도록 만든다.
어떤 클래스를 사용하는 함수를 선언할 때는 그 클래스의 정의를 가져오지 않아도 된다. 심지어 그 클래스 객체를 값으로 전달하거나 반환하더라도 클래스 정의가 필요 없다.
1
2
3
4
5
6
// 클래스 선언
class Date;
// 좋다. Date 클래스의 정의를 가져오지 않아도 된다.
Date today();
void clearAppointments(Date d);
물론 ‘값에 의한 전달’ 방식은 좋은 방식이라고 보긴 힘들지만, 피치 못할 사정 때문에 써야 할 경우도 있을 텐데, 이런 경우에도 불필요한 컴파일 의존성을 끌고 들어오는 게 아무런 변명거리도 되지 못한다는 것이다.
Date를 정의하지 않고도 today와 clearAppointments 함수를 선언할 수 있다는 데 놀라는 사람들도 있겠지만, 보기만큼 이상한 것도 아니다. 누군가가 이들 함수를 호출한다면 호출하기 전에 Date의 정의가 파악되어야 한다. 그렇다면, 어째서 아무도 호출할 것 같지 않은 함수를 이렇게까지 애써서 선언하려는 걸까? 호출하는 사람이 아무도 없어서가 아니라, 호출하는 사람이 모두가 아니기 때문이다. 수십 개의 함수를 선언해 둔 우리의 라이브러리를 쓰는 다른 사용자들이 전부 그 라이브러리의 함수들을 빠짐없이 불러 주리라 생각하진 않을 것이다. 제품을 만들려면 클래스 정의를 제공하는 일을 어딘가에서 해야 하겠지만, 함수 선언이 되어 있는 우리의 헤더 파일 쪽에서 그 부담을 주지 않고 실제 함수 호출이 일어나는 사용자의 소스 파일 쪽에 전가하는 방법을 사용한 것이다. 이렇게 하면 실제로 쓰지도 않을 타입 정의에 대해 사용자가 의존성을 끌어오는 거추장스러움을 막을 수 있다.
- 선언부와 정의부에 대해 별도의 헤더 파일을 제공한다
“클래스를 둘로 쪼개자”라는 지침을 제대로 쓸 수 있도록 하려면 헤더 파일이 짝으로 있어야 한다. 하나는 선언부를 위한 헤더 파일이고, 또 하나는 정의부를 위한 헤더 파일일 것이다. 당연한 이야기이겠지만 이 두 파일은 관리도 짝 단위로 해야 한다. 한쪽에서 어떤 선언이 바뀌면 다른 쪽도 똑같이 바꾸어야 한다는 것이다. 그렇기 떄문에, 라이브러리 사용자 쪽에서는 전방 선언 대신에 선언부 헤더 파일을 항상 #include해야 할 것이고, 라이브러리 제작자 쪽에서는 헤더 파일 두 개를 짝지어 제공하는 일을 잊으면 안 된다. 그러니까 예를 들어, Date의 사용자가 today 함수와 clearAppointments 함수를 선언하고 싶다고 해서 위의 코드에 나온대로 Date를 직접 전방 선언하면 난감해진다는 뜻이다. 그렇제 하지 말고, Date 클래스에 대한 선언부 헤더를 #include해야 한다.
아래를 보자.
1
2
3
4
#include "datefwd.h" // Date 클래스를 선언하고 있는(그러나 정의하진 않는) 헤더 파일
Date today();
void clearAppointments(Date d);
선언부만 들어 있는 헤더 파일의 이름이 “datefwd.h”이다. 사실 이 이름은 표준 C++ 라이브러리의
> 라이브러리 헤더는 그 자체로 모든 것을 갖추어야 하며 선언부만 갖고 있는 형태여야 한다. 이 규칙은 템플릿이 쓰이거나 쓰이지 않거나 동일하게 적용하자.
### 추가로 1. export 키워드는 C++11에서 삭제되었다. 2. 책의 예제에는 없지만, 핸들 클래스를 만들 때 make_shared 혹은 make_unique 함수를 이용하자. pimpl 관용규는 다른 곳과 포인터를 공유할 이유가 없기에 make_unique이 더 적절하다. 3. 인터페이스 클래스를 설명할 때 자식(구체) 클래스가 인터페이스 클래스를 상속 받을 때, override 키워드를 붙히자.
_참고_ - [Effective C++ 제3판](https://www.yes24.com/product/goods/17525589)