[Effective C++] Chapter 03. 자원 관리 | 항목 13~17

seunghyun·2024년 4월 12일
1

Effective C++

목록 보기
3/4

13) 자원 관리는 객체에게 맡기는 것이 좋다.

어떠한 자원을 얻어냈다면 반드시 반환하는 것이 원칙이다.
자원을 객체에 넣고 그 자원의 해제를 객체의 소멸자가 맡도록 하고, 그 소멸자가 자원을 얻어낸 부분을 탈출할 때 호출되도록 만드는 것이 좋다.

자원을 획득하여 자원 관리 객체에 넘긴다 (RAII)
자원 관리 객체는 소멸자를 활용해 자원이 확실히 해제되도록 한다

스마트 포인터인 std::auto_ptr가 이 아이디어를 구현하고 있다.
어떠한 객체를 가리키는 auto_ptr의 개수가 둘 이상이면 삭제를 두번 하게 되므로 절대로 안된다.
그래서 auto_ptr은 객체를 복사하면 원본 객체를 null로 만든다.
이러한 특성 때문에 원소가 정상적으로 복사 동작을 해야하는 STL 컨테이너의 원소로는 auto_ptr을 사용할 수 없다.

만약 이러한 특성때문에 auto_ptr을 사용하기 꺼려질 때에는 참조 카운팅 방식 스마트 포인터인 RCSP를 사용할 수 있다.
std::shared_ptr이 대표적인 rcsp이다.
shared_ptr은 stl의 원소로도 사용할 수 있다.

이 두 포인터는 소멸자 내부에서 delete[]가 아니라 delete를 사용하므로, 동적으로 할당한 배열에 대해서 사용하는 것은 좋지 않다.
동적 배열은 이제 vector와 string으로 거의 대체되므로 괜찮을 것이다.
만약 정말 필요하다면 boost::scoped_array와 boost::shared_array를 참조하라


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

auto_ptr과 shared_ptr은 힙에 생성되는 객체를 처리할 수 있는 스마트 포인터이다.
만약 힙에 생성되지 않는 자원은 어떻게 해야할까?
예를 들면 뮤텍스의 lock과 같은 것 말이다. 잠금을 잊지 않고 풀어줘야 하기 때문이다.
그럴땐 다음과 같이 직접 RAII를 적용한 클래스를 만든다.

class Lock{
public:
    explicit Lock(Mutex* pm) : mutexPtr(pm)
    {
      lock(mutexPtr);
    }
    ~Lock()
    {
      unlock(mutexPtr);
    }
    
private:
    Mutex *mutexPtr;
}

만약 이 객체에 대해 복사를 시도할 때에는 동작을 어떻게 정의해야 할까?

복사를 금지한다. 복사 연산자를 private으로 만들면 된다.
shared_ptr과 같이 관리 자원에 대해 참조 카운팅을 수행한다. shared_ptr은 원래 참조 카운트가 0이 되면 해당 객체를 삭제하지만, deleter를 정의해 해당 동작을 지정할 수 있다.

class Lock{
public:
    explicit Lock(Mutex *pm) : mutexPtr(pm, unlock)
    {
      lock(mutexPtr.get());
    }
    
private:
    std::shared_ptr<Mutex> mutexPtr;
}

진짜로 복사한다. deep copy를 통해 해당 자원까지 복사시킨다.
관리중인 자원의 소유권을 옮긴다. auto_ptr처럼 말이다.


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

RAII 객체에서 직접 실제 자원으로 변환할 방법이 필요하다.
명시적 변환과 암시적 변환 두가지 방법이 있다.

명시적 변환: get()과 같은 함수로 자원의 포인터를 얻을 수 있도록 한다
암시적 변환: ->와 *의 연산자 오버로딩으로 자원에 접근하도록 만들거나 암시적 변환 함수를 직접 제공하도록 한다

명시적 변환이 귀찮다 하더라도 암시적 변환은 원하지 않는 타입 변환이 일어날 수 있는 가능성을 키우므로, 시의 적절하게 사용하자.

이러한 접근 방법을 열어주는 것이 캡슐화에 위배될까 걱정은 하지말자.
애초에 자원 관리 객체의 목적은 은닉이 아니다!
shared_ptr처럼 참조 카운팅 메커니즘만 잘 은닉되면 좋은 설계로 보여진다.


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

new 연산자를 사용해서 어떤 객체를 동적 할당하면, 이로 인해 두 가지의 내부 동작이 진행된다.

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

delete 연산자를 사용하면 또 다른 두 가지의 내부 동작이 진행된다.

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

여기서 질문.

delete 연산자가 적용되는 객체는 몇 개나 될까?

  • 소멸자가 호출되는 횟수이다.

음??

다시 질문하자면, 삭제되는 포인터는 객체 하나만 가리킬까, 아니면 객체의 배열을 가리킬까?

  • new로 힙에 만들어진 단일 객체의 메모리 배치구조는 객체 배열에 대한 메모리 배치구조와 다르기 때문에 중요하다.
  • 배열을 위해 만들어지는 힙 메모리에는 대개 배열원소의 개수가 저장된다. (컴파일러마다 꼭 이렇게 구현할 필요는 없으나, 대다수의 경우는 이렇게 구현한다고 한다)
  • 이 때문에 delete 연산자는 소멸자가 몇 번 호출될지를 쉽게 알 수 있다.
  • 반면, 단일 객체용 힙 메모리는 이런 정보가 없다.

delete가 '포인터가 배열을 가리키고 있구나' 라는 걸 알게 해주려면 아래 코드처럼 [] 대괄호 쌍을 delete 뒤에 붙여줘야 한다.
delete는 앞쪽의 메모리 몇 바이트를 읽고 이것을 배열 크기라고 해석하고, 배열 크기에 해당하는 횟수만큼 소멸자를 호출하기 시작한다.

string *stringPtr1 = new string;
string *strintPtr2 = new string[100];

delete stringPtr1;
delete [] stringPtr2;

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

Q. 아래 코드처럼, 처리 우선순위를 알려주는 함수가 하나 있고, 동적으로 할당한 Widget 객체에 대해 어떤 우선순위에 따라 처리를 적용하는 함수가 하나 있다고 가정하겠습니다.
이 때 Widget 객체를 어떻게 생성하면 좋을지 1~3번 중에서 골라주세요 (객관식)

// 문제 코드
int priority();
processWidget(shared_ptr<Widget> pw, int priority);
// 1~3번 중에서 골라주세요
1. processWidget(shared_ptr<Widget>(new Widget), priority());

2. processWidget(shared_ptr<Widget>(new Widget), priority());

3. shared_ptr<Widget> pw(new Widget);
		 processWidget(pw, priority());
  • 답 참고 135~137쪽 3번
    • 1번은 컴파일이 됩니다. 그러나 자원을 흘릴 가능성이 있습니다. 자원이 생성되는 시점(new Widget을 통한) 과 그 자원이 자원 관리 객체로 넘어가는 시점 사이에 예외가 끼어들 수 있기 때문입니다.
    • 2번은 컴파일이 안됩니다. 포인터를 받는 shared_ptr 의 생성자는 explicit 로 선언되어 있으므로 new Widget 표현식에 의해 만들어진 포인터가 shared_ptr 타입의 객체로 바꾸는 암시적인 변환이 불가능하기 때문입니다.
    • 3번에서는 new로 생성한 객체를 스마트 포인터에 담는 코드를 하나의 독립적인 문장으로 만들었기에, 한 문장 안에 있는 연산들보다 문장과 문장 사이에 있는 연산들이 컴파일러의 재조정을 받을 여지가 적으므로 자원 누출 가능성이 없습니다.
profile
game client programmer

1개의 댓글

comment-user-thumbnail
2024년 4월 14일

와우,,

답글 달기