MoreEffectiveC++
1.1. 소개 ¶
Reference counting(이하 참조 세기, 단어가 길어 영어 혼용 하지 않음)는 같은 값으로 표현되는 수많은 객체들을 하나의 값으로 공유해서 표현하는 기술이다. 참조 세기는 두가지의 일반적인 동기로 제안되었는데, 첫번째로 heap 객체들을 수용하기 위한 기록의 단순화를 위해서 이다. 하나의 객체가 만들어 지는데, new가 호출되고 이것은 delete가 불리기 전까지 메모리를 차지한다. 참조 세기는 같은 자료들의 중복된 객체들을 하나로 공유하여, new와 delete를 호출하는 스트레스를 줄이고, 메모리에 객체가 등록되어 유지되는 비용도 줄일수 있다. 두번째의 동기는 그냥 일반적인 생각에서 나왔다. 중복된 자료를 여러 객체가 공유하여, 비용 절약 뿐아니라, 생성, 파괴의 과정의 생략으로 프로그램 수행 속도까지 높이고자 하는 목적이다.
이런 간단한 생각들의 적용을 위해서는 많이 쓰이는 자료형태를 찾아 예제삼아 보여주는 것이 이해에 도움이 도리것이다. 이런 면에서 다음의 예는 적합할 것이다.
a~e까지 모두 "Hello"라는 같은 값을 가지고 있는 다른 객체이다. 이 클래스는 참조 세기가 적용되지 않았기 때문에 모두 각각의 값을 가지고 있다. 문자열의 할당(assignment) 연산자는 아마 다음과 같이 구현되어 있을 것이다.
이 코드의 의미를 그림으로 표현하면 다음과 같다.
~cpp class String { // 표준 문자열 형은 이번 아이템의 참조세기를 갖추고 public: // 있을테지만, 그것을 배제했다고 가정하자 String(const char *value = ""); String& operator=(const String& rhs); ... private: char *data; }; String a, b, c, d, e; a = b = c = d = e = "Hello";
~cpp String& String::operator=(const String& rhs) { if (this == &rhs) return *this; // Item E17 참고 delete [] data; data = new char[strlen(rhs.data) + 1]; strcpy(data, rhs.data); return *this; // Item E15 참고 }
이러한 그림을 참조 세기에서 말하는 중복 객체에 대한 자료의 공유를 적용시킨 그림은 다음과 같을 것이다.
"Hello"라는 값은 하나만 저장되어 있는 것이고, 이를 문자열들이 공유해서 표현시 가지고 있는 것이다. 하지만 실질적으로 "Hello"의 할당 시점은 손쉽게 알수 있지만, 파괴 시점을 알수 있는것은 만만치 않다. 그래서 파괴 시점을 알기 위해서 "Hello" 값에 그것을 참조하는 정도를 기록하고, 그 참조가 0가 되는 시점을 값의 파괴 시점으로 삼아야 하는데, 이런 생각을 아까 그림에 다시 넣으면 다음과 같다.
그리고 여기의 5에 해당 하는 숫자를 Reference count 라고 부른다. 혹자는 use count라고 부르기도 하는데, 학술 용어의 당파에 따른거니 별 상관 안한다. 하지만 나(scott mayer) 그렇게 안부른다.
1.2. Implementing Reference Counting : 참조 세기 적용 ¶
참조 세기를 하는 String 클래스를 만드는건 어렵지는 않지만, 세세한 부분에 주목해서 어떻게 그러한 클래스가 구현되는지 주목해 보자. 일단, 자료를 저장하는 저장소가 있고, 참조를 셀수 있는 카운터가 있어야 하는데, 이 둘을 하나로 묶어서 StringValue 구조체로 잡는다. 구조체는 String의 사역(private)에 위치한다.
지금까지 말한 사항의 기본은 다음과 같다.
물론 이의 이름은 String과 다른 이름을 매겨야 하겠지만,(아마 RCString정도?) 하지만 String자체를 구현한다는 의미로 그냥 이름은 유지하고, 앞으로 말할 참조세기를 적용시킨 String 객체를 만들어 나가겠다.
지금까지 말한 사항의 기본은 다음과 같다.
~cpp class String { public: ... // String member들 위치 private: struct StringValue { ... }; // 참조를 세는 인자와, String의 값을 저장. StringValue *value; // 위의 구조체의 값 };
위의 기본 디자인을 구현해 본다.
이것으로 StringValue의 구현은 일단 끝이다. 이를 사용하는 String 객체의 구현에 들어가야 한다.
~cpp class String { private: struct StringValue { int refCount; // 참조를 세기위함 카운터 char *data; // 값 포인터 StringValue(const char *initValue); ~StringValue(); }; ... }; // StringValue의 복사 생성자, 초기화 목록으로 refCount 인자 1로 초기화 String::StringValue::StringValue(const char *initValue): refCount(1) { data = new char[strlen(initValue) + 1]; // 새로운 값 할당(아직 참조세기 적용 x strcpy(data, initValue); // 스트링 복사 } // StringValue의 파괴자 String::StringValue::~StringValue() { delete [] data; // 스트링 삭제 }
먼저 생성자를 구현한다.
클라이언트의 코드는 보통 다음과 같다.
이를 처음 참조세기 설명에 나온 그림식으로 설명하자면 다음과 같다.
~cpp class String { public: String(const char *initValue = ""); String(const String& rhs); ... };
~cpp // String의 복사 생성자, 초기화 목록으로 StringValue에 값을 할당한다. String::String(const char *initValue): value(new StringValue(initValue)) {}
~cpp String s("More Effective C++");
그럼 String객체의 생성시 서로 공유하지 않은 데이터 구조를 가지도록 클라이언트가 코드를 작성하면 다음과 같다.
이렇게 되는데, 이는 그림으로 표현하면,
~cpp String s1("More Effective C++"); String s2("More Effective C++");
다음과 같다. 여기에서 "More Effective C++" 라는 문자열을 공유한다면, 참조세기가 이루어 지는 것일 거다. 그러기 위해서 String의 복사 생성자는, 참조 카운터를 올리고, 자료를 새로 생성할것이 아니라, 그냥 포인터만 세팅해 주는 일을 해야 한다. 이런 과정을 구현하면
이 코드를 사용하는 상황은 다음과 같고,
이번에 그림으로 표현하면
~cpp // String 클래스의 복사 생성자, 초기화 목록으로 StringValue의 포인터를 세팅한다. String::String(const String& rhs): value(rhs.value) { ++value->refCount; // 참조 카운터를 올린다. }
~cpp String s1("More Effective C++"); String s2 = s1;
여기서의 요점은 참조세기가 적용되지 않은 String 클래스보다 더 효율이 높아 진다는 점이다. 이 책 전반에 계속 언급했듯이, 생성과 파괴 같은 시간과, 공간의 많은 비용을 소모하는 식이 아닌, 해당 포인터만을 복사하므로, 일단, 생성시의 비용을 아낀다.
생성자의 손쉬운 구현같이 파괴자 구현도 그리 어려운 일이 아니다. StringValue의 파괴는, 서로가 최대한 사용하고, 값이 파괴 시점은 참조 카운터가(reference counter:이하 참조 카운터만) 1인 경우 더이상 사용하는 객체가 없으므로 파괴하도록 구성한다.
파괴자의 효율을 비교해도 역시, 마찬가지이다. delete가 불리는 시점은 해당 객체가 더 이상 필요 없을때만 제거하는 것이기 때문에 비용을 아낄수 있다.
~cpp class String { public: ~String(); ... }; String::~String() { if (--value->refCount == 0) delete value; // 참조 카운터가 1인 상태 }
다음에 구현해야할 사항은 할당(assignment)관련한 구현 즉 operator= 이다. 복사 생성자(copy constructor)를 호출이 아닌 할당은 다음과 같이 선언되고,
다음과 같이 사용되며
이것의 구현은 약간은 복잡한데, 이유는 생성과 파괴가 동시에 있어야 하는 상황을 고려해야 하기 때문이다. 그래도 아직 앞으로 다루어야할 내용에 비해 상당히 간단한 편이다. 자세한 설명은 소스에 주석을 참고하라
~cpp class String { public: String& operator=(const String& rhs); ... };
~cpp s1 = s2;
~cpp String& String::operator=(const String& rhs) { if (value == rhs.value) { // 이미 두 객체가 같은 값을 가리킨다면, return *this; // 특별히 할일은 없다. } if (--value->refCount == 0) { // 현재 값이 자신 외에 아무도 참조하고 delete value; // 있지 않다면 삭제 한다. } value = rhs.value; // 자료를 공유한다. ++value->refCount; // 참조 카운터를 올린다. return *this; // 이건 알아서 --+ }
1.3. Copy-on-Write : 쓰기에 기반한 복사 ¶
참조세기가 적용된 문자열에 대하여 둘러 봤는데, 이번에는 배열에 관한(array-bracket) 연산자들 "[]" 이녀석들에 관해서 생각해 보자. 클래스의 선언은 다음과 같다.
const String에 대한 값을 주는 것은 아래와 같이 간단히 해결된다. 내부 값이 아무런 영향을 받을 것이 없을 경우이기 떄문이다.
(이 함수는 원래의 C++에서 배열의 사용 개념과 같이, index의 유효성을 점검하지 않는다. 이에 대한 감은은 참조 세기의 주제에 떨어져 있고, 추가하는 것도 그리 어려운일이 아니라 일단은 제외한다.)
~cpp class String { public: const char& operator[](int index) const; // const String에 대하여 char& operator[](int index); // non-const String에 대하여 ... };
~cpp const char& String::operator[](int index) const { return value->data[index]; }
하지만 non-const의 operator[]는 이것(const operator[])와 완전히 다른 상황이 된다. 이유는 non-const operator[]는 StringValue가 가리키고 있는 값을 변경할수 있는 권한을 내주기 때문이다. 즉, non-const operator[]는 자료의 읽기(Read)와 쓰기(Write)를 다 허용한다.
참조 세기가 적용된 String은 수정할때 조심하게 해야 된다. 그래서 일단 안전한 non-const operator[]를 수행하기 위하여 아예 operator[]를 수행할때 마다 새로운 객체를 생성해 버리는 것이다. 그래서 만든 것이 다음과 같은 방법으로 하고, 설명은 주석에 더 자세히
이것은 부족한 점이 있다. 다음 과정에서 보강한다.
~cpp String s; ... cout << s[3]; // 이건 읽기(Read) s[5] = 'x'; // 이건 쓰기(Write)
~cpp char& String::operator[](int index) // non-const operator[] { // if we're sharing a value with other String objects, // break off a separate copy of the value for ourselves // 만약 다른 객체와 자료를 공유하지 않고 있다면, 자료를 노출 시켜도 // 상관이 없다. if (value->refCount > 1) { // if 안쪽은 새로운 객체를 생성해야 할 경우 --value->refCount; // 새로운 객체의 생성을 위해서 현재 참조하는 // 자료의 refCount를 감소 시킨다. value = new StringValue(value->data); // 현재 자료의 사본을 만들어 // 이것을 가리킨다. } // 이제, 아무도 공유하고 있지 않은 StringValue의 객체 // 내부의 문자열의 한 부분을 반환한다. return value->data[index]; }
이러한 생각은 컴퓨터 과학에서 오랫동안 다루어 져있던 것이다. 특히나 OS(operating system)설계시에 Process가 공유하고 있던 데이터를 그들이 수정하고자 하는 부분을 복사해서 접근을 허락 받는 루틴에서 많이 쓰인다. 흔이 이러한 기술에 관해서 copy-on-write(쓰기에 기반한 복사, 이후 copy-on-write를 그냥 쓴다.) 라고 부른다.
1.4. Pointers, References, and Copy-on-Write : 포인터, 참조, 쓰기에 기반한 복사 ¶
copy-on-write를 구현할때 정확성과 효율성, 둘다 만적 시키는 편이다. 하지만 항상따라다니는무넺가 있는데, 다음 코드를 생각해 보자.
이를 대강 그림을 나타내 보면 p가 Hello의 e를 가리키는 이정도의 느낌일 것이다.
~cpp String s1 = "Hello"; char *p = &s1[1];
그러면 이번에는 여기에 한줄만 덧붙여 본다.
String 복사 생성자가 아마 s2가 s1의 StringValue를 공유하게 만들것이다. 그래서 당음과 같은 그림으로 표현될 것이다.
~cpp String s2 = s1;
그래서 다음과 같은 구문을 실행한다면
String의 복사 생성자는 이러한 상태를 감지할 방법이 없다. 위에서 보듯이, s2가 참고할수 있는 정보는 모두 s1의 내부에 있는데, s1에게는 non-const operator[]를 수행하였는지에 관한 기록은 없다.
~cpp p = 'x'; // 이 것은 s1, s2를 모두 변경!
이런것을 해결할수 있는 방법으로는 최소한 세가지를 생각할수 있는데, 첫번째는 이것을 없는걸로 취급하고, 무시 해 버리는 것이다. 이러한 접근 방향은 참조 세기가 적용되어 있는 클래스 라이브러리에 상당한 괴로움을 덜어 주는것이다. 하지만 이러한 문제를 구현상에서 완전히 무시할수는 없는 노릇이다. 두번째로 생각할수 있는 방법은 이러한것을 하지 말도록 명시하는 것인데, 역시나 복잡하다. 세번째로, 뭐 결국 제거야만 할것이다. 이러한 분제의 제거는 그리 어렵지는 않다. 문제는 효율이다. 이런 중복에 관련한 문제를 제거하기 위해서는, 새로운 자료 구조를 만들어 내야하고, 이것의 의미는 객체간에 서로 공유하는 자료가 줄어 든다는 의미이다. 즉, 비용이 많이 들어간다. 하지만 어쩔수 없지 않을까?
간단한 플래그 하나를 추가하는 것으로 이 문제는 해결된다. 이번 수정 버전은 공유의 여부를 허락하는 sharable 인자를 더한 것이다. 코드를 보자.
복사 생성자에서는 참조 뿐만 아니라. 공유를 거부할때 역시 감안해야 하므로, 수정해야 한다.
그리고 이 shareable인자는 non-const operator[]가 호출될때 false로 변경되어서 이후, 해당 자료의 공유를 금지한다.
만약 Item 30에 언급되는 proxy 클래스 방법을 사용해서 읽는 작업과, 쓰는 작업에 대한 것을 구분한다면 StringValue 객체의 숫자를 좀더 줄일수 있을 것이다. 그건 다음 장에서 보자.
~cpp class String { private: struct StringValue { int refCount; bool shareabl; // 이 인자가 더해 졌다. char *data; StringValue(const char *initValue); ~StringValue(); }; ... }; String::StringValue::StringValue(const char *initValue) : refCount(1), shareable(true) // 초기화시에는 공유를 허락한다. { data = new char[strlen(initValue) + 1]; strcpy(data, initValue); } String::StringValue::~StringValue() { delete [] data; }
~cpp String::String(const String& rhs) { if (rhs.value->shareable) { // 공유를 허락할 경우 value = rhs.value; ++value->refCount; } else { // 공유를 거부할 경우 value = new StringValue(rhs.value->data); } }
~cpp char& String::operator[](int index) { if (value->refCount > 1) { --value->refCount; value = new StringValue(value->data); } value->shareable = false; // 이제 자료의 공유를 금지한다. return value->data[index]; }
1.5. A Reference-Counting Base Class : 참조-세기 기초 클래스 ¶
참조세기는 단지 string의 경우에만 유용한걸까? 다른 객체 역시 참조세기를 적용해서 사용할수는 없을까? 생각해 보면 참조세기를 구현하기 위해서는 지금까지의 언급의 기능을 묶어서 재사용 가능하게 만들면 좋을것 같은데, 한번 만들어 보도록 하자.
제일 처음에 해야 할일은 참조세기가 적용된 객체를 위한 (reference-counded object) RCObject같은 기초 클래스를 만드는 것이다. 어떠한 클라스라도 이 클래스를 상속받으면 자동적으로 참조세기의 기능이 구현되는 형식을 바라는 것이다. 그러기 위해서는 RCObject가 가지고 있어야 하는 능력은 카운터에 대한 증감에 대한 능력일 것이다. 게다가 더 이상, 사용이 필요 없는 시점에서는 파괴되어야 한것이다. 다시말해, 파괴되는 시점은 카운터의 인자가 0이 될때이다. 그리고 공유 허용 플래그에 대한(shareable flag) 관한 것은, 현재가 공유 가능한 상태인지 검증하는 메소드와, 공유를 묶어 버리는 메소드, 이렇게만 있으면 될것이다. 공유를 푼다는 것은 지금까지의 생각으로는 불가능한 것이기 때문이다.
이러한 정의를 모아모아 뼈대를 만들면 다음과 같다.
RCObject는 생성되고, 파괴되어 질수 있어야 한다. 새로운 참조가 추가되면 현재의 참조는 제거되어야 한다. 공유 상태에 대해 여부를 알수 있어야 하며, disable로 설정할수 있어야 한다. 파괴자에 대한 실수를 방지하기 위하여 베이스 클래스는 파괴자를 가상 함수로선언해야 된다. 여기에서는 순수 가상 함수(pure virtual function)로 선언한다.
~cpp class RCObject { public: RCObject(); RCObject(const RCObject& rhs); RCObject& operator=(const RCObject& rhs); virtual ~RCObject() = 0; void addReference(); void removeReference(); void markUnshareable(); bool isShareable() const; bool isShared() const; private: int refCount; bool shareable; };
다음 코드가 RCObject를 별 예외 없다는 전제로, 지금까지의 생각들을 간단히 구현한 코드이다.
~cpp RCObject::RCObject() : refCount(0), shareable(true) {} RCObject::RCObject(const RCObject&) : refCount(0), shareable(true) {} RCObject& RCObject::operator=(const RCObject&) { return *this; } RCObject::~RCObject() {} // 파괴자는 그것이 순수 가상 함수라도 // 항상 구현되어 있어야 한다. void RCObject::addReference() { ++refCount; } void RCObject::removeReference() { if (--refCount == 0) delete this; } void RCObject::markUnshareable() { shareable = false; } bool RCObject::isShareable() const { return shareable; } bool RCObject::isShared() const { return refCount > 1; }
의문 둘, 복사 생성자는 refCount를 항상 0으로 세팅해 버린다. 사실 복사할때는 refCount는 상관이 없다. 이유는 새로운 객체의 값이라, 이것은 항상 어떤 객체와도 공유하고 있지 않기 때문에 refCount를 초기화 시킨다.
의문 셋, 할당(assignment) 연산자는 아까 공유 문제를 뒤집어 버리는 것 처럼 보인다. 하지만 이 객체는 기초 클래스이다. 어떤 상황에서 이를 유도하는 다른 클래스가 공유 flag를 비활성화 시키는지 확신 할수 없기 때문에, 일단 아무것도 하지 않고, 현재의 객체를 반환하게 해 두었다.
계속 해깔릴수 있는데, 다음과 같은 코드를 보면
자, RCObject는 기초 클래스이며, 차후 데이터를 저장하는 공간은 RCObject의 자식에게서 구현되어 진다. 그래서 opreator=은 유도 된 클래스에서 원할때 이 참조 객체를 바꾸기 위하여, RCObject::operator= 형식으로 불러야 한다. 이하, 거의다 소스 자체를 이해해야 한다. 해당 RCObject를 이용해서 유도된 클래스의 모양은 다음과 같으며, 모든 코드는 구현하지 않겠지만, 지금까지의 개념으로 이해할수 있을 것이다.
이 StringValue버전은 이전에 보던것과 거의 동일한 형태를 보인다.단, refCount를 다루는 것만이 RCObject의 멤버 함수로서 모든 기능을 후행하는 것이다. 이 것의 더 구체적인 코드는 다음 장에서 보이는 스마트 포인터를 이용한 자동 참조 카운팅 기능을 덧붙이면서 어차피 모든 코드를 서술할 것이다.
~cpp sv1 = sv2; // 어떻게 sv1과 sv2의 참조 카운터가 영향 받을까?
~cpp 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; }
이번 주제의 nested class의 모습을 보면 그리 좋와 보이지는 않을 것이다.처음에는 생소하겠지만, 이러한 방버은 정당한 것이다. nested클래스의 방법은 수많은 클래스의 종류중 단지 하나 뿐이다. 이것을 특별히 취급하지는 말아라.
1.6. Automating Reference Count Manipulations : 자동 참조 세기 방법 ¶
RCObject는 참조 카운터를 보관하고, 카운터의 증감을 해당 클래스의 멤버 함수로 지원한다. 하지만 이건 유도되는 다른 클래스에 의하여, 손수 코딩이 되어야 한다. 즉, 위의 경우라면, StirngValue 클래스에서 addReference, removeReference 를 호출하면서, 일일이 코딩해 주어야 한다. 이것은 재사용 클래스로서 보기 않좋은데, 이것을 어떻게 자동화 할수는 없을까? C++는 이런 재사용을 지원할수 있을까.
유도 받은 클래스에서 참조 카운터에 관련한 코드를 없애기, 말처럼 쉬운 일은 아닌것 같다. 거기에다가 String같은 형의 구현을 위해서는 non-const operator[]에 따른 공유 여부까지 따져 줘야 하지 더욱 복잡해 지는 문제이다. 현재 String의 값을 보관하는것은 StringValue인데 이렇게 표현되어 있다.
StringValue 객체의 refCount의 증감을 위해서는 상당히 많은 양의 코딩이 들어가야 할것이다. 복사할때, 재 할당 받을때, 그리고 해당 값이 파괴되어 질때, 이런 모든것을 자동화 시키는 것이 이번 장의 목적이다. 여기서 StringValue를 전장에 언급했던 스마트 포인터 기술을 적용해서 이런 참조 세기에 관해서 재사용을 넘어 자동화를 시켜 보자.
~cpp class String { private: struct StringValue: public RCObject { ... }; StringValue *value; // 이 String의 값 ... };
스마트 포인터에 대한 설명은 너무 방대하다. 하지만 여기 RCObject를 가리킬 스마트 포인터가 가지고 있을 능력은 멤버 선택(->), 역참조(deferencing, *) 연산자 정도만 있으면 충분하다. 물론 복사나, 생성은 기본이고 말이다. 참조 세기를 위한 스마트 포인터 템플릿을 RCPtr이라고 명명하고, 기본적인 뼈대를 다음과 같이 구성한다.
위에서 언급했듯이, 템플릿의 목적은 RCObject의 refCount의 증감을 자동화하기 위한 것이다. 예를 들어서, RCPtr이 생성될때 객체는 참조 카운터를 증가시키키 원할 것이고, RCPtr의 생성자가 이를 수행하기 때문에 이전처럼 일일이 코딩할 필요가 없을 것이다. 일단, 생성 부분에서의 증가만을 생각해 본다.
공통된 코드를 init()으로 묶었는데, 이런 경우 정확히 동작하지 않을수 있다. init함수가 새로운 복사본을 필요로 할때가 그것인데, 공유를 허용하지 않을 경우에 복사하는 바로 이부분,
opintee의 형은 pointer-to-T이다. 아마 String클래스에서는 이것이 String::StringValue의 복사 생성자 일것인데, 우리는 StringValue의 복사 생성자를 선언해 주지 않았다. 그래서 컴파일러가 자동적으로 C++내의 규칙을 지키면서 복사 생성자를 만들어 낼것이다. 그래서 복사는 오직 StringValue의 data 포인터에 해당하는 것만이 이루어 질것이다. data는 복사가 아닌 참조가 행해 질것이다. 이러한 이유로 이후, 작성되는 모든 틀래스에 복사 생성자가 추가되어야 한다.
~cpp // T 객체를 가리키기 위한 스마트 포인터 클래스 템플릿 // 여기에서 T는 RCObject나, 이것을 상속 받는 클래스이다. template<class T> class RCPtr { public: RCPtr(T* realPtr = 0); RCPtr(const RCPtr& rhs); ~RCPtr(); RCPtr& operator=(const RCPtr& rhs); T* operator->() const; // Item 28 참고 T& operator*() const; // Item 28 참고 private: T *pointee; // 원래의 포인터를 가리키기위한 // 더미(dumb) 포인터 pointer this void init(); // 보통의 초기화 루틴 };
~cpp template<class T> RCPtr<T>::RCPtr(T* realPtr): pointee(realPtr) { init(); } template<class T> RCPtr<T>::RCPtr(const RCPtr& rhs): pointee(rhs.pointee) { init(); } template<class T> void RCPtr<T>::init() { if (pointee == 0) { // 만약 더미(dumb)포인터가 null이라면 return; } if (pointee->isShareable() == false) { // 공유를 허용하지 않으면 pointee = new T(*pointee); // 복사를 한다. } pointee->addReference(); // 새로운 참조 카운터를 올린다. }
~cpp pointee = new T(*pointee);
RCPtr<T>의 정확한 수행을 위해서 T는 복사 생성자를 가지고 있어서, 독립적으로 복사를 수행해야 한다.(다른 말로 deep copy가 이루어 져야 한다.)
이러한 deep-copy 복사 생성자의 존재는 RCPtr<T>가 가리키는 T에 국한 되는 것이 아니라, RCObject를 상속하는 클래스에도 지원되어야 한다. 사실 RCPtr 객체 관점에서 볼때는 오직 참조 세기를 하는 객체를 가리키기만 하는 관점에서 이것은 납득할수 없는 사실일지 모른다. 그렇지만 이러한 사실이 꼭 문서화 되어 클라이언트에게 알려져야 한다.
~cpp class String { private: struct StringValue: public RCObject { StringValue(const StringValue& rhs); ... }; ... }; String::StringValue::StringValue(const StringValue& rhs) { data = new char[strlen(rhs.data) + 1]; // 이 부분이 deep copy의 strcpy(data, rhs.data); // 수행 부분이다. }
마지막 RCPtr<T>에서의 가성은 T에 대한 형에 관한 것이다. 이것은 확실하게 보인다. 결국 pointee는 T* 형으로 선언되는데, pointee는 아마 T로 부터 유도된 클래스 일지 모른다. 예를 들자면 만약 당신이 SepcialStringValue라는 String::StringValue에서 유도된 클래스가 존재 한다면
일단 우리는 String에 RCPtr<StingValue> 포인터로 SpecialStringValue 객체를 가리키는 상태로 만들어서 포함시킬수 있다. 이러한 경우에 우리가 원하는 init가 되지 않는다.
즉, SepcialStringValue의 복사 생성자를 부르길 원하는데, StringValue의 복사 생성자가 불린다. 우리는 이러한 것을 가상 복사 생성자를 써서 (Item 25 참고) 할수 있다. 하지만, 이것은 디자인때 의도한 것이 아니기 때문에, 이러한 경우에 대해서는 납득할수가 없다.
~cpp class String { private: struct StringValue: public RCObject { ... }; struct SpecialStringValue: public StringValue { ... }; ... };
~cpp pointee = new T(*pointee); // T 는 StringValue이다, 하지만 // pointee가 가리키는건 SpecialStringVale이다.
이제, 생성자에 관한 내용을 벗어나, 할당(assignment) 연산자에 관한 내용을 다룬다. 할당 경우도 공유의 경우를 생각해야 하기 때문에 까다롭게 될수 있는데, 다행스럽게 이미 그러한 과정은 init에 공통적으로 들어 있기 때문에 코드가 간단해 진다.
파괴자느 ㄴ간단하다. 그냥 참조 카운터 하나를 감소하면 된다.
주석에서와 같이 removeReference() 는 참조 카운터가 0이되면 해당 객체를 파괴한다.
~cpp template<class T> RCPtr<T>& RCPtr<T>::operator=(const RCPtr& rhs) { if (pointee != rhs.pointee) { // 둘이 같은 객체를 공유하면 // 할당을 생략한다. if (pointee) { pointee->removeReference(); // 현재 참조 객체 참조 카운터 감소 or 파괴 } pointee = rhs.pointee; // 가리키는 참조를 옮긴다. init(); // 공유가 불가능 할경우는 자료를 // 생성하고, 어찌되었든 참조를 증가 } return *this; }
~cpp template<class T> RCPtr<T>::~RCPtr() { if (pointee)pointee->removeReference(); // 알아서 파괴됨 }
마지막으로, 이제 스마트 포인터의 백미인 포인터 흉내내는 부분이다.
~cpp template<class T> T* RCPtr<T>::operator->() const { return pointee; } template<class T> T& RCPtr<T>::operator*() const { return *pointee; }
1.7. Putting it All Together : 지금까지의 방법론 정리 ¶
드디어 끝이다. 스마트 포인터와, 기초 클래스로 이제 재사용 가능한 참조세기를 구현할수 있다. 지금까지 하나하나 만들어온 것을 이번에 모두 합쳐서 표현해 보자.
이러한 개략도는 다음과 같은 클래스로 정의 되어 진다.
대다수의 부분에서 우리는 이미 만들 것을 그대로 썼다. 그래서 특별히 신경써야 할것은 없다. 유의 해야 할것은 String::StringValue에 보이는 init 함수에서 신경 써야 한다.
~cpp template<class T> // T를 가리키는 스마트 포인터 class RCPtr { // 여기에서 T는 RCObject를 상속해야 한다. public: RCPtr(T* realPtr = 0); // 초기화, 생성자 겸(기본값 때문에) RCPtr(const RCPtr& rhs); // 복사 생성자 ~RCPtr(); RCPtr& operator=(const RCPtr& rhs); // 할당 연산자 T* operator->() const; // 포인터 흉내 T& operator*() const; // 포인터 흉내 private: T *pointee; // 더미(dumb) 포인터 void init(); // 참조 세기 초기화(참조 카운터 증가) }; class RCObject { // 참조세기의 기초 클래스 public: void addReference(); // 참조 카운터 증가 void removeReference(); // 참조 카운터 감소 void markUnshareable(); // 공유 막기 bool isShareable() const; // 공유 해도 되는가 묻기 bool isShared() const; // 현재 공유 중인가 묻기 protected: RCObject(); // 생성자 RCObject(const RCObject& rhs); // 복사 생성자 RCObject& operator=(const RCObject& rhs); // 할당 연산자 virtual ~RCObject() = 0; // 순수 가상 파괴자 // (순수지만 반드시 구현되어야 한다.) private: int refCount; // 참조 카운터 bool shareable; // 공유 플래스(공유 허용 여부) }; // 처음 보다 많이 빠진 String같지 않은가? ^^; class String{ // application 개발자가 사용하는 클래스 public: String(const char *value = ""); // 생성자, 초기화 const char& operator[](int index) const; // const operator[] char& operator[](int index); // non-const operator[] 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의 스마트 포인터 };
자 중요한 이 아이템을 처음 시작할때 String클래스의 인터페이스와 다른 점은, 복사 생성자는 어디 있는가? 할당(assignment) 연산자는 어디 있는가? 파괴자는 어디 있는가? 정말 심각한 잘못으로 보이지 않는가?하지만 걱정 할것없다. 사실 이 구현 형태는 완전하다. 만약 이유를 모르겠으면, C++을 좀더 공부해라. (작성자주:이런 건방진 말투로 바꾸는..)
이제는 이러한 함수들이 더이상 필요 없다. 물론 아직 String객체의 복사는 지원된다. 복사는 참조세기를 기반한 StringValue객체에서 이루어 지는 것으로, String클래스는 다이성 이런 것을 위해서 한줄도 코딩할 필요가 없다. 그 이유는 이제 컴파일러가 만들 복사 생성자에게 모두 맡겨 버리면 기타의 것들은 모두 자동으로 생성된다. RCPtr은 스마트 포인터이다. 그사실을 기억하라, 복사시 모두 스마트 포인터가 참조를 관리해준다.
자, 이제 전체의 구현코드를 보여줄 차례이다. 먼저 RCObject의 구현 상황이다.
다음은 RCPtr의 구현 코드이다.
다음은 String::StringValue의 구현 코드이다.
이제 모든걸 감싸는 String 클래스의 구현 코드이다.
이 String클래스를 위한 코드와, 그냥 더미(dumb)포인터를 사용한 클래스(처음에 참조세기 구현한것)와는 두가지의 큰 차이점이 있다. 첫번째로 이 클래스의 코드가 굉장히 적다는 점이다. 이유는, RCPtr이 참조세는 작업을 모두 맡아서 이다. 두번째로는 스마트 포인터로 교체했지만, String의 코드가 거의 유지된다는 점이다. 사실 변화는 operator[]에서만 공유의 경우를 체크하는 루틴 때문에 바뀌었다. 이렇게 스마트 포인터로서 손수 해야하는 작업들이 많이 줄어 든다.
~cpp RCObject::RCObject() : refCount(0), shareable(true) {} RCObject::RCObject(const RCObject&) : refCount(0), shareable(true) {} RCObject& RCObject::operator=(const RCObject&) { return *this; } RCObject::~RCObject() {} void RCObject::addReference() { ++refCount; } void RCObject::removeReference() { if (--refCount == 0) delete this; } void RCObject::markUnshareable() { shareable = false; } bool RCObject::isShareable() const { return shareable; } bool RCObject::isShared() const { return refCount > 1; }
~cpp template<class T> void RCPtr<T>::init() { if (pointee == 0) return; if (pointee->isShareable() == false) { pointee = new T(*pointee); } pointee->addReference(); } template<class T> RCPtr<T>::RCPtr(T* realPtr) : pointee(realPtr) { init(); } template<class T> RCPtr<T>::RCPtr(const RCPtr& rhs) : pointee(rhs.pointee) { init(); } template<class T> RCPtr<T>::~RCPtr() { if (pointee)pointee->removeReference(); } template<class T> RCPtr<T>& RCPtr<T>::operator=(const RCPtr& rhs) { if (pointee != rhs.pointee) { if (pointee) pointee->removeReference(); pointee = rhs.pointee; init(); } return *this; } template<class T> T* RCPtr<T>::operator->() const { return pointee; } template<class T> T& RCPtr<T>::operator*() const { return *pointee; }
~cpp void String::StringValue::init(const char *initValue) // deep copy를 위해서 { 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; }
~cpp String::String(const char *initValue) : value(new StringValue(initValue)) {} // value는 RCPtr<StringValue> 이다. 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 클래스에 관한 기반 생각은 클라이언트 측에서 새부사항을 알필요가 없어야 밑이 나는 것이다. 알아야 할것이 없을수록 더 좋은 상태이다. 현재, String을 쓰는 기본 인터페이스는 바뀐것이 없다. 단지 참조세기의 기능이 추가되었을 뿐이다. 그래서 클라이언트는 기존 코드를 고칠 필요가 없다. 단, 재 컴파일(recompile)과 재링크(relink) 과정만이 남아 있을 것이다. 이러한 비용은 참조세기가 주는 이득에 비하면 정말 완전히 없는 비용이나 마찬가지이다. 캡슐화는 정말 좋은거다. (작성자주:뭐야 이 결론은..)
1.8. Adding Reference Counting to Exitsting Classes : 참조 세기를 이미 존재하는 클래스에 더하기 ¶
휴, 우리는 지금까지 흥미로운 클래스에 관해서 논의 했는데, 이번에는 Widget같은 이미 정의되어 있는 클래스를 전혀 건드리지 않고, 참조 세기를 적용 시킬수는 없을까? 그러니까. 라이브러리를 고치지 않고 말이다. Widget에 참조 세기를 적용 시켜야 하는데, 앞의 방법 처럼 RCObject를 Widget이 상속 시킬 방법은 전혀 없다. 그래서 RCPtr도 적용할수 없다. 방법이 없는 걸까?
간단히 말해 우리는 조금만 우리의 디자인을 손봐야 한다. 일단 만약 우리의 기존 디자인인 String/StringValue관계를 고수하면서 Widget을 적용 시키는 것을 가정해 보자. 그러면 디자인은 다음과 같을 것이다.
이제 컴퓨터 우회적으로 방향을 바꾸는 부분(level)을 추가하는 방법으로 컴퓨터 과학이 처한 커다란 문제를 해결해 보자. 새로 추가될 ContHolder는 참조 세기 기능을 구현하고 있으며, 대신 RCPtr 클래스 역시 RCIPtr 클래스로 한다.("I"는 indirection(우회)의 의미로 붙은거다.) 이런 디자인은 다음과 같은 모습을 보일 것이다.
기본 개념은 StringValue에서 적용된 방식과 비슷하다. CountHolder는 RCWidget의 클라이언트로 부터 구현 상황을 숨겨 버릴 것이다. 사실 자세한 구현은 RCIPtr에 거의 다되어 있다. 그래서 이 클래스의 구현 상황을 보자.
RCPPtr을 RCPtr과 오직 두가지 점에서 다른다. 첫번째는 RCIPtr이 중간 조정자인 CountHolder통해서 접근하는 것과 달리 RCPtr 객체는 값을 직접 가리킨다는 점이다. 두번째로는 operator->와 operator*을 오버로드(overload)해서 copy-on-write에 자동적으로 대응할수 있게 하였다.
~cpp template<class T> class RCIPtr { public: RCIPtr(T* realPtr = 0); 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<class T> void RCIPtr<T>::init() { if (counter->isShareable() == false) { T *oldValue = counter->pointee; counter = new CountHolder; counter->pointee = new T(*oldValue); } counter->addReference(); } template<class T> RCIPtr<T>::RCIPtr(T* realPtr) : counter(new CountHolder) { counter->pointee = realPtr; init(); } template<class T> RCIPtr<T>::RCIPtr(const RCIPtr& rhs) : counter(rhs.counter) { init(); } template<class T> RCIPtr<T>::~RCIPtr() { counter->removeReference(); } template<class T> RCIPtr<T>& RCIPtr<T>::operator=(const RCIPtr& rhs) { if (counter != rhs.counter) { counter->removeReference(); counter = rhs.counter; init(); } return *this; } template<class 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<class T> // const 접근; const T* RCIPtr<T>::operator->() const // copy-on-write를 비감안할 { return counter->pointee; } template<class T> // non-const 접근 T* RCIPtr<T>::operator->() // copy-on-write 감안 { makeCopy(); return counter->pointee; } template<class T> // const 접근; const T& RCIPtr<T>::operator*() const // copy-on-write를 비감안할 { return *(counter->pointee); } template<class T> // non-const 접근 T& RCIPtr<T>::operator*() // copy-on-write 감안 { makeCopy(); return *(counter->pointee); }
그럼 RCIPtr에 비하여 RCWidget은 상당히 구현이 간단하다. 거의 모든 기능이 RCIPtr에서 구현되었고, RCWidget은 그것을 통해서 인자만 전달하면 되기 때문이다.(delegate) 만약 Widget이 다음과 같이 생겼다면
RCWidget은 아마 이렇게 구현될 것이다.
(작성자주: 기타 내용은 그냥 내부에 대한 설명이다. 생략한다. 나중에 시간이되면 추가)
~cpp class Widget { public: Widget(int size); Widget(const Widget& rhs); ~Widget(); Widget& operator=(const Widget& rhs); void doThis(); int showThat() const; };
~cpp class RCWidget { public: RCWidget(int size): value(new Widget(size)) {} void doThis() { value->doThis(); } // delegate시켜 준다. int showThat() const { return value->showThat(); } // delegate시켜 준다. private: RCIPtr<Widget> value; };
1.9. Evaluation : 평가 ¶
지금까지, widget, string, 값(value), 스마트 포인터(smart pointer), 참조 세기 기본 클래스(reference-counting base class)에 관해서 구체적인 부분을 다루어 왔다. 이 모든 것은 우리에게 참조 세기를 적용할수 있는 넓은 폭을 가져다 주었다. 이제 조금 일반적인 이야기로, 질문해 보자. 다시 말하자면, 대체 언제 참조 세기의 기술을 적용 시켜야 할까?
참조세기의 구현은 공짜가 아니다. 모든 참조세기는 참조세기에 대한 그만한 비용을 지출하야 하는데, 사용자는 이러한 방법론의 적용에, 검증을 원한다. 단순히 보면, 참조세기는 더 많은 메모리를 잡아먹게도 할수 잇고, 더 많은 코드를 잡아 먹게 할수 있다. 거기에다 모잘라, 코드를 더 복잡하게 하고, 정성들여 만든 코드에 대하여 망쳐 버릴수도 있다. 마지막에 최종 구현된 String(StringValue, RCObject, RCPtr이 적용된 버전) 클래스 보다는, 보통 잡조세기가 적용 안된 코드들을 쓴다. 사실 우리가 디자인한 좀더 복잡한 디자인은 자료의 공유로 더 좋은 효율을 끌어 들인다. 그것은 객체의 소유권들을 주리고, 참조세기의 재사용 방법에 대한 생각들을 제시한다. 그럼에도, 네가지의 클래스를 사용해야 하고, 태스트하고, 문서화하고, 유지 ㅗ스하는데에는, 하나의 클래스를 작성,문서화,유지보수 하는것보다 더 많은 일을 부담하게 만든다.
참조세기는 보통의 객체들을 공유해서 시스템의 비용을 줄이고자 하는 최적화 기술이다. 즉, 공유를 많이 하지 않은 프로그램 객체에 대하여 이를 적용하면 더 많은 비용과, 더 복잡한 프로그램을 작성할수 밖에 없다는 결론이 나는 것이다. 그 반대라면, 시간, 공간 비용 모두를 아끼게 해줄 것이다. 그러한 상황을 생각해 본다.
- 적은 자료를 많은 객체들이 사용하고자 한다. 이러한 경우에는 생성자와 복사에 관한 비용이 많이 든다. 이런 경우 참조세기의 적용이 더 놓은 효율을 끌어 낼수 있을 것이다.
- Relatively few values are shared by relatively many objects.
- Relatively few values are shared by relatively many objects.
- 객체를 생성하고, 파괴하는데 높은 비용을 지불해야 하거나, 많은 메모를 사용한다. 이러한 경우 조차 참조세기는 많은 객체가 공유하면 할수록 비용을 줄여 줄것이다.
- Object values are expensive to create or destroy, or they use lots of memory.
- Object values are expensive to create or destroy, or they use lots of memory.
지금까지의 구현으로 참조세이긔 자료는 heap영역에만 선언할수 있다. 아무리 지역 변수로 선언한들, 내부에서 자료는 heap영역에 선언하여, 관리된다. 정확한 선언이된 클래스를 만들어야 하고, 그래서 확실히 동작하는가 확신할수 있는 버그 없는 코드를 만들어야한다. 그리고 이 참조세기 기본클래스가 우리의 손을 올바르게 떠나는 것은 사용 방법에 대하여 정확한 기술이 필요하다.
DeleteMe)영어가 짧은지 뒤의 내용은 지루한 같은 토론의 연속같다. 추가할 기운도 안난다.
2. Item 30: Proxy ¶
- Item 30: 대리자
이 코드는 합법이다.:
하지만 차원의 크기는 변수가 될수 없다. 이런것이 안된다.:
거기에 Heap 영역에 기반한 할당 역시 규칙에 어긋난다.
~cpp int data[10][20]; // 2차원 배열 10 by 20
~cpp void processInput(int dim1, int dim2) { int data[dim1][dim2]; // 에러! 배열의 차원은 단지 컴파일 중에만 결정된다. ... }
~cpp int *data = new int[dim1][dim2]; // 에러!
2.1. Implementing Two-Dimensional Arrays 이차원 배열의 구현 ¶
다차원의 배열은 C++에 뿐아니라. 다른 언어에서도 유용하다. 그래서 다차원 배열은 최근에 이것들에 지원하는 방법에 대한 중요성이 대두되고 있다. 보통의 방법은 C++에서 표준 중에 하나이다.(필요로한 객체를 표현하기 위해 클래스를 만든다. 하지만 알맞게 구현하기가 어렵다. ) 바로 이차원 배열에 대한 템플릿을 정의할수 있다.
원하는데로 배열을 정의할수 있다.
하지만 이러한 배열 객체의 사용은 완벽하지 않다. C와 C++상에서 기본 문법을 적용시킨다면, 괄호를 사용해서 객체의 index를 사용할수 있어야 한다.
그렇지만 Array2D상에 인텍스에 관한인자를 어떻게 정의하고, 사용할까?
~cpp template<class T> class Array2D { public: Array2D(int dim1, int dim2); ... };
~cpp Array2D<int> data(10, 20); // 옳다 Array2D<float> *data = new Array2D<float>(10, 20); // 옳다 void processInput(int dim1, int dim2) { Array2D<int> data(dim1, dim2); // 옳다 ... }
~cpp cout << data[3][6];
첫번째 하고 싶은건 아마 operator[][]를 선언해 버리는 것이다. 이렇게
보자마자 이 연산자의 의도를 알것이다. 하지만 operator[][]란건 선언할수가 없다. 그리고 당신의 컴파일러역시 이것을 감안하지 않을 것이다. (오버로드(overload)와 관련한 연산자들에 관한 정보는 Item 7을 참고하라) 우리는 그외의 다른 방안을 찾아야 한다.
~cpp template<class T> class Array2D { public: // 이러한 선언은 컴파일 할수 없다. T& operator[][](int index1, int index2); const T& operator[][](int index1, int index2) const; ... };
만약 문법 때문에 골머리가 아프다면, 배열을 지원하는 많은 언어에서 사용하고 있는 방법을 따라서, ()를 이용하는 인텍스의 접근을 만들어 볼수도 있다. ()의 이용은 단지 operator()를 오버로드(overload)하면 된다.
클라이언트에서는 이렇게 사용한다.
이러한 구현은 쉽고, 당신이 사용하고자 하는 많은 차원에서 일반화 시키기도 용이하다. 하지만 결점이 있는데, Array2D 객체는 built-in 배열같이 보이지 않는다는 점이다. 사실 위의, 각 data의 인자들에 대하여 (3,4)과 같은 접근 방법은 함수 호출과 같은 모습을 하고 있다.
~cpp class Array2D { public: // 이런 사항은 잘 컴파일 된다. T& operator()(int index1, int index2); const T& operator()(int index1, int index2) const; ... };
~cpp cout << data(3, 6);
FORTRAN과 같이 보이는 이러한 배열 표현법이 마음에 들지 않는다면, index 연산자와 같은 개념을 으로 다시 돌아가 본다. operator[][]를 사용하지 않고도 합법적으로 다음과 같이 보이는 코드를 구현할수는 없을까?
어떻게 하면 될까? 변수인 data는 실제로 이차원 배열이 결코 아니다. 그것은 10개-인자가 하나의 차원으로 된 배열으로 주어진 것이다. 10개 인자는 각기 20개의 인자를 가진 배열로 되어 있다. 그래서 data36은 실제로는 (data3)6를 의미하는것이다. 다시 말하자면, data의 네번째 인자인 배열의 일곱번째 인자. 짧게 말해 값은 처음 괄호의 의미는 또다른 배열이다. 그래서 두번째 괄호의 적용은 두번째의 배열로 부터 인자를 가지고 오는 것이다.
~cpp int data[10][20]; ... cout << data[3][6];
같은 방식을 Array2D에 operaotr[]가 새로운 객체인, Array1D를 반환시키는 방식으로 풀어나가 보자. 원래 이차원 배열의 안에 존재하는, 반환되는 인자 Array1D에서 operator[]를 오버로드할수 있다.
이러게 하면 다음과 같은 문법이 합법적이다.
여기에서 data3은 Array1D를 이야기 하는 것이고, operator[]는 두번째 차원에 위치 (3,6)에 있는 float를 호출한다.
~cpp template<class T> class Array2D { public: // 2번째 차원에 위치하는 Array1D class Array1D { public: T& operator[](int index); const T& operator[](int index) const; ... }; // 위의 1차원 배열 객체 Array1D Array1D operator[](int index); const Array1D operator[](int index) const; ... };
~cpp Array2D<float> data(10, 20); ... cout << data[3][6]; // 옳다.
Array2D 클래스의 클라이언트는 Array1D클래스에 관해서 신경 쓸필요 없다. 이러한 객체는 1차원의 배열에대한 객체의 표준이지만, 개념적으로는 존재하지 않는다. 이것들을 실제로 쓰는 그러한 클라이언트들은 이차원 배열을 정확히 프로그램 한다. C++의 엉뚱한 짓을 만족시키기 위하여, 일차원 배열을 다루는데 문법적으로 정확히 구현한 Array2D의 클라이어트들이 걱정하는 일이 없다.
2.2. Distinguishing Reads from Writes via operator[] : operator[]의 쓰기에 기반한 읽기를 구별 ¶
다차원 배열과 같은 인스턴스를 만드는 프록시의 사용은 일반적이다. 하지만 프록시 클래스들은 일반 배열보다 유연하지 못하다. Item 5에서 예를 들어 보면 어떻게 프록시 클래스들이 의도하지 않은 생성자의 사용을 막을수 있는지 방법을 보여준다. 하지만 프록시 클래스의 다채로운 사용이 가장 잘알려진 것은 마로 operator[]에서 write와 read를 구분하는 것이다.
operator[]를 지원하는, 참조세기가 적용된 문자열 형에 관해서 생각해 보자. 자세한 설명은 Item 29를 참고하라, 만약 Item 29의 방법대로 참조세기의 개념을 적용해서, 그것을 배열에 일반화 시키는 것은 좋은 생각이다.
operator[]를 지원하는 문자열 형은 클라이언트에게 다음과 같은 코드를 허용한다.
operator[] 는 각기 다른 목적으로 호출될수 있음을 유의하라: 문자를 읽거나 혹은 문자를 쓰거나, 읽기는 rvalue의 형태로 쓰여지도록 알려져 있다.; 그럼 쓰기는 lvalue형태(r은 right hand value, l은 left 이하 같음) 일반적으로 lvalue의 의미는 객체에서 그러한 객체의 수정을 의미하며, rvalue는 수정을 할수 없는 것을 의미한다.
~cpp String s1, s2; // 문자열과 비슷한 클래스;프록시의 쓰임은 // 표준 문자열 인터페이스를 따르는 클래스 형태를 // 유지한다. ... cout << s1[5]; // s1 읽기 s2[5] = 'x'; // s2 쓰기 s1[3] = s2[8]; // s1 쓰기, s2 읽기
이러한 상태, 즉 perator[]에서 lvalue와 rvalue를 구분해야만 한다. 왜냐하면 참조세기가 적용된 자료구조의 경우에 읽기는 쓰기에 비하여 훨씬 적은 비용을 소모하기 때문이다. Item 29에서 참조세기 객체의 쓰기는 아마 전체 자료구조의 복사를 유도하지만, 읽기는 간단한 값의 반환을 의미한다고 설명했다. 불행히도, operator[]의 내부에서, 이들의 호출 목적을 구분할 방법은 없다. operator[]는 lvalue와 rvalue의 쓰임의 차이를 구분할수 없다.
"그렇지만 잠시!" 하고 당신이 말한다. "꼭 그럴 필요가 없다. operator[]의 상수화 된 개념을 받아들여서 operator[]의 읽기와 쓰기를 구분하면 되지 않은가?" 이러한 다른 측변으로 당신은 우리에게 문제의 해결 방식을 제안한다.
불행히도 이러한 것은 수행되지 않는다. 컴파일러는 const와 non-const 멤버 함수의 구분을 오직 그 객체가 const인가의 여부에 따라만 판단한다. 이러한 구현은, const구별의 목적을 위해 아무런 영향을 못 끼친다.
그러므로 이런 방식의 operator[]의 오버로드는 읽기와 쓰기의 구분에 실패한다.
~cpp class String { public: const char& operator[](int index) const; // 읽기 위해 존재 char& operator[](int index); // 쓰기 위해 존재 ... };
~cpp String s1, s2; ... cout << s1[5]; // non-const operator[] 호출 왜냐하면 // s1이 non-const이기 때문에 s2[5] = 'x'; // 역시 non-const operator[] 호출: s2는 non-const이다. s1[3] = s2[8]; // 둘다 non-const operator[] 호출 왜냐하면 s1,s2모두 // non-const 객체이다.
Item 29에서 우리는 operator[]를 쓰기를 위해서 재 디자인했다. 아마 이걸 쉽게 포기할수는 없을 꺼다.(작성자주:얼마나 고생하면서 봤는데, 바꾸기 싫지.) Item 29의 디자인은 lvalue와 rvalue의 사용을 구분하는 것이 아니라, operator[]를 호출하면 무조건 쓰기로 취급해 버리는 것이다.
operator[]가 읽기와 쓰기를 구분 못하지만 일단 우리는 가능한한 이것을 구현해 보고자 하는 입장에서 접근해 보자. operator[]가 반환한 이후에 읽기와 쓰기의 상태를 알아내는 방법을 필요로 한다. 이것의 의미는 앞에 다루었던, lazy evaluation의 개념과 비슷하지 않을까?
프록시 클래스는 우리가 필요한 시간을 벌어 줄수 있다. 우리는 operator[]의 반환인자를 문자대신에 문자열을 위한 프록시 객체를 반환하도록 수정할수 있기 때문이다. 우리는 이렇게 프록시를 사용해서 시간을 벌수 있다. 이 프록시 클래스가 읽힐때, operator[]가 읽기인지 쓰기인지를 알수 있다.
일단 소스를 보기전에 우리가 프록시를 어떻게 써야할지 세가지의 순서로 나누어 생각하자.
- 프록시를 만든다. 다시 말해 문자열에서 문자를 대신하는 것에 알맞도록 만든다.
- 프록시를 써야할 곳, 즉 문자열의 글자를 할당할 곳에 적용한다. 적용을 할때 프록시는 operaotr[]에서 lvalue의 쓰임으로 사용된다.
- 또 다른 방식으로 프록시를 사용한다. 이렇게 사용되면 프록시는 operator[]에 대한 rvalue의 쓰임을 구현한다.
~cpp class String { // 참조세기가 적용된 문자열, Item 29참고 public: class CharProxy { // 문자의 프록시 public: CharProxy(String& str, int index); // 생성 CharProxy& operator=(const CharProxy& rhs); // lvalue CharProxy& operator=(char c); // 의 쓰임에 반응 operator char() const; // rvalue의 쓰임에 반응 // use private: String& theString; // 프록시에서 문자열을 참조할 경우가 필요할시 int charIndex; // 문자의 인덱스 }; // String클래스가 포함하고 있는 것 const CharProxy operator[](int index) const; // const String에 반응 CharProxy operator[](int index); // non-const String을 위해서 ... friend class CharProxy; private: RCPtr<StringValue> value; };
~cpp String s1, s2; // 프록시를 사용하는 참조 세기가 적용된 문자열 ... cout << s1[5]; // 옳다. s2[5] = 'x'; // 역시 옳다. s1[3] = s2[8]; // 역시나 잘돌아간다.
맨처음에 이 구문을 생각해 보자.
s15의 표현은 CharProxy 객체를 반환한다. s15가 output(<<) 연산자에 대하여 객체에 대하여 정의된것은 없다. 그래서 당신의 컴파일러는 operator<<에 적용할수 있는 암시적(implicit) 형변환을 찾는다. 컴파일러는 그래서 프록시 클래스 내부에 선언되어 있는 char()를 찾을수 있다. 컴파일러는 이(char) 형변환을 수행하기를 요청하고, 결과적으로 CharProxy는 문자로 변환되어서 출련되어 진다. 다시 말하지만, 이것은 CharProxy-to-char 로의 형변환이 CharProxy내부에 암시적(implicit) 형변환이 선언되어 있기 때문이다.
~cpp cout << s1[5];
lvalue의 사용은 좀 다르게 잡히는데, 이 구문을 다시 보자.
s25의 표현은 CharProxy객체를 반환한다. 그리고 할당(assignment)연산자의 목표가 된다.어떤 할당(assignment) 연산자가 불려지는 걸까? 할당의 목표는 CharProxy이다. 그래서 할당연산자는 CharProxy 클래스 안에서 불려진다. 이것은 중요한 것이다. 왜냐하면 CharProxy의 할당(assignment) 연산자를 사용하는것으로 우리는 Stirng에서 lvalue로서 이번 연산이 수행된다는 것을 알수있다. 그래서 우리는 문자열 클래스가 이번에는 lvalue에 알맞는 동작을 해야 한다는 결론을 얻는다.
~cpp s2[5] = 'x';
비슷하게 다음과 같은 구문을 보면
이것은 두개의 CharProxy를 위해서 할당 연산자가 동작하고, 하나는 char으로 암시적 변환, 또 하나는 CharProxy객체의 할당 연산자를 사용하는 것
으로 둘은 lvalue와 rvalue를 구분하고 올바르게 사용하게 된다.
자, 여기 그럼 String의 operator[]에 대한 새로운 구현 코드가 있다.
각 함수는 문자 요구시에 CharProxy 객체를 만들어서 반환한다. 문자열은 스스로는 아무 일을 하지 못한다. 우리는 그러한 동작을 읽기와 쓰기의 접근을 알수있을때 까지 지연시키도록 만들어야 한다.
~cpp s1[3] = s2[8];
으로 둘은 lvalue와 rvalue를 구분하고 올바르게 사용하게 된다.
자, 여기 그럼 String의 operator[]에 대한 새로운 구현 코드가 있다.
~cpp const String::CharProxy String::operator[](int index) const { return CharProxy(const_cast<String&>(*this), index); } String::CharProxy String::operator[](int index) { return CharProxy(*this, index); }
const 버전의 operator[] 는 const proxy 객체를 반환해야 하는 것을 보자. CharProxy::operator=은 const 멤버 함수가 이니기 때문에 할당(assignment)의 목표가 되지 않는다. 그러므로, proxy는 const 버전의 operator[]나, lvalue로서의 문자열의 사용을 고려하지 않아도 된다. 간단히, const 버전의 operator[]에서도 정확히 돌아간다는 이야기이다.
이번에는 CharProxy를 만들때 const버전의 operator[]에서 const_cast(Item 2참고)를 사용해서 *this를 넘기는걸 주목하자.저것은 CharProxy생성자에 조건에 부합하기 위한 수행으로, non-const String만 인자로 받기위해서 형변환을 수행한다. 형변환은 보통은 귀찮다. 그렇지만 이러한 경우에 CharProxy 객체는 그것 자체가 const이기 때문에 String가 포함하고 있는 proxy가 참조하는 String은 수정되어지는 걱정이 없을 것이다.
operator[]에 의해 반환되는 각 proxy는 표현을 위하여 문자로서 필요한 인덱스와 글자 정보를 수록하고 있다.
rvalue로의 proxy의 형변환은 곧바로 일어 난다. 즉, 단지 proxy에서 형변환으로 해당하는 수행만 해주면 된다.
만약 String객체와 관계를 읽어 버렸다면, value 멤버와 data 멤버와의 관계에 대하여 Itmem 29를 보고 기억해라. 이 함수는 문자를 값으로(by-value)로 전달한다. 그리고 C++은 그러한 값으로(by-value) 전달을 반환 값으로만 반환 값을 사용하도록 제한하기 때문에 다음과 같은 형변환 함수는 오직 rvalue때만 유효하다.
~cpp String::CharProxy::CharProxy(String& str, int index) : theString(str), charIndex(index) {}
~cpp String::CharProxy::operator char() const { return theString.value->data[charIndex]; }
그래서 CharProxy의 할당(assignment) 연산자 구현으로 lvalue에 해당하는 작업만 가능하게 구현할수 있다. 이제 CharProxy의 할당 연산자를 다음과 같이 구현한다.
Item 29에 나와있는, non-const String::operator[]과 비교해보면 인상적인 느낌이 올것이다. 이것은 예측할수 있다. Item29에서는 이러한 쓰기와 읽기를 구분하지 못해서 무조건 non-const operator[]를 쓰기로 취급했다. 그리고, CharProxy는 String에 대한 자유로운 접근을 위해 friend로 선언했기에 문자값의 복사 과정의 수행이 가능하다.
~cpp String::CharProxy& String::CharProxy::operator=(const CharProxy& rhs) { // 만약 문자열이 다른 String객체와 값을 공유가 가능할 경우 if (theString.value->isShared()) { theString.value = new StringValue(theString.value->data); } // 문자 값의 복사 과정 theString.value->data[charIndex] = rhs.theString.value->data[rhs.charIndex]; return *this; }
이제 나머지 직접 문자열이 입력될때를 구현한다.
Software Engineer을 수행하는 입장이라면 물론 이와 같이 CharProxy를 통해서 읽기와 쓰기를 구분해서, 복사에 해당하는 코드를 삭제해야 한다.하지만 이것에 대한 결점을 생각해 보자.
~cpp The second CharProxy assignment operator is almost identical: ¤ Item M30, P58 String::CharProxy& String::CharProxy::operator=(char c) { if (theString.value->isShared()) { theString.value = new StringValue(theString.value->data); } theString.value->data[charIndex] = c; return *this; }
2.3. Limitations : 제한 ¶
proxy 클래스의 사용은 operator[] 사용시 lvalue와 rvalue의 구분을 명료하게 한다. 그렇지만 무시할수 없는 결점을 가지고 있다. proxy객체는 그들이 목표하는 객체를 완전히 교체하는 것이 목표지만 정말 어렵다. 여기에서 문자처럼, lvalue와 rvalue의 목적뿐 아니라. 다른 방법으로 객체는 접근될수 있다.
Item 29에 언급된 공유플래그를 더한 StringValue객체에 관해서 다시 생각해 보자. 만약 String::operator[] 가 char&대신에 CharProxy를 반환한다면 이러한 경우는 더이상 컴파일 할수가 없다.
s11의 표현은 CharProxy를 반환하기 때문에, 두번째에서 오른쪽의 의미는 CharProxy*를 반환하는 것이다. 하지만 CharProxy*를 char*로 바꾸는 형변환은 허용되지 않기때문에 p의 초기화에서 컴파일러는 에러를 낸다. 일반적으로 proxy의 주소는 실제 객체보다 다른 포인터 타입을 가진다.
~cpp String s1 = "Hello"; char *p = &s1[1]; // 에러!
이러한 문제를 제거하기 위하여 주소에 관한 연산자를 CharProxy 클래스에 오버로드(overload)한다.
이 함수는 구현하기 쉽다. const 함수는 단지 const 버전의 문자의 포인터를 프록시 클래스로 전달하면 된다.
non-const 함수의 경우 좀더 신경쓸것이 많은데, 반환되는 포인터의 문자가 수정 가능성이 있기 때문이다. 이러한 경우는 Item 29에서 다루었던 non-const 버전의 String::operator[]의 모습과 비슷하다.그리고 구현 역시 비슷하다.
이 코드는 CharProxy의 다른 멤버 함수들과 같이 평범하다.
~cpp class String { public: class CharProxy { public: ... char * operator&(); const char * operator&() const; ... }; ... };
~cpp const char * String::CharProxy::operator&() const { return &(theString.value->data[charIndex]); }
~cpp char * String::CharProxy::operator&() { // 다른 객체와 정보를 공유할때는 새 자료를 만든다. if (theString.value->isShared()) { theString.value = new StringValue(theString.value->data); } // 이제 이 함수가 반환하는 객체를 통하여 수정할수 있다. 그러므로 // 공유 못하도록 막는다. theString.value->markUnshareable(); return &(theString.value->data[charIndex]); }
두번째의 결과, CharProxy가 다른점은 lvalue와 rvalue의 구분을 위해 operator[]가 적용된 프록시 클래스를 사용하는, 참조세기 배열 템플릿이라면, 확연히 드러나는 것이다.
이렇게 구현된 배열을 어떻게 사용하는가 보자.
예상되는 대로 operator[]의 목표인 간단한 할당(assignment)은 성공하지만, left-hand의 operator[]에서 operator+=이니 operator-=를 호출하는건 실패한다. 왜냐하면 operator[]가 반환하는 것은 프록시 객체 이기 떄문이다. 비슷한 경우에 존재하는 모든 연산자가 실패한다. operator*=, operator/=, operator<<=, operator-= 등 말이다. 만약 이런 수행을 원한다면 이러한 함수를 모두 선언해주어야 하는데, 이는 너무 일이 많다 그리고 아마 원하지도 않을 것이다. 추가한들 추가하지 않은들 둘다 괴로운 일이다.
~cpp template<class T> // 프록시를 사용하는 class Array { // 참조세기 적용 배열 public: class Proxy { public: Proxy(Array<T>& array, int index); Proxy& operator=(const T& rhs); operator T() const; ... }; const Proxy operator[](int index) const; Proxy operator[](int index); ... };
~cpp Array<int> intArray; ... intArray[5] = 22; // 옳다. intArray[5] += 5; // 에러! ++intArray[5]; // 에러!
관계있는 문제로 프록시를 통한 실제 겍체의 호출에서 일어날수 있는데, 할수 없는것에 관한 모호성이다. 예를들어서, 유리수 배열을 참조세기로 구현했다고 해보자. 이것을 Rational 클래스로 정의하고 Array 템플릿을 사용한다. 코드는 다음과 같다.
이는 예측할수 있는 배열의 사용이다. 하지만 허용되지 않는다.
이러한 어려움은 충분히 예상된다. operator[]가 반환하는 유리수에 관한 프록시이지 진짜 Rational객체가 아니다. numerator과 denominator 멤버 함수는 Rational위해서만 존재하지 프록시를 위해서는 존재하지 않는다. 그러므로 컴파일러가 이에 대한 수행을 못한다. 프록시를 만드는것은 그들이 의미하는 객체와 비슷한거지 완전히 동일한 객체의 기능을 제공할수 없다.
~cpp class Rational { public: Rational(int numerator = 0, int denominator = 1); int numerator() const; int denominator() const; ... }; Array<Rational> array;
~cpp cout << array[4].numerator(); // 에러! int denom = array[22].denominator(); // 에러!
아직도 프록시가 진짜 객체를 교체하기 힘든 문제는 남아 있다. 다음과 같이 reference로 넘길때 조차 말이다.
String::operator[]는 CharProxy를 반환하지만 swap가 원하는 것은 char&이다. CharProxy는 아마 암시적(implicit) 형변환으로 char로 변화할것이고 char&로는 변환이 필요 없다. 개다가 형변환된 char은 swap내부에서 수행에 소용이 없다. 왜냐하면, char은 임시 객체이기 때문이다. 이에 대한것은 Item 19에 자세히 언급되어 있다.
~cpp void swap(char& a, char& b); // a 와 b의 값을 바꾼다. String s = "+C+"; // 에구, 이거 이 문자열은 "C++" 일텐데. swap(s[0], s[1]); // 그것을 고칠려고 한다.
마지막 프록시가 실패하는 진짜 객체를 교체하지 못하는 상황은 암시적(implicit) 형변환에서 기인한다. 프록시 객체는 암시적(implicit)으로 진짜 객체로 형변환할때 user-defined 형변환 함수가 불린다. 예를들어서 CharProxy는 char로 operator char을 호출해서 변화한다. Item 5의 설명을 보면 컴파일러는 user-defined 형변환 함수를 반응하는 인자로의 필요성이 있는 부분에서 해당 연산을 호출한다고 한다. 결국 함수 호출은 프록시가 전달될때 실패하면 실제 객체를 넘기는 것을 성공시켜서 가능한 것이다. 예를들어서 TVStation리하는 클래스에 watchTV 함수가 있다고 한다면:
암시적 수행에 의해서 우리는 다음과 같이 수행할수 있다.
하지만 프록시 사용, 참조세기 적용 배열을 사용한다면
이 문제는 암시적 형변환이 주는 또 하나의 문제이다. 이것을 해결하기는 어렵다. 사실 TVStation에 생성자를 explicit로 선언하는 것이 더 좋은 디자인일 것이다. 그렇다면 watchTV에서 컴파일이 실패한다. explicit에 관한 자세한 설명은 Item 5를 참고하라
~cpp class TVStation { public: TVStation(int channel); ... }; void watchTV(const TVStation& station, float hoursToWatch);
~cpp watchTV(10, 2.5); // 10번 본다. 2.5시간 본다.
~cpp Array<int> intArray; intArray[4] = 10; watchTV(intArray[4], 2.5);
2.4. Evaluation : 평가 ¶
Proxy클래스는 구현하기 어려운 어떤 상황에서 해결책이 될수 있다. 다차원 배열이 첫번째, lvaue/rvalue가 두번째 , 암시적 형변환 금지가 세번째 이다.
또 Proxy 클래스는 단점도 많이도 가지고 있다. 함수가 값을 반환할때 프록시 객체들은 임시 인자(temporaries:Item 19참고)로 전달된다. 그래서 그들은 생성, 삭제된다. 이것은 공짜가 아니다. 읽기와 쓰기의 경우를 가리기 위한 조치도, 임시인자를 만들기 때문에 비용이 발생한다. 프록시 클래스가 있어서 소프트웨어 구조는 복잡해 진다. 더 어려운 디자인, 구현, 이해 그리고 유지 보수..
마지막으로 진짜 객체에 대한 일의 대행은 종종 문법의 제한을 가지고 온다. 왜냐하면 프록시 객체가 실제 객체를 완전히 교체할만큼 능력을 가질수 없기 때문이다. 대로 프록시는 시스템의 디자인시 더 나쁜 선택을 가지고 온다. 하지만 많은 경우 프록시의 존제는 클라이언트에게 그 수행을 의식하게 하는 경우는 거의 없다. 예를 들어서 클라이언트는 이차원 배열의 예제에서 Array1D 객체의 주소를 원하는 클라이언트는 거의 없다. 그리고 ArrayIndex객체(Item 5참고)는 예상되는 다른 형태의 함수로 전달 될것이다. 많은 경우에 프록시는 진짜 객체의 수행을 대행한다. 그들을 적용할때, 아무일 없는건 거의 대부분이다.