36-3-나.auto_ptr의 내부

auto_ptr 템플릿은 포인터를 클래스로 감싸서 파괴자가 자동으로 해제할 수 있는 포인터의 래퍼 클래스라고 할 수 있다. 자동으로 파괴된다는 것 외에는 모든 동작이 래핑된 포인터와 동일한데 이것이 어떻게 가능한지 연구해 보자. auto_ptr 클래스 템플릿이 정의되어 있는 memory 헤더 파일을 읽어 보면 내부를 어렵지 않게 분석할 수 있다. 길이도 얼마되지 않으므로 C++ 코드를 잘 읽는 사람은 금방 그 실체를 파악할 수 있을 것이다.

그러나 auto_ptr의 정의가 간단하다고는 하지만 컴파일러마다 구현 방식이 조금씩 다르고 템플릿 때문에 코드가 다소 난해한 부분도 있으므로 표준 auto_ptr을 조금 단순화시킨 클래스로부터 자동화된 파괴 방식을 연구해 보도록 하자. 다음 예제의 myptr은 auto_ptr의 기능 중 일부만을 흉내낸 클래스이되 길이가 훨씬 더 짧다. 액기스만 보자는 얘기다.

 

: myptr

#include <Turboc.h>

#include <string>

#include <iostream>

using namespace std;

 

template <typename T>

class myptr

{

private:

     T *p;

 

public:

     explicit myptr(T *ap) : p(ap) { }

     ~myptr() { delete p; }

     T& operator *() const { return *p; }

     T* operator ->() const { return p; }

};

 

void main()

{

     myptr<string> pStr(new string("AutoPtr Test"));

 

     cout << *pStr << endl;

     cout << "길이 = " << pStr->size() << endl;

}

 

myptr은 인수로 전달된 대상체 T에 대한 포인터를 감싸는 래퍼 클래스이다. 클래스 템플릿이므로 임의 타입의 포인터를 래핑할 수 있다. 멤버 변수로 T *형의 p를 선언하고 있는데 이 포인터는 생성자에서 초기화된다. main에서 myptr<string> 타입의 pStr을 선언하고 new string문으로 새로운 string 객체를 동적 할당하여 그 포인터를 생성자로 전달했다. 이렇게 되면 myptr의 멤버 p는 동적 할당된 string 객체를 가리킬 것이다.

이 상태에서 *연산자로 myptr을 읽으면 이 연산자가 *p를 대신 리턴한다. 그래서 myptr에 가해지는 연산은 p가 가리키는 객체, 그러니까 이 예제의 경우 동적 할당된 string 객체를 대상으로 하게 된다. *pStr을 읽으면 "AutoPtr Test"라는 문자열이 읽혀질 것이다. 멤버 참조 연산자인 ->도 포인터를 리턴하도록 되어 있으므로 이 연산자로 래핑된 포인터가 가리키는 객체의 멤버를 바로 참조할 수 있다. main이 종료될 때 myptr의 파괴자가 호출되고 여기서 delete 연산자로 p를 삭제함으로써 p객체 자체와 p가 사용하는 부가 메모리까지도 자동으로 정리되는 것이다.

myptr은 auto_ptr의 기능 중 생성자, 파괴자, *연산자, ->연산자만을 흉내내고 있는데 이 예제만으로도 자동화된 파괴가 어떻게 가능한지를 이해하기에는 충분하다. auto_ptr은 이 외에도 대입 연산자, 복사 생성자, 호환 타입으로의 변환 연산자 등을 추가로 더 정의하여 객체에 대한 모든 연산이 포인터에 대한 연산이 되도록 한다. 포인터의 기능에 자동화된 파괴 기능만을 더한 것이 바로 auto_ptr이다.

예제의 myptr은 물론이고 auto_ptr도 생성자는 explicit로 선언되어 있어 명시적인 변환만 허용하는데 암시적인 변환까지 허용할 경우 다음과 같은 코드도 이상없이 컴파일되어 문제가 될 수 있다.

 

myptr<int> mpi(new int);

int i,*pi=&i;

mpi=pi;

 

생성자가 explicit가 아니라면 정수형 포인터 변수 pi로부터 임시 myptr 객체를 생성한 후 이 객체를 mpi에 그대로 대입해 버릴 것이다. myptr<int>와 int *는 다른 타입이므로 대입에 의한 암시적 변환은 어울리지 않는다. 반드시 명시적으로 생성자의 인수로 넘길 때만 이 포인터를 받아 들여야 한다.

다음은 auto_ptr 템플릿을 사용할 때의 일반적인 주의 사항과 한계에 대해 알아보자. 다음 코드는 정수형의 포인터를 래핑하는 auto_ptr 객체 api를 선언한 예인데 아주 전형적이면서 정상적인 코드이다.

 

auto_ptr<int> api(new int(1234));

 

정수형 포인터를 래핑하는 api에 정수형 변수를 할당해서 전달했으므로 문제가 없다. 그러나 다음과 같이 정적으로 할당한 변수의 번지는 전달할 수 없다.

 

int i=1234;

auto_ptr<int> api(&i);

 

왜냐하면 auto_ptr의 파괴자는 무조건 delete 연산자로 포인터를 삭제하도록 되어 있는데 위 코드의 i변수는 힙에 할당된 것이 아니라 스택에 생성된 것이므로 해제할 수 없는 것이다. 컴파일은 되지만 해제할 때 에러가 발생한다. int i, *pi=&i; delete pi; 코드를 순서대로 실행했을 때 에러가 발생하는 것과 똑같은 이유이다. 다음 코드도 불가능하다.

 

auto_ptr<int> api((int *)malloc(sizeof(int)));

 

malloc은 free와 짝이므로 malloc으로 할당한 메모리를 delete로 해제할 수는 없다. auto_ptr의 파괴자는 무조건 delete로 포인터를 해제하도록 되어 있다. 자신이 래핑하고 있는 포인터가 new에 의해 할당되었다고 가정하는 것이다. 그래서 다음 코드도 제대로 동작하지 않는다.

 

auto_ptr<int> api(new int[10]);

 

new 연산자로 정수형 변수 10개분의 메모리를 할당하여 auto_ptr로 넘겼는데 관리는 잘 되지만 파괴할 때 delete [ ]가 아닌 delete로 해제하면 이 경우도 메모리 누수가 발생한다. auto_ptr은 오로지 new 연산자로 할당한 대상만 자동으로 파괴할 수 있으며 malloc으로 할당했거나 new [ ]로 할당한 대상은 해제하지 못한다. 만약 정 이런 해제도 자동으로 하고 싶다면 auto_array, auto_free 등의 템플릿 클래스를 만들어 쓸 수는 있을 것이다.

auto_ptr 객체끼리 대입했을 때 두 개의 대상을 같은 객체가 가리키는 상황이 될 수도 있다. 이렇게 되면 두 객체가 개별적으로 해제될 때 이중 해제에 의한 문제가 발생할 것이다. auto_ptr은 대입할 때 우변 객체가 래핑하고 있는 포인터의 소유권을 포기하고 자신을 스스로 무효화함으로써 이중해제의 위험을 피하며 delete 연산자는 NULL 포인터에 대해 아무런 동작도 하지 않음으로써 무효화된 객체도 별 이상없이 디폴트 처리한다.

그러나 이 방법은 대입 연산에 의해 한쪽의 auto_ptr 객체가 무효화되며 두 객체가 한 대상을 가리키지 못한다는 논리적인 취약점이 있다. 그래서 좀 더 똑똑한 래퍼는 같은 대상을 가리키는 회수인 참조 카운트를 유지하며 객체가 해제될 때 카운트만 1 감소하고 카운트가 0이 될 때 실제 객체를 해제하는 방법을 쓰기도 한다. 이런 식으로 동작하는 포인터를 스마트 포인터(Smart Pointer:번역하자면 똑똑한 놈)라고 하는데 auto_ptr보다는 한 단계 더 발전한 개념이다. 스마트 포인터는 COM 프로그래밍에서 흔히 사용된다.