스토리텔링 개발자

[More Effective C++] 27. 힙 전용, 힙 불가 클래스 만들기 본문

개발/More Effective C++

[More Effective C++] 27. 힙 전용, 힙 불가 클래스 만들기

김디트 2024. 9. 6. 13:20
728x90

항목 27. 힙(heap)에만 생성되거나 힙에는 만들어지지 않는 특수한 클래스를 만드는 방법

 

 

 

객체가 힙에만 생성되게 하기
  • 암시적 객체 생성 / 소멸(변수 선언에 의해 생성자가 자동으로 호출되고 유효범위의 종료에 의해 소멸자가 호출되는 것)을 불법화 시킨다.
  • 생성자와 소멸자를 private로 선언한다.
    • 생성자를 private화 하는 건 너무 과한 감이 있다. 만약 기존의 것에 적용한다면 인터페이스를 과하게 해친다.
    • 그래도 생성자를 private로 하고자 한다면 큰 단점이 있는데..
      • 너무 많은 생성자를 고려해야 한다는 점이다.
      • 컴파일러가 자동 생성하는 생성자는 public이기 때문이다.
  • 소멸자만 private로 선언한다.
    • 유사 소멸자(소멸자 기능을 담는 함수)를 만들어서 객체를 소멸시킬 때는 이 유사 소멸자를 호출하도록 한다.
class UPNumber
{
public:
    UPNumber();
    UPNumber(int initValue);
    UPNumber(double initValue);
    UPNumber(const UPNumber& rhs);
    
    // 유사 소멸자
    // const인 이유는, 상수 객체도 소멸될 수 있기 때문이다.
    void destroy() const { delete this; }
    ...
private:
    ~UPNumber(); // 소멸자 private 선언
};

UPNumber n; // 에러! 암시적으로 소멸자가 호출될 때 불법이 된다.

UPNumber* p = new UPNumber; // 성공
...
delete p; // 에러! 소멸자 호출 불가

p->destroy(); // 성공
  • 생성자나 소멸자의 접근을 제한하는 방법은 다 좋지만, 클래스 상속과 합성이 불가능하다는 문제가 있다. (항목 26 참조)
// 상속의 경우
// 에러! 소멸자나 생성자가 컴파일되지 않는다.
class NonNegativeUPNumber : public UPNumber
{ ... };

// 합성의 경우
class Asset
{
private:
    // 에러! 소멸자나 생성자가 컴파일되지 않는다.
    UPNumber value;
    ...
};
class UPNumber
{
protected:
    ~UPNumber() { ... }
}

// 상속의 경우
// 문제 없음.
// 하지만 힙에 생성되었다는 조건을 만족시켰다고 할 수 있을까?
class NonNegativeUPNumber : public UPNumber { ... };

// 합성의 경우
class Asset
{
public:
    Asset(int initValue) : value(new UPNumber(initValue)) { ... }
    ~Asset() { value->destroy(); }
    ...
private:
    UPNumber* value; // 포인터. 문제 없음.
};

 

 

 

어떤 객체가 힙에 생성되었는지 확인하기
  • 위 코드에서 NonNegativeUpNumber은 문제 소지가 있다.
NonNegativeUPNumber n; // 컴파일 에러가 나지 않는다.
// 하지만 이 경우 UPNumber 부분은 힙에 생성되지 않았다.
  • 결국 UPNumber의 파생 클래스를 모두 힙에 생성되도록 제약을 가하고 싶다면 어떻게 해야 할까?
  • 생성자에서 힙 기반인지 아닌지 알아낼 수 있으면 쉽겠지만..
    • 아쉽게도 그런 방법은 없다.
  • 그래도 operator new를 재정의해서 잘 만지면 가능할 거 같은데?
class UPNumber
{
public:
    class HeapConstraintViolation {}; // 힙 기반이 아닐 때를 위한 예외
    
    // operator new 재정의
    static void* operator new(size_t size);
    
    UPNumber();
    ...
private:
    static bool onTheHeap; // 힙인지 아닌지에 대한 플래그
};

bool UPNumber::onTheHeap = false;

void* UPNumber::operator new(sizt_t size)
{
    // 힙에 메모리를 마련할 때 플래그를 켠다.
    onTheHeap = true;
    return ::operator new(size);
}

UPNumber::UPNumber()
{
    if(!onTheHeap) // 메모리 할당이 없었으면 예외 발생
        throw HeapContraintViolation();
        
    ...
    
    onTheHeap = false; // 플래그 초기화
}
  • 문제점
    1. 배열 메모리는 operator new[]에 의해 할당된다.
      • operator new[] 도 재정의하면 된다고 생각할 수 있다. 하지만..
    2. 배열 메모리는 생성자는 배열 크기만큼 호출되지만, operator new[]는 한번만 호출된다.
      • onTheHeap은 한번만 활성화되지만, 생성자는 여러번 호출되므로 논리적으로 오류.
      • 배열만 아니라면 문제가 없을까? 아쉽게도 아니다.
    3. 할당이 한 문장에서 일어나면 operator new의 호출 순서를 보장할 수 없다.
      • // 이렇게 호출될 경우
        UPNumber* pn = new UPNumber(*new UPNumber);
        
        // 이렇게 될 걸 예상하겠지만..
        // 1. 제일 왼쪽의 것의 operator new가 호출
        // 2. 제일 왼쪽의 것의 생성자가 호출
        // 3. 다음 것의 operator new가 호출
        // 4. 다음 것의 생성자가 호출
        
        // 아쉽게도 실제로는 컴파일러에 따라 다른 사항이다.
        // C++ 언어 스펙에 규정이 없기 때문이다.
        // 즉, 다음처럼 될 여지가 있다!
        
        // 1. 제일 왼쪽의 것의 operator new가 호출
        // 2. 다음 것의 operator new가 호출
        // 3. 제일 왼쪽의 것의 생성자가 호출
        // 4. 다음 것의 생성자가 호출
  • 이식성을 무시한 방법
  • 대부분의 시스템이 프로그램의 주소 공간을 연속된 주소로 배열한다는 사실을 활용한다면?

  • 위의 그림대로라고 가정한다면, 메모리가 힙 영역에 있는지를 알아낼 수 있지 않을까?
// 해당 주소값이 힙 영역인지 알아내는 함수
// 바람직하지는 않다..
bool onHeap(const void* address)
{
    char onTheStack; // 스택에 지역 변수를 만들어 보고
    
    return address < &onTheStack; // 주소값 위치를 비교
}
  • 문제점
    • 객체가 생성되는 장소는 세 가지이다.
      • 힙 / 스택 / 정적 객체
      • 정적 객체는 전역 객체와 네임스페이스 안에 정의된 객체까지 모두 포함한다.
      • 보통 위와 같은 메모리 시스템이라고 가정한다면, 정적 객체는 힙의 아래쪽에 둔다.
      • 그렇다면 onHeap으로 정적 객체도 체크되는 버그가 있다고 볼 수 있다.

char* pc = new char;
onHeap(pc); // true 반환

char c;
onHeap(c); // false 반환

static char sc;
onHeap(sc); // true 반환
  • 힙에 할당되는지 알아내는 이식성 있는 방법은 없다. 포기하자.
  • 만약 힙 영역에 있는지 알고 싶은 이유가, delete를 해도 괜찮은지 알고 싶기 때문이라면..
    • 이 두 가지는 완전히 동일한 의미가 아니라는 걸 상기하자.
    • 왜냐하면 힙에 있다고 해서 100% 안전하게 삭제할 수 있는 것은 아니기 때문이다.
class Asset
{
private:
    UPNumber value;
    ...
};

// pa는 힙에 생성된다.
Asset* pa = new Asset;

// 그러므로 pa->value 역시 힙에 있다.

// 하지만 이건 재앙이다.
delete &pa->value;
  • 다행히 어떤 포인터가 삭제해도 괜찮은 포인터인지 알아내는 방법은 훨씬 간단하다.
    • operator new에서 지금까지 할당된 주소들의 콜렉션을 유지하면 된다.
std::unordered_set<void*> heap_allocations;

void* operator new(size_t size)
{
    void* ptr = malloc(size);
    
    // 콜렉션에 저장
    heap_allocations.insert(ptr);
    
    return ptr;
}

void operator delete(void* ptr) noexcept
{
    // 콜렉션에서 제거
    heap_allocations.erase(ptr);
    
    free(ptr);
}

bool isSafeToDelete(const void* ptr)
{
    // 콜렉션에 존재하는지 체크
    return heap_allocations.find(ptr) != heap_allocations.end();
}
  • 문제점
    • 전역 함수는 객체지향적으로 볼땐 아무래도 껄끄럽다.
      • operator new와 operator delete를 또 새로 구현한 다른 소프트웨어와 함께 작동할 수 없게 된다.
    • 힙 할당을 할 때마다 콜렉션을 업데이트하는 것은 효율적이진 않다.
    • isSafeToDelete 함수를 모든 경우에 대해 동작하도록 구현하는 것은 불가능하다.
      • 다중 상속으로 만들어진 객체나 가상 기본 클래스로부터 만들어진 객체는 주소도 여러개이다.
      • operator new에서 반환된 주소가 isSafeToDelete로 넘어간 주소가 같으리라는 보장이 없다.
  • 위의 기능을 유지하되, 문제점들만 해결할 방법이 있다.
  • 추상 믹스인 기본 클래스(abstract mixin base class)
    • 믹스인(mix in) 클래스
      • 명확한 기능을 딱 하나만 제공하는 클래스이다.
      • 이 클래스의 파생 클래스가 제공할지도 모르는 다른 기능과 호환되도록 설계된다.
class HeapTracked
{
public:
    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<RawAddress> HeapTracked::addresses;

HeapTracked::~HeapTracked() {}

void* HeapTracked::operator new(size_t size)
{
    void* memPtr = ::operator new(size):
    address.push_front(mempPtr); // 메모리 할당 시 컬렉션에 추가
    return memPtr;
}

void HeapTracked::operator delete(void* ptr)
{
    list<RawAddress>::iterator it = find(addresses.begin(), addresses.end(), ptr);
    if(it != addresses.end())
    {
        addresses.erase(it); // 컬렉션에 있으면 삭제 후 정상 동작
         ::operator delete(ptr);
     }
     else
     {
         throw MissingAddress(); // 예외 발생
     }
 }
 
 bool HeapTracked::isOnHeap() const
 {
     // 다중 상속 ,가상 상속을 통해 만들어진 객체는 여러개의 주소를 가진다.
     // 하지만 이 함수는 HeapTracked 객체에 대해서만 적용되기 때문에
     // dynamic_cast를 통해 해결해 볼 수 있다.
     // - const void*로 캐스트하여 이 객체의 가장 앞 부분 포인터를 얻어온다.
     // dynamic_cast는 가상 함수를 적어도 하나 가진 클래스에 대해서만 적용 가능하므로
     // 전역에서는 불가능한 사용 방법이다.
     const void* rawAddress = dynamic_cast<const void*>(this);
     
     // 컬렉션에서 찾는다.
     list<RawAddress>::iterator it = find(addresses.begin(), addresses.end(), ptr);
     
     return it != addresses.end();
 }
 
 // 사용
 class Asset : public HeapTracked
 {
 private:
     UPNumber value;
     ...
 };
 
 Asset* ap;
 ...
 if(ap->isOnHeap())
     // ap는 힙 기반 객체다.
 else
     // ap는 힙 기반 객체가 아니다.

 

 

 

객체가 힙에 생성되지 않게 하기
  • 고려해야 하는 경우의 수
    • 객체가 직접 인스턴스화 되는 경우
    • 파생 클래스의 기본 클래스 부분으로 들어가는 경우(상속)
    • 다른 객체의 멤버로 들어가는 경우(합성)
  • 객체가 직접 인스턴스화 되는 경우
    • operator new를 접근 제한한다.
class UPNumber
{
private:
    static void* operator new(size_t size);
    
    // 특별한 이유가 없으면 delete도 함께 같은 성질로 선언한다.
    static void operator delete(void* ptr);
    ...
}
// 물론 operator new[]와 operator delete[] 역시 private 선언해야 할 것이다.
  • 파생 클래스의 기본 클래스 부분으로 들어가는 경우(상속)
    • 이는 첫 번째를 해결하면 자연스럽게 해결된다.
    • 왜냐하면 operator new와 operator dlete는 상속이 가능한 함수이기 때문이다.
    • 파생 클래스에서 억지로 public으로 다시 선언하지 않는 한 기본 버전의 것이 상속된다.
class NonNegativeUPNumber : public UPNumber { ... };

NonNegativeUPNumber n1; // 문제 없음
static NonNegativeUPNumber n2; // 문제 없음

NonNegativeUPNumber* p = new NonNegativeUPNumber; // 컴파일 에러!
  • 다른 객체의 멤버로 들어가는 경우(합성)
    • 이 경우는 걸러지지 않는다.
    • 이를 거르기 위한 이식성 있는 방법은 없다.
// 이 경우는 힙 공간에 할당되어 있지만 문제 없다.
class Asset
{
public:
    Asset(int initValue);
    ...
private:
    UPNumber value;
};

Asset* pa = new Asset(100); // 문제 없음
728x90
Comments