[Effective C++]템플릿으로 만들어진 기본 클래스 안의 이름에 접근하는 방법을 알아 두자

템플릿과 일반화 프로그래밍, 세 번째 이야기

Posted by SungBeom on December 31, 2019 · 13 mins read

템플릿으로 만들어진 기본 클래스 안의 이름에 접근할 때의 문제

여러분은 지금 다른 몇 개의 회사에 메시지를 전송할 수 있는 응용프로그램을 만들어내야 합니다. 전송용 메시지는 암호화될 수도 있고 비가공텍스트(비암호화) 형태가 될 수도 있습니다. 만약 어떤 메시지가 어떤 회사로 전송될지를 컴파일 도중에 결정할 수 있는 충분한 정보가 있다면, 주저 없이 템플릿 기반의 방법을 쓸 수 있겠지요.

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
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 nsg;
        info로부터 msg를 만듭니다;
 
        Company c;
        c.sendCleartext(msg);
    }
 
    void sendSecret(const MsgInfo& info)  // sendClear 함수와 비슷합니다. 단,
    { ... }                               // c.sendEncrypted 함수를 호출하는 점이 차이
};
cs

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

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

코드를 보면 파생 클래스에 있는 메시지 전송 함수의 이름(sendClearMsg)이 기본 클래스에 있는 것(sendClear)과 다르다는 점이 눈에 띕니다. 기본 클래스로부터 물려받은 이름을 파생 클래스에서 가리는 문제는 물론이고 상속받은 비가상 함수를 재정의하는 문제를 일으키지 않도록 한 것이기 때문입니다. 하지만 이 코드는 'sendClear 함수가 존재하지 않는다'는 이유로 컴파일되지 않습니다. 컴파일러가 LoggingMsgSender 클래스 템플릿의 정의와 마주칠 때, 컴파일러는 대체 이 클래스가 어디서 파생된 것인지를 모릅니다. MsgSender<Company>인 것은 분명히 맞지만, Company는 템플릿 매개변수이고, 이 템플릿 매개변수는 LoggingMsgSender가 인스턴스로 만들어질 때까지 무엇이 될지 알 수 없습니다. Company가 정확히 무엇인지 모르니 MsgSender<Company> 클래스가 어떤 형태인지 알 방법이 없고, sendClear 함수가 들어 있는지 없는지 알아낼 방법도 없습니다.

문제를 구체적으로 알기 위해 CompanyZ라는 클래스가 있고, 이 클래스는 암호화된 통신만을 사용해야 한다고 가정합시다.

1
2
3
4
5
6
class CompanyZ {  // 이 클래스는 sendCleartext 함수를
public:           // 제공하지 않습니다.
    ...
    void sendEncrypted(const std::string& msg);
    ...
};
cs

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

1
2
3
4
5
6
7
template<>                    // MsgSender 템플릿의 완전 특수화
class MsgSeneder<CompanyZ> {  // 버전입니다. sendClear 함수가
public:                       // 빠진 것만 제외하면 일반형 템플릿과
    ...                       // 똑같습니다.
    void sendSecret(const std::string& msg)
    { ... }
};
cs

여기서 클래스 정의 앞에 있는 "template<>" 구문은 MsgSender 템플릿을 템플릿 매개변수가 CompanyZ일 때 쓸 수 있도록 특수화한 버전입니다. 특히 지금 보시는 특수화는 완전 템플릿 특수화(total template specialization)라고 합니다. MsgSender 템플릿이 CompanyZ 타입에 대해 특수화되었고, 이때 이 템플릿의 매개변수들이 하나도 빠짐없이(완전히) 구체적인 타입으로 정해진 상태라는 뜻이죠.

이제 MsgSender 템플릿이 CompanyZ에 대해 특수화된 상태라고 가정하고, 파생 클래스인 LoggingMsgSender를 다시 봅시다.

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

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

템플릿으로 만들어진 기본 클래스 안의 이름에 접근하는 방법

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

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

둘째, using 선언을 사용합니다. 가려진 기본 클래스의 이름을 파생 클래스의 유효범위에 끌어오는 용도로 사용합니다.

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

예제 코드가 컴파일되도록 만드는 마지막 방법은, 호출할 함수가 기본 클래스의 함수라는 점을 명시적으로 지정하는 것입니다. 하지만 호출되는 함수가 가상 함수인 경우에는, 명시적 한정을 해 버리면 가상 함수 바인딩이 무시되기 때문입니다.

1
2
3
4
5
6
7
8
9
10
11
12
template<typename Company>
class LogginMsgSeneder: public MsgSender<Company> {
public:
    ...
    void sendClearMsg(const MsgIngo& info)
    {
        "메시지 전송 전" 정보를 로그에 기록합니다;
        MsgSender<Company>::sendClear(info);  // sendClear 함수가 상속되는 것으로 가정합니다.
        "메시지 전송 후" 정보를 로그에 기록합니다;
    }
    ...
};
cs

이름에 대한 가시성을 조작한다는 면에서 보면 말씀드린 세 가지 방법은 모두 동작 원리가 같습니다. 기본 클래스 템플릿이 이후에 어떻게 특수화되더라도 원래의 인반형 템플릿에서 제공하는 인터페이스를 그대로 제공할 것이라고 컴파일러에게 약속을 하는 것입니다. 본질적인 논점은 기본 클래스의 멤버에 대한 참조가 무효한지를 컴파일러가 진단하는 과정이 미리 파생 클래스 템플릿의 정의분석될 때 들어가느냐, 아니면 나중에 파생 클래스 템플릿이 특정한 템플릿 매개변수를 받아 인스턴스화될 때가 들어가느냐가 바로 이번 항목의 핵심입니다. 여기서 C++은 이른바 '이른 진단(early diagnose)'을 선호하는 정책으로 결정한 것이죠. 파생 클래스가 템플릿으로부터 인스턴스화될 때 컴파일러가 기본 클래스의 내용에 대해 아무것도 모르는 것으로 가정하는 이유도 이제 이해하실 수 있을 거예요.


정리

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