스토리텔링 개발자

[More Effective C++] 29. 참조 카운팅 본문

개발/More Effective C++

[More Effective C++] 29. 참조 카운팅

김디트 2024. 9. 27. 11:18
728x90

항목 29. 참조 카운팅(Reference Counting)

 

 

 

참조 카운팅이란
  • 여러 개의 객체들이 똑같은 값을 가졌으면, 각각이 하나의 데이터를 공유하게 하여, 데이터의 양을 절약하는 기법이다.

 

 

 

참조 카운팅의 동기
  1. 힙 객체의 관리를 편하게 하기 위함이다.
    • 힙에 할당된 객체는 소유권을 추적하는 일이 상당히 까다롭다.
    • 하지만 참조 카운팅을 사용하면 소유권을 일일이 추적하지 않아도 된다.
  2. 똑같은 값을 가지고 있는 객체들이 값을 각각 가지게 되면 낭비이다.

 

 

참조 카운팅 기본 구현법
class String
{
public:
    // 생성자
    String(const char* initValue = "");
    String(const String& rhs);
    
    // 소멸자
    ~String();
    
    // 대입 연산자
    String& operator=(const String& rhs);
    
    // [] 연산자
    const char& operator[](int index) const; // 상수 String 객체에 대한 [] 연산자
    char& operator[](int index); // 비상수 String 객체에 대한 [] 연산자
    
private:
    // 참조 카운트 + 실제 문자열을 가지는 구조체
    struct StringValue
    {
        int refCount;
        char* data;
        
        StringValue(const char* initValue);
        ~StringValue();
    };
    
    StringValue* value;
};

// 생성자
String::String(const char* initValue) : value(new StringValue(initValue)) {}

// 복사 생성자
String::String(const String& rhs) : value(rhs.value)
{
    ++value->refCount;
}

// 소멸자
String::~String()
{
    if(--value->refCount == 0)
        delete value;
}

// 대입 연산자
String& String::operator=(const String& rhs)
{
    // 이미 같은 값이면 아무것도 하지 않는다.
    if(value == rhs.value)
        return *this;
        
    // 대입 이전의 값을 사용하는 객체가 없으면 value 삭제
    if(--value->refCount == 0)
        delete value;
        
    value = rhs.value;
    ++value->refCount;
    
    return *this;
}

// 상수 [] 연산자
// 상수이므로 별다른 처리 없이 값을 리턴해도 좋다.
const char& String::operator[](int index) const
{
    // 정식으로 구현하려면 index 유효성 검사가 필요할 것이다.
    return value->data[index];
}

// 비상수 [] 연산자
// 리턴값을 사용자가 맘껏 수정할 수 있으므로 구현이 까다롭다.
// 무조건 쓰기 동작을 사용할 것으로 가정할 수밖에 없다.
// 안전한 구현 아이디어 : StringValue 객체 참조 카운트가 1일때만 쓸 수 있게 하자.
// 기록 시점 복사(copy-on-write)
char& String::operator[](int index)
{
    // 기존에 여러 String이 참조하고 있는 상황이라면
    if(value->refCount > 1)
    {
        // 자신의 참조를 제거하고
        --value->refCount;
        
        // 새 문자열 사본을 준비한다.
        value = new StringValue(value->data); // refCount는 1이다.
    }
    
    // refCount가 1인 값만 리턴하게 된다.
    return value->data[index];
}
        

// 구조체 생성자
String::StringValue::StringValue(const char* initValue) : refCount(1)
{
    data = new char[strlen(initValue) + 1];
    strcpy(data, initValue);
}

// 구조체 소멸자
String::StringValue::~StringValue()
{
    delete[] data;
}
  • 기록시점 복사(copy-on-write)
    • 지연 평가 기법의 특수한 종류이다.(항목 17 참조)
    • 운영체제에서 많이 사용하는 기법이다.

 

 

 

문제점
String s1 = "Hello";
char* p = &s1[1]; // e를 가리킨다.

String s2 = s1; // 참조 카운팅만 올라간다.

*p = 'x'; // s1, s2 둘 다 "Hxllo"로 변경된다!!
  • 포인터 뿐 아니라, 마찬가지로 비상수 operator[]에 대해 참조자로 저장했을 때도 마찬가지.
  • 해결 방법
    1. 문제를 무시한다.
      • 꽤 많은 문자열 참조 카운팅이 그냥 덮어놓고 모른척 한다..
    2. 불법으로 규정한다.
      • 사용 문서에 주의 사항을 적어두는 수준이지만..
    3. StringValue 객체에 그 객체의 공유가능 여부를 표시하는 플래그를 넣는다.
      • 처음에는 공유가능 플래그가 켜져있다.
      • 비상수 operator[]가 호출될 때 이 플래그를 끈다.
      • 플래그가 꺼지면, 꺼진 상태가 끝까지 유지된다.
class String
{
private:
    struct StringValue
    {
        int refCount;
        bool shareable; // 이것이 추가되었다.
        char* data;     
        ...
    };
...
};

// 복사 생성자
String::String(const String& rhs)
{
    if(rhs.value->shareable) // 공유 가능할 때만 참조 카운팅
    {
        value = rhs.value;
        ++value->refCount;
    }
    else // 아니면 객체 생성
    {
        value = new StringValue(rhs.value->data);
    }
}

// 비상수 operator[]
char& String::operator[](int index)
{
    if(value->refCount > 1)
    {
        --value->refCount;
        value = new StringValue(value->data);
    }
    
    value->shareable = false; // 이것이 추가되었다! 공유 기능을 끈다.
    return value->data[index];
}

 

 

 

 

참조 카운팅 기능을 가진 기본 클래스
  • 범용적으로 사용할 수 있는 참조 카운팅이 있으면 좋을텐데.
  • 그 첫 단추로 참조 카운팅 기본 클래스를 만들어보자.
class RCObject
{
public:
    RCObject();
    RCObject(const RCObject& rhs);
    RCObject& operator=(const RCObject& rhs);
    virtual ~RCObject() = 0; // 순수 가상 함수이므로 기본 클래스로만 쓰인다.
    
    // refCount 추가 제거
    void addReference();
    void removeReference();
    
    // 참조 가능성 플래그 조작
    void markUnshareable();
    bool isShareable() const;
    bool isShared() const;
    
private:
    int refCount;
    bool shareable;
};

RCObject::RCObject() : refCount(0), shreable(true) {}
RCObject::RCObject(const RCObject&) : refCount(0), shareable(true) {}
RCObject& RCObject::operator=(const RCObject&)
{
    // 아무 일도 하지 않는다? 아래에서 설명.
    return *this;
}
RCObject::~RCObject() {} // 다형성을 위한 가상 소멸자 body(항목 33 참조)

void RCObject::addReference() { ++refCount; }
void RCObject::removeReference()
{
    // delete는 힙객체일 때만 안전하다는 문제가 있다.
    if(--refCount == 0) delete this;
}
void RCObject::markUnsahreable() { shareable = false; }
bool RCObject::isShareable() const { return shareable; }
bool RCObject::isShared() const { return refCount > 1; }
  • 생성자에서 refCount를 0으로 하는 이유
    • 해당 RCObject를 새로 만드는 쪽에서 이 값을 관리해 준다고 가정했기 때문이다.
    • 위의 코드를 보면, String 쪽에서 StringValue의 refCount를 관리하고 있다.
  • operator=가 아무 일도 하지 않고 그저 자신을 리턴하는 이유
    • 위의 기본 클래스는 refCount만을 관리할 뿐이다.
    • 헌데, 대입으로 인해 refCount가 변화될 이유가 하등 없다.
    • // String 끼리의 대입이 아니라
      // StringValue 끼리의 대입... 발생할 수도 있을 것이다.
      sv1 = sv2; // 이 경우 sv1과 sv2의 참조 카운트는 어떻게 변화할까?
    • sv1 : 해당 대입으로 인해 sv1을 가리키는 String 객체의 개수가 바뀌진 않으므로 그대로 유지해주면 된다.
    • sv2 : 마찬가지로 sv2를 가리키는 String 객체의 개수는 변하지 않는다.
  • StringValue를 RCObject를 상속받도록 바꿔 보자.
class String
{
private:
    struct StringValue : public RCObject
    {
        char* data;
        StringValue(const char* initValue);
        ~StringValue();
    };
    ...
};

String::StringValue::StringValue(const char* initValue)
{
    data = new char[strlen(initValue) + 1];
    strcpy(data, initValue);
}

String::StringValue::~StringValue()
{
    delete[] data;
}

 

 

 

참조 카운트 조작을 자동화하기
  • 위에서 만든 RCObject 클래스는 참조 카운트를 조작할 수 있는 함수가 들어가 있으나, 일일이 이를 호출해 주어야 하는 번거로움이 있다.
    • StringValue 객체의 addReference / removeReference를
    • String의 복사 생성자와 대입 연산자에서 직접 호출해 주어야 한다.
  • 아이디어는, 객체를 가리키는 포인터 중 하나에 '특정 사건'이 생길 때마다 refCount를 조작하는 것이다.
class String
{
private:
    struct StringValue : public RCObject { ... };
    
    StringValue* value; // 여기에 특정 사건이 생길 때마다 참조 카운트 조작.
    // 어떻게 캐치할 수 있을까?
};
  • 방법은 바로 스마트 포인터를 사용하는 것이다.
    • 특정 사건(대입 등)이 있을 때 init() / remofeReference() 함수를 호출함을 유의 깊게 보자.
// RCObject 하위 클래스의 raw 포인터를 관리하는 스마트 포인터
template<typename T>
class RCPtr
{
public:
    RCPtr(T* realPtr = 0);
    RCPtr(const RCPtr& rhs);
    ~RCPtr();
    
    RCPtr& operator=(const RCPtr& rhs);
    
    T* operator->() const;
    T& operator*() const;
    
private:
    T* pointee;
    
    void init();
};

template<typename T>
RCPtr<T>::RCPtr(T* realPtr) : pointee(realPtr) { init(); }

template<typename T>
RCPtr<T>::RCPtr(const RCPtr& rhs) : pointee(rhs.pointee) { init(); }

// 공유 불가능 여부 때문에 addReference를 한번 래핑한 함수라 할 수 있다.
template<typename T>
void RCPtr<T>::init()
{
    if(pointee == nullptr)
        return; // raw 포인터가 널이면 스마트 포인터도 invalid
        
    if(pointee->isShareable() == false)
        pointee = new T(*pointee); // 공유 플래그가 꺼져 있으면 복사 생성
        // 하지만 이 부분에서 문제가 있다.
        // 깊은 복사가 가능하도록 복사 생성자가 선언되어 있지 않다면...
        // 실제로 StringValue의 경우 복사 생성자가 없으므로 char* 포인터가 복사될 뿐이다.
        // 이는 큰 문제!! StringValue에 사용자 정의 복사 생성자를 추가해 줄 것.
    
    pointee->addReference(); // 새 참조자가 생겼으므로 pointee에 대해 refCount 추가
}

template<typename T>
RCPtr<T>& RCPtr<T>::operator=(const RCPtr& rhs)
{
    if(pointee != rhs.pointee) // pointee가 같으면 아무 처리 안함
    {
        T* oldPointee = pointee; // 이전 pointee 임시 저장
        pointee = rhs.pointee;
        
        init(); // refCount 조작 혹은 공유 불가 시 객체 생성
        
        if(oldPointee)
            oldPointee->removeReference(); // 이전 pointee refCount 조작
        
        return *this
    }
}

template<typename T>
RCPtr<T>::~RCPtr()
{
    if(pointee)
        pointee->removeReference(); // 이 함수가 소멸자의 모든 동작을 가진다.
}

// raw 포인터 동작을 흉내내기 위한 역참조 연산자들
template<typename T>
T* RCPtr<T>::operator->() const { return pointee; }

template<typename T>
T& RCPtr<T>::operator*() const { return *pointee; }
  • 이제 위를 기반으로 String 클래스를 구성해 보면..
class String
{
public:
    String(const char* value = "");
    
    const char& operator[](int index) const;
    char& operator[](int index);
    
private:
    struct StringValue : public RCObject
    {
        char* data;
        
        StringValue(const char* initValue)
        StringValue(const StringValue& rhs); // 스마트 포인터를 위한 복사 생성자
        void init(const char* initValue);
        ~StringValue();
    };
    
    RCPtr<StringValue> value; // 스마트 포인터를 사용한 stringvalue
};

// StringValue 구현
void String::StringValue::init(const char* initValue)
{
    data = new char[strlen(initValue) + 1];
    strcpy(data, initValue);
}
String::StringValue::StringValue(const char* initValue) { init(initValue); }
String::StringValue::StringValue(const StringValue& rhs) { init(rhs.data); }
String::StringValue::~StringValue() { delete[] data; }

// String 구현
String::String(const char* initValue) : value(new StringValue(initValue)) {}
const char& String::operator[](int index) const { return value->data[index]; }
char& String::operator[](int index)
{
    // 수정 가능한 역참조 연산자는 스마트 포인터로 감싸지 못했다.
    if(value->isShared())
        value = new StringValue(value->data);
    
    value->markUnshareable();
    
    return value->data[index];
}
  • String 클래스의 복사 생성자, 대입 연산자, 소멸자 정의가 사라졌다?
    • 왜냐하면 이제 컴파일러가 생성해주는 버전이 RCPtr의 것을 알아서 호출해줄 것이기 때문이다.
    • 그러므로 사용자 정의 버전은 굳이 필요가 없다.

 

 

 

구현을 수정할 수 없는 기존의 클래스에 참조 카운팅 기능울 부착하기
  • 예컨대 어떤 클래스 라이브러리의 Widget 클래스에 참조 카운팅 기능을 붙이고 싶다면?
  • Widget을 합성하고, RCObject를 상속받는 래퍼 클래스를 사용하여 해결하자!
template<typename T>
class RCIPtr
{
public:
    RCIPtr(T* realPtr = nullptr);
    RCIPtr(const RCIPtr& rhs);
    ~RCIPtr();
    
    RCIPtr& operator=(const RCIPtr& rhs);
    
    const T* operator->() const;
    T* operator->();
    const T& operator*() const;
    T& operator*();
    
private:
    // 참조 카운팅 기능을 가지는 래퍼 클래스
    struct CountHolder : public RCObject
    {
        ~CountHolder() { delete pointee; }
        T* pointee;
    }
    
    CountHolder* counter;
    
    void init();
    void makeCopy();
};

template<typename T>
void RCIPtr<T>::init()
{
    if(counter->isShareable() == false)
    {
        T* oldValue = counter->pointee;
        counter = new CountHolder;
        counter->pointee = new T(*oldValue);
    }
    
    counter->addReference();
}

template<typename T>
RCIPtr<T>::RCIPtr(T* realPtr) : counter(new CountHolder)
{
    counter->pointee = realPtr;
    init();
}

template<typename T>
RCIPtr<T>::RCIPtr(const RCIPtr& rhs) : counter(rhs.counter) { init(); }

template<typename T>
RCIPtr<T>::~RCIPtr() { counter->removeReference(); }

template<typename T>
RCIPtr<T>& RCIPtr<T>::operator=(const RCIPtr& rhs)
{
    if(counter != rhs.counter)
    {
        counter->removeReference();
        counter = rhs.counter;
        init();
    }
    return *this;
}

template<typename T>
void RCIPtr<T>::makeCopy() // copy-on-write의 복사부를 구현
{
    if(counter->isShared()) // 공유 중인 포인터라면
    {
        // 기존 참조 제거
        T* oldValue = counter->pointee;
        counter->removeReference();
        
        // 새로운 객체 생성
        counter = new CountHolder;
        counter->pointee = new T(*oldValue);
        counter->addReference();
    }
}

// 역참조 연산자 구현
template<typename T>
const T* RCIPtr<T>::operator->() const { return counter->pointee; }
template<typename T> // 쓰기 가능 버전
T* RCIPtr<T>::operator->() { makeCopy(); return counter->pointee; }
template<typename T>
const T& RCIPtr<T>::operator*() const { return *(counter->pointee); }
template<typename T> // 쓰기 가능 버전
T& RCIPtr<T>::operator*() { makeCopy(); return *(counter->pointee); }
  • RCIPtr과 RCPtr의 차이
    1. RCPtr은 값을 직접 가리키지만, RCIPtr은 CountHolder 객체가 값을 가리키게 하고, CountHodler를 가리킨다.
    2. RCIPtr은 역참조 연산자 내부에서 makeCopy()를 호출하여 쓰기 가능할 때 자동으로 기록시점 복사가 이루어지도록 하였다.
  • 위의 RCIPtr을 사용하여 RCWidget을 만들 수 있을 것이다.
class RCWidget
{
public:
    RCWidget(int size) : value(new Widget(size)) {}
    
    // 이런 식으로 Widget의 인터페이스를 노출할 수도 있다.
    void doThis() { value->doThis(); }
    int showThat() const {return value->showThat(); }
    
private:
    RCIPtr<Widget> value;
};

 

 

 

정리
  • 참조 카운팅은 무료가 아니다.
    • 참조 카운트 횟수 / 횟수 조작에 따른 코스트가 있다.
    • 추가 메모리와 복잡한 소스코드.
  • 하지만 참조 카운팅을 사용하면 그 비용을 지불하더라도 되려 실행 시간과 메모리 공간이 절약되는 상황이 있다.
  • 참조 카운팅이 효율 향상에 효과적일 수 있는 상황들
    • 많은 객체들이 값을 공유할 때
    • 객체 값을 생성하거나 소멸시키는 데 많은 비용이 들거나 메모리 소모가 클 때
  • 물론 이에 대해서는 프로파일링을 통해 확실히 확인할 것.(항목 16 참조)
  • 참조 카운팅을 사용할 때 유의해야 하는 상황
    • 자기 참조(self-referential), 원형 의존(circular dependency) 등이 발생할 수 있는 자료구조들
      • 방향성 그래프(directed graph), 트리(tree) 구조가 여기에 속한다.
      • 참조 카운트가 절대 0이 되지 않는 상황이 발생할 수 있다.
  • 참조 카운팅은 효율 뿐 아니라 객체 생성, 소멸을 트래킹할 때도 사용할 수 있다.
  • 추가적으로 확인할 사항
    • removeReference의 delete는 객체가 반드시 힙에 할당되었음을 가정하고 있다.
    • 그렇다면 반드시 new로 생성하도록 제약을 걸 필요성이 있을 것이다.
    • 위에서는 private 내부 클래스로 정의하여 이 제약을 거는 데 성공했다.
728x90
Comments