Effective C++ : 3
Updated:
Chapter3. 자원 관리
프로그래밍 분야에서 자원(resource)이란, 사용을 일단 마치고 난 후엔 시스템에 돌려주어야하는 모든 것을 일컫는다.
항목 13: 자원 관리에는 객체가 그만!
- auto_ptr [스마트 포인터(smart pointer)]
- 첫째 ,자원을 획득한 후에 자원 관리 객체에게 넘긴다. 자원 획득 즉 초기화(Resource Acquisition is Initialization: RAII)
- 둘째, 자원 관리 객체는 자신의 소멸자를 사용해서 자원이 확실히 해제되도록 한다.
- auto_ptr은 자신이 소멸될 때 자신이 가리키고 있는 대상에 대해 자동으로 delete를 먹인다. 그러므로 어떤 객체를 가리키는 auto_ptr의 개수가 둘 이상이면 절대로 안 된다.
- auto_ptr 객체를 복사하면(복사 생성자 혹은 복사 대입 연산자를 통해) 원본 객체는 null로 만든다. 복사하는(copying) 객체만이 그 자원의 유일한 소유권(ownership)을 갖는다고 가정하기 때문이다.
- auto_ptr을 쓸 수 없는 상황이라면 그 대안으로 참조 카운팅 방식 스마트 포인터(reference-counting smart pointer: RCSP)가 아주 괜찮다. RCSP는 특정한 어떤 자원을 가리키는(참조하는) 외부 객체의 개수를 유지하고 있다가 그 개수가 0이 되면 해당 자원을 자동으로 삭제하는 스마트 포인터이다.
- 이것만은 잊지 말자!
- 자원 누출을 막기 위해, 생성자 안에서 자원을 획득하고 소멸자에서 그것을 해제하는RAII 객체를 사용하자.
- 일반적으로 널리 쓰이는 RAII 클래스는 tr1::shared_ptr 그리고 auto_ptr이다. 이 둘 가운데 tr1::shared_ptr이 복사 시의 동작이 직관적이기 때문에 대개 더 좋다. 반면, auto_ptr은 복사되는 객체(원본 객체)를 null로 만들어 버린다.
항목 14: 자원 관리 클래스의 복사 동작에 대해 진지하게 고찰하자
-
RAII 객체가 복사될 때 어떤 동작이 이루어져야 할까
-
복사를 금지한다. 실제로 RAII 객체가 복사되도록 놔두는 것 자체가 말이 안 되는 경우가 꽤 많다. 복사하면 안 되는 RAII 클래스에 대해서는 반드시 복사가 되지 않도록 막아야 한다.
class Lock: private Uncopyable { // 복사를 금지한다. 참고로 // 항목 6을 보시기 바란다. ... // 이전과 같다. };
-
관리하고 있는 자원에 대한 참조 카운팅을 수행한다. 해당 자원을 참조하는 객체의 개수에 대한 카운트를 증가시키는 식으로 RAII 객체의 복사 동작을 만들어야 한다. 참고로, 이런 방식은 현재 tr1::shared_ptr이 사용하고 있다.
-
-
RAII 클래스에 참조 카운팅 방식의 복사 동작을 넣고 싶을 때 tr1::shared_ptr을 데이터 멤버로 넣으면, 간단히 해결될까?
-
아쉽게도 tr1::shared_ptr은 참조 카운트가 0이 될 때 자신이 가리키고 있던 대상을 삭제해 버리도록 기본 동작이 만들어져 있다. Mutex를 다 썼을 때 이것에 대해 잠금 해제만 하면 되지, 삭제까지는 하고 싶지 않다.
-
다행히도 tr1::shared_ptr이 ‘삭제자(deleter)’ 지정을 허용한다는 사실이다. 여기서 삭제자란, tr1::shared_ptr이 유지하는 참조 카운트가 0이 되었을 때 호출하는 함수 혹은 함수 객체를 일컫는다. 삭제자는 tr1::shared_ptr 생성자의 두 번째 매개변수로 선택적으로 넣어 줄 수 있다. 다음 코드를 보자.
class Lock { public: explicit Lock(Mutex *pm) // shared_ptr을 초기화하는데 가리킬 포인터로 : mutexPtr(pm,unlock) // Mutex 객체의 포인터를 사용하고 삭제자로 { // unlock 함수를 사용한다. lock(mutexPtr.get());// "get"의 이야기는 항목 15에서 확인해라 } private: std::tr1::shared_ptr<Mutex> mutexPtr; // 원시 포인터 대신에 }; // shared_ptr을 사용한다.
이 예제에는 소멸자가 없다. 클래스의 소멸자는 비정적 데이터 멤버의 소멸자를 자동으로 호출하게 되어 있다. 이 ‘비정적 데이터 멤버’에 해당하는 것이 mutex-Ptr이다. mutexPtr의 소멸자는 뮤텍스의 참조 카운트가 0이 될 때 tr1::shared_ptr의 삭제자를 자동으로 호출할 거라는 것이다.
-
관리하고 있는 자원을 진짜로 복사한다. 때에 따라서는 자원을 원하는 대로 복사할 수도 있다. 이때는 ‘자원을 다 썼을 때 각각의 사본을 확실히 해제하는 것’이 자원 관리 클래스가 필요한 유일한 명분이 되는 것이다. 자원 관리 객체를 복사하면 그 객체가 둘러싸고 있는 자원까지 복사되어야 한다. 즉, ‘깊은 복사(deep copy)’를 수행해야 한다는 이야기이다.
-
관리하고 있는 자원의 소유권을 옮긴다. 흔한 경우는 아니지만, 어떤 특정한 자원에 대해 그 자원을 실제로 참조하는 RAII 객체는 딱 하나만 존재하도록 만들고 싶어서, 그 RAII 객체가 복사될 때 그 자원의 소유권을 사본 쪽으로 아예 옮겨야 할 경우도 생긴다.
-
-
이것만은 잊지 말자!
- RAII 객체의 복사는 그 객체가 관리하는 자원의 복사 문제를 안고 가기 때문에, 그 자원을 어떻게 복사하느냐에 따라 RAII 객체의 복사 동작이 결정된다.
- RAII 클래스에 구현하는 일반적인 복사 동작은 복사를 금지하거나 참조 카운팅을 해 주는 선으로 마무리하는 것이다.
항목 15: 자원 관리 클래스에서 관리되는 자원은 외부에서 접근할 수 있도록 하자
- RAII 클래스(지금은 tr1::shared_ptr)의 객체를 그 객체가 감싸고 있는 실제 자원(그러니까 Investment*)으로 변환할 방법이 필요하다. 이런 목적에 일반적인 방법을 쓴다면 두 가지가 있는데, 하나는 명시적 변환(explicit conversion)이고 또 다른 하나는 암시적 변환(implicit conversion)이다.
- tr1::shared_ptr / auto_ptr은 명시적 변환을 수행하는 get이라는 멤버 함수를 제공한다. 다시 말해 이 함수를 사용하면 각 타입으로 만든 스마트 포인터 객체에 들어 있는 실제 포인터(의 사본)를 얻어낼 수 있다.
- 제대로 만들어진 스마트 포인터 클래스라면 거의 모두가 그렇듯, tr1::shared_ptr과 auto_ptr은 포인터 역참조 연산자(operator-> 및 operator*)도 오버로딩하고 있다. 따라서 자신이 관리하는 실제 포인터에 대한 암시적 변환도 쉽게 할 수 있다.
- 이것만은 잊지 말자!
- 실제 자원을 직접 접근해야 하는 기존 API들도 많기 때문에, RAII 클래스를 만들 때는 그 클래스가 관리하는 자원을 얻을 수 있는 방법을 열어 주어야 한다.
- 자원 접근은 명시적 변환 혹은 암시적 변환을 통해 가능하다. 안정성만 따지면 명시적 변환이 대체적으로 더 낫지만, 고객 편의성을 놓고 보면 암시적 변환이 괜찮다.
항목 16: new 및 delete를 사용할 때는 형태를 반드시 맞추자
new 연산자를 사용해서 표현식을 꾸미게 되면(즉, new로 어떤 객체를 동적 할당하면), 이로 인해 두 가지의 내부 동작이 진행된다.
- 일단 메모리가 할당된다(이때 operator new라는 이름의 함수가 쓰인다. 항목 49, 51 참조).
- 그 다음, 할당된 메모리에 대해 한 개 이상의 생성자가 호출된다.
delete 표현식을 쓸 경우에는(즉, delete 연산자를 사용할 때는) 또 다른 두 가지의 내부 동작이 진행된다.
- 우선 기존에 할당된 메모리에 대해 한 개 이상의 소멸자가 호출된다.
- 그 후에 메모리가 해제된다(이때 operator delete라는 이름의 함수가 쓰인다).
delete 연산자가 적용되는 객체는 대체 몇 개나 될까? 이 질문의 답은 ‘소멸자가 호출되는 횟수’이다.
복잡하니 풀어써보자. 삭제되는 포인터는 객체 하나만 가리킬까, 아니면 객체의 배열을 가리킬까? 이것이 진짜 핵심인데, 왜냐하면 (new로 힙에 만들어진) 단일 객체의 메모리 배치구조(layout)는 객체 배열에 대한 메모리 배치구조와 다르기 때문이다. 특히, 배열을 위해 만들어지는 힙 메모리에는 대개 배열원소의 개수가 박혀 들어간다는 점이 가장 결정적인데, 이 때문에 delete 연산자는 소멸자가 몇 번 호출될지를 쉽게 알 수 있다. 반면, 단일 객체용 힙 메모리는 이런 정보가 없다.
어떤 포인터에 대해 delete를 적용할 때, delete 연산자로 하여금 ‘배열 크기 정보가 있다’는 것을 알려 줄 칼자루는 우리가 쥐고 있다. 이때 대괄호 쌍([])을 delete 뒤에 붙여 주는 것이다. 그제야 delete가 ‘포인터가 배열을 가리키고 있구나’라고 가정하게 된다.
std::string *stringPtr1 = new std::string;
std::string *stringPtr2 = new std::string[100];
...
delete stringPtr1; // 객체 한 개를 삭제한다.
delete [] stringPtr2; // 객체 배열을 삭제한다.
stringPtr1에 ‘[]’ 형태를 사용하면 어떤 일이 생길까? 우선 delete는 앞쪽의 메모리 몇 바이트를 읽고 이것을 배열 크기라고 해석한다. 배열 크기에 해당하는 횟수만큼 소멸자를 호출하기 시작한다.
stringPtr2에 ‘[]’ 형태를 사용하지 않으면 어떤 일이 생길까? 미정의 동작이 나타난다.
- 이것만은 잊지 말자!
- new 표현식에 []를 썼으면, 대응되는 delete 표현식에도 []를 써야 한다.
- new 표현식에 []를 안 썼으면, 대응되는 delete 표현식에도 []를 쓰지 말아야 한다.
항목 17: new로 생성한 객체를 스마트 포인터에 저장하는 코드는 별도의 한 문장으로 만들자.
- 이것만은 잊지 말자!
- new로 생성한 객체를 스마트 포인터로 넣는 코드는 별도의 한 문장으로 만들자. 이것이 안 되어 있으면, 예외가 발생할 때 디버깅하기 힘든 자원 누출이 초래될 수 있다.
Leave a comment