13. 자원 관리에는 객체가 그만
어떤 클래스가 있고 그 클래스를 동적으로 생성 후 반환해 주는 함수(팩토리 함수)가 있습니다.
class Base { };
Base* CreateBase() {
Base* b = new Base;
return b;
}
int main() {
Base* p = CreateBase();
...;
delete p;
}
당연하게도 new를 통해 동적 할당해주었기 때문에 delete로 삭제시켜줘야 합니다. 하지만 CreateBase()와 delete 사이에 어떤 일이 일어날지 모릅니다. 이런 결과는 결국은 메모리 누수로 이어지고 계속 누적된다면 어떤 문제가 일어날지 모릅니다.
이 문제의 대안 중 하나는 스마트 포인터 입니다. 가리키는 대상에 소멸자가 자동으로 delete를 시켜주는데 이 스마트 포인터로는 auto_ptr, shared_ptr 등이 있습니다. 위 예제에서 auto_ptr을 적용해보겠습니다. 스마트 포인터를 사용하기 위해서는 #include <memory>를 해주셔야 합니다.
#include <iostream>
#include <memory>
class Base { };
Base* CreateBase() {
Base* b = new Base;
return b;
}
int main() {
std::auto_ptr<Base> pBase(CreateBase());
// auto_ptr의 소멸자를 통해 동적 할당된 객체는 자동으로 해제시켜줍니다.
}
https://cplusplus.com/reference/memory/auto_ptr/
이 스마트 포인터 같은 자원 관리 객체는 자원을 획득 후 자원 관리 객체에 넘기고 자원 관리 객체는 자신의 소멸자를 통해 얻는 자원을 확실히 해제되도록 합니다. 이 특징 때문에 자원 획득 즉 초기화 (Resource Acquisition Is Initalization: RAII)라고도 합니다. 이 auto_ptr은 소멸할 때 관리하는 객체를 소멸시키기 때문에 이 객체는 가리키는 대상이 둘 이상일 땐 먼저 가리킨 객체는 null이 됩니다.
#include <iostream>
#include <memory>
using namespace std;
class Base { };
Base* CreateBase() {
Base* b = new Base;
return b;
}
int main() {
std::auto_ptr<Base> pBase1(CreateBase());
std::auto_ptr<Base> pBase2(pBase1);
// pBase1 == NULL
pBase1 = pBase2;
// pBase2 == NULL
}
위와 같은 auto_ptr의 문제를 해결하기 위해 shared_ptr이 있습니다. 이 shared_ptr은 참조 카운팅 방식 스마트 포인터라고 해서 객체를 참조하고 있는 수를 체크해서 참조 중인 관리 객체가 0이 됐을 경우에 소멸 시키는 방식입니다.
아래처럼 사용했을 경우 CreateBase를 통해 생성된 객체를 관리하는 자원 객체는 1개 입니다. (pBase1)
#include <iostream>
#include <memory>
using namespace std;
class Base { };
Base* CreateBase() {
Base* b = new Base;
return b;
}
int main() {
std::shared_ptr<Base> pBase1(CreateBase());
}
이 pBase1이 소멸되면서 CreateBase를 통해 생성된 객체를 관리하는 자원 객체는 0개가 되고 참조 카운트가 0이 됐다는 것을 의미합니다. 그렇기 때문에 소멸시킵니다. 아까 위에서 봤던 복사되는 예제를 살펴봅시다.
#include <iostream>
#include <memory>
using namespace std;
class Base { };
Base* CreateBase() {
Base* b = new Base;
return b;
}
int main() {
std::shared_ptr<Base> pBase1(CreateBase());
// 참조 카운트 1
std::shared_ptr<Base> pBase2(pBase1);
// 참조 카운트 2
pBase1 = pBase2;
// 참조 카운트 2
}
shared_ptr을 통해 생성된 객체로 객체를 넘기면 참조 카운트가 올라가면서 자원 관리 객체가 소멸될 때마다 참조 카운트가 감소하게 되고 결국에 0이 됐을 때 넘겨받은 객체를 소멸시켜 줍니다.
하지만 이 스마트 포인터들이 호출하는 delete는 delete []가 아니기 때문에 동적으로 할당한 배열을 넘겨주면 안됩니다.
14. 자원 관리 클래스의 복사 동작에 대해 진지하게 고찰하자
RAII를 통해 동적 생성, 즉 힙 자원에 관리에 대해 다뤄보았습니다. 하지만 힙 자원이 아닌 경우에는 스마트 포인터로 해결할 수 없습니다.
스레드들의 임계 구역을 정해주는 mutex가 있습니다. 이 mutex를 좀 더 손쉽게 사용하기 위해 RAII 모양새로 클래스를 하나 설계하려고 합니다. 생성 시에 전달받은 mutex의 lock을 걸고 소멸 시에 unlock을 하는 것입니다.
#include <iostream>
#include <mutex>
#include <memory>
using namespace std;
void lock(mutex* p) { p->lock(); }
void unlock(mutex* p) { p->unlock(); }
class MutexLocker {
public:
explicit MutexLocker(mutex* pm) :
mutexPtr(pm) {
lock(mutexPtr);
}
~MutexLocker() {
unlock(mutexPtr);
}
private:
mutex* mutexPtr;
};
mutex m;
{
...;
MutexLocker locker(&m);
...;
}
그런데 만약 우리가 만든 자원 관리 객체를 복사하려고 한다면 여러 방향으로 처리할 수 있습니다.
먼저 복사 자체를 금지하는 경우입니다. 예전에 했던 복사 생성자, 복사 대입 연산자를 막기 위한 여러 방법을 시도합니다.
다음으론 클래스 내부에서 mutex를 관리할 shared_ptr을 사용하는 것입니다. 이 shared_ptr은 삭제 자라는 것을 넘겨줄 수 있는데 소멸될 시 호출할 함수 포인터를 넘겨 삭제 시 호출할 함수를 지정합니다.
#include <iostream>
#include <mutex>
#include <memory>
using namespace std;
void lock(mutex* p) { p->lock(); }
void unlock(mutex* p) { p->unlock(); }
class MutexLocker {
public:
explicit MutexLocker(mutex* pm) :
mutexPtr(pm, unlock) {
lock(mutexPtr.get());
}
private:
shared_ptr<mutex> mutexPtr;
};
마지막 방법은 auto_ptr처럼 하나의 객체에서만 관리할 수 있게 완전히 넘겨준 후 자신은 NULL으로 만드는 것입니다.
15. 자원 관리 클래스에서 관리되는 자원은 외부에서 접근할 수 있도록 하자
위에서 좋은 자원 관리 객체들을 많이 봤습니다. 하지만 관리하고 있는 자원을 직접 접근해야 하는 경우는 언제든 생길 수 있습니다.
#include <iostream>
#include <memory>
class Base { };
Base* CreateBase() {
Base* b = new Base;
return b;
}
int main() {
std::auto_ptr<Base> pBase(CreateBase());
// auto_ptr의 소멸자를 통해 동적 할당된 객체는 자동으로 해제시켜줍니다.
}
13장에서 아래의 예제처럼 팩토리 함수를 통해 객체를 생성해서 넘겨주는 예제가 있었습니다. 만약 스마트 포인터가 관리하는 자원을 매개변수로 넘기는 함수가 있다면 관리하고 있는 자원의 포인터를 넘겨줄 방법이 필요합니다.
auto_ptr, shared_ptr에서는 get 함수를 통해 실제 포인터를 넘길 수 있습니다.
#include <iostream>
#include <memory>
class Base { };
Base* CreateBase() {
Base* b = new Base;
return b;
}
int GetBaseInfo(Base* b) {
...;
}
int main() {
std::auto_ptr<Base> pBase(CreateBase());
GetBaseInfo(pBase.get());
}
혹은 클래스가 갖고 있는 멤버 변수를 오퍼레이터를 통해 접근 가능합니다.
#include <iostream>
#include <memory>
class Base {
public:
void print() { std::cout << "Base\n"; }
};
Base* CreateBase() {
Base* b = new Base;
return b;
}
int main() {
std::auto_ptr<Base> pBase(CreateBase());
pBase->print();
(*pBase).print();
}
마지막으로 암시적 변환 함수를 통해 사용하는 방법입니다. 폰트를 관리하는 Font 클래스와 실제 자원을 나타내는 FontHandle 클래스를 갖고 있습니다.
#include <iostream>
using namespace std;
void releaseFont(FontHandle fh);
class Font {
public:
explicit Font(FontHandle fh) : f(fh) {}
~Font() { releaseFont(f); }
private:
FontHandle f;
};
사용자가 FontHandle 자원을 접근하기 위해서는 클래스에서 Get 함수를 구현해줘야 하고 사용자는 그 함수를 통해서 접근해야 합니다.
class Font {
public:
FontHandle get() const { return f; }
private:
FontHandle f;
};
하지만 접근시 마다 get을 호출하는 것은 사용자에 따라 귀찮고 번거로울 수 있습니다. 이럴 경우 암시적 변환 함수를 제공하여 접근성을 더 높일 수 있습니다.
#include <iostream>
using namespace std;
class FontHandle {};
class Font {
public:
// FontHandle get() const { return f; }
operator FontHandle() const { return f; }
private:
FontHandle f;
};
void releaseFont(FontHandle fh);
int main()
{
Font f;
FontHandle f2;
f2 = f;
releaseFont(f);
}
16. new 및 delete를 사용할 때는 형태를 반드시 맞추자
이 항목은 아래 예제로 모든 것이 설명이 가능합니다. []로 new 시에는 무조건 []로 delete를 해야 합니다.
#include <iostream>
using namespace std;
int main()
{
int* p = new int;
int* pA = new int[100];
delete p;
delete[] pA;
}
new는 결국 메모리 할당, delete는 메모리 해제하는 것입니다. 단일로 객체를 동적 생성하는 것과는 다르게 배열로 동적 생성하게 되면 할당됐을 때 앞에 배열의 크기(개수) 정보를 통해 생성된 객체들을 소멸시키게 됩니다.
17. new로 생성한 객체를 스마트 포인터에 저장하는 코드는 별도의 한 문장으로 만들자
어떤 함수를 통해 Base 객체를 처리하는 작업을 하고 싶은데 동적 할당된 Base 객체를 스마트 포인터로 관리하기 위해 아래와 같이 작성했습니다.
#include <iostream>
#include <memory>
using namespace std;
class Base{ };
int RandomNum() { return 1; }
void ProcessBase(std::shared_ptr<Base> pB, int nNum);
int main()
{
ProcessBase(new Base, RandomNum());
}
하지만 shared_ptr의 생성자는 explicit으로 선언되어 있어 new Base를 통해 만들어진 포인터가 shared_ptr로 바꾸는 암시적 변환이 불가능합니다.
그렇기 때문에 아래처럼 전달 자체를 shared_ptr로 생성 후 넘겨줘야 합니다.
#include <iostream>
#include <memory>
using namespace std;
class Base{ };
int RandomNum() { return 1; }
void ProcessBase(std::shared_ptr<Base> pB, int nNum);
int main()
{
ProcessBase(std::shared_ptr<Base>(new Base), RandomNum());
}
이 ProecessBase 함수에서는 총 3가지 동작을 수행합니다.
1. new Base
2. shared_ptr 생성자 호출
3. RandomNum() 호출
위 순서는 컴파일러마다 다르게 호출하게 되는데 만약 아래의 순서에서 문제가 발생했을 때 메모리가 누수될 가능성이 있습니다.
1. new Base
2. RandomNum() 호출
3. shared_ptr 생성자 호출
RandomNum() 호출에서 예외가 발생하는 경우 동적 생성된 Base는 제대로 관리할 수 없습니다. 이러한 문제를 최대한 방지하기 위해 new로 생성한 객체를 담는 스마트 포인터를 생성하는 코드는 따로 사용합니다.
#include <iostream>
#include <memory>
using namespace std;
class Base{ };
int RandomNum() { return 1; }
void ProcessBase(std::shared_ptr<Base> pB, int nNum);
int main()
{
std::shared_ptr<Base> pSB(new Base);
ProcessBase(pSB, RandomNum());
}
'Programming > C++' 카테고리의 다른 글
[C++] 람다 함수 (0) | 2023.04.07 |
---|---|
[Effective C++] 4. 설계 및 선언 (0) | 2022.11.14 |
[Effective C++] 2. 생성자, 소멸자 및 대입 연산자 (4) | 2022.10.31 |
[Effective C++] 1. C++에 왔으면 C++의 법을 따릅시다. (0) | 2022.10.24 |
C++ 방문자 패턴 (0) | 2021.06.20 |