[C++] 항목 45: “호환되는 모든 타입”을 받아들이는 데는 멤버 함수 템플릿이 직방!

게시:     수정

카테고리:

태그:

이 글은 아래의 책을 자세히 정리한 후, 정리한 글을 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;
};

🧐 정리

  1. 호환되는 모든 타입을 받아들이는 멤버 함수를 만들려면 멤버 함수 템플릿을 사용한다.
    이를 통해 타입 간 변환을 유연하게 지원할 수 있다.
  2. 일반화된 복사 생성 연산과 일반화된 대입 연산을 위해 멤버 템플릿을 선언했다 하더라도,
    보통의 복사 생성자와 복사 대입 연산자는 여전히 직접 선언해야 한다.

    컴파일러는 일반화 버전을 복사 생성자로 인식하지 않는다.
  3. 기본 포인터 초기화를 활용하여 타입 변환의 적법성을 컴파일 타임에 검증할 수 있다.
    U*T*로 변환 가능한 경우에만 컴파일이 성공한다.
  4. explicit 키워드는 신중하게 사용하자.
    일반화 복사 생성자는 기본 포인터의 동작을 모방하기 위해 보통 explicit를 붙이지 않는다.

Cpp 카테고리 내 다른 글 보러가기

댓글남기기