Post

[Effective C++] 3. 자원 관리 [1/2]

[Effective C++] 3. 자원 관리 [1/2]

이 글은 제 개인적인 공부를 위해 작성한 글입니다.
틀린 내용이 있을 수 있고, 피드백은 환영합니다.

개요


프로그램이 분야에서 자원이란, 사용을 일단 마치고 난 후엔 시스템에 돌려주어야 하는 모든 것을 일컫는다. 돌려주지 않는 순간부터 암울한 일들이 시작할 될 것이다. C++ 프로그램에서 가장 흔하게 눈에 밟히는 자원이라면 동적 할당한 메모리를 들 수 있는데, 사실 메모리는 우리가 관리해야 하는 많고 많은 자원 중 한 가지일 뿐이다. 자원에는 파일 서술자(file descriptor)도 있고, 뮤텍스 잠금(mutex lock)도 있으며, 그래픽 유저 인터페이스(graphic user interface: GUI)에서 쓰이는 폰트와 브러시도 자원이다. 그뿐인가? 데이터베이스 연결, 네트워크 소켓 역시 자원에 해당된다. 중요한 것은 “어쨌든 가져와서 다 썼으면 해제해야, 즉 놓아 주어야 한다”는 사실이앋.

이것을 수작업으로 제대로 해 주기란 어떤 상황에서도 그리 녹녹치 않은 일이다. 하지만 예외 발생도 고려해야 하고, return 문이 여러 개 들어 있는 함수도 만륻어야 하는데다가, 이런 것들이 바뀔 때 어떤 결과가 만들어지는지 제대로 모르는 유지보수 프로그래머가 우리 프로그램을 고치는 경우까지 생각하다 보면, “에라 모르겠다. 되는 대로 하자” 식의 자원 관리 방법은 몇 퍼센트 부족한 사람들이 하는 일이란 점은 분명한 것 같다.

이번 장은 순도 100% 객체 기반 방식의 자원 관리를 보여주는 것으로 시작하겠다. C++가 지원하는 생성자, 소멸자, 객체 복사 함수를 사용하는 방법이다. 이 방법을 기회가 닿는 대로 써먹는 버릇을 들이면 어지간한 자원 관리 문제는 모두 박명할 수 있음은 많은 이들의 경험으로 증명된 바 있다. 그 다음에는 몇 개의 항목을 할애해서 메모리 관리를 집중적으로 조명해 보자. 일반적인 메모리 관리 방법을 앞의 항목들에 싣고, 특수 사항에 대해 보충하는 내용을 뒤의 항목들에서 다룰 것이다. 메모리가 관리하는 객체가 그 메모리를 적절히 처리하는 방법을 알아야 한다.


항목 13 : 자원 관리에는 객체가 그만!


투자를 모델링 해주는 클래스 라이브러리를 가지고 어떤 작업을 한다고 가정하자. 이 라이브러리는 Investment라는 최상위 클래스가 있고, 이것을 기본으로 하여 구체적인 형태의 투자 클래스가 파생되어 있다.

1
class Investment { ... };

가정을 하나 더 하자. 이 라이브러리는 Investment에서 파생된 클래스의 객체를 사용자가 얻어내는 용도로 팩토리 함수만을 쓰도록 만들어져 있다고 말이다.

1
2
3
4
// Investment 클래스 계통에 속한 클래스의 객체를 동적 할당하고 그 포인터를 반환한다.
// 이 객체의 해제는 호출자 쪽에서 직접 해야 한다.
// 매개변수는 복잡하니까 뺐다
Investment* createInvestment();

주석 문에 나와 있듯이, createInvestment 함수를 통해 얻어낸 객체를 사용할 일이 없을 때 그 객체를 삭제해야 하는 쪽은 이 함수의 호출자이다. 그렇게 쓰라고 만든 만큼 따라 주는 것이 당연하겠다. 아래의 함수 f는 그렇게 만들었다.

1
2
3
4
5
6
void f()
{
    Investment* pInv = createInvestment();
    ...
    delete pInv;
}

멀쩡해 보이지만, createInvestment 함수로부터 얻은 투자 객체의 삭제에 실패할 수 있는 경우가 한 두가지가 아니다. 첫 번째는 ‘…’ 부분 어딘가에서 return 문이 들어 있을 가능성이다. 이 문장이 실행되면 프로그램 제어가 delete 문까지 도달하지 않게 된다. 비슷한 경우는 또 있다. createInvestment 호출문과 delete가 하나의 루프 안에 들어 있고 continue 혹은 goto 문에 의해 갑작스레 루프로부터 빠져나왔을 때가 그런 경우다. 마지막으로, ‘…’ 안의 어떤 문장에서 예외를 던질 수 있다는 점도 고려해야 한다. 예외가 던져지면 delete 문이 실행되지 않게 된다. delete 문을 건너뛰는 경우는 이렇게 여러 가지이지만, 결과는 똑같다. 우선 투자 객체를 담고 있는 메모리가 누출되고, 그와 동시에 그 객체가 갖고 있던 자원까지 모두 샌다.

물론, 하나하나 따져 가면서 꼼꼼하게 프로그램을 만들면 이런 종류의 에러는 막을 수 있겠지만, 오랜 시간 코드를 변경한다면 어떻게 할지 생각해 보자. 소프트웨어가 유지보수에 들어가면, 유지보수를 맡은 누군가는 자신이 하는 일이 이 함수의 자원 관리 전략에 어떤 결과를 가져오는지에 대해 제대로 파악하지 못한 채로 return이나 continue 문을 써대는 경우도 있단 말이다. 상황이 더 안좋아질 수도 있다. f의 ‘…’ 부분에서 어떤 함수를 호출하는데, 이 함수는 예외가 전혀 발생하지 않다가 “누군가가 고친답시고 어떻게 해둔” 후부터 갑자기 예외를 터뜨려 대기 시작할지는 아무도 모른다. f가 항상 delete 문으로 가 줄 거라고 믿지 말자. 오래 못간다.

createInvestment 함수로 얻어낸 자원이 항상 해제되도록 만들 방법은, 자원을 객체에 넣고 그 자원 해제를 소멸자가 맡도록 하며, 그 소멸자는 실행 제어가 f를 떠날 때 호출되도록 만드는 것이다. 사실 이부분은 이번 항목에서 소개할 아이디어의 전반부이니 새겨두자. 자원을 객체에 넣음으로써, C++가 자동으로 호출해 주는 소멸자에 의해 해당 자원을 저절로 해제할 수 있다.

소프트웨어 개발에 쓰이는 상당수의 자원이 힙에서 동적으로 할당되고, 하나의 블록(block) 혹은 함수 안에서만 쓰이는 경우가 잦기 때문에 그 블록 혹은 함수로부터 실행 제어가 빠져 나올 때 자원이 해제되는게 맞다. 표준 라이브러리를 보면 auto_ptr이란 것이 있는데, 바로 이런 용도에 쓰라고 마련된 클래스이다. auto_ptr은 포인터와 비슷하게 동작하는 스마트 포인터로서, 가리키고 있는 대상에 대해 소멸자가 자동으로 delete를 불러주도록 설계되어 있다. 그럼 f에서 생길 수 있는 자원 누출을 막기 위해 auto_ptr을 사용하는 방법을 보자.

1
2
3
4
5
6
void f()
{
    std::auto_ptr<Investment> pInv(createInvestment());
    ...
    // auto_ptr의 소멸자를 통해 pInv를 삭제한다.
}

아주 간단한 예제이지만, 자원 관리에 객체를 사용하는 방법의 중요한 두 가지 특징을 볼 수 있다.

  1. 자원을 획득한 후에 자원 관리 객체에게 넘긴다.

    위의 예제를 보면 createInvestment 함수가 만들어 준 자원은 그 자원을 관리한 auto_ptr 객체를 초기화하는 데 쓰이고 있다. 실제로, 이렇게 자원 관리에 객체를 사용하는 아이디어에 대한 업계 용어도 자주 통용되고 있는데, 자원 획득 즉 초기화(Resource Acquisition Is Initialization: RAII) 라는 이름이다. 이런 이름이 나온 이유는 자원 획득과 자원 관리 객체의 초기화가 바로 한 문장에서 이루어지는 것이 너무나도 일상적이기 때문이다. 획득된 자원으로 자원 관리 객체를 초기화하지 않고 그 자원을 그 객체에 대입하는 경우도 종종 있기는 하지만, 어찌 됐든 “자원을 획득하고 나서 바로 자원 관리 객체에 넘겨 준다”는 점은 같다.

  2. 자원 관리 객체는 자신의 소멸자를 사용해서 자원이 확실하게 해제되도록 한다.

    소멸자는 어떤 객체가 소멸될 때 자동적으로 호출되기 때문에, 실행 제어가 어떤 경위로 블록을 떠나는가에 상관없이 자원 해제가 제대로 이루어지게 되는 것이다. 물론 객체를 해제하다가 예외가 발생될 수 있는 상황에 빠지만 사태가 많이 꼬이기도 하겠지만, 그 문제는 항목 8에서 해결할 부분이기 때문에 여기서 걱정할 필요는 없다.

auto_ptr은 자신이 소멸될 떄 자신이 가리키고 있는 대상에 대해 자동으로 delete를 먹이기 때문에, 어떤 객체를 가리키는 auto_ptr의 개수가 둘 이상이면 절대로 안 되겠다. 정말 중요하다. 만에 하나 이러한 사태가 되면 결국 자원이 두 번 삭제되는 결과를 낳게 되고, 프로그램은 미정의 상태에 빠지게 된다. 이런 불상사를 막는답시고 auto_ptr은 상당히 유별난 특성을 지니게 되었는데, 그것이 무엇인고 하니 auto_ptr 객체를 복사하면 원본 객체는 null로 만든다는 것이다. 복사하는 객체만이 그 자원의 유일한 소유권을 갖는다고 가정한다.

1
2
3
4
5
6
// pInv1이 가리키는 대상은 createInvestment이 반환한 객체이다.
std::auto_ptr<Investment> pInv1(createInvestment());
// pInv2는 현재 그 객체를 가리키고 있는 반면, pInv1은 null이 된다.
std::auto_ptr<Investment> pInv2(pInv1);
// pInv1은 그 객체를 가리키고 있으며, pInv2는 null이 된다.
pInv1 = pInv2;

이렇듯 상식을 살짝 비껴가는 복사 동작도 그렇고, auto_ptr이 관리하는 객체는 두 개 이상의 auto_ptr 객체가 물고 있으면 안된다는 요구사항까지 깔려 있는 통에, 동적으로 할당되는 모든 자원에 대한 관리 객체로서 auto_ptr을 쓰는 것은 최선이 아닐 듯하다는 느낌이 든다. 예를 들어, STL 컨테이너의 경우엔 원소들이 정상적인 복사 동작을 가져야 하기 때문에, auto_ptr을 원소로 담을 수 없다.

auto_ptr을 쓸 수 없는 상황이라면 그 대안으로 참조 카운팅 방식 스마트 포인터(reference-counted smart pointer: RCSP)가 아주 괜찮다. RCSP는 특정한 어떤 자원을 가리키는 외부 객체의 개수를 유지하고 있다가 그 개수가 0이 되면 해당 자원을 자동으로 삭제하는 스마트 포인터이다. 이것만 보면, RCSP의 동작은 가비지 컬렉션의 그것과 상당히 흡사하다. 단, 참조 상태가 고리를 이루는 경우(예를 들면 다른 두 객체가 서로를 가리키고 있다든지)를 없앨 수 없다는 점은 가비지 컬렉션과 다르다.

TR1에서 제공되는 tr1::shared_ptr이 대표적인 RCSP이다. 이것을 써서 f 함수를 다시 작성해 보면 다음과 같다.

1
2
3
4
5
6
7
void f()
{
    ...
    std::tr1::shared_ptr<Investment> pInv(createInvestment());
    ...
    // shared_ptr의 소멸자를 통해 pInv를 자동으로 삭제한다.
}

auto_ptr을 사용한 버전과 거의 똑같아 보이는 코드이지만, shared_ptr의 복사가 훨씬 자연스러워졌다.

1
2
3
4
5
6
7
8
9
10
11
12
void f()
{
    ...
    // pInv1이 가리키는 대상은 createInvestment이 반환한 객체이다.
    std::tr1::shared_ptr<Investment> pInv1(createInvestment());
    // pInv1과 pInv2가 동시에 그 객체를 가리키게 된다.
    std::tr1::shared_ptr<Investment> pInv2(pInv1);
    // 마찬가지
    pInv1 = pInv2;
    ...
    // pInv1과 pInv2는 소멸되며, 이들이 가리키는 객체도 자동으로 삭제된다.
}

복사 동작이 예상대로 이루어지기 때문에, tr1::shared_ptr은 괴상한 복사 동작으로 인해 auto_ptr을 쓸 수 없는 STL 컨테이너 등의 환경에서 딱 맞게 쓸 수 있다.

말을 늘어놓다 보니 주제가 옆으로 많이 샌 느낌이다. auto_ptr과 tr1::shared_ptr 등 스마트 포인터 이야기를 늘어놓으려고 이번 항목이 있는 것은 아니다. 자원을 관리하는 객체를 써서 자원을 관리하는 것이 중요하다는 이야기가 진짜이다. auto_ptr과 tr1::shared_ptr은 그렇게 하는 여러 가지 방법들 중 몇 가지 일뿐이다. (tr1::shared_ptr 클래스에 대한 더 자세한 이야기는 항목 14, 18, 54에서 참고하자)

알아두어야 할 게 하나 더 있다. auto_ptr 및 tr1::shared_ptr은 소멸자 내부에서 delete 연산자를 사용한다. delete[] 연산자가 아니다. 말하자면, 동적으로 할당된 배열에 대해 auto_ptr이나 tr1::shared_ptr을 사용하면 난감하다는 이야기다. 동적 배열을 썼을 때 컴파일 에러가 났으면 그나마 좋겠는데 애석하게도 컴파일 에러가 발생하지 않는다.

1
2
3
std::auto_ptr<std::string> aps(new std::string[10]); // 잘못된 delete가 사용된다.

std::tr1::shared_ptr<int> spi(new int[1024]); // 잘못된 delete가 사용된다.

이 말을 들으면 더 놀랄 것 같지만, C++ 표준 라이브러리에서는 동적 할당된 배열을 위해 준비된 auto_ptr 혹은 tr1::shared_ptr 같은 클래스가 제공되지 않는다. 심지어 TR1에서도이다. 왜냐하면 동적으로 할당된 배열은 이제 vector 및 string으로 거의 대체할 수 있기 때문이다. 배열에 쓸 수 있는 auto_ptr이라든지 tr1::shared_ptr이 있으면 좋겠다고 하는 사람은 부스트(항목 55)를 참고하자. boost::scoped_array와 boost::shared_array가 있을 것이다.

이번 항목에서 “자원 관리에 객체를 쓰자”라는 지침을 통해 필자가 강조하고 싶은 바는 이것이다. 자원 해제를 우리가 일일이 하다 보면(자원 관리 클래스를 쓰지 않고 delete를 쓴다든지 해서) 언젠가 잘못을 저지르고 만다는 이야기이다. 이미 널리 쓰이고 있는 auto_ptr이나 tr1::shared_ptr 같은 자원 관리 클래스를 활용하는 것도 이번 항목의 조언을 쉽게 지킬 수 있는 한 가지 방법이다. 다만 이런 클래스로도 제대로 관리할 수 없는 자원도 있다는 사실을 잊지 말자. 이런 경우엔 우리만의 자원 관리 클래스를 직접 만들 수밖에 없다.

마지막으로 앞에서 본 createInvestment 함수의 반환 타입이 포인터로 되어 있는데, 이 부분 떄문에 문제가 생길 수 있음을 지적하고 싶다. 반환된 포인터에 대한 delete 호출을 호출자 쪽에서 해야 하는데, 그것을 잊어버리고 넘어가기 쉽기 떄문이다. (auto_ptr 혹은 tr1::shared_ptr을 써서 delete를 수행한다고 하더라도, createInvestment의 반환 값을 스마트 포인터에 저장해야 한다는 점만은 여전히 기억하고 있어야 한다.) 이 문제를 어떻게든 해결하려면 createInvestment를 수정해서 인터페이스를 고쳐야 하는데, 항목 18에서 해두었다.

자원 누출을 막기 위해, 생성자 안에서 자원을 획득하고 소멸자에서 그것을 해제하는 RAII 객체를 사용하자.
일반적으로 널리 쓰이는 RAII 클래스는 tr1::shared_ptr과 auto_ptr이다. 이 둘 가운데 tr1::shared_ptr이 복사 시의 동작이 직관적이기 때문에 대개 더 좋다. 반면 auto_ptr은 복사되는 객체(원본 객체)를 null로 만들어 버린다.


항목 14 : 자원 관리 클래스의 복사 동작에 대해 진지하게 고찰하자.


자원 관리 클래스의 주축을 이루는 아이디어인 자원 획득 즉 초기화(RAII)을 공부했고, 힙 기반 자원에 대해 이 아이디어를 적용한 auto_ptr과 tr1::shared_ptr 클래스도 알게되었다. 하지만 세상의 자원이 모두 힙에서 생기지 않기에 모든 문제가 끝나지는 않는다. 힙에서 생기지 않는 자원은 auto_ptr 혹은 tr1::shared_ptr 등의 스마트 포인터로 처리해 주기엔 맞지 않다는 것이 일반적인 견해이다. 항상 그런 것은 아니지만, 자원 관리 클래스를 우리 스스로 만들어야 할 필요를 느끼는 경우가 바로 이런 경우이다.

예를 하나 들어 보자. Mutex 타입의 뮤텍스 객체를 조작하는 C API를 우리가 사용 중이라고 가정하자. 이 C API에서 제공하는 함수 중엔 lock 및 unlock이 있다.

1
2
3
4
// pm이 가리키는 뮤텍스에 잠금을 건다.
void lock(Mutex* pm);
// pm이 가리키는 뮤텍스에 걸린 잠금을 푼다.
void unlock(Mutex* pm);

그런데 뮤텍스 잠금을 관리하는 클래스를 하나 만들고 싶다. 이전에 걸어 놓은 뮤텍스 잠금을 잊지 않고 풀어 줄 목적이다. 이런 용도의 클래스는 기본적으로 RAII 법칙을 따라 구성한다. 즉, 생성 시에 자원을 획득하고, 소멸 시에 자원을 해제하는 것이다.

1
2
3
4
5
6
7
8
9
class Lock
{
public:
    explicit Lock(Mutex* pm) : mutexPtr(pm)
    { lock(mutexPtr); }
    ~Lock() { unlock(mutexPtr); }
    
private:
    Mutex* mutexPtr;

사용자는 Lock을 사용할 때 RAII 방식에 맞추어 쓰면 된다.

1
2
3
4
5
6
Mutex m; // 뮤텍스 정의
...
{ // 임계 영역을 정하기 위해 블록을 만든다
    Lock ml(&m); // 뮤텍스에 잠금을 건다.
    ... // 임계 영역에서 할 연산을 수행
} // 블록의 끝. 뮤텍스에 걸렸던 잠금이 자동으로 풀린다.

여기까지만 보면 앞으로도 잘 될 것 같다. 그런데 Lock 객체가 복사가 된다면 어떻게 해야 할까?

1
2
Lock ml1(&m); // 뮤텍스에 잠금을 건다.
Lock ml2(ml1); // ml1을 ml2로 복사한다.

사실 이 질문은 약간 더 일반화해서 이렇게 정리할 수 있다. RAII 클래스를 만들어 본 사람이면 한 번쯤 고민 꽤나 했을 그런 질문이기도 하다. 바로 “RAII 클래스가 복사될 때 어떤 동작이 이루어져야 할까?” 이다. 아마도 열에 아홉을 다음의 선택지 중 하나를 골라 잡고 싶을 것이다.

  1. 복사를 금지한다

    실제로, RAII 객체가 복사되도록 놔두는 것 자체가 말이 안되는 경우가 꽤 많다. 위의 Lock 같은 클래스도 이런 부류에 속할 것 같다. 어떤 스레드 동기화 깨체에 대한 ‘사본’이라는 게 실제로 거의 의미가 없으니까 말이다. 복사하면 안 되는 RAII 클래스에 대해서는 반드시 복사가 되지 않도록 막아야 한다. 복사를 막는 방법은 항목 6을 참고하면 된다. 골자만 말하면 복사 연산 함수를 private 멤버로 만드는 것이다.

    1
    2
    3
    4
    5
    
     class Lock : private Uncopyable
     {
     public:
     ...
     };
    
  2. 관리하고 있는 자원에 대해 참조 카운팅을 수행한다.

    자원을 사용하고 있는 마지막 객체가 소멸될 때까지 그 자원을 유지하는게 바람직할 경우도 종종 있다. 이럴 경우에는, 해당 자원을 참조하는 객체의 개수에 대한 카운트를 증가시키는 식으로 RAII 객체의 복사 동작을 만들어야 한다. 참고로, 이런 방식은 현재 tr1::shared_ptr이 사용하고 있다.

    지금 좋은 생각이 떠오른 사람이 있을 수 있다. 자신의 RAII 클래스에 참조 카운팅 방식의 복사 동작을 넣고 싶을 때 tr1::shared_ptr을 데이터 멤버로 넣으면, 간단히 해결되겠죠? 그러니까 Lock이 참조 카운팅 방식으로 돌아가면 좋을 것 같다고 생각했다면, mutexPtr의 타입을 Mutex*에서 tr1::shared_ptr로 바꾸라는 것이다. 단 아쉽게도, tr1::shared_ptr은 참조 카운트가 0이 될 때 자신이 가리키고 있던 대상을 삭제해 버리도록 기본 동작이 만들어져 있어서, 우리의 바람과는 다소 어긋난다. Mutex를 다 썼을 때 이것에 대해 잠금 해제만 하면 되지, 삭제까지 하고 싶지는 않기 때문이다.

    참으로 다행스러운 것은 tr1::shared_ptr이 ‘삭제자(deleter)’ 지정을 허용한다는 것이다. 여기서 삭제자란, tr1::shared_ptr이 유지하는 참조 카운트가 0이 되었을 떄 호출된느 함수 혹은 함수 객체를 일컫는다. 삭제자는 tr1::shared_ptr 생성자의 두 번째 매개변수로 선택적으로 넣어 줄 수 있다. 이제 이걸 활용해서 바꾼 코드를 보자.

    1
    2
    3
    4
    5
    6
    7
    8
    
     class Lock
     {
     public:
         explicit Lock(Mutex* pm) : mutexPtr(pm, unlock)
         { lock(mutexPtr.get()); }
            
     private:
         std::tr1::shared_ptr<Mutex> mutexPtr;
    

    이 예제에서 자세히 볼 부분은, Lock 클래스가 이제는 소멸자를 선언하지 않는다는 점이다. 이유는 간단하다. 필요 없으니까이다. 항목 5를 읽은 사람은 알겠지만, 클래스의 소멸자는 비정적 데이터 멤버의 소멸자를 자동으로 호출하게 되어 있다. 이 비정적 데이터 멤버에 해당하는 것이 mutexPtr이다. 그런데 mutexPtr의 소멸자는 뮤텍스의 참조 카운트가 0이 될 때 tr1::shared_ptr의 삭제자(현재의 경우엔 unlock)를 자동으로 호출할 거라는 거다.

  3. 관리하고 있는 자원을 진짜로 복사한다

    때에 따라서는 자원을 원하는 대로 복사할 수도 있다. 이때는 ‘자원을 다 썼을 때 각각의 사본을 확실히 해제하는 것’이 자원 관리 클래스가 필요한 유일한 명분이 될 것이다. 자원 관리 객체를 복사하면 그 객체가 둘러싸고 있는 자원까지 복사되어야 한다. 즉, 깊은 복사를 수행해야 한다는 이야기이다.

    몇몇 구현환경에서 표준 string 타입을 구현해 놓은 모습을 보면, 문자열을 구성하는 원소들을 힙 메모리에 저장해 놓고 이 메모리에 대한 포인털르 데이터 멤버로 갖고 있는 경우를 종종 보게 된다. 이렇게 설계된 string 타입으로 생성한 객체는 결국 힙 메모리를 포인터로 물고 있는 형태가 된다. 이때 이 객체를 복사하면, 사본은 포인터 및 그 포인터가 가리키는 힙 메모리를 갖게 된다. 깊은 복사를 보여주는 한 예라고 볼 수 있다.

  4. 관리하고 있는 자원의 소유권을 옮긴다

    그리 흔한 경우는 아니지만, 어떤 특정한 자원에 대해 그 자원이 실제로 참조하는 RAII 객체는 딱 하나만 존재하도록 만드록 싶어서, 그 RAII 객체가 복사될 때 그 자원의 소유권을 사본 쪽으로 아예 옮겨야 할 경우도 살다 보면 생긴다. 이런 스타일의 복사는 이미 항목 13에서 보았을 것이다. 바로 auto_ptr의 복사 동작이다.

객체 복사 함수(복사 생성자 및 복사 대입 연산자)는 컴파일러에 의해 생성될 여지가 있기 때문에, 컴파일러가 생성한 버전의 동작이 우리가 원한 바에 맞지 않으면, 우리가 객체 복사 함수를 직접 만들 수밖에 없다. (어떻게 동작하는지는 항목 5에서 확인하자) 또 경우에 따라 이들 함수의 일반화 버전도 지원하고 싶을 수 있는데, 어떤 버전인지는 항목 45에 나와 있다.

RAII 객체의 복사는 그 객체가 관리하는 자원의 복사 문제를 안고 가기 때문에, 그 자원을 어떻게 복사하느냐에 따라 RAII 객체의 복사 동작이 결정된다.
RAII 클래스에 구현하는 일반적인 복사 동작은 복사를 금지하거나 참조 카운팅을 해 주는 선으로 마무리하는 것이다. 하지만 이 외에 방법들도 가능하니 참고해 두자.


현대 C++과 다른 점들


std::auto_ptr의 삭제

책에서는 auto_ptr을 소개하지만, C++17 표준에서 std::auto_ptr이 삭제되었다. 이제 std::unique_ptr이 auto_ptr의 역할을 대신하고 있다. auto_ptr은 복사 연산을 통해 소유권을 넘기는데, p2 = p1;와 같은 코드에서 p1에 있던 포인터가 p2로 옮겨가고, p1은 강제로 null이 된다. 이런 동작은 직관적이지 않고, STL 컨테이너에 넣을 수 없었다.

하지만 std::unique_ptr은 복사는 금지하고 이동만 허용한다. p2 = p1;처럼 대입하려고 하면 컴파일 에러를 내고, 소유권을 옮기고 싶다면 반드시 std::move()를 사용하여 이것은 이동이다라고 명시해야 한다. 그렇기에 STL 컨테이너를 사용 가능하다.


std::shared_ptr의 표준화와 make_shared

책에서는 tr1::shared_ptr을 소개하지만, C++11 표준에서 std::shared_ptr이 도입되었다. 그리고 shared_ptr를 만들 때 new 연산자를 직접 사용하는 대신, std::make_shared 함수를 사용하는 것이 권장된다. std::make_shared는 객체와 참조 카운트 정보를 하나의 메모리 블록에 할당하여 메모리 사용을 최적화하고, 예외 안전성을 높이는 장점이 있다.


스마트 포인터의 배열 지원

책에서는 auto_ptr과 tr1::shared_ptr이 동적 배열을 지원하지 않는다고 언급하지만, 이제는 std::unique_ptr과 std::shared_ptr 모두 배열 버전을 제공한다. 소멸 시 자동으로 delete[]를 호출한다. 하지만 여전히 std::vector를 쓰는 것이 권장된다고 한다.


참고

This post is licensed under CC BY 4.0 by the author.