[Effective C++]"호환되는 모든 타입"을 받아들이는 데는 멤버 함수 템플릿이 직방!

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

Posted by SungBeom on January 02, 2020 · 9 mins read

멤버 함수 템플릿

스마트 포인터(smart pointer)는 그냥 포인터처럼 동작하면서도 포인터가 주지 못하는 기능을 덤으로 갖고 있는 객체를 일컫습니다. 이를테면 힙 기반 자원의 삭제를 제때 수행하게 하는 데 표준 라이브러리의 auto_ptr 및 tr1::shared_ptr 객체를 이용하는 예를 보셨을 것입니다. STL 컨테이너의 반복자도 스마트 포인터나 마찬가지입니다. 포인터에다가 "++" 연산을 적용해서 연결 리스트의 한 노드에서 다른 노드로 이동하는 것이 list::iterator에서는 가능합니다.

포인터에도 스마트 포인터로 대신할 수 없는 특징이 있습니다. 그 중 하나가 암시적 변환(implicit conversion)을 지원한다는 점입니다. 파생 클래스 포인터는 암시적으로 기본 클래스 포인터로 변환되고, 비상수 객체에 대한 포인터는 상수 객체에 대한 포인터로의 암시적 변환이 가능하고, 기타 등등 말입니다. 예를 들어, 아래와 같이 세 수준(level)으로 구성된 클래스 계통이 주어졌다면 그 아래에 나온 것처럼 몇 가지의 타입 변환이 가능할 것입니다.

1
2
3
4
5
6
class Top { ... };
class Middle: public Top { ... };
class Bottom: public Middle { ... };
Top *pt1 = new Middle;  // Middle* => Top*의 변환
Top *pt2 = new Bottom;  // Bottom* => Top*의 변환
const Top *pct2 = pt1;  // Top* =? const Top*의 
cs

이런 식의 타입 변환을 사용자 정의 스마트 포인터를 써서 흉내 내려면 무척 까다롭습니다. 이를테면 다음과 같은 코드를 컴파일러에 통과시키고 싶은데 말이죠.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template<typename T>
class SmartPtr {
public:                             // 스마트 포인터는 대개
    explicit SmartPtr(T *realPtr);  // 기본제공 포인터로 초기화됩니다.
    ...
};
 
SmartPtr<Top> pt1 =                 // SmartPtr<Middle> =>
    SmartPtr<Middle>(new Middle);   // SmartPtr<Top>의 변환
 
SmartPtr<Top> pt2 =                 // SmartPtr<Bottom> =>
    SmartPtr<Bottom>(new Bottom);   // SmartPtr<Top>의 변환
 
SmartPtr<const Top> pct2 = pt1;     // SmartPtr<Top> =>
                                    // SmartPtr<const Top>의 변환
cs

같은 템플릿으로부터 만들어진 다른 인스턴스들 사이에는 어떤 관계도 없기 때문에, 컴파일러의 눈에 비치는 SmartPtr<Middle>과 SmartPtr<Top>은 완전히 별개의 클래스입니다. 결론적으로 SmartPtr 클래스들 사이에 어떤 변환을 하고 싶다고 생각했다면, 변환이 되도록 직접 프로그램을 만들어야 한다는 것입니다. 위 SmartPtr 에제 코드를 보시면, 모든 문장이 하나같이 new를 써서 스마트 포인터 객체를 만들고 있습니다. 그런 의미에서, 스마트 포인터의 생성자를 우리가 원하는 대로 동작하게끔 작성하는 쪽에 일단 초점을 맞춥시다. 생성자 함수를 직접 만드는 것으로는 우리에게 필요한 모든 생성자를 만들어내기란 불가능합니다. 나중에 클래스 계통이 더 확장된다든지 하면 다른 스마트 포인터 타입으로부터 SmartPtr<Top> 객체를 만들 방법도 마련되야 하니까요.

원칙적으로 지금 우리가 원하는 생성자의 개수는 '무제한'입니다. 그런데 템플릿을 인스턴스화하면 '무제한' 개수의 함수를 만들어낼 수 있죠. 그러니까 SmartPtr에 생성자 함수(function)를 두는 것이 아니라, 생성자를 만들어내는 템플릿(template)을 쓰는 것입니다. 이 생성자 템플릿은 이번 항목에서 이야기할 멤버 함수 템플릿(member function template, 멤버 템플릿)의 한 예인데, 멤버 함수 템플릿은 간단히 말해서 어떤 클래스의 멤버 함수를 찍어내는 템플릿을 일컫습니다.

1
2
3
4
5
6
7
template<typename T>
class SmartPtr {
public:
    template<typename U>                 // "일반화된 복사 생성자"를
    SmartPtr(const SmartPtr<U>& other);  // 만들기 위해 마련한
    ...                                  // 멤버 템플릿
};
cs

일반화 복사 생성자

위의 코드는 모든 T 타입 및 모든 U 타입에 대해서, SmartPtr<T> 객체가 SmartPtr<U>로부터 생성될 수 있다는 이야기입니다. 그 이유는 SmartPtr<U>의 참조자를 매개변수로 받아들이는 생성자가 SmartPtr<T> 안에 들어있기 때문입니다. 이런 꼴의 생성자(같은 템플릿을 써서 인스턴스화되지만 타입이 다른 타입의 객체로부터 원하는 객체를 만들어 주는 생성자)를 가리켜 일반화 복사 생성자(generalized copy constructor)라고들 부릅니다. 위의 예제에 나온 일반화 복사 생성자는 explicit으로 선언되지 않았는데, 이는 기본제공 포인터는 포인터 타입 사이의 타입 변환이 암시적으로 이루어지며 캐스팅이 필요하지 않기 때문에, 스마트 포인터도 이런 형태로 동작하도록 흉내 내기 위해서입니다.

보시면 알겠지만, 지금 SmartPtr에 선언된 일반화 복사 생성자는 실제로 우리가 원하는 것보다 더 많은 것을 해 줍니다. Smart<Bottom>으로부터 SmartPtr<Top>을 만들 수 있기만을 원했지, 반대로 SmartPtr<Top>으로부터 SmartPtr<Bottom>을 만들 수 있는 것까지 바라지 않았습니다. 이것은 public 상속의 의미에 역행하는 오버입니다. 게다가 지금의 생성자로는 심지어 SmartPtr<double>로부터 SmartPtr<int>를 만든다든지 하는 것도 가능해서, 뭔가 대책이 필요합니다.

auto_ptr 및 tr1::shared_ptr에서 쓰는 방법을 그대로 따라서 SmartPtr도 get 멤버 함수를 통해 해당 스마트 포인터 객체에 자체적으로 담긴 기본제공 포인터의 사본을 반환한다고 가정하면, 이것을 이용해서 생성자 템플릿에 우리가 원하는 타입 변환 제약을 줄 수 있을 것 같습니다.

1
2
3
4
5
6
7
8
9
10
11
12
template<typename T>
class SmartPtr {
public:
    template<typename U>
    SmartPtr(const SmartPtr<U>& other)  // 이 SmartPtr에 담긴 포인터를
    : heldPtr(other.get()) { ... }      // 다른 SmartPtr에 담긴 포인터로 초기화합니다.
 
    T* get() const { return heldPtr; }
    ...
private:                                // SmartPtr에 
    T *heldPtr;                         // 기본 제공 포인터
};
cs

보시다시피 멤버 초기화 리스트를 사용해서, SmartPtr<T>의 데이터 멤버인 T* 타입의 포인터를 SmartPtr<U>에 들어 있는 U* 타입의 포인터로 초기화했습니다. 이렇게 해 두면 U*에서 T*로 진행하는 암시적 변환이 가능할 때만 컴파일 에러가 나지 않습니다. 이제 SmartPtr<T>의 일반화 복사 생성자는 호환되는(compatible) 타입의 매개변수를 넘겨받을 때만 컴파일되도록 만들어졌습니다.

멤버 함수 템플릿의 활용은 비단 생성자에만 그치지 않습니다. 가장 흔히 쓰이는 예를 하나 더 말씀드리면 대입 연산입니다. 예를 들면, TR1의 shared_ptr 클래스 템플릿은 호환되는 모든 기본제공 포인터, tr1::shared_ptr, auto_ptr, tr1::weak_ptr 객체들로부터 생성자 호출이 가능한데다가, 이들 중 tr1::weak_ptr을 제외한 나머지를 대입 연산에 쓸 수 있도록 만들어져 있습니다.

실제 코드를 보면 일반화 복사 생성자를 제외한 모든 생성자가 explicit으로 선언되어 있을 텐데, 이는 shared_ptr로 만든 어떤 타입으로부터 또 다른 shared_ptr로 만든 타입으로 진행되는 암시적 변환은 허용되지만 기본제공 포인터 혹은 다른 스마트 포인터 타입으로부터 변환되는 것은 막겠다는 뜻입니다(단, 명시적 변환은 가능합니다).

일반화 복사 생성자 & 보통의 복사 생성자

멤버 함수 템플릿은 코드 재사용만큼이나 기특하고 훌륭한 기능이지만, C++ 언어의 기본 규칙까지 바꾸지는 않습니다. tr1::shared_ptr에는 분명히 일반화 복사 생성자가 선언되어 있는데, T 타입과 Y 타입이 동일하게 들어온다면 이 일반화 복사 생성자는 분명 "보통의" 복사 생성자를 만드는 쪽으로 인스턴스화되겠지요. 여기서 규칙을 하나 보자면, '복사 생성자가 필요한데 프로그래머가 직접 선언하지 않으면 컴파일러가 자동으로 하나 만든다'라는 규칙이 있습니다. 그러나 일반화 복사 생성자(그러니까 멤버 템플릿)를 어떤 클래스 안에 선언하는 행위는 컴파일러 나름의 복사 생성자(비템플릿)를 만드는 것을 막는 요소가 아닙니다. 일반화 복사 생성자는 일반화 복사 생성자일 뿐이라, 어떤 클래스의 복사 생성을 직접 사용하기 위해서는 보통의 복사 생성자까지 직접 선언해야 합니다. 대입 연산자도 마찬가지입니다.


정리

호환되는 모든 타입을 받아들이는 멤버 함수를 만들려면 멤버 함수 템플릿을 사용합시다.
일반화된 복사 생성 연산과 일반화된 대입 연산을 위해 멤버 템플릿을 선언헀다 하덜라도, 보통의 복사 생성자와 복사 대입 연산자는 여전히 직접 선언해야 합니다.