일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |
Tags
- 비교 함수 객체
- 영화
- exception
- Smart Pointer
- 스마트 포인터
- 반복자
- 참조자
- 다형성
- 메타테이블
- Effective c++
- resource management class
- more effective c++
- 예외
- 상속
- 영화 리뷰
- 오블완
- 티스토리챌린지
- 암시적 변환
- effective stl
- lua
- virtual function
- 게임
- reference
- Vector
- UE4
- 언리얼
- implicit conversion
- operator new
- 루아
- c++
Archives
- Today
- Total
스토리텔링 개발자
[More Effective C++] 29. 참조 카운팅 본문
728x90
항목 29. 참조 카운팅(Reference Counting)
참조 카운팅이란
- 여러 개의 객체들이 똑같은 값을 가졌으면, 각각이 하나의 데이터를 공유하게 하여, 데이터의 양을 절약하는 기법이다.
참조 카운팅의 동기
- 힙 객체의 관리를 편하게 하기 위함이다.
- 힙에 할당된 객체는 소유권을 추적하는 일이 상당히 까다롭다.
- 하지만 참조 카운팅을 사용하면 소유권을 일일이 추적하지 않아도 된다.
- 똑같은 값을 가지고 있는 객체들이 값을 각각 가지게 되면 낭비이다.
참조 카운팅 기본 구현법
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[]에 대해 참조자로 저장했을 때도 마찬가지.
- 해결 방법
- 문제를 무시한다.
- 꽤 많은 문자열 참조 카운팅이 그냥 덮어놓고 모른척 한다..
- 불법으로 규정한다.
- 사용 문서에 주의 사항을 적어두는 수준이지만..
- 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의 차이
- RCPtr은 값을 직접 가리키지만, RCIPtr은 CountHolder 객체가 값을 가리키게 하고, CountHodler를 가리킨다.
- 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이 되지 않는 상황이 발생할 수 있다.
- 자기 참조(self-referential), 원형 의존(circular dependency) 등이 발생할 수 있는 자료구조들
- 참조 카운팅은 효율 뿐 아니라 객체 생성, 소멸을 트래킹할 때도 사용할 수 있다.
- 추가적으로 확인할 사항
- removeReference의 delete는 객체가 반드시 힙에 할당되었음을 가정하고 있다.
- 그렇다면 반드시 new로 생성하도록 제약을 걸 필요성이 있을 것이다.
- 위에서는 private 내부 클래스로 정의하여 이 제약을 거는 데 성공했다.
728x90
'개발 > More Effective C++' 카테고리의 다른 글
[More Effective C++] 31. 다중 디스패치(multiple dispatch) (0) | 2024.10.07 |
---|---|
[More Effective C++] 30. 프록시 클래스 (0) | 2024.10.04 |
[More Effective C++] 28. 스마트 포인터 (0) | 2024.09.09 |
[More Effective C++] 27. 힙 전용, 힙 불가 클래스 만들기 (1) | 2024.09.06 |
[More Effective C++] 26. 클래스 인스턴스 개수 제한 (0) | 2024.09.05 |
Comments