2010년 4월 26일 월요일

std::unique_ptr

C++ 차기 표준인 C++0x에서는 다양한 언어적 차원의 기능과 라이브러리들이 추가되었는데, 라이브러리 중 unique_ptr라는 상당히 흥미로운 물건이 추가되었다. 이 클래스의 역할은 기존의 auto_ptr 클래스를 대체하는 것인데, 이를 이해하려면 auto_ptr에 대해 어느 정도 이해가 필요하다.

 

auto_ptr는 C++ 코딩의 원칙 중 하나인 RAII[footnote]Resource aquisition is initlaization, 자원의 획득은 객체의 생성과, 해제는 객체의 소멸과 일치시키라는 의미로, 컴파일러가 자동으로 관리해주는 객체의 생명 주기와 자원의 생명 주기를 일치시켜 컴파일러로 하여금 자원을 자동적으로 관리하도록 하는 정책을 의미한다.[/footnote]를 구현하기 위해 표준 라이브러리에 존재하는 클래스로, auto_ptr 객체가 정의된 스코프를 벗어나면 해당 auto_ptr에 저장된 객체의 해제를 보장하는 역할을 한다. 간단히 말해 스마트 포인터인 셈이다. 안 그래도 복잡한 언어인 C++에서 이러한 스마트 포인터의 존재는 코드의 복잡도를 낮추는데 무척 중요한 역할을 한다. 자원 할당 및 해제까지는 조심스럽게 코딩함으로써 어찌 할 수 있겠으나, 예외 안전성의 영역까지 가면 자원을 사람이 일일히 관리하기가 거의 불가능한 수준이 되기 때문이다. [footnote]여담이지만 C++에서 delete란 만악의 근원으로, 가능하면 컴파일러가 알아서 삽입하도록 코드를 짜는게 좋다. C++에서는 그 방법으로 제안되는 것이 스마트 포인터이다.[/footnote]

 

헌데 auto_ptr 클래스의 동작을 잘 보면 상당히 특이하다. 일반적으로 복사 생성자와 대입 연산자는 동일한 객체를 두 개 만든다는 의미에 맞게 동작하는데, 이 경우는 포인터의 소유권을 이전하는 방식으로 동작한다. 이를테면 auto_ptr 객체 a, b가 있을 때 a에 b를 대입하면 a가 가리키고 있던 객체는 해제되고 b에 있는 객체를 가리킨다. 그리고 b에는 널 포인터가 들어간다. 결과적으로 한 포인터를 가리키는 auto_ptr 객체는 동시에 하나만 존재하게 되어 이중 해제가 되지 않음을 보장하는 것이다. (물론 잘못 쓰면 충분히 가능한 일이지만)

 

문제는 STL 컨테이너를 사용하는 경우 컨테이너에 들어가는 객체들에 요구되는 조건 중 하나가 복사 생성자와 대입 연산자가 동일한 객체를 두 개 만든다는 의미를 가져야 하는 것이다. 헌데 auto_ptr는 이러한 조건을 지키지 않기 때문에 STL 컨테이너에 사용하는 경우 내부에서 이루어지는 대입, 복사 과정에서 가리키는 포인터가 마구마구 해제되어 버리는 불상사가 발생한다. 그렇기 때문에 표준에서 auto_ptr를 STL 컨테이너를 구체화시키기 위한 타입으로 사용하는 것을 아예 금지하고 있으며[footnote]관련 내용으로는 여기를 참조하자.[/footnote], boost에서는 아예 복사라는 개념 자체를 제거한 scoped_ptr을 제공한다.

 

C++에서 STL 컨테이너에 담을 수 없는 클래스라면 아무리 좋은 물건이라도 반쪽 클래스일 수 밖에 없다. 이러한 것을 극복하기 위해 소유권을 이전하는 형식이 아닌, 소유권을 나누는 방식의 shared_ptr이 제안된다. 이는 레퍼런스 카운팅 방식으로 동작하는 포인터로 사실상 모든 경우에 대해 포인터를 대체할 수 있으며 대부분의 경우에 대해 멀티 쓰레드 환경과 예외에 대해 안전하고 가리키는 포인터가 없어지는 경우는 해당 자원이 자동으로 해제되기 때문에 무척 유용하다. 순환 참조에 대해서만 조심하면 자바나 C#의 가비지 콜렉터가 부럽지 않을 정도다.

 

그러나 모든 편리함은 그 댓가를 필요로 하는 법이다. shared_ptr은 자체적으로 레퍼런스 카운팅을 제공하지 않는 클래스에 대해서도 동작해야 하기 때문에 각 개체마다 레퍼런스 카운트 값을 저장할 공간을 추가로 가져야 한다. 이 값은 shared_ptr마다 하나가 아니라 가리키는 객체마다 하나씩 필요하므로 새로운 포인터를 가리킬 때마다 추가로 힙 공간을 할당해야 한다는 의미이다. 게다가 shared_ptr의 복사 및 소멸마다 레퍼런스 카운트 값을 조절해야 하는데 이는 쓰레드 안전해야 하므로 비교적 수행 속도가 느린 atomic operation을 이용해야 하며, 이는 전부 힙 공간을 조작하는 행위이므로 캐쉬 지역성에 악영향을 끼칠 우려가 있다. 여기에 추가로 객체 접근을 위해 포인터를 역참조할 때 이 값이 shared_ptr에 저장된다면 shared_ptr의 크기가 2배로 불어나며 레퍼런스 카운트를 저장해두는 곳에 저장한다면 간접 참조를 해야 하므로 메모리 연산이 두 번 일어나는 셈이다.

 

물론 대부분의 경우 shared_ptr의 유용성은 이러한 오버헤드를 충분히 감수하고도 남을만 하지만, 해당 객체의 수명이 아주 명확한 경우까지 shared_ptr을 쓰는 것은 낭비라고 볼 수 있다. 다시 말해 auto_ptr와 shared_ptr 사이의 절충안이 있으면 좋겠다는 의미다. 이를 위해 C++ 표준 위원회에서는 unique_ptr이라는 클래스를 새롭게 도입한다.

 

unique_ptr은 기본적으로 auto_ptr와 유사하게 소유권의 이전에 기반한 동작을 한다. 그러나 일반 복사 생성, 대입 연산이 아닌 C++0x에서 새롭게 추가된 R-value reference를 이용한다는 것이 틀리다. 자세히 들어가자면 무척 긴 내용이라 시시콜콜 설명하진 않겠으나 R-value reference를 이용한 복사 생성와 대입 연산은 의미적으로 값의 복사가 아닌 값의 이동을 뜻한다. [footnote]R-value reference가 도입된 목적이 어차피 곧 무효화될 임시 객체에 대해서는 값을 복사하지 말고 이동시켜서 복사에 따른 오버헤드를 줄이자는 것이기 때문이다.[/footnote] 때 마침 STL 컨테이너에서도 효율성을 위해 내부적인 복사 및 대입 동작은 전부 R-value reference를 이용하도록 바뀌었는데, 일반 복사 및 대입 연산자를 막아 버리는 대신 R-value reference를 이용한 복사 및 대입 연산자만 정의한다면 STL에서도 사용할 수 있는 auto_ptr가 생긴다는 것이 unique_ptr의 의미이다.

 

unique_ptr와 auto_ptr의 차이점은 대충 아래와 같이 정리할 수 있다.

  • 일반 복사, 대입 연산을 막은 대신 R-value reference를 이용함으로써 그 의미가 뚜렷해진다.
  • 또한 STL 컨테이너에서도 사용할 수 있다.
  • 해제자를 따로 지정할 수 있다. (배열의 경우는 템플릿 특수화를 통해 전용 해제자를 이미 정의해놓은 상태이다)

간단히 말해 auto_ptr가 할 수 있는 것은 unique_ptr도 전부 할 수 있으며, 거기에 다양한 기능이 추가된데다 raw pointer에 비해 추가적인 오버헤드도 없는 스마트 포인터라 할 수 있다. 앞에서 말한 auto_ptr를 대체하기 위한 클래스라는 것은 바로 이런 의미이다. 다만 의미적인 뚜렷함과 안전한 사용을 위해 대부분의 암시적인 변환이 막혀 있고, 일반적인 대입 및 복사가 막혀 있으므로 unique_ptr 끼리의 대입에는 std::move 함수를 명시적으로 사용해야 하는 등 코딩량이 다소 늘어난다는 불편함이 있으나, 안전한 코딩을 위해서라면 이 정도는 충분히 감수할 만 하다.

 

unique_ptr의 경우 개인 프로젝트인 C--에서도 상당히 유용하게 쓰고 있는데, 이를 잘만 쓰면 아무런 오버헤드 없이 유효 범위가 확실한 객체에 대해 delete를 전부 제거할 수 있으며, 나머지도 shared_ptr을 이용하여 제거할 수 있다. 유일하게 예외가 있다면 union을 쓰는 경우인데 (union의 멤버는 생성자를 가질 수 없다) 이 경우는 사용자가 따로 래퍼 클래스를 만들어야 한다.

 

개인적으로는 boost의 intrusive_ptr[footnote]shared_ptr과 비슷하게 레퍼런스 카운트를 하지만 이 녀석은 개체 자신이 레퍼런스 카운트 값을 유지해야 한다는 제약이 있다. 물론 퍼포먼스 측면에서는 그만큼 이득이 있다.[/footnote]의 인터페이스를 좀 더 다듬어서 오버헤드가 적은 shared_ptr의 모양새로 표준에 도입해보는 것도 좋지 않을까 생각하는데, 표준화 위원회는 이 쪽에 관심이 없거나 이 정도면 충분하다고 생각하는 것 같다. 내가 만들어 볼까 생각도 해봤으나 모든 코너 케이스를 고려하면서 안전한 스마트 포인터 클래스를 만드는 것은 그리 만만한 일이 아닐 것 같다.

댓글 없음:

댓글 쓰기