본문 바로가기

책 스터디

[Exceptional C++ Style] 22. new와 예외, 1부 : 여러 종류의 new

이번 챕터에서는 new 함수의 여러 종류에 대해 알아보고, 
커스텀 할당자(operator new를 재정의하는)를 제작할 때 주의해야 할 점에 대해 기술하고 있다.
아울러, new 함수 중 예외를 던지지 않는 new 함수에 대한 경고도 함께 이야기하고 있다.


신참 질문

C++ 표준에 있는 new 함수는 어떠한 종류들이 있는가?

C++ 표준은 다음과 같은 new 함수들을 제공하며, 추가적으로 그것들을 얼마든지 overload 할 수 있도록 허용한다.
// 표준이 제공하는 operator new의 overloaded functions
// 이외 배열에 해당하는 operator new [] 도 있지만, 규칙은 동일하므로 예시에선 제외...

// 1. 보통의 평범한 new : 용법 new T
void* ::operator new(std::size_t size) throw(std::bad_alloc);

// 2. 예외를 던지지 않는 new : 용법 new (std::nothrow) T
void* ::operator new(std::size_t size, const std::nothrow_t&) throw();

// 3. placement new(위치 지정식 new) : 용법 new (ptr) T
// 1,2 번 방식과 다르게 실제 할당이 일어나지 않기에 inline 키워드가 붙어 있다.
// 이는 operator new[]에도 대응되며, placement delete에도 똑같이 inline이 붙는다.
inline void* ::operator new(std::size_t size, void* ptr) throw();

// placement new 예제
// T를 포함할 수 있는 메모리 공간을 미리 할당해놓고
void *p = ::operator new(sizeof(T));
// 그 메모리 주소에서 T* t를 생성한다.
T* t = new (p) T;
위 세 가지 함수들 중 마지막 placement new를 제외하고는 프로그래머가 자신만의 버전으로 교체할 수 있다.
이 표준 new 들은 모두 namespace std 가 아닌 전역 범위에 있음을 주의하여야 한다.

세 가지 함수의 주요 특징을 아래 표를 이용해 간략하게 정리해 보았다.
(아래의 모든 특징은 "전역 범위"의 함수들에 대한 것이다)
   추가 매개변수  할당 여부   실패 가능   예외 던짐  교체 가능 (전역)
 new  -  O  O (예외던짐)  std::bad_alloc  O
 nothrow new  std::nothrow_t  O  O (NULL 반환)  X  O
 placement new  void*  X  X  X  X

참고로, 우리가 new 함수를 호출하면, 아래의 순서를 통해 객체가 생성된다.

1. operator new가 호출되어 메모리를 할당하고,
2. 해당 객체의 생성자를 호출하여 생성을 완료함.

따라서, 커스텀 할당자를 만드려면 new 함수가 아닌 operator new를 수정하여야 하며,
그것들의 종류가 위에 나와 있는 예제인 것이다.

그리고, 위에서 new 함수들에 대해 자유롭게 overload 함수를 작성할 수 있다고 하였다.
즉, 아래와 같은 overload 함수를 제공할 수 있다 뜻이다.
// 사용자가 추가한 overload 버전
void* ::operator new(std::size_t size, int, double, const char*);

// 예제
T* t = new (42, 3.14159, "xyzzy") T;
 
고수 질문

클래스에 고유한 new는 무엇이며, 어떻게 사용하는가? 
그리고 클래스 고유의 new와 delete 버전을 만들 때 특별히 신경써야 하는 부분들을 서술하라.


C++은 전역 operator new를 프로그래머가 다른 것으로 교체할 수 있도록 허용할 뿐 아니라, 
클래스들 역시 자신만의 고유한 new를 가질 수 있게 한다. 

위 신참 질문 파트에서 나온 이야기들은 모두 전역 범위에 있는 new 함수들에 관한 것이고,
지금부터는 클래스에 고유한 new 함수들에 대해 알아보자.

우선, 전역 vs 클래스 new의 차이점부터 살펴보면 다음과 같은 차이점들이 있다.

1. 전역 placement new를 직접 교체하는 것은 불가능하지만, 클래스에 대해서는 placement new를 고유의 것으로 교체가 가능하다.

2. 전역 nothrow new를 교체하든 그렇지 않든, 클래스에 고유한 nothrow new로 교체할 수 있다.

다음은 클래스에 고유한 new를 사용하는 간단한 예제이다.
class X
{
public:
    // 1. 보통의 new
    static void* operator new(std::size_t) throw(std::bad_alloc);
    // 2. nothrow new
    static void* operator new(std::size_t, const std::nothrow_t&) throw();
    // 3. placement new (클래스에 고유한 placement new를 작성 가능함)
    static inline void* operator new(std::size_t, void* ptr) throw();
};

X* p1 = new X;    // 1. 보통의 new를 호출

X* p2 = new (std::nothrow) X;    // 2. nothrow new 호출

void* ptr = malloc(sizeof(X));
X* p3 = new (ptr) X;    // 3. placement new 호출

하지만, 클래스 고유의 new 함수들을 사용할 때 주의할 점이 하나 있는데, 바로 "이름이 가려지는 문제" 이다.
커스텀 할당자를 구현할 때 반드시 명심해야 할 점이기도 하다.

다음 예제를 통해 실제로 이름이 왜 어떻게 가려지는지 살펴보자.
class Base
{
public:
    static inline void* operator new(std::size_t, const FastMemory&);
};

class Derived : public Base
{
    // ...
};

Derived* p1 = new Derived;    // 1

Derived* p2 = new (std::nothrow) Derived;    // 2

void* ptr = malloc(sizeof(Derived));
Derived* p3 = new (ptr) Derived;    // 3

FastMemory fm;
Derived* p4 = new (fm) Derived;    // 4
 
다른 맥락에서의 이름 가려짐 문제들(파생 클래스의 이름이 기반 클래스의 같은 이름을 가리는 등의)에 대해서는 대부분 익숙하다. 그러한 이름 가려짐 문제가 operator new에서도 발생할 수 있다.

이를 제대로 이해하려면, 역시나 C++ 컴파일러의 이름을 조회하는 우선 순위 규칙을 제대로 이해하고 있어야 한다.

1. Name Lookup(이름 조회)

가장 먼저, 컴파일러는 가까운 범위에서부터 거슬러 올라가면서 operator new를 이름을 찾는다.
이름이 하나라도 있는 범위를 만나면 거기서 조회가 끝나며, 거기에 있는 이름들이 후보가 된다.
여기에서는 Derived부터 찾고 없으면 Base로 간 다음, 거기서도 없으면 전역 범위에서 찾는다.

2. 오버로드 해소

그런 다음에는 후보들 중 주어진 호출과 가장 일치하는 하나를 고르는 오버로드 해소를 수행한다.

위 예제의 경우 부모 클래스에 FastMemory에 대한 위치지정 new를 제외하고는 함수가 없으므로,
4번을 제외한 나머지 녀석들은 오버로드 해소 과정에서 탐색이 실패하게 된다.

3. 접근성 점검

마지막으로, 컴파일러는 선택된 함수에 대해 접근성 점검을 수행한다.

그리고, 위 세가지 규칙들은 규칙 중 하나가 충족되면 그 규칙내에서 성공/실패를 구분해 버리지,
해당 규칙 내에서 실패했다고 아래 순위의 규칙으로 찾아 내려가지는 않는다. 

위의 예제에서는 Base 범위에서 이름을 찾았다면 거기에서 이름 조회 과정은 멈춘다.
즉, 그 바깥의 범위에 대해서는 전혀 신경쓰지 않고 다음 단계인 오버로드 해소 과정으로 넘어가는 것이다.

역시 같은 원리로, 오버로드 해소 과정에서 실패했다면, 밖의 범위에 아무리 정확한 버전의 함수가 있다고 해도,
그 범위로 조회가 이루어지지 않아, 결국 "이름이 가려지는 문제"가 발생하는 것이다.


이제 이를 제대로 이해했다면 위의 예제가 아래와 같이 실패하는 것을 알 수 있다.
class Base
{
public:
    static inline void* operator new(std::size_t, const FastMemory&);
};

class Derived : public Base
{
    // ...
};

// 아래 모든 호출들에 대해 Name Lookup은 Base 클래스에서 멈추었다.
// 이제 오버로드 해소 과정을 거치는데...

// 1 : operator new(std::size_t) 찾을 수 없어서 에러!!!
Derived* p1 = new Derived;

// 2 : operator new(std::size_t, const std::nothrow_t&) 찾을 수 없어서 에러!!!
Derived* p2 = new (std::nothrow) Derived;

// 3 : opeator new(std::size_t size, void* ptr) 찾을 수 없어서 에러!!!
void* ptr = malloc(sizeof(Derived));
Derived* p3 = new (ptr) Derived;

// 4 : operator new(std::size_t size, const FastMemory&)가 있어서 성공!!!
FastMemory fm;
Derived* p4 = new (fm) Derived;
 
이러한 이름 조회 방식 때문에, 클래스가 고유한 operator new를 가지고 있는 경우, 그 new는 모든 전역 new들을 가리게 된다. 
따라서, 클래스에 고유한 new를 작성하고 싶다면 전역에 존재하는 모든 형태의 new에 대해 클래스 고유의 것이 요구된다. 

세 가지 타입 중 클래스 고유의 것을 하나만 작성하고 싶은 경우라도, 다음과 같이 전달 함수라도 만들어 줘야 한다.
// 보통의 new 함수만 수행 내용을 바꾸고 싶은 경우
class X
{
public:
    // 보통의 new 함수를 class 고유의 것으로 교체함.
    static void* operator new(std::size_t)
    {
        // blah~ blah~ blah~
    }

    // 아래 두 함수는 내용을 바꾸지 않더라도 반드시 추가해 주어야 한다.
    // 그럴 땐 전역 new가 수행되도록 전달 함수 형식으로 작성하는 것이 좋다.

    static void* operator new(std::size_t size, std::nothrow_t& n) throw()
    {
        return operator new(size, n);
    }

    static inline void* operator new(size_t size, void* ptr) throw()
    {
        return operator new(size, ptr);
    }
}
 
위의 예제에서 한가지 특이한 점은 보통의 new 함수에 예외 명세가 붙어 있지 않다는 것이다.
C++ 표준에 명시된 보통의 new 함수의 시그너쳐와 예외 명세가 다른 것이다.
// C++ 표준의 보통 new
void* ::operator new(std::size_t size) throw(std::bad_alloc);

// class X의 보통 new
static void* operator new(std::size_t);

 
예외 명세는 사실 쓰고 싶지 않다.
throw(std::bad_alloc)으로 한다고 해도, 반드시 bad_alloc만 던져지는 것은 아니다.
쓰잘데기 없이 try, catch 블럭만 추가되고...

게다가 위에서 클래스 고유의 new 함수를 추가하는 것은 가상 함수도 아니며, typedef도 아니다.
즉, 대체를 하되 예외 명세를 다르게 지정해도 문법적으로는 문제가 없는 것이다.

따라서, 개인적으로 예외를 던질 거면 차라리 다 던지도록 예외 명세를 하지 않는 것이 차라리 낫다고 생각한다.

이제 길었던 고수 질문 파트를 다음과 같이 요약해 보자.
클래스에 고유한 new 함수를 한 형식이라도 쓰고 싶다면, 클래스에 전역의 모든 형식을 제공해야 한다.
 

이 챕터에서 중요한 부분 하나가 누락되었길래 추가로 작성한다.

바로 operator new에 대응하는 operator delete를 모두 구비하라는 것이다.
즉, 기본형 new, 위치지정 new, 예외 불가 new에 대해 모두 대응되는 operator delete 역시 제공해야 한다.
 
class X
{
public:
    // 기본형 new/delete
    static void* operator new(std::size_t size)
    {
        return ::opeator new(size);
    }
    static void operator delete(void* p)
    {
        return ::operator delete(p);
    }

    // 위치지정 new/delete
    static inline void* operator new(std::size_t size, void* ptr) throw()
    {
        return ::operator new(size, ptr);
    }
    static inline void operator delete(void* p, void* ptr) throw()
    {
        return ::operator delete(p, ptr);
    }

    // 예외불가 new/delete
    static void* operator new(std::size_t size, const std::nothrow& nt) throw()
    {
        return ::operator new(size, nt);
    }
    static void operator delete(void* p, const std::nothrow& nt) throw()
    {
        return ::operator delete(p);
    }
};
 
이제 책에서 정리한 고수 질문의 핵심을 다시 한번 정리해 보자.
 
클래스에 고유한 new/delete 함수를 한 형식이라도 쓰고 싶다면, 전역의 모든 형식을 new/delete 짝을 맞추어 제공해야 한다.