스토리텔링 개발자

[More Effective C++] 28. 스마트 포인터 본문

개발/More Effective C++

[More Effective C++] 28. 스마트 포인터

김디트 2024. 9. 9. 12:00
728x90

항목 28. 스마트 포인터(Smart Pointer)

 

 

 

스마트 포인터를 사용하는 이유
  1. 생성과 소멸 작업을 조절할 수 있다.
    • 스마트 포인터가 생성되고 소멸되는 시기를 사용자가 결정할 수 있다.
    • 초기값을 스마트 포인터가 컨트롤하므로, 쓰레기값 문제가 없다.
    • 객체를 알아서 소멸시켜주므로 리소스 누수가 없다.
  2. 복사와 대입 동작을 조절할 수 있다.
    • 스마트 포인터가 복사되고 대입될 때의 일을 사용자가 결정할 수 있다.
    • 깊은 복사와 얕은 복사를 원하는 대로 정의할 수 있다.
    • 혹은 복사 대입을 전혀 사용하지 못하도록 막을 수도 있다.
  3. 역참조 동작을 조절할 수 있다.
    • 스마트 포인터가 가리키는 객체를 가져오려고 할 때 일어나는 일을 결정할 수 있다.
    • 지연 방식 데이터 / 명령어 가져오기(fetching)(항목 17 참조)

 

 

 

스마트 포인터의 생김새
  • 범용성을 위해 템플릿으로 구현되어 있다.
template<typename T>
class SmartPtr
{
public:
    SmartPtr(T* realPtr = 0); // 포인터에 쓰레기값이 들어가지 않도록
    SmartPtr(const SmartPtr& rhs); // 복사 생성자
    ~SmartPtr(); // 소멸자
    
    SmartPtr& operator=(const SmartPtr& rhs); // 대입 연산자
    
    T* operator->() const; // 역참조한 포인터 멤버
    T& operator*() const; // 역참조
private:
    T* pointee; // 스마트 포인터가 가리키는 실제 객체의 주소
};

 

 

 

스마트 포인터의 생성, 대입, 소멸
  • 생성
    • 가리킬 객체를 하나 준비하고, 그 객체의 포인터를 스마트 포인터 내부에서 가리키도록 한다.
    • 가리킬 객체가 없으면 내부 포인터를 널로 처리하거나, 에러를 알린다.
  •  대입과 소멸
    • 복사 생성자, 대입 연산자, 소멸자 구현은 소유권 때문에 꽤 복잡하다.
    • 자신이 가리키는 객체는 소멸 시에 삭제해줘야 하지만...
      • 만약 복사나 대입이 되었다면 삭제해도 안전할까?
auto_ptr<TreeNode> ptn1(new TreeNode);

// 복사 생성자
auto_ptr<TreeNode> ptn2 = ptn1;

// 대입 연산자
auto_ptr<TreeNode> ptn3;
ptn3 = ptn2;

// 그리고 ptn1이 범위를 벗어나면서 소멸자가 호출되면?
// ptn2, ptn3도 같은 포인터를 가리키므로 댕글링 포인터가 발생하지 않을까?
  • 해결법을 강구해보자.
    1. 복사시, new를 사용하여 가리키는 객체를 깊은 복사 해버린다?
      • 불필요하게 수행 성능의 저하가 발생할 수 있다.
      • 스마트 포인터의 타입과 실제 객체의 타입이 일치하지 않을 수 있다.
        • T 타입의 파생 클래스라도 문제없이 가질 수 있기 때문이다.
        • 그렇다면 잘못된 클래스(기본 클래스)로 파생 클래스가 깊은 복사가 될 여지가 있다.
    2. 복사나 대입을 못하게 한다?
      • 너무 큰 불편함을 야기한다.
    3. 복사 혹은 대입될 때 소유 관계를 옮긴다?
      • auto_ptr의 기능이 바로 그것이다.
      • 하지만 이 역시 다양한 문제를 야기하기 때문에, 모던 C++에서는 이동으로만 소유권을 옮길 수 있는 unique_ptr을 사용한다.
      • auto_ptr은 C++17에서 완전히 제거되었다.

 

 

 

auto_ptr(C++17에서 제거됨)
  • 복사 혹은 대입될 때 소유 관계를 옮기는 스마트 포인터
template<typename T>
class auto_ptr
{
...
public:
    auto_ptr(auto_ptr<T>& rhs); // 복사 생성자
    auto_ptr<T>& operator=(auto_ptr<T>& rhs); // 대입 연산자
...
};
template<typename T>
auto_ptr<T>::auto_ptr(auto_ptr<T>& rhs)
{
    pointee = rhs.pointee; // 본 객체를 옮긴다.
    rhs.pointee = nullptr; // 기존의 본 객체는 invalid
}

template<typename T>
auto_ptr<T>& auto_ptr<T>::operator=(auto_ptr<T>& rhs)
{
    if(this == &rhs) // 자기 자신에게 대입 시에는 아무 일도 없다.
        return *this;
        
    delete pointee; // 자신이 가진 객체를 삭제한다.
    
    pointee = rhs.pointee; // 소유권을 옮긴다.
    rhs.pointee = nullptr; // 기존의 본 객체는 invalid
    
    return *this;
}
  • 이 경우 값으로 함수의 매개변수에 auto_ptr을 넘기게 되면 문제가 된다.
// 재앙을 일으키는 함수
void printTreeNode(ostream& s, auto_ptr<TreeNode> p) { s << *p; }

int main()
{
    auto_ptr<TreeNode> ptn(new TreeNode);
    
    // auto_ptr이 전달된다!!
    // 즉, 복사가 발생한다.(복사 생성자)
    printTreeNode(cout, ptn);
    // ptn은 nullptr을 참조하게 되며,
    // 복사된 매개변수는 유효범위를 벗어나며 삭제되고 만다..
}
  • 참조자로 전달해서 위기를 모면하면 된다.
void printTreeNode(ostream& s, const auto_ptr<TreeNode>& p)
{
    s << *p;
}
  • 추가로 확인할 만한 사항
    1. auto_ptr의 복사 생성자, 대입 연산자의 매개변수 auto_ptr은 const가 아니다.
      • 매개변수의 상태가 바뀌는지 아닌지, 인터페이스만으로 알아볼 수 있다.
      • 만약 복사 생성자, 대입 연산자가 const 매개변수만 받아야 한다고 C++이 제약을 가했으면 온갖 트릭을 사용해야 했을 것이다..
    2. auto_ptr의 소멸자 구현
      • template<typename T>
        SmartPtr<T>::~SmartPtr()
        {
            if(*this가 *pointee를 소유하면)
                delete pointee;
        }
      • 여기서 점검부(if문)은 auto_ptr에서는 필요가 없다.
        • 반드시 객체를 소유하고 있다고 봐도 무방하기 때문이다.

 

 

 

역참조(Dereferencing) 연산자 구현
operator*
template<typename T>
T& SmartPtr<T>::operator*() const
{
    스마트 포인터 처리 수행;
    // 예를 들면, 지연 방식(항목 17 참조)으로 가져오게 되어 있다면 객체 불러오기 처리
    
    return *pointee;
}
  • 반환값은 참조자로 구현되어 있다. 값으로는 절대 반환하지 말자.
    • 왜냐하면 pointee는 반드시 T라고 할 수 없기 때문이다.
      • T의 하위 클래스일 수도 있다. 그 경우 슬라이스 문제가 발생한다. (항목 13 참조)
    • 더군다나 당연하게도 값보다 참조자로 반환하는 것이 더 효율적이다.
  • nullptr에 대해 operator*를 호출하면 어떻게 될까?
    • 원하는 대로 에러 처리를 하자.
    • 널 포인터에 대해 역참조한 결과는 정의되지 않았기 때문이다.

 

 

 

역참조(Dereferencing) 연산자 구현
operator->
  • operator*의 동작과 별반 다르지 않다.
  • 하지만, 아래의 상황에서의 의미를 고찰해볼 필요가 있다.
void editTuple(DBPtr<Tuple>& pt)
{
    LogEntry<Tuple> entry(*pt);
    
    do
    {
        pt->displayEditDialog();
        // 이는 다음과 같이 해석된다.
        // (pt.operator->())->displayEditDialog();
        // 즉,
        // 1. operator->가 실행되고,
        // 2. 멤버 지정 연산자(->)가 실행된다.
        // 이는 멤버 지정 연산자(->)는 operator->의 반환값에 상관 없이 잘 동작해야 한다는 의미.
    }
    while (pt->isValid() == false);
}
  • 보통 이런 식으로 구현된다.
template<typename T>
T* SmartPtr<T>::operator->() const
{
    스마트 포인터 처리를 수행한다;
    
    return pointee;
}
  • 포인터가 리턴되므로, operator->를 호출한 곳에서 멤버 지정 연산자는 문제 없이 동작하게 된다.

 

 

 

스마트 포인터가 널(nullptr)인지 점검
  • 위의 구현만으로는 스마트 포인터는 raw 포인터는 가능한, 아래와 같은 사용이 불가능하다.
SmaprtPtr<TreeNode> ptn;

if(ptn == nullptr) ... // 에러
if(ptn) ... // 에러
if(!ptn) ... // 에러
  • 어떻게 해결해볼 방법이 있을까?
  • 방법 1 : 암시적 타입변환 연산자를 구현하기
template<typename T>
class SmartPtr
{
public:
    ...
    operator void*();
    // 스마트 포인터가 널이면 nullptr 반환, 아니면 포인터 반환
    ...
};

SmartPtr<TreeNode> ptn;

if(ptn == nullptr) ... // 동작
if(ptn) ... // 동작
if(!ptn) ... // 동작
  • 이 방법은 iostream 클래스가 제공하는 타입변환 방법과 유사하다.
ifstream inputFile("datafile.dat");
if(inputFile) ... // inputFile이 제대로 열렸으면 true 반환
  • 이 방법의 단점은, 실패할 것 같은 호출에 대해서도 실패시키지 않는다는 점이다.(항목 5 참조)
SmartPtr<Apple> pa;
SmartPtr<Orange> po;

// 전혀 다른 두 개의 타입을 비교하는데..
if(pa == po) ... // 컴파일이 성공한다!!
// operator== 함수가 없더라도 문제 없는 코드이다.
// void* 포인터로 암시적 변환이 가능하기 때문이다.
  • 방법 2 : operator! 연산자를 오버로딩하기
  • 위의 방법에 비해 실수에 안전하고 그나마 가독성 있는 사용법을 제공하게 된다.
template<typename T>
class SmartPtr
{
public:
    ...
    bool operator!() const; // 스마트 포인터가 널일 때만 true 반환
    ...
};

SmartPtr<TreeNode> ptn;

if(!ptn == false) ... // 성공

// 허나, 아래의 코드는 실패
if(ptn == nullptr) ... // 실패
if(ptn) ... // 실패

// 주의해야 하는 경우
SmartPtr<Apple> pa;
SmartPtr<Orange> po;
if(!pa == !po) ... // 안타깝게도 컴파일 성공

 

 

 

스마트 포인터를 raw 포인터로 변환하기
  • 기존의 raw 포인터를 스마트 포인터로 바꿔버리고 싶다면, 이 변환이 필요해진다.
class Tuple { ... };
void normalize(Tuple* pt); // 다음과 같은 인터페이스가 있었다면

DBPtr<Tuple> pt; // 우리는 위 함수에 스마트 포인터를 넣고 싶다.

normalize(pt); // 스마트 포인터를 넣어보지만.. 컴파일 실패
// DBPtr<Tuple>에서 Tuple*로 변환하는 데 필요한 것이 아무것도 없다.

normalize(&*pt); // 못생겼지만, 컴파일은 성공
  • 좀 더 우아하게 해결해보자.
  • 스마트 포인터 템플릿에다가 raw 포인터로의 암시적 변환 연산자를 추가해준다.
template<typename T>
class DBPtr
{
public:
    ...
    // T*로의 암시적 변환 추가
    operator T*() { return pointee; }
    ...
};
DBPtr<Tuple> pt;

normalize(pt); // 컴파일 성공

// 이제 널 점검에 대한 고민도 사라진다.

if(pt == nullptr) ... // Tuple*로 변환되어 성공
if(pt) ... // Tuple*로 변환되어 성공
if(!pt) ... // Tuple*로 변환되어 성공
  • 하지만 암시적 변환 비직관적인 문제들이 산재한다. (항목 5 참조)
  • 더군다나 아래처럼 마구잡이로 사용할 여지가 있으므로 위험하다.
void processTuple(DBPtr<Tuple>& pt)
{
    Tuple* rawTuplePtr = pt; // DBPtr<Tuple>을 Tuple*로 변환
    
    rawTuplePtr을 써서 튜플 데이터를 바꾼다;
}
  • 그리고 raw 포인터로의 암시적 변환이 모든 상황을 대처하진 않는다.
    • 사용자 정의 타입 변환을 암시적으로 두 번 이상 하는 건 C++이 금지하고 있기 때문이다.
class TupleAccessors
{
public:
    TupleAccessors(const Tuple* pt);
    ...
}

// 다음과 같은 함수가 있다면
TupleAccessors merge(const TupleAccessors& ta1, const TupleAccessors& ta2);

// 문제 없는 상황
Tuple* pt1;
Tuple* pt2;
marge(pt1, pt2);

// 하지만 스마트 포인터일 때는 문제가 생긴다.
DBPtr<Tuple> pt1;
DBPtr<Tuple> pt2;
marge(pt1, pt2); // 컴파일 에러. TupleAccessors로 바꿀 방법이 없다.
  • 정말 크리티컬한 이슈도 남았다!
  • 스마트 포인터에 대해 delete를 사용해버린다면?
DBPtr<Tuple> pt = new Tuple;
delete pt; // 암시적 변환으로 인해 컴파일이 성공해버린다!
  • 이 경우, 해당 포인터는 두 번 삭제될 것이다.
    1. delete 문으로 인한 삭제
    2. DBPtr의 소멸자로 인한 삭제
  • 두 번 삭제는 미정의 사항이므로 프로그램이 어떻게 동작할지 알 수 없다.
  • 그러므로 정말 정말 필요하지 않다면 raw 포인터로의 암시적 변환을 지원하진 말자.

 

 

 

스마트 포인터와 상속 기반의 타입변환
  • 기본적으로 스마트 포인터는 다형성 기반을 제공하지 않는다.
class MusicProduct
{
public:
    MusicProduct(const string& title);
    virtual void play() const = 0;
    virtual void displayTitle() const = 0;
    ...
};
class Casssette : public MusicProduct
{
public:
    Cassette(const string& title);
    virtual void play() const;
    virtual void displayTitle() const;
    ...
};
class CD : public MusicProduct
{
public:
    CD(const string& title);
    virtual void play() const;
    virtual void displayTitle() const;
    ...
};

void displayAndPlay(const MusicProduct* pmp, int numTimes)
{
    for(int i = 1 ; i <= numTimes ; ++i)
    {
        // 상속 계통을 사용한 가상 함수 호출
        pmp->displayTitle();
        pmp->play();
    }
}

Cassette* funMusic = new Cassette("Alapalooza");
CD* nightmareMusic = new CD("Disco Hits of the 70s");

// 별반 문제 없는 다형성 사용
displayAndPlay(funMusic, 10);
displayAndPlay(nightmareMusic, 0);

// 근데 스마트 포인터를 사용하면 이 평범한 코드가 불가능하다.
// 스마트 포인터를 사용하게 인터페이스 수정
void displayAndPlay(const SmartPtr<MusicProduct>& pmp, int numTimes);

SmartPtr<Cassette> funMusic(new Cassette("Alapalooza"));
SmartPtr<CD> nightmareMusic(new CD("Disco Hits of the 70s"));

displayAndPlay(funMusic, 10); // 컴파일 에러!
displayAndPlay(nightmareMusic, 0); // 컴파일 에러!
  • 다행스럽게도 이 제약을 벗어날 방법이 있다.
  • 위에서 사용했던 암시적 변환 방법을 사용하는 것이다.
class SmartPtr<Cassette>
{
public:
    operator SmartPtr<MusicProduct>() // SmartPtr<MusicProduct> 로의 암시적 변환
    {
        return SmartPtr<MusicProduct>(pointee);
    }
    ...
private:
    Cassette* pointee;
};

class SmartPtr<CD>
{
public:
    operator SmartPtr<MusicProduct>() // SmartPtr<MusicProduct> 로의 암시적 변환
    {
        return SmartPtr<MusicProduct>(pointee);
    }
    ...
private:
    CD* pointee;
};
  • 이 방법의 두 가지 단점
    1. SmartPtr 클래스를 타입 매개변수에 귀속되는 구현이므로 템플릿의 취지와는 충돌한다.
    2. 가리키는 객체가 가지고 있는 클래스 계통 구조가 깊으면 깊을수록 더 많은 변환 연산자를 추가해야 한다.
  • 컴파일러가 알아서 암시적 타입 변환을 해주면 되는 거 아냐?
    • 다행스럽게도 (비가상) 멤버 함수 템플릿(멤버 템플릿)을 선언해서 해결할 수 있다.
template<typename T>
class SmartPtr
{
public:
    SmartPtr(T* realPtr = 0);
    
    T* operator->() const;
    T& operator*() const;
    
    // 암시적 변환 연산자를 위한 템플릿 함수
    template<typename newType>
    operator SmartPtr<newType>()
    {
        return SmartPtr<newType>(pointee);
    }
    ...
};
  • 이제 아래의 코드는 정상적으로 컴파일된다.
void displayAndPlay(const SmartPtr<MusicProduct>& pmp, int howMany);

SmartPtr<Cassette> funMusic(new Cassette("Alapalooza"));
SmartPtr<CD> nightmareMusic(new CD("Disco Hits of the 70s"));

displayAndPlay(funMusic, 10); // 컴파일 성공
displayAndPlay(nightmareMusic, 0); // 컴파일 성공
  • 컴파일 성공 과정
    1. displayAndPlay 함수는 SmartPtr<MusicProduct> 객체를 받도록 되어 있다.
    2. 타입 불일치를 감지하고 funMusic을 SmartPtr<MusicProduct> 객체로 바꿀 수단을 찾기 시작한다.
    3. SmartPtr<MusicProduct> 클래스에서 SmartPtr<Cassette>를 취하는 단일 인자 생성자를 찾지만, 실패한다.
    4. SmartPtr<Cassette> 클래스 안에서 암시적 타입 연산자(SmartPtr<MusicProduct>로 바꿔주는)를 찾지만, 실패한다.
    5. 암시적 타입 변환을 해주는 멤버 함수 템플릿을 찾고, SmartPtr<Cassette>안에서 찾아낸다.
      • 다음과 같이 함수를 찍어낸다.
      • SmartPtr<Cassette>::operator SmartPtr<MusicProduct>()
        {
            return SmartPtr<MusicProduct>(pointee);
        }
    6. SmartPtr<MusicProduct>의 생성자가 pointee를 받아서 정상 처리됨을 확인한다.
    7. 스마트 포인터 타입의 암시적 변환 성공!
  • 이는 업 캐스팅 뿐 아니라 다운 캐스팅도 해내는 방법이므로 강력하다.
  • 하지만 아래의 경우는 컴파일이 실패하게 된다.
// 새로운 하위 클래스
class CasSingle : public Cassette { ... }

template<typename T>
class SmartPtr { ... };

// displayAndPlay의 두 가지 버전
void displayAndPlay(const SmartPtr<MusicProduct>& pmp, int howMany);
void displayAndPlay(const SmartPtr<Cassette>& pc, int howMany);

// 스마트 포인터 선언
SmartPtr<CasSingle> dumbMusic(new CasSingle("Achy Breaky Heart"));

displayAndPlay(dumbMusic, 1); // 어느 버전을 선택할지 모호하기에 컴파일 에러!!!
  • 이것 외에 추가적인 스마트 포인터의 변환을 멤버 템플릿으로 구현하는 방법의 두 가지 단점
    1. 멤버 템플릿을 제대로 지원하는 환경이 많지 않기 때문에 이식성이 떨어진다.
      • 하지만 이는 옛날 이야기로, 이제는 웬만하면 지원할 것이다.
    2. 이 방법의 동작 원리를 제대로 이해하기 힘들다.
      • 함수 호출 시의 인자 일치 규칙을 알아야 한다.
      • 암시적 타입 변환 함수 및 암시적 템플릿 함수 인스턴스화도 알아야 한다.
      • 멤버 함수 템플릿도 알아야 한다.
  • 그래서 결론은?
    • 스마트 포인터 클래스가 상속 기반의 타입변환에 대해서도 raw 포인터처럼 동작하도록 구현할 수 있을까?
    • 절대로 불가능하지만, 위의 방법들을 사용해서 어색하게나마 보완할 수 있다.

 

 

 

스마트 포인터와 const
  • raw 포인터에 대해서는 const를 두 가지 용법으로 사용할 수 있다.
    1. 포인터가 가리키는 것을 상수로 한다.
    2. 포인터 그 자체를 상수로 한다.
CD goodCD("Flood");

// 상수 CD 객체에 대한 비상수 포인터
const CD* p;

// 비상수 CD 객체에 대한 상수 포인터
// 상수 포인터이므로 초기화가 필수이다.
CD* const p = &goodCD;

// 상수 CD 객체에 대한 상수 포인터
const CD* const p = &goodCD;
  • 이 개념을 스마트 포인터에도 어떻게 적용시킬 수 있지 않을까?
  • 스마트 포인터의 경우에는 포인터에 대해서만 상수로 만들 수 있다.
// 비상수 CD 객체에 대한 상수 스마트 포인터
const SmartPtr<CD> p = &goodCD;
  • 아니, 이렇게 하면 객체에 대해서도 상수화 할 수 있지 않을까?
// 상수 CD 객체에 대한 비상수 스마트 포인터?
SmartPtr<const CD> p = &goodCD;
  • 하지만 대입에서 이 스마트 포인터가 가리키는 객체 상수화의 문제점이 드러난다.
CD* pCD = new CD("Famous Movie Themes");
const CD* pConstCD = pCD; // raw 포인터의 경우 컴파일 성공

SmartPtr<CD> pCD = new CD("Famous Movie Themes");
SmartPtr<const CD> pConstCD = pCD; // 컴파일 에러!!!
// SmartPtr<CD>와 SmartPtr<const CD>는 완전히 다른 타입이다.
  • 이를 해결하기 위해서는 타입을 변환해주는 함수가 있어야 한다.
    • 앞에서처럼 암시적 타입 변환 연산자를 사용할 수 있을 것이다.
  • const가 붙은 포인터나 객체 사이의 타입 변환은 일방통행임을 명심하며 구현해야 한다.
    • 비상수 -> 상수는 안전하지만, 상수 -> 비상수는 위험하다.
    • 이는 public 상속 규칙과 흡사하다.
      • 파생 클래스 -> 기본 클래스는 안전하지만, 기본 클래스 -> 파생 클래스는 위험하다.
    • 그렇다면 이 특성을 활용해서 스마트 포인터 구현에 응용할 수 있지 않을까?
      • T 객체 스마트 포인터 클래스와 const T 객체 스마트 포인터 클래스를 is-a 상속 관계로 만든다.
template<typename T>
class SmartPtrToConst
{
    ...
protected:
    // T*용, const T*용 포인터 공간을 각각 마련하는 것은 아무래도 메모리 낭비이므로
    // union을 사용해서 처리한다.
    union
    {
        const T* constPointee; // SmartPtrToConst용
        T* pointee; // SmartPtr용
    };
};

template<typename T>
class SmartPtr : public SmartPtrToConst<T> { ... }

 

 

 

 

정리
  • 스마트 포인터는 구현하기 까다롭고, 이해하기 쉽지 않으며, 유지보수도 어렵다.
  • 더군다나 아무리 노력해도 raw 포인터를 완벽하게 대신할 수 있는 스마트 포인터는 절대 설계할 수 없다.
  • 그렇지만, 그럼에도 불구하고 스마트 포인터는 너무나 매력적이므로 꼼꼼히 잘 따져서 적절히 사용할 것.
728x90
Comments