36-3.auto_ptr

36-3-가.자동화된 파괴

C++의 클래스는 파괴자라는 특별한 함수를 가지는데 이 함수는 객체가 파괴될 때 자동으로 호출된다. 그래서 객체가 동적으로 메모리를 할당하거나 시스템 자원을 사용하더라도 파괴자에 정리 코드를 작성해 놓으면 별도의 조치가 없더라도 객체가 사라질 때 해제 작업을 하도록 되어 있다. 파괴자의 이런 동작은 굉장히 편리한데 지역 객체일 경우 함수안에서 마음대로 만들어 쓰다가 그냥 나가기만 하면 된다. 범위를 벗어난 변수는 스택에서 제거되며 이때 객체의 파괴자가 호출되어 자신이 사용하던 자원을 알아서 정리하는 것이다.

앞 절에서 연구해 본 string 클래스를 생각해 보면 파괴자가 얼마나 편리한가를 알 수 있다. string 객체는 가변 길이의 문자열을 저장하기 위해 버퍼를 동적으로 할당해서 관리하는데 개발자가 신경쓰지 않아도 이 메모리는 자동으로 회수된다. 이런 면을 보면 파괴자는 역시 편리한 함수이다. 그러나 파괴자는 스택에 정적으로 할당된 객체에 대해서만 동작하며 동적으로 할당한 메모리에 대해서는 책임지지 않는 문제점이 있다. 다음 예제를 보자.

 

: dynalloc

#include <iostream>

using namespace std;

 

void main()

{

     double *rate;

 

     rate=new double;

     *rate=3.1415;

     cout << *rate << endl;

     // delete rate;

}

 

실수형 변수를 가리키는 rate 포인터를 선언하고 이 포인터에 실수형의 길이만큼 동적 할당하여 그 번지를 저장했다. 이렇게 되면 *rate는 실수형 변수가 되므로 동적으로 할당된 메모리를 실수형 변수처럼 사용할 수 있다. rate는 이 함수의 지역변수이므로 함수가 종료될 때 자동으로 해제된다. 그러나 rate가 가리키는 메모리는 자동으로 해제되지 않는데 동적으로 할당했다는 것은 필요할 때까지 쓰겠다는 의사 표현이므로 직접 해제하기 전까지는 힙에 계속 남아 있는다.

메모리 관리 원칙에 의해 한 번 할당한 메모리는 해제할 때까지 다른 용도로 재사용되지 않으므로 명시적으로 delete를 호출해야만 해제된다. 그래서 동적으로 할당한 메모리는 반드시 대응되는 해제 코드(free, delete)로 해제해야 한다. 위 예제에는 delete 호출문이 주석으로 처리되어 있으므로 이렇게 되면 할당한 메모리를 더 이상 사용할 수 없는 메모리 누수(Memory Leak)가 발생할 것이다. 동적으로 할당된 메모리는 이름이 없으므로 포인터를 잃어 버리면 더 이상 참조할 수 없고 해제하지도 못한다.

물론 이런 짧은 코드에서 delete문을 빼 먹는 실수는 잘 하지 않을 것이다. 그러나 코드가 아주 길고 복잡하다 보면 해제하는 코드를 깜박 잊어 버리는 경우가 종종 있다. 또는 해제하는 코드가 있다 하더라도 예외 처리 구문에 의해 함수를 강제로 종료할 때는 이 코드가 실행되지 못하는 경우도 있는데 다음 코드가 이런 예를 보여 준다.

 

void func()

{

     double *rate=new double(3.14);

     if (어떤 조건) {

          throw("야! 똑바로 못해");

     }

     ....

     delete rate;

}

 

정상적인 실행 흐름이라면 new와 delete가 짝을 이루어 할당, 해제가 이상없이 진행되지만 예외 조건에 의해 throw를 호출하면 함수 실행을 즉시 중지하고 호출부의 catch로 점프해 버리므로 delete는 실행되지 못한다. 이럴 경우 예외 처리 구문은 스택 되감기를 통해 지역 객체의 파괴자를 자동으로 호출하도록 되어 있지만 지역 객체가 가리키는 메모리까지 해제되는 것은 아니다. 따라서 불가피하게 메모리 누수가 발생하게 된다.

이런 식의 메모리 누수는 양이 많지 않을 경우 당장은 별 문제가 되지 않으며 컴파일 중에 에러가 나는 것도 아니다. 그러나 오랫동안 실행되는 프로그램은 시스템 자원을 야금 야금 갉아 먹으므로 언젠가는 말썽을 부릴 것이다. 이 문제는 생각보다 심각한데 사람은 해제 코드를 빼먹는 실수를 종종 하는데 비해 몇 달, 몇 년동안이나 실행되어야 하는 서버 프로그램의 경우 조금의 메모리 누수도 허용되지 않기 때문이다. 멀티 태스킹 환경에서 메모리 누수는 자신뿐만 아니라 같이 실행되는 다른 프로그램에도 피해를 끼친다는 점에서 심각하다.

단순 포인터는 파괴자를 가지지 않기 때문에 C++의 파괴자로는 이 문제를 제대로 해결할 수 없다. 포인터 변수만 해제될 뿐이지 포인터가 가리키는 메모리는 해제되지 않는다. 이런 문제를 해결하기 위해 만들어진 것이 바로 auto_ptr이다. auto_ptr은 동적으로 할당된 메모리도 자동으로 해제하는 기능을 가지는 포인터의 래퍼 클래스이다. auto_ptr의 파괴자에 포인터 해제 코드를 작성하면 어떤 경우라도 안전한 해제를 보장할 수 있다. 다음 예제를 보자.

 

: auto_ptr

#include <iostream>

#include <memory>

using namespace std;

 

void main()

{

     auto_ptr<double> rate(new double);

    

     *rate=3.1415;

     cout << *rate << endl;

}

 

auto_ptr 템플릿은 memory 헤더 파일에 정의되어 있으므로 사용하려면 이 헤더 파일을 먼저 포함시켜야 한다. auto_ptr은 다음과 같이 정의되어 있는 클래스 템플릿이다.

 

template<typename T> class auto_ptr

 

포인터가 가리키는 대상체의 타입 T를 인수로 받아 들이며 T *형의 포인터를 대신 관리한다. 생성자로 포인터를 전달하면 이 포인터를 가지고 있다가 파괴자에서 delete로 해제하므로 포인터뿐만 아니라 포인터가 가리키는 메모리도 자동으로 해제된다. 예제 코드를 보면 auto_ptr<double> 타입의 객체 rate를 선언하되 새로운 double형 변수를 동적 할당하여 생성자로 전달했다.

auto_ptr은 이 포인터를 내부 멤버 변수에 저장해 놓고 *, ->, = 등 포인터에 사용하는 대부분의 연산자를 오버로딩하여 이 객체에 대한 모든 연산을 내부 포인터에 대한 연산으로 중계하는 역할을 한다. 그래서 rate를 마치 double형의 포인터인 것처럼 사용할 수 있다. rate객체에 *연산자를 적용하면 동적으로 할당된 메모리에 대해 *연산자가 적용되어 이 값을 읽거나 변경할 수 있다. 래퍼이므로 래핑한 대상을 그대로 흉내내는 것이다.

rate의 파괴자에서는 delete를 자동으로 호출하므로 함수가 끝날 때 rate를 해제할 필요가 없으며 해제되지도 않는다. delete rate 코드를 함수 끝에 작성하면 컴파일 에러로 처리되는데 rate 객체 자체는 포인터가 아니기 때문이다. 예외 처리 구문에 의해 스택 되감기를 실행할 때도 rate객체의 파괴자가 호출되며 이때 메모리도 해제된다. 단순 포인터는 파괴자가 없지만 auto_ptr은 클래스이므로 파괴자가 호출된다.

다음은 좀 더 복잡한 예제를 보자. 단순 타입에 대한 포인터가 아닌 객체에 대한 포인터를 auto_ptr로 관리할 수도 있다. 다음 예제는 string 객체를 동적으로 할당한 후 해제하지 않고 리턴함으로써 의도적으로 메모리 누수를 발생시킨다.

 

: dynstring

#include <Turboc.h>

#include <string>

#include <iostream>

using namespace std;

 

 

void main()

{

     string *pStr=new string("AutoPtr Test");

 

     cout << *pStr << endl;

     // delete pStr;

}

 

pStr 변수만 파괴될 뿐 이 변수가 가리키는 string 객체는 파괴되지 않으며 뿐만 아니라 string 객체가 관리하는 문자열 버퍼도 파괴되지 않는다. 만약 문자열의 길이가 아주 길다면 이때의 메모리 누수는 심각한 시스템 자원 누출이 될 것이다. string 객체는 동적으로 할당되었으므로 함수가 종료될 때 자동으로 파괴되지 않으며 예외 처리 구문에 의해 강제 종료될 때도 마찬가지이다. 이 문제도 auto_ptr을 사용하면 해결할 수 있다.

 

: autostring

#include <Turboc.h>

#include <string>

#include <iostream>

#include <memory>

using namespace std;

 

 

void main()

{

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

 

     cout << *pStr << endl;

}

 

string 대상체를 가리키는 auto_ptr 객체 pStr을 선언하고 새로운 string객체를 동적으로 할당한 번지를 생성자로 전달했다. 이때 pStr의 메모리 내부는 아마도 다음과 같은 모양이 될 것이다.

main 함수가 종료되면 지역 객체 pStr이 파괴되며 이 과정에서 pStr의 파괴자가 호출된다. 파괴자는 내부적으로 유지하고 있는 포인터를 delete한다. 삭제되는 대상이 string 객체이므로 이 과정에서 string의 파괴자가 호출되며 문자열 버퍼도 정리된다. 설사 main이 비정상적으로 종료되더라도 정리 코드가 자동으로 실행되므로 메모리 누수는 발생하지 않는다.

동적으로 메모리를 할당하거나 객체를 생성할 때는 auto_ptr 템플릿을 사용하면 확실히 안전하기는 하다. 그러나 단순 포인터를 쓰는 것에 비해 다소 번거롭다는 단점이 있다. 자신이 책임지고 해제한다거나 예외가 발생할 가능성이 전혀 없다면 굳이 auto_ptr을 쓰지 않아도 상관없다. 그러나 많은 개발자들이 이런 확신을 하지 못하기 때문에 동적 할당할 때는 auto_ptr을 통해 확실한 해제를 보장받고자 하는 것이다.