[Effective C++]new 및 delete를 작성할 때 따라야 할 기존의 관례를 잘 알아 두자

new와 delete를 내 맘대로, 세 번째 이야기

Posted by SungBeom on January 08, 2020 · 12 mins read

관례적인 operator new

기존의 관례에 잘 맞는 operator new를 구현하려면 다음의 요구사항만큼은 기본으로 지켜야 합니다. 일단 반환 값이 제대로 되어 있어야 하고, 가용 메모리가 부족할 경우에는 new 처리자 함수를 호출해야 하며, 크기가 없는(0바이트) 메모리 요청에 대한 대비책을 갖춰두어야 합니다. 끝으로, 실수로 "기본(nomal)" 형태의 new가 가려지지 않도록 하십시오. 사실 이 부분은 구현 요구사항이라기보다는 클래스 인터페이스에 관한 문제이긴 하지만 대단히 중요합니다.

operator new의 반환 값은 요청된 메모리를 마련해 줄 수 있으면 그 메모리에 대한 포인터를 반환하면 됩니다. 메모리를 마련해 줄 수 없는 경우는, bad_alloc 타입의 예외를 던지게 하면 됩니다. 구현이 간단하지는 않은데, 사실 operator new는 메모리 할당이 실패할 때마다 new 처리자 함수를 호출하는 식으로 메모리 할당을 2회 이상 시도하기 때문입니다. 그러니까, 어떻게든 어떤 메모리를 해제하는 데 실마리가 되는 동작을 new 처리자 함수 쪽에서 할 수 있을 것으로 가정하는 거죠. operator new가 예외를 던지게 되는 경우는 오직 new 처리자 함수에 대한 포인터가 널일 때뿐입니다.

그리고 상당히 어색한 요구사항이 있는데, 0바이트가 요구되었을 때조차도 operator new 함수는 적법한 포인터를 반환해야 한다는 것입니다. 어쨌든 지금까지의 요구사항을 모으고 정리해서, 비멤버 버전의 operator new 함수를 의사 코드로 만들어 보면 다음과 같습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void* operator new(std::size_t sizethrow(std::bad_alloc)
{                     // 여러분의 operator new 함수는 다른
                      // 매개변수를 추가로 가질 수 있습니다.
    using namespace std;
 
    if (size == 0) {  // 0 바이트 요청이 들어오면,
        size = 1;     // 이것을 1 바이트 요구로
    }                 // 간주하고 처리합니다.
 
    while (true) {
        size 바이트를 할당해 봅니다;
        if (할당이 성공했음)
            return (할당된 메모리에 대한 포인터);
 
        // 할당이 실패했을 경우, 현재의 new 처리자 함수가
        // 어느 것으로 설정되어 있는지 찾아냅니다.
        new_handler globalHandler = set_new_handler(0);
        set_new_handler(globalHandler);
 
        if (globalHandler) (*globalHandler)();
        else throw std::bad_alloc();
    }
}
cs

외부에서 0바이트를 요구했을 때 1 바이트 요구인 것으로 간주하고 처리하는 수법은 간단하고, 규칙을 어긴 것도 아니며, 제대로 돌아갑니다. 반면에 new 처리자 함수의 포인터를 널로 설정하고 바로 뒤에 원래의 처리자 함수로 되돌려 놓는 코드는 거슬려 보이지만, 현재의 전역 new 처리자 함수를 얻어오는 직접적인 방법은 없습니다. set_new_handler 함수를 호출하고 그 반환 값을 가져오는 방법밖에 없기 때문에, 위와 같이 할 수 밖에 없었던 것입니다. 단일 스레드에서 동작하는 환경이라면 이렇게 해도 되지만, 다중 스레드 환경에서는 new 처리자를 둘러싼 전역 자료구조들이 조작될 때 스레드 안전성이 보장되어야 하기 때문에 스레드 잠금을 걸어야 합니다.

위의 코드를 보시면 무한 루프가 들어 있는데, 이 루프를 빠져나오는 조건은 메모리 할당이 성공하든지 아니면 종료 동작들 중 한 가지를 new 처리자 함수 쪽에서 해 주든지 둘 중 하나입니다. new 처리자 함수는 가용 메모리를 늘려 주든가, 다른 new 처리자를 설치하든가, new 처리자의 설치를 제거하든가, bad_alloc 혹은 bad_alloc에서 파생된 타입의 예외를 던지든가, 아예 함수 복귀를 포기하고 도중 중단을 시켜야 합니다.

사실, operator new 멤버 함수는 파생 클래스 쪽으로 상속이 되는 함수입니다. 위에 나온 operator new 함수의 의사 코드를 보시면, 할당을 시도하는 메모리의 크기가 size 바이트로 되어 있습니다. 그런데 특정 클래스 전용의 할당자를 만들어서 할당 효율을 최적화하기 위해서 사용자 정의 메모리 관리자를 작성할 수 있습니다. 여기서 특정 클래스란 '그' 클래스 하나를 가리킬 뿐, '그 클래스 혹은 그 클래스로부터 파생된 다른 클래스들' 모두를 통칭하는 것은 아닙니다. 그러니까 어떤 X라는 클래스를 위한 operator new 함수가 있다면, 이 함수의 동작은 크기가 sizeof(X)인 객체에 대해 맞추어져 있는 것입니다. 그런데 상속 때문에 파생 클래스 객체를 담을 메모리를 할당하는 데 기본 클래스의 operator new 함수가 호출되는 웃지 못 할 일이 생긴다는 것입니다.

1
2
3
4
5
6
7
8
9
10
class Base {
public:
    static void * operator new(std::size_t sizethrow(std::bad_alloc);
    ...
};
 
class Derived: public Base  // Derived에서는 operator new가
{ ... };                    // 선언되지 않았습니다.
 
Derived *= new Derived;   // Base::operator new가 호출됩니다.
cs

만약 Base 클래스 전용의 operator new가 이런 상황에 대해 어떤 조치를 취하도록 설계되지 않았다면(게다가 그렇게 설계되지 않았을 가능성이 충분히 있다면) 전체 설계를 바꾸지 않고 쓸 수 있는 가장 좋은 해결 방법은 "틀린" 메모리 크기가 들어왔을 때를 시작부분에서 확인한 후에 표준 operator new를 호출하는 쪽으로 살짝 비껴가게 만드는 것입니다.

1
2
3
4
5
6
7
8
void * Base::operator new(std::size_t sizethrow(std::bad_alloc)
{
    if (size != sizeof(Base))         // "틀린" 크기가 들어오면,
        return ::operator new(size);  // 표준 operator new 쪽에서 메모리
                                      // 할당 요구를 처리하도록 넘깁니다.
    ...                               // 맞는 크기가 들어오면 메모리 할당
                                      // 요구를 여기서 처리합니다.
}
cs

위의 코드에서 0바이트 점검 코드가 없어 보이지만, 단지 sizeof(Basse)와 size를 비교하는 코드에 합쳐져 있을 뿐입니다. 여기서 C++에는 모든 독립 구조(freestanding)의 객체는 반드시 크기가 0이 넘어야 한다는 요상한 금기사항 같은 것이 있습니다. 이런 덕택에 sizeof(Base)기 0이 될 일은 없습니다. 따라서 size가 0이면 if 문이 거짓이 되어 메모리 처리 요구가 ::operator new 쪽으로 넘어가는 것입니다.

만약에 배열에 대한 메모리 할당을 클래스 전용 방식으로 하고 싶다면, operator new[] 함수를 구현하면 됩니다. operator new[] 안에서 해 줄 일은 단순히 원시 메모리의 덩어리를 할당하는 것밖엔 없다는 것입니다. 이 시점에서는 배열 메모리에 아직 생기지도 않은 클래스 객체에 대해서 아무것도 할 수 없습니다.

사실 배열 안에 몇 개의 객체가 들어갈지 계산하는 것조차도 안 됩니다. 첫째, 객체 하나가 얼마나 큰지를 확정할 방법이 없습니다. 이 말을 풀이해 보면, Base::operator new[]에서 할당한 배열 메모리에 들어가는 객체의 개수를 (요구된 바이트 수/sizeof(Base))로 계산할 수 없다는 뜻이 나오는 것이죠. 둘째, operator new[]에 넘어가는 size_t 타입의 인자들은 담기에 딱 맞는 메모리 양보다 더 많이 설정되어 있을 수도 있습니다. 동적으로 할당된 배열에는 배열 원소의 개수를 담기 위한 자투리 공간이 추가로 들어갑니다.

관례적인 operator delete

operator delete를 작성할 때의 관례는 operator new의 경우보다 더 간단합니다. C++은 널 포인터에 대한 delete 적용이 항상 안전하도록 보장한다는 사실만 잊지 않으면 됩니다. 여러분이 할 일은 이 보장을 유지하는 것뿐입니다. 간단히 작성해 본 비멤버 버전 operator delete의 의사 코드입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Base {
public:
    static void * operator new(std::size_t sizethrow(std::bad_alloc);
    static void operator delete(void *rawMemory, std::size_t sizethrow();
    ...
};
 
void Base::operator delete(void *rawMemory, std::size_t sizethrow()
{
    if (rawMemory == 0return;        // 널 포인터에 대한 점검
 
    if (size != sizeof(Base)) {        // 크기가 "틀린" 경우,
        ::operator delete(rawMemory);  // 표준 operator delete가
        return;                        // 메모리 삭제 요청을 맡도록 합니다.
    }
 
    rawMemory가 가리키는 메모리를 해제합니다.
 
    return;
}
cs

삭제될 메모리의 크기를 점검하는 코드를 넣어 주어야 한다는 점만 빼면 단순합니다. 클래스 전용의 operator new가 "틀린" 크기의 메모리 요청을 ::operator new 쪽으로 넘기도록 구현되었다고 가정하면, 클래스 전용의 operator delete 역시 "틀린 크기로 할당된" 메모리의 삭제 요청을 ::operator delete 쪽으로 전달하는 식으로 구현하면 되겠네요.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Base {                           // 이전과 같으나, 지금은
public:                                // operator delete가 선언된 
    static void * operator new(std::size_t sizethrow(std::bad_alloc);
    static void operator delete(void *rawMemory, std::size_t sizethrow();
    ...
};
 
void Base::operator delete(void *rawMemory, std::size_t sizethrow()
{
    if (rawMemory == 0return;        // 널 포인터에 대한 점검
 
    if (size != sizeof(Base)) {        // 크기가 "틀린" 경우,
        ::operator delete(rawMemory);  // 표준 operator delete가
        return;                        // 메모리 삭제 요청을 맡도록 합니다.
    }
 
    rawMemory가 가리키는 메모리를 해제합니다.
 
    return;
}
cs

가상 소멸자가 없는 기본 클래스로부터 파생된 클래스의 객체를 삭제하려고 할 경우에는 operator delete로 C++이 넘기는 size_t 값이 엉터리일 수 있습니다. 어쨌든, 기본 클래스에서 가상 소멸자를 빼먹으면 operator delete 함수가 동작하지 않을 수 있습니다.


정리

관례적으로, operator new 함수는 메모리 할당을 반복해서 시도하는 무한 루프를 가져야 하고, 메모리 할당 요구를 만족시킬 수 없을 때 new 처리자를 호출해야 하며, 0바이트에 대한 대책도 있어야 합니다. 클래스 전용 버전은 자신이 할당하기로 예정된 크기보다 더 큰(틀린) 메모리 블록에 대한 요구도 처리해야 합니다.
operator delete 함수는 널 포인터가 들어왔을 때 아무 일도 하지 않아야 합니다. 클래스 전용 버전의 경우에는 예정 크기보다 더 큰 블록을 처리해야 합니다.