[C++] 항목 45: “호환되는 모든 타입”을 받아들이는 데는 멤버 함수 템플릿이 직방!
카테고리: Cpp
태그: Cpp
이 글은 아래의 책을 자세히 정리한 후, 정리한 글을 GPT에게 요약을 요청하여 작성되었습니다.
이펙티브 C++ 제3판, 스콧 마이어스 저자, 곽용재 번역
📦 7. 템플릿과 일반화 프로그래밍
👉🏻 항목 45: “호환되는 모든 타입”을 받아들이는 데는 멤버 함수 템플릿이 직방!
✅ 기본 포인터의 암시적 변환
포인터는 암시적 변환을 지원한다 (하지만 스마트 포인터는 불가)
기본 포인터가 지원하는 변환:
- 파생 클래스 포인터 → 기본 클래스 포인터
- 비상수 객체 포인터 → 상수 객체 포인터
- 기타 등등…
class Top { ... };
class Middle: public Top { ... };
class Bottom: public Middle { ... };
int main() {
// Middle* => Top*
Top* pt1 = new Middle;
// Bottom* => Top*
Top* pt2 = new Bottom;
// Top* => const Top*
const Top* pct2 = pt1;
}
⚠️ 문제: 같은 클래스 계통의 변환 불가
template<typename T>
class SmartPtr {
public:
explicit SmartPtr(T* realPtr);
...
};
int main() {
// SmartPtr<Middle> => SmartPtr<Top>
SmartPtr<Top> pt1 = SmartPtr<Middle>(new Middle);
// SmartPtr<Bottom> => SmartPtr<Top>
SmartPtr<Top> pt2 = SmartPtr<Bottom>(new Bottom);
// SmartPtr<Top> => SmartPtr<const Top>
SmartPtr<const Top> pct2 = pt1;
}
문제:
- 같은 템플릿으로부터 만들어진 다른 인스턴스들 사이에는 어떤 관계가 없다
SmartPtr<Middle>과SmartPtr<Top>은 완전히 별개의 클래스
현실적 한계:
SmartPtr클래스들 사이에 변환을 하고 싶다면, 직접 변환이 되도록 만들어야 한다- 스마트 포인터의 생성자 함수를 직접 한땀한땀 만드는 것은 불가능하다
Top클래스 계통이 더 늘어난다면, 다른 스마트 포인터 타입으로부터SmartPtr<Top>객체를 만들 방법도 만들어야 하기 때문
✅ 해결: 멤버 함수 템플릿
멤버 함수 템플릿:
어떤 클래스의 멤버 함수를 찍어내는 템플릿
template<typename T>
class SmartPtr {
public:
// "일반화된 복사 생성자"를 만들기 위해
// 마련한 멤버 템플릿
template<typename U>
SmartPtr(const SmartPtr<U>& other);
...
};
일반화 복사 생성자:
같은 템플릿을 써서 인스턴스화되지만,
타입이 다른 타입의 객체로부터 원하는 객체를 만들어주는 생성자
효과:
- 위 코드의 경우, 모든
T타입 및 모든U타입에 대해서 SmartPtr<U>객체로부터SmartPtr<T>객체를 생성할 수 있다
📌 explicit를 사용하지 않은 이유
질문: 왜 일반화 복사 생성자를 explicit로 선언하지 않았는가?
답변:
- 기본 제공 포인터는 포인터 타입 사이의 타입 변환이 암시적으로 이루어지며, 캐스팅이 필요하지 않다
- 파생 클래스 포인터 → 기본 클래스 포인터로의 변환이 있다
- 예:
Top* pt = new Middle;
🚨 현재 코드의 문제점
원치 않는 변환이 가능함:
- ❌
SmartPtr<Top>→SmartPtr<Bottom>가능 - ❌
SmartPtr<double>→SmartPtr<int>가능
예시 코드:
template<typename T>
class SmartPtr {
public:
SmartPtr(T* ptr): heldPtr(ptr) {}
// 일반화 복사 생성자
// 아무것도 하지 않는 것 같아보이지만,
// 내부에서 숨겨진 동작을 수행한다.
template<typename U>
SmartPtr(const SmartPtr<U>& other) {}
T* get() { return heldPtr; }
private:
T* heldPtr;
};
int main() {
SmartPtr<Top> st(new Top);
// ⚠️ SmartPtr<Top> → SmartPtr<Bottom> (논리적으로 불가능하지만 컴파일됨)
SmartPtr<Bottom> p3 = st;
// ⚠️ SmartPtr<double> → SmartPtr<int> (이상하지만 컴파일됨)
SmartPtr<int> p4 = SmartPtr<double>(new double(3.14));
}
전체 코드 (펼치기/접기)
#include <iostream>
#include <typeinfo>
using namespace std;
template<typename T>
class SmartPtr {
public:
SmartPtr(T* ptr): heldPtr(ptr) {}
// 일반화 복사 생성자.
// 아무것도 하지 않는 것 같아보이지만,
// 내부에서 숨겨진 동작을 수행한다.
template<typename U>
SmartPtr(const SmartPtr<U>& other) {}
T* get() {
return heldPtr;
}
private:
T* heldPtr;
};
struct Top {};
struct Middle : Top {};
struct Bottom : Middle {};
int main() {
SmartPtr<Bottom> sb(new Bottom);
SmartPtr<Middle> sm(new Middle);
SmartPtr<Top> st(new Top);
cout << "\n--- 올바른 변환 ---\n";
// ✅ SmartPtr<Bottom> → SmartPtr<Top> (OK)
SmartPtr<Top> p1 = sb;
cout << "p1의 타입: " << typeid(p1.get()).name() << endl;
cout << "------" << endl;
// ✅ SmartPtr<double> → SmartPtr<double> (OK)
SmartPtr<double> p2 = SmartPtr<double>(new double(3.14));
cout << "p2의 타입: " << typeid(p2.get()).name() << endl;
cout << "p2의 포인터 값: " << *(p2.get()) << endl;
std::cout << "\n--- 잘못된 변환 ---\n";
// ⚠️ SmartPtr<Top> → SmartPtr<Bottom> (논리적으로 불가능하지만 컴파일됨)
SmartPtr<Bottom> p3 = st;
cout << "p3의 타입: " << typeid(p3.get()).name() << endl;
cout << "------" << endl;
// ⚠️ SmartPtr<double> → SmartPtr<int> (이상하지만 컴파일됨)
SmartPtr<int> p4 = SmartPtr<double>(new double(3.14));
cout << "p4의 타입: " << typeid(p4.get()).name() << endl;
// 한번씩 에러 발생
try {
cout << "p4의 포인터 값: " << *(p4.get()) << endl;
} catch(const exception& e) {
cout << "에러 발생: " << e.what() << endl;
}
}
/*
결과:
--- 올바른 변환 ---
p1의 타입: P3Top
------
p2의 타입: Pd
p2의 포인터 값: 3.14
--- 잘못된 변환 ---
p3의 타입: P6Bottom
------
p4의 타입: Pi
p4의 포인터 값: 6422500
*/
✅ 해결: 기본 포인터 초기화를 이용한 제약
template<typename T>
class SmartPtr {
public:
// other에 담긴 포인터를
// 현재 객체에 담긴 포인터로 초기화
template<typename U>
SmartPtr(const SmartPtr<U>& other)
: heldPtr(other.get()) { ... }
T* get() const { return heldPtr; }
private:
// 기본 제공 포인터
T* heldPtr;
};
핵심:
- 이제 호환되는 타입의 매개변수를 넘겨받을 때만 컴파일된다
heldPtr(other.get())을 통해 기본 제공 포인터를 초기화할 때, 에러 발생 유도U*가T*로 암시적 변환 가능한 경우에만 컴파일 성공
📌 실제 shared_ptr 구현
template<class T>
class shared_ptr {
public:
// (1) 생성자
template<class Y>
explicit shared_ptr(Y* p);
// (2) 일반화 복사 생성자
template<class Y>
shared_ptr(shared_ptr<Y> const& r);
// (3) 생성자
template<class Y>
explicit shared_ptr(weak_ptr<Y> const& r);
// (4) 생성자
template<class Y>
explicit shared_ptr(auto_ptr<Y>& r);
// (5) 일반화 복사 대입 연산자
template<class Y>
shared_ptr& operator=(shared_ptr<Y> const& r);
// (6) 대입 연산자
template<class Y>
shared_ptr& operator=(auto_ptr<Y>& r);
...
};
설계 분석:
(2) 일반화 복사 생성자가 explicit가 아닌 이유:
shared_ptr<D>→shared_ptr<B>로의 암시적 변환은 허용한다
(1)(3)(4)가 explicit인 이유:
기본제공 포인터,weak_ptr,auto_ptr로부터 변환을 막기 위함- 명시적 변환은 가능
(4)(6)의 auto_ptr만 const가 아닌 이유:
auto_ptr은 복사 연산으로 인해 객체가 수정될 때- 오직 복사된 쪽 하나만 유효하게 남아야 하기 때문
🚨 중요: 일반화 복사 함수 ≠ 복사 함수
동일한 타입(T==Y)의 shared_ptr이 들어오면?
template<class T>
class shared_ptr {
public:
// (1) 복사 생성자
shared_ptr(shared_ptr const& r);
// (2) 일반화 복사 생성자
template<class Y>
shared_ptr(shared_ptr<Y> const& r);
// (3) 복사 대입 연산자
shared_ptr& operator=(shared_ptr const& r);
// (4) 일반화 복사 대입 연산자
template<class Y>
shared_ptr& operator=(shared_ptr<Y> const& r);
...
};
핵심 원칙:
- 일반화 복사 생성자(2)는 복사 생성자(1)가 아니다
- 만약, (1)을 선언하지 않았다면 컴파일러는 기본 복사 생성자를 생성한다
- 그러므로, 복사 생성자와 일반화 복사 생성자까지 직접 선언해야 한다
- 복사 대입 연산자(3)와 일반화 복사 대입 연산자(4)도 동일하게 적용된다
📊 멤버 함수 템플릿 vs 일반 멤버 함수
| 특성 | 일반화 복사 생성자 | 복사 생성자 |
|---|---|---|
| 선언 | template<class Y> SmartPtr(const SmartPtr<Y>&) |
SmartPtr(const SmartPtr&) |
| 용도 | 다른 타입 간 변환 | 같은 타입 복사 |
| 컴파일러 자동 생성 | ❌ | ✅ (선언 안 하면) |
| 필요성 | 타입 변환 지원 시 | 항상 필요 |
💡 실전 활용 패턴
올바른 스마트 포인터 설계:
template<typename T>
class SmartPtr {
public:
// 일반 생성자
explicit SmartPtr(T* realPtr = 0);
// 복사 생성자 (명시적)
SmartPtr(const SmartPtr& other);
// 일반화 복사 생성자
template<typename U>
SmartPtr(const SmartPtr<U>& other)
: heldPtr(other.get()) { }
// 복사 대입 연산자 (명시적)
SmartPtr& operator=(const SmartPtr& other);
// 일반화 복사 대입 연산자
template<typename U>
SmartPtr& operator=(const SmartPtr<U>& other) {
heldPtr = other.get();
return *this;
}
T* get() const { return heldPtr; }
private:
T* heldPtr;
};
🧐 정리
- 호환되는 모든 타입을 받아들이는 멤버 함수를 만들려면 멤버 함수 템플릿을 사용한다.
이를 통해 타입 간 변환을 유연하게 지원할 수 있다. - 일반화된 복사 생성 연산과 일반화된 대입 연산을 위해 멤버 템플릿을 선언했다 하더라도,
보통의 복사 생성자와 복사 대입 연산자는 여전히 직접 선언해야 한다.
컴파일러는 일반화 버전을 복사 생성자로 인식하지 않는다. - 기본 포인터 초기화를 활용하여 타입 변환의 적법성을 컴파일 타임에 검증할 수 있다.
U*가T*로 변환 가능한 경우에만 컴파일이 성공한다. - explicit 키워드는 신중하게 사용하자.
일반화 복사 생성자는 기본 포인터의 동작을 모방하기 위해 보통 explicit를 붙이지 않는다.
댓글남기기