책/이펙티브 C++

이펙티브 C++(320p ~ 331p)

readme.md 2024. 2. 8. 23:17

이펙티브 C++
https://www.yes24.com/Product/Goods/17525589
페이지 320p ~ 331p

이펙티브 C++(320p ~ 331p)

요약

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

  • 스마트 포인터는 일반적인 포인터처럼 동작하면서도 포인터가 주지 못하는 기능을 가지고 있다.
    • STL 컨테이너의 반복자도 스마트 포인터로 마찬가지의 기능을 가지고 있다.
    • 포인터에 ++ 연산자를 사용해서 연결 리스트의 한 노드에서 다른 노드로 이동하는 기능은 상상할 수도 없다.
  • 포인터에도 스마트 포인터로 대신할 수 없는 특징이 있다.
  • 그 중 하나가 암시적 변환이다.
  • 파생 클래스 포인터는 암시적으로 기본 클래스 포인터로 변환되고, 비상수 객체에 대한 포인터는 상수 객체에 대한 포인터로의 암시적 변환이 가능하고, 등등이 있다.
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* 변환
  • 이런 식의 타입 변환을 사용자 정의 스마트 포인터를 써서 흉내 내려면 무척 까다롭다.
    • 스마트 포인터로 다음과 같은 코드를 만들었다.
template<typename T>
class SmartPtr {
public:
    // 스마트 포인터는 대개 기본제공 포인터로 초기화된다.
    explicit SmartPtr(T* realPtr);
};

SmartPtr<Top> pt1 = SmartPtr<Middle>(new Middle);

SmartPtr<Top> pt2 = SmartPtr<Bottom>(new Bottom);

SmartPtr<const Top> pct2 = pt1;
  • 현재 위의 코드는 컴파일 에러가 발생한다.
    • 같은 템플릿으로부터 만들어진 다른 인스턴스들 사이에는 어떤 관계도 없기 때문에, 컴파일러의 눈에 비치는 SmartPtr<Middle>SmartPtr<Top>은 완전히 별개의 클래스이다.
    • 따라서 SmartPtr 클래스들 사이에 어떤 변환을 하고 싶다면, 변환이 되도록 직접 프로그램을 만들어야 한다.
  • 위의 코드를 보면 모든 문장이 하나같이 new를 써서 스마트 포인터 객체를 만들고 있다.
    • 그런 의미에서, 스마트 포인터의 생성자를 우리가 원하는 대로 동작하게끔 작성하는 쪽에 초점을 맞춘다.
    • 생성자 함수를 직접 만드는 것으로는 우리에게 필요한 모든 생성자를 만들어내기란 불가능하다.
      • 위의 클래스 계통에서는 Top - Middle - Bottom의 관계를 가지고 있지만, 나중에 확장되었을 때 다른 스마트 포인터 타입으로부터 Top 객체를 만들 방법도 마련되어야 하기 때문이다.
  • 원칙적으로 지금 우리가 원하는 생성자의 개수는 무제한이다.
    • 템플릿을 인스턴스화하면 무제한 개수의 함수를 만들어 낼 수 있다.
    • 그래서 SmartPtr에 생성자를 만들어내는 템플릿을 이용한다.
    • 생성자 템플릿은 멤버 함수 템플릿의 한 예인데, 멤버 함수 템플릿은 간단히 말해 어떤 클래스의 멤버 함수를 찍어내는 템플릿을 의미한다.
template<typename T>
class SmartPtr {
public:

    // 일반화된 복사 생성자를 만들기 위해 마련한 멤버 템플릿
    template<typename U>
    SmartPtr(const SmartPtr<U>& other);
};
  • 모든 T 타입 및 모든 U 타입에 대해서, SmartPtr<T> 객체가 SmartPtr<U>로부터 생성될 수 있다는 의미를 가진 코드이다.
  • 그 이유는 SmartPtr<U>의 참조자를 매개변수로 받아들이는 생성자가 SmartPtr<T> 안에 들어 있기 때문이다.
  • 이런 꼴의 생성자 - 같은 템플릿을 써서 인스턴스화되지만 타입이 다른 타입의 객체로부터 원하는 객체를 만들어 주는 - 즉, SmartPtr로부터 SmartPtr를 만들어내는 생성자를 가리켜 일반화 복사 생성자라고 부른다.
  • 위의 복사 생성자는 explicit으로 선언되지 않았다. 기본제공 포인터 타입 사이의 타입 변환이 암시적으로 이루어지며 캐스팅이 필요하지 않기 때문에 스마트 포인터도 동일한 형태로 동작하도록 하기 위하여 작성했다.
  • 현재 만들어 놓은 일반화 복사 생성자는 실제로 원하는 동작보다 더 많은 것을 한다.
    • SmartPtr<Bottom>으로부터 SmartPtr<Top>을 만들 수 있다.
    • SmartPtr<Top>으로부터 SmartPtr<Bottom>도 만들 수 있다.(원하지 않는 동작)
    • 이는 public 상속의 의미를 역행한다.
  • SmartPtr 도 get 멤버 함수를 통해 해당 스마트 포인터 객체에 자체적으로 담긴 기본제공 포인터 사본을 반환하다고 가정하면, 이를 이용해 생성자 템플릿에 우리가 원하는 타입 제약을 줄 수 있다.
template<typename T>
class SmartPtr {
public:

    // 이 SmartPtr에 담긴 포인터를 다른 SmartPtr에 담긴 포인터로 초기화
    template<typename U>
    SmartPtr(const SmartPtr<U>& other) : heldPtr(other.get()) {};

    T* get() const { reutrn heldPtr; }

private:
    T* heldPtr;            // SmartPtr에 담긴 기본 제공 포인터
};
  • 멤버 초기화 리스트를 사용해서 SmartPtr<T>의 데이터 멤버인 T* 타입의 포인터를 SmartPtr<U>에 들어 있는 U* 타입의 포인터로 초기화했다.
  • 이렇게 하면 U에서 T로 진행되는 암시적 변환이 가능할 때만 컴파일 에러가 나지 않는다.
  • 이제 SmartPtr의 일반화 복사 생성자는 호환되는 타입의 매개변수를 넘겨받을 때만 컴파일된다.
  • 멤버 함수 템플릿은 생성자에만 이용할 수 있는 것이 아니다. 가장 흔히 쓰이는 예는 대입 연산이다.
template<typename T>
class shared_ptr {
public:
    template<class Y>
    explicit shared_ptr(Y* p);                // 호환되는 모든 기본제공 포인터

    // shared_ptr, weak_ptr, auto_ptr 객체로부터 생성자 호출 가능
    template<class Y>
    shared_ptr(shared_ptr<Y> const& r);

    template<class Y>
    explicit shared_ptr < weak_ptr<Y> const& r);

    template<class Y>
    explicit shared_ptr < auto_ptr<Y>& r);

    // 호환되는 모든 shared_ptr, auto_ptre로부터 대입 가능
    template<class Y>
    shared_ptr& operator=(shared_ptr<Y> const& r);

    template<class Y>
    shared_ptr& operator=(auto_ptr<Y>& r);
};
  • 일반화 복사 생성자를 제외하고 모든 생성자가 explicit으로 선언되어 있다.
    • 이는 share_ptr 로 만든 어떤 타입으로부터 또 다른 타입으로 진행되는 암시적 변환은 허용되지만, 기본제공 포인터 혹은 또 다른 타입으로부터 변환되는 것은 막겠다는 의미이다.
  • shared_ptr 생성자와 대입 연산자에 넘겨지는 auto_ptr이 const로 선언되지 않는 것은 auto_ptr은 복사 연산으로 인해 객체가 수정될 때 오직 복사된 쪽 하나만 유효하기 때문이다.
  • 멤버 템플릿은 언어의 규칙을 바꾸지는 않는다. 멤버 템플릿 생성자가 있더라도 일반 생성자 정의는 필요하다. 복새 대입 연산자도 마찬가지이다.(이 함수들은 컴파일러가 없을 경우 자동으로 생성해주는 함수이다.)

항목 46: 타입 변환이 바람직할 경우에는 비멤버 함수를 클래스 템플릿 안에 정의해 두자

  • 모든 매개변수에 대해 암시적 타입 변환이 되도록 만들기 위해서는 비멤버 함수밖에 방법이 없다.
template<typename T>
class Rational {
public:
    Rational(const T& numerator = 0, const T& denominator);

    const T numerator() const;
    const T demonimator() const;
};

// 함수 템플릿 operator*
template<typename T>
const Rational<T> operator*(const Rational<T>& lhs, const Rational<T>& rhs);
  • 혼합형 수치 연산이 필요하기 때문에 operator*를 재정의하였는데, 다음과 같은 코드는 컴파일 에러가 발생한다.
Rational<int> oneHalf(1, 2);

Rational<int> result = oneHalf * 2;
  • 그 이유는 혼합형 수치 연산을 위해 호출해야 하는 operator* 함수가 무엇인지 컴파일러가 알 수 없기 때문이다.
    • 컴파일러는 operator* 라는 이름의 템플릿으로부터 인스턴스화할 함수를 결정하기 위해 온갖 계산을 동원하기만 한다.
    • 이 시점에 컴파일러가 확실히 아는 것은 Rational<T> 타입의 매개변수를 두 개 받아들이는 operator* 라는 이름의 함수를 인스턴스화해야 하는 것 뿐이다.
    • 여기에서 컴파일러는 T가 무엇인지 알 방법이 없다.
  • T의 정체를 파악하기 위해 컴파일러는 우선 operator* 호출 시에 넘겨진 인자의 모든 타입을 살핀다. 지금의 경우 Rational<int>int다.
    • oneHalf의 경우 첫 번째 매개변수가 되는데, Rational<int> 타입이기 때문에 T는 확실하게 int가 된다.
    • 2의 경우 타입을 유추하기가 힘들다.
      • Rational<int>에는 explicit으로 선언되지 않은 생성자가 있기 때문에, 컴파일러가 이를 이용해서 2를 Rational<int>로 변환하고 이를 통해 T가 int라고 유추할 수 있다고 예상해볼 수 있다. 하지만 컴파일러는 이렇게 작동하지 않는다.
      • 그 이유는 템플릿 인자 추론 과정에서는 암시적 타입 변환이 고려되지 않기 때문이다.
      • 함수 호출에 필요한 매개변수의 타입 변환은 진행 되는게 당연하지만, 어떤 함수를 호출해야 하는지 미리 알고 있어야 가능한 일이다.
  • 템플릿 인자 추론을 하는 방법은 클래스 템플릿 내부에 프렌드 함수를 넣어 두는 것이다.
    • 프렌드 함수를 넣는 것을 통해 함수 템플릿으로서의 성격을 주지 않고 특정한 함수 하나를 나타낼 수 있다는 사실을 이용하는 방법이다.
    • 이렇게 되면 Rational<T> 클래스에 대해 operator* 를 프렌드 함수로 선언하는 것이 가능하다.
    • 클래스 템플릿은 템플릿 인자 추론 과정에 좌우되지 않는다.(함수 템플릿에만 적용되는 과정)
    • 따라서 T의 정확한 정보는 Rational<T> 클래스가 인스턴스화될 당시에 바로 알 수 있다.
template<typename T>
class Rational {
public:
    // operator* 함수 선언
    friend const Rational operator*(const Rational& lhs, const Rational& rhs);
};

template<typename T>
const Rational<T> operator*(const Rational<T>& lhs, const Rational<T>& rhs) {};
  • 이제 oneHalf 객체가 Rational<int> 타입으로 선언되면 Rational<int> 클래스가 인스턴스로 만들어지고, 이때 그 과정의 일부로서 Rational<int> 타입의 매개변수를 받는 프렌드 함수인 operator*도 자동으로 선언된다.
  • 이전과 달리 지금은 함수가 선언된 것이므로(함수 템플릿이 아니라) 컴파일러는 이 함수 호출문에 대해 암시적 타입 변환을 적용할 수 있게 된다.
    • 그러나 현재 코드는 컴파일은 되지만, 링크가 되지 않는다.
    • 문법에 대해 먼저 살펴보자.
  • 클래스 템플릿 내부에서는 템플릿의 이름(<> 뗀 것)을 그 템플릿 및 매개변수의 줄임말로 쓸 수 있다.
    • Rational<T> 안에서는 Rational이라고만 써도 Rational<T>가 된다.
    • 위의 예제에서는 타이핑을 줄이기 위해 이를 사용했다.
    • 따라서 위의 코드는 다음과 정확히 똑같은 의미를 가진다.
template<typename T>
class Rational {
public:
    // operator* 함수 선언
    friend const Rational<T> operator*(const Rational<T>& lhs, const Rational<T>& rhs);
};
  • 링크의 문제로 돌아오면, 컴파일러가 우선 어떤 함수를 호출해야 하는지를 알게 됐으니 컴파일의 문제는 해결 됐다.
  • 그런데 이 함수는 Rational 안에서 선언만 되어 있지, 정의까지 되어 있는 것은 아니다.
  • 클래스 외부에 있는 프렌드 템플릿에서 함수 정의를 제공하고 싶은게 의도였다.
  • 따라서 가장 간단한 해결 방법은 함수의 본문을 선언부와 붙이는 것이다.
template<typename T>
class Rational {
public:
    // operator* 함수 선언 및 정의
    friend const Rational operator*(const Rational& lhs, const Rational& rhs)
    {
        return Raional(lhs.numerator() * rhs.numerator(), lhs.denominator() * rhs.denominator());
    }
};
  • 이제 컴파일, 링크가 모두 정상적으로 되는 코드가 나왔다.
  • 프렌드 함수를 선언했지만, 클래스의 public 영역이 아닌 부분에 접근하기 위함과는 아무런 관계가 없다는 것을 명심해야 한다.
    • 모든 인자에 대해 타입 변환이 가능하도록 만들기 위해 비멤버 함수가 필요하고, 호출 시의 상황에 맞는 함수를 자동으로 인스턴스화하기 위해서는 그 비멤버 함수를 클래스 안에 선언해야 하는데, 클래스 안에서 비멤버 함수를 선언하는 방법이 프렌드 함수 밖에 없기 때문이다.
  • 클래스 안에 정의된 함수는 암시적으로 인라인으로 선언된다.
  • 따라서 클래스의 바깥에서 정의된 도우미 함수만 호출하는 식으로 구현하면 함수의 본문이 복잡한 경우에는 좋은 성능을 기대해볼 수 있다.
// Rational 템플릿 선언
template<typename T> class Rational;

// 도우미 함수 템플릿 선언
template<typename T>
const Rational<T> doMultiply(const Rational<T>& lhs, const Rational<T>& rhs);

template<typename T>
class Rational {
public:

    // 프렌드 함수가 도우미 함수 호출
    friend const Rational operator* (const Rational& lhs, const Rational& rhs)
    {
        return doMultiply(lhs, rhs);
    }
};
  • 대다수의 컴파일러에서 템플릿 정의를 헤더 파일에 전부 넣을 것을 사실상 강제로 강요하다시피 하고 있으니, doMultiply 함수도 헤더 파일 안에 정의해 넣어야 할 것이다.
    • 이런 템플릿은 반드시 인라인일 필요는 없다.
    • 정의를 추가하면 다음과 같은 형태가 될 것이다.
// 도우미 함수 템플릿 선언 및 정의
template<typename T>
const Rational<T> doMultiply(const Rational<T>& lhs, const Rational<T>& rhs)
{
    return Rational<T>(lhs.numerator() * rhs.numerator(), lhs.denominator() * rhs.denominator());
}
  • doMultiply 함수는 템플릿으로서 혼합형 곱셈을 지원하지 못하지만, 지원할 필요가 없다.
  • 이 템플릿을 사용하는 고객은 operator* 밖에 없는데, operator*가 이미 혼합형 연산을 지원하고 있기 때문이다.
    • operator* 함수는 자신이 받아들이는 매개변수가 제대로 곱해지도록 어떤 타입도 Rational 객체로 바꿔준다.
    • 이렇게 바꾼 Rational 객체 두 개는 doMultiply 템플릿 함수의 인스턴스가 받아 실제 곱셈에 사용한다.

메모

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

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

항목 46: 타입 변환이 바람직할 경우에는 비멤버 함수를 클래스 템플릿 안에 정의해 두자

  • 모든 매개변수에 대해 암시적 타입 변환을 지원하는 템플릿과 관계가 있는 함수를 제공하는 클래스 템플릿을 만들려고 한다면, 이런 함수는 클래스 템플릿 안에 프렌드 함수로서 정의하자.