Post

[Effective C++] 7. 템플릿과 일반화 프로그래밍 [2/4]

이 글은 제 개인적인 공부를 위해 작성한 글입니다. 틀린 내용이 있을 수 있고, 피드백은 환영합니다.

[Effective C++] 7. 템플릿과 일반화 프로그래밍 [2/4]

항목 43 : 템플릿으로 만들어진 기본 클래스 안의 이름에 접근하는 방법을 알아 두자


정확한 이유는 알 수 없지만 어쨌든 우리는 지금 다른 몇 개의 회사에 메세지를 전송할 수 있는 응용프로그램을 만들어내야 한다. 전송용 메세지는 암호화될 수도 있고 비가공텍스트(비암호화) 형태가 될 수도 있다. 만약 어떤 메세지를 어떤 회사로 전송될지를 컴파일 도중에 결정할 수 있는 충분한 정보가 있다면, 주저 없이 템플릿 기반의 방법을 쓸 수 있을 것이다. 다음처럼 말이다.

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
class CompanyA
{
public:
    ...
    void sendCleartext(const std::string& msg);
    void sendEncrypted(const std::string& msg);
    ...
};

class CompanyB
{
public:
    ...
    void sendCleartext(const std::string& msg);
    void sendEncrypted(const std::string& msg);
    ...
};

...

class MsgInfo { ... };

template <typename Company>
class MsgSender
{
public:
    ...
    void sendClear(const MsgInfo& info)
    {
        std::string msg;
        // info로부터 msg를 만들어 낸다.
        
        Company c;
        c.sendCleartext(msg);
    }
    
    void sendSecure(const MsgInfo& info) // sendClear와 비슷. 단, c.sendEncrypted 함수를 호출
    { ... }
};

여기까지만 하면 일단 잘 돌아갈 것이다. 그런데 이에 덧붙여서 메세지를 보낼 때마다 관련 정보를 로그로 남기고 싶은 사람도 있을 것이다. 파생 클래스를 사용하면 이 기능을 쉽게 붙일 수 있고, 그렇게 해 주는게 맞을 것 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
template <typename Company>
class LoggingMsgSender : public MsgSender<Company>
{
public:
    ...
    void sendClearMsg(const MsgInfo& info)
    {
        // 메세지 전송 전 정보를 로그에 기록
        sendClear(info); // 기본 클래스의 함수를 호출하는데, 이 코드는 컴파일되지 않는다.
        // 메세지 전송 후 정보를 로그에 기록
    }
    ...
};

조금만 신경 써서 코드를 보면 파생 클래스에 있는 메세지 전송 함수의 이름이 기본 클래스에 있는 것과 다르다는 점이 눈에 띈다. 꼼꼼하게 잘 된 설계이다. 기본 클래스로부터 물려받은 이름을 파생 클래스에서 가리는 문제는 물론이고 상속받은 비가상 함수를 재정의하는 문제를 일으키지 않도록 한 것이다. 그것까진 좋은데 이 코드는 컴파일되지 않는다. 적어도 표준을 따라는 컴파일러를 쓴다면 컴파일이 안 될 것이다. ‘sendClear 함수가 존재하지 않는다’라는 것이 컴파일이 안 되는 이유이다. 우리 눈에는 기본 클래스에 들어 있는 sendClear 함수가 보이는데도, 컴파일러는 기본 클래스를 들여다보려고 하지 않는 것이다. 어째서일까?

문제는 간단하다. 컴파일러가 LoggingMsgSender 클래스 템플릿의 정의와 마주칠 때, 컴파일러는 대체 이 클래스가 어디서 파생된 것인지를 모른다는 것이다. MsgSender인 것은 분명 맞다. 하지만 Company는 템플릿 매개변수이고, 이 템플릿 매개변수는 나중(LoggingMsgSender가 인스턴스로 만들어질 때)까지 무엇이 될지 알 수 없다. Company가 정확히 무엇인지 모르는 상황에서는 MsgSender 클래스가 어떤 형태인지 알 방법이 있을 리가 없을 것이다. 이러니, sendClear 함수가 들어 있는지 없는지 알아낼 방법이 없는 것도 당연하다.

문제를 구체적으로 알고 싶은 사람을 위해, 가정을 하나 더 하자. CompanyZ라는 클래스가 있고, 이 클래스는 암호화된 통신만을 사용해야 한다.

1
2
3
4
5
6
7
class CompanyZ
{
public:
    ...
    void sendEncrypted(const std::string& msg);
    ...
};

조금 전에 본 일반형 MsgSender 템플릿은 그대로 CompanyZ 클래스에 쓰기엔 좀 그렇다. 이 템플릿은 CompanyZ 객체의 설계 철학과 맞지 않는 sendClear 함수를 제공하기 때문이다. 이 부분을 바로 잡기 위해, CompanyZ를 위한 MsgSender의 특수화 버전을 만들 수 있다.

1
2
3
4
5
6
7
8
template<>
class MsgSender<CompanyZ>
{
public:
    ...
    void sendSecret(const MsgInfo& info)
    { ... }
};

여기서 클래스 정의 앞머리에 삐죽 나와 있는 “template<>” 구문을 잘 보자. 괄호 안에 아무것도 없는 template의 뜻은 ‘이건 템플릿도 아니고 클래스도 아니다’라는 것이다. 정확히 말하면, 위의 코드는 MsgSender 템플릿을 템플릿 매개변수가 CompanyZ 일 때 쓸 수 있도록 특수화한 버전이다. 특히 지금 우리가 보는 특수화는 완전 템플릿 특수화(total template specialization)라고 한다. MsgSender 템플릿이 CompanyZ 타입에 대해 특수화되었고, 이때 이 템플릿의 매개변수가 하나도 빠짐없이(완전히) 구체적인 타입으로 정해진 상태라는 뜻이다. 그러니까, 일단 타입 매개변수가 CompanyZ로 정의된 이상 이 특수화된 템플릿의 매개변수로는 다른 것이 올 수 없게 된다는 이야기이다.

이제 MsgSender 템플릿이 CompanyZ에 대해 특수화된 상태라고 가정하고, 앞에 나왔던 파생 클래스인 LoggingMsgSender로 다시 돌아와 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
template<typename Company>
class LoggingMsgSender : public MsgSender<Company>
{
public:
    ...
    void sendClearMsg(const MsgInfo& info)
    {
        // 메세지 전송 전 정보를 로그에 기록
        sendClear(info); // 만약 Company == CompanyZ라면 이 함수는 있을 수조차 없다!
        // 메세지 전송 후 정보를 로그에 기록
    }
    ...
};

주석문에도 나와 있듯이, 기본 클래스가 MsgSender이면 이 코드는 말이 되지 않는다. MsgSender 클래스에는 sendClear 함수가 없으니까 말이다. 바로 이런 일이 생길 수 있기 때문에 위와 같은 함수 호출을 C++가 받아주지 않는 것이다. 기본 클래스 템플릿은 언제라도 특수화될 수 있고, 이런 특수화 버전에서 제공하는 인터페이스가 원래의 일반형 템플릿과 꼭 같으리란 법은 없다는 점을 C++가 인식한다는 이야기이다. 이렇게 때문에, C++ 컴파일러는 템플릿으로 만들어진 기본 클래스를 뒤져서 상속된 이름을 찾는 것을 거부한다. 어떤 의미로 보면, C++의 하위 언어들 중 한 부분인 객체 지향 C++에서 템플릿 C++로 옮겨 갈 때 상속 매커니즘이 끊기는 것이다.

이 문제를 해결하기 위해서는 어떻게든 C++의 “난 템플릿화된 기본 클래스는 멋대로 안 뒤질 거야” 동작이 발현되지 않도록 해야 한다. 방법이 세 가지나 있다. 첫째, 기본 클래스 함수에 대한 호출문 앞에 “this->”를 붙인다.

1
2
3
4
5
6
7
8
9
10
11
12
13
template<typename Company>
class LoggingMsgSender : public MsgSender<Company>
{
public:
    ...
    void sendClearMsg(const MsgInfo& info)
    {
        // 메세지 전송 전 정보를 로그에 기록
        this->sendClear(info); // sendClear가 상속되는 것으로 가정한다.
        // 메세지 전송 후 정보를 로그에 기록
    }
    ...
};

둘째, using 선언을 사용한다. 아마 항목 33을 읽었다면 ‘앗 이거?!’ 할지도 모른다. 항목 33에는 가려진 기본 클래스의 이름을 파생 클래스의 유효범위에 끌어오는 영도로 using 선언을 사용하는 방법이 나와 있다. 어쨌든 sendClearMsg 함수는 다음과 같이 작성할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template<typename Company>
class LoggingMsgSender : public MsgSender<Company>
{
public:
    using MsgSender<Company>::sendClear; // 컴파일러에게 sendClear 함수가 기본 클래스에 있다고 가정하고 알려준다.
    ...
    
    void sendClearMsg(const MsgInfo& info)
    {
        // 메세지 전송 전 정보를 로그에 기록
        sendClear(info); // sendClear가 상속되는 것으로 가정한다.
        // 메세지 전송 후 정보를 로그에 기록
    }
    ...
};

지금의 경우와 항목 33에서 using 선언으로 해결한 것 자체는 똑같지만, 해결한 문제는 다르다. 이번 항목의 문제는 기본 클래스의 이름이 파생 클래스에서 가려지는 것이 아니라, 템플릿화된 기본 클래스의 유효범위를 뒤지라고 우리가 컴파일러에게 알려 주지 않으면 컴파일러가 알아서 찾는 일이 없다는 것이다.

마지막 방법은 호출할 함수가 기본 클래스의 함수라는 점을 명시적으로 지정하는 것이다.

1
2
3
4
5
6
7
8
template<typename Company>
class LoggingMsgSender : public MsgSender<Company>
{
public:
    ...
    MsgSender<Company>::sendClear(info); // sendClear가 상속되는 것으로 가정한다.
    ...
};

이 마지막 방법은 문제 해결에 있어서 그리 추천하고 싶진 않다. 호출되는 함수가 가상 함수인 경우에는, 이런 식으로 명시적 한정을 해 버리면 가상 함수 바인딩이 무시되기 때문이다.

이름에 대한 가시성을 조작한다는 면에서 보면 방금 말한 세 가지 방법은 모두 동작 원리가 같다. 기본 클래스 템플릿이 이후에 어떻게 특수화되더라도 원래의 일반형 템플릿에서 제공하는 인터페이스를 그대로 제공할 것이라고 컴파일러에게 약속을 하는 것이다. 이런 약속은 LoggingMsgSender 등의 파생 클래스 템플릿을 컴파일러가 구문분석하는 데 반드시 필요하지만, 그 약속이 거짓이라는게 밝혀지면 컴파일이 되지 않는다. 예를 들어, 이후 소스 코드가 다음과 같이 되어 있으면

1
2
3
4
5
6
LoggingMsgSender<CompanyZ> zMsgSender;
MsgInfo msgData;

... // msgData에 정보를 채운다.

zMsgSender.sendClearMsg(msgData); // 에러! 컴파일되지 않는다.

sendClearMsg 호출문은 컴파일이 되지 않는다. 지금 이 부분에서는 기본 클래스가 MsgSender(템플릿 특수화 버전)라는 사실을 컴파일러가 알고 있는데다가, sendClearMsg 함수가 호출하려고 하는 sendClear 함수는 MsgSender 클래스에 안 들어 있다는 사실도 컴파일러가 알아챈 후이기 때문이다.

본질적인 논점을 추려보자. 기본 클래스의 멤버에 대한 참조가 무효한지를 컴파일러가 진단하는 과정이 파생 클래스 템플릿의 정의가 구문분석될 때 미리 들어가느냐, 아니면 파생 클래스 템플릿이 특정한 템플릿 매개변수를 받아 인스턴스화될 때 나중에 들어가느냐가 바로 이번 항목의 핵심이다. 여기서 C++는 이른바 ‘이른 진단(early diagnosis)’을 선호하는 정책으로 결정한 것이다. 파생 클래스가 템플릿으로부터 인스턴스화될 때 컴파일러가 기본 클래스의 내용에 대해 아무것도 모르는 것으로 가정하는 이유도 이제 이해할 수 있을 것이다.

파생 클래스 템플릿에서 기본 클래스 템플릿의 이름을 참조할 때는, “this->”를 접두사로 붙이거나 기본 클래스 한정문을 명시적으로 써 주는 것으로 해결하자.


항목 44 : 매개변수에 독립적인 코드는 템플릿으로부터 분리시키자.


템플릿은 코딩 시간 절약, 코드 중복 회피의 두 마리 토끼를 한꺼번에 잡아 주는 참으로 기막힌 물건이다. 이를테면 멤버 함수가 15개나 되면서 생김새나 하는 일 모두 비슷비슷한 클래스 20개를 하나씩 손으로 타이핑해야 하는 끔찍한 상상은 이제 안녕이다. 클래스 템플릿 하나 써 놓고 나머지는 컴파일러에게 맡기면 우리에게 필요한 클래스 20개와 함수 300개가 인스턴스화되니까 말이다 (정확히 말하면, 클래스 템플릿의 멤버 함수는 이들이 실제로 사용될 때만 암시적으로 인스턴스화되기 때문에, 멤버 함수 300개를 손에 넣는다는 건 모든 함수가 진짜로 쓰여야만 가능한 일이다). 함수 템플릿은 또 어떠한가. 많고 많은 비슷한 함수를 다 작성할 필요 없이, 함수 템플릿 하나만 만들어 놓으면 이후는 컴파일러가 알아서 하는 것이다. 정말이지 좋은 기술이다.

하지만 아무 생각 없이 템플릿을 사용하면 템플릿의 적, 코드 비대화(code bloat)가 초래될 수 있다. 똑같거나 거의 똑같은 내용의 코드와 데이터가 여러 벌로 중복되어 이진 파일로 구워진다는 뜻이다. 소스 코드만 보면 단정하고 깔끔해 보이겠지만, 목적 코드의 모양새는 뚱뚱해질 수 있다. 그렇기 때문에 우리는 이진 코드가 템플릿으로 인해 불어 터지는 불상사를 미연에 방지할 방법을 알아두어야 한다.

우선적으로 써 볼 수 있는 방법이라면 공통성 및 가변성 분석(commonality and variability analysis)이 있다. 이름이 부담스러워 보이지만 전혀 그렇지 않다. 템플릿을 전혀 쓰지 않았던 우리의 지난 인생을 되돌아보면, 거창한 이름만 안 붙었을 뿐, 이미 우리는 이런 분석을 항상 해 왔다.

우리가 어떤 함수를 만들고 있다가 무심코 다른 함수를 봤는데, 지금 만들고 있는 함수의 구현 중 일부가 다른 함수의 구현에도 똑같이 있더라는 사실을 알아 챘다고 가정해 보자. 우리는 지금의 코드를 복제한 것이 당연히 아닐 것이다. 이제 우리는 두 함수로부터 공통 코드를 뽑아내고, 이것을 별도의 새로운 함수에 넣은 후, 이 함수를 기존의 두 함수가 호출하도록 코드를 수정할 것이다. 이 이야기를 ‘공통성 및 가변성 분석’에 빗대어 다시 구성하면, 우리는 두 함수를 분석해서 공통적인 부분과 다른 부분을 찾은 후에 공통 부분은 새로운 함수에 옮기고 다른 부분은 원래의 함수에 남겨둔 것이다. 클래스의 경우도 비슷하다. 지금 만들고 있는 클래스의 어떤 부분이 다른 클래스의 어떤 부분과 똑같다는 사실을 발견한다면, 그 공통 부분을 양쪽에 두지 않는 것이 맞는 코딩일 것이다. 즉, 공통 부분을 별도의 새로운 클래스로 옮긴 후, 클래스 상속 혹은 객체 합성을 사용해서 원래의 클래스들이 공통 부분을 공유하도록 한다. 원래의 두 클래스가 제각기 갖고 있는 다른 부분(고유 부분)은 원래의 위치에 남아 있게 된다.

템플릿을 작성할 경우에도 똑같은 분석을 하고 똑같은 방법으로 코드 중복을 막으면 되지만, 우리의 뒤통수를 노리는 뜻밖의 전개가 하나 있다. 템플릿이 아닌 코드에서는 코드 중복이 명시적이다. 두 함수 혹은 두 클래스 사이에 똑같은 부분이 있으면 눈으로 찾아낼 수 있다는 거다. 반면, 템플릿 코드에서는 코드 중복이 암시적이다. 소스 코드에는 템플릿이 하나밖에 없기 때문에, 어떤 템플릿이 여러 번 인스턴스화될 때 발생할 수 있는 코드 중복을 우리의 감각으로 알아채야 한다는 거다. 따라서 피나는 수련은 필수이다.

설명이 너무 길었지만, 이제 예제이다. 고정 크기의 정방행렬을 나타내는 클래스 템플릿을 하나 만들고 싶다. 다른 기늗들도 있긴 하지만 특히 이 클래스 템플릿은 역행렬 만들기 연산을 지원한다.

1
2
3
4
5
6
7
8
// T 타입의 객체를 원소로 하는 n행 n열의 행렬을 나타내는 템플릿
template <typename T, std::size_t n>
class SquareMatrix
{
public:
    ...
    void invert(); // 주어진 행렬을 그 저장공간에서 역행렬로 만든다.
};

이 템플릿은 T라는 타입 매개변수도 받지만, size_t라는 타입의 비타입 매개변수(non-type parameter)인 n도 받도록 되어 있다. 비타입 매개변수는 타입 매개변수보다는 덜 흔하지만 C++에서 적법하게 인정되는 매개변수이다. 그리고 이 예제에서도 보았듯이 무척 자연스럽게 쓸 수 있다.

자, 이제는 다음의 코드를 보자.

1
2
3
4
5
6
7
SquareMatrix<double, 5> sm1;
...
sm1.invert();

SquareMatrix<double, 10> sm2;
...
sm2.invert();

이때 invert의 사본이 인스턴스화되는데, 만들어지는 사본의 개수가 두 개이다. 이 둘은 같은 함수일 수가 없다. 그도 그럴 것이, 한쪽은 5x5 행렬에 대해 동작할 함수이고, 다른 쪽은 10x10 행렬에 대해 동작할 함수이기 때문이다. 그렇지만 행과 열의 크기를 나타내는 상수만 빼면 두 함수는 완전히 똑같다. 이런 현상이 바로 템플릿을 포함한 프로그램이 코드 비대화를 일으키는 일반적인 형태 중 하나라고들 한다.

사용하는 값이 5와 10인 것만 다르고 나머지는 한 글자 한 글자까지 빼다 박은 듯이 똑같은 두 함수가 눈에 띈다면 우리는 어떻게 하겠는가? 그 값을 매개변수로 받는 별도의 함수를 만들고, 그 함수에 5와 10을 매개변수로 넘겨서 호출하게 만들려는 자세가 진정한 개발자의 혼일테다. 그 혼이 바로 정답이다! SquareMatrix 클래스에도 이것이 가능한데, 일단 다음과 같이 만든다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
template <typename T>
class SquareMatrixBase
{
protected:
    ...
    void invert(std::size_t matrixsize);
    ...
};

template <typename T, std::size_t n>
class SquareMatrix : private SquareMatrixBase<T>
{
private:
    using SquareMatrixBase<T>::invert; // 기본 클래스의 invert가 가려지는 것을 막기 위한 문장이다. 항목 33을 보자.
    
public:
    ...
    void invert() { this->invert(n); } // invert의 기본 클래스 버전에 대해 인라인 호출을 수행한다.
    ...
};

행렬의 크기를 매개변수로 받도록 바뀐 invert 함수가 기본 클래스인 SquareMatrixBase에 들어 있는 것이 우리 눈에도 보일 것이다. SquareMatrixBase가 템플릿인 것은 SquareMatrix와 마찬가지이지만, 행렬의 원소가 갖는 타입에 대해서만 템플릿화되어 있을 뿐이고 행렬의 크기는 매개변수로 받지 않는다는 것은 SquareMatrix와 다르다. 따라서 같은 타입의 객체를 원소로 갖는 모든 정방행렬은 오직 한 가지의 SquareMatrixBase 클래스를 공유하게 되는 것이다. 다시 말해, 같은 원소 타입의 정방행렬이 사용하는 기본 클래스 버전의 invert 함수도 오직 한 개의 사본이란 말이다.

SquareMatrixBase::invert 함수는 파생 클래스에서 코드 복제를 피할 목적으로만 마련한 장치이기 때문에, public 멤버가 아니라 protected 멤버로 되어 있다는 점도 일러둔다. 참고로, 이 함수의 호출에 드는 추가 비용은 하나도 없어야 한다. 기본 클래스의 invert 함수를 호출하도록 구현된 파생 클래스의 invert 함수가 바로 인라인 함수이기 때문이다(이 경우엔 암시적 인라인). 또 이 함수의 본문을 보면 “this->” 표기가 붙어 있다. 항목 43에서 이야기한, 템플릿화된 기본 클래스의 멤버 함수 이름이 파생 클래스에서 가려지는 문제를 피해 가기 위하 ㄴ것인데, 이미 그런 역할을 해 주는 using 선언이 위쪽에 있으므로 불필요한 부분이기도 하다. 참, SquareMatrix와 SquareMatrixBase 사이의 상속 관계가 private인 점도 놓치지 말자. 기본 클래스를 사용한 데는 순전히 파생 클래스의 구현을 돕기 위한 것 외엔 아무 이유도 없다는 사실을 콕 집어서 드러내는 부분이 private 키워드이니까이다. SquareMatrixBase와 SquareMatrix의 어떤 개념적인 is-a 관계를 나타내려 했다면 private 상속을 쓰지 않았을 것이다.

아직 해결하지 못한 문제가 하나 남았다. SquareMatrixBase::invert 함수는 자신이 상대할 데이터가 어떤 것인지를 어떻게 알 수 있을까? 정방행렬의 크기야 뭐 매개변수로 받으니 쉽게 알 수 있지만, 진짜 행렬을 저장한 데이터가 어디에 있는지는 어떻게 아느냔 말이다. 지금 이 정보를 아는 쪽은 파생 클래스밖에 없을 테다. 그렇다면, 기본 클래스 쪽에서 역행렬을 만들 수 있도록 ‘정방행렬의 메모리 위치’를 파생 클래스가 기본 클래스로 넘겨주변 될 것 같은데, 어떻게 할까?

한 가지 방법은 SquareMatrixBase::invert 함수가 매개변수를 하나 더 받도록 만드는 것이다. 이 매개변수는 아마도 행렬 데이터가 들어 있는 메모리 덩어리의 시작주소를 가리키는 포인터일 것이다. 길게 생각할 필요도 없이 이 방법은 아주 잘 돌아갈 것이다. 하지만 SquareMatrix의 함수 중에 invert처럼 행렬 크기에 상관 없는 동작방식을 갖기 때문에 SquareMatrixBase에 옮겨 놓어야 하는 함수가 invert 하나만 있지는 않을 것이다. 이런 함수가 진짜로 몇 개 있다면, 이들 함수에는 행렬 내의 값을 담고 있는 메모리를 찾아낼 수단이 전부 필요할 것이다. 각 함수마다 매개변수를 하나씩 더 달아 주면 어떻게든 해결은 되겠지만, 결국 이것은 SquareMatrixBase에게 똑같은 정보를 되풀이해서 알려 주는 꼴이 될 것이다. 암만 생각해도 이건 아닌 듯하다.

다른 방법을 하나 더 궁리해 보자.

먼저, 옮긴이가 이 책에 나온 다른 방법이 아닌, 조금은 위험한 또 다른 방법 하나를 소개한다. 파생 클래스의 생성자의 초기화 리스트에 넣는 기본 클래스 생성자에 *this 혹은 this를 매개변수로 넘기는 방법이다. 즉, 파생 클래스 객체의 참조자 혹은 포인터를 기본 클래스 쪽으로 올려 보내는 것이다. 이 방법을 사용하면 기본 클래스 멤버 함수에서 파생 클래스의 멤버에 접근할 수 있다. 이 방법이 위험한 이유는, 기본 클래스 생성자가 호출될 때는 파생 클래스 부분이 아직 초기화되지 않았기 때문이다. 따라서 초기화되지 않은 파생 클래스 부분을 기본 클래스 생성자에서 접근하지 않아야 한다는 제약 내에서만 의미를 부여할 수 있다. 어쨌든, 접근해야 할 파생 클래스 부분이 한두 개가 아니거나 계속 늘어날 여지가 있을 때 상당히 유용하다. 빌드 시에 컴파일 경고가 뜨니 참고하자.

행렬 값들을 담는 메모리에 대한 포인터를 SquareMatrixBase가 저장하게 하는 것이다. 그리고 이 클래스 템플릿에 포인터도 저장하는 마당에 행렬 크기도 저장하지 않을 이유가 없다. 이것도 저장하자. 결과적으로 다음과 같은 설계가 나올 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
template <typename T>
class SquareMatrixBase
{
protected:
    SquareMatrixBase(std::size_t n, T* pMem)
    : size(n), pData(pMem) { }
    
    void setDataPtr(T* ptr) { pData = ptr; }
    ...

private:
    std::size_t size;
    T* pData;
};

이렇게 설계해 두면, 행렬 값을 담을 메모리 할당 방법의 결정 권한이 파생 클래스 쪽으로 넘어가게 된다. 파생 클래스를 만드는 살마에 따라, 행렬 데이터를 SquareMatrix 객체 안에 데이터 멤버로 직접 넣는 것으로 결정할 수도 있다.

1
2
3
4
5
6
7
8
9
10
template <typename T, std::size_t n>
class SquareMatrix : private SquareMatrixBase<T>
{
public:
    SquareMatrix() : SquareMatrixBase<T>(n, data) { }
    ...
    
private:
    T data[n * n];
};

이렇게 파생 클래스를 만들면 동적 메모리 할당이 필요 없는 객체가 되지만, 객체 자체의 크기가 좀 커질 수도 있다. 이 방법이 마음에 들지 않는 사람은 각 행렬의 데이터를 힙에 둘 수도 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
template <typename T, std::size_t n>
class SquareMatrix : private SquareMatrixBase<T>
{
public:
    SquareMatrix()                      // 기본 클래스의 포인터를 널로 설정
    : SquareMatrixBase<T>(n, 0),        // 행렬 값의 메모리 할당
    pData(new T[n * n])                 // 파생 클래스의 포인터에 그 메모리를 물려 놓고
    { this->setDataPtr(pData.get()); }  // 이 포인터의 사본을 기본 클래스로 올려 보낸다
    ...
    
private:
    boost::scoped_array<T> pData;
};

어느 메모리에 데이터를 저장하느냐에 따라 설계가 다소 달라지긴 하지만, 코드 비대화의 측면에서 아주 중요한 성과를 손에 쥘 수 있는 점은 같다. 중요한 성과란 바로, SquareMatrix에 속해 있는 멤버 함수 중 상당수(아마 전부일 것이다)가 기본 클래스 버전을 호출하는 단순 인라인 함수가 될 수 있으며, 똑같은 타입의 데이터를 원소로 갖는 모든 정방행렬들이 행렬 크기에 상관없이 이 기본 클래스 버전의 사본 하나를 공유한다는 것이다. 이와 동시에, 행렬 크기가 다른 SquareMatrix 객체는 저마다 고유의 타입을 갖고 있다는 점도 아무 중요하다. 그러니까 이를테면 SquareMatrix<double, 5> 객체와 SquareMatrix<double, 10> 객체가 똑같이 SquareMatrixBase 클래스의 멤버 함수를 사용하고 있다 하더라도 이 둘은 타입이 다르기 때문에 SquareMatrix<double, 10>을 취하는 함수가 SquareMatrix<double, 5> 객체를 날름 받아들이려고 하면 컴파일러가 가만히 있지 않는다는 소리이다.

이건 멋진 일인데, 공짜는 아니다. 행렬 크기가 미리 녹아든 상태로 별도의 버전이 만들어지는 invert, 그리고 행렬 크기가 함수 매개변수로 넘겨지거나 객체에 저장된 형태로 다른 파생 클래스들이 공유하는 버전인 invert 함수, 이 둘을 비교해 보면 전자가 후자보다 더 좋은 코드를 생성할 가능성이 높다. 예를 들어 크기별 고정 버전(전자)의 경우, 행렬 크기가 컴파일 시점에 투입되는 상수이기 때문에 상수 전파(constant propagation) 등의 최적화가 먹혀 들어가기에 좋다. 생성되는 기계 명령어에 대해 이 크기 값이 즉치 피연산자(immediate operand)로 바로 박히는 것도 이런 종류의 최적화 중 하나이다. 이런 혜택은 크기 독립형 버전(후자)에서는 절대 얻어낼 수 없다.

반면, 이런 것도 생각해 볼 필요가 있다. 여러 행렬 크기에 대해 한 가지 버전의 invert를 두도록 만들면 실행 코드의 크기가 작아지는 이점을 손에 쥘 수 있다. 그런데 실행 코드가 작아지면 작은 코드로 끝나는 것이 아니라, 프로그램의 작업세트 크기가 줄어들면서 명령어 캐시 내의 참조 지역성도 향상된다는 것이 중요한 포인트이다. 이렇게 되면 프로그램 실행 속도가 더 빨라질 수 있는데, 그 효과는 크기별 고정 버전의 invert 함수가 가진 최적화 효과를 얻지 못한 것에 대해 보상을 받고도 남을 수 있다. 자, 어떤 효과가 우선일 것 같은가? 정확한 판단을 위해서는 우리가 쓰는 플랫폼 및 대표적인 데이터 집합에 대해 직접 두 방법을 전부 적용해 보고 그 결과를 관찰하는 수 밖에 없다.

작업 세트(working set) : 한 프로세스에서 자주 참조하는 페이지의 집합. 실질적으로 이 페이지 집합은 늘 주 메모리에 올라와 있기 때문에, 한 프로세스에서 주 메모리에 올라갈 수 있는 페이지의 양을 나타낸다. 간단히 ‘프로세스가 현재 사용하는 메모리의 양’을 지칭할 때도 쓴다.

참조 지역성(locality of reference) : 프로세스의 메모리 참조가 실행 중에 균일하게 흩어져 있지 않으며 특정 시점 및 특정 부분에 집중된다는 경험적/실험적 특성. 여기에는 시간적 지역성(temporal locality: 지금 참조된 메모리는 멀지 않은 나중에 또 참조될 가능성이 높다는 성질)과 공간적 지역성(spatial locality: 지금 참조된 메모리와 가까운 곳에 있는 메모리가 참조될 가능성이 높다는 성질)의 두 가지 지역성이 있다. 캐시는 바로 이러한 참조 지역성을 이용해서 수행 성능을 높이는 고속 메모리이다.

효율에 대해 생각해 볼 문제가 하나 더 있는데, 바로 객체의 크기이다. invert 비슷한 크기 독립형 버전의 함수를 기본 클래스 쪽으로 아무 생각 없이 옮겨 놓다 보면, 커져 버리는 각 객체의 전체 크기 때문에 황당한 지경에 빠질 수도 있다. 방금 본 코드만 해도 그렇다. SquareMatrix 객체는 메모리에 생길 때 마다 SquareMatrixBase 클래스에 들어 있는 데이터를 가리키는 포인터를 하나씩 떠안고 있다. 파생 클래스 자체에 이미 이 데이터에 접근할 수 있는 수단이 있는데 말이다. 결국 이것 때문에 SquareMatrix 객체 하나의 크기는 최소한 포인터 하나 크기만큼 낭비된 것이다. 뭐, 조금만 고민하면 이런 포인터가 필요 없도록 설계를 수정하는 것도 불가능한 것은 아닐 것이다. 하지만 그렇게 해서 얻는 것이 있으면 주는 것도 생긴다는 사실을 잊지 말자. 예를 들어, 기본 클래스로 하여금 행렬 데이터의 포인터를 protected 멤버로 저장하게끔 만들었다가는 항목 22에서 설명한 캡슐화 효과가 날아가 버린다. 그뿐 아니라, 자원 관리에서도 골치 아픈 일이 생길 수 있다. 만약 행렬 데이터의 포인터를 저장하는 일은 기본 클래스가 담당하게 하되, 실제로 이 데이터를 저장할 메모리를 동적으로 할당하든지 정적으로 할당하든지 해서 마련하는 일은 파생 클래스 쪽에서 하도록 맡긴다면(앞에서 보았듯이), 포인터를 삭제할지 말지는 어떤 식으로 결정해야 할까? 이런 문제는 머리만 조금 수고하면 해결 방법을 찾을 수 있는 고민거리이지만, 머리를 멋있게 쓰려고 할수록 상태가 더 복잡해지게 되어 있는 것도 이런 문제가 가진 특징이다. 웬만큼 파고들다보면, 오히려 코드 중복을 조금 허용하는 편이 인간미 넘쳐 보이기 시작하럭다.

이번 항목에서는 비타입 템플릿 매개변수로 인한 코드 비대화만은ㄹ 다루엇다. 그렇다고 해서 타입 매개변수는 비대화의 원인이 아니냐? 그건 또 아니다. 간단한 예를 하나 들자. 상당수의 플랫폼에서 int와 long은 이진 표현구조가 동일하다. 그렇기 때문에, 이를테면 vector와 vector의 멤버 함수는 서로 빼다 박은 듯 똑같게 나올 수 있다. 딱 코드 비대화다. 어떤 링커의 경우엔 이렇게 동일한 표현구조를 가진 함수들을 합쳐 주는 기특함을 보이기도 하지만, 그렇지 않은 링커도 꽤 있다. 그러니까 당연하고도 간단히 이렇게 정리할 수 있다. int 및 long에 대해 인스턴스화되는 템프릿들은 '어떤 환경'에서는 코드 비대화를 일으킬 수 있다고 말이다. 비슷한 예가 포인터 타입의 경우이다. 어지간한 대부분의 플랫폼에서 포인터 타입은 똑같은 이진 표현구조를 갖고 있기 때문에, 포인터 타입을 매개변수로 취하는 동일 계열의 템플릿들(이를테면 list<int*>, list<const int*>, list<SquareMatrix<long, 3>*> 등은)은 이진 수준에서만 보면 멤버 함수 집합을 달랑 한 벌 써도 되어야 한다. 이 말을 기술적으로 풀어 보면, 타입 제약이 엄격한 포인터(T* 포인터)를 써서 동작하는 멤버 함수를 구현할 때는 하단에서 타입미정(untyped) 포인터(void*)로 동작하는 버전을 호출하는 식으로 만든다는 뜻이다. 실제로 C++ 표준 라이브러리의 몇 개 구현 제품이 vector, deque, list 등의 템플릿에 대해 이런 식으로 하고 있다. 우리 나름대로 템플릿을 설계하면서 코드 비대화를 고민한다면 이와 똑같이 한 번 해보길 권한다.

템플릿을 사용하면 비슷비슷한 클래스와 함수가 여러 벌 만들어진다. 따라서 템플릿 매개변수에 종속되지 않은 템플릿 코드는 비대화의 원인이 된다.

비타입 템플릿 배개변수로 생기는 코드 비대화의 경우, 템플릿 배개변수를 함수 매개변수 혹은 클래스 데이터 멤버로 대체함으로써 비대화를 종종 없앨 수 있다.

타입 매개변수로 생기는 코드 비대화의 경우, 동일한 이진 표현구조를 가지고 인스턴스화되는 타입들이 한 가지 함수 구현을 공유하게 만듦으로써 비대화를 감소시킬 수 있다.


ICF(identical COMDAT folding)


책에서 vector와 vector의 이진 표현이 같을 때 발생하는 코드 비대화를 걱정하며, 포인터 타입(T*)의 비대화를 막기 위해 내부적으로 void*를 쓰도록 설계하는 표준 라이브러리의 기법을 소개했다.

하지만 현대 C++에선 링커가 발전하여 함수의 기계어 바이너리가 같으면 둘 중 하나만 남기고 합쳐버린다(folding). 이걸 ICF(identical COMDAT folding)라고 하는데, COMDAT은 포트란(fortran) 프로그래밍 언어의 특징인 “common data”의 약자이다.

기계어 바이너리가 같은게 무슨 소리냐면, 예를 들어 접근하는 메모리 오프셋의 위치가 동일한 것이다. 코드로 예시를 하나 보자면,

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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
// Test.h
#pragma once

template <typename T>
class TemplateClass
{
public:
    T GetValue() { return value; }
private:
    T value = 30;
};

template <typename T>
class TemplateClassWithPadding
{
public:
    T GetValue() { return value; }
private:
    int padding = 100000;
    T value = 10;
};

// .cpp
#include "Test.h"
#include <iostream>

__declspec(noinline) int F1(TemplateClass<int>* p)
{
    return p->GetValue();
}

__declspec(noinline) long F2(TemplateClass<long>* p)
{
    return p->GetValue();
}

__declspec(noinline) int F3(TemplateClassWithPadding<int>* p)
{
    return p->GetValue();
}

__declspec(noinline) long F4(TemplateClassWithPadding<long>* p)
{
    return p->GetValue();
}

int main()
{
    TemplateClass<int> obj1;
    TemplateClass<long> obj2;
    
    std::cout << F1(&obj1) << '\n';
    std::cout << F2(&obj2) << '\n';
    
    TemplateClassWithPadding<int> obj4;
    TemplateClassWithPadding<long> obj5;
    
    std::cout << F3(&obj4) << '\n';
    std::cout << F4(&obj5) << '\n';
    
    return 0;
}

목적 코드 레벨에서 관찰하면 F1과 F2는 완전히 똑같은 함수가 된다. int와 long의 이진 표현은 같기에 멤버 변수의 메모리 오프셋이 동일하기에 어셈블리어 관점에서 동일한 코드로 평가가 가능하다 (예를 들어, 멤버 변수 int나 멤버 변수 long이나 접근하려면 this 포인터 + 4 바이트의 위치에 접근하는거다). 그러므로, 링커는 동일한 두 함수를 가질 필요가 없다며 한 개만 남기고 그것으로 두 함수를 모두 대체해 버린다. 두 함수가 함쳐짐으로써 주소가 동일하다. 이 코드 조각들은 단지 두 가지 이름으로 불릴 뿐이다. 따라서 디버거가 해당 위치로 점프한 것을 감지하면, 어떤 이름을 사용해야 할지 알 수 없으므로 그냥 하나를 선택한다.

Rider에서 이를 확인해보려면 빌드 구성을 Build가 아닌 Release로 바꾸자. ICF는 릴리즈 빌드 환경에서 링커가 수행하는 작업이라고 한다.

그리고 릴리즈 빌드에선

1
2
3
4
int F1(TemplateClass<int>* p)
{
    return p->GetValue();
}

같은 함수는 인라인화되어 step-into를 할 수 없기에 인라인 금지 키워드인 __declspec(noinline)을 붙여서 인라인화되지 않도록 했다.

F1 호출 라인에서 step-into를 하면 F1 함수로 들어가는데, F2 호출 라인에서도 step-into를 하면 F1 함수로 들어가는 걸 확인할 수 있다.

ICF는 컴파일러에서 자동으로 활성화되어 있는데, 이 말은 즉슨 디버깅을 하다가 다른 함수의 이름으로 표시될 수 있다는 뜻이기도 하다. 알아만 두자.


참고

This post is licensed under CC BY 4.0 by the author.