본문 바로가기

study/C++

[C++][Effective C++] 13~17. 자원 관리

728x90

[Effective C++(3판)]에 대한 내용을 공부하면서 정리한 내용이다.

 

이번에 요약하는 내용은 05~12 항목으로 아래와 같다.

  • 13. 자원 관리에는 객체가 그만!
  • 14. 자원 관리 클래스의 복사 동작에 대해 진지하게 고찰하자
  • 15. 자원 관리 클래스에서 관리되는 자원은 외부에서 접근할 수 있도록 하자
  • 16. new 및 delete를 사용할 때는 형태를 반드시 맞추자
  • 17. new로 생성한 객체를 스마트 포인터에 저장하는 코드는 별도의 한 문장으로 만들자

요약

13. 자원 관리에는 객체가 그만!

자원이 항상 해제되도록 만들 방법은, 자원을 객체에 넣고 그 자원 해제를 소멸자가 맡도록 하며, 그 소멸자는 실행 제어가 func(자원을 생성하게 된 함수)를 떠날 때 호출하게 하는 것이다. 자원을 객체에 넣음으로써 C++가 자동으로 호출하는 소멸자에 의해 해당 자원을 자동으로 해제하는 것이다.

아래의 예를 보자. 위와 같이 별도 객체로 관리되지 않는다면, ... 부분에서 예외나 반환이 발생하게 될 경우에는 pInv의 자원을 해제하지 못하게 된다.

class CInvestment {};

CInvestment *createInvestment();

void func() {
	CInvestment *pInv = createInvestment();
	// ...

	delete pInv;
	pInv = NULL;
}

 

개발 시 발생하는 상당 수의 자원은 힙에서 동적으로 할당된다. 그리고, 하나의 블록에서만 사용되는 경우가 많다. 따라서 그 블록 혹은 함수로부터 실행 제어가 빠져나올 때 자원을 해제하는 게 맞다. 바로 이런 용도에 사용하라고 만든 클래스로 auto_ptr(C++11 이후 사라짐. C++11 이후 unique_ptr을 사용)이라는 것이 있다.

auto_ptr은 포인터와 비슷하게 동작하는 객체(스마트 포인터)로, 가리키는 대상에 대해 소멸자가 자동으로 delete를 볼러주도록 설계되어 있다.

 

auto_ptr은 C++11 이후에는 사용되지 않기 때문에 별도 작성하지 않는다. 여러 문제 및 단점이 존재한다.

 

자원관리에 객체를 사용하는 방법은 두 가지 특징을 얻을 수 있다.

  1. 자원을 획득한 후에 자원 관리 객체에게 넘긴다.
    • RAII(Resource Acquisition Is Initialization)로 부르며, 자원 획득과 자원 관리 객체의 초기화를 한 번에 진행한다.
  2. 자원 관리 객체는 자신의 소멸자를 이용해 자원이 확실히 해제되도록 한다.
    • 자원 관리 객체가 소멸될 때 자동으로 소멸자를 호출하기 때문에 자원이 확실히 해제된다.

 

auto_ptr을 쓸 수 없는 상황이라면 RCSP(Reference-Counting Smart Pointer)를 사용하는 방법도 괜찮다. RCSP는 특정 어떤 자원을 가리키는 외부 객체의 개수를 유지하고 있다가, 그 개수가 0이 되면 해당 자원을 자동 삭제하는 스마트 포인터다. 가비지 컬렉션과 유사하게 동작하지만, 참조 상태가 고리를 이루는 경우에는 모두 삭제할 수 없게 된다.

RCSP의 예로는 C++11 이후 표준이 된 shared_ptr(예전에는 tr1 포함)이 있다.

 

어찌되었든 중요한 것은 소멸자에서 delete를 통해(delete[]가 아님) 자원을 해제하고 있다는 것이다. 즉, 스마트 포인터에서도 동적 배열에 대해서는 자원 해제가 어려울 수 있다는 것이다. 

동적 배열에 대해서는 C++11 이전에는 boost의 scoped_array와 shared_array라는 것이 있었다. C++11 이후에는 unique_ptr<T[]>와 shared_ptr의 형태로 사용하는 것으로 보인다.

 

정리하자면, 자원 해제를 직접 하다 보면 언젠가 잘못된 일을 하고 만다는 이야기다. 자원 관리 클래스를 활용하게되면 좀 더 확실히 자원을 해제할 수 있을 것이다. 널리 알려진 auto_ptr이나 shared_ptr을 사용하지 못하는 경우에는 직접 만들어야 한다.

 

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

힙 기반 생성된 자원(new로 생성한 자원)에 대해서 RAII 기법을 적용할 수 있는 방안을 13장에서 다루었다. 이제는 힙에 생기지 않는 자원을 처리할 방안에 대해서 생각해보자. 그중 하나는 자원 관리 클래스 객체가 복사할 때의 동작을 정의하는 것이다.

RAII 객체가 복사될 때 어떤 동작을 하게 될까?

  1. 복사 금지
  2. 관리하고 있는 자원에 대해 참조 카운팅 수행
    • RCSP처럼 동작하도록 함
    • 이는 lock 변수에 대해서는 옳지 않을 수 있음. lock 이후 참조 카운트가 0이면 unlock이 되어야지 lock 변수가 자원 해제되면 안 됨.
    • 이때는 shared_ptr삭제자(deleter) 지정을 통해 자원 해제 함수를 unlock()으로 설정해주면 됨.
  3. 관리하고 있는 자원을 진짜로 복사
    • 자원을 다 썼을 때 각각의 사본을 확실히 해제하는 방식으로 동작함.
    • 즉, 깊은 복사(deep copy)가 되어야 함.
  4. 관리하고 있는 자원의 소유권을 옮김
    • 실제 참조하는 RAII 객체가 단 하나만 존재해야 한다면, 소유권을 사본 쪽으로 아예 옮기는 방법임.

 

15. 자원 관리 클래스에서 관리되는 자원은 외부에서 접근할 수 있도록 하자

모든 일을 자원 관리 클래스를 거쳐서 수행한다면 안전하고 편할 것이다. 그러나, 수많은 API들이 자원을 직접 참조하도록 만들어져있기 때문에 자원 관리 객체의 안쪽에 위치한 실제 자원을 다뤄야하는 경우가 많다.

그러다보니 RAII 클래스의 객체는 실제 자원으로 변환할 방법이 필요하다. 이때는 일반적으로 아래 두 가지 방법으로 변환한다.

  1. 명시적 변환(explicit conversion)
  2. 암시적 변환(implicit conversion)

 

위에서 소개했던 스마트 포인터인 auto_ptr과 shared_ptr은 명시적 변환을 위해 get()이라는 멤버 함수를 제공한다. 또한, 암시적 변환을 위해 역참조 연산자(operator-> 또는 operator*)도 오버로딩하고 있다. 단, 암시적 변환은 잘못 사용할 경우가 있어 주의를 요한다.

 

어쨌든 잘 설계한 클래스라면 맞게 쓰기에는 쉽게, 틀리게 쓰기에는 어렵게 만들어졌을 것이다. 사용 환경과 용도에 맞게 변환 함수를 만들어두면 된다.

 

RAII 클래스의 자원 접근 함수를 열어 두는 설계가 캡슐화를 위배하지 않는지 걱정할 수 있다. 맞는 말일 수 있지만, 이는 처음부터 틀려먹은 설계도 아니다. RAII 클래스는 애초부터 데이터 은닉의 목적이 아니고, 원하는 동작을 실수 없이 이루어지게 하기 위함이기 때문이다.

시중의 RAII 클래스 중에는 엄격한 캡슐화를 지원하는 것과 느슨한 캡슐화를 지원하는 것 모두 있다. 사용자가 볼 필요 없는 데이터는 가리고, 꼭 접근해야 하는 데이터는 열어두는 것이 잘 설계된 것이다.

 

16. new 및 delete를 사용할 때는 형태를 반드시 맞추자

new 연산자로 표현식을 꾸미면(어떤 객체를 동적 할당하면), 이로 인해 두 가지 내부 동작이 발생한다.

  1. 메모리가 할당된다.
  2. 할당된 메모리에 한 개 이상의 생성자가 호출된다.

 

delete 연산자로 표현식을 쓸 경우에는 다음 두 가지 내부 동작이 발생한다.

  1. 기존 할당된 메모리에 대해 한 개 이상의 소멸자가 호출된다.
  2. 메모리가 해제된다.

 

여기서, delete 연산자는 단일 객체 대상으로 동작하게 된다. 동적 배열 대상으로는 delete 뒤에 대괄호 []를 추가해주어야 한다. 이럴 경우 delete는 포인터가 배열을 가리킨다는 것을 알게 되고, 배열의 모든 객체에 대한 delete를 수행한다.

단일 객체와 배열 객체의 구조가 달라 delete / delete[]의 동작이 다르기 때문에, 맞지 않는 형태를 사용할 경우에는 정상적으로 자원이 해제되지 않는다.

 

17. new로 생성한 객체를 스마트 포인터에 저장하는 코드는 별도의 한 문장으로 만들자

이번 항목은 예제를 위해 shared_ptr을 사용해보도록 하자. 우선순위를 알려주는 함수가 있고, 동적으로 할당된 CWidget 객체에 대해 우선순위에 따라 처리하도록 한다고 해보자.

int priority();

class CWidget {
};

void processWidget(std::shared_ptr<CWidget> pw, int priority);

int main(void) {
	processWidget(std::shared_ptr<CWidget>(new CWidget), priority()); // 문제 발생 가능 코드
}


위의 코드에서 processWidget()의 인자로 주어진 std::shared_ptr<CWidget>(new CWidget)을 보자. 이 부분은 컴파일러에 따라 정상적으로 동작할 수도 있지만, 아래와 같은 순서로 컴파일러가 동작한다면 문제가 발생할 수 있다.

  1. new CWidget() 호출
  2. priority() 호출
  3. shared_ptr 생성자 호출

 

3번 과정 전에 반드시 인자가 완료되어야 하기 때문에 1번이 앞에 오도록 모두 구현은 되어 있을 것이다. 그러나, 2번 과정 때문에 문제가 발생할 수 있다. 만약, priority() 함수 실행 중에 예외가 발생하면 어떻게 될까? 동적 생성된 CWidget 객체는 shared_ptr에 포함되지 못한 상태다. 따라서 자원이 해제되지도 않는 문제가 발생된다.

이를 해결하기 위해서는 CWidget 객체를 생성해 스마트 포인터에 저장하는 과정을 한 문장으로 처리하면 된다. 아래와 같은 코드로 개선이 가능하다.

int priority();

class CWidget {
};

void processWidget(std::shared_ptr<CWidget> pWidget, int priority);

int main(void) {
	std::shared_ptr<CWidget> pWidget(new CWidget); // RAII
	processWidget(pWidget, priority());
}
728x90