1.3.1. Requiring Heap-Based Objects : Heap 기반 객체들이 필요 ¶
우선 객체를 Heap영역 상에서만 생성되고 사용하는 객체로 제한하는 것에 관해서 생각해 보자. Heap영역에 자리를 차지하는 객체는 모두 new를 호출해서 한자리씩 하니, 뭐, 간단히 new를 막아 버리면 되는것이다. 그렇다면 반대로 Heap영역이 아닌 객체들은 모두 new를 호출하지 않고, 자동으로 묵시적 생성되고, 묵시적 파괴되어 진다는 의미가 됙ㅆ다.
Item 26에 다룬것과 같이 생성자와 파괴자를 사역(private)로 묶어 버리는 아이디어가 객체 생성 제한의 시작이 될수 있을 것이다. 하지만, 둘다 묶어 버리는건 너무 과잉이다. 그냥 파괴자만을 사역(private)로 묶어 버리고, 생성자는 공역(public)으로 놓아도 효과는 비슷하다. 다음의 예제를 보자.
~cpp
class UPNumber {
public:
UPNumber();
UPNumber(int initValue);
UPNumber(double initValue);
UPNumber(const UPNumber& rhs);
// 가짜 파괴자(pseudo-destructor) 상수 멤버 메소드,
// 상수 객체를 제거 할수 있게 하기 때문에
void destroy() const { delete this; }
...
private:
~UPNumber(); // 파괴자가 사역(private)으로 선언되었다.
};
클라이언트가 이렇게 선언된 클래스를 기반한 객체의 사용을 알아 보자면
~cpp
UPNumber n; // 에러! 파괴자 사역(private)이라 작동 할수 없다.
UPNumber *p = new UPNumber; // 통과
...
delete p; // 에러! 역시 파괴자 사역(private) 이유
p->destroy(); // 통과
자 다음과 같이,
UPNumber 클래스는 Heap상에서만 사용할수 있는 객체만을 생성 할수 있다. 이것의 대안으로는 Item 26 마지막에 나온 예제와 같이 모든 생성자 만을 사역(private)화 시키는 것이지만, 이 아이디어의 문제는 많은 생성자가 모두 사역(private)으로 있어야 하고, 그것들을 반드시 기억해야 한다는 점이다. 기본 생성자는 물론, 복사 생성자를 전부 선언해 주어야 한다. 그렇지 않으면 컴파일러는 기본적으로 모두 공역(public)으로 취급하고 지역 변수를 만들수 있다. 결과적으로, 파괴자만을 사역(private)화 시키는 것이 간단하다.
클래스의 생성자와, 파괴자 양쪽을 사역화(private)시켜서 지역 객체(non-heap object)로 만드는걸 막는데도, 약간의 제한 사항이 발생한다. 그 제한사항이란, 상속 관계와 포함(containment:has-a)관계에서 발생한다. 다음의 예제를 보면.
~cpp
class UPNumber { ... }; // 생성자와 파괴자 모두 사역(private)로
//선언되었다고 가정한다.
class NonNegativeUPNumber:
public UPNumber { ... }; // 에러! 생성자와 파괴자를 컴파일 못한다.
class Asset {
private:
UPNumber value; // 에러! 생성자와 파괴자를 컴파일 못한다.
...
};
이런 문제는 해결하기 어렵지 안하. 상속 관계의 문제는 생성자는 공역(public)으로 유지하고, 파괴자만을 보호(proteced) 관계로 바꾸면 되는것이고, 포함(contain) 관계에서는 해당 포함 인자를 pointer로 바꾸고, 초기화 리스트에서 생성해 버리고, 파괴자에서 약간만 신경써주면 된다. 위의 코드의 해결책은 다음과 같다.
~cpp
class UPNumber { ... }; // 파괴자를 보호(protected)로 설정한다.
// 대신 생성자는 공역(public)으로 설정한다.
// 기능상 상관 없으므로
class NonNegativeUPNumber:
public UPNumber { ... }; // 이제는 유도되는 데는 지장 없다.
// 유도되는 클래스들은 protected인자는
// 접근하는데 문제가 없다.
class Asset {
public:
Asset(int initValue);
~Asset();
...
private:
UPNumber *value; // 위에서의 객체 선언을 포인터로 바꾸었다.
};
Asset::Asset(int initValue)
: value(new UPNumber(initValue)) // 다음과 같이 초기화 리스트로 객체를 만들고
{ ... }
Asset::~Asset()
{ value->destroy(); } // 파괴시에 신경써준다.(resource leak방지)
1.3.2. Determining Whether an Object is On The Heap : Heap에 객체를 올릴지 결정하기. ¶
자, 지금까지 다소 맹목적(?)으로 Heap영역에 객체 올리기에만 열중했다. 그럼 여기에서는 "on the heap"의 의미를 확실한 테스트로서 알아 보도록 하겠다. 앞서 써먹은
NonNegativeUPNumber를 non-heap 객체로 만드는건 뭐 틀리지 않은 것이다.
~cpp
NonNegativeUPNumber n; // fine
이것이 허용된다는 것이다. 자, 그럼 지역 객체인
NonNegativeUPNumber의 n에서
UPNumber의 부분은 heap위에 있는 것이 아니다. 맞는가? 이 답변은 클래스의 설계(design)과 적용(implementation)에 기인해야 이해 할수 있을 것이다. 답을 찾아가 보자.
UPNumber가 반드시 heap영역에 존재 해야 한다는 것에 관해 not okay를 던지면서 시작해 보자. 어떻게 우리는 이러한 제한 사항을 해결해야 하는 걸까?
이것은 그리 쉽지 않다.
UPNumber 생성자가 이것을 유도 받은 모든 객체에게, Heap영역 기반의 객체로 만들어 버리라고 결정 하는것은 가능하지 않다. 기본 클래스로 쓰인
UPNumber의 생성자가 다음의 두 상황을 체크하는 것은 불가능하다.
~cpp
NonNegativeUPNumber *n1 = new NonNegativeUPNumber; // Heap영역
NonNegativeUPNumber n2; // 비 Heap영역
그렇자민 아마 조금 다른 방법의 접근을 할수 있다. 바로 Heap영역에 올라가는 객체는 항상 new를 호출하는것, 그리고 이 new의 호출은 new operator와 operator new와의 상호 작용에서 작업이 이루어 지는 것이다. 자세한 내용은 Item 8을 참고하고, 다음과 같이
UPNumber를 고쳐서 유도되는 객체들 마져 제한을 걸수 있다.
~cpp
class UPNumber {
public:
// 만약 non-heap 객체라면 다음의 예외를 발생 시킨다.
class HeapConstraintViolation {};
static void * operator new(size_t size);
UPNumber();
...
private:
static bool onTheHeap; // 생성자 내부에서 heap영역에 생성되는가 판별인자로 쓴다.
...
};
// static 인자의 의 초기화 부분
bool UPNumber::onTheHeap = false;
void *UPNumber::operator new(size_t size)
{
onTheHeap = true; // new operator가 불리면 Heap영역을 사용하는것.
return ::operator new(size); // operator new로서 메모리를 할당한다.
}
UPNumber::UPNumber()
{
if (!onTheHeap) {
throw HeapConstraintViolation(); // 힙이 아니라면 예외를 던진다.
}
일반적인 생성 과정을 기술한다.;
onTheHeap = false; // 객체 생성 플래스를 세팅한다.
}
operator new는 raw메모리 할당을 하고, 해당 메모리에서 생성자를 부르므로서 초기화를 수행한다. operator new에서 onTheHeap을 참으로 설정하여 주면, 생성자에서 이를 검사해서 예외 발생을 하지 않고, 일반 지역 변수로 객체가 선언되면 operator new를 거치지 않으므로, 기본 값인 false인해 생성자에서 예외를 발생시킨다.
하지만 이 코드다 능사가 아닌것이, 다음과 같은 객체의 배열을 선언하면 문제가 된다.
~cpp
UPNumber *numberArray = new UPNumber[100];
첫째로, 이 경우 operator new가 불리는 것이 아니라. 메모리는 operator new[]로 할당 되기때문에, 문제가 발생하는 것이고, 둘째로 operator new[]에 플래그 값을 주었다고 하더라도, 처음 한번의 operaotr new[]이후에 계속 생성자 100번이 불리면서 첫번째 생성자에서 다시 onTheHeap를 false로 초기화 시키기에, 이후에 불리는 생성자는 전부 onTheHeap이 false값으로 예외를 발생 시켜 버린다.
또 배열이 아니더라도 다음과 같은 경우도 생각해 본다.
~cpp
new UPNumber(*new UPNumber);
이 경우에는 두가지의 new를 가지고 있다. 그러므로 operator new도 두번 불리고 생성자 역시 두번 불릴 것이다. 프로그래머가 일반적으로 기대하는 다음 순서에서는 아무런 문제가 없다. (Item 8 참고)
- operator new가 첫번째 객체에 관해서 불린다.
- 첫번째 객체의 생성자가 불린다.
- 두번째 객체의 operator new 가 불린다.
- 두번째 객체의 생성자가 불린다.
그렇지만 C++언어 상에서 이런 보장에 대한 스펙이 정의 되어 있지 않다 그래서 어떤 컴파일러는 다음과 같이 부를수도 있다.
- 첫번째 객체의 operator new가 불린다.
- 두번째 객체의 operator new가 불린다.
- 첫번째 객체의 생성자가 불린다.
- 두번째 객체의 생성자가 불린다. : 여기서 문제가 발생한다.
후자의 순서로 코드가 생성되어도 컴파일러에게 잘못은 전혀 없다. 그렇지만
set-a-bit-in-operator-new 방법을 사용한 상단의 예제는 두번째 순서에서는 실패 할수 밖에 없다.
이런 어려움이 "각 생성자에서 *this가 heap영역에 있는가에 대한 여부를 알아낸다." 라는 아이디어의 근간을 흔드는 것은 아니다. 거기에다가 이런 어려움들은 operator new나 operator new[] 안에서 bit set을 점검해 보는 것이 이런 기본 정보를 결정하는데 신뢰성 있는 방법이 아님을 반증하고 있다. 우리가 필요한 방법을 위해서 한번 생각해 본다.
절망하고 있는가? 그럼 한번 임시로나마 이식성이 떨어지는 영역에서까지 그런 아이디어를 확장해서 생각해 보자. 예를 들어서 많은 시스템 상에서 사실인, 프로그램의 주소 공간은 선형으로 배열되고, 프로그램의 스텍은 위에서 아래로 늘어 난다고 그리고 Heap영역은 밑에서 위로 늘어난다는 사실에 주목해 보자. 그림으로 표현되면 다음과 같은 모습이 된다.
이런 방식으로 구성된 프로그램의 시스템에서 다음과 같은 방법으로 비교를 할수 있지 않을까?
~cpp
// heap에 대한 여부가 정확하지 않은 점검 방법
bool onHeap(const void *address)
{
char onTheStack; // 지역 스택 변수(local stack variable)
return address < &onTheStack; // 주소 비교
}
함수에서 이러한 생각은 참 의미롭다. onHeap함수내에서 onTheStack는 지역 변수(local variable)이다. 그러므로 그것은 스택에 위치할 것이고, onHeap가 불릴때 onHeap의 스텍 프레임은 아마 프로그램 스텍의 가장 위쪽에 배치 될것이다. 스택은 밑으로 증가하는 구조이기에, onTheStack는 방드시 어떠한 stack-based 변수나 객체에 비하여 더 낮은 위치의 메모리에 위치하고 있을 것이다. 만약 address 인자가 onTheStack의 위치보다 더 작다면 스택위에 있을수 없는 것이고, 이는 heap상에 위치하는 것이 되는 것이다.
이러한 구조는 옳다. 하지만 아직 충분히 생각하지 않은 것이 있는데, 그것은 객체가 위치할수 있는 세가지의 위치를 감안하지 않은 근본적인 문제이다. 지역(local) 변수,객체(variable, object)나, Heap영역 상의 객체는 감안해지만 빼먹은 하나의 영역 바로 정적(static)객체의 위치 영역이다.
이들의 위치는 전부 수행되는 시스템에 의존적이다. 하지만 많은 시스템이 stack와 heap이 서로를 향해 증가 하도록 구성되어 있으며, 가장 최하단에 static여역이 자리 잡는 구성으로 되어 있다. 그러므로 앞에 언급한 그림에서 한가지를 추가하면 진짜 메모리의 구성이 된다. 다음과 같이 말이다.:
갑자기 이제 앞서 작성한 onHeap함수가 어떠한 시스템에서도 정확한 수행을 못할 것이 명백해 진다.:heap 객체와 적적(static) 객체를 구별하지를 못한다. 예제에서 보면
~cpp
void allocateSomeObjects()
{
char *pc = new char; // heap 객체: onHeap(pc)
// 이것은 true를 반환
char c; // 스택(=:지역) 객체 object: onHeap(&c)
// 이 주소는 false 를 반환
static char sc; // 정적(static) 객체: onHeap(&sc)
// 이 주소는 true 를 반환
...
}
이제 heap 객체와 stack 객체를 판별하는 방법에 혼란이 올 것이다. 그리고 이러한 방법은 이식성에도 역시나 문제가 있다. 그래서 결국
compare-the-addresses 방법은 신뢰할수 없는 방법이 된다.
여담이라면, 이러한 방법은 대다수의 시스템에서 사실이지만, 꼭 그렇다고는 볼수 없다. 그러니까 완전히 not portable한게 아닌 semi-portable이라고 표현을 해야 하나. 그래서 앞머리에 잠시나마 생각해 보자고 한것이고, 이러한 시스템 의존적인 설계는 차후 다른 시스템에 이식시에 메모리의 표현 방법이 다르다면, 소프트웨어 자체를 다시 설계해야 하는 위험성이 있다. 그냥 알고만 있자.
우리는 앞쪽에서 "delete this"로 가상 파괴자로 객체가 스스로를 자살 시키는 방법으로 heap객체만을 사용하도록 제한 시키는 방법을 기억할 것이다. 이런 "delete this"식으로의 제거는 추천할 만한 방법이 결코 아니다. (
DeleteMe 모호) 그렇지만, 지우기 위한 객체의 안전성을 아는 것은 heap상에서 포인터가 지칭하는가를 간단히 알아네고자 하는 방법과 같은 것이 아니다. 자, 다시
UPNumber 객체를 가지는 Asset 객체의 관해서 생각해 보자.
~cpp
class Asset {
private:
UPNumber value;
...
};
Asset *pa = new Asset;
명백히 *pa는 heap상에 위치한 객체이다. 또 명백하게, pa->value의 포인터 역시 delete로 지울수 없다. 왜냐하면 이녀석이 new를 호출해서 만든것이 아니기 떄문이다.
포인터가 지우기 안전한가에 판단은, 포인터가 heap상에 위치하는 객체를 가리키는가를 알아내는 것보다 쉽다. 왜냐하면 우리는 operator new로 인해서 반환되는 주소의 모음으로, 전자의 질문에 관해서 알아 낼수 있기 떄문이다. 여기 예제에 그런 문제에 관한 접근 방식이 기술되어 있다.
~cpp
void *operator new(size_t size)
{
void *p = getMemory(size); // 메모리를 할당하는 어떤 함수를 호출한다.
// 그리고 이것은 out-of-memory 예외를 잡을수 있다.
할당된 주소를 collection에 저장한다.;
return p;
}
void operator delete(void *ptr)
{
releaseMemory(ptr); // 메모리를 free한다.
이번에 지운 주소를 collection에서 제거한다.;
}
bool isSafeToDelete(const void *address)
{
물어보는 주소가 collection상에 존재하는지 여부를 반환한다.;
}
이러한 것은 간단하다. operator new는 collection에 메모리를 할당하는 주소를 기록하고, operator delete는 그것을 지운다. 그리고 isSafeToDelete는 collection에 해당 주소가 있는지 알려주는 역할을 한다. 만약 operator new와 operator delete가 전역 공간에 있다면 이것은 모든 타입의 작업시에 적용 될것이다.
실제로 세가지 생각이 이러한 디자인을 매달리지 못하게 한다. 첫번째는 전역 공간에 어떤것을 정의하는 극도로 피하려는 경향이다. operator enw나 operator delete같은 미리 정의된 함수에 대하여 특별하게 고친다는 것은 더 그럴 것이다. 둘째로 효율에 관한 문제이다. 모든 메모리 할당에서 overhead가 발생한다는 의미인데, 이것을 유지하겠는가? 마지막으로 걱정되는 것은 평범하지만 중요한 것으로 isSafeToDelete이 모든 수행에 관하여 적용되는 적용하는 것이다. 하지만 이것이 근본적으로 불가능하다고 보이기 때문이다. 조금더 이약 해보자면, 다중 상속이나, virtual base class가 가지는 여러게의 주소들, 이 주소들 때문에 isSafeTo Delete에게 전달되는 주소에 대한 확실한 보장이 없다. 자세한 내용은 Item 24, 31일 참고하라.
자 위의 이유로 이번 아이디어도 쓰레기통으로 들어갈 참이다. 하지만 그 아이디어를 채용해서 C++에서는 abstract base class를 제공할수 있다. 여기서는 abstract mixin base class라고 표현한다.
가상 클래스라고 해석 될수 있는 abstract base 는 초기화 될수가 없다. 덧붙여 말하자면, 이것은 순수 가상 함수를 최소한 하나 이상은 가지고 있어야만 한다. mixin("mix in")클래스는 잘 정의된 기능과 상속되는 어떤 클래스와도 잘 부합되도록 설계되어져 있다. 이런 클래스들은 거의 항상 abstract이다. 그래서 여기에서는 abstract mixin base class는 용도로 operator new로 객체의 할당 여부만 알수 있는 능력만을 유도된 클래스에게 제공한다.
~cpp
class HeapTracked { // mixin class; keeps track of
public: // ptrs returned from op. new
class MissingAddress{}; // 예외 클래스;아래 참고
virtual ~HeapTracked() = 0;
static void *operator new(size_t size);
static void operator delete(void *ptr);
bool isOnHeap() const;
private:
typedef const void* RawAddress;
static list<RawAddress> addresses;
};
이 클래스에서 list데이터 구조체는 C++의 라이브러리에 정의되어 있다.(Item 35참고) list가 하는 일은 예상되는 것과 같이 operator new에서 반환된 주소의 저장이다. 그리고 operator delete는 메모리를 해제하고, list로 부터 해당 주소의 엔트리를 지운다.
HeapTrack 적용은 간단하다. 왜냐하면, operator new와 operator delete가 실제 메모리 할당과 해제를 수행하고 list 클래스는 삽입, 지우기, 그리고 검색 엔진을 포함하기 때문이다. 자 여기 이런 내용의 적용에 관련한 내부 코드를 살피자.
~cpp
// static 클래스 멤버의 정의는 규칙이다.
list<RawAddress> HeapTracked::addresses;
// HeapTracked의 파괴자는 가상 클래스의 순수 가상 함수이다. (E14참고)
// 그래서 이 파괴자가 구현되어야 만한다.
HeapTracked::~HeapTracked() {}
void * HeapTracked::operator new(size_t size)
{
void *memPtr = ::operator new(size); // 메모리 할당
addresses.push_front(memPtr); // 해당 주소를 list의 암쪽에 저장
return memPtr;
}
void HeapTracked::operator delete(void *ptr)
{
// "iterator"를 얻어서 list에서 검색하는 부분 Item 35참고
list<RawAddress>::iterator it = find(addresses.begin(), addresses.end(), ptr);
if (it != addresses.end()) { // 지울 주소를 찾아서
addresses.erase(it); // 해당 엔트리를 지우고
::operator delete(ptr); // 메모리를 해제하고
} else { // 하지만
throw MissingAddress(); // ptr에 할당이 안되었다면 예외를 던진다.
}
}
bool HeapTracked::isOnHeap() const
{
// *this로 할당받은 메모리의 시작점을 얻는 과정;자세한건 밑에 본문
const void *rawAddress = dynamic_cast<const void*>(this);
// 주소 list에서 pointer를 찾고 operator new에 의해 반환된다.
list<RawAddress>::iterator it = find(addresses.begin(), addresses.end(), rawAddress);
return it != addresses.end(); // return whether it was
} // found
이코드는 약간 생소한 list클래스에 대한 것이 보일 것이다. 뭐, 이제는 하도 많이 나와서 별로 안생소 할것 같다. STL이고 Item 35에 모든것이 설명 되어 있다. 하지만 주석을 바탕으로 예제에 관하여 충분히 설명 가능하리라고 본다.
멤버 메소드 isOnHeap에 존재하는 이 구문이 다소 의문이 들것이다.
~cpp
const void *rawAddress = dynamic_cast<const void*>(this);
(
DeleteMe 모호 )
위에서 isSafeToDelete를 구현할때 다중 상속이나 가상 기초 함수으로 여러개의 주소를 가지고 있는 객체가 전역의 해당 함수를 복잡하게 할것이라고 언급했다. 그런 문제는 isOnHeap에서 역시 마찬가지이다. 하지만 isOnHeap는 오직
HeapTracked객체에 적용 시킨 것이기 때문에, dynamic_cast operatror를 활용으로 위의 문제를 제거한다. 간단히 포인터를 dynamic_cast 하는 것은 (혹은 const void* or volatile void* or 알맞는 것으로 맞추어서) 객체의 가장 앞쪽 포인터, 즉, 할당된 메모리의 가장 앞쪽에 주소를 가리키는 포인터를 의미한다. 그렇지만 dynamic_cast는 가상함수를 하나 이상 가지는 객체를 가리키는 포인터에 한해서만 허용 된다. isSafeToDelete함수는 모든 포인터에 관해서 가능하기 때문에 dynamic_cast가 아무런 소용이 없다. isOnHeap는 조금더 선택의 폭이 있어서 this를 const void*로 dynamic_cast하는 것은 우리에게 현재 객체의 메모리 시작점의ㅣ 포인터를 주게 된다. 그 포인터는
HeapTracked::operator new가 반드시 반환해야만 하는 것으로
HeapTrack::operator new의 처음 부분에 있다. 당신의 컴파일러가 dynamix_cast를 지원하면 이러한 기술은 이식성이 높다.
이러한 클래스가 주어지고, BASIC 프로그래머는 이제 heap 에 할당하는 것을 추적할수 있는 능력을 클래스에게 부여 할수 있다. 추적을 원하는 클래스는 반드시
HeapTracked를 상속하도록 만든다. 예를들어서 우리가 Asset객체를 사용할때 해당 객체가 heap-base 객체인지 알고 싶다면 다음과 같이
~cpp
class Asset: public HeapTracked {
private:
UPNumber value;
...
};
이제 우리는 Asset* 포인터에 관해서 객체의 상태를 물어 볼수 있다.
~cpp
void inventoryAsset(const Asset *ap)
{
if (ap->isOnHeap()) {
ap 는 heap-based asset 이다.
}
else {
ap 는 non-heap-based asset 이다.
}
}
이 mixin 객체의 단점이라면 int나 char따위의 built-in 형에는 써먹지를 못하는 것이다. 이것들은 상속을 할수 없기 때문이다.