관리 메뉴

KoreanFoodie's Study

Effective C++ 정리 8 : new 와 delete 를 내 맘대로 본문

Tutorials/C++ : Advanced

Effective C++ 정리 8 : new 와 delete 를 내 맘대로

머니덕 2022. 9. 19. 11:04

C++ 프로그래머의 필독서이자 바이블인, 스콧 마이어스의 Effective C++ 를 읽고 기억할 내용을 요약하고 있습니다. 꼭 읽어보시길 추천드립니다!


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

핵심 :

1. set_new_handler 함수를 쓰면 메모리 할당 요청이 만족되지 못했을 때 호출되는 함수를 지정할 수 있다.
2. 예외불가(nothrow) new 는 영향력이 제한되어 있다. 메모리 할당 자체에만 적용되기 때문이다. 이후에 호출되는 생성자에서는 얼마든지 예외를 던질 수 있다.

 

메모리 할당이 제대로 되지 못했을 때, operator new 는 예외를 던지기 전 사용자 쪽에서 지정할 수 있는 에러 처리 함수를 우선적으로 호출한다. 이 함수를 new 처리자(new-handler, 할당에러 처리자) 라고 한다. 이와 같은 함수를 사용자 쪽에서 지정할 수 있도록, 표준 라이브러리에 set_new_handler 라는 함수가 준비되어 있다.

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

  // throw() 는 예외지정으로, 예외를 던지지 않는다
  new_handler set_new_handler(new_handler p) throw();
}

void outOfMem()
{
  std::cerr << "Unable to satisfy request for memory\n";
  std::abort();
}

int main()
{
  std::set_new_handler(outOfMem);

  // Unable to satisfy request for memory 출력!
  int *pBigDataArray = new int[100000000000000l];
}

위에서 new_handler 의 타입은 void 를 받아 void 를 리턴하는 함수에 대한 포인터이다.

 

호출되는 new 처리자 함수를 만들 때는 다음 사항들을 고려해서 만들어야 한다.

  • 사용할 수 있는 메모리를 더 많이 확보한다
  • 다른 new 처리자를 설치한다 : 현재의 new 처리자 안에서 set_new_handler 를 설치하고 호출한다
  • new 처리자의 설치를 제거한다 : set_new_handler 에 널 포인터를 넘긴다
  • 예외를 던진다
  • 복귀하지 않는다 : 대개 abort 혹은 exit 을 호출한다

 

특정 클래스의 메모리 할당이 실패했을 때, 우리가 원하는 함수가 호출되길 원한다면 이런 식으로 구현하면 된다.

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;

std::new_handler Widget::set_new_handler(std::new_handler p) throw()
{
  std::new_handler oldHandler = currentHandler;
  currentHandler = p;
  return oldHandler;
}

그 후 Widget 의 operator new 를 다음과 같이 동작하게 한다.

  1. 표준 set_new_handler 함수에 Widget 의 new 처리자를 넘겨서 설치(전역 new 처리자로서 Widget 의 new 처리자를 설치) 한다.
  2. 전역 operator new 를 호출하여 실제 메모리 할당을 수행한다. 실패하면, 이 함수는 Widget 의 new 처리자를 호출한다(전 단계에서 처리자 설치했음). 마지막까지 전역 operator new 의 메모리 할당 시도가 실패하면, bad_alloc 예외를 던진다. 이 경우 Widget 의 operator new 는 전역 new 처리자를 원래의 것으로 되돌려 놓고 이 예외를 전파한다. 이때 전역 new 처리자를 자원으로 간주하고 처리한다.
  3. 전역 operator new 가 Widget 객체 메모리 할당에 성공하면, 전역 new 처리자를 관리하는 객체의 소멸자가 호출되며 Widget 의 operator new 가 호출되기 전에 쓰이던 전역 new 처리자가 복원된다.
class NewHandlerHolder
{
public:
  explicit NewHandlerHolder(std::new_handler nh)
  : handler(nh) {}

  ~NewHandlerHolder()
  { std::set_new_handler(handler); }

private:
  // 핸들러를 기억해 둔다
  std::new_handler handler;

  // 복사를 막기 위한 부분 (항목 14 참고)
  NewHandlerHolder(const NewHandlerHolder&);
  NewHandlerHolder& operator=(const NewHandlerHolder&);
};

void* Widget::operator new(std::size_t size) throw(std::bad_alloc)
{
  // Widget 의 new 처리자 설치
  // NewHandlerHolder 는 currentHandler 로 핸들러가 
  // 바뀌기 전의 oldHandler 를 쥐고 있음!
  NewHandlerHolder h(std::set_new_handler(currentHandler));

  // 할당 실패 시 예외를 던짐
  return ::operator new(size);

  // 이전의 전역 new 처리자가 자동으로 복원됨
}


int main()
{
  // Widget 객체에 대한 메모리 할당이 실패했을 때 호출할 함수
  void outOfMem();

  // Widget 의 new 처리자 함수로 outOfMem 설치
  Widget::set_new_handler(outOfMem);

  // 메모리 할당 실패 시 outOfMem 호출
  Widget *pw1 = new Widget;

  // 메모리 할당 실패 시 전역 new 처리자 함수 호출(있으면)
  std::string *ps = new std::string;

  // Widget 클래스의 new 처리자 함수를 null 로 설정
  Widget::set_new_handler(0);

  // 메모리 할당이 실패하면 이제 바로 예외를 던짐
  Widget *pw2 = new Widget;
}

 

위의 코드를 템플릿화하여 다른 클래스들도 자유롭게 사용하도록 만들어 보자.

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);
  // operator new 의 다른 버전들을 아래에 구현 (항목 52 참고)
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);
}

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

이제 Widget 은 NewHandlerSupport<Widget> 을 상속받기만 하면 된다.

class Widget: public NewHandlerSupport<Widget>
{
public:
  // int arr[10000000000000000l];
private:
  static std::new_handler currentHandler;
};

위의 코드처럼, Widget 이 NewHandlerSupport<Widget> 으로부터 상속받는 패턴을 신기하게 반복되는 템플릿 패턴(curiously recurring template pattern: CRTP) 라고 부른다.

 

추가적으로 한 가지만 짚고 넘어가자. 1993 년까지, C++ 는 operator new 가 메모리 할당에 실패하면 널 포인터를 반환했지만, 이후 bad_alloc 예외를 던지도록 명세를 바꾸었다. 과거의 동작을 지원하기 위해, "예외불가(nothrow)" 형태의 operator new 를 새롭게 만들었다.

class Widget { ... };
// 할당이 실패하면 bad_alloc 예외를 던짐
Widget *pw1 = new Widget;
if (pw1 == 0) {} // 이 점검 코드는 항상 실패

// 할당이 실패하면 0(null) 을 반환
Widget *pw2 = new (std::nothrow) Widget;
if (pw2 == 0) {} // 이 점검 코드는 성공할 수 있음

하지만, new (std::nothrow) Widget 에서는 예외가 발생하지 않아도, Widget 의 생성자에서 예외가 발생하면 예전처럼 예외가 전파된다. 아마 예외불가 new 를 필요로 할 일은 거의 없을 것이다!

 

 

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

핵심 :

개발자가 스스로 사용자 정의 new 및 delete 를 작성하는 데는 여러가지 이유가 있다. 여기에는 수행 성능 향상, 힙 사용 시의 에러 디버깅, 힙 사용 정보 수집 등의 목적이 포함된다.

 

operator new 와 operator delete 를 바꾸는 가장 흔한 세 가지 이유를 한 번 보자.

  • 잘못된 힙 사용을 탐지하기 위해 : 데이터 오버런(overrun) 및 언더런(underrun) 을 탐지하기 위해 탐지용 바이트를 추가로 할당할 수 있다.
  • 효율을 향상시키기 위해 : 힙 단편화 등 실행환경에 맞게 커스터마이징
  • 동적 할당 메모리의 실제 사용에 관한 통계 정보를 수집하기 위해 : 로그 수집 등

 

경계 표지 패턴의 간단한 구현을 한 번 보자.

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

void*operator new(std::size_t size) throw(std::bad_alloc)
{
	using namespace std;
	// 경계 표지 2개를 앞뒤에 붙일 수 있을 만큼 메모리를 확장
	size_t realSize = size + 2 * sizeof(int);

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

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

	// 앞쪽 경계 표지 바로 다음의 메모리를 가리키는 포인터를 반환
	return static_cast<int*>(pMem) + sizeof(int);
}

위 구현은 operator new 에 쓰이는 관례(new 처리자 호출 루프 구현)이 지켜지지 않았지만, 자세한 건 항목 51에서 다루기로 한다.

사실 위의 문제는, 바이트 정렬(alignment) 의 문제를 갖고 있다. 여기서는 경계 표지를 4바이트(int 의 사이즈) 로 설정해서 포인터를 경계 표지 시작지점 + 4 인 곳을 리턴한다. 하지만 해당 operator new 를 사용하는 녀석이 double 을 할당하고, 각각의 주소값을 8바이트로 잡는 경우, 문제가 생길 수 있다.

이외에도 추가적인 문제가 생길 수 있지만, 사용자 operator new 와 operator delete 를 만들 때는, 다음과 같은 이유로 만드는 것인지를 잘 고려해 보자.

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

 

 

항목 51 : new 및 delete 를 작성할 때 따라야 할 기존의 관례를 잘 알아 두자

핵심 :

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

 

기본적인 요구사항을 반영한 operator new 함수를 의사 코드로 만들어 보면 다음과 같다.

// 커스텀 operator new 함수는 다른 매개변수를 추가로 가질 수 있다
void* operator new(std::size_t size) throw(std::bad_alloc)
{
	using namespace std;

	// 0 바이트 요청이 들어오면 1 바이트 요구로 간주하고 처리
	if (size == 0)
	{
		size = 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();
	}
}

항목 49에서, new 처리자 함수는 가용 메모리를 늘려 주던가, 다른 new 처리자를 설치하든가, new 처리자의 설치를 제거하든가, bad_alloc 혹은 bad_alloc 에서 파생된 타입의 예외를 던지든가, 아예 함수 복귀를 포기하고 중단을 시켜야 한다고 이야기했다. 이렇듯 new 처리자 함수가 4가지 중 하나를 택해야 하는 이유는, 위의 operator new 함수의 구현처럼 내부 루프를 끝낼 수 있도록 구현되어야 하기 때문이다.

 

operator new 멤버 함수는 파생 클래스 쪽으로 상속이 되는데, 얼만큼의 메모리를 할당할 것인지(size) 가 파생 클래스로 제대로 전달되지 않을 수 있다.

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

class Derived: public Base {};

int main()
{
	// Base::operator new 가 호출!
	Derived *p = new Derived;	
}

위의 케이스는, 실제 할당하려는 클래스의 메모리 크기가 Base 에서의 operator new 사이즈와 다르면, 표준 operator new 를 호출하게 만듦으로써 해결할 수 있다.

class Base
{
public:
	static void* operator new(std::size_t size) throw(std::bad_alloc)
	{
		// 0 바이트도 점검된다. 왜냐하면 Base 크기는 무조건 0 이상으로 잡히기 때문
		if (size != sizeof(Base))
			return ::operator new(size);

		...
	}
};

operator new[ ] 의 경우, 객체의 갯수를 (요구된 바이트 수 / sizeof(Base)) 로 계산할 수 없고(파생 클래스에서 사용할 수 있으므로) 인자로 넘어가는 size_t 타입의 인자는 객체들을 담기에 딱 맞는 메모리 양보다 더 많게 설정되어 있을 수도 있다. 이런 어려움 때문에, operator new[ ] 구현은 조금 더 까다롭다.

 

operator delete 도 비슷하다. 다음 예시를 보자.

class Base
{
public:
	static void* operator new(std::size_t size) throw(std::bad_alloc);
	static void* operator delete(void* rawMemery, std::size_t size) throw();
};

void Base::operator delete(void *rawMemery, std::size_t size) throw()
{
	// 널 포인터 점검
	if (rawMemery == 0) return;

	if (size != sizeof(Base))
	{
		::operator delete(rawMemery);
		return;
	}

	rawMemery 가 가리키는 메모리 해제;

	return;
}

참고로, 가상 소멸자가 없는 기본 클래스로부터 파생된 클래스의 객체를 삭제하려고 하면 operator delete 로 C++ 가 넘기는 size_t 값이 엉터리일 수 있다!

 

 

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

핵심 :

1. operator new 함수의 위치지정(placement) 버전을 만들 때는, 이 함수와 짝을 이루는 위치지정 버전의 operator delete 함수도 꼭 만들자. 이 일을 빼먹으면, 찾아내기도 힘들고 생겼다가 안 생겼다 하는 메모리 누출 현상을 경험하게 된다.
2. new 및 delete 의 위치지정 버전을 선언할 때는, 의도한 바도 아닌데 이들의 표준 버전이 가려지는 일이 생기지 않도록 주의하자.

 

아래 코드를 보자.

Widget *pW = new Widget;

위의 코드는 먼저 operator new 가 호출되고, 이후 Widget 의 생성자가 호출될 것이다. 그런데 Widget 생성자에서 예외가 발생하면 C++ 런타임 시스템은 방금 호출한 operator new 와 "짝이 맞는" operator delete 를 호출해 주어야 한다. 기본형은 다음과 같이 정의되어 있기에, 표준 형태의 new / delete 사용은 문제가 되지 않는다.

// 기본형 operator new
void* operator new(std::size_t size) throw(std::bad_alloc);

// 전역 유효범위에서의 기본형 시그니처
void operator delete(void* rawMemory) throw();
// 클래스 유효범위에서의 기본형 시그니처
void operator delete(void* rawMemory, std::size_t size) throw();
 

 

하지만 다음과 같이 추가적인 인자를 넘겨주는 커스텀 operator new (이런 것을 위치지정 - placement - 버전 이라고 부른다) 를 만들면, delete 가 호출될 때 맞는 짝을 찾을 수가 없어 어떤 operator delete 도 호출되지 않는다.

// 위치지정(placement) new (비표준 형태) 예시들
void* operator new(std::size_t size, void* pMemory) throw(std::bad_alloc);
void* operator new(std::size_t size, std::ostream& logStream) throw();

...

// cerr 를 ostream 인자로 넘김
// Widget 생성자에서 예외가 발생하면 메모리 누출!
Widget *pW = new (std::cerr) Widget;

즉, 메모리 누출을 막으려면 "짝이 맞는" operator delete 를 만들어 주면 된다!

class Widget
{
public:
	void operator delete(void* rawMemory) throw();

	static void* operator new(std::size_t size, void* pMemory) throw(std::bad_alloc);
	void operator delete(void* rawMemory, void* pMemory) throw();

	static void* operator new(std::size_t size, std::ostream& logStream) throw();	
	void operator delete(void* rawMemory, std::ostream& logStream) throw();
};

...

// 이제 메모리 누출 걱정이 없음!
Widget* pW = new (std::cerr) Widget;

// Widget 생성자에서 예외를 던지지 않으면?
// 아래 코드는 기본형의 operator delete 를 호출한다
delete pW;

즉, 커스텀 operator new 를 만들 때는, 첫째, 기본형의 operator delete 를 만들어야 하고, 둘째, 위치지정 new 와 같은 추가 매개 변수를 받는 operator delete 를 만들어야 한다.

 

마지막으로, 클래스 전용의 함수가 기존의 operator new 를 가리는 상황을 보자.

/* C++ 가 전역 유효범위에서 제공하는 operator new 의 표준 형태 */

void* operator new(std::size_t size) throw(std::bad_alloc); // 기본
void* operator new(std::size_t size, void* ptr) throw(); // 위치지정
void* operator new(std::size_t size, const std::nothrow_t& nt) throw(); // 예외불가

/* operator new 를 클래스 안에 선언하면 위의 표준 형태들이 전부 가져짐! */


class Base
{
public:
	// 아래 코드는 기존의 표준 operator new 를 가린다
	static void* operator new(std::size_t size, std::ostream& logStream) throw();	
	...
};

...

// 에러! 표준 형태의 전역 operator new 가 가려짐
Base *pb = new Base;

// Base 의 위치지정 new 를 호출!
Base *pb = new (std::cerr) Base;


class Derived: public Base
{
public:
	// 기본형 new 를 클래스 전용으로 다시 선언
	static void* operator new(std::size_t size) throw();
	...
};

...

// 에러! Base 의 위치지정 new 가 가려져 있음
Derived *pd = new (std::clog) Derived;

// 문제 없음
Derived *pd = new Derived;

 

다음의 기본형 클래스를 만들고 상속하는 방식으로 위치지정 new 버전의 확장을 용이하게 할 수 있다.

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

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

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

// StandardNewDeleteForms 를 상속받아, 원하는 operator new/delete 추가
class Widget: public StandardNewDeleteForms
{
	// 표준 형태가 Widget 내부에서 보이도록 만듦
	using StandardNewDeleteForms::operator new;
	using StandardNewDeleteForms::operator delete;

	// 사용자 정의 위치지정 new/delete 추가
	static void* operator new(std::size_t size, std::ostream& logStream) throw(std::bad_alloc)
	{ ... }
	static void operator delete(void* pMemory, std::ostream& logStream) throw()
	{ ... }
	...
};

 

0 Comments
댓글쓰기 폼