C++ 이야기 네번째: boost::shared_ptr 소개

Posted at 2007. 3. 12. 07:00 // in S/W개발/C++ 이야기 // by 김윤수


C++ 이야기 네번째입니다. 개발자가 아니신 분들이나 C++ 로 주로 개발하지 않으시는 분들은 별로 관심이 가는 내용이 아닐 것 같네요.

이번에는 auto_ptr 과 같이 Smart Pointer의 일종이지만 작동 방식이 약간 다른 boost::shared_ptr 을 소개해 드릴까 합니다.(이번에는 좀 깁니다. 각오하시고 보세요)

눈치 빠른 분들은 이름에서 감을 잡으셨겠지만, shared_ptr은 자신이 가리키고 있는 객체에 대한 참조 카운트를 유지하는 녀석입니다. 어떻게 유지하냐구요 ? 그거야 복사 동작이 일어날 때는 참조 카운트를 늘리고, 소멸이 일어날 때는 참조 카운트를 줄이는 거죠. 그러다가 참조 카운트가 0이 될 때, 그제서야 가리키고 있는 객체를 소멸시키게 됩니다. 참 똑똑한 녀석입니다.

게다가 더 좋은 점은 auto_ptr 처럼 복사할 때 이상하게 동작하는 녀석이 아니라는 겁니다. 보통 복사하는 동작이랑 거의 비슷하기 때문에 복사가 훨씬 자연스럽습니다-그렇다고 완전히 동일한 건 아닙니다. deep copy가 아니라 참조 카운트만 하나 늘리는 것이니깐요.

그럼 shared_ptr을 어떻게 쓸 수 있는지 볼까요 ?

#include "BigClass.h"
// shared_ptr을 쓰기 위해 include 해야 합니다.
#include “boost/shared_ptr.hpp”

typedef boost::shared_ptr<BigClass> BigClassSharedPtr;
void f()
{
  // auto_ptr 대신 shared_ptr을 씁니다.
  // 참조 카운트는  당연히 1로 초기화 되겠지요.
  BigClassSharedPtr bcsp1(new BigClass());
  // 이런 저런 일을 합니다.

  // bcsp1에서 bcsp2로 소유권 이전이 되지 않고,
  // 참조 카운트만 2로 늘어납니다.
  BigClassSharedPtr bcsp2(bcsp1);

  // bcsp1은 여전히 유효한 객체를 가리키고 있습니다

  // ‘->’ 로  멤버 접근도 그대로 할 수 있구요.
  int nprop = bcsp1->GetProperty();
  // 역참조 연산자 ‘*’ 를 그대로 쓸 수 있습니다.
  BigClass bc = *bcsp2;              
  ......                              // 이런 저런 일을 합니다.
} // 이 시점에서 bcsp2 와 bcsp1의 소멸자가 차례로-생성된 순서의 거꾸로-불리면서,
  // 참조 카운트가 0이 되기 때문에 shared_ptr 이 가리키는 객체도 소멸됩니다.

어떻습니까? 아름답죠? 예외 안전성도 확보하면서 메모리 새는 걱정까지 막았으니까요. 게다가 복사도 맘대로할 수 있고.

이 코드를 보시고 나더니 저기 머리 회전 빠르신 분들 새로운 사용처를 금방 찾아내셨군요. 포인터로 객체를 가리키는 멤버를 가지고 있는 클래스에서 그냥 포인터 대신 shared_ptr을 쓰면, 소멸자에서 포인터로 가리키고 있는 객체를 일일이 삭제하지 않아도 된다구요 ? 예, 똑똑하시네요. 하나를 가르쳐 주면 둘을 아는 훌륭한 학생이네요. 예를 들어, 아래와 같은 BigClass 를 좀 나이스하게 바꿔 보자는 것 같네요.

// 다음은 BigClassImpl.h 에 정의되어 있습니다.
// 실제 implementation이 정의되어 있는 클래스입니다.
class BigClassImpl {
private:
  int m_nprop;
 
public:
  BigClassImpl(int nprop = 0);
  int GetProperty();
  void SetProperty(int nprop);
};

// 다음은 BigClass.h에 정의되어 있습니다.
class BigClassImpl;

// 인터페이스가 정의되어 있는 클래스입니다.
class BigClass {
private:
  BigClassImpl* m_pImpl;
 
public:
  BigClass(int nprop = 0): m_pImpl(new BigClassImpl(nprop)) {}
  ~BigClass() { delete m_pImpl; }
  int GetProperty() { m_pImpl->GetProperty(); }
  void SetProperty(int nprop) { m_pImpl->SetProperty(nprop); }
};
 
소멸자에서 delete m_pImpl; 이라고 객체를 일일이 삭제하고 있는 걸 확인하실 수 있습니다. 그리고 이 코드는 복사 생성자와 복사 대입 연산자가 정의되어 있지 않기 때문에 이렇게 정의된 클래스 인스턴스를 이리 저리 굴려먹다가 한 번 삭제하면 그 다음부터 다른 인스턴스는 모두 껍데기만 있는 해골 바가지가 될 것 입니다. m_pImpl 멤버가 있지도 않은 객체를 가리킬 것이니까요. 자~ 다음 코드를 한 번 보세요.

#include “BigClassImpl.h”
#include “BigClass.h”
 
BigClass* f()
{
  BigClass bc(1000);
  BigClass pbc =
    new BigClass(bc);  // 값을 리턴하기 위해 bc를 가지고 복사 생성합니다.
                       // 기본 복사 생성자는 그냥 m_pImpl 포인터만 복사
                       // 하게 될 것입니다.
  ......               // 이런 저런 일을 하다가
  return pbc;          // pbc를 리턴합니다.
} // 이 시점에서 bc.m_pImpl이 가리키던 객체는
  // 저 세상으로 갑니다. 헉! 그럼 pbc->m_pImpl 이
  // 가리키던 객체는 ?

이런 복잡한 문제들을 한 방에 날려버릴 비장의 무기가 여러분에게 있으니 바로 shared_ptr 입니다. 그냥 BigClass 포인터 대신에 shared_ptr<BigClass> 를 쓰는 거죠. 여러분이 객체 소멸자에 별다른 내용을 써 주지 않으면 컴파일러는 여러분이 클래스 정의에 멤버 변수를 써준 그 역순으로 멤버 변수들의 소멸자를 호출하도록 되어 있는 것을 잘 아실 겁니다. 그 단순한 사실을 이용하는 거죠. 그렇다면 BigClass 소멸자가 호출되는 과정에서 shared_ptr<BigClass>의 소멸자도 호출되고, 그 와중에 shared_ptr<BigClass> 가 가리키던 객체도 소멸하게 될 것입니다. 그러니 애써서 BigClass 소멸자에서 일일이 신경 써 가며 delete m_pimpl을 호출할 일이 없어지는 거죠.

BigClass 정의를 이렇게 바꾸는 겁니다.

// 다음은 BigClass.h에 정의되어 있습니다.
#include “boost/shared_ptr.hpp”

class BigClassImpl;
 
// 인터페이스가 정의되어 있는 클래스입니다.
class BigClass {
private:
  typedef boost::shared_ptr<BigClassImpl> BigClassImplSharedPtr;
  BigClassImplSharedPtr m_spImpl;

public:
  BigClass(int nprop = 0): m_spImpl(new BigClassImpl(nprop)) {}
  ~BigClass() {}          // 원래 코드와 달리 delete m_spImpl 이 없습니다
  int GetProperty() { m_spImpl->GetProperty(); }
  void SetProperty(int nprop) { m_spImpl->SetProperty(nprop); }
};

게다가 아까 문제가 있던 코드도 문제가 없어집니다. 진짜로요. 여러분이 BigClass*를 shared_ptr<BigClass> 라고 바꾸는 순간 포인터와의 전쟁에서 여러분이 유리한 고지를 점령하게 되는 겁니다. 못 믿겠다고요 ? 에이 속고만 사셨나~ 정 못 믿겠다면 자세히 들여다 보기로 하죠.

#include “BigClassImpl.h”
#include “BigClass.h”
 
BigClass* f()
{
  BigClass bc(1000);
  BigClass* pbc =
    new BigClass(bc);      // 값을 리턴하기 위해 bc를 가지고 복사 생성합니다.
                           // 기본 복사 생성자는 그냥 m_spImpl 포인터를 복사
                           // 하게 되고, m_spImpl은 참조 카운트를 2로 증가
                           // 시킵니다.
  ......                   // 이런 저런 일을 하다가
  return pbc;              // pbc를 리턴합니다.
}  // 이 시점에서 bc.m_spImpl 의 소멸자가 불리면
   // 참조 카운트가 1로 됩니다. pbc->m_sImpl 이
   // 가리키던 객체는 포인터와의 전쟁에서 살아남게 됩니다.

여기에 한 술 더 떠서 f() 가 BigClass 포인터를 직접 리턴하는 것이 아니라, shared_ptr<BigClass>를 리턴하도록 할 수도 있을 겁니다.

// typedef 매직을 썼다고 가정하죠.
BigClassSharedPtr f()
{
  BigClassSharedPtr spbc(new BigClass(1000));  // 참조 카운트 1이됨 더불어
                                               // 예외 안전성 확보
  ......
  return spbc;
} // 복사 생성자로 임시 객체를 하나 생성하면서 참조 카운트가 2가 됐다가
  // 지역 변수인 spbc가 소멸되면서 다시 1이 됩니다. 문제 없이 객체가 리턴됩니다.




shared_ptr을 써 먹을 수 있는 곳이 또 있습니다. 바로 표준 컨테이너에 써 먹을 수 있습니다. 아직 표준 컨테이너에 대해 설명 드리진 않았지만 vector 정도는 다들 잘 아신다고 생각하고, 한 번 설명드려 보겠습니다.(잘 모르시는 분들을 위해 살짝 설명 드리면, vector는 알아서 줄어들었다 늘어났다 하는 편한 array 정도로 알고 계서도 될 것 같습니다)

표준 컨테이너는 모든 객체들을 집어 넣을 때 기본적으로 복사 방식으로 집어 넣도록 되어 있습니다. 따라서, 복사 연산 비용이 클 수 밖에 없는 덩치 큰 객체를 vector 에 담고 싶을 경우에는 보통 그 객체의 포인터를 원소로 갖는 vector를 생각하기 마련입니다. 예를 들어, BigClass 가 이름에 걸맞게 정말로 덩치가 큰 녀석이라고 생각해 봅시다. 그래서 다음과 같이 vector를 정의해서 쓰려고 합니다.

// vector를 쓰려면 include 해야 합니다.
#include <vector>
#include "BigClass.h"
#include "BigClassImpl.h"

typedef std::vector<BigClass*> BigClassPtrVector;

위와 같이 정의해 놓고 쓴다면, vector를 삭제할 때는 반드시 각각의 원소들을 미리 삭제를 해줘야 되겠지요.

void f()
{
  BigClassPtrVector vbc;

  for (int i = 0; i < 10; i++)
  {
   // 중간 쯤 원소를 집어 넣다가 예외가 발생하면 ???
    vbc.push_back(new BigClass(i));
  }
  ......                 // 이런 저런 일을 한다고 칩니다
                         // 이런 저런 일을 하다 중간에 예외가 발생하면 ???

  // 마지막에 vector에 담겨있는 원소들이 필요 없게 된다면 이렇게 일일이
  // 삭제해야 합니다.
  // 이렇게 index를 쓰지 않고 iterator라는 걸 쓰는 방법도 있지만, iterator를
  // 설명하려면 다시 복잡해 지니까 그냥 index 쓰는 예제로 보여 드립니다.
  for (int i = 0; i < 10; i++)
  {
    delete vbc[i];
  }
}

위 코드도 조금만 들여다 보면 예외가 생겼을 때, 메모리가 줄줄 새게 된다는 걸 눈치 채셨을 겁니다. 꼭 예외뿐만이 아니더라도 중간에 에러가 발생해서 에러 처리를 하면서 항상 자원을 해제하는 코드가 들어가야 한다면 에러 처리 코드가 하염없이 길어질 수 있습니다. 결코 바람직한 방법은 아닙니다. 이런 문제를 해결하기 위해 shared_ptr을 사용할 수 있습니다. 어차피 vector 도 자신이 소멸되기 전에 각 원소를 소멸시키기 때문에 각 원소에 포인터 대신 shared_ptr을 둔다면 share_ptr이 가리키는 객체는 참조 카운트가 0이 되는 순간 자동으로 삭제될 것입니다.

위의 코드를 다음과 같이 바꾸면 새는 메모리를 꽉꽉 막을 수  있게 됩니다.

// vector를 쓰려면 include 해야 합니다.
#include <vector>
#include "boost/shared_ptr.hpp"
#include "BigClass.h"
#include "BigClassImpl.h"

typedef boost::shared_ptr<BigClass> BigClassSharedPtr;
typedef std::vector<BigClassSharedPtr> BigClassSharedPtrVector;

void f()
{
  BigClassSharedPtrVector vspbc;

  for (int i = 0; i < 10; i++)
  {
    BigClassSharedPtr spbc(new BigClass(i));
    vbc.push_back(spbc);   // 중간 쯤 원소를 집어 넣다가 예외가 발생하면 ???
  }
  ......                   // 이런 저런 일을 한다고 칩니다
                           // 이런 저런 일을 하다 중간에 예외가 발생하면 ???
} // 마지막에 vector에 담겨있는 원소들이 필요 없게 된다면 이렇게 일일이
  // 삭제할 필요가 없습니다. vector의 소멸자가 불리면서 vector 각 원소의 소멸자도
  // 불리게 되고, 그러면 BigClassSharedPtr 각 원소가 가리키는 BigClass 객체는
  // 소멸될 겁니다.

위 코드는 중간에 예외가 발생하더라도 stack unwinding이 일어나면서 vector의 소멸자가 호출되고, 다시 BigClassSharedPtr 의 소멸자가 호출되므로 메모리가 새는 일이 없습니다.

이 정도면 한 번 쓸만하지 않나요 ? 당장 한 번 여러분이 짠 코드를 들여다 보세요. 메모리가 새고 있지는 않은지, 새지는 않더라도 새는 걸 막느라고 얼마나 많은 에러 처리 코드를 얼기 설기 짜 놓았는지.

그렇다고 shared_ptr 를 여기 저기 아무 생각 없이 쓸 수 있을까요 ? 그건 당연히 아니죠. shared_ptr에 의해 가리키는 객체는 여러 shared_ptr에 의해 공유가 되고 있기 때문에 만약 한쪽에서 수정을 하면 다른쪽에서도 그 수정된 내용이 공유가 될 것입니다.  만약 이런 것이 허용되지 않는 경우에는 shared_ptr을 쓰면 안 되겠죠. 그리고, shared_ptr이 서로 circle을 이루어서 가리키게 되는 경우에는 절대 참조 카운트가 0이 되지 않기 때문에 써서는 안된다고 하네요.(이렇게 말하긴 하는데 어떻게 하면 그렇게 circle이 발생하는지 예제를 생각해 내기가 어렵네요. 아시는 분 좀 알려주세요) circle을 이룰 수 있는 경우에는 boost::weak_ptr<>을 쓰면 된다고 하는 군요.

shared_ptr 앞에 왜 boost 라는 namespace가 붙어 있을까 궁금해 하시는 분들을 위해 boost에 대해 잠깐 소개드리겠습니다.

boost는 일종의 확장 C++ 라이브러리인데, 상당히 많은 C++ 전문가들의 리뷰를 거쳐 탄생한 상당히 탄탄한 라이브러리라고 생각하시면 됩니다. 이 라이브러리의 워낙 정의 및 구현이 잘 되어 있어서 그런지 최근에 일부 클래스들이 C++ 표준화 위원회의 Library Technical Report 에 채택되기도 했습니다-shared_ptr도 std::tr1 이라는 namespace에 채택이 되었습니다. 다음과 같은 영역에서 상당히 많은 기능이 지원되고 있습니다.

String and text processing
Containers
Iterators
Algorithms
Function Objects and higher-order programming
Generic Programming
Template Metaprogramming
Preprocessor Metaprogramming
Concurrent Programming
Math and numerics
Correctness and testing
Data structures
Input/Output
Inter-language support
Memory
Parsing
Programming Interfaces
 

홈페이지는 http:://www.boost.org 이니 관심 있으신 분들은 가벼운 마음으로 들러 보세요.

...
...
...

(제가 한 가지 거짓말을 했는데요. www.boost.org 는 절대 가벼운 마음으로 들러 볼 곳은 아닌 것 같아요. 솔직히 거기 있는 내용을 이해하려면 상당한 내공이 이미 갖춰져 있어야 하는 듯...)

여기까지 왔는데 다음에는 뭘 할까요 ? 고민 고민 ^^;

TODO:
- shared_ptr의 인터페이스 및 구현 설명
- boost::scoped_ptr & boost::noncopyable 설명

참고문헌
Effective C++ 3rd Edition, Scott Meyers, 항목 13, 14
boost shared_ptr class template(클릭해 보세요)

소프트웨어 관련된 저의 다른 글들도 참고로 읽어 보세요.

소프트웨어는 soft 해야 제 맛이다
Flexible한 S/W 작성하기
소스코드 복사의 위험성
C++ 이야기 첫번째: auto_ptr 템플릿 클래스 소개
C++ 이야기 두번째: auto_ptr의 두 얼굴
C++ 이야기 세번째: new와 delete
C++ 이야기 네번째: boost::shared_ptr 소개
C++ 이야기 다섯번째: 내 객체 복사하지마!
C++ 이야기 여섯번째: 기본기 다지기(bool타입에 관하여)


제 글이 유익하셨다면 오른쪽 버튼을 눌러 제 블로그를 구독하세요. ->
블로그를 구독하는 방법을 잘 모르시는 분은 2. RSS 활용을 클릭하세요.
RSS에 대해 잘 모르시는 분은 1. RSS란 무엇인가를 클릭하세요.