C++/Study

[C++] 메모리 할당 이야기

MoongStory 2024. 12. 13. 18:57
반응형

출처 - http://cafe.naver.com/cppmaster/2229

C++ 문법중 어려운 것중 하나가 메모리 관련 기법들 입니다.

이번에는 C++의 메모리 할당 관련 내용을 총정리 해 보도록 하겠습니다.

참고 자료는 아래 표와 같습니다.

책 제목
참고
More Effective C++
항목 8
Effective C++
항목 16, 49, 50, 51, 52
The C++ Programming Language
19.4
Exceptional C++
항목 35, 36
Modern C++ Design
4장
C++ 표준 문서 (ISO 14882:2003)
1.7, 3.7.3, 5.3.4, 5.3.5, 12.5, 20.4

되도록 틀린 내용이 없도록 하기 위해서 C++표준 문서(ISO 14882:2003)를 통해서 모든 내용을 검증 하긴 했는데,

그래도 잘못되거나 빠진 부분이 있을수 있으니 잘못을 발견하시면 댓글을 달아 주세요.

1. new, delete 연산자의 기본 동작

C++에서는 동적으로 메모리를 할당하기 위해서는 new 연산자를 사용하고, 할당된 메모리를 해지 하기 위해서는 delete 연산자를 사용합니다.

간단한 예제가 아래에 있습니다.

Point *p = new Point;

delete p;

 

이때 new 연산자가 하는 일은 대략 아래와 같습니다.

( 1 ) C++ 이 전역적으로 제공하는 함수인 operator new() 함수를 사용해서 Point 크기의 메모리를 할당 합니다.

( 2 ) (1)의 메모리 할당이 성공한 경우 Point 객체의 생성자를 호출합니다.

또한 delete 연산자는

( 1 ) Point 의 소멸자를 호출합니다.

( 2 ) p가 가르키는 메모리를 해지 하기 위해서 operator delete()함수를 호출 합니다.

여기서 중요한 사실은 new 연산자와 operator new() 함수는 분명히 다르다는 사실입니다.

new 연산자가 내부적으로 사용하는 함수가 operator new() 가 됩니다.

또한, operator new() 함수를 바로 호출해서 메모리만 할당하는 방법도 가능합니다.

아래의 예제를 실행해 보면 됩니다.

class Point
{
public:
	<? xml : namespace prefix = o /> Point() { cout << "Point::Point()" << endl; }
	~Point() { cout << "Point::~Point()" << endl; }
};
int main()
{
	// Point 객체를 생성하고 파괴한다. - 생성자와 소멸자가 호출된다.
	Point *p1 = new Point; // (A)
	delete p1;

	// Point의 크기만큼의 메모리를 할당한다.
	Point *p2 = (Point *)operator new(sizeof(Point)); // (B)
	operator delete(p2);
}

 

(A)의 경우 "메모리 할당과 생성자 호출" 의 2가지의 작업을 하지만 (B)는 단순히 메모리 할당만을 하기 때문에 생성자가 호출되지 않는 것을 볼 수 있습니다.

delete 도 마찬가지 입니다.

2. set_new_handler, std::bad_alloc

표준화 이전에는 new 연산자가 메모리 할당에 실패 할경우 null 포인터를 리턴했습니다.

하지만, 현재 표준은 std::bad_alloc 예외를 던지게 되어 있습니다.

정확히는 operator new()함수가 예외를 던지는 것입니다.

operator new() 함수의 모양은 아래와 같습니다.

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

 

결국 아래와 같이 메모리 할당 실패를 처리 하면 됩니다.

int main()
{
	int *p = 0;

	try
	{
		p = new int[10000];
	}
	catch (std::bad_alloc &e)
	{
		cout << e.what() << endl;
	}

	delete p;
}

 

또한, 사용자는 아래 함수를 사용해서 메모리 할당에 실패한 경우 bad_alloc 예외 대신 특정 함수를 실행하게 할 수 있습니다.

typedef (*new_handler)();

new_handler set_new_handler(new_handler handler) throw();

 

set_new_handler() 의 리턴값은 이전에 등록된 new_handler 의 함수 주소입니다.

이럴경우 operator new() 함수가 메모리 할당에 실패할 경우 set_new_hander()로 등록된 함수가 실행된 후 다시 메모리 할당을 시도하게 됩니다.

void my_handler()
{
	cout << "no memory" << endl;
	exit(-1);
}
int main()
{
	// 메모리 할당에 실패한 경우 예외 대신 my_handler 가 수행되도록 한다.
	set_new_handler(my_handler);

	int *p = new int[10000];

	// ......

	delete[] p;
}

 

결국 operator new() 함수의 정확한 동작 방식은 다음과 같습니다.(아래의 코드를 참고 해서 보시면 됩니다.)

A) 무한 루프를 수행합니다. 루프 안에서 요청된 크기의 메모리 할당을 시도 합니다.

B) 요청한 메모리 할당에 성공한다면 주소를 리턴합니다. - 함수 종료.

C) 메모리 할당에 실패 하고 set_new_handler()로 등록된 함수가 없을경우 std::bad_alloc 예외를 던집니다(throw) - 함수 종료

D) 메모리 할당에 실패 하고 등록된 new handler가 있을경우 해당함수를 호출합니다. new handler 함수의 수행이 종료 되면 다시 루프의 처음으로 돌아가서 메모리 할당 과정을 반복합니다.

또한, operator new() 함수는 인자로 전달 받은 크기가 0 이라 하더라더 적법한 포인터를 리턴해 주어야 하는 규칙이 있습니다.

결국, 모든 것을 고려해 볼때 operator new() 함수의 대략적은 구현은 아래와 같이 생각해 볼수 있습니다.

void *operator new(std::size_t size) throw(std::bad_alloc)
{
	if (size == 0)
	{
		size = 1;
	}

	while (1)
	{
		void *p = (메모리 할당을 시도);

		if (p)
		{
			return p;
		}

		// 등록된 new_handler가 있는지 확인합니다.
		// 아무 값이나 인자로 해서 함수를 호출한 후 리턴값을 확인해서 설치된 new_handler가 있는지 확인하기 위한 코드 입니다.
		new_handler handler = set_new_handler(0);
		set_new_handler(handler);

		if (handler)
		{
			(*handler)(); // 핸들러를 수행합니다.
		}
		else
		{
			throw std::bad_alloc();
		}
	}
}

 

여기서 중요한 사실은 핸들러 함수를 수행하고 다시 루프의 처음으로 돌아가서 메모리 할당을 시도 하도록 되어 있다는 점입니다.

따라서, 사용자가 set_new_handler()로 제공하는 함수는 반드시 아래의 동작중에 하나를 하도록 제공해야 합니다.

A) 메모리를 확보 할수 있는 어떤 조치를 해 놓습니다. 그래야만 함수가 종료 되면 operator new()로 돌아가서 다시 메모리 할당을 시도 하게 됩니다.

B) std::bad_alloc 또는 std::bad_alloc 에서 파생된 타입의 예외를 던집니다.

C) abort() 또는 exit()을 사용해서 종료 합니다.

또한 oeprator delete() 함수는 다음과 같은 조건을 만족해야 합니다.

A) null pointer를 delete하는 것도 유효 해야 합니다.

B) 절대 예외를 던져서는 안됩니다.

 

void operator delete(void *p) throw()
{
	if (p == 0)
	{
		return;
	}

	// 메모리를 해지합니다.
}

 

 

 

3. overloading operator new()

사용자는 operator new() 함수를 재정의 할수 있습니다. 또한, 인자를 2개 이상 가지도록 만들수도 있습니다. 즉,

// 단지 Test를 위한 간단한 구현입니다.
void *operator new(std::size_t size) throw(std::bad_alloc)
{
	cout << "operator new : " << size << endl;
	<? xml : namespace prefix = o /> return malloc(size);
}
void *operator new(std::size_t size, const char *p, int n) throw(std::bad_alloc)
{
	cout << "operator new : " << size << ", " << p << ", " << n << endl;
	return malloc(size);
}
void operator delete(void *p) throw()
{
	cout << "operator delete" << endl;
	free(p);
}
int main()
{
	int *p1 = new int;
	int *p2 = new ("AAA", 1) int;

	delete p1;
	delete p2;
}

 

이때 2번째 인자 부터는 new( ) 안에 넣어주면 됩니다. 즉, 아래 표현은

new("AAA", 1) int;

 

1번째 인자인 size_t 로는 int 의 크기인 4, 2번째 인자로 문자열 "AAA", 3번째 인자로 1을 전달하는 표현 입니다.

operator new()를 재정의 할때 지켜야 할 몇가지 규칙이 있습니다.

(A) 1번째 인자는 반드시 std::size_t type 가 되어야 합니다. 또한, 1번째 인자는 디폴트 인자값을 가질수 없습니다.

(B) operator new()함수는 전역 함수 또는 클래스의 static 멤버 함수로만 만들수 있습니다.(이 이야기는 나중에 다시 나옵니다.)

namespace 안에 만들수 없습니다.

또한, operator new()함수를 재정의 할 때는 앞의 글 2장에서 배운 기본 동작을 충실하게 구현하는 것이 좋습니다.

왜, operator new()를 재정의 하는가에는 여러 가지 이유가 있습니다. 일단 다른 이야기를 먼저 하고 나중에 차례차례 다양한 기법을 정리 해보도록 하지요.

4. new(nothrow)

new 연산자는 메모리 할당에 실패할 경우에 기본적으로 std::bad_alloc 예외를 던지도록 되어 있습니다.

하지만 new(nothrow) 버전을 사용하면 예외 대신 null Pointer를 리턴 받도록 할 수 있습니다.

int main()
{
	int *p1 = 0;
	int *p2 = 0;

	try
	{
		p1 = new int; // 할당에 실패할 경우 std::bad_alloc을 던집니다.
	}
	catch (std::bad_alloc &)
	{
	}
	p2 = new (nothrow) int; // 할당에 실패할 경우 null pointer를 리턴합니다.
	if (p2 == 0)
	{
	}
}

 

new(nothrow)때문에 과거 표준화 이전의 코드(이때는 new 가 실패시 0을 리턴했습니다.)를 최신 버전의 컴파일러로 컴파일 할 때 아주 편리하게 사용 할수 있습니다.

// 표준화 이전의 코드는 아래처럼 에러를 처리했습니다.
int main()
{
	int *p1 = new int;

	if (p1 == 0)
	{
		// 에러 처리
	}

	delete p;
}

 

위 코드를 컴파일 하기위해서는 단지 코드의 제일 윗줄에 아래의 1줄만 추가하면 됩니다.

#define new new(nothrow)

 

new(nothrow)버전의 원리는 아래와 같습니다.

// 기본버전 - 할당 실패 시 예외를 던집니다.
void *operator new(std::size_t size) throw(std::bad_alloc)
{
	// 메모리 할당 실패 시 std::bad_alloc을 던집니다.
}
class nothrow_t
{
};
nothrow_t nothrow;
// nothrow 버전
void *operator new(std::size_t size, const nothrow_t &) throw(std::bad_alloc)
{
	// 메모리 할당 실패 시 null pointer(0)를 리턴하도록 구현합니다.
}

 

이때 주의 깊게 봐야할 점은 empty class 를 사용하는 기법 입니다.

class nothrow_t {};

 

C++에서는 위처럼 아무 구현도 없는 empty 클래스를 사용하는 경우가 많은데, 이 경우 nothrow_t도 하나의 타입이므로 template 인자나 함수 오버로딩으로 사용될 수 있다는 특징이 있습니다.

위의 경우가 함수 오버로딩을 위해서 empty class를 사용하는 것입니다.

물론, C++ 표준으로 제공되므로 사용자가 별도로 만들어 사용할 필요는 없습니다.

5. placement new

이번에는 C++ 초보 시절에 어려워 하는 요소 중 하나인 위치지정(placement) new 를 생각해 보도록 하지요.

C++에서는 생성자를 명시적으로 호출하는 것은 에러입니다.

class Test
{
public:
	Test() { cout << "Test()" << endl; }
	~Test() { cout << "~Test()" << endl; }
};
int main()
{
	Test t;

	t.Test(); // Error. 생성자는 명시적으로 호출할 수 없습니다.
}

 

하지만 아래 처럼 operator new()함수를 사용하면 이미 할당된 메모리에 대해 나중에 생성자를 호출할 수 있습니다.

class Test
{
public:
	Test() { cout << "Test()" << endl; }
	~Test() { cout << "~Test()" << endl; }
};
// placement new - 설명을 위해서 만들었습니다. C++표준에서 제공 되므로 따로 구현할 필요는 없습니다.
void *operator new(std::size_t size, void *p) throw()
{
	return p;
}
int main()
{
	Test *p = (Test *)malloc(sizeof(Test)); // 메모리만 할당. 생성자가 호출되지 않습니다.

	new (p) Test; // 이 순간 생성자가 호출됩니다.

	p->~Test(); // 소멸자 호출

	free(p);
}

 

원리는 new가 하는 2가지의 기본 동작을 잘생각하면 됩니다.

(A) operator new()를 호출해서 메모리를 할당한다. - 이경우 operator new(std::size_t, void*)버전이 호출되는데, 내부적으로는 메모리 할당을 수행하지 않고 단지 2번째 인자로 전달된 포인터를 그대로 리턴해주고 있습니다.

(B) operator new()가 유효한 포인터를 리턴했으므로 해당 주소에 대해 생성자가 호출됩니다.

즉, 메모리를 추가 할당하는것이 아니라 생성자만 호출하는 것입니다.

또한 소멸자의 호출은 명시적 호출이 가능하므로 아래 처럼 호출하면 됩니다.

p->~Test();

 

이와 같은 placement new를 사용하면

(A) 메모리 할당과 객체생성(생성자 호출)을 분리하므로서 다양한 메모리 할당 전략을 사용할수 있게 됩니다.

(B) 리눅스나 윈도우 프로그램에서 사용하는 공유메모리등에 객체를 생성할 수 있습니다.

(C) vector의 reserve 같은 개념을 오버헤드 없이 구현 할 때 편리 합니다.

아래의 예제를 생각해 봅시다.

class Test
{
public:
	Test() { cout << "Test()" << endl; }
	~Test() { cout << "~Test()" << endl; }
	Test(const Test &p) { cout << "Test(const Test&)" << endl; }
};
int main()
{
	vector<Test> v; // 크기가 0입니다. 메모리 할당이 필요없습니다.
	// 20의 Test를 담을 메모리 공간(capacity)을 확보합니다. 단지 메모리만 있으면 됩니다.
	// 생성자가 호출될 필요는 없습니다.
	v.reserve(20);

	// vector의 크기를 10으로 합니다. 준비해놓은 20개의 메모리 중에서 10개에 객체를 생성해야하므로 메모리 할당 없이 10개의 객체에대해 생성자를 호출합니다.
	v.resize(10);

	// 다시 크기를 5로 줄입니다. 메모리가 줄어들지는 않습니다. 하지만 객체는 파괴되어야 합니다. 소멸자만 5번 호출합니다.
	v.resize(5);

	cout << "------------" << endl;
}
반응형