일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | ||||||
2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | 24 | 25 | 26 | 27 | 28 |
- 절두체 컬링
- FrameResource
- gitlab
- 게임 클래스
- effective C++
- light
- TCP/IP
- 네트워크
- C++
- DirectX12
- 동적 색인화
- 입방체 매핑
- 게임 프로그래밍
- Deferred Rendering
- 직교 투영
- 조명 처리
- 노멀 맵핑
- Frustum Culling
- gitscm
- InputManager
- 장치 초기화
- DirectX
- Render Target
- Dynamic Indexing
- 네트워크 게임 프로그래밍
- 게임 디자인 패턴
- 디퍼드 렌더링
- Direct3D12
- 큐브 매핑
- direct3d
- Today
- Total
코승호딩의 메모장
자원 관리 본문
13 자원 관리에는 객체가 그만!
class Investment { };
Investment* CreateInvestment();
다음과 같이 투자를 모델링해 주는 클래스 라이브러리를 가지고 어떤 작업을 한다고 가정하자. Investment라는 최상위 클래스가 있고, 이것을 기본으로 하여 구체적인 형태의 투자 클래스가 파생되어 있다. 그리고 파생된 클래스의 객체를 사용자가 얻어내는 용도인 팩토리 함수가 있다. 만약 CreateInvestment를 통해 얻응 객체를 사용할 일이 없을 때, 그 객체를 삭제해야 하는 쪽은 이 함수의 호출자이다.
void f()
{
Investment* pInv = CreateInvestment();
// ...
delete pInv;
}
멀쩡해 보이지만, 객체의 삭제가 실패할 수 있는 경우가 있다. ... 과정에서 return 문이 들어 있거나, continue 혹은 goto 문에 의해 루프로부터 빠져나오는 경우이다. 그리고 ... 안에서 예외가 발생할 수도 있다. 이 경우 delete를 실행하지 않게 되고 메모리 누수가 일어난다. 따라서 CreateInvestment 함수로 얻은 자원을 항상 해제되도록 만들 방법은, 자원을 객체에 넣고 그 자원 해제를 소멸자가 맡도록 하는 것이다.
void f()
{
auto_ptr<Investment> pInv(CreateInvestment());
// ...
}
auto_ptr은 포인터와 비슷하게 동작하여 소멸자가 자동으로 delete를 불러주도록 설계되어 있다. 이러한 자원 관리 객체를 사용하는 방법의 중요한 두 가지 특징이 있다.
- 자원을 획득한 후 자원 관리 객체에 넘긴다. 위 예시 코드에서 CreateInvestment 함수가 생성한 자워늘 auto_ptr 객체를 초기화하는데 사용하고 있다. 이렇게 자원 관리에 객체를 사용하는 것을 RAII 자원 획득 즉 초기화라고 한다. 자원 획득과 자원 관리 객체의 초기화가 바로 한 문장에서 이루어지는 것이 일상적이기 때문이다.
- 자원 관리 객체는 자신의 소멸자를 사용해 자원이 확실이 해제되도록 한다. 소멸자는 어떤 객체가 소멸될 때, 자동으로 호출된다. 따라서 실행 제어가 어떤 경위로 블록을 떠나는가에 상관없이 자원 해제가 제대로 이뤄진다.
auto_ptr은 자신이 소멸될 때, 자신이 가리키고 있는 대상에 대해 자동으로 delete를 호출한다. 따라서 어떤 객체를 가리키는 auto_ptr이 두 개 이상이면 절대로 안된다. 두 번 삭제가 될 수도 있기 때문이다. 따라서 auto_ptr을 쓸 수 없는 상황이라면 참조 카운팅 방식 스마트 포인터(RCSP)를 사용해야 한다. 자원을 가리키는 외부 객체의 개수를 유지하다가 0이 되면 해당 자원을 자동으로 삭제하는 스마트 포인터이다.
void f()
{
shared_ptr<Investment> pInv(CreateInvestment());
shared_ptr<Investment> pInv2(pInv);
pInv = pInv2;
}
동시에 같은 객체를 가리키고 있고 블록을 벗어나더라도 해제가 잘 된다.
여기서 중요한 점은 auto_ptr 및 shared_ptr은 소멸자 내부에서 delete[ ]가 아닌 delete를 호출한다. 따라서 동적 배열에 대해 스마트 포인터를 사용하면 안된다. 컴파일 에러도 발생하지 않기 때문에 조심해야 한다.
14 자원 관리 클래스의 복사 동작에 대해 진지하게 고찰하자
세상에 모든 자원은 힙에만 생기는 것이 아니다. 힙에 생기지 않는 자원은 auto_ptr 혹은 shared_ptr 등의 스마트 포인터로 처리해 주기엔 맞지 않다. 이때 자원 관리 클래스를 직접 만들어야 할 필요가 있다.
void Lock(mutex* pm);
void UnLock(mutex* pm);
pm이 가리키는 뮤텍스에 잠금을 걸고 해제하는 함수가 있다고 하자. 이러한 뮤텍스 잠금을 관리하는 클래스를 하나 만들고 싶다. RAII 법칙에 따라 생성 시 자원을 획득하고, 소멸 시 자원을 해제하도록 하는 것이 목적이다.
class Lock {
public:
explicit Lock(mutex* pm)
:pMutex(pm) {
lock(pMutex);
}
~Lock() {
unLock(pMutex);
}
private:
mutex* pMutex;
};
Mutex m;
{
Lock m1(&m);
}
여기까지만 보면 재대로 동작하는 것으로 보인다. 그런데 만약 Lock 객체가 복사가 된다면 어떻게 될까? 즉, RAII 객체가 복사가 될 때 어떤 동작이 이루어져야 할까?
1. 복사를 금지한다.
실제로 RAII 객체가 복사되도록 놔두는 것은 말이 안된다. 따라서 RAII 클래스에 대해서는 반드시 복사가 되지 않도록 막아야 한다. 복사를 막는 방법은 앞서 설명한 Uncopyable을 사용한다.
2. 관리하고 있는 자원에 대해 참조 카운팅을 수행한다.
class Lock {
public:
explicit Lock(mutex* pm)
:pMutex(pm, unLock) {
lock(pMutex.get());
}
private:
shared_ptr<mutex> pMutex;
};
해당 자원을 참조하는 객체의 개수에 대한 카운트를 증가시키는 식으로 RAII 객체의 복사 동작을 만드는 것이다. shared_ptr과 비슷하게 말이다. 간단하게 위 경우 mutex* 가 아닌 shared_ptr<mutex>를 사용하면 될 것이다. 그러나 이 경우 대상을 삭제하도록 기본 동작이 만들어지기 때문에, 잠금 해제만 하면 되지, 삭제까지는 하고 싶지 않다. 다행인 것은 shared_ptr이 삭제자(deleter) 지정을 허용한다는 것이다. 삭제자란 shared_ptr이 유지하는 참조 카운트가 0이 될 경우 호출되는 함수 혹은 함수 객체를 일컫는다. 이 삭제자는 shared_ptr의 생성자의 두 번째 인자로 넣어줄 수 있다. 아래 코드와 같이 Lock 클래스는 소멸자를 선언하지 않았고, 뮤텍스의 참조 카운트가 0이 될 때, 삭제자 unLock을 자동으로 호출할 것이다.
3. 관리하고 있는 자원을 진짜로 복사한다.
때에 따라 자원을 원하는 대로 복사할 수 있다. 이때, 자원을 다 썼을 때 각각의 사본을 확실히 해제하는 것이 자원 관리 클래스가 필요한 유일한 명분이다. 자원 관리 객체를 복사하면 그 객체가 둘러싸고 있는 자원까지 복사가 되어야 한다. 즉, 싶은 복사가 수행되어야 한다는 것이다.
4. 관리하고 있는 자원의 소유권을 옮긴다.
어떤 자원을 실제로 참조하는 RAII 객체는 딱 하나만 존재하도록 만들고 싶을 때, 복사한 경우 사본 쪽으로 소유권을 아예 옮겨야 한다. 이것이 바로 auto_ptr의 복사 동작에 해당한다.
15 자원 관리 클래스에서 관리되는 자원은 외부에서 접근할 수 있도록 하자
int daysHeld(const Investment* pi);
shared_ptr<Investment> pInv(CreateInvestment());
int days = daysHeld(pInv);
위 코드와 같이 Investment 객체를 사용하는 함수로 다음과 같다고 할 때, 위 코드는 컴파일이 안된다. 왜냐하면 daysHeld 함수는 Investment* 타입의 실제 포인터를 원하는데 shared_ptr<Investment> 타입의 객체를 넘기고 있기 때문이다. 이에 따라 shared_ptr<Investment>의 객체를 Investment* 객체로 변환할 방법이 필요하다. 이 때 일반적으로는 명시적 변환과 암시적 변환이 있다.
shared_ptr과 auto_ptr은 명시적 변환을 수행하는 get이라는 멤버 함수를 제공한다. 따라서 이 함수를 사용하면 각 타입으로 만든 스마트 포인터 객체에 들어 있는 실제 포인터의 사본을 얻어낼 수 있다.
int daysHeld(const Investment* pi);
shared_ptr<Investment> pInv(CreateInvestment());
int days = daysHeld(pInv.get());
다음과 같이 말이다. 따라서 RAII 클래스를 만들 때는 그 클래스가 관리하는 자원을 얻을 수 있는 방법을 열어 줘야 하며, 자원 접근은 명시적 혹은 암시적 변환을 통해 가능하지만, 안전성만 따지면 명시적 변환이 대체적으로 더 낫다. 편의성만으로면 암시적 변환도 꽤 괜찮다.
16 new 및 delete를 사용할 때는 형태를 반드시 맞추자
string* stringArray = new string[100];
delete stringArray;
위 코드는 100개의 string 객체들 가운데 99개는 정상적인 소멸 과정을 거치지 못할 것이다.
만약 new를 통해 동적 할당을 하면, 두 가지의 내부 동작이 진행된다. operator new라는 이름의 함수로 인해 메모리가 할당될 것이고, 할당된 메모리에 대한 한 개 이상의 생성자가 호출될 것이다.
delete 연산자를 사용할 경우에도 두 가지의 내부 동작이 진행된다. 우선 기존에 할당된 메모리에 대해 한 개 이상의 소멸자가 호출이 될 것이고, 그 후에 operator delete라는 이름의 함수로 인해 그 메모리가 해제될 것이다. 여기서 delete 연산자가 적용되는 객체는 몇 개나 될까? 바로 소멸자가 호출되는 횟수만큼이다.
new로 힙에 만들어진 단일 객체의 메모리 배치구조는 객체 배열에 대한 메모리 배치구조와 다르다. 특히, 배열을 위해 만들어지는 힙 메모리에는 대개 배열원소의 개수가 박혀 들어간다. 때문에 delete 연산자는 소멸자가 몇 번 호출되는지 쉽게 알 수 있다. 반면, 단일 객채용 힙 메모리에는 이런 정보가 없다. 따라서 어떤 포인터에 대해 delete를 적용할 때, 배열 크기 정보가 있다는 것을 알려줘야 한다. 이때 대괄호 쌍([ ])을 delete 뒤에 붙여 주는 것이다. 그제야 delete가 포인터가 배열을 가리키고 있구나라고 가정하게 된다. 그렇지 않으면 그냥 단일 객체라고 간주하고 만다.
string* stringArray = new string[100];
delete[] stringArray;
우선 delete는 앞쪽의 메모리 몇 바이트를 읽고 이것을 배열 크기라고 해석한다. 이후, 배열 크기에 해당하는 횟수만큼 소멸자를 호출한다.
이제껏 그랬던 것처럼 new 표현식에 [ ]를 썼으면, 여기에 대응되는 delete 표현식에도 [ ]를 써야 한다. 쓰지 않았다면 delete 표현식에도 쓰지 않아야 한다.
17 new로 생성한 객체를 스마트 포인터에 저장하는 코드는 별도의 한 문장으로 만들자
int Priority();
void ProcessWidget(shared_ptr<Widget> pw, int priority);
ProcessWidget(new Widget, Priority()); // X
ProcessWidget(shared_ptr<Widget>(new Widget), Priority()); // O
다음과 같이 처리 우선순위를 알려 주는 함수가 있고, 동적으로 할당한 Widget 객체에 대해 어떤 우선순위에 따라 처리를 적용하는 함수가 하나 있다고 가정하자. 위 코드는 당연히도 컴파일이 되지 않는다. 왜냐하면 shared_ptr의 생성자는 explicit으로 선언되어 있기 때문에, new Widget 표현식에 의해 만들어진 포인터가 shared_ptr로 바꾸는 암시적인 변환이 없다.
그러나 위 코드는 자원을 흘릴 가능성이 있다. 컴파일러는 ProcessWidget 호출 코드를 만들기 전, 우선 이 함수의 매개변수로 넘겨지는 인자를 평가한다. 여기서 두 번째 인자는 Priority 함수의 호출문밖에 없지만, 첫 번째 인자는 new Widget을 실행하는 부분과 shared_ptr의 생성자를 호출하는 두 부분으로 나누어져 있다. 때문에 PorcessWidget 함수 호출이 이루어지기 전 컴파일러는 Priority 함수를 호출, new Widget 실행, shared_ptr 생성자를 호출하는 세 가지 연산을 위한 코드를 만들어야 한다. 그러나 각각의 연산이 실행되는 순서는 컴파일러 제작사마다 다르다. new Widget 표현식은 당연히 shared_ptr 생성자가 호출되기 전 호출되어야 할 것이다. 그러나 Priority 호출은 처음 호출될 수도 있고, 두 번째나 세 번째에 호출될 수 있다.
만약 Priority 호출이 두 번째에 호출되었다고 한다면 1. new Widget을 실행, 2. Priority 호출, 3. shared_ptr 생성자 호출의 순서가 될 것이다. 그러나 만약 Priority를 호출하는 부분에서 예외가 발생한다면 new Widget으로 만들어졌던 포인터가 유실될 수 있을 것이다. shared_ptr에 저장되기도 전에 예외가 발생했기 때문이다. 그러니까 ProcessWidget 호출 중에 자원이 누출될 가능성이 있는 이유는, 자원이 생성되는 시점(new Widget을 통과)과 그 자원이 관리 객체로 넘어가는 시점 사이에 예외가 끼어들 수 있기 때문이다.
shared_ptr<Widget> pw(new Widget);
ProcessWidget(pw, Priority());
이러한 문제를 피하기 위해서는 Widget을 생성해서 스마트 포인터에 저장하는 코드를 별도의 문장 하나로 만들고, 그 스마트 포인터를 ProcessWidget으로 넘기는 것이다. 한 문장에 있는 연산들보다 문장과 문장 사이에 있는 연산들이 컴파일러의 재조정을 받을 여지가 적기 때문에 자원 누출 가능성이 없다.
따라서 new로 생성한 객체를 스마트 포인터로 넣는 코드는 별도의 한 문장으로 만들어야 한다.
'C++ Study > Effective C++' 카테고리의 다른 글
생성자, 소멸자 및 대입 연산자 (1) | 2024.01.11 |
---|---|
C++에 왔으면 C++의 법을 따르자 (0) | 2023.12.31 |