프로그래밍 언어/Effective C++

[Effective C++]항목 49~52: new와 delete

우향우@ 2023. 6. 11. 00:23

항목 49 : new 처리자

new처리자는 new로 메모리 할당에 실패했을때 메모리 확보를 위해 시도되는 함수이다.

표준 new의 경우 다음과 같이 처리된다.

 

1. 매개변수로 할당할 바이트 사이즈를 받는다.

2. 바이트 사이즈가 0이면 1로 바꾼다

3. while(true)

4. malloc으로 사이즈만큼 할당시도

5. 성공시 할당된 포인트 리턴

6. 실패시 설정된 new 처리자 호출(new 처리자가 null 함수 포인터로 설정되있다면 throw bad_alloc)

7. 예외가 던져지거나 성공할때까지 반복

 

프로그래머는 <new>의 set_new_handle에 void (void) 함수 포인터를 넣어 new 처리자를 자신이 만든 것으로 설정할 수 있다. 이때 리턴값은 기존 함수 포인터이다. new 처리자는 다음중 하나를 수행해야한다.

1. 메모리 확보

2. 다른 new 처리자 설정

3. new 처리자를 null로 설정

4. 예외 throw

5. 프로그램 종료

 

클래스 별 new처리자

언어 수준에서 클래스별 new 처리자를 제공해주지는 않는다. 따라서 우리는 직접 코드를 짜 다음과 같이 구현한다.

1. 클래스에 static 함수 포인터 멤버를 하나두고 여기에 해당 클래스에 사용할 new 처리자를 넣을 수 있도록 한다.

2. 클래스에서 operator new를 오버라이드하여 set_new_handle에 자신의 처리자를 설정하고 기존 것을 저장해둔다.

3. 전역 new로 할당은 진행하고 처리자를 다시 기존것으로 복구하여 리턴한다.

 

여기서 new 처리자 복구를 자원 관리 객체를 통해 하면 정상 처리및 예외 발생 모두의 경우에 정상적으로 처리자가 복구되도록 할 수 있다. 자원 관리 객체에 기존 함수 포인터를 저장하고 소멸자에 해당 포인터를 set_new_handler에 넣어 호출하도록 구현하면 된다.

 

CRTP

위의 구현을 위해선 static 멤버하나와 set함수를 위한 static 함수, static operator 오버라이드 하나를 코딩해주어야 한다. 이를 템플릿을 통해 편하게 구현할 수 있다. 위 기능들을 모두가진 타입 매개변수 하나를 받는 템플릿을 만든다. 이때 템플릿 내부에서는 T를 전혀 쓰지 않는다. new operator같은 경우도 코드상 전역 new에 매개변수 size만 넘겨주는데 이는 그냥 자동으로 정상 처리 되기에 추가적인 코딩이 필요가 없다. 참고로 부모에서 size를 받는 new operator가 구현되어 있고 자식이 이를 상속받았다 할때 부모를 생성하면 size에 부모의 size가, 자식을 생성할 땐 자식의 size가 정상적으로 넘어간다.

그 다음 각 클래스에서 자신을 타입 매개변수로 넘긴 해당 템플릿을 상속받아 자신을 정의한다. 이는 각 클래스 별로 별도의 부모 클래스 코드가 생성되도록 하여 별도의 static 멤버를 가질 수 있도록 하기 위함이다. 이런식으로 자신의 타입을 넘겨 부모를 만드는 기법을 CRTP라고 한다. 각 자식들은 자신만을 위한 부모 코드를 가지게 된다.

 

nothrow

new (std::nothrow) Widget과 같이 할당하면 할당 실패시 예외가 아닌 null포인터를 리턴하는 식으로 처리된다. 이는 표준으로 제공되는 위치지정 new(추가적인 매개변수를 받는 new) 중 nothrow타입을 하나 받는 new이다. 위치지정 new는 이후 항목52에서 자세히 설명한다.

 

항목 50 : 사용자 정의 new, delete

항목 49에서 봤듯이 우리가 직접 new와 delete를 정의할 수 있다. 이는 void포인터에 대해 비멤버 operator로 선언되어 만들어지는 전역 new, delete도 있으며 위 처럼 각 클래스 별로 정의되는 클래스 new, delete도 있다.

항목 49의 경우에는 그냥 다른 new(표준 new)를 호출하는 식으로 구현되었지만 정석적으로 new를 만들때는 다음과 같이 설계된다.

 

(위의 표준 new sudo 동작과 동일)

1. 매개변수로 할당할 바이트 사이즈를 받는다.

2. 바이트 사이즈가 0이면 1로 바꾼다

3. while(true)

4. malloc으로 사이즈만큼 할당시도

5. 성공시 할당된 포인트 리턴

6. 실패시 설정된 new 처리자 호출(new 처리자가 null 함수 포인터로 설정되있다면 throw bad_alloc)

7. 예외가 던져지거나 성공할때까지 반복

 

항목 49에서 설명한 표준 new의 sudo동작과 동일하다. 이는 표준 new가 기본적인 요구사항들을 지키며 만들어 졌기 때문이다. 물론 실제 표준 new는 이보다 훨씬 복잡하지만 핵심적인 sudo 수준으로 표현했기에 통일되었다.

 

사용자 커스텀 new및 delete를 쓰는 이유에는 대표적으로 다음과 같은 것들이 있다.

잘못된 힙사용 감지

new에서 요구된 바이트보다 앞뒤로 몇 바이트씩 더 할당하고 고유값을 넣은 뒤 논리적 데이터 시작 할당 위치를 리턴하여 사용하도록 한다. delete에서 해제전 앞뒤 고유값을 보고 오버플로가 없었는지 확인 할 수 있다.

동적 할당 사용 통계

효율 증가를 위해 new를 직접 만들거나 사용할 new를 선택하기전에 실제 프로그램의 동작에서 new와 delete의 사용 경향을 분석할 필요가 있다. 이럴때 로그를 남기는 new를 사용하면 유용하다.

효율 향상

할당 및 해제 속도 증가

공간 오버헤드 감소

바이트 정렬 동작 강화(이후 추가 설명)

관련 객체들 지역성 증가

기타

공유 메모리 사용, 보안을 위한 해제시 0 채우기 등등 

 

바이트 정렬

new는 다음과 같은 규칙을 따라야한다. 각 객체는 int의 경우 4바이트 단위, double의 경우 8바이트 단위 등 자신의 사이즈 단위의 주소에 할당되어야 한다.

위 잘못된 힙사용 감지에서 할당할 데이터 앞뒤로 고유값을 추가한다고 했다. 만약 앞뒤로 4바이트 씩 int를 추가했다고하자. malloc의 경우 위의 바이트 정렬 규칙을 자동으로 맞춰 주기에 만약 double을 할당한다고 하면 8바이트 단위에 맞춰 할당될 것이다. 이때 원래 데이터 사이즈 보다 8바이트 크게 할당하여 앞뒤 4바이트를 int로 채우고 원래 할당 받은 주소 +4를 데이터 시작 주소로 리턴하여 구현될텐데 이렇게 되면 double이 적절하지 않은 시작주소로 할당되어 사용되는 셈이다. 따라서 위 설계는 문제가 있으며 new를 설계할때는 이러한 바이트 정렬을 신경써야한다. 바이트 정렬이 실패할 경우 시스템에 따라 에러를 띄우거나 성능이 저하된다.

위 new delete를 새로 만드는 이유중 바이트 정렬 동작 강화가 있는데 이는 일부 바이트 정렬 실패시 성능저하되는 시스템에서 8바이트 정렬은 포기하는 경우가 있다. 이때 성능 향상을 위해 우리가 8바이트 정렬을 만들어 줄 수 있다.

 

항목 51 : new 및 delete 관례

사용자 커스텀 new 및 delete를 만들때는 몇가지 관례가 있다. 이는 위에서 설명한 표준적인 동작에 다 나타나있다.

new

1. 할당된 메모리 주소를 리턴해야한다.

2. 메모리 부족 시 루프를 통해 new 처리자를 호출해야한다.

3. 0바이트 할당에 대한 처리가 있어야한다.

delete

1. 널 포인터 해제시 그냥 아무 것도 안하고 return한다.

 

new 및 delete의 상속

어떤 클래스에서 new나 delete를 만들면 이를 상속받는 클래스들도 자연스래 이것으로 처리하게 된다. 이때 상속받는 클래스들은 이 new와 delete를 처리하도록 하지 않고 싶다면 맨앞에 할당된 사이즈와 sizeof(부모)를 비교하여 같지 않다면 자식을 간주하고 기본 전역 new나 delete를 쓰도록 하면된다.

 

항목 52 : 위치 지정 new , delete

위치 지정 new는 size외에 추가적인 매개변수를 받는 new이다. new (a1, a2, a3) 타입 으로 호출 할 수 있다.

만약 다음과 같이 객체가 할당된다고 하자.

Widget *pw = new Widget

이때 두개의 함수가 호출된다. new, widget의 생성자. 만약 new가 정상적으로 끝나고 widget의 생성자에서 예외가 발생한다면 런타임 시스템에서는 자동으로 new에 대응되는 delete를 잘 찾아서 호출 해준다. 하지만 new가 위치지정 이라면 이와 동일한 추가 매개변수를 같는 delete를 찾아 호출하려고 한다. 만약 존재하지 않는다면 delete는 호출되지 않고 메모리 누수가 발생한다. 따라서 operator new(size, int)로 위치지정 new를 만들었다면 반드시 delete(void*, int)도 만들어 주어야한다.

추가로 표준 전역 new는 기본적으로 세가지가 주어진다.size 하나짜리, size와 void*, size와 nothrow를 받는 세가지 new이다. 그리고 이들에 대응되는 delete도 존재한다. 만약 클래스가 자신의 new와 delete를 하나라도 만들었다면 표준 new와 delete들은 가려져 사용되지 못한다. 그렇기에 사용하고 싶다면 똑같은 시그니처의 new와 delete를 만들고 표준의 것을 그대로 호출하도록 하자.

 

 

 

 

 

참고서적

Effective C++