- set_new_handler 함수를 쓰면 메모리 할당 요청이 만족되지 못했을 때 호출되는 함수를 지정할 수 있다.
- 예외불가 new는 영향력이 제한되어 있다. 메모리 할당 자체에만 적용되기 때문이다. 이후에 호출되는 생성자에서는 얼마든지 예외를 던질 수 있다.
new 처리자의 동작 원리
할당할 메모리가 없을 때 operator new 함수는 예외를 던지게 되어 있다.
메모리 할당이 제대로 되지 못한 상황에 대한 반응으로 operator new가 예외를 던지기 전에, 사용자 쪽에서 지정할 수 있는 에러 처리 함수를 우선적으로 호출하도록 되어 있는데, 이 에러 처리 함술 가리켜 new 처리자(new_handler)라고 한다.
이와 같은 메모리 고갈 상황을 처리할 함수를 사용자 쪽에서 지정할 수 있도록, 표준 라이브러리에는 set_new_handler라는 함수가 준비되어 있다. 이 함수는 <new>에 선언되어 있다.
namespace std {
typedef void (*new_handler) ();
new_handler set_new_handler(new_handler p) throw();
}
new_handler는 받는 것도 없고 반환하는 것도 없는 함수의 포인터에 대해 typedef를 걸어 놓은 타입동의어이다.
즉, set_new_handler는 new_handler를 받고 new_handler를 반환하는 함수이다.
throw()는 예외 지정이라고 불리며 이 함수는 어떤 예외도 던지지 않을 것이라는 뜻이다.
set_new_handler가 받아들이는 new_handler 타입의 매개변수는 요구된 메모리를 operator new가 할당하지 못했을 때 operator new가 호출할 함수의 포인터이다.
void outOfMem()
{
std::cerr << "Unable to satisfy request for memory\n";
std::abort();
}
int main()
{
std::set_new_handler(outOfMem);
int* pBigDataArray = new int[100000000L];
...
}
만약 operator new가 1억 개의 정수 할당에 실패하면 outOfMem 함수가 호출될 것이고, 이 함수는 에러 메시지를 출력하면서 프로그램을 강제로 끝내 버릴 것이다.
그런데 cerr이 에러 메시지를 쓰는 과정에서 메모리가 동적으로 할당되어야 한다면 문제가 생긴다.
사용자가 요청한 만큼의 메모리를 할당해 주지 못하면, operator new는 충분한 메모리를 찾아낼 때까지 new 처리자를 되풀이해서 호출한다.
이렇게 new 처리자 함수가 프로그램의 동작에 영향을 미치는 쪽으로 설계되어 있다면 다음 동작 중 하나를 꼭 해주어야 한다.
- 사용할 수 있는 메모리를 더 많이 확보한다.
operator new가 시도하는 이후의 메모리 확보가 성공할 수 있도록 하는 전략이다. 구현 방법은 여러 가지가 있지만, 프로그램이 시작할 때 메모리 블록을 크게 할당해 놓았다가 new 처리자가 가장 처음 호출될 때 그 메모리를 쓸 수 있도록 허용하는 방법이 그중 한 가지이다.
- 다른 new 처리자를 설치한다.
현재의 new 처리자가 더 이상 가용 메모리를 확보할 수 없다 해도, 이 경우에 자기 몫까지 해 줄 다른 new 처리자의 존재를 알고 있을 가능성도 있다. 그렇다면 현재의 new 처리자는 다른 new 처리자를 설치할 수 있다.
현재의 new 처리자 안에서 set_new_handler를 호출하면 된다.
operator new 함수가 다시 new 처리자를 호출할 때가 되면, 새로 설치된 new 처리자가 호출되는 것이다.
new 처리자가 자기 자신의 동작 원리를 변경하도록 만들 수도 있다.
다음에 이 함수가 호출될 때는 지금과 다른 방식으로 동작하게 되는 것이다.
이렇게 만드는 한 가지 방법은 new 처리자의 동작을 조정한느 데이터를 정적 데이터 혹은 네임스페이스 유효범위 안의 데이터, 전역 데이터로 마련해 둔 후 new 처리자가 이 데이터를 수정하게 하는 것이다.
- new 처리자의 설치를 제거한다.
set_new_handler에 널 포인터를 넘긴다. new 처리자가 설치된 것이 없으면, operator new는 메모리 할당이 실패했을 때 예외를 던진다.
- 예외를 던진다.
bad_alloc 혹은 파생된 타입의 예외를 던진다. operator new에는 이런 종류의 에러를 받아서 처리하는 부분이 없기 때문에, 이 예외는 메모리 할당을 요청한 원래의 위치로 전파된다.
- 복귀하지 않는다.
abort 혹은 exit을 호출한다.
new 처리자 활용
할당된 객체의 클래스 타입에 따라서 메모리 할당 실패에 대한 처리를 다르게 가져가고 싶은 경우가 있다.
class X {
public:
static void outOfMemory();
...
};
class Y {
public:
static void outOfMemory();
...
};
X* p1 = new X; // 메모리 할당 실패 -> X::outOfMemory
Y* p2 = new Y; // 메모리 할당 실패 -> Y::outOfMemory
C++에는 특정 클래스만을 위한 할당 에러 처리자를 둘 수 있는 기능은 없다.
하지만, 직접 구현할 수 있다.
해당 클래스에서 자체 버전의 set_new_handler와 operator new를 제공하도록 만들어 주면 된다.
클래스에서 제공하는 set_new_handler 함수의 역할은 사용자로부터 그 클래스에 쓰기 위한 new 처리자를 받아내는 것이다.
클래스에서 제공하는 operator new 함수는 그 클래스 객체를 담을 메모리가 할당되려고 할 때 전역 new 처리자 대신 클래 버전의 new 처리자가 호출되도록 만드는 역할을 맡는다.
예를 들어, Widget 클래스에 대한 메모리 할당 실패를 직접 처리하고 싶다고 가정해 보자.
Widget 객체를 담을 만큼의 메모리를 operator new가 할당하지 못할 경우에 호출될 new 처리자 함수가 있어야 하므로, 이 new 처리자를 가리키는 new_handler 타입의 정적 멤버 데이터를 선언한다.
class Widget {
public:
static std::new_handler set_new_handler(std::new_handler p) throw();
static void* operator new(std::size_t size) throw(std::bad_alloc);
private:
static std::new_handler currentHandler;
};
정적 클래스 멤버의 정의는 클래스의 바깥쪽에 있어야 하므로, 다음과 같이 하면 된다.
std::new_handler Widget::currentHandler = 0; // null(구현 파일에)
Widget이 제공하는 set_new_handler 함수는 자신에게 넘어온 포인터를 저장해 놓고, 반환하는 역할을 한다.
표준 라이브러리의 set_new_handler도 마찬가지이다.
std::new_handler Widget::set_new_handler(std::new_handler p) throw()
{
std::new_handler oldHandler = currentHandler;
currentHandler = p;
return oldHandler;
}
마지막으로 Widget의 operator new가 할 일만 남았다.
- 표준 set_new_handler 함수에 Widget의 new 처리자를 넘겨서 호출한다. 즉, 전역 new 처리자로서 Widget의 new 처리자를 설치한다.
- 전역 operator new 를 호출하여 실제 메모리 할당을 수행한다. 전역 operator new 할당이 실패하면, 이 함수는 Widget의 new 처리자를 호출하게 된다. 마지막까지 전역 operator new의 메모리 할당이 실패하면, 전역 operator new는 bad_alloc 예외를 던진다. 이 경우 Widget의 operator new는 전역 new 처리자를 원래의 것으로 돌려놓고, 예외를 전파해야 한다. 원래의 전역 new 처리자를 실수 없이 돌려놓을 수 있도록, Widget은 전역 new 처리자를 자원으로 간주하고 처리한다.
- 전역 operator new가 Widget 객체 하나만큼의 메모리를 할당할 수 있으면, Widget의 operator new는 이렇게 할당된 메모리를 반환한다. 이와 동시에, 전역 new 처리자를 관리하는 객체의 소멸자가 호출되면서 Widget의 operator new가 호출되기 전에 쓰이고 있던 전역 new 처리자가 자동으로 복원된다.
전역 new 처리자를 자원으로 삼는다고 했으므로, 자원 관리 클래스를 준비해야 한다.
이 클래스는 RAII 연산만을 수행한다.
class NewHandlerHolder {
public:
explicit NewHandlerHolder(std::new_handler nh): handler(nh) {}
~NewHandlerHolder(){ std::set_new_handler(handler); }
private:
std::new_handler handler;
NewHandlerHolder(const NewHandlerHolder&); // 복사 방지
NewHandlerHolder& operator= (const NewHandlerHolder&);
};
Widget의 operator new 는 간단히 구현할 수 있다.
void* Widget::operator new(std::size_t size) throw(std::bad_alloc)
{
NewHandlerHolder h(std::set_new_handler(currentHandler)); // new 처리자 설치
return ::operator new(size); // 메모리 할당 시도, 실패시 예외처리
}
Widget 클래스를 사용하는 쪽에서 new 처리자 기능을 쓰려면 다음과 같이 하면 된다.
void outOfMem(); // 메모리 할당 실패시 호출될 함수
Widget::set_new_handler(outOfMem); // new 처리자 함수 설치
Widet *pw1 = new Widget; // 메모리 할당 실패시 outOfMem
std::string* ps = new std::string; // 메모리 할당 실패시 전역 new 처리자 함수
Widget::set_new_handler(0); // Widget 클래스의 new 처리자 함수 제거
Widget* pw2 = new Widget; // 메모리 할당 실패시 예외를 던짐(new 처리자 함수 없음)
자원 관리 객체를 통한 할당에러 처리를 구현하는 방식의 코드는 어떤 클래스를 쓰더라도 똑같이 나올 것이다.
즉, 재사용할 수 있도록 설계하면 좋다.
그 방법으로는 믹스인(mixin)방식의 기본 클래스가 있다.
즉, 다른 파생 클래스들이 한 가지의 특정 기능만을 물려받을 수 있도록 설계된 기본 클래스를 만들면 된다.
지금 경우의 특정 기능은 클래스별 new 처리자를 설정하는 기능이다.
그다음엔 기본 클래스를 템플릿으로 변경한다.
이렇게 하면 파생 클래스마다 클래스 데이터의 사본이 따로따로 존재하게 된다.
이렇게 설계된 클래스 템플릿으로 얻을 수 있는 효과는 두 가지이다.
기본 클래스 부분은 파생 클래스들이 가져야 하는 set_new_handler 함수와 operator new 함수를 물려준다.
그리고 템플릿 부분은 각 파생 클래스가 currentHandler 데이터 멤버를 따로따로 가질 수 있게 한다.
template<typename T>
class NewHandlerSupport {
public:
static std::new_handler set_new_handler(std::new_handler p) throw();
static void* operator new(std::size_t size) throw(std::bad_alloc);
...
private:
static std::new_handler currentHandler;
};
template<typename T>
std::new_handler NewHandlerSupport<T>::set_new_handler(std::new_handler p) throw()
{
std::new_handler oldHandler = currentHandler;
currentHandler = p;
return oldHandler;
}
template<typename T>
void* NewHandlerSupport<T>::operator new(std::size_t size) throw(std::bad_alloc)
{
NewHandlerHolder h(std::set_new_handler(currentHandler));
return ::operator new(size);
}
이렇게 만들어진 클래스 템플릿이 있으면, Widget 클래스에 set_new_handler 기능을 추가하는 것은 어렵지 않다.
NewHandlerSupport<Widget>으로부터 상속만 받으면 된다.
class Widget: public NewHandlerSupport<Widget>
{
...
};
NewHandlerSupport 템플릿은 타입 매개변수 T를 쓰지 않는다.
실제로 필요한 것은 NewHandlerSupport로부터 파생된 각 클래스에 대한 NewHandlerSupport 객체의 서로 다른 사본 밖에 없다.
이 템플릿의 매개변수인 T는 파생 클래스들을 구분해 주는 역할만 한다.
템플릿 매커니즘 자체는 NewHandlerSupport가 인스턴스화될 때 전달되는 T를 위한 currentHandler의 사본을 만드는 것이다.
매개변수로 Widget을 받아 만들어진 기본 클래스로부터 Widget이 파생된 것은 신기하게 반복되는 템플릿 패턴(Curiously Recurring Template Pattern: CRTP)이라고 한다.
예외불가 new
C++은 메모리 할당 실패 시 null 포인터를 반환하는 방식에서 bad_alloc 예외를 던지는 방식으로 정책이 바뀌었는데, 여러 가지 사정으로 인해 할당 실패 시 널을 반환하는 방식의 대안적인 형태의 operator new도 같이 제공하게 되었다.
이런 형태를 가리켜 예외불가(nothrow)형태라고 한다.
class Widget { ... };
Widget* pw1 = new Widget; // 할당 실패 시 bad_alloc
if(pw1 == 0) ..
Widget* pw2 = new (std::nothrow) Widget; // 할당 실패 시 0(null) 반환
if(pw2 == 0) ...
new(std::nothrow) Widget 표현식에서는 실제로 두 가지 동작이 이루어진다.
우선 operator new 함수의 예외불가 버전이 호출되어 Widget 객체를 담기 위한 메모리 할당을 시도한다.
만약 이 할당이 실패하면 operator new는 널 포인터를 반환한다.
하지만, 할당이 성공할 때 주의해야 할 부분이 있다.
성공 시에는 Widget 생성자가 호출되는데, 이런 후에는 예외불가가 소용이 없다.
Widget 생성자가 만약 내부에서 자체적으로 new를 또 쓸 수도 있다. 이때 이 new는 맨 처음 실행됐던 예외불가 new로부터 전혀 제약을 받지 않는다.
new (std::nothrow) Widget에서 호출된 operator new가 예외를 던지지 않는다 해도 Widget 생성자에서 예외가 빠져나올 수 있다는 뜻이다. 그렇게 되면 예외가 전파된다.
예외 불가 new는 호출되는 operator new에서만 예외가 발생되지 않도록 보장할 뿐, (std::nothrow) Widget 등의 표현식에서 예외가 나오지 않게 막아준다는 이야기가 아니다.
보통의 new를 쓰든, 예외불가 new를 쓰든 상관없이 중요한 것이 하나 있다.
new 처리자의 동작 원리를 제대로 이해해야 한다는 것이다.
new 처리자는 양쪽에서 모두 쓰이기 때문이다.