메모리 할당/해제 연산자(new/delete) 오버로딩

Jin Hur·2023년 9월 14일
1

OOP with C++ 

목록 보기
18/30

reference:

  • 전문가를 위한 C++ / 마크 그레고리 / 한빛미디어

C++는 메모리 할당과 해제 작업을 하는 new, delete 연산자를 원하는 형태로 정의하도록 오버로딩을 허용한다. 이러한 커스터마이징 작업은 프로그램 전반에 적용할 수 있도록 전역으로 만들 수도 있고, 클래스 단위로 적용하게 만들 수도 있다.
이 오버로딩을 통해 조그만 객체들을 여러 차례 할당하고 해제하는 과정에서 발생하는 메모리 파편화(단편화)를 방지하는데 주로 사용된다. 예를 들어 메모리가 필요할 때마다 디폴트 C++ 메모리 할당 기능 대신 고정 크기의 메모리 영역을 미리 할당해서 메모리 풀 할당자로 만들고 여기서 메모리를 재사용하도록 구현할 수 있다.

1. new와 delete의 구체적인 작동 방식

TestClass* tclass = new TestClass();

위 코드에서 new TestClass()new 표현식이라 부른다. 이 문장은 두 가지 작업을 하는데, 먼저 operator new를 호출해 객체에 대한 메모리를 할당(1)한다. 그리고 객체의 생성자를 호출(2)한다. 생성자의 실행이 끝나야 비로소 객체에 대한 포인터가 리턴된다.
delete도 작동 방식이 비슷하다. 먼저 객체의 소멸자를 호출한 다음 operator delete를 호출하여 객체에 할당된 메모리를 해제한다.

new와 malloc을 통한 메모리 할당의 차이점

  • malloc은 할당할 크기를 인자로 받아 해당 크기만큼의 공간을 할당해 주지만, new는 이에 더해 객체의 생성자까지 호출하여 객체를 생성한다.
  • malloc은 메모리 공간 할당 실패 시 nullptr을 반환하지만, new는 기본적으로 프로그램이 종료된다. 요청한 만큼의 메모리가 없어서 익셉션이 발생하고, 이에 따라 프로그램이 종료된다.

operator new와 operator delete를 오버로딩해서 메모리 할당과 해제 과정을 직접 제어할 수 있다. 그러나 new-표현식과 delete-표현식 자체를 오버로딩할 순 없다. 이는 실제 메모리를 할당하고 해제되는 과정은 커스터마이징할 수 있지만 생성자와 소멸자를 호출하는 동작은 변경할 수 없다는 것을 의미한다.

new-표현식과 operator new

new-표현식은 여섯 가지 종류가 있고 각 버전마다 적용되는 operator new가 따로 있다. 이 중 네 가지(new, new[], new(nothrow), new(nothrow)[]) 형태에 대응하는 opeator new는 다음과 같으며 오버로딩이 가능하다.

// <new> header file
void* operator new(size_t size);
void* operator new[](size_t size);
void* operator new(size_t size, const std::nothrow_t&) noexcept;
void* operator new[](size_t size, const std::nothrow_t&) noexcept;

그리고 나머지 두 개는 실제로 객체를 할당하지 않고 기존에 저장된 객체의 생성자를 호출만 하는 배치 new 연산자(placement new operator)라는 특수한 형태의 new-표현식이다. 이 연산자를 사용하면 아래와 같이 기존에 확보된 메모리에 객체를 생성할 수 있다.

void* ptr = allocateMemory();
TestClass* tclass = new (ptr) TestClass();

이 연산자는 매번 메모리를 해제하지 않고 재사용할 수 있도록 메모리 풀을 구현할 때 유용하다.
배치 new 연산자에 대응하는 operator new는 다음과 같다. 주의할 점은 이 operator new에 대한 연산자 오버로딩은 금지된다.

void* operator new(size_t, void* p) noexcept;
void* operator new[](size_t, void* p) noexcept;

delete-표현식과 operator delete

직접 호출할 수 있는 delete-표현식은 두 개(delete, delete[]) 뿐이다. 예외를 던지지 않는 nothrow나 배치 버전은 없다. 그러나 operator delete는 여섯 가지가 있는데, delete-표현식과 operator delete의 표현식이 맞지 않는 이유는 nothrow 버전 두 개와 배치 버전 두 개는 생성자에서 익셉션이 발생할때만 사용되기 때문이다. 익셉션이 발생하면 생성자를 호출하기 전 메모리 할당에 사용했던 operator new에 대응되는 operator delete가 호출된다.
여섯 가지 버전의 operator delete의 원형은 다음과 같다.

void operator delete(void* ptr) noexcept;
void operator delete[](void* ptr) noexcept;
void operator delete(void* ptr, const std::nothrow_t&) noexcept;
void operator delete[](void* ptr, const std::nothrow_t&) noexcept;
void operator delete(void* ptr, void*) noexcept;
void operator delete[](void* ptr, void*) noexcept;

2. operator new와 operator delete 오버로딩

전역 함수 버전인 operator new와 operator delete는 필요에 따라 오버로딩할 수 있다. 이 함수는 프로그램에 new-표현식, delete-표현식이 나올 때마다 호출된다. 단, 클래스마다 이보다 구체적인 루틴이 정의돼 있다면 호출되지 않는다.
이러한 전역 함수가 아닌 특정한 클래스에 대해서만 오버로딩하는 것이 좋다. 해당 클래스의 객체를 할당하거나 해제할 때만 이 연산자가 호출되게 할 수 있다.
아래는 배치 버전이 아닌 네 가지 operator new/delete를 클래스에 대해 오버로딩한 예이다. 전역 버전의 연산자에 단순히 인수를 전달하는 방식으로 구현하였다.

#include <new>
#include <iostream>

class SampleClass {
public:
	virtual ~SampleClass() = 0;

	// new SampleClass(); / delete sampleClass;
	void* operator new(size_t size);
	void operator delete(void* ptr) noexcept;

	// new SampleClass[n]; / delete[] sampleClasses;
	void* operator new[](size_t size);
	void operator delete[](void* ptr) noexcept;

	// new (nothrow) SampleClass(); / delete sampleClass;
	void* operator new(size_t size, const std::nothrow_t&) noexcept;
	void operator delete(void* ptr, const std::nothrow_t&) noexcept;

	// new (nothrow) SampleClass[n]; / delete[] sampleClasses;
	void* operator new[](size_t size, const std::nothrow_t&) noexcept;
	void operator delete[](void* ptr, const std::nothrow_t&) noexcept;

};

void* SampleClass::operator new(size_t size) {
	std::cout << "My operator new" << std::endl;
	// 전역 버전의 연산자 호출
	return ::operator new(size);
}
void SampleClass::operator delete(void* ptr) noexcept {
	std::cout << "My operator delete" << std::endl;
	
	return ::operator delete(ptr);
}
void* SampleClass::operator new[](size_t size) {
	std::cout << "My operator new[]" << std::endl;
	
	return ::operator new[](size);
}
void SampleClass::operator delete[](void* ptr) noexcept {
	std::cout << "My operator delete[]" << std::endl;
	
	return ::operator delete[](ptr);
}
void* SampleClass::operator new(size_t size, const std::nothrow_t&) noexcept {
	std::cout << "operator new nothrow" << std::endl;
	
	return ::operator new(size, std::nothrow);
}
void SampleClass::operator delete(void* ptr, const std::nothrow_t&) noexcept {
	std::cout << "operator delete nothrow" << std::endl;

	return ::operator delete(ptr, std::nothrow);
}
void* SampleClass::operator new[](size_t size, const std::nothrow_t&) noexcept {
	std::cout << "operator new[] nothrow" << std::endl;

	return ::operator new[](size, std::nothrow);
}
void SampleClass::operator delete[](void* ptr, const std::nothrow_t&) noexcept {
	std::cout << "operator delete[] nothrow" << std::endl;

	return ::operator delete[](ptr, std::nothrow);
}

주의할 점은 operator new를 오버로딩할 때 반드시 이에 대응되는 operator delete도 오버로딩해야 한다. 그렇지 않으면 메모리 할당과 다르게 해제할 때는 C++ 기본 동작에 따라 처리하기에 할당 로직과 맞지 않을 수 있기 때문이다.

또한 한 가지는 모든 버전의 operator new를 오버로딩할 필요가 있다는 것이다. 이렇게 하는 것이 메모리 할당 방식의 일관성을 유지하는데 도움이 되기 때문이다. 일부 버전에 대한 구현을 생략할 경우 =delete로 명시적으로 삭제한다.

class SampleClass {
public:
    void* operator new(size_t size) = delete;
    void* operator new[](size_t size) = delete;
}

이렇게 명시적으로 삭제하면 new, new[]로 이 클래스 객체를 동적으로 생성할 수 없게 된다(참고로 디폴트 설정도 가능하다). 따라서 아래와 같은 코드는 컴파일 에러가 발생한다.

int main() {
    SampleClass* sampleClass = new SampleClass;
    SampleClass* pArray = new SampleClass[2];
    reuturn 0;
}

3. operator new와 operator delete 매개변수를 추가하도록 오버로딩

operator new를 표준 형태 그대로 오버로딩할 수 있을 뿐 아니라 매개변수를 원하는 형태로 추가해서 오버로딩할 수 있다. 매개변수를 추가하면 자신이 정의한 메모리 할당 루틴에 다양한 플래그나 카운터를 전달할 수 있다. 예를 들어 추가된 매개변수로 객체가 할당된 지점의 파일 이름과 줄 번호를 받아서 메모리 누수가 발생하면 문제가 되는 문장을 알려줄 수 있다.
예를 들어 정수 매개변수를 추가한 버전의 operator new와 operator delete의 원형은 다음과 같다.

void* operator new(size_t size, int extra);
void operator delete(void* ptr, int extra) noexcept;

이렇게 매개변수를 추가해서 operator new를 오버로딩하면 컴파일러는 이에 대응되는 new-표현식을 알아서 찾아준다. new에 추가한 매개변수는 함수 호출 문법에 따라 전달된다.

SampleClass* sampleClass = new(5) SampleClass();
delete sampleClass;

operator new에 매개변수를 추가해서 정의할 때 이에 대응되는 operator delete도 반드시 똑같이 매개변수를 추가해서 정의해야 한다. 이 버전의 operator delete를 직접 호출할 순 없고, 매개변수를 추가한 버전의 operator new를 호출할 때 그 객체의 생성자에서 익셉션을 던져야 호출된다.

4. operator delete에 메모리 크기를 매개변수로 전달하도록 오버로딩

operator delete를 오버로딩할 때 해제할 대상을 가리키는 포인터 뿐 아니라 해제할 메모리 크기도 전달하게 정의할 수 있다. 여섯 가지 operator delete를 모두 메모리 크기에 대한 매개변수를 받는 버전으로 만들 수 있는데, 아래 예는 첫 번째 버전의 operator delete를 삭제할 메모리의 크기를 매개변수로 받는 버전으로 정의한 클래스이다.

class SampleClass {
public:
    ...
    void* operator new(size_t size);
    void operator delete(void* ptr, size_t size) noexcept;
    ...
};

이 기법은 복잡한 메모리 할당 및 해제 매커니즘을 클래스에 직접 정의할 때 유용하다.

한가지 주의할 점은 매개변수를 받지 않는 operator delete와 동시에 선언하면 항상 매개변수가 없는 버전이 호출된다. 따라서 크기에 대한 매개변수가 있는 버전을 사용하려면 그 버전만 정의한다.

1개의 댓글

comment-user-thumbnail
2024년 1월 15일

좋은 글 감사합니다 :)

답글 달기