본문 바로가기

study/C++

[C++][Effective C++] 49~52. new와 delete를 내 맘대로

728x90

[Effective C++(3판)]에 대한 내용을 공부하면서 정리한 내용이다.

 

이번에 요약하는 내용은 49~52 항목으로 아래와 같다.

  • 49. new 처리자의 동작 원리를 제대로 이해하자
  • 50. new 및 delete를 언제 바꿔야 좋은 소리를 들을지를 파악해두자
  • 51. new 및 delete를 작성할 대 따라야 할 기존의 관례를 잘 알아 두자
  • 52. 위치 지정 new를 작성한다면 위치 지정 delete도 같이 준비하자

요약

49. new 처리자의 동작 원리를 제대로 이해하자

사용자가 보낸 메모리 할당 요청을 operator new 함수가 만족하지 못할 경우에는 예외를 던지고 있다. 예전에는 malloc을 사용한 것과 같이 NULL 포인터를 반환했다.

메모리 할당에 실패한 경우 예외를 던지기 전에 사용자가 지정한 에러 처리 함수를 우선적으로 호출하도록 되어있다. 예외 던지기 전에 사용자가 지정하는 예외 처리 함수new 처리자(new-handler, 할당 에러 처리자)라고 부른다. 표준 라이브러리에서 set_new_handler라는 함수를 통해 적용 가능하다. set_new_handler는 보다시피 typedef를 걸어놓은 함수 포인터로, operator new가 호출할 함수 포인터다.

namespace std {
	typedef void (*new_handler)();

	new_handler set_new_handler(new_handler p) throw(); // 예외를 던지지 않음(예외 지정 없음)
}

 

new 처리자가 프로그램에 좋은 영향을 주도록 설계되어 있다면, 다음 동작 중 하나를 꼭 해주어야 한다.

  • 사용할 수 있는 메모리를 더 많이 확보한다.
    • opeartor new 이후 메모리 확보를 성공할 수 있도록 하는 전략이다.
    • 방법 중 하나로, 프로그램 실행 시 메모리 블록을 크게 할당해 두었다가 new 처리자 호출 시 해당 메모리를 사용한다.
  • 다른 new 처리자를 설치한다.
    • 현재 new 처리자가 더 이상 가용 메모리를 확보할 수 없다면, 자기 몫을 수행해줄 다를 new 처리자를 설치한다.
    • operator new 함수가 다시 새로 지정된 new 처리자를 호출하게 되는 구조다.
  • new 처리자의 설치를 제거한다.
    • set_new_handler에 NULL 포인터를 넘겨, operator new 함수에서 메모리 할당이 실패한 것에 대한 예외를 던지도록 한다.
  • 예외를 던진다.
    • bad_alloc 또는 bad_alloc에서 파생된 예외를 던진다.
    • operator new는 bad_alloc 에러에 대해 처리하는 부분이 없기 때문에, 전파된다.
  • 복귀하지 않는다.
    • abort 또는 exit을 통해 프로그램을 종료한다.

 

클래스별로 new 처리자를 지정해주고 싶다면, 정적 멤버 함수로 직접 구현해주면 된다. 클래스에서 제공하는 oeprator new 함수는, 전역 new 처리자 대신 클래스 버전의 new 처리자를 먼저 호출하게 된다.

class Widget {
public:
	static void *operator new(std::size_t size) throw(std::bad_alloc);

	static std::new_handler set_new_handler(std::new_handler p) throw();

private:
	static std::new_handler currHandler;
};

std::new_handler Widget::currHandler = 0;

 

위의 내용을 확장해보면 아래와 같다. 다른 것은 주석을 통해 보면 쉽고, operator new 부분만 살펴보겠다.

class NewHandlerHolder {
public:
	// 현재의 new 처리자 저장
	explicit NewHandlerHolder(std::new_handler nh) : m_handler(nh) {}

	// 소멸시 기존의 new 처리자를 등록
	~NewHandlerHolder() {
		std::set_new_handler(m_handler);
	}

private:
	// 복사방지
	NewHandlerHolder(const NewHandlerHolder &other);
	NewHandlerHolder & operator = (const NewHandlerHolder & other);

private:
	// new 처리자 저장소
	std::new_handler m_handler;
};

class Widget {
public:
	static void *operator new(std::size_t size) throw(std::bad_alloc) {
		// 현재의 new 처리자를 등록하며, 코드가 종료될 때 자동으로 이전의 전역 new 처리자가 복원
		NewHandlerHolder h(std::set_new_handler(currHandler));

		// operator new를 재호출하면서 메모리 할당에 실패하면 예외를 던진다.
		return ::operator new(size);
	}

	static std::new_handler set_new_handler(std::new_handler p) throw() {
		std::new_handler oldHandler = currHandler;
		currHandler = p;
		return oldHandler;
	}

private:
	static std::new_handler currHandler;
};

std::new_handler Widget::currHandler = 0;

 

위의 코드에서 operator new는 아래와 같이 일을 한다.

  • 표준 set_new_handler 함수에 Widget 클래스의 new 처리자를 넘겨서 호출한다. 즉, 전역 new 처리자로 Widget 클래스의 new 처리자를 호출한다.
  • 전역 operator new 호출로 실제 메모리 할당을 수행한다. 전역 operator new 할당이 실패하면, Widget 클래스의 new 처리자를 호출하게 된다. 최종적으로 operator new 함수가 메모리 할당에 실패하면 bad_alloc 예외를 던진다. 이때 Widget 클래스의 operator new 함수는 전역 new 처리자를 Widget 클래스의 것이 아닌, 기존의 것으로 되돌린다. 원래의 new 처리자를 항상 되돌려놓을 수 있도록 전역 new 처리자를 자원으로 간주하고, 자원 관리 객체(NewHandlerHolder)를 사용한다.
  • 전역 operator new 함수가 Widget 객체 하나만큼의 메모리를 할당할 수 있으면, Widget 클래스의 operator new 함수는 할당된 메모리를 반환한다. 이때 전역 new 처리자의 관리 객체가 소멸하면서 Widget 클래스의 operator new 함수 호출 전의 전역 new 처리자가 복원된다.

 

위와 같이 자원 관리 객체로 할당 에러를 처리하는 방식은 어디서나 적용할 수 있다. 따라서, 믹스인(mixin) 양식의 기본 클래스를 구현해놓으면 좋다. 다른 파생 클래스들이 한 가지 특정 기능만을 물려받을 수 있도록 설계된 기본 클래스를 만드는 것으로, 지금의 특정 기능은 클래스별 new 처리자를 설정하는 기능이다.

추가적으로 이 기본 클래스를 템플릿으로 바꿔, 파생 클래스마다 클래스 데이터의 사본을 따로 만들어두도록 할 수 있다. 이렇게 템플릿으로 구현했을 경우, 기본 클래스 부분은 파생 클래스가 가져야 할 set_new_handler 함수와 operator new 함수를 물려주고, 템플릿 부분(currHandler)은 각 파생 클래스가 인스턴스화 되면서 각자 갖게 될 것이다.

// 믹스인 양식 기본 클래스
template<typename T>
class NewHandlerSupport {
public:
	// type T에 대한 set_new_handler
	static std::new_handler set_new_handler(std::new_handler p) throw() {
		std::new_handler oldHandler = currHandler;
		currHandler = p;
		return oldHandler;
	}

	// type T에 대한 operator new
	static void *operator new (std::size_t size) throw(std::bad_alloc){
		NewHandlerHolder h(std::set_new_handler(currHandler));
		return ::operator new(size);
	}

	// operator new의 다른 버전들 (항목 52 참고)

private:
	static std::new_handler currHandler;
};

template<typename T>
std::new_handler NewHandlerSupport<T>::currHandler = 0;

 

위의 템플릿 클래스에서 단순히 매개변수 TNewHandlerSupport를 파생 클래스에 대한 서로 다른 사본을 만들어주는 역할밖에 안된다.

 

실제 사용할 때는 아래와 같이 사용한다. 템플릿 매개변수로 자기 자신의 클래스를 넣어주는 형태다. Widget을 매개변수로 만들어진 기본 클래스로부터 Widget이 파생된 모습은 이상할 수 있지만, 꽤 쓸만한 기법이다. 신기하게 반복되는 템플릿 패턴(CRTP, Curiously Recurring Template Pattern)이라 불리는 패턴이다. 간단히 자기 자신만의 템플릿을 사용한다고 생각하면 된다.

class Widget : public NewHandlerSupport<Widget> {
public:
	// set_new_handler / operator new 선언을 하지 않아도 됨
};

 

50. new 및 delete를 언제 바꿔야 좋은 소리를 들을지를 파악해두자

컴파일러가 제공하는 opeerator newoperator delete를 바꾸려는 이유를 정리하면 아래와 같다.

  1. 잘못된 힙 사용을 탐지하기 위해
    • new 한 메모리에 대해 delete를 하지 않으면 메모리 누수가 발생한다.
    • new 한 메모리에 두 번 이상 delete를 하면 미정의 동작이 발생한다.
    • 할당된 메모리 주소 목록을 operator new를 호출할 때 추가하고 operator delete가 호출될 때 제거해주면 위의 실수가 없을 것이다.
  2. 효율을 향상하기 위해
    • 컴파일러가 제공하는 버전은 대체적으로 일반적인 쓰임새에 맞춰 설계된 것이다.
    • 따라서 다양한 경우에 범용적으로 적용되는 것이지, 최적으로 적용되는 것이 아니다.
  3. 동적 할당 메모리의 실제 사용에 관한 통계 정보를 수집하기 위해
    • 직접 만드는 소프트웨어가 동적 메모리를 어떻게 사용하는지에 관한 정보를 수집할 수 있다.

 

개념적으로 operator new를 직접 만드는 작업은 어렵지 않다. 버퍼 오버런/언더런(경계 바깥에 기록)을 탐지하기 쉬운 형태로 만들어주는 전역 operator new를 예로 들어보자.

static const int signature = 0xDEADBEEF;
typedef unsigned char BYTE;

void *operator new(std::size_t size) throw(std::bad_alloc) {
	using namespace std;

	// 경계지표(signature)를 앞/뒤로 붙일 수 있도록 메모리 크기를 할당
	size_t realSize = size + 2 * sizeof(int); 

	void *pMem = malloc(realSize);
	if ( NULL != pMem ) {
		throw bad_alloc();
	}

	// 메모리 블록 시작과 끝 부분에 경계지표 기록
	*(static_cast<int *>(pMem)) = signature;
	*(reinterpret_cast<int *>(static_cast<BYTE *>(pMem) + realSize - sizeof(int))) = signature;

	return static_cast<BYTE *>(pMem) + sizeof(int);
}

 

위의 예제 코드는 new 처리자 지정과 같은 operator new에 대한 관례는 생략하도록 한다. 까다로운 문제가 하나 있는데, 바로 바이트 정렬이다. 

 

대부분의 컴퓨터는 아키텍처 적으로 특정 타입의 데이터가 특정 종류의 메모리 주소를 시작 주소로 할 것을 요구사항으로 두고 있다. 이를테면 포인터는 4의 배수에 해당하는 주소에 맞춰 저장되어야(4바이트 단위로 정렬되어야) 한다. double은 8의 배수에 해당하는 주소에 맞춰 저장되어야(8바이트 단위로 정렬되어야) 한다. 아키텍처에서는 이런 바이트 정렬을 만족해야 더 나은 성능을 제공하게 된다.

 

위의 예제에서는 malloc에서 얻은 포인터를 operator new가 바로 반환하는 안전한 형태가 아니다. malloc에서 나온 포인터를 기준으로 int 크기만큼 뒤로 어긋난 주소를 반환하고 있다.. 이럴 경우에는 안전하다는 보장을 할 수 없다. 만약 사용자가 operator new를 통해 double을 담을 메모리를 얻어내는데, int가 4바이트 정렬이 되어야 하고 double이 8바이트 정렬이 되어야 하는 컴퓨터라면, 바이트 정렬이 어긋난 포인터가 반환되기 때문이다. 이로 인해 어떤 프로그램은 속도가 느려질 것이고, 어떤 프로그램은 완전히 다운될 수도 있다.

따라서 잘 동작하는 훌륭한 메모리 관리자(operator new / operator delete)를 웬만하면 직접 만들 일이 없다. 사용하고 싶다면 잘 만들어진 상용 제품 또는 오픈소스를 사용하면 된다. 대표적인 오픈소스가 부스트의 풀(Pool) 라이브러리다. Pool 라이브러리는 크기가 작은 객체를 많이 할당할 때에 최적으로 동작하게 되어있다.

 

operator newoperator delete를 바꾸려는 추가적인 이유들만 정리해보면 아래와 같다.

  • 할당 및 해제 속력을 높이기 위해
  • 기본 메모리 관리자의 공간 오버헤드를 줄이기 위해
  • 적당히 타협한 기본 할당자의 바이트 정렬 동작을 보장하기 위해
  • 임의의 관계를 맺고 있는 객체들을 한 군데에 나란히 모아 놓기 위해
  • 그때그때 원하는 동작을 수행하도록 하기 위해

 

51. new 및 delete를 작성할 대 따라야 할 기존의 관례를 잘 알아 두자

operator new에 대한 관례다.

  • 반환 값이 제대로 되어 있어야 한다.
    • opeartor new의 반환 값은 요청된 메모리에 대한 포인터다.
    • 메모리를 마련할 수 없다면 bad_alloc 타입의 예외를 던지면 된다.
  • 가용 메모리가 부족할 경우에는 new 처리자 함수를 호출해야 한다.
  • 크기가 없는(0 바이트) 메모리 요청에 대한 대비책을 갖춰야 한다.
    • 0바이트가 요구되었을 때조차도 적법한 포인터를 반환해야 한다.
    • 외부에서 0바이트를 요구했을 때 1바이트 요구로 간주하고 처리한다.
  • 실수로 기본 형태의 new가 가려지지 않도록 해야 한다.

 

operator new에 대한 추가 고려 사항이다.

  • 기존 클래스의 operator new가 자식 클래스 생성 시 호출될 경우를 대비해야 한다.
    • 기존 클래스의 operator new에서는 입력된 사이즈가 클래스의 크기와 일치하는지 확인해서 기본 클래스의 operator new 또는 표준 operator new를 호출한다.

 

operator delete에 대한 관례다.

  • NULL 포인터에 대한 delete 적용이 항상 안전하도록 보장해야 한다.
    • NULL 포인터는 delete 하지 않도록 해야 미정의 동작이 수행되지 않는다.

 

operator delete에 대한 추가 고려 사항이다.

  • operator new와 마찬가지로, 틀린 크기의 메모리 삭제 요청이 되었다면, 표준 operator delete를 호출한다.

 

52. 위치 지정 new를 작성한다면 위치 지정 delete도 같이 준비하자

기본 형태의 operator newoperator delete에서는 고려하지 않아도 알아서 new와 일치하는 delete를 호출한다. 문제가 발생하는 경우는 매개변수를 추가로 갖는 경우다. 이는 위치 지정 new라 부른다.

class StandardNewDeleteForms {
public:
	// 기본형 new/delete
	static void *operator new(std::size_t size) throw(std::bad_alloc);
	static void operator delete(void *pMemory) throw();

	// 위치지정 new/delete
	static void *operator new (std::size_t size, void *ptr) throw(std::bad_alloc);
	static void operator delete (void *pMemory, void *ptr) throw();

	// 예외불가 new/delete
	static void *operator new (std::size_t size, const std::nothrow_t &nt) throw();
	static void operator delete(void *pMemory, const std::nothrow_t &nt) throw();
};

 

표준적인 형태는 위의 코드의 3가지다. 특정 위치 지정 new의 두 번째 인자가 void *이기 때문에 다양한 타입을 지정해줄 수 있다. 위치 지정 new를 지정할 경우, 일치하는 타입을 갖는 위치 지정 delete를 지정해야 한다.

만약, 위치 지정 new에 해당하는 위치 지정 delete를 정의하지 않는다면, 아무런 자원 해제 과정이 없어지게 된다. 이를 대비해서 표준 형태의 new/delete는 반드시 기본적으로 마련해두어야 한다. 그래야 원인모를 메모리 누출을 확실히 막을 수 있다.

728x90