산타는 없다

Techniques - 항목 29 : 참조 카운팅 (Reference Counting) 본문

프로그래밍 서적/More Effective C++

Techniques - 항목 29 : 참조 카운팅 (Reference Counting)

LEDPEAR 2021. 10. 9. 18:19
반응형

참조 카운팅(Reference counting)이란 여러 개의 객체들이 똑같은 값을 가졌으면, 그 객체들로 하여금 그 값을 나타내는 하나의 데이터를 공유하게 해서 데이터의 양을 절약하는 기법

이 기법에는 두 가지 목표가 있다

첫째, 힙 객체를 둘러싼 내부 정보를 유지하는 작업을 단순하게 하자
둘째, 똑같은 값을 가지고 있는 객체들이 그 값을 하나씩 꿰어차도록 놔두는 것은 낭비이므로 그 값을 나타내는 데이터 하나만 공유하게 하면 여러모로 이득이다.


참조 카운팅은 기본적으로 이렇게 구현합니다.

참조 카운트가 저장되는 위치는 굳이 객체 내부에 있을 필요가 없다. 실제 객체 하나에 대해서 참조 카운트는 하나만 잇으면 되기 때문에 참조 카운트와 값을 묶어서 관리해도 된다.

따라서, 참조 카운트와 값을 저장하는 클래스를 만든다.

이 클래스는 오로지 기존 객체의 구현을 보조한느 것이기 때문에 private 영역에 클래스를 중첩(nesting) 시키고 클래스가 아닌 구조체로 선언하여 모든 멤버 함수가 액세스 할 수 있게 합니다.
(이렇게 구조체를 클래스의 private 영역에 중첩시켜 두면, 이 구조체는 클래스의 모든 멤버 함수가 액세스 할 수 있고 외부에는 절대로 노출되지 않는다 (물론, 프렌드는 예외))

이 방법은 복사에 대해서는 객체를 관리할 수 있지만 객체가 이미 만들어진 값을 새로 생성하려고 할 때 기존 객체를 추적해서 참조하게 하지는 못합니다. 이 부분은 따로 작업을 해주어야 합니다.

이 객체의 소멸자는
if (--value->refCount == 0) delete value;
와 같은 구문을 통해 참조 카운팅에 따라 객체를 관리하게 되고

대입했을 때 Rvalue는 참조 카운팅을 증가시켜야 하고 Lvalue는 참조 카운팅을 감소시켜야 하며 참초 카운팅이 0이 됐을 경우 delete 시켜줘야 합니다.


기록시점 복사(Copy-on-Write)

참조 카운팅 기능을 가진 문자열의 기본적인 뼈대를 마무리하는 마지막 부품은 배열-대괄호 연산자([])이다.

상수 객체에 대한 [] 연산자와 비상수 객체에 대한 [] 연산자 둘다 구현되는데, 상수 버전은 읽기 전용의 연산만 맡으면 되기 때문에 간단히 값을 반환하는 것으로 구현된다.

하지만 비상수 객체에 대한 [] 연산자는 값이 수정될 가능성이 있으므로 참조 카운트를 1 감소시킨 후 새로운 객체를 생성하여 독립적인 객체로 만들어 줘야 한다.


포인터, 참조자, 그리고 기록시점 복사

위에서 구현된 기록시점 복사 기법은 호율과 정확성을 유지할 수 있는 꽤 괜찮은 방법이고 특히 운영체제에서 이 아이디어가 많이 사용되었다.

하지만 문제가 하나 남아있다. 만약 []연산 이후 객체의 참조카운트가 증가되어 1이 아니게 됐을 경우 변수로 저장해둔 []연산의 결과를 수정할 경우 참조하고 있는 모든 포인터가 가리키는 객체가 수정이 된다.

이 문제를 해결하는 방법은 최소 3 가지이다.

  1. 이런 문제를 무시하고, 문제가 없는 것처럼 꾸미기
  2. 불법으로 규정하기
  3. 이 문제를 적극적으로 직접 없애기

세 번째 방법의 핵심은 객체의 공유가능 여부를 표시하는 플래그를 넣는 것이다. 처음에는 플래그를 켜고, 나중에 그 객체가 나타내는 값에 대해 읽기/쓰기 겸용 operator[]가 호출될 때마다 이 플래그를 끕니다. 일단 그 플레그가 꺼져 있게 되면, 꺼진 상태를 유지합니다.


참조 카운팅 기능을 가진 기본 클래스

참조 카운팅 기능을 가진 객체의 기반 코드를 제공하는 C++ 기본 클래스를 만드는 것부터 시작합니다. 자동 참조 카운팅 기능을 이용하고자 하는 클래스는 이 기본 클래스를 상속하면 됩니다.

 

자세한 코드 생략

 


참조 카운트 조작을 자동화하기

이렇게 함수를 직접 호출하지 않고 재사용 가능한 클래스에다 몰아버리는 것이 더 깔끔하다

포인터처럼 동작하는 객체로 포인터를 대신하면 가능합니다.

이러한 객체를 가리켜 스마트 포인터라고 한다. 스마트 포인터에 대해서는 항목 28에서 자세히 공부할 수 있다.

스마트 포인터 객체는 진짜 포인터처럼 멤버 선택(->)과 역참조(*) 연산을 지원하고, 타입 제약이 엄격하다는 성질(T에 대한 스마트 포인터로 T 이외의 객체를 가리키게 할 수는 없다)만 알고 있으면 된다.

스마트 포인터 기능이 들어간 템플릿은 객체 생성, 대입, 소멸이 이루어질 때 그 상황에 맞게 refCount 필드를 적절히 조작하도록 만들어져 있다.

포인터를 가진 클래스에 대해서는 복사생성자(그리고 대입 연산자)를 직접 만들지 않으면 포인터만 복사하고 실제 값은 복사하지 않는 경우가 있으므로 주의해야 한다.


헤쳐 모여!

위의 내용을 기반으로 참조 카운팅 클래스를 만들어 보겠습니다.

책 내용 참고


기존의 클래스에 참조 카운팅 기능을 부착하는 방법

지금까지 내용은 클래스의 소스코드를 직접 건드릴 수 있다는 가정을 아래에 깔고 있었습니다. 하지만 참조 카운팅 기능을 넣었으면 하는 클래스를 직접 건드릴 수 없다면 어떻게 하시겠습니까?

지금까지 배운 설계 방법을 약간만 고치면, 이미 만들어져 있는 어떤 타입에도 참조 카운팅 기능을 붙일 수 있습니다.

책 내용 참고


참조 카운팅 기능을 구현할 때 주의할 점

  • 비상수 [] 연산자를 사용하는 경우 새로운 값으로 생성해야 한다.
  • 대입 연산자와 복사 연산자에서 참조 값을 변경해야 한다.

총정리

참조 카운팅은 무료로 얻어지지 않습니다. 참조 카운팅으로 유지되는 객체값에는 참초 횟수가 반드시 포함되며, 관련된거의 모든 동작에서 이 참조 횟수를 조작해야 합니다. 따라서 객체값에는 객체값 이외의 메모리가 추가로 들어가게 되고, 당연히 이 메모리를 액세스 하는 코드도 필요합니다. 게다가, 참조 카운팅을 쓰는 클래스는 그렇지 않은 클래스보다 더 복잡한 소스 코드로 만들어집니다. 복잡하게 설계한 이 클래스는 공유가 빈번한 경우에도 엄청난 효율 향상을 보장하고, 객체의 소유관계를 추적할 필요도 없을 뿐만 아니라, 코드의 재사용성이 매우 높습니다. 그럼에도 불구하고, 제작, 시험, 문서화, 유지보수라는 개발 과정을 고려하지 않을 수 없습니다.

참조 카운팅은 객체들 사이에서 값의 공유가 빈번하다는 가정 하에 고안한 최적화 기법입니다. 만약에 참조 카운팅을 쓰지 않은 코드보다 참조 카운팅을 쓴 코드가 메모리도 더 많이 먹고 코드도 더 많이 실행한다면, 이 가정은 실패한 것입니다. 하지만 객체들이 공통된 값을 가지는 경향이 뚜렷한 경우에는 참조 카운팅을 통해 실행 시간과 메모리 공간을 절약 할 수 있습니다.

참조 카운팅이 효율 향상에 효과적일 수 있는 상황은 다음의 두 가지로 정리할 수 있습니다.

  1. 상대적으로 많은 객체들이 상대적으로 적은 값을 공유할 때.
    이러한 상황은 대개 대입 연산자와 복사 생성자가 호출될 때 만들어집니다. 객체 개수에 대한 값 개수의 비율이 클수록 참조 카운팅을 할 필요가 더 커집니다.
  2. 어떤 객체값을 생성하거나 소멸시키는데 많은 비용이 들거나 메모리 소모가 클 때.
    이러한 경우라고 해도, 여러 객체들이 값을 공유하지 않으면 참조 카운팅은 별 효과를 보이지 않습니다.

앞의 조건이 충족되었다고 하더라도 참조 카운팅을 사용한 코드 설계가 여전히 적합하지 않을 경우도 있습니다.

 

반응형
Comments