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

업데이트:     Updated:

카테고리:

태그:

이 글은 아래의 책을 자세히 정리한 후, 정리한 글을 GPT에게 요약을 요청하여 작성되었습니다.
이펙티브 C++ 제3판, 스콧 마이어스 저자, 곽용재 번역

📦 8. new와 delete를 내 맘대로

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

🔍 new 연산의 동작 과정

Widget* pw = new Widget;

이 코드는 두 개의 함수를 순서대로 호출한다:

  1. 메모리 할당을 위해 operator new 호출
  2. Widget 기본 생성자 호출

예외 발생 시 문제:

  • 만약 2번째에서 예외가 발생하면:
    • 메모리는 할당이 되어있지만, pw에 메모리 위치를 가리키는 포인터가 대입되어 있지 않다
    • 사용자 코드에서 해당 메모리를 해제할 방법은 없다
    • 이러한 경우, C++ 런타임 시스템이 메모리 할당을 해제해준다

⚙️ 런타임 시스템 처리 과정

처리 방법:

  • 1번째에서 호출한 operator new 함수와 짝이 되는 operator delete를 호출해야 한다
  • 런타임 시스템은 operator delete 함수들 중, 어떤 것을 호출해야 하는지 알아야 한다
    • 문제는 new/delete 함수가 짝이 맞지 않는 경우 발생한다

📌 기본형 operator new/delete 함수

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

// 기본형 operator delete들
// 전역 유효범위
void operator delete(void* rawMemory) throw();
// 클래스 유효범위
void operator delete(void* rawMemory, size_t size) throw();

기본형이란?

  • 전형적인 매개변수를 갖는 표준 형태의 함수를 뜻한다
  • 기본형 operator new는 기본형 operator delete와 짝을 맞추기 때문에 문제가 없다

🎯 비기본형 operator new/delete 함수

class Widget {
public:
	...
	// 비기본형 operator new
	static void* operator new(
		size_t size,
		ostream& logStream
	) throw(bad_alloc);

	// 기본형 operator delete
	// 클래스 유효범위를 가진다.
	static void operator delete(
		void* pMemory,
		size_t size
	) throw();
	...
};

비기본형이란?

  • 다른 매개변수를 추가로 갖는 비표준 형태의 함수를 뜻한다

📍 위치지정(placement) new

정의:

  • operator new 함수는 매개변수를 추가로 받는 형태로 선언할 수 있다
    • 이를 위치지정(placement) new라고 부른다
void* operator new(size_t, void* pMemory) throw();

대표적인 위치지정 new 함수:

  • 어떤 객체를 생성시킬 메모리 위치를 나타내는 포인터를 매개변수로 받는다
  • 유용성을 인정받아 C++ 표준 라이브러리에 <new>로 들어가 있다
    • vector도 원소 객체를 생성할 때 위치지정 new를 사용한다

⚠️ 문제 발생 상황

class Widget {
public:
	...
	static void* operator new(
		size_t size,
		ostream& logStream
	) throw(bad_alloc);

	static void operator delete(
		void* pMemory,
		size_t size
	) throw();
	...
};

int main() {
	// operator new를 호출할 때,
	// cerr을 ostream 인자로 넘긴다.
	// Widget 생성자에서 예외 발생 시,
	// 메모리가 누출된다.
	Widget* pw = new (std::cerr) Widget;
}

문제 분석:

  • 메모리 할당은 성공했지만, Widget 생성자에서 예외가 발생하였다고 가정하자

동작 과정:

  1. C++ 런타임 시스템이 처리해야 한다
  2. operator new의 동작 방법을 알아낼 방법이 없다
    • 할당 자체를 되돌릴 수 없다
  3. 호출된 operator new와 시그니처가 같은 operator delete를 찾고,
    찾았다면 그 함수를 호출해야 한다
    • 하지만 존재하지 않는다
  4. C++ 런타임 시스템은 아무것도 하지 않는다
    • 어떠한 operator delete도 호출하지 않게 된다

해결 방법:

void operator delete(void*, ostream&) throw();
  • 동작하기 위해선 동일 시그니처를 가진 delete가 있어야 한다
    • 매개변수를 추가로 받아들인다는 점에서 위치지정 delete라고 부른다

✅ 수정된 코드

class Widget {
public:
	...
	static void* operator new(
		size_t size,
		ostream& logStream
	) throw(bad_alloc);

	static void operator delete(
		void* pMemory
	) throw();

	static void operator delete(
		void* pMemory,
		ostream& logStream
	) throw();
	...
};

int main() {
	Widget* pw = new (std::cerr) Widget;
	delete pw; // 기본형 operator delete 호출
}

핵심 사항:

  • 짝이 맞는 위치지정 new/delete가 존재하여 메모리 누출을 막을 수 있다
  • 만약 예외 없이 delete pw까지 도달하면 기본형 operator delete를 호출한다
    • 위치지정 버전을 호출하지 않는다!
    • 위치지정 버전을 호출하는 경우는 생성자에서 예외가 발생할 경우뿐이다
    • 즉, 기본(표준) 형태의 operator delete를 함께 마련해두어야 한다

🚫 이름 가려짐 문제

클래스 내부에서의 가려짐:

class Base {
public:
	...
	// 이 new가 표준 형태의 전역 new를 가린다.
	static void* operator new(
		size_t size,
		ostream& logStream
	) throw(bad_alloc);
	...
}

int main() {
	// ❌ 에러: 표준 형태의 전역 operator new가 가려짐.
	Base* pb = new Base;
	// ✅ Base의 위치지정 new를 호출한다.
	Base* pb = new (std::cerr) Base;
}

원칙:

  • 바깥 유효범위에 있는 어떤 함수의 이름과, 클래스 멤버 함수의 이름이 같으면
    • 바깥 유효범위에 있는 함수가 가려진다

상속에서의 가려짐:

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

int main() {
	// ✅ Derived의 operator new를 호출한다.
	Derived* pd = new Derived;
	// ❌ 에러: Base의 위치지정 new가 가려짐.
	Derived* pd = new (std::clog) Derived;
}
  • 이는 상속에서도 그대로 이어진다

📋 표준 전역 operator new의 형태들

// 기본형 new
void* operator new(size_t) throw(bad_alloc);
// 위치지정 new
void* operator new(size_t, void*) throw();
// 예외불가 new (항목 49 참조)
void* operator new(size_t, const nothrow_t&) throw();

주의사항:

  • 어떤 형태이든 operator new가 클래스 안에 선언되기만 하면
    • 위의 표준 형태들이 모두 가려진다
  • 클래스 내에서 할당/해제 함수들이 표준 형태로 동작하길 원하면
    • 클래스 전용 버전이 전역 버전을 호출하도록 하자
    • 이것이 귀찮다면 상속을 사용하면 된다

🔧 상속과 using을 통한 해결

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

	static void operator delete(void* pMemory)
		throw()
	{ ::operator delete(pMemory); }

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

	static void operator delete(void* pMemory, void* ptr)
		throw()
	{ ::operator delete(pMemory, ptr); }

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

	static void operator delete(void* pMemory, const nothrow_t&)
		throw()
	{ ::operator delete(pMemory); }
};

// 표준 형태를 물려받는다.
class Widget: public StandardNewDeleteForms {
public:
	// 표준 형태를 using 선언한다.
	using StandardNewDeleteForms::operator new;
	using StandardNewDeleteForms::operator delete;

	// 사용자 정의 위치지정 new를 추가한다.
	static void* operator new(size_t size, ostream& logStream)
		throw(bad_alloc);

	// 위와 짝이 되는 위치지정 delete를 추가한다.
	static void operator delete(void* pMemory, ostream& logStream)
		throw();
	...
};

해결 방법:

  • 상속using을 사용하여 표준 형태를 끌고 온다
    • 이후 원하는 new/delete를 만들면 된다

🧐 정리

  • operator new 함수의 위치지정(placement) 버전을 만들 때는 해당 함수와 짝을 이루는 위치지정 버전의 operator delete 함수도 꼭 만들자
    • 그렇지 않으면 찾기도 힘든 메모리 누출 현상을 경험하게 된다
  • new/delete 위치지정 버전을 선언할 때는 의도하지 않았지만 표준 버전이 가려지는 일이 없도록 주의하자

Cpp 카테고리 내 다른 글 보러가기

댓글남기기