제네릭 프로그래밍과 디자인 패턴을 적용한 Modern C++ Design(번역서) 정리
※ Loki<library> 다운로드 링크:
http://loki-lib.sourceforge.net/index.php?n=Main.Download
※ 직접 다운받기:
loki-book-1st-edition-version.zip
※ HTML문서로 보기:
Loki 라이브러리
※ Loki 라이브러리 라이선스:
MIT License
읽다보면 Loki 라이브러리를 참조하는 경우가 있어서 Loki 라이브러리를 링크와 함께 첨부합니다. 덧붙여 예제 코드에, 식별자 이름을 틀리는 사소한
문제부터 아예 컴파일이 되지 않을 정도의 문제까지, 여러 문제점이 포함되어 있는 것을 발견했습니다. 여기에 옮기며 가능하면 수정을 하였으나, 미처 발견하지
못하고 수정하지 못한 예제 코드가 있을 수도 있습니다. 이점 양해 바랍니다.
차례
-
-
단위전략 기반의 클래스 디자인(policy-based class design)이란 매우 단순한 동작이나 구조만을 가지는
작은 클래스(policy, 단위전략)들을 모아서 복합된 기능을 하는 새로운 클래스를 만들어 내는 디자인 방식을 말합니다.
-
소프트웨어 공학은 - 아마도 다른 그 어떤 공학보다 더 - 풍부한 다양성(어떤 문제를 해결하는 방법이 매우 다양함)을 지니고 있습니다.
소프트웨어 시스템을 디자인하는 일이란, 문제를 해결하는 다양한 방법들 중에서 하나씩 어느 한 쪽을 선택해야만 하는 연속적인 선택의
과정이라 할 수 있을 것입니다.
이와 같이 연속적인 선택을 강요하게 되는 소프트웨어 디자인의 속성은 초보자들에게는 도저히 풀 수 없는 어려운 문제가 됨은 물론이고,
어느 정도 숙달된 라이브러리 제작자들에게도 가장 심각한 문제의 원천이 되곤 합니다. 유용한 라이브러리를 디자인하기 위해서는 많은
전형적인 상황들을 분류하고 잘 조절해야 하며, 애플리케이션 제작자들이 특정한 상황의 요구에 맞추어 라이브러리를 수정하고 적용할 수
있도록 배려해야만 합니다.
진정으로 유연하고도 안전한 디자인 컴포넌트 라이브러리를 만들려면 어떻게 해야 할까요? 그러한 컴포넌트들을 사용자가 쉽게 조율하여
사용할 수 있도록 하려면 어떻게 해야 할까요? 적절한 양의 코드로 '다양성의 재앙'과 맞서 싸우기 위해서는 어떻게 해야 할까요?
이러한 질문들에 대한 대답이 바로 이번 장에서 여러분께 설명하고자 하는 내용입니다.
-
Do-It-All 인터페이스(말 그대로 필요한 모든 기능과 동작에 대한 인터페이스를 일일이 정의하여 단순히 나열시킨 디자인을 말함)의 모토 하에
모든 것을 하나하나 구현하는 것은 결코 좋은 방법이라 할 수 없습니다.
이러한 인터페이스는 그 크기와 효율성에 있어서 부정적인 결과를 가져옵니다. 이보다 더 큰 문제점은 사실 자료형의 정적 안정성을 잃는다는
것에 있습니다(이론적으로는 컴파일 타임에 검토가 가능한 오류들이 이러한 방식으로는 걸러지지 않는다는 사실을 의미함).
예를 들어, 싱글톤 라이브러리가 스레드 관련 코드까지 모두 포함하고 있다면, 호환성이 없는 스레드 모델을 가진 다른 시스템에서는 이 라이브러리를
사용할 수 없을 것입니다. 또한, 만일 라이브러리가 내부 코드들을 감추지 않고 그대로 접근할 수 있도록 허용한다면, 프로그래머가 이를 사용하면서
문법적으로는 옳지만 문맥상으로는 잘못된 코드를 작성하여 전체 설계를 망쳐 버릴 수도 있습니다.
만일 라이브러리가 서로 다른 디자인 요소들을 작은 별개의 클래스들로 나누어 따로따로 구현한다면 어떻게 될까요? 스마트 포인터의 경우로
예를 들어본다면, SingleThreadedSmartPtr, MultiThreadedSmartPtr, RefCountedSmartPtr, RefLinkedSmartPtr
등의 다양한 구현들이 가능하겠지요.
하지만 이러한 접근 방법도 지나치게 다양한 선택의 조합이 난립할 수 있다는 문제가 있습니다. 위에 네 개의 클래스들을 검토하다 보면 필연적으로
SingleThreadedRefCountedSmartPtr 과 같은 조합을 생각하게 됩니다. 절대로 brute-force 알고리즘(가능한 모든 경우를
일일이 시도해 보는 접근 방식)을 이용해서 이러한 기하급수적 조합에 일일이 대응해서는 안됩니다.
디자인은 항상 어떤 제한 조건들을 수반하게 됩니다. 디자인을 목적으로 한 라이브러리들은 미리 규정된 라이브러리 자체의 제한 조건을
가지기보다는, 사용자가 만든 디자인이 디자인 스스로의 제한 조건을 가지도록 만들어 주어야 합니다.
미리 결정된 선택 사항들은 이러한 라이브러리에서 매우 껄끄러운 요소 입니다. 물론, "아주 널리 쓰이는" 혹은 "추천되는" 준비된 해법들은
환영해도 좋을 것입니다. 나중에 클라이언트 프로그래머가 필요에 따라 이를 수정할 수 있도록 허용해 주기만 한다면 말이지요.
-
다중 상속을 통해 별개의 속성들을 하나로 엮는 방법의 문제점에는 다음과 같은 것들이 있습니다.
-
구조적 문제: 상속된 컴포넌트들을 잘 제어해가며 취합할 수 있는 공통된 코드 같은 것이
존재할 수가 없습니다. 쉽게 말해서, 각각의 컴포넌트들을 조합시킬 수 있는 유일한 도구가 다중 상속이라는
언어적 메커니즘뿐이라는 것이지요. 이것은 아주 단순한 경우를 제외하고는 실제로 적용될 수 없습니다. 원하는 동작을 얻고자 한다면, 대개 상속된
클래스 간의 동작을 면밀하게 조율해 주어야 할 필요가 있습니다.
-
자료형 파악의 문제: 기반 클래스들은 그들의 작업을 수행하기 위해 필요한 자료형에 관한 정보를 전혀
가지고 있지 못합니다. 예를 들어, 스마트 포인터의 온전한 복사 기능을 구현하기 위하여 DeepCopy라는 기반 클래스를 사용한다고 생각해 봅시다.
DeepCopy는 자신이 알지도 못하는 자료형의 객체를 생성해야만 합니다.
-
상태 처리의 문제: 기반 클래스를 통해 구현된 다양한 동작 요소들은 그 상태가 정확히 동기화
되어야 합니다. 이것은 상태를 담고 있는 기반 클래스를 상속하기 위해서는 가상 상속(virtual inheritance)을 이용해야만 한다는
것을 뜻합니다. 가상 상속은 디자인을 복잡하고 융통성 없는 구조로 만들게 됩니다.
다중 상속은 각 기반 클래스의 속성을 조합하여 가질 수 있는 좋은 특징을 지니고 있긴 하지만, 그것만으로 다양한 디자인 선택의 문제에 어떤 명확한 해답을
줄 수 있을 것이라고 기대하기는 힘듭니다.
-
템플릿은 컴파일 타임에 코드를 생성해 내며, 조합이 가능하다는 속성을 지니고 있기 때문에, 각 디자인 요소를 만들어 내는 데 아주 매력적인 도구가 될 수 있습니다.
만일 클래스 템플릿에서 일반적인 동작보다는 별도의 처리가 필요한 부분이 생긴다면, 클래스 템플릿을 구체화시키면서 얼마든지 특화된 코드를 추가할 수 있습니다.
더 나아가, 다중 인자(parameter)를 가지는 클래스 템플릿의 경우에는, 템플릿의 부분 특화(partial template specialization)가 가능합니다.
하지만 실제로 이러한 디자인을 구현해 나가다 보면, 왠지 모르게 모호한 몇 가지 문제에 부딪히게 됩니다.
-
자료형의 구조적 요소를 특화(specialize)시킬 수 없습니다.
템플릿만을 가지고는 클래스의 구조(데이터 멤버)를 특화시킬 수 없으며, 오직 함수에 대해서만 특화가 가능합니다.
-
멤버 함수의 특화에는 확장성이 없습니다.
템플릿 인자가 한 개일 경우에는 어떤 멤버 함수라도 특화가 가능하지만, 템플릿 인자가 여러 개일 경우에는 각각의 멤버 함수에 대한 특화가 불가능합니다.
예제 코드 보기
- template <class T> class Widget
- {
- void Fun(void) { .. 일반적인 구현 .. }
- };
-
- template <> Widget<char>::Fun(void)
- {
- ... 특화된 구현 ...
- }
-
- template <class T, class U> class Gadget
- {
- void Fun(void) { .. 일반적인 구현 .. }
- };
-
- template <class U> void Gadget<char, U>::Fun(void)
- {
- ... 특화된 구현 ...
- }
예제 코드 접기
-
라이브러리를 만들 때에 기본 값(default value)을 다중으로 제공할 수 없습니다.
클래스 템플릿을 구현할 때에는 각각의 멤버 함수에 대해 오직 하나의 기본 값만이 구현 가능합니다. 템플릿 멤버 함수에 대해
여러 개의 기본 값을 정의하는 것은 불가능합니다.
다중 상속이 가지는 약점과 템플릿의 약점을 비교해 보면 흥미롭게도, 다중 상속과 템플릿은 서로 상호보완적인 속성을 지니면서,
또한 서로 트레이드 오프(trade-off)인 관계에 있습니다.
다중 상속은 자료형에 대한 정보를 전혀 다룰 수 없지만, 템플릿은 자유롭게 다룰 수 있습니다. 템플릿의 특화는 확장성이 부족하지만, 다중 상속의 특화는
쉽게 확장이 가능합니다. 템플릿 멤버 함수는 오직 하나의 기본 값만을 가질 수 있지만, 기반 클래스는 전혀 제한 없이 정의가 가능합니다.
이러한 내용을 분석해 보면, 템플릿과 다중 상속을 병용하는 것이 디자인 요소로 사용될 라이브러리를 작성하는 데 매우 편리한 도구가 될 수 있다는 사실을 알 수 있습니다.
-
단위전략과 단위전략 클래스는 안전하고, 효과적이며, 커스터마이징이 용이한 디자인 요소를 만드는 데 도움이 되는 개념입니다.
단위전략은 클래스 인터페이스, 혹은 클래스 템플릿 인터페이스를 정의하게 됩니다.
인터페이스란 모름지기 내부 자료형의 정의, 멤버 함수 그리고 멤버변수중 어느 하나, 혹은 그 전부로 구성되어 있습니다.
예를 들어, 임의의 객체를 생성하는 단위전략을 정의해 보도록 합시다. Creator 단위전략은 자료형
T라는 클래스 템플릿의 속성을 다음과 같이 규정하게 됩니다. 이 클래스 템플릿은 Create라는 멤버 함수를 노출해야 합니다. Create 멤버 함수는
인자를 가지지 않으며, T에 대한 포인터를 반환하는 함수입니다. 그 의미상, Create 함수는 매 호출마다 새로운 T 형의 객체에 대한 포인터를
반환해 주어야 합니다. 객체가 생성되는 정확한 방식은 단위전략의 구현방법에 따라 달라질 수 있습니다.
이제, Creator 단위전략을 구현하는 단위전략 클래스(단위전략에 대한 구현물)를 정의해 보도록 합시다. 우선적으로 생각해 볼 수 있는 방법은 new 연산자를 사용하거나,
new 대신 malloc 함수를 호출하는 방법이 있을 수 있으며, 프로토타입 객체를 복사하여 새로운 객체를 생성하는 방법도 가능합니다.
여기에는 세 가지 방법 모두를 예제를 통해 다루어 보도록 하겠습니다.
단위전략 클래스 예제 코드 보기
- template <class T>
- struct OpNewCreator
- {
- static T* Create(void)
- {
- return new T;
- }
- };
-
- template <class T>
- struct MallocCreator
- {
- static T* Create(void)
- {
- void* buf = std::malloc(sizeof(T));
- if (!buf) return 0;
- return new(buf) T;
- }
- };
-
- template <class T>
- struct PrototypeCreator
- {
- PrototypeCreator(T* pObj = 0)
- : pPrototype_(pObj)
- {}
- T* Create(void)
- {
- return pPrototype_ ? pPrototype_->clone() : 0;
- }
- T* GetPrototype(void) { return pPrototype_; }
- void SetPrototype(T* pObj) { pPrototype_ = pObj; }
-
- private:
- T* pPrototype_;
- };
-
-
-
-
-
-
-
-
-
단위전략 클래스 예제 코드 접기
어떤 단위전략이 주어졌을 때에 그것은 여러 종류의 단위전략 클래스로 구현될 수 있습니다. 각 단위전략 클래스는 모두 주어진 단위전략에서 정의한 인터페이스를
준수해야 합니다. 그러면 사용자는 실제 코드에서 어떤 단위전략 클래스를 사용하는 것이 좋을지를 결정하게 됩니다.
위의 예제에서 정의한 세 단위전략 클래스들은 구 구현 방법이 모두 다르며, 심지어 인터페이스도 조금은 다릅니다. 어쨌든, 세 가지 모두 정해진 자료형을 반환하는
Create 멤버 함수를 가지며, 이로써 그들은 모두 Creator 단위전략을 구성하게 됩니다.
이제, Creator 단위전략을 사용하는 클래스를 디자인하는 방법에 대해 한 번 살펴보도록 합시다. 이 클래스는 다음 예제와 같이 위에서 정의된 세 가지 단위전략
클래스 중 하나를 상속받거나 또는 포함해야 합니다.
하나, 혹은 그 이상의 단위전략을 사용하는 클래스를 우리는 호스트 내지는
호스트 클래스(템플릿)라고 부릅니다.
예제 코드 보기
- template <class CreationPolicy>
- class WidgetManager : public CreationPolicy
- {
- ...
- };
-
-
-
-
-
- typedef WidgetManager< OpNewCreator<Widget> > MyWidgetMgr;
-
-
-
예제 코드 접기
-
대부분의 경우에, 단위전략의 템플릿 인자를 선택하는 것은 사용자의 몫이 됩니다. 사용자가 반드시 OpNewCreator의 템플릿 인자를 명시적으로
넘겨주어야 한다는 것은 사실 좀 불편한 일입니다. 일반적으로 호스트 클래스는 단위전략 클래스의 템플릿 인자를 이미 알고 있거나, 또는 쉽게
추론해 낼 수 있습니다.
위의 예에서는, WidgetManager가 항상 Widget 형의 객체를 관리하게 됩니다. 따라서 Widget의 인스턴스를 만들 때 사용자가
직접 OpNewCreator의 템플릿 인자를 명시하도록 하는 것은 잠재적인 위험 요소가 될 수 있습니다.
이러한 경우에, 라이브러리 코드는 템플릿 템플릿 인자를 사용하여 단위전략을 구성할 수 있습니다.
예제 코드 보기
- template <template <class Created> class CreationPolicy>
- class WidgetManager : public CreationPolicy<Widget>
- {
- ...
- };
-
-
-
- template <template <class> class CreationPolicy>
- class WidgetManager : public CreationPolicy<Widget>
- {
- ...
- };
-
-
-
- typedef WidgetManager<OpNewCreator> MyWidgetMgr;
-
-
-
-
-
- template <template <class> class CreationPolicy>
- class WidgetManager : public CreationPolicy<Widget>
- {
- ...
- void DoSomething(void)
- {
- Gadget* pW = CreationPolicy<Gadget>().Create();
- ...
- }
- };
-
-
-
- template <template <class> class CreationPolicy = OpNewCreator>
- class WidgetManager ...
예제 코드 접기
이렇게 단위전략을 사용하면, WidgetManager에 놀랄 만큼의 유연성을 부여할 수 있습니다. 먼저, 사용자는
외부 코드에서 객체에 대한 생성 전략을 바꿀 수 있습니다. 단지 WidgetManager 인스턴스를
만들면서 템플릿 인자만을 다르게 선택하면 됩니다.
또한, 사용자의 애플리케이션에 정확히 들어맞는 고유의 생성 전략을 제공할 수도 있습니다. new 연산자나, malloc 함수, 또는 prototype을 이용할 수
있을 뿐 아니라, 심지어 특정 시스템에서만 사용되는 고유의 메모리 관리 라이브러리를 사용할 수도 있는 것입니다.
가상 함수와 단위전략은 비슷한 효과를 낼 수 있지만, 단위전략이 단순한 가상 함수와는 상당히 다르다는 점에 주의하십시오. 단위 전략의 경우에는
자료형을 컴파일 타임에 미리 파악할 수 있으므로 정적 바인딩(가상 함수의 동적 바인딩과 반대되는 개념으로 보면 됨)이 가능합니다.
단위전략을 사용하면, 몇 가지 간단한 선택을 조합하는 것만으로 새로운 디자인을 창조해 낼 수 있으며, 자료형에 따른 안정성까지도 보장받을 수 있습니다.
더 나아가, 호스트 클래스와 단위전략 간의 연결이 컴파일 타임에 이루어지기 때문에 일일이 수작업으로 최적화시킨 코드에 뒤지지 않는, 단단하고도 효율적인
코드가 만들어지게 됩니다.
-
단위전략 클래스를 구성할 때 템플릿 템플릿 인자를 사용하는 대신에, 간단한 클래스들과 연결되는 템플릿 멤버 함수를
사용하는 것도 한 방법이 될 수 있습니다.
예를 들어, 우리는 Creator 단위전략을 템플릿 함수인 Create<T>를 노출하는 일반 클래스로 재정의할 수
있습니다.
예제 코드 보기
- struct OpNewCreator
- {
- template <class T>
- static T* Create(void)
- {
- return new T;
- }
- };
예제 코드 접기
이런 방식으로 단위전략을 정의하고 구현하는 것은 구형 컴파일러에서도 잘 컴파일된다는 장점을 가지게 됩니다.
반면에, 이렇게 정의된 단위전략은 대개 그것에 대한 논의, 정의 그리고 그 구현이나 사용법 등이 더 까다롭다는
특징이 있습니다.
-
Creator 단위전략은 Create()라는 오직 하나의 멤버 함수만을 규정하고 있습니다. 그렇지만
PrototypeCreator는 두 개의 추가적인 함수를 정의하고 있습니다(섹션 1.5 의 예제 코드 참조).
여기에서, 이 템플릿이 어떤 결과 코드를 만들어 낼지 한번 분석해 볼 필요가 있을 것 같습니다.
WidgetManager가 자신의 단위전략을 계승하고 있고, 또 GetPrototype과 SetPrototype이 PrototypeCreator의
public 멤버이기 때문에, 이 두 함수는 WidgetManager를 통해 클라이언트가 직접적으로 접근이 가능하게 됩니다.
어쨌든, WidgetManager는 오로지 Create 멤버 함수만을 요구합니다. 하지만 사용자들은 GetPrototype이나
SetPrototype과 같은 보강된 인터페이스를 사용할 수 있습니다.
PrototypeCreator 단위전략 클래스의 사용자는 다음과 같은 코드를 작성할 수 있을 것입니다.
예제 코드 보기
- typedef WidgetManager<PrototypeCreator>
- MyWidgetManager;
- ...
- Widget* pPrototype = ...;
- MyWidgetManager mgr;
- mgr.SetPrototype(pPrototype);
- ... use mgr ...
예제 코드 접기
만일 사용자가 나중에 가서 prototype을 지원하지 않는 다른 단위전략 클래스를 사용하기로 결정을 바꾼다면,
컴파일러는 prototype에 대한 인터페이스가 사용된 부분을 정확히 짚어 낼 것입니다.
이 템플릿의 결과 코드는 매우 편리할 수 있습니다. 보강된 인터페이스를 사용하고자 하는 프로그래머는 호스트 클래스의 기본
동작에 전혀 영향을 끼치지 않으면서 보강된 인터페이스의 이점을 마음껏 누릴 수 있습니다.
-
대부분의 경우에, 호스트 클래스는 단위전략을 public으로 상속받게 됩니다. 이로 인해, 단위전략 클래스의 포인터가
호스트 클래스 객체를 가리킬 수 있으며, 이러한 포인터에 대해 delete 연산 역시 가능할 수 있습니다.
단위전략 클래스가 가상 소멸자를 정의하고 있지 않다면, 단위전략 클래스로 형변환된 포인터를 delete하는 것은
예기치 못한 결과를 초래할 수 있습니다. 다음의 예를 보세요.
예제 코드 보기
- typedef WidgetManager<PrototypeCreator>
- MyWidgetManager;
- ...
- MyWidgetManager* wm = new MyWidgetManager;
- PrototypeCreator<Widget>* pCreator = wm;
- delete pCreator;
-
예제 코드 접기
단위전략 클래스의 소멸자를 가상으로 선언할 수 있지만, 이는 성능상의 문제를 야기할 수 있는 방법입니다.
따라서 가상 소멸자는 선택하지 말아야 할 방법입니다.
그렇다면 호스트 클래스가 단위전략 클래스를 protected 혹은 private로 상속하면 문제가 해결될 것 같습니다.
그렇지만 이 방법은 다시 1.6 섹션에서 언급된 인터페이스의 보강을 불가능하게 만듭니다.
단위전략이 사용해야 할 가장 가볍고도 효과적인 해법은 가상 선언을 없앤 채 protected 소멸자를 사용하는 것입니다.
예제 코드 보기
- template <class T>
- struct OpNewCreator
- {
- static T* Create(void)
- {
- return new T;
- }
-
- protected:
- ~OpNewCreator(void) {}
- };
-
-
-
예제 코드 접기
-
만일 템플릿에서 전혀 사용되지 않는 멤버 함수가 있다면, 해당 객체의 구체화 과정에서 그 함수의 존재는 아예 무시됩니다.
이것은, 컴파일러는 그저 기본적인 문법 검사만 수행할 뿐, 그 템플릿 코드를 자세히 훑어보거나 하지는 않는다는 뜻입니다.
따라서, 호스트 클래스는 단위전략 클래스의 추가적인 특성을 사용할 수 있는 추가적인 기회를 가지게 됩니다. 예를 들어,
WidgetManager에 SwitchPrototype 멤버 함수를 추가적으로 정의해 봅시다.
예제 코드 보기
- template <template <class> class CreationPolicy>
- class WidgetManager : public CreationPolicy<Widget>
- {
- ...
- void SwitchPrototype(Widget* pNewPrototype)
- {
- CreationPolicy<Widget>& myPolicy = *this;
- delete myPolicy.GetPrototype();
- myPolicy.SetPrototype(pNewPrototype);
- }
- };
예제 코드 접기
이 템플릿의 구체화가 만들어 내는 코드는 다음과 같이 매우 흥미로운 결과를 나타냅니다.
-
만일 PrototypeCreator 단위전략을 사용하여 WidgetManager를
구체화시킨다면, SwitchPrototype 멤버 함수를 사용할 수 있게 됩니다.
-
만일 prototype을 지원하지 않는 단위전략을 사용하여 WidgetManager를
구체화한 후에 SwitchPrototype 멤버 함수를 호출한다면, 컴파일 에러가 발생됩니다.
-
만일 prototype을 지원하지 않는 단위전략을 사용하여 WidgetManager를
구체화하고, 그 후에 SwitchPrototype 멤버 함수를 전혀 호출하지 않는다면, 이 프로그램은 올바르게
컴파일되며, 또 잘 동작하게 됩니다.
이것이 의미하는 것은, WidgetManager는 그 인터페이스를 보강하여 얻을 수 있는 이점을 마음껏 누릴 수도 있으며,
특정 멤버 함수를 사용하지 않는 경우에는 기본 인터페이스만으로도 훌륭하게 사용될 수도 있다는 것입니다.
-
단위전략의 유용성은 단위전략 클래스들을 조합하여 사용할 때에 비로소 극대화됩니다. 예를 들어, 일반적인 용도로 사용될 수 있는
스마트 포인터 클래스에 대해 생각해 보도록 합시다(전체 구현에 대해서는 7장에서 다루고 있습니다).
여러분은 지금 단위전략을 디자인하는 데 두 가지의 중요한 선택 요소를 가지고 있습니다. 하나는 스레딩 모델에 대한 것이고,
또 하나는 객체의 유효성 검사 유무에 대한 것입니다. 그렇다면 다음과 같이 두 개의 단위전략을 사용하는 SmartPrt 클래스
템플릿을 구현해야 합니다.
예제 코드 보기
- template
- <
- class T,
- template <class> class CheckingPolicy,
- template <class> class ThreadingModel
- >
- class SmartPrt;
예제 코드 접기
SmartPrt은 세 개의 템플릿 인자를 가집니다. 첫 번째 인자는 포인터가 가리킬 자료형을 나타내고, 나머지 두 가지는
선택해야 할 단위전략을 나타냅니다. SmartPrt 내부에서 이 두 개의 단위전략을 조화롭게 융화시켜야 합니다.
SmartPrt을 이렇게 디자인하게 되면, 사용자는 간단한 typedef 문을 쓰는 것만으로 이 SmartPrt을 사용할 수 있게 됩니다.
예제 코드 보기
- typedef SmartPrt<Widget, NoChecking, SingleThreaded>
- WidgetPtr;
-
-
- typedef SmartPrt<Widget, EnforceNotNull, SingleThreaded>
- SafeWidgetPtr;
예제 코드 접기
여기서 두 가지의 단위전략은 다음과 같이 정의될 것입니다.
-
Checking: CheckingPolicy<T> 클래스 템플릿은 Check(T*)
멤버 함수를 노출해야 합니다. SmartPrt은 객체에 대한 참조 직전에 Check(T*) 함수에 해당 포인터를 넘겨주며
호출하여 그 포인터의 유효성을 검사하게 됩니다.
-
ThreadingModel: ThreadingModel<T> 클래스 템플릿은
Lock이라는 내부 자료형을 노출해야 합니다. Lock은 그 생성자로 T& 자료형을 받아야 합니다. 그러면 Lock 객체가
살아있는 동안 T 자료형 객체에 대한 모든 연산이나 동작들이 일련화될 것입니다.
한 예로, NoChecking과 EnforceNotNull 단위전략 클래스에 대한 구현을 살펴보도록 하겠습니다.
예제 코드 보기
- template <class T> struct NoChecking
- {
- static void Check(T*) {}
- };
-
- template <class T> struct EnforceNotNull
- {
- class NullPointerException : public std::exception { ... };
- static void Check(T*)
- {
- if (!ptr) throw NullPointerException();
- }
- };
-
-
-
- template <class T> struct EnsureNotNull
- {
- static void Check(T*& ptr)
- {
- if (!ptr) ptr = GetDefaultValue();
- }
- };
예제 코드 접기
SmartPrt은 Checking 단위전략을 다음과 같은 방식으로 사용합니다.
예제 코드 보기
- template
- <
- class T,
- template <class> class CheckingPolicy,
- template <class> class ThreadingModel
- >
- class SmartPtr
- : public CheckingPolicy<T>
- , public ThreadingModel<SmartPtr>
- {
- ...
- T* operator->(void)
- {
- typename ThreadingModel<SmartPtr>::Lock guard(*this);
- CheckingPolicy<T>::Check(pointee_);
- return pointee_;
- }
-
- private:
- T* pointee_;
- };
예제 코드 접기
두 개의 템플릿 인자에 따라서 SmartPrt::operator-> 연산자는 완전히 다른 방식으로 동작할 수 있습니다.
바로 이것이 단위전략을 서로 조합하여 사용할 때에 생기는 진정한 힘이라 할 수 있겠습니다.
-
SmartPrt에 대한 비포인터형의 표현을 지원하고자 한다고 가정해 봅시다. 예를 들어, 어떤 플랫폼 하에서는 특정 부류의 포인터들이
핸들이라는 개념으로 표현되어야 합니다. 이 핸들은 사용자가 시스템에게 실제로 포인터를 넘겨주기 위해 사용되는 내부적인 값입니다.
이러한 문제를 해결하기 위하여 아마도 단위전략을 통해 포인터에 대한 접근을 간접화시키려 할 것입니다. 이럴 때에 쓰이는 단위전략을
Structure 단위전략이라고 부르기로 하겠습니다.
Structure 단위전략은 포인터 저장 구조를 추상화합니다. 따라서, Structure는 PointerType(해당 객체에 대한 포인터
자료형)이라는 자료형과 ReferenceType(포인터가 가리키는 객체에 대한 참조형)자료형 그리고 GetPointer나 SetPointer와
같은 멤버 함수들을 노출해야 합니다.
포인터의 자료형이 T*으로 하드코딩 되어 있지 않다는 사실은 중요한 이점을 가집니다. 예를 들어, SmartPrt을 비표준 포인터
자료형(세그먼트 아키텍쳐의 near와 far 포인터와 같은)에 대해 사용할 수 있습니다.
예제 코드 보기
-
- template <class T>
- class DefaultSmartPtrStorage
- {
- public:
- typedef T* PointerType;
- typedef T& ReferenceType;
-
- protected:
- PointerType GetPointer(void) { return ptr_; }
- void SetPointerJ(PointerType ptr) { ptr_ = ptr; }
-
- private:
- PointerType ptr_;
- };
-
-
-
-
- template
- <
- class T,
- template <class> class CheckingPolicy,
- template <class> class ThreadingModel,
- template <class> class Storage = DefaultSmartPtrStorage
- >
- class SmartPtr;
-
-
예제 코드 접기
-
유효성 검사를 하지 않는 FastWidgetPtr과 유효성 검사를 수행하는 SafeWidgetPtr, 두 가지 버전의 SmartPrt을
구체화하는 상황을 가정해 보도록 합시다. 그러면 다음과 같은 흥미로운 질문이 나올 수 있습니다.
"FastWidgetPtr 객체를 SafeWidgetPtr 객체로 대입시킬 수 있나요?"
SafeWidgetPtr이 FastWidgetPtr보다 더 많은 제한을 가진다는 이유를 떠올려 본다면, FastWidgetPtr에서
SafeWidgetPtr로의 형변환이 허용되는 것은 자연스러워 보입니다.
반면에, SafeWidgetPtr 객체로부터 FastWidgetPtr 객체로의 변환은 위험할 수 있습니다. 따라서, 오직 명시적으로
제어되는 형변환만을 허용하며, 이 FastWidgetPtr의 사용빈도를 최소화시키는 것이 좋을 것입니다.
단위전략 간의 형변환에 대한 가장 훌륭한, 그리고 확장성 있는 방법은 아래와 같이 각 단위략을 별도로 초기화하여
SmartPrt 객체를 단위전략 단위로 복사하는 것입니다.
예제 코드 보기
-
- template
- <
- class T,
- template <class> class CheckingPolicy
- >
- class SmartPtr : public CheckingPolicy<T>
- {
- ...
- template
- <
- class T1,
- template <class> class CP1
- >
- SmartPtr(const SmartPtr<T1, CP1>& other)
- : pointee_(other.pointee_), CheckingPolicy<T>(other)
- { ... }
- };
예제 코드 접기
위 예제를 보면, SmartPrt의 생성자가 다른 어떤 SmartPrt 인스턴스도 받아들일 수 있는 템플릿 함수로 정의되어
있다는 것을 알 수 있습니다. 굵은 글자로 표시된 부분은 SmartPrt이 자신의 각 요소들을 초기화하면서,
SmartPrt<T1, CP1>로 표현되는 other 객체를 그 원본으로 삼고 있다는 사실을 말해주고 있습니다.
여기서, Widget으로부터 상속받은 ExtendedWidget이라는 클래스를 가지고 있다고 가정해 봅시다. 만일
SmartPrt<Widget, NoChecking>의 인스턴스를 SmartPrt<ExtendedWidget, NoChecking>
인스턴스를 기반으로 초기화한다면, 컴파일러는 Widget*을 ExtendedWidget*을 기초로 하여 초기화하려고 시도할 것입니다(물론
이것은 잘 동작합니다). CheckingPolicy의 경우에는 NoChecking 단위전략으로부터 NoChecking 단위전략을 초기화하려는
경우에 해당하므로, 초기화는 무리 없이 이루어집니다.
이제부터 흥미로운 이야기가 나옵니다. 지금 SmartPrt<Widget, EnforceNotNull> 인스턴스를
SmartPrt<ExtendedWidget, NoChecking>의 인스턴스를 기반으로 하여 만들어 내려 한다고 가정해 봅시다.
방금 살펴봤듯이, ExtendedWidget*으로부터 Widget*로의 형변환은 문제가 없습니다. 이제 컴파일러는
SmartPrt<ExtendedWidget, NoChecking>을 EnforceNotNull의 생성자에게 짝지어 주려 시도하게 될
것입니다.
만일 EnforceNotNull이 NoChecking 객체를 받아들일 수 있도록, 해당되는 인자를 갖는 생성자를 구현해 놓은 상태라면
컴파일러는 이 생성자를 찾아낼 것입니다. 만일 NoChecking이 EnforceNotNull로의 형변환 연산자를 구현해 놓았다면,
이 형변환이 이루어지게 될 것입니다. 이 두 경우가 모두 아닐 경우에는, 컴파일 에러가 발생합니다.
-
단위전략을 기반으로 클래스를 디자인하는 데 가장 어려운 작업은 클래스의 각 기능 요소들을 올바르게 적절한 단위전략으로
분리해 내는 일입니다. 가장 중요한 규칙은 다음과 같습니다. 먼저 클래스의 동작을 구성하는 데 필요한 결정 사항들을
파악해내야 하며, 그다음에는 거기에 적절한 이름을 부여해야 합니다. 한 가지 이상의 방법으로 수행될 수 있는 일이 있다면
놓치지 말고 클래스로부터 단위전략으로 분리해 내기 바랍니다.
예를 들어, WidgetManager가 내부적으로 새로운 Widget 객체를 생성한다면, 그 생성 과정은 전적으로 단위전략에
맡겨져야 합니다. 만일 WidgetManager가 Widget에 대한 집합을 저장해야 한다면, 별도로 다른 특별한 저장 메커니즘이
필요한 상황이 아닌 이상, 그 집합체에 대한 저장 기능을 담당하는 별도의 단위전략을 만들어 처리하는 것이 바람직합니다.
극단적인 경우에, 호스트 클래스는 완전히 단위전략을 가지고 있는 것 자체가 전부인 경우도 있습니다. 이렇게 과도하게
일반화된 호스트 클래스의 단점은 템플릿 인자가 지나치게 많아질 수 있다는 점입니다.
자료형에 대한 사용자 정의(typedef)는 단위전략으로 이루어진 클래스를 사용하는 데 필수적인 도구가 됩니다.
typedef 문은, 타이핑해야할 문자 수를 줄일 뿐만 아니라, 자료형에 대한 올바른 사용과 코드에 대한 손쉬운 유지보수를
보장해 줍니다. 예를 들어 다음과 같은 사용자 정의 자료형을 한 번 검토해 봅시다.
예제 코드 보기
- typedef SmartPtr
- <
- Widget,
- RefCounted,
- NoChecked
- >
- WidgetPtr;
-
-
예제 코드 접기
클래스를 단위전략으로 분리해 낼 때에는 서로 상호작용하지 않는 요소를 찾아 분리해
내는 것이 매우 중요합니다. 각각의 단위전략들이 서로 완전히 독립적으로 동작하도록 만들지 못할 경우에는, 다양한 단위전략들이
서로에 관한 정보를 모두 알고 있어야 한다는 문제점이 발생합니다.
예를 들어, 스마트 포인터에 Array 단위전략을 추가하는 경우를 생각해 보도록 합시다. Array는 스마트 포인터가 배열의
시작점을 가리키는지, 아니면 그저 하나의 객체만을 가리키는지를 말해 주는 매우 단순한 단위전략입니다. 문제는 이 단위전략은
파괴자와 관련된 단위전략과 상호작용을 하게 된다는 문제가 있습니다. 단일 객체를 삭제할 때에는 delete 연산자를 사용해야 하지만,
배열로 이루어진 객체들을 삭제하고자 할 때에는 delete[] 연산자를 사용해야 하기 때문입니다.
만일 이 두 단위전략을 꼭 독립된 단위전략으로 구성하기를 원한다면, 두 단위전략이 서로 정보를 주고받을 수 있는 어떤 수단을
마련해 주어야 할 것입니다. 하지만, 이렇게 하는 것은 두 단위전략 모두를 보다 복잡하게 만들 뿐 아니라, 예기치 못한
제한 사항을 발생시키는 결과를 가져오게 됩니다.
비독립적인 단위전략은 컴파일 타임에서 보장되어야 할 자료형에 대한 안정성에 악영향을 줄 뿐만 아니라, 호스트 클래스와
단위전략 클래스 모두의 디자인을 복잡하게 만들어 버립니다.
만일 어쩔 수 없이 비독립적인 단위전략을 사용할 수 밖에 없다면, 하나의 단위전략을 다른 단위전략의 템플릿 매개변수에 대한
인자로 넘겨주도록 함으로써, 서로 간의 의존성을 최소화시키는 방법을 사용해야 합니다.
-
적은 양의 작은 코드로 디자인의 다양성과 싸우고자 한다면, 디자인을 목적으로 한 라이브러리의 작성자는 몇 가지 특별한
테크닉들을 개발하여 구사해 주어야 합니다. 이러한 테크닉들은 작은 수의 기본적인 장치들을 조합하는 것으로써 유연한
코드를 생성해내는 것을 그 목적으로 합니다. 그리고 물론 라이브러리는 이러한 장치들을 제공해 주어야 합니다.
더 나아가서 라이브러리는 사용자들도 자신만의 장치들을 만들어 낼 수 있도록, 이러한 장치들을 구성해낸 세부 명세들도
노출해 주어야 합니다. 이렇게 함으로 해서 단위전략 기반의 디자인은 열린 속성을 지닌 디자인이 되는 것입니다.
여기서 말한 이러한 장치들을 우리는 바로 단위전략이라고 부르며, 이 단위전략의
구현물을 우리는 단위전략 클래스라고 부릅니다.
단위전략의 메커니즘은 템플릿과 다중 상속을 병용한다는 것에 근거하고 있습니다. 단위전략을 사용하는
호스트 클래스는 다수의 템플릿 인자를 가지는 또 다른 템플릿이며, 이 때 각
템플릿 인자는 그것이 사용하는 단위전략을 가리키게 됩니다. 호스트 클래스는 자신이 선택한 단위전략들을 통해
그 기능을 간접화시키게 되며, 각 단위전략들을 서로 밀접하게 응집시켜 주는 하나의 그릇으로써 작용하게 됩니다.
단위전략을 통해 디자인된 클래스는 보강된 인터페이스를 통해 추가 기능을 지원할 수도 있으며, 필요 없는 경우에는
일부의 기능만을 사용할 수도 있습니다. public 상속을 이용하여 단위전략은 호스트 클래스에서도 사용될 수 있는
추가적인 기능을 부여해 줄 수 있습니다. 더 나아가서, 호스트 클래스는 이러한 단위전략의 추가 기능을 사용하여
자신의 기능을 확장시킬 수도 있습니다. 만일 단위전략에서 이러한 추가 기능을 제공하지 못하는 경우에도, 호스트 클래스가
이를 사용하지 않으면 컴파일은 문제없이 성공적으로 이루어지게 됩니다.
단위전략의 진정한 힘은 서로 조합되어 사용될 수 있다는 특징에서 나옵니다. 단위전략 기반의 클래스는 각 단위전략이 제공하는
단순한 동작들을 조합하여 다양한 기능을 이끌어 낼 수 있습니다.
단위전략 클래스를 사용하면, 프로그래머는 그 동작과 기능에 대한 커스터마이징 뿐만 아니라, 자료 구조에 대한 커스터마이징도
할 수 있게 됩니다.
또한, 단위전략에 기반한 클래스들은 형변환에 있어서의 유연성을 제공합니다. 하나의 단위전략에서 다른 단위전략으로의 복사를
하게 되는 경우에, 각 단위전략은 자신이 다른 어떤 단위전략을 받아들일 수 있는지, 혹은 자신이 어떤 단위전략으로 변환될
수 있는지를 적절한 생성자나 형변환 연산자를 통해 제어할 수 있습니다.
하나의 클래스를 단위전략으로 쪼개 나갈 때에는, 다음의 두 가지 중요한 규칙을 따라야 합니다. 첫째, 클래스를 디자인하는 데
트레이드 오프 관계가 되거나, 다양한 방법으로 구현될 수 있는 문제가 있다면, 그것을 찾아내어 지역화시키고, 거기에 적절한
이름을 붙여주어야 한다는 것입니다. 또 하나의 규칙은 하나의 단위전략이 변하더라도 다른 단위전략에 영향을 끼치지 않도록,
서로 독립적인 요소로 단위전략을 구성해야 한다는 것입니다.
-
이번 장에서는 다음의 테크닉, 혹은 도구들을 익히게 될 것입니다.
-
컴파일 타임 어써션(Compile-time assertions)
-
템플릿의 부분 특화(Partial template specialization)
-
로컬 클래스(Local classes)
-
자료형과 값 사이의 매핑(Int2Type과 Type2Type 클래스 템플릿)
-
Select 클래스 템플릿: bool 조건에 따라 컴파일 타임에 자료형을 선택할 수 있는 도구
-
컴파일 타임에 이루어지는 형변환이나 상속 여부에 대한 판별법
-
TypeInfo: std::type_info를 감싸는 편리한 포장 클래스(wrapper)
-
Traits: 어떠한 C++ 자료형에도 적용될 수 있는 traits들의 모음
사용자들은 보다 고수준의 코드를 만들어 내기 위해 이것들을 조합하여 사용할 수 있습니다. 아울러, 이 테크닉을 통해 강력한 프로그램
구조를 형성하는 튼튼한 기초를 쌓아 나갈 수 있게 될 것입니다.
-
C++ 언어를 통한 제네릭 프로그래밍이 시작됨에 따라, 코드에 대한 보다 정적인 검사의 필요성이 대두되어 왔습니다.
그리고 보다 나은 에러 메시지에 대한 커스터마이징 역시 그 필요성이 강조되었습니다.
예를 들어, 안전한 형변환을 하기 위한 함수를 개발하고 있다고 가정해 봅시다. 이 형변환 함수는, 더 큰 자료형은 절대로
자기보다 작은 크기의 자료형으로 변환하지 않는다고 합시다.
예제 코드 보기
- template <class To, class From>
- To safe_reinterpret_cast(From from)
- {
- assert(sizeof(From) <= sizeof(To));
- return reinterpret_cast<To>(from);
- }
-
-
-
- int i = ...;
- char* p = safe_reinterpret_cast<char*>(i);
예제 코드 접기
위의 예제에 사용된 assert 함수는 런타임에 에러를 잡아낼 수 있습니다. 만일, 이러한 에러를 컴파일 타임에 잡아낼 수 있다면,
그것은 분명 환영할 만한 일이 될 것입니다. 형변환이라는 것이 프로그램 내에서 매우 드물게 발생하는 작업이라고 가정해 봅시다.
런타임에 예제의 형변환 함수를 호출하는 코드를 실행하지 않는다면, 에러를 잡아낼 수 없을 것입니다.
위에서 assert 구문의 인자로 사용되고 있는 비교식은 컴파일러의 입장에서 볼 때 상수로 분류됩니다. 이것은 컴파일 타임에 이 부분이
검사될 수 있다는 가능성을 제시해 줍니다. 따라서, 컴파일러에게 0이 아닐 경우에는 적법하고, 0일 경우에는 문제가 되는 구문을
언어적 구조로 표현하여 넘겨주면 되겠다는 아이디어를 생각해 볼 수 있습니다.
C++뿐만 아니라 C에서도 잘 동작하는 가장 단순한 형태의 해법(Van Horn 1997)은 길이가 0인 배열이 허용되지 않는다는 점을
이용하는 것입니다.
예제 코드 보기
- #define STATIC_CHECK(expr) { char unnamed[(expr) ? 1 : 0]; }
-
-
-
- template <class To, class From>
- To safe_reinterpret_cast(From from)
- {
- STATIC_CHECK(sizeof(From) <= sizeof(To));
- return reinterpret_cast<To>(from);
- }
- ...
- void* somePointer = ...;
- char c = safe_reinterpret_cast<char>(somePointer);
-
-
-
예제 코드 접기
이러한 접근 방법의 문제는 에러 메시지에 전혀 의미 있는 정보를 담아 낼 수 없다는 데 있습니다.
보다 나은 해법은, 그 이름에 의미 있는 정보를 실은 템플릿 코드를 사용하는 것입니다. 다행히도, 컴파일러는 에러 메시지에서
템플릿의 이름을 언급해 줄 것입니다.
예제 코드 보기
- template <bool> struct CompileTimeError;
- template <> struct CompileTimeError<true> {};
-
- #define STATIC_CHECK(expr) \
- (CompileTimeError<(expr) != 0>())
-
-
-
-
예제 코드 접기
STATIC_CHECK에 추가적인 인자를 전달하여 이 인자를 어러 메시지에 출력되도록 만들어 보자는 아이디어를 생각해 볼 수 있겠습니다.
하지만 여기에 사용되는 에러 메시지는 C++의 심볼로 쓰이기에 적합한 형태여야 한다는 한 가지 단점이 남기는 합니다.
예제 코드 보기
- template <bool> struct CompileTimeChecker
- {
- CompileTimeChecker(...);
- };
-
- template <> struct CompileTimeChecker<false> {};
-
- #define STATIC_CHECK(expr, msg) \
- {\
- class ERROR_##msg {};\
- (void)sizeof(CompileTimeChecker<(expr) != 0>((ERROR_##msg())));\
- }
-
-
-
-
- template <class To, class From>
- To safe_reinterpret_cast(From from)
- {
- STATIC_CHECK(sizeof(From) <= sizeof(To),
- Destination_Type_Too_Narrow);
- return reinterpret_cast<To>(from);
- }
- ...
- void* somePointer = ...;
- char c = safe_reinterpret_cast<char>(somePointer);
-
-
-
- template <class To, class From>
- To safe_reinterpret_cast(From from)
- {
- {
- class ERROR_Destination_Type_Too_Narrow {};
- (void)sizeof(
- CompileTimeChecker<(sizeof(From) <= sizeof(To))>(
- ERROR_Destination_Type_Too_Narrow()));
- }
- return reinterpret_cast<To>(from);
- }
-
-
-
-
-
-
-
-
-
예제 코드 접기
-
템플릿의 부분적인 특화(Partial Template Specialization)를 통해 그 템플릿으로 가능한 구체화 중에서
특정 부분집합의 경우만을 대응시키는 것이 가능합니다.
먼저 템플릿에 대한 명시적인 특화부터 살펴보도록 하겠습니다.
전체 템플릿에 대한 명시적인 특화 예제 코드 보기
-
- template <class Window, class Controller>
- class Widget
- {
- ... 일반화된 구현 ...
- };
-
-
-
- template <>
- class Widget<ModalDialog, MyController>
- {
- ... 특화된 구현 ...
- };
-
-
전체 템플릿에 대한 명시적인 특화 예제 코드 접기
그런데 어떤 경우에는 Widget을 모든 Window와 MyController에 대해 특화시키고자 할 필요가 생길 수 있습니다.
여기가 바로 템플릿의 부분 특화가 등장해야 할 장소입니다.
템플릿의 부분 특화 예제 코드 보기
- template <class Window>
- class Widget<Window, MyController>
- {
- ... 부분적으로 특화된 구현 ...
- };
-
-
-
-
- template <class ButtonArg>
- class Widget<Button<ButtonArg>, MyController>
- {
- ... 추가적으로 특화된 구현 ...
- };
템플릿의 부분 특화 예제 코드 접기
불행하게도, 템플릿의 부분 특화는 멤버 함수이든, 그렇지 않든 간에 함수에게는 적용되지 않습니다.
이러한 제한 조건은 유연성과 독립성의 감소를 가져다주는 한 요인이 됩니다.
-
클래스 템플릿의 멤버 함수를 전체적으로 특화시킬 수는 있지만,
부분적으로 특화시킬 수는 없습니다.
-
어떤 네임스페이스에 속한 템플릿 함수를 부분적으로 특화시킬 수 없습니다. 하지만, 함수의 오버로딩을 통해
네임스페이스에 속한 템플릿 함수를 부분적으로 특화시키는 것과 비슷한 효과를 낼 수 있습니다. 실제로, 이것은
오직 함수의 인자만이 독립적인 특화의 능력을 가지고 있다는 사실을 의미합니다. 즉, 함수의 반환 값이나
내부 자료형에 대해서는 특화가 불가능합니다.
템플릿 함수의 부분 특화 예제 코드 보기
- template <class T, class U> T Fun(U obj);
- template <class U> void Fun<void, U>(U obj);
- template <class T> T Fun(Window obj);
템플릿 함수의 부분 특화 예제 코드 접기
-
로컬 클래스는 매우 흥미롭지만, 별로 잘 알려지지 않은 C++의 특성입니다. 다음과 같이 클래스는 함수 내부에서도
정의될 수 있습니다.
로컬 클래스 예제 코드 보기
- void Fun(void)
- {
- class Local
- {
- ... 멤버변수 ...
- ... 멤버 함수 정의 ...
- };
- ... 로컬 클래스를 이용한 코딩 ...
- }
로컬 클래스 예제 코드 접기
여기에는 몇 가지 제한점이 있습니다. 로컬 클래스는 static 멤버변수를 정의할 수 없으며, static이 아닌 함수(위
예제에서는 Fun) 내부의 지역 변수에 접근할 수 없습니다.
로컬 클래스를 정말로 흥미롭게 만들어 주는 요소는, 템플릿 함수에서 이것들을 응용할 수 있다는 점입니다. 템플릿 함수 내부에
정의된 로컬 클래스는 그것을 감싸고 있는 템플릿 인자를 그대로 가져다 쓸 수 있습니다.
로컬 클래스 예제 코드 보기
-
-
-
- class Interface
- {
- public:
- virtual void Fun(void) = 0;
- ...
- };
-
- template <class T, class P>
- Interface* MakeAdapter(const T& obj, const P& arg)
- {
- class Local : public Interface
- {
- public:
- Local(const T& obj, const P& arg)
- : obj_(obj), arg_(arg) {}
-
- virtual void Fun(void)
- {
- obj_.Call(arg_);
- }
-
- private:
- T obj_;
- P arg_;
- };
- return new Local(obj, arg);
- }
로컬 클래스 예제 코드 접기
로컬 클래스는 구현을 보다 단순화시키고, 심볼의 지역성을 높여주는 데 기여하는 기법이라 할 수 있습니다.
어쨌든, 로컬 클래스는 자신만의 독특한 성격을 가지고 있습니다. 로컬 클래스는 최종
자료형으로서 외부의 사용자가 함수 속에 숨어 있는 이러한 클래스를 상속받을 수 없다는 특징을 가집니다.
로컬 클래스가 없다면, 각 컴파일 단위마다 무명의 네임스페이스를 부여해 주어야 할 것입니다.
-
Alexandrescu(2000b)에서 기술된 다음과 같은 간단한 템플릿은 많은 제네릭 프로그래밍 기법에서 상당히
유용하게 쓰이고 있습니다.
Int2Type 예제 코드 보기
Int2Type은 정수 값으로 넘겨진 각각의 상수에 대해 별도의 자료형을 생성해 줍니다. 즉, Int2Type<0>은
Int2Type<1>과 다르며, 다른 모든 정수마다 각기 다른 자료형을 만들게 됩니다. 또한, 자료형을 생성해 낸
상수 값은 value라는 나열형 값에 저장되게 됩니다.
이를 통해, 컴파일 타임의 연산 결과에 따라 서로 다른 함수를 선택하는 것이 가능합니다.일반적으로 다음의 두 조건이 만족된다면,
Int2Type이 사용될 수 있습니다.
-
컴파일 타임에 주어지는 상수에 따라 몇 개의 함수 중 하나를 선택해서 호출해야 할 때
-
이러한 디스패칭 작업을 컴파일 타임에 결정짓고자 할 때
프로그램 실행 중에 어떤 조건에 의한 디스패치를 하고자 한다면, 간단하게 if-else 구문이나, switch 구문을 사용하면
아주 쉽게 해결됩니다. 하지만, 경우에 따라서는 그래서는 안 되는 경우도 있습니다. if-else 문을 사용할 때에는 if 문에
사용될 조건이 컴파일 타임에 이미 알려져 있는 상태임에도 불구하고, if-else의 각 조건에 대한 모든 가지마다 그 코드가
컴파일에 성공해야 한다는 문제가 있습니다.
예제 코드 보기
-
- template <class T> class NiftyContainer
- {
- ...
- };
-
-
-
-
-
- template <typename T, bool isPolymorphic>
- class NiftyContainer
- {
- ...
- void DoSomething(void)
- {
- T* pObj = ...;
- if (isPolymorphic)
- {
- T* pNewObj = pObj->Clone();
- ... 다형성 자료형에 대한 알고리즘 ...
- }
- else
- {
- T* pNewObj = new T(*pObj);
- ... 다형성을 가지지 않는 자료형에 대한 알고리즘 ...
- }
- }
- };
-
-
-
-
-
예제 코드 접기
여기에는 다양한 해법이 있을 수 있습니다. 하지만, 그 중에서는 특히 Int2Type을 사용하는 것이 가장 깔끔한 해결책이 될 것입니다.
Int2Type 예제 코드 보기
- template <typename T, bool isPolymorphic>
- class NiftyContainer
- {
- private:
- void DoSomething(T* pObj, Int2Type<true>)
- {
- T* pNewObj = pObj->Clone();
- ... 다형성을 갖는 자료형에 대한 알고리즘 ...
- }
- void DoSomething(T* pObj, Int2Type<false>)
- {
- T* pNewObj = new T(*pObj);
- ... 다형성을 가지지 않는 자료형에 대한 알고리즘 ...
- }
-
- public:
- void DoSomething(T* pObj)
- {
- DoSomething(pObj, Int2Type<isPolymorphic>());
- }
- };
Int2Type 예제 코드 접기
여기서 사용된 비결은, 사용되지 않는 템플릿 함수는 컴파일러가 전혀 컴파일을 하지 않는다는 점을 이용하는 것입니다.
-
2.2 섹션에서 설명된 바와 같이, 템플릿 함수에 대한 부분 특화는 존재하지 않습니다.
하지만 때때로 그런 비슷한 기능이 필요할 때가 있습니다. 다음의 함수를 생각해 보도록 합시다.
예제 코드 보기
- template <class T, class U>
- T* Create(const U& arg)
- {
- return new T(arg);
- }
-
예제 코드 접기
이제, 현재 개발중인 애플리케이션에 다음과 같은 규칙이 있다고 합시다. Widget 자료형의 객체는 건드릴 수 없는 라이브러리
코드에 속해 있으며, 그 생성자는 두 개의 인자를 필요로 하고, 그 중 두 번째 인자는 -1과 같이 고정된 값이어야만 합니다.
하지만, 이 Widget을 상속받아 만든 여러분의 클래스는 이러한 문제점을 가지고 있지 않습니다.
먼저, 이런 특수한 경우에 적용되는 CreateWidget이라는 별도의 함수를 명시적으로 정의하는 방법이 있을 수 있습니다.
하지만 이렇게 되면, 불행하게도 Widget과 Widget을 상속받은 다른 객체들은 서로 인터페이스의 일관성을 잃어버리게 됩니다.
이것은 Create 함수를 다른 제네릭 코드에서는 사용할 수 없게 만들어 버립니다.
템플릿 함수에 대한 부분적인 특화가 불가능하기 때문에, 우리가 사용할 수 있는 유일한 도구는 바로 오버로딩입니다.
오버로딩을 통해 해결한 예제 코드 보기
-
- template <class U>
- Widget* Create<Widget, U>(const U& arg)
- {
- return new Widget(arg, -1);
- }
-
-
-
- template <class T, class U>
- T* Create(const U& arg, T )
- {
- return new T(arg);
- }
-
- template <class U>
- Widget* Create(const U& arg, Widget )
- {
- return new Widget(arg, -1);
- }
오버로딩을 통해 해결한 예제 코드 접기
하지만 이러한 해법은 사용되지 않는 채로 남게 되는 상당히 복잡한 임시 객체를 만들어야 하는 오버헤드를 초래하게 됩니다.
이런 경우 사용할 수 있는 해법이 Type2Type 입니다.
오버로딩과 Type2Type을 통해 해결한 예제 코드 보기
-
- template <typename T>
- struct Type2Type
- {
- typedef T OriginalType;
- };
-
-
-
-
-
- template <class T, class U>
- T* Create(const U& arg, Type2Type<T>)
- {
- return new T(arg);
- }
-
- template <class U>
- Widget* Create(const U& arg, Type2Type<Widget>)
- {
- return new Widget(arg, -1);
- }
-
- String* pStr = Create("Hello", Type2Type<String>());
- Widget* pW = Create(100, Type2Type<Widget>());
오버로딩과 Type2Type을 통해 해결한 예제 코드 접기
-
때때로, 제네릭한 스타일의 코드에서는 주어진 Boolean 값의 상수에 따라 두 자료형 중 하나를 선택해야 할 필요가 있을 수 있습니다.
2.4 섹션에서 다루었던 NiftyContainer의 예제를 다시 들고오겠습니다. 분명, 다형성 객체의
경우에는 그 값이 그대로 저장될 수는 없습니다. 반면에, 다형성을 가지지 않는 객체의 경우에는 효율성의 이유로 그 값 자체를
저장하고자 할 수 있습니다.
결론적으로 말해서, isPolymorphic의 값에 따라 ValueType 자료형은 T* 혹은 T로 typedef이 되어야 합니다.
이러한 용도로 Traits 클래스 템플릿(Alexandrescu 2000a)이 사용될 수 있습니다.
Traits 클래스 템플릿 예제 코드 보기
- template <typename T, bool isPolymorphic>
- struct NiftyContainerValueTraits
- {
- typedef T* ValueType;
- };
-
- template <typename T>
- struct NiftyContainerValueTraits<T, false>
- {
- typedef T ValueType;
- };
-
- template <typename T, bool isPolymorphic>
- class NiftyContainer
- {
- ...
- typedef NiftyContainerValueTraits<T, isPolymorphic> Traits;
- typedef typename Traits::ValueType ValueType;
- };
Traits 클래스 템플릿 예제 코드 접기
사실 이러한 방법은 좀 필요 이상으로 불편해 보입니다. 게다가 그다지 확장성도 없습니다.
Loki 라이브러리가 제공하는 Select 클래스 템플릿은 자료형의 선택을 훨씬 쉽게 만들어 줍니다. 이 Select 템플릿의 정의는
역시 부분 특화의 기능을 이용하고 있습니다.
Select 예제 코드 보기
- template <bool flag, typename T, typename U>
- struct Select
- {
- typedef T Result;
- };
-
- template <typename T, typename U>
- struct Select<false, T, U>
- {
- typedef U Result;
- };
-
-
-
-
-
- template <typename T, bool isPolymorphic>
- class NiftyContainer
- {
- ...
- typedef typename Select<isPolymorphic, T*, T>::Result ValueType;
- ...
- };
Select 예제 코드 접기
-
상속 여부를 알아내는 방법은 형변환의 여부를 판단해 내는 방법을 응용하여 사용하게 됩니다. 따라서, 보다 일반적으로
검토해 보아야 할 문제는 임의로 주어진 자료형 T가 자료형 U로의 자동 형변환을 지원하는 지의 여부를 어떻게 알아내느냐
하는 것입니다.
물론, 여기에는 이 문제에 대한 해답이 있습니다. 그것은 바로 sizeof의 힘을 이용하는 것입니다.
프로그래머는 sizeof를 코드 상의 어떠한 복잡한 수식에도 적용시킬 수 가 있습니다. 그리고 sizeof는 프로그램 실행 시간이
아닌 컴파일 타임에 정확히 그 크기를 반환해 줍니다. 이것은 sizeof가 C++ 구문에서 사용될 수 있는
모든 결정 사항들에 대해 미리 알 수 있다는 것을 의미합니다.
사실, sizeof는 어떠한 수식의 자료형이든지 완벽하게 연역해 낼 수 있는 멋진 장치를 내재하고 있습니다.
그리고 sizeof가 작용할 때마다, 해당 수식은 결과 코드에서 사라져 버리며 오직 그 자료형의 크기만 결과로 남게 됩니다.
형변환 여부를 판단해 내는 방법은 sizeof를 함수 오버로딩과 함께 이용하자는 데에서 그 아이디어를 얻고 있습니다.
우리가 하나의 함수에 대해 두 개의 오버로드 함수를 제공한다고 가정해 봅시다. 하나는 U로 형변환이 가능한 자료형을
받아들일 것이며, 다른 하나는 그 외의 다른 모든 자료형을 받아들일 것입니다. 그리고 우리가 U로 형변환이 되는지
여부를 알아내고자 하는 자료형 T를 가지고 그 오버로드 함수를 호출했다고 합시다. 만일 U를 받아들이는 오버로드 함수가
호출되었다면, 우리는 T가 U로 형변환이 된다는 사실을 알아낼 수 있습니다. 둘 중 어떤 함수가 호출되었는지 판단하기 위해서,
우리는 두 오버로드 함수가 서로 다른 크기를 가지는 자료형을 반환하도록 만들고, sizeof를 통해 이를 식별하기만 하면 됩니다.
먼저 크기가 다른 두 개의 자료형을 만들도록 합시다. 예를 들어 char와 long double 자료형을 사용할 수도 있겠지만,
C/C++ 표준은 두 자료형의 크기가 다르다고 보장해 주지 않습니다. 따라서 모든 경우에 통용될 수 있는 보다 완전한
다음과 같은 자료형을 만드는 것이 좋을 것 같습니다.
크기가 다른 자료형 예제 코드 보기
다음으로, 우리는 두 개의 오버로드 함수를 제공해야 합니다. 하나는 자료형 U를 받아들이고, Small 자료형을 반환해야 하겠지요.
다른 함수는 U를 제외한 다른 모든 자료형을 받아들이고 Big 자료형을 반환해야 합니다.
오버로드 함수 예제 코드 보기
이제 우리는 자료형 T에 대한 Test의 호출 결과를 sizeof로 측정하기만 하면 됩니다.
sizeof를 활용한 형변환 가능 여부 판별 예제 코드 보기
- const bool convExists = sizeof(Test(T())) == sizeof(Small);
-
-
-
-
-
- T MakeT(void);
- const bool convExists = sizeof(Test(MakeT())) == sizeof(Small);
sizeof를 활용한 형변환 가능 여부 판별 예제 코드 접기
남은 일은 자료형 추론에 대한 이런 모든 구구절절한 내용들은 숨기고 오직 결과만을 알려주도록 하나의 클래스 템플릿 속에
이것을 잘 포장해 내는 일일 것입니다.
예제 코드 보기
- template <class T, class U>
- class Conversion
- {
- typedef char Small;
- class Big { char dummy[2]; };
- static Small Test(U);
- static Big Test(...);
- static T MakeT(void);
-
- public:
- enum { exists = sizeof(Test(MakeT())) == sizeof(Small) };
- };
-
-
- int main(void)
- {
- using namespace std;
- cout
- << Conversion<double, int>::exists << ' '
- << Conversion<char, char*>::exists << ' '
- << Conversion<size_t, vector<int> >::exists << ' ';
-
- return 0;
- }
-
-
-
-
-
-
- template <class T, class U>
- class Conversion
- {
- ... 위의 내용과 동일 ...
- enum { sameType = false };
- };
-
-
- template <class T>
- class Conversion<T, T>
- {
- public:
- enum { exists = true, sameType = true };
- };
예제 코드 접기
다시 원래의 명제로 돌아오도록 합시다. Conversion을 사용해서, 우리는 이제 상속의 여부를 쉽게 판단할 수 있습니다.
상속 여부 판단 예제 코드 보기
- #define SUPERSUBCLASS(T, U)\
- (Conversion<const U*, const T*>::exists && \
- !Conversion<const T*, const void*>::sameType)
-
-
-
-
-
-
-
- #define SUPERSUBCLASS_STRICT(T, U)\
- (SUPERSUBCLASS(T, U) && \
- !Conversion(const T*, const U*)::sameType)
-
-
상속 여부 판단 예제 코드 접기
-
C++ 표준은 사용자가 객체의 자료형을 실행 시간에 조사할 수 있는 std::type_info라는 클래스를 제공해 줍니다.
일반적으로 type_info는 typeid 연산자와 함께 사용되곤 합니다. typeid 연산자는 type_info 객체에 대한 참조
값을 반환합니다. type_info는 비교 연산자(==, !=) 외에 두 가지의 함수를 더 지원하고 있습니다.
-
name 멤버 함수는 해당 자료형에 대한 문자열 표현을 const char*의 형태로 반환해 줍니다.
단 클래스의 이름을 문자열로 치환하는 데 정해진 규칙이 없기 때문에 typeid(Widget)가 "Widget" 문자열을
반환해 줄 것이라고 기대해서는 안됩니다.
-
before 멤버 함수는 type_info 객체 간의 순차적 관계를 알려줍니다. type_info::before를 사용함으로써,
type_info 객체에 대한 index를 구성할 수 있습니다.
불행히도, type_info의 유용한 기능들은 그것을 이용하기에 필요 이상으로 힘든 방법으로 패키지화되어 있습니다.
type_info 클래스는 복사 생성자나 대입 연산자의 사용이 금지되어 있습니다. 따라서 type_info 객체를 저장하는 것은
불가능한 일입니다. 하지만, type_info 객체에 대한 포인터를 저장하는 것은 가능합니다. typeid를 통해 얻어진 객체들은
정적인 저장 공간에 담겨져 있습니다. 따라서 이에 대한 포인터의 생명주기 문제에
대해서는 전혀 걱정할 필요가 없습니다. 오직 포인터가 가리키는 주체에만 신경을 쓰면 됩니다.
C++ 표준은 예를 들어, 매 호출마다 typeid(int)가 항상 같은 type_info 객체에 대한 참조값을 반환한다고
보장해 주지 않습니다. 따라서, type_info 객체에 대한 포인터를 비교하는 것은 의미가 없습니다. 실제로 필요한 일은,
type_info 객체에 대한 포인터를 저장하고, type_info::operator== 연산자를 이용하여 그 포인터가 가리키는
객체에 대한 비교 작업을 수행하는 것입니다.
만일 type_info 객체를 정렬하고 싶을 때 역시 type_info에 대한 포인터를 저장하고, before 멤버 함수를 사용해야 합니다.
STL의 컨테이너들을 type_info와 함께 사용하여 정렬하고자 한다면, 포인터를 다루는 간단한 함수자(functor)를 만들어
주어야 합니다.
이러한 모든 작업들은 사실 상당히 불편한 일이기 때문에, type_info를 감싸는 포장 클래스를 만들어서 이러한 일련의 작업들을
맡겨 버리는 것이 좋을 것입니다. 이런 포장 클래스는 type_info에 대한 포인터를 저장하여야 하며 다음과 같은 기능이나
멤버들을 제공해야 합니다.
-
type_info가 가지는 모든 멤버 함수
-
public으로 선언된 복사 생성자와 대입 연산자의 제공
-
<와 == 연산자를 통한 비교 연산
Loki는 type_info를 감싸주는 편리한 포장 클래스 TypeInfo를 제공하고 있습니다.
TypeInfo 클래스의 개요 예제 코드 보기
-
- class TypeInfo
- {
- public:
-
- TypeInfo(void);
- TypeInfo(const std::type_info&);
- TypeInfo(const TypeInfo&);
- TypeInfo& operator=(const TypeInfo&);
-
-
- bool before(const TypeInfo&) const;
- const char* name(void) const;
-
- private:
- const std::type_info* pInfo_;
- };
-
- bool operator==(const TypeInfo&, const TypeInfo&);
- bool operator!=(const TypeInfo&, const TypeInfo&);
- bool operator<(const TypeInfo&, const TypeInfo&);
- bool operator<=(const TypeInfo&, const TypeInfo&);
- bool operator>(const TypeInfo&, const TypeInfo&);
- bool operator>=(const TypeInfo&, const TypeInfo&);
-
-
-
- void Fun(Base* pObj)
- {
- TypeInfo info = typeid(Derived);
- ...
- if (typeid(*pObj) == info)
- {
- ... pBase는 실질적으로 Derived 객체를 가리키고 있음 ...
- }
- ...
- }
TypeInfo 클래스의 개요 예제 코드 접기
TypeInfo 객체를 복사하고 비교할 수 있다는 사실은, 다양한 상황에서 매우 중요하게 작용합니다.
8장에서 소개되는 팩토리의 복제와 11장의 이중 디스패치 엔진은
TypeInfo를 사용하는 좋은 예가 될 것입니다.
-
Loki는 NullType과 EmptyType이라는 매우 간단한 두 자료형을 정의하고 있습니다.
이것들은 자료형에 대한 연산 과정에서 어떤 경계 값으로 사용될 수 있습니다.
NullType은 무형 자료형임을 나타내는 표시로 작용하게 됩니다.
class NullType {};
NullType의 객체를 만드는 것은 아무 의미가 없습니다. 이 자료형은 오직 "난 의미 있는 자료형이 아닌데요"라는 사실을
표시해 주기 위해서만 존재합니다. 2.10 섹션에서 문법적으로 NullType이 존재해야만 하는
실제 예가 나옵니다. 또한, 3장에서 다루게 되는 Typelist에서도 Typelist의 끝을 가리키거나,
해당 자료형이 발견되지 않았다는 의미로 NullType을 사용하게 됩니다.
또 다른 비슷한 용도의 자료형으로 EmptyType이 존재합니다.
struct EmptyType {};
EmptyType은 다른 클래스가 이를 상속할 수 있는 자료형이며, EmptyType 자료형의 값을 함수 인자로 넘겨주는 것도 가능합니다.
이 참으로 무미건조한 자료형은 템플릿의 기본 자료형("Don't care"의 의미)으로 사용될 수 있습니다.
3장의 Typelist는 EmptyType을 이와 같은 용도로 사용하고 있습니다.
-
Traits는 자료형에 기초하여 컴파일 타임에 어떠한 결정을 내리기 위해 쓰이는 일반적인 프로그래밍 테크닉입니다.
Traits는 실행 시간에 이루어지는 결정들이 "값"에 기초하고 있다는 사실과 잘 대비되는 속성을 가지고 있습니다.
잘 알려진 바와 같이, 이것은 소프트웨어 엔지니어링 상의 다양한 문제를 해결해 줄 수 있도록 "추가적 수준의 간접화"
를 더해 줌으로써 자신을 생성해 낸 외부 구문에서 자료형과 관련된 어떤 결정을 내릴 수 있도록 만들어 줍니다.
결과적으로 이것은 코드를 더 깔끔하고, 더 이해하기 쉬우며, 유지보수도 더 쉽도록 만들어 줍니다.
보통, 실제 코드 상에서 Traits가 필요하게 되면, 자신만의 Trait 템플릿과 클래스를 작성해 주게 됩니다.
하지만, 어떤 Traits들은 실제로 모든 자료형에 대해 적용될 수도 있습니다.
예를 들어, 매우 빠른 BitBlast라는 기초 함수를 가지고 있는 다중 프로세서 머신에서 동작할 복사 알고리즘을
개발하는 경우를 생각해 봅시다. 물론, 가능하면 이 BitBlast의 장점을 최대한 끌어내고 싶어 할 것입니다.
BitBlast는 물론 기본 데이터형과 평면적인 스트럭처 자료형에 대해서만 올바르게 동작할 것입니다.
유의미한 동작을 하는 복사 생성자가 필요한 복잡한 자료형에 대해 BitBlast를 사용해서는 안될 것입니다.
그렇다면 BitBlast가 적용될 수 있는 단순 자료형에 대해서는 이를 사용하고, 그렇지 않고 복잡한 자료형에
대해서는 보다 전통적인 일반 알고리즘을 사용하면 매우 좋을 것입니다.
복사 알고리즘 예제 코드 보기
- template <typename InIt, typename OutIt>
- OutIt Copy(InIt first, InIt last, OutIt result)
- {
- if (복사 대상이 단순 자료형인 경우) {
- return BitBlast(first, last, result);
- }
- else {
- return std::copy(first, last, result);
- }
- }
복사 알고리즘 예제 코드 접기
여기서 필요한 작업은 다음의 두 가지 조건에 대한 검증입니다.
-
InIt과 OutIt이 보통의 포인터인가?(잘 꾸며진 iterator 형과는 반대의 의미로)
-
InIt과 OutIt이 가리키는 자료형이 비트 단위 복사를 통해 복사가 가능한 자료형인가?
만일 컴파일 타임에 이러한 질문에 대한 답을 찾을 수 있고, 또한 두 질문에 대한 답이 모두 yes라면, BitBlast를
이용할 수 있을 것입니다.
자료형에 대한 Traits가 이러한 문제를 해결하는 데 도움이 될 수 있습니다. 이번 장에서 등장하는 자료형에 대한 Traits는
Boost C++ 라이브러리(Boost)의 내용에서 많은 부분을 차용해 왔습니다.
-
Loki는 TypeTraits라는 클래스 템플릿을 정의하여 일반적인 자료형에
대한 일련의 Traits들을 제공하고 있습니다. TypeTraits는 내부적으로 템플릿의 특화를 이용하고 있으며,
그 특정 결과를 반환하여 줍니다. 예를 들어, 다음과 같은 코드는 T가 포인터 자료형인지의 여부를 가려줍니다.
TypeTraits 예제 코드 보기
- template <typename T>
- class TypeTraits
- {
- private:
- template <class U> struct PointerTraits
- {
- enum { result = false };
- typedef NullType PointeeType;
- };
-
- template <class U> struct PointerTraits<U*>
- {
- enum { result = true };
- typedef U PointeeType;
- };
-
- public:
- enum { isPointer = PointerTraits<T>::result };
- typedef PointerTraits<T>::PointeeType PointeeType;
- ...
- };
-
-
-
-
-
-
-
-
- int main(void)
- {
- const bool
- iterIsPtr = TypeTraits<vector<int>::iterator>::isPointer;
-
- cout << "vector<int>::iterator is " <<
- iterIsPtr ? "fast" : "smart" << '\n';
-
- return 0;
- }
-
-
-
TypeTraits 예제 코드 접기
클래스 멤버에 대한 포인터를 알아내는 법(자세한 내용은 5장의 멤버에 대한 포인터 부분을
참고하기 바랍니다)은 이와는 조금 다릅니다. 여기에 필요한 특화 코드는 다음과 같습니다.
TypeTraits 예제 코드 보기
- template <typename T>
- class TypeTraits
- {
- private:
- template <class U> struct PToMTraits
- {
- enum { result = false };
- };
-
- template <class U, class V>
- struct PToMTraits<U V::*>
- {
- enum { result = true };
- };
-
- public:
- enum { isMemberPointer = PToMTraits<T>::result };
- };
TypeTraits 예제 코드 접기
-
TypeTraits<T>는 isStdFundamental이라는 컴파일 타임 상수를 제공하고 있습니다. 이 상수는
T가 표준적인 기본 자료형인지의 여부를 말해줍니다. 표준적인 기본 자료형은 void와 모든 수치 자료형(부동소수점과
정수형)으로 이루어져 있습니다. TypeTraits는 주어진 자료형이 어느 분류에 속하는지를 드러내는 상수를 정의하고
있습니다.
조금 앞서서 이야기를 해본다면, 3장에서 소개될
Typelist라는 마법과도 같은 도구를 사용하면, 특정 자료형이 주어진
어떤 자료형의 집합에 포함되는지의 여부를 쉽게 판단해 낼 수 있습니다. 어쨌든 지금은 일단 다음과 같은 표현만
알고 있으면 됩니다.
Typelist 예제 코드 보기
- TL::IndexOf<T, TYPELIST_nn(comma-separated list of types)>::value
-
-
-
-
-
- TL::IndexOf<T, TYPELIST_4(signed char, short int, int, long int)>::value
-
Typelist 예제 코드 접기
다음의 정의는 TypeTraits의 정의 중 기본 자료형에 대한 부분입니다.
Typelist 예제 코드 보기
- template <typename T>
- class TypeTraits
- {
- ... 위에서 다룬 정의와 같습니다 ...
- public:
- typedef TYPELIST_4(
- unsigned char, unsigned short int,
- unsigned int, unsigned long int)
- UnsignedInts;
-
- typedef TYPELIST_4(signed char, short int, int, long int)
- SignedInts;
-
- typedef TYPELIST_3(bool, char, wchar_t) OtherInts;
-
- typedef TYPELIST_3(float, double, long double) Floats;
-
- enum { isStdUnsignedInt =
- TL::IndexOf<T, UnsignedInts>::value >= 0 };
-
- enum { isStdSignedInt = TL::IndexOf<T, SignedInts>::value >= 0 };
-
- enum { isStdIntegral = isStdUnsignedInt || isStdSignedInt ||
- TL::IndexOf<T, OtherInts>::value >= 0 };
-
- enum { isStdFloat = TL::IndexOf<T, Floats>::value >= 0 };
-
- enum { isStdArith = isStdIntegral || isStdFloat };
-
- enum { isStdFundamental = isStdArith ||
- Conversion<T, void>::sameType };
-
- ...
- };
-
-
-
Typelist 예제 코드 접기
-
템플릿 코드에 있어서, 때때로 다음의 질문에 대답해야 할 필요가 생깁니다. 임의로 주어진 자료형 T에 대해서,
T형의 객체를 전달하는 가장 효율적인 방법은 무엇일까요? 일반적으로, 복잡하게 정의된 자료형을 전달하는 가장
효과적인 방법은 그 참조형을 쓰는 것이며, 스칼라 자료형(앞에서 언급한 수치 자료형과 나열형, 포인터형 그리고
멤버에 대한 포인터형 등을 말합니다)의 경우에는 그 값 자체를 전달하는 것이 가장 효율적입니다. 복잡한 자료형의
경우에는 임시 값에 대한 오버헤드를 방지하기 위해서, 그리고 스칼라 자료형의 경우에는 간접 인덱싱에 의한 오버헤드를
피하기 위해서 이러한 정책이 필요할 것입니다.
함수 호출에 있어서 최적화된 인자의 자료형을 조금만 분석해 보면(C++가 참조형에 대한 참조를 허용하지 않는 다는 점도
고려하여), 다음의 알고리즘을 얻을 수 있습니다. 우리가 살펴볼 인자의 자료형을 ParameterType이라고 부르도록
합시다.
pseudo 코드 보기
- If: T가 다른 타입에 대한 참조형일 경우
- ParameterType은 T와 같다.
- (참조형에 대한 참조는 허용되지 않기 때문)
-
- Else:
- If: T가 스칼라 자료형인 경우
- ParameterType은 T가 된다.
- (기본 자료형들은 값을 통해 전달되는 것이 가장 효율적이기 때문)
-
- Else:
- ParameterType은 T&가 된다.
- (일반적으로, 기본 자료형이 아닐 경우 참조를 통해 전달되는 것이 가장 효율적이기 때문)
pseudo 코드 접기
그동안 다루었던 테크닉과 이미 정의된 ReferenceType이나 isPrimitive와 같은 Traits를 이용하여
TypeTraits::ParameterType을 구현하는 것은 아주 쉽습니다.
예제 코드 보기
- template <typename T>
- class TypeTraits
- {
- ... 앞의 정의와 같습니다 ...
- public:
- typedef Select<isStdArith || isPointer || isMemberPointer,
- T, ReferencedType&>::Result
- ParameterType;
- };
예제 코드 접기
불행하게도, 이러한 방안조차 나열형(enum) 객체를 값에 의해 전달하는 데 그 해답이 될 수 없습니다.
왜냐하면 enum 자료형의 여부를 판단하는 방법은 아직까지 전혀 알려진 것이 없기 때문입니다.
5장에서 정의된 함수자(Functor) 클래스 템플릿은 TypeTraits::ParameterType을
이용하여 구현되어 있습니다.
-
자료형 T가 주어졌을 때, 간단하게 const T라고 입력하는 것만으로 그에 대한 상수 자료형을 쉽게 얻을 수 있습니다.
하지만 그 반대의 작업(어떤 자료형에서 const나 volatile 한정자를 없애는 것)은 훨씬 더 어렵습니다.
다시 템플릿의 부분 특화를 이용해 본다면, "const 제거기"는 다음과 같이 쉽게 구현됩니다.
const 제거기 예제 코드 보기
- template <typename T>
- class TypeTraits
- {
- ... 앞의 정의와 같습니다 ...
- private:
- template <class U> struct UnConst
- {
- typedef U Result;
- };
-
- template <class U> struct UnConst<const U>
- {
- typedef U Result;
- };
-
- public:
- typedef UnConst<T>::Result NonConstType;
- };
const 제거기 예제 코드 접기
-
TypeTraits는 많은 흥미로운 일을 하는 데 도움이 될 수 있습니다. 한 가지 예를 든다면, 이제 BitBlast(이
문제는 2.10 섹션에서 다루고 있습니다)를 이용하는 복사 루틴을 작성할 수 있습니다.
또한, TypeTraits를 주어진 두 반복자에 대한 자료형 정보를 알아내기 위해 사용할 수 있습니다. 그리고 BitBlast를
호출할지, 고전적인 복사 루틴을 사용할지를 결정하기 위해 Int2Type 템플릿을 사용할 수 있습니다.
BitBlast 예제 코드 보기
- enum CopyAlgoSelector { Conservative, Fast };
-
- template <typename InIt, typename OutIt>
- OutIt CopyImpl(InIt first, InIt last, OutIt result,
- Int2Type<Conservative>)
- {
- for (; first != last; ++first, ++result)
- *result = *first;
-
- return result;
- }
-
- template <typename InIt, typename OutIt>
- OutIt CopyImpl(InIt first, InIt last, OutIt result,
- Int2Type<Fast>)
- {
- const size_t n = last - first;
- BitBlast(first, result, n * sizeof(*first));
- return result + n;
- }
-
- template <typename InIt, typename OutIt>
- OutIt Copy(InIt first, InIt last, OutIt result)
- {
- typedef TypeTraits<InIt>::PointeeType SrcPointee;
- typedef TypeTraits<OutIt>::PointeeType DestPointee;
-
- enum { copyAlgo =
- TypeTraits<InIt>::isPointer &&
- TypeTraits<OutIt>::isPointer &&
- TypeTraits<SrcPointee>::isStdFundamental &&
- TypeTraits<DestPointee>::isStdFundamental &&
- sizeof(SrcPointee) == sizeof(DestPointee) ? Fast :
- Conservative };
-
- return CopyImpl(first, last, result, Int2Type<copyAlgo>());
- }
-
-
-
-
-
-
- int *p1 = ...;
- int *p2 = ...;
- unsigned int *p3 = ...;
-
- Copy(p1, p2, p3);
-
BitBlast 예제 코드 접기
이 Copy 함수의 약점은 BitBlast의 빠른 성능을 적용할 수 있는 모든 경우를 찾아내지는 못 한다는 것입니다.
예를 들어, C언어 스타일의 평범한 struct 구조체(이것을 plain old data, 즉 POD 구조체라 부르기로 합시다)
를 복사하려는 경우가 있겠습니다. POD 구조체는 비트 단위 복사가 가능하지만, Copy 함수가 주어진 자료형에 대해
POD 구조체인지의 여부를 감별해 낼 방법은 사실상 존재하지 않습니다. 이런 경우에 다시, TypeTraits가 아닌
전통적인 traits에 의존해야 할 필요성이 생깁니다. 다음 예제 코드를 보세요.
예제 코드 보기
- template <typename T> struct SupportBitwiseCopy
- {
- enum { result = TypeTraits<T>::isFundamental };
- };
-
- template <typename InIt, typename OutIt>
- OutIt Copy(InIt first, InIt last, OutIt result)
- {
- typedef TypeTraits<InIt>::PointeeType SrcPointee;
- typedef TypeTraits<OutIt>::PointeeType DestPointee;
-
- enum { useBitBlast =
- TypeTraits<InIt>::isPointer &&
- TypeTraits<OutIt>::isPointer &&
- SupportBitwiseCopy<SrcPointee>::result &&
- SupportBitwiseCopy<DestPointee>::result &&
- sizeof(SrcPointee) == sizeof(DestPointee) };
-
- return CopyImpl(first, last, result, Int2Type<useBitBlast>());
- }
-
-
- template <> struct SupportBitwiseCopy<MyType>
- {
- enum { result = true };
- };
예제 코드 접기
-
Loki에서 구현된 traits의 완전한 정의가 [표 2.1]에 나타나 있습니다.
[표 2.1] TypeTraits<T> 멤버 목록 보기
-
-
컴파일 타임 어써션(2.1 섹션)은
템플릿 코드에서 라이브러리가 의미 있는 에러 메시지를 출력하도록 도와줍니다.
-
템플릿의 부분 특화(2.2 섹션)는
고정된 인자 목록에 대해서가 아니라, 어떤 패턴에 대응하는 인자의 집합에 대해 템플릿을 특화시킬 수 있는
능력을 부여합니다.
-
로컬 클래스(2.3 섹션)는
템플릿 함수 내부에서 작용할 수 있는 많은 흥미로운 작업들을 가능하게 해 줍니다.
-
상수 값에서 자료형으로의 매핑(2.4 섹션)은
숫자 값을 통해 컴파일 타임에 이루어지는 디스패치 작업을 쉽게 만들어 줍니다(특히 Boolean 조건에 대해).
-
자료형에서 다른 자료형으로의 매핑(2.5 섹션)은
C++에서 빠져 있는 함수에 대한 템플릿의 부분 특화를 대신하여 함수 오버로딩을 사용할 수 있도록 만들어 줍니다.
-
자료형의 선택(2.6 섹션)은
Boolean 조건에 근거하여 자료형을 선택할 수 있도록 만들어 줍니다.
-
형변환과 상속 가능의 여부를 컴파일 타임에 알아내기(2.7 섹션)는
임의로 주어진 두 자료형이 서로 간에 변환될 수 있는지, 그 둘이 같은 자료형에 대한 서로 다른 이름인지,
또는 하나가 다른 하나로 상속이 되는지를 계산해 낼 수 있는 능력을 부여합니다.
-
TypeInfo(2.8 섹션)는
std::type_info에 대한 포장 클래스를 구현한 것으로, 이것은 그 값에 의한 저장과 순서 비교를 지원하고 있습니다.
-
NullType과 EmptyType 클래스(2.9 섹션)는
템플릿을 사용하는 메타프로그래밍에 있어서 특정 표식용 자료형으로 작용하게 됩니다.
-
TypeTraits 템플릿(2.10 섹션)은
사용자가 특정 유형의 자료형에게 맞도록 코드를 제단하게 만들어 주는 일반 목적의 traits의 집합을 제공합니다.
-
이번 장을 읽은 후에는 다음과 같은 일들이 가능해 질 것입니다.
-
Typelist의 개념에 대한 이해
-
Typelist가 생성되고 처리되는 방식에 대한 이해
-
Typelist를 효과적으로 다루는 방법에 대한 이해
-
Typelist의 주요한 사용법과 이것이 가능하게 만들어 주는 프로그래밍 기법들에 대한 이해
Typelist는 실제로 9장, 10장과 11장의 내용을 가능하게 해 주는 중요한 도구로 사용되고 있습니다.
-
때때로, 여러 자료형에 대한 동일한 코드를 반복하여 작성해야만 할 때가 생깁니다. 심지어 템플릿조차도
이 문제를 해결하는 데 도움이 되지 못할 수도 있습니다. 예를 들어 추상 팩토리를 구현하는 경우를 생각해 봅시다.
다음 예제와 같이 디자인을 하면서 예측 가능한 자료형들 중, 그 각각에 대해 별도의 가상 함수를 정의하게 됩니다.
예제 코드 보기
- class WidgetFactory
- {
- public:
- virtual Window* CreateWindow(void) = 0;
- virtual Button* CreateButton(void) = 0;
- virtual ScrollBar* CreateScrollBar(void) = 0;
- };
예제 코드 접기
만일 추상 팩토리의 개념을 일반화시켜 라이브러리로 구현하고자 한다면, Window, Button, ScrollBar 뿐만 아니라,
다른 모든 임의의 자료형 모음에 대해 사용자가 팩토리를 만들어 낼 수 있도록 만들어 주어야 합니다. 템플릿은 이러한 특징을
제공해 주지 않습니다.
처음에는, 추상 팩토리가 제공하는 추상화 및 일반화 능력이 그다지 범용적이지 못해 보이지만, 다음과 같은 몇 가지
사항을 면밀히 검토해 보는 것은 의미 있는 고찰이 될 것입니다.
-
기본적인 개념들조차 일반화시켜 볼 수 없다면, 그러한 개념에 의한 구체적인 존재를 일반화시킨다는 것은 영원히
불가능한 일입니다. 추상 팩토리의 경우에 있어서 추상화된 기반 클래스가 상당히 간단함에도 불구하고, 다양한
구체적인 팩토리를 구현하는 데 중복된 코드를 작성하느라 상당한 난처함을 겪게 될 것입니다.
-
WidgetFactory의 멤버 함수를 그리 쉽게 다룰 수가 없습니다(앞의 예제 코드를 보세요). 위의 가상 함수들을
집합적으로 일반화시켜 다루는 것은 원천적으로 불가능합니다. 예를 들어, 다음과 같은 코드를 보시기 바랍니다.
예제 코드 보기
- template <class T>
- T* MakeRedWidget(WidgetFactory& factory)
- {
- T *pW = factory.CreateT();
- pW->SetColor(RED);
- return pW;
- }
예제 코드 접기
T가 Window인지, Button인지, 또는 ScrollBar인지에 따라 CreateWindow, CreateButton, 또는
CreateScrollBar를 일일이 호출해 주어야 합니다. C++은 이러한 작업을 대신해 줄 만한 문법을 가지고 있지 않습니다.
-
마지막으로 좋은 라이브러리는 명명법에 대한 표준안에 대한 논쟁을 종식시켜 줄 수 있는 멋진 부대효과를 가지고 있습니다.
또한, 시스템의 변경 또한 거의 필요가 없습니다. 라이브러리는 어떤 일을 처리하는 데 보다 선호되는 일바적인 방법을
소개합니다. 물론, 추상 팩토리를 추상화하는 것 역시 이러한 부대효과의 덕을 봐야만 할 것입니다.
한 번 희망 사항을 나열하여 목록을 만들어 봅시다.
예제 코드 보기
-
- typedef AbstractFactory<Window, Button, ScrollBar> WidgetFactory;
-
-
-
-
- template <class T>
- T* MakeRedWidget(WidgetFactory& factory)
- {
- T* pW = factory.Create<T>();
- pW->SetColor(RED);
- return pW;
- }
예제 코드 접기
하지만, 우리는 이러한 바람들을 만족시킬 수 없습니다. 첫째, WidgetFactory에 대해서 위의 예제에서처럼 typedef를
하는 것은 불가능합니다. 왜냐하면 템플릿 인자의 개수는 고정되어 있기 때문에, 임의의 개수를 가지는 목록을 표현할 수 없기
때문입니다. 둘째, Create<Xxx>()와 같은 템플릿 구문은 문법적으로 올바르지 않습니다. 왜냐하면 가상 함수는
템플릿의 형식으로 표현될 수 없기 때문입니다.
이에, Typelist는 제네릭한 스타일의 추상 팩토리를 비롯하여 그 밖의 다른 많은 것들을 가능하게 해 줄 것입니다.
-
다음과 같은 Typelist의 전통적인 표현은 기본적으로 아주 간단하게 정의되어 있습니다.
Typelist 예제 코드 보기
- template <class T, class U>
- struct Typelist
- {
- typedef T Head;
- typedef U Tail;
- };
-
- namespace TL
- {
- ... Typelist 알고리즘 ...
- }
-
-
-
-
-
-
- typedef Typelist<char, Typelist<signed char, unsigned char> >
- CharList;
Typelist 예제 코드 접기
Typelist는 어떠한 값도 지니고 있지 않습니다. Typelist는 오직 자료형을 담기 위해서만 존재합니다.
따라서 Typelist에 대한 연산은 필수적으로 컴파일 타임에 이루어져야 합니다. Typelist의 인스턴스는
아무런 가치가 없습니다. 오직 그것의 자료형으로서의 의미만이 쓸모가 있습니다(3.13.2 섹션을
보면 값들의 집합을 다루는 데 Typelist를 어떻게 이용하는지 나옵니다).
여기서 쓰인 템플릿의 속성은 템플릿의 인자가 그 어떤 자료형이든 상관하지 않는다는 점입니다. 다른 인자를 사용한
템플릿 자기 자신을 포함해서 말이지요. Typelist가 두 개의 인자를 받아들이기 때문에, 우리는 Typelist의 한쪽
인자를 또 다른 Typelist로 무한히 반복해 가면서 확장이 가능한 것입니다.
한가지 문제는 0개 혹은 1개의 자료형만을 갖는 Typelist는 표현할 방법이 없다는 것입니다. 따라서 우리에게 필요한 것은
목록이 존재하지 않는 자료형이며, 2장에서 다루었던 NullType 클래스가 이런 상황에 알맞은
해결책이 될 수 있을 것입니다.
모든 Typelist가 NullType을 끝으로 가져야 한다는 규칙을 세우도록 합시다. 이제 우리는 하나의 원소만을 갖는 Typelist를
다음과 같이 정의할 수 있습니다.
Typelist 예제 코드 보기
- typedef Typelist<int, NullType> OneTypeOnly;
-
-
- typedef Typelist<char, Typelist<signed char,
- Typelist<unsigned char, NullType> > > AllCharTypes;
Typelist 예제 코드 접기
-
Typelist는 너무도 LISP와 비슷하여 사용하기가 그리 쉽지 않습니다. 예를 들어, 다음과 같은 정수 자료형에
대한 Typelist의 정의를 한 번 생각해 보십시오.
예제 코드 보기
- typedef Typelist<signed char,
- Typelist<short int,
- Typelist<int, Typelist<long int, NullType> > > >
- SignedIntegrals;
예제 코드 접기
Typelist는 멋진 개념을 가지고 있을지는 몰라도, 그것에 대한 정의는 좀 더 나은 포장법이 필요할 것처럼 보입니다.
Typelist의 생성을 선형화하기 위해서, Loki의 Typelist 라이브러리는 반복적 재귀 정의를 단순한 나열 방식으로 바꿔
줄 수 있는 다수의 매크로를 정의하고 있습니다. 라이브러리는 50개의 원소를 가지는 Typelist까지 정의를 해놓고 있습니다.
다음 예제 코드를 참고하십시오.
Typelist 선형화 예제 코드 보기
- #define TYPELIST_1(T1) Typelist<T1, NullType>
- #define TYPELIST_2(T1, T2) Typelist<T1, TYPELIST_1(T2) >
- #define TYPELIST_3(T1, T2, T3) Typelist<T1, TYPELIST_2(T2, T3) >
- #define TYPELIST_4(T1, T2, T3, T4) Typelist<T1, TYPELIST_3(T2, T3, T4) >
- ...
- #define TYPELIST_50(...) ...
-
-
-
-
- typedef TYPELIST_4(signed char, short int, int, long int)
- SignedIntegrals;
Typelist 선형화 예제 코드 접기
Typelist를 다루는 작업은 여전히 불편합니다. 예를 들어, 위 예제의 SignedIntegrals의 마지막 원소에 접근하고자
한다면 'SignedIntegrals::Tail::Tail::Tail::Head'와 같은 표현이 필요합니다. 이제 일반적인 list에서
행해지는 것과 같은 방식으로 Typelist에 대한 몇 가지 기본적인 연산을 정의해야 할 때가 온 것 같습니다.
-
우선 TList라는 Typelist가 주어져 있을 때에 그 list의 길이를 나타내는 컴파일 타임 상수를 얻을 수 있는 간단한 연산을
정의해 보도록 합시다. Typelist의 속성이 그러하듯이, Typelist와 연관된 모든 계산들은 컴파일 타임에 이루어져야 합니다.
대부분의 Typelist를 다루는 방법들은 템플릿을 재귀적으로 정의하는 방법에 기초하고 있습니다.
Typelist의 길이를 계산하는 코드는 다음과 같이 매우 간결하게 구현됨을 알 수 있습니다.
Length 예제 코드 보기
- template <class TList> struct Length;
- template <> struct Length<NullType>
- {
- enum { value = 0 };
- };
-
- template <class T, class U>
- struct Length<Typelist<T, U> >
- {
- enum { value = 1 + Length<U>::value };
- };
-
-
-
-
- std::type_info* intsRtti[Length<SignedIntegrals>::value];
-
Length 예제 코드 접기
-
"재귀적인 방법이 아니라, 순환적인 방법을 사용하는 버전의 Length를 만들어 낼 수는 없을까요?"
결론부터 말하자면 대답은 "No!"입니다. 여기에는 다음과 같은 흥미로운 이유가 존재합니다.
우리가 C++에서 컴파일 타임 프로그래밍을 하는 데 쓸 수 있는 유일한 도구는 바로 템플릿, 컴파일 타임의 정수(상수) 연산,
그리고 자료형에 대한 사용자 정의(typedef) 뿐입니다.
-
템플릿(특히 템플릿의 부분 특화)은 컴파일 타임에 있어서 마치 if 문과 같은 기능을 제공합니다.
앞에서 다룬 Length의 정의에서 보듯이, 템플릿의 부분 특화는 Typelist와 다른 자료형에
대한 차별적 처리를 가능하게 한다는 것입니다.
-
정수 연산은 순전히 값에 대한 연산이기 때문에, 자료형을 값으로 변환하여 사용하게 됩니다. 하지만,
여기에는 독특한 특징이 있습니다. 컴파일 타임에 사용되는 값은 모두 불변하는
값입니다. 일단 어떤 상수를 정의하는 경우, 예를 들어 나열형(enum) 값을 정의했다고 합시다.
그러면 그 값은 절대로 바뀔 수가 없습니다.
-
사용자 정의 자료형(typedef)은 어떤 자료형에 새로운 이름을 부여하는 자료형 상수라고 파악될 수 있습니다.
이것 역시 typedef가 한 번 이루어지고 나면, 그 정의도 완전히 고정된다는 것을 뜻합니다.
컴파일 타임 연산의 이러한 특징은 본질적으로 순환 알고리즘과는 맞지 않는 요소입니다. 순환 알고리즘이란,
순환자(iterator)를 담을 수 있는 공간을 필요로 하면서, 특정한 조건이 만족될 때까지 그 값을 계속
바꿔주는 과정을 의미합니다. 컴파일 타임의 세계에서 우리는 변수의 역할을 담당할 아무런 장치도 가지고 있지
못하므로, 결국 어떠한 순환 알고리즘도 구현할 수가 없는 것입니다.
-
Typelist의 원소에 접근하는 데 index를 통해 접근이 가능하다면 그것은 참으로 바람직한 일이 될 것입니다.
index 연산을 지원하기 위한 템플릿과 그 알고리즘을 정의해 보도록 합시다.
TypeAt 예제 코드 보기
-
- template <class TList, unsigned int index> struct TypeAt;
-
-
-
- TypeAt
- Input: Typelist TList 및 index i
- Output: 내부 자료형 Result
-
- If TList가 null이 아니고 i가 0이라면
- Result는 TList의 head가 된다.
- Else
- If TList가 null이 아니고 i가 0이 아니라면
- Result는 TypeAt를 재귀적으로 TList의 tail과 i-1에 대해 적용시켜 얻어진다.
- Else
- index의 범위가 적법한 범위를 벗어났으면 컴파일 에러를 유발해야 한다.
-
-
-
- template <class Head, class Tail>
- struct TypeAt<Typelist<Head, Tail>, 0>
- {
- typedef Head Result;
- };
-
- template <class Head, class Tail, unsigned int i>
- struct TypeAt<Typelist<Head, Tail>, i>
- {
- typedef typename TypeAt<Tail, i - 1>::Result Result;
- };
-
-
TypeAt 예제 코드 접기
Loki(Typelist.h 파일)는 위의 TypeAt을 조금 변형하여 TypeAtNonStrict라는 템플릿을 정의하고 있습니다.
TypeAtNonStrict는 TypeAt과 동일한 기능을 제공하고 있지만, 범위를 벗어난 index 값에 대해 오류를 출력하는 대신,
사용자가 선택한 default 자료형을 반환하게 됩니다. TypeAtNonStrict는 5장에서 다루고
있는 제네릭한 스타일의 콜백의 구현에 사용되었습니다.
Index를 통해 Typelist에 접근하는 것은 그 Typelist의 크기에 비례하는 만큼의 수행 시간이 걸립니다.
Typelist의 경우에는 이러한 시간 소요가 컴파일 타임에 이루어지게 됩니다.
-
Typelist에서 해당 자료형에 대한 index를 찾아주는 IndexOf 알고리즘을 한 번 구현해 보도록 합시다.
만일 해당 자료형을 찾아낼 수 없다면, IndexOf는 그 결과로 -1을 반환해야 합니다.
pseudo 코드 보기
- IndexOf
- Input: Typelist와 type T
- Output: 컴파일 타임 상수 값인 value
-
- If TList가 NullType이라면
- value는 -1이다.
- Else
- If TList의 head가 T라면
- value는 0이다.
- Else
- TList의 Tail과 T에 대한 IndexOf 연산을 수행하여 temp값에 담는다.
- If temp가 -1이라면
- value는 -1이다.
- Else
- value는 (temp + 1)이다.
-
-
pseudo 코드 접기
우리는 템플릿을 세 가지로 특화시켜서 이 알고리즘을 구현해야 합니다. 각각의 템플릿에 대한 특화는 알고리즘 상의 각
조건에 해당합니다. 그리고 마지막 조건문(temp를 가지고 값을 판단해 내는 부분)의 처리는 조건 연산자 ?:를 통해
수행될 수 있습니다. 이를 구현한 템플릿 코드는 다음과 같습니다.
IndexOf 예제 코드 보기
- template <class TList, class T> struct IndexOf;
-
- template <class T>
- struct IndexOf<NullType, T>
- {
- enum { value = -1 };
- };
-
- template <class T, class Tail>
- struct IndexOf<Typelist<T, Tail>, T>
- {
- enum { value = 0 };
- };
-
- template <class Head, class Tail, class T>
- struct IndexOf<Typelist<Head, Tail>, T>
- {
- private:
- enum { temp = IndexOf<Tail, T>::value };
-
- public:
- enum { value = temp == -1 ? -1 : 1 + temp };
- };
IndexOf 예제 코드 접기
-
어떤 자료형, 혹은 Typelist를 다른 Typelist에 추가시켜야 하는 경우가 생길 수 있습니다. 기존의 Typelist를
수정하는 것은 불가능하기 때문에 우리는 새로운 Typelist를 결과로 반환해 주는 방식을 사용해야 할 것입니다.
pseudo 코드 보기
- Append
- Input: Typelist TList와 자료형 또는 또 다른 Typelist T
- Output: 내부에서 정의된 사용자 정의 자료형 Result
-
- If TList가 NullType이고 T가 NullType일 때
- Result는 NullType이 된다.
- Else
- If TList가 NullType이고 T가 단순(Typelist가 아닌) 자료형일 때
- Result는 T 하나를 갖는 Typelist가 된다.
- Else
- If TList가 NullType이고 T가 Typelist일 때
- Result는 T 자신이 된다.
- Else TList가 NullType이 아닌 경우에
- Result는 TList::Head를 Head로 갖고,
- TList::Tail과 T의 Append 연산 결과를
- Tail로 갖는 새로운 Typelist가 된다.
pseudo 코드 접기
이 알고리즘은 자연스럽게 다음과 같은 코드로 이어지게 됩니다.
Append 예제 코드 보기
- template <class TList, class T> struct Append;
-
- template <> struct Append<NullType, NullType>
- {
- typedef NullType Result;
- };
-
- template <class T> struct Append<NullType, T>
- {
- typedef TYPELIST_1(T) Result;
- };
-
- template <class Head, class Tail>
- struct Append<NullType, Typelist<Head, Tail> >
- {
- typedef Typelist<Head, Tail> Result;
- };
-
- template <class Head, class Tail, class T>
- struct Append<Typelist<Head, Tail>, T>
- {
- typedef Typelist<Head, typename Append<Tail, T>::Result>
- Result;
- };
-
-
-
-
- typedef Append<SignedIntegrals,
- TYPELIST_3(float, double, long double)>::Result
- SignedTypes;
-
Append 예제 코드 접기
-
특정 자료형을 삭제할 때, 우리에게는 두 가지 선택 사항을 가지고 있습니다. 하나는 처음 발견되는 자료형만을 제거하는 것이고,
다른 하나는 해당 Typelist 내에서 발견되는 모든 자료형을 제거하는 것입니다.
먼저 처음 발견되는 자료형만을 제거하는 알고리즘을 생각해 봅시다.
pseudo 코드 보기
- Erase
- Input: Typelist TList와 자료형 T
- Output: 내부에서 정의된 결과 자료형 Result
-
- If TList가 NullType일 때
- Result는 NullType이 된다.
- Else
- If T가 TList::Head와 같을 때
- Result는 TList::Tail이 된다.
- Else
- Result는 TList::Head를 Head로 갖고,
- Erase를 TList::Tail과 T에 적용한 결과를
- Tail로 갖는 새로운 Typelist가 된다.
pseudo 코드 접기
이 알고리즘을 C++ 언어로 표현해 보면 다음과 같습니다.
Erase 예제 코드 보기
- template <class TList, class T> struct Erase;
-
- template <class T>
- struct Erase<NullType, T>
- {
- typedef NullType Result;
- };
-
- template <class T, class Tail>
- struct Erase<Typelist<T, Tail>, T>
- {
- typedef Tail Result;
- };
-
- template <class Head, class Tail, class T>
- struct Erase<Typelist<Head, Tail>, T>
- {
- typedef Typelist<Head,
- typename Erase<Tail, T>::Result>
- Result;
- };
-
-
-
-
-
-
- typedef Erase<SignedTypes, float>::Result SomeSignedTypes;
Erase 예제 코드 접기
이제 반복적인 Erase 알고리즘을 검토해 보도록 합시다. EraseAll 템플릿은 어떤 Typelist에서 주어진 자료형
T를 검색하여 모두 지워주는 동작을 하게 됩니다. 그 구현은 Erase의 구현과 비슷하지만 하나의 차이점이 있습니다.
지워야 할 자료형을 발견하였을 때 이 알고리즘은 거기서 동작을 멈추지 않습니다. EraseAll은 Typelist의 마지막
원소까지 지울 자료형을 찾는 작업을 계속하게 됩니다.
EraseAll 예제 코드 보기
- template <class TList, class T> struct EraseAll;
-
- template <class T>
- struct EraseAll<NullType, T>
- {
- typedef NullType Result;
- };
-
- template <class T, class Tail>
- {
-
- typedef typename EraseAll<Tail, T>::Result Result;
- };
-
- template <class Head, class Tail, class T>
- struct EraseAll<Typelist<Head, Tail>, T>
- {
-
- typedef Typelist<Head, typename EraseAll<Tail, T>::Result>
- Result;
- };
EraseAll 예제 코드 접기
-
중복 자료형을 삭제하는 작업은 Typelist를 변형하여 각각의 자료형이 오직 한 번씩만 존재하게 만들기 위해 필요합니다.
이러한 작업은 지금까지보다는 조금 복잡해 보일 수도 있지만, 여기서 Erase의 도움을 받는다면 훨씬 쉽게 구현될 수
있습니다.
pseudo 코드 보기
- NoDuplicates
- Input: Typelist TList
- Output: 내부에서 정의된 결과 자료형 Result
-
- If TList가 NullType일 경우
- Result는 NullType이 된다.
- Else
- TList::Tail에 대해 NoDuplicates를 적용시켜 L1이라는 임시 Typelist를 얻는다.
- Erase를 사용하여 L1으로부터 TList::Head를 제거하여 L2라는 임시 Typelist를 얻는다.
- Result는 TList::Head를 Head로 가지며, L2를 Tail로 하는 새로운 Typelist가 된다.
pseudo 코드 접기
이 알고리즘을 코드에 적용시키면 다음과 같이 됩니다.
NoDuplicates 예제 코드 보기
- template <class TList> struct NoDuplicates;
-
- template <> struct NoDuplicates<NullType>
- {
- typedef NullType Result;
- };
-
- template <class Head, class Tail>
- struct NoDuplicates<Typelist<Head, Tail> >
- {
- private:
- typedef typename NoDuplicates<Tail>::Result L1;
- typedef typename Erase<L1, Head>::Result L2;
-
- public:
- typedef Typelist<Head, L2> Result;
- };
-
-
-
NoDuplicates 예제 코드 접기
-
때때로, 어떤 자료형을 찾아서 지우는 것보다는 그것을 다른 자료형으로 대치시키는 것이 필요한 경우가 있습니다.
우리는 주어진 Typelist TList에 대해 자료형 T를 찾아 자료형 U로 대치시키고자 합니다.
pseudo 코드 보기
- Replace
- Input: Typelist TList, 자료형 T(바꿀 대상), 자료형 U(바뀌어질 새로운 자료형)
- Output: 내부적으로 정의된 결과 자료형 Result
-
- If TList가 NullType일 경우
- Result는 NullType이 된다.
- Else
- If TList의 head가 자료형 T일 경우
- Result는 U를 head로 하고 TList::Tail을 tail로
- 하는 새로운 Typelist가 된다.
- Else
- Result는 TList::Head를 head로 하고, TList::Tail에 Replace를 적용하여
- T를 U로 바꾼 결과를 tail로 갖는 새로운 Typelist가 된다.
pseudo 코드 접기
이 재귀 알고리즘을 올바로 구현한다면, 다음과 같은 코드가 될 것입니다.
Replace 예제 코드 보기
- template <class TList, class T, class U> struct Replace;
-
- template <class T, class U>
- struct Replace<NullType, T, U>
- {
- typedef NullType Result;
- };
-
- template <class T, class Tail, class U>
- struct Replace<Typelist<T, Tail>, T, U>
- {
- typedef Typelist<U, Tail> Result;
- };
-
- template <class Head, class Tail, class T, class U>
- struct Replace<Typelist<Head, Tail>, T, U>
- {
- typedef Typelist<Head, typename Replace<Tail, T, U>::Result>
- Result;
- };
-
Replace 예제 코드 접기
-
우리가 Typelist를 상속 관계에 의해 정렬시키기를 원한다고 가정해 봅시다. 즉, 우리가 원하는 것은 계층 구조의 가장
하위 자료형을 먼저 나타나게 하고, 동등한 관계의 자료형의 순서는 바꾸지 않고 그대로 내버려두는 것입니다.
어떤 집합을 정렬하고자 할 때 우리는 대소 비교 함수가 필요하게 됩니다. 우리는 이미 2장에서
서로의 상속 관계를 알아낼 수 있는 SUPERSUBCLASS(T, U)라는 도구를 다뤄 보았습니다. 이제 우리가 해야 할 일은
이 상속 관계를 알아내는 매크로를 Typelist에 적용시키는 것입니다.
여기에서 완벽한 정렬 알고리즘을 사용할 수는 없습니다. 왜냐하면, 우리는 자료형들 간의 모든 선후 관계를 알고 있지 못하기
때문입니다. 계층 구조상에서 형제 관계에 있는 동등한 레벨의 두 클래스는 SUPERSUBCLASS(T, U)를 가지고 정렬될 수가
없을 것입니다. 따라서 우리는 상속된 클래스를 앞으로 가져오고, 다른 클래스는 서로 간의 상대 위치를 그대로 유지시켜 주는
조금 변형된 알고리즘이 필요합니다.
DerivedToFront pseudo 코드 보기
- DerivedToFront
- Input: Typelist TList
- Output: 내부적으로 정의된 결과 자료형 Result
-
- If TList가 NullType일 경우
- Result는 NullType이 된다.
- Else
- TList::Head를 상속받고 있는 가장 말단의 자료형을 TList::Tail에서 찾아내어
- 임시 자료형 TheMostDerived에 저장한다.
- TList::Tail에 존재하는 TheMostDerived를 찾아 TList::Head로 바꾸고,
- 그 결과 자료형을 L에 담는다.
- Result는 TheMostDerived를 head로 하고,
- L에 DerivedToFront를 적용한 결과를 tail로 하는
- 새로운 Typelist가 된다.
-
DerivedToFront pseudo 코드 접기
여기에는 Typelist 내에서 주어진 자료형을 상속받고 있는 가장 말단의 자료형을 찾아내는 알고리즘에 대한 내용이 생략되어 있습니다.
SUPERSUBCLASS 매크로가 컴파일 타임 상수인 Boolean 값을 알려주고 있으므로, 우리는 Select 클래스 템플릿(역시
2장에서 다루었던)을 유용하게 사용할 수 있습니다.
MostDerived 알고리즘은 Typelist 하나와 Base라는 자료형을 인자로 받아서, 그것을 상속하고 있는 계층 구조상
가장 말단의 자료형을 반환해 주게 됩니다. 물론 Base를 상속받은 다른 자료형이 존재하지 않는다면 Base 그 자신을 반환할
수도 있습니다. 이 알고리즘은 다음과 같습니다.
MostDerived pseudo 코드 보기
- MostDerived
- Input: Typelist TList, type Base
- Output: 내부에서 정의된 결과 자료형 Result
-
- If TList가 NullType일 경우
- Result는 Base가 된다.
- Else
- TList::Tail과 Base에 대해 MostDerived를 적용시켜서
- 그 결과를 Candidate에 담는다.
- If TList::Head가 Candidate를 상속받은 경우
- Result는 TList::Head가 된다.
- Else
- Result는 Candidate가 된다.
MostDerived pseudo 코드 접기
MostDerived를 구현한 코드와, 이를 기초하여 구현한 DerivedToFront 알고리즘의 구현은 다음과 같이 될 것입니다.
예제 코드 보기
-
- template <class TList, class T> struct MostDerived;
-
- template <class T>
- struct MostDerived<NullType, T>
- {
- typedef T Result;
- };
-
- template <class Head, class Tail, class T>
- struct MostDerived<Typelist<Head, Tail>, T>
- {
- private:
- typedef typename MostDerived<Tail, T>::Result Candidate;
-
- public:
- typedef typename Select<SUPERSUBCLASS(Candidate, Head),
- Head, Candidate>::Result
- Result;
- };
-
-
-
- template <class T> struct DerivedToFront;
-
- template <>
- struct DerivedToFront<NullType>
- {
- typedef NullType Result;
- };
-
- template <class Head, class Tail>
- struct DerivedToFront<Typelist<Head, Tail> >
- {
- private:
- typedef typename MostDerived<Tail, Head>::Result
- TheMostDerived;
-
- typedef typename Replace<Tail, TheMostDerived, Head>::Result
- L;
-
- public:
- typedef Typelist<TheMostDerived, typename DerivedToFront<L>::Result> Result;
- };
예제 코드 접기
-
이번 장은 Typelist를 이용하여 코드의 자동 생성을 위한 기본적인 장치들을 정의하는 것을 목적으로 합니다.
이러한 장치들은 C++만의 가장 강력한 특징인 템플릿 템플릿 인자를
사용하여 구성됩니다.
Typelist는 그 자체로는 아무 쓸모가 없지만, Typelist를 통한 프로그래밍이 필요한 이유는 이것을 통해
클래스를 생성해 낼 수 있다는 것 때문입니다. 애플리케이션 프로그래머들은 클래스 코드를 Typelist가 안내하는
방향으로 채워야 할 필요가 종종 생기게 됩니다. 우리는 이러한 과정을 "Typelist를 통한 자동화"라고 부릅니다.
C++에는 순환이나 재귀적 용법의 매크로가 잘 제공되지 않기 때문에, Typelist의 각 자료형에 코드를 추가해
주는 것은 상당히 어렵습니다. 템플릿의 부분 특화를 이용해 볼 수는 있겠지만, 이런 방법을 사용자 코드에서
구현하는 것은 복잡하고 보기에 좋지 않습니다. 하지만 Loki는 분명히 이러한 작업에 도움이 될 수 있을 것입니다.
-
Loki가 제공하는 강력한 템플릿 도구인 GenScatterHierarchy는 Typelist의 각 자료형을 사용자가
제공한 기본적인 템플릿에 적용시켜 클래스를 구성하는 과정을 보다 쉽게 만들어 줄 것입니다.
사용자는 단지 하나의 인자를 갖는 간단한 템플릿을 정의하기만 하면 됩니다.
이 라이브러리 클래스 템플릿은 GenScatterHierarchy라고 불립니다. 일단 지금은 그 정의문부터
살펴보도록 합시다.
GenScatterHierarchy 예제 코드 보기
- template <class TList, template <class> class Unit>
- class GenScatterHierarchy;
-
- template <class T1, class T2, template <class> class Unit>
- class GenScatterHierarchy<Typelist<T1, T2>, Unit>
- : public GenScatterHierarchy<T1, Unit>
- , public GenScatterHierarchy<T2, Unit>
- {
- public:
- typedef Typelist<T1, T2> TList;
- typedef GenScatterHierarchy<T1, Unit> LeftBase;
- typedef GenScatterHierarchy<T2, Unit> RightBase;
- };
-
- template <class AtomicType, template <class> class Unit>
- class GenScatterHierarchy : public Unit<AtomicType>
- {
- typedef Unit<AtomicType> LeftBase;
- };
-
- template <template <class> class Unit>
- class GenScatterHierarchy<NullType, Unit>
- {
- };
GenScatterHierarchy 예제 코드 접기
궁극적으로 GenScatterHierarchy를 만들어 내는 작업은
Typelist에 존재하는 모든 자료형마다 각각 만들어진 Unit의 템플릿 적용
결과들을 상속하며 끝나게 됩니다. 예를 들어, 다음의 코드를 살펴보도록 합시다.
예제 코드 보기
- template <class T>
- struct Holder
- {
- T value_;
- };
-
- typedef GenScatterHierarchy<
- TYPELIST_3(int, string, Widget),
- Holder>
- WidgetInfo;
예제 코드 접기
예제 코드의 WidgetInfo로부터 만들어진 상속 구조의 계층 관계는 [그림 3.2]에 표시된 것과 같습니다.
[그림 3.2] WidgetInfo의 상속 구조 보기
우리는 이러한 구조를 '비선형'이라고 표현합니다.
이것은 반복적으로 사용자가 모델로 제공한 클래스 템플릿을 반복적으로 구체화해가면서 클래스의 계층 구조를
자동으로 만들어 냅니다.
Holder<int>와 Holder<string> 그리고 Holder<Widget>을 모두 상속받고
있기 때문에, WidgetInfo는 Typelist 상의 각 자료형별로 value_라는 하나의 멤버변수를 가지게 됩니다.
이제 여러분은 Widgetinfo 객체를 통해 흥미로운 일을 할 수가 있습니다. 예를 들어, 다음과 같이 코딩함으로써
WidgetInfo에 저장된 string 멤버에 접근할 수 있습니다.
예제 코드 보기
- WidgetInfo obj;
- string name = (static_cast<Holder<string>&>(obj)).value_;
-
-
-
-
-
-
- template <class T, class H>
- typename Private::FieldTraits<H>::Rebind<T>::Result&
- Field(H& obj)
- {
- return obj;
- }
예제 코드 접기
위 예제의 Field 함수는, 상속된 클래스로부터 기반 클래스로 암시적 형변환이 가능하다는 사실을 기초로 작성되어 있습니다.
Field가 클래스의 멤버 함수가 아니라, 네임스페이스에 정의된 함수라는 점을 주의하세요. 이런 고도의 제네릭한
스타일의 프로그래밍은 그들의 이름을 정하는 작업에 매우 신중을 가해야만 합니다. 예를 들어, Unit 자신이 Field라는
이름의 심볼을 정의하고 있다고 가정한다면, 이 함수는 Unit의 Field 멤버와 충돌할 수 있습니다.
Field를 사용하는 데 또 다른 측면의 불편한 경우가 있을 수 있습니다. 만일 Typelist에 중복된
자료형이 포함되어 있는 경우에는, 이것을 사용할 수 있는 방법이 없다는 것입니다. 다음과 같이 조금
변형된 WidgetInfo에 대해 생각해 보도록 합시다.
예제 코드 보기
- typedef GenScatterHierarchy<
- TYPELIST_4(int, int, string, Widget),
- Value>
- WidgetInfo;
예제 코드 접기
이제 WidgetInfo가 같은 int 형을 가지는 두 개의 value_ 멤버를 가지게 되었습니다. 만일
Field<int>를 WidgetInfo 객체에 대해 호출하게 되면, 컴파일러는 모호성을 해결해 달라고
불평을 하게 됩니다. [그림 3.4]에서 보듯이 WidgetInfo가 Holder<int>를 서로 다른 경로로
두 번이나 상속을 받으며 정의되고 있기 때문에, 이 모호성을 해결해 줄 수 있는 단순한 방법은 존재하지 않습니다.
[그림 3.4] Holder<int>를 두 번 상속하고 있는 WidgetInfo 보기
우리에게 필요한 것은 GenScatterHierarchy의 인스턴스가 가지고 있는 값들을 그 자료형의 이름에 의해서가
아니라, Typelist내의 위치 index에 따라 선택할 수 있는 수단입니다.
우리는 0의 값에 대해서 Typelist의 head를, 그리고 0이 아닌 값에 대해 Typelist의 tail을 참조하도록
컴파일 타임에 결정해 주어야 합니다. 이 작업은 2장에서 다룬 Int2Type 템플릿을
통해 쉽게 구현될 수 있습니다.
예제 코드 보기
- template <class H, typename R>
- inline R& FieldHelper(H& obj, Type2Type<R>, Int2Type<0>)
- {
- typename H::LeftBase& subobj = obj;
- return subobj;
- }
-
- template <class H, typename R, int i>
- inline R& FieldHelper(H& obj, Type2Type<R> tt, Int2Type<i>)
- {
- typename H::RightBase& subobj = obj;
- return FieldHelper(subobj, tt, Int2Type<i - 1>());
- }
-
- template <int i, class H>
- typename Private::FieldTraits<H>::At<i>::Result&
- Field(H& obj)
- {
- typedef typename Private::FieldTraits<H>::At<i>::Result
- Result;
-
- return FieldHelper(obj, Type2Type<Result>(), Int2Type<i>());
- }
-
-
-
-
-
- WidgetInfo obj;
- ...
- int x = Field<0>(obj).value_;
- int y = Field<1>(obj).value_;
예제 코드 접기
-
때때로, 이름 없는 Field로 이루어진 작은 구조체를 생성해야 할 필요가 생길 수 있습니다. 이러한 구조체를 일부
언어에서는 Tuple이라고 부릅니다. 다음의 예제를 생각해 보도록 합시다.
예제 코드 보기
- template <class T>
- struct Holder
- {
- T value_;
- };
-
- typedef GenScatterHierarchy<
- TYPELIST_3(int, int, int),
- Holder>
- Point3D;
-
-
예제 코드 접기
Loki는 GenScatterHierarchy와 비슷한 방식으로 구현된, 그러나 직접적인 Field 접근을 제공하는
Tuple 템플릿 클래스를 정의하고 있습니다. Tuple은 다음과 같이 동작합니다.
Tuple 예제 코드 보기
- typedef Tuple<TYPELIST_3(int, int, int)>
- Point3D;
-
- Point3D pt;
- Field<0>(pt) = 0;
- Field<1>(pt) = 100;
- Field<2>(pt) = 300;
-
-
-
- Tuple<TYPELIST_3(int, int, int)> GetWindowPlacement(Window&);
-
Tuple 예제 코드 접기
Tuple.h 파일을 살펴보면, Loki가 제공하는 다른 Tuple 관련 함수들을 찾아볼 수 있을 것입니다.
-
이벤트 핸들러 인터페이스를 정의하는 다음과 같은 간단한 템플릿의 경우를 생각해 봅시다. 이것은 오직 OnEvent
멤버 함수만을 정의하고 있습니다.
우리는 GenScatterHierarchy를 이용하여 EventHandler를 Typelist 내의 각 자료형에 대해
적용시켜 줄 수 있습니다.
예제 코드 보기
- template <class T>
- class EventHandler
- {
- public:
- virtual void OnEvent(const T&, int eventId) = 0;
- virtual ~EventHandler(void) {}
- };
-
-
- typedef GenScatterHierarchy
- <
- TYPELIST_3(Window, Button, ScrollBar),
- EventHandler
- >
- WidgetEventHandler;
예제 코드 접기
GenScatterHierarchy의 단점은 다중 상속을 사용하고 있다는 점입니다. 만일 바이너리 사이즈나 메모리 사용량에
있어서 최적화가 필요하다면, GenScatterHierarchy를 사용하는 것은 부적절할 수 있습니다. 왜냐하면,
WidgetEventHandler는 EventHandler 객체가 한 번 생성될 때마다 가상 함수 테이블에 세 개씩의 포인터를
등록시켜야 하기 때문입니다. 그리고 이것은 Typelist에 자료형의 개수가 늘어날 때마다 함께 증가하게 될 것입니다.
가장 공간 효율적인 구성은 아마도 모든 가상 함수를 WidgetEventHandler 바로 안쪽에 선언하는 방법일 것입니다.
하지만, 그렇게 되면 자동으로 코드를 생성해 내는 기법은 사용할 수 없게 될 것입니다.
WidgetEventHandler를 하나의 가상 함수당 하나의 클래스로 쪼개는 적절한 구상법은 [그림 3.5]에서와 같이
선형 상속 구조를 사용하는 것입니다.
![[그림 3.5] WidgetEventHandler를 공간 효율적으로 최적화시킨 구조 [그림 3.5] WidgetEventHandler를 공간 효율적으로 최적화시킨 구조](./BS4/P/3.5.PNG)
[그림 3.5] WidgetEventHandler를 공간 효율적으로 최적화시킨 구조
이러한 계층 구조를 자동으로 생성해주는 메커니즘을 만들려면, GenScatterHierarchy와 비슷한 재귀적 템플릿
정의가 도움이 될 수 있습니다. 물론, 둘 사이에는 분명한 차이점이 존재합니다. 이제 사용자가 제공하는 Unit 클래스
템플릿은 두 개의 템플릿 인자를 받아야 합니다. 하나는 GenScatterHierarchy
에서와 같이 현재 선택된 자료형이고, 다른 하나는 이 템플릿이 어떤 기반 클래스를 상속받고 있는지를 나타내는 기반
클래스 자료형입니다. 두 번째 템플릿 인자가 필요한 이유는, 사용자가 정의한 코드가 이젠 클래스 계층 구조상의
중간에도 위치하게 되기 때문입니다.
이제 GenLinearHierarchy를 정의해 보도록 합시다.
GenLinearHierarchy 예제 코드 보기
- template
- <
- class TList,
- template <class AtomicType, class Base> class Unit,
- class Root = EmptyType
- >
- class GenLinearHierarchy;
-
- template
- <
- class T1,
- class T2,
- template <class, class> class Unit,
- class Root
- >
- class GenLinearHierarchy<Typelist<T1, T2>, Unit, Root>
- : public Unit<T1, GenLinearHierarchy<T2, Unit, Root> >
- {
- };
-
- template
- <
- class T,
- template <class, class> class Unit,
- class Root
- >
- class GenLinearHierarchy<TYPELIST_1(T), Unit, Root>
- : public Unit<T, Root>
- {
- };
-
-
-
- template <class T, class Base>
- class EventHandler : public Base
- {
- public:
- virtual void OnEvent(T& obj, int eventId);
- };
-
- typedef GenLinearHierarchy
- <
- TYPELIST_3(Window, Button, ScrollBar),
- EventHandler
- >
- MyEventHandler;
GenLinearHierarchy 예제 코드 접기
[그림 3.6] GenLinearHierarchy의 클래스 계층 구조 보기
GenLinearHierarchy는 자신의 템플릿 템플릿 인자인 Unit에게 다음과 같은 두 가지 사항을 요구하고 있습니다.
Unit(위 예제에서는 EventHandler)은 반드시 두 번째 템플릿 인자를 받아서 그것을 상속받아야만 합니다.
대신에 GenLinearHierarchy는 그에 대한 보상으로 클래스 계층 구조를 생성하는 힘든 작업을 대신해 주게 됩니다.
-
Typelist는 아주 중요한 제네릭 프로그래밍 테크닉입니다. 임의의 커다란 자료형의 모음을 표현하고, 다루는 방법, 그리고
그러한 자료형 목록으로부터 자료 구조를 생성해 내고, 코드를 만들어 내는 방법을 알게 해 주었으며, 그 밖의 셀 수 없을
만큼의 많은 가능성도 함께 가져다 주었습니다.
컴파일 타임에 있어서 Typelist는 일반적인 리스트 자료 구조가 가지는 것과 같은 다양한 기본 기능들을 제공합니다.
Add, erase, search, access, replace, erase duplicates 그리고 상속 관계에 의한 부분적인 정렬 등이
그것입니다. Typelist를 다루는 코드를 구현하는 방법은 기능형 언어의 스타일로 제한되어 있습니다. 왜냐하면 컴파일
타임에는 변수의 사용이 불가능하기 때문입니다. 이러한 이유로 인해, 대부분의 Typelist 연산은 템플릿의 재귀적 정의와
템플릿의 부분 특화를 통한 유형 선택을 이용하여 구현되어 있습니다.
Typelist는 동일한 코드를 특정 자료형의 집합에 반복하여 적용시켜야 할 경우에 매우 유용하게 쓰입니다.
Typelist는 다른 모든 제네릭 프로그래밍 테크닉에서는 적용되지 않는 골치 아픈 존재들을 추상화시키고 일반화시킬 수
있는 능력을 부여해 줍니다. 이러한 이유로 인해 9장과 10장에서
볼 수 있듯이, Typelist는 완전히 새로운 기법과 라이브러리 구현을 가능하게 하는 강력한 수단이 됩니다.
Loki는 Typelist로부터 클래스 계층 구조를 자동으로 생성해 내는 데 쓰이는 두 가지 강력한 기본 장치를 제공합니다.
그것은 바로 GenScatterHierarchy와 GenLinearHierarchy 입니다. 하나는 비선형(scattered, [그림
3.2] 참조)이고, 다른 하나는 선형(linear, [그림 3.6])입니다.
선형 클래스 계층 구조는 보다 공간 효율적인 구조입니다. 반면에, 비선형 클래스 계층 구조는 다음과 같은 유용한
속성을 가지고 있습니다. [그림 3.2]에서 보듯이, 그것은 사용자가 정의한
템플릿(GenScatterHierarchy의 인자로 전달되는)의 모든 인스턴스가 결과 클래스의 최상위 기반 클래스가 된다는 것입니다.
-
-
헤더 파일: Typelist.h
-
모든 Typelist 관련 도구들은 Loki::TL 네임스페이스 내에 존재합니다.
-
클래스 템플릿 Typelist<Head, Tail>이 정의되어 있습니다.
-
Typelist를 생성해주는 매크로가 TYPELIST_1에서부터 TYPELIST_50까지 정의되어 있습니다.
이것은 그 이름에 주어진 숫자만큼의 인자를 받도록 만들어져 있습니다.
-
TYPELIST_xx의 상한선(50)은 사용자에 의해 확장될 수 있습니다. 예를 들면, 다음과 같습니다.
TYPELIST_xx 확장 예제 코드 보기
-
기본 가정에 의하면, Typelist는 첫 번째 원소(head)로 항상 단순 자료형(Typelist가 아닌)을 가져야 하며,
tail은 오직 다른 Typelist나 NullType만이 가능합니다.
-
Typelist.h 헤더 파일은 Typelist에 관한 기본적인 연산들을 정의하고 있습니다. 가정에 따라, 모든 연산들은
그 결과를 Result라 불리는 내부적으로 다시 생성된 public한 사용자 정의 자료형으로 변환합니다. 만일 이러한
연산의 결과가 value라면, 그 이름도 value가 됩니다.
-
Typelist에 대한 기본적인 연산들은 [표 3.1]에 정의되어 있습니다.
-
클래스 템플릿 GenScatterHierarchy의 기본 정의는 다음과 같습니다.
GenScatterHierarchy의 기본 정의 예제 코드 보기
-
GenScatterHierarchy는 Typelist TList의 각 자료형에 대한 Unit 템플릿의 인스턴스를 만드는
계층 구조를 생성해 냅니다. GenScatterHierarchy 객체는 직접적으로 혹은 간접적으로 Unit<T>를
상속받고 있습니다(T는 모든 TList의 원소).
-
GenScatterHierarchy가 생성해 내는 계층 구조는 [그림 3.2]에 묘사되어 있습니다.
-
클래스 템플릿 GenLinearHierarchy의 기본 정의는 다음과 같습니다.
GenLinearHierarchy의 기본 정의 예제 코드 보기
-
GenLinearHierarchy는 [그림 3.6]에 보이는 바와 같이 선형 계층 구조를
생성해 냅니다.
-
GenLinearHierarchy는 Unit 템플릿에 대한 인스턴스를 만들어 줍니다. 그리고 이와 동시에 Typelist TList의
모든 자료형을 각각 Unit 템플릿의 첫 번째 템플릿 인자로 넘겨 주게 됩니다. 주의! Unit은 그 두 번째 템플릿
인자를 public으로 상속받아야 합니다.
-
Field 오버로드 함수는 계층 구조상의 각 노드에 대해 자료형에 의한 접근과 index에 의한 접근 모두를 제공합니다.
-
Field<Type>(obj)는 Type으로 주어진 자료형에 따라 생성된 Unit 인스턴스에 대한 참조 값을
반환합니다.
-
Field<index>(obj)는 주어진 Typelist에서 index가 가리키는 위치에 존재하는 자료형에 따라
생성된 Unit 인스턴스에 대한 참조 값을 반환합니다.
[표 3.1] Typelist에 작용하는 컴파일 타임 알고리즘 보기
-
C++에는 사용자 메모리 공간에서 사용될 수 있는 new와 delete 연산자를 제공하고 있습니다. 하지만, 이런 연산자들은
일반적인 용도로 만들어졌기 때문에, 크기가 작은 객체들에 대해서는 상당히 좋지 않은 성능을 보여줍니다.
많은 고급 테크닉들이 다량의 작은 객체들을 활용하고 있기 때문에, 작은 객체에 대한 동적 할당을 빠르게 할 수 있다면, 성능의
문제를 전혀 걱정할 필요 없이 고급 테크닉을 자유롭게 구사할 수 있을 것입니다.
-
C++가 제공하는 기본 메모리 할당기는 C의 메모리 관련 함수인 malloc, realloc및 free 등을 단순히 포장해 주도록
만들어져 있습니다. 이런 C 함수들은 메모리를 작은 덩어리로 나누어 할당해 주는 작업에 초점이 맞추어져 있지 않습니다.
C 프로그램은 그 대신 보통 수백 byte에서 수천 byte를 넘는 커다란 크기의 객체들을 사용합니다.
C++의 기본 할당기가 가지는 이러한 일반성은, 느린 속도 뿐만 아니라 메모리 공간의 낭비도 가져오게 됩니다.
기본 할당기는 메모리 pool을 관리해 주며, 이러한 작업을 위해 추가적인 메모리 공간을 필요로 합니다.
보통 new 연산자에 의해 할당된 메모리 블록은 자신의 표시를 위해 4byte에서 32byte 가량의 공간을 더
차지하게 됩니다. 큰 객체를 할당하는 경우에는 그 오버헤드를 무시해도 괜찮을 정도지만, 만일 8byte 정도의 작은
객체를 할당하게 되면, 그 오버헤드는 무시하지 못할 정도로 커집니다.
-
메모리 할당기는 byte들로 이루어진 메모리 pool을 관리해야 합니다. 또한, 그러한 pool에서 임의의 크기를 갖는
덩어리를 할당할 수 있어야 합니다. 메모리 관리 구조의 한 예로, 다음과 같은 간단한 제어 블록이 있을 수 있습니다.
struct MemControlBlock
{
std::size_t size_;
bool available_;
};
이 MemControlBlock에 의해 관리되는 메모리는 이 제어 블록의 바로 뒤에 위치하게 되며 그 크기는 size_ 바이트가 됩니다.
그리고 그 뒤에는 다시 또 다른 MemControlBlock이 뒤따르게 되며, 계속해서 이런 구조가 반복적으로 나열되게 됩니다.
프로그램이 시작할 때 메모리 pool은 단 하나의 MemControlBlock만이 존재하는 상태로 주어집니다. 이 제어 블록은
전체 메모리를 하나의 덩어리로 관리하게 됩니다. 이것이 바로 불변의 위치를 가지는 최상위 제어 블록입니다.
매번 메모리 할당 요청이 발생할 때마다 할당기는 선형적 검색을 통해 요구된 크기에 맞는 적절한 블록을 찾아내게 됩니다.
사용 가능한 메모리 블록 가운데서 주어진 요청에 가장 잘 맞는 블록을 찾아내는 전략에는 first fit, best fit,
worst fit, 그리고 random fit라 불리는 방법들이 있으며, 재미있게도 이 중 worst fit 전략이 best fit
전략보다 더 좋은 성능을 보입니다.
메모리를 해제해야 하는 경우에는 해제시킬 메모리 블록에 바로 선행하는 메모리 블록을 찾아, 그 블록의 크기를 수정해 주게
됩니다.
이러한 전략은 그다지 시간적으로 효율적이지 못하지만, 그 크기에 있어서의 오버헤드는 상대적으로 적은 편입니다.
실제로, 많은 경우에 size_의 값에서 하나의 bit를 차용하여 그것을 available_ 의미로 사용할 수 있습니다.
// 이것은 플랫폼과 컴파일러에
// 독립적이지 못한 코드로,
// 컴파일이 되지 않을 수도 있습니다.
struct MemControlBlock
{
std::size_t size_ : 31;
bool available_ : 1;
};
만일 각 MemControlBlock마다 바로 이전 블록과 다음 블록에 대한 포인터를 저장하도록 한다면, 메모리의 해제 작업은
상수 시간 안에 수행될 수 있습니다.
struct MemControlBlock
{
bool available_;
MemControlBlock* prev_;
MemControlBlock* next_;
};
this->next_를 통해 그 크기를 쉽게 계산해 낼 수 있기 때문에, size_ 멤버변수는 필요하지 않습니다.
하지만 여전히, 각 메모리 블록당 두 개의 포인터와 하나의 bool 값을 저장해야 한다는 오버헤드는 남게
됩니다(특정 시스템에서는 두 포인터 중 한쪽으로부터 bool 값을 대신할 하나의 bit를 차용하는 것이 가능합니다).
하지만 여전히 메모리를 할당하는 작업은 선형 시간을 필요로 합니다. 사실 완벽한 메모리 할당 전략은 존재할 수가 없습니다.
어떤 경우에 잘 맞는 메모리 할당 전략이 있다 하더라도, 거기에 맞지 않는 유형으로 메모리를 사용하게 되면 다른 전략보다
좋지 않은 성능을 낼 수 있는 것입니다.
우리는 C++가 제공하는 일반적인 메모리 할당기를 최적화하는 것이 아니라, 작은 객체를 가장 잘 다룰 수 있도록 특별히
디자인된 메모리 할당기에 초점을 맞춰보도록 합시다.
-
이번 장에서 다루고 있는 작은 객체에 대한 메모리 할당기는 [그림 4.3]에서 보이는 것처럼 4계층의 구조로 구성되어 있습니다.
상위의 계층은 자신의 하위 계층에서 제공하고 있는 기능들을 이용하게 됩니다.
[그림 4.3] 계층적 구조로 이루어진 작은 크기의 객체에 대한 메모리 할당기 보기
-
Chunk 형의 객체는 메모리의 뭉치를 포함하고, 이를 관리해 주며, 각 Chunk들은 고정된 양의 메모리 블록을 가지게 됩니다.
이러한 Chunk를 생성시킬 때에는, 그 블록의 크기와 개수를 결정해 주어야 합니다.
Chunk는 자신이 가진 메모리의 뭉치로부터 필요한 메모리 블록을 할당하고 해제하기 위한 로직을 갖추고 있습니다.
하지만 Chunk 안에 더 이상 사용 가능한 블록이 없는 경우에는 할당 작업은 실패하게 되고, 그 함수는 0의 값을
반환하게 됩니다.
Chunk의 정의는 다음과 같습니다.
Chunk의 정의 예제 코드 보기
- struct Chunk
- {
- void Init(std::size_t blockSize, unsigned char blocks);
- void* Allocate(std::size_t blockSize);
- void Deallocate(void* p, std::size_t blockSize);
- unsigned char* pData_;
- unsigned char
- firstAvailableBlock_,
- blockAvailable_;
- };
Chunk의 정의 예제 코드 접기
할당된 메모리를 가리키는 포인터와 더불어, Chunk는 다음의 두 정수 값을 저장하게 됩니다.
-
firstAvailableBlock_: 이것은 이 Chunk에서 아직 할당되지 않은 첫 번째
블록의 index를 가리킵니다.
-
blockAvailable_: 이 Chunk에서 아직 할당되지 않은 블록의 전체 개수를
가리킵니다.
Chunk의 인터페이스는 아주 단순합니다. Init은 Chunk 객체를 초기화하고, Allocate/Deallocate가 각각 블록을
할당/해제해 줍니다. Chunk는 할당된 블록의 크기 정보를 저장하지 않기 때문에 할당/해제 함수를 호출할 때에 반드시 인자로
블록의 크기를 넘겨 주어야 합니다. 만일 Chunk가 불필요하게 blockSize_ 멤버를 가지게 된다면, 그것은 공간과 시간을
모두 낭비하는 일이 될 것입니다. 또한, 이와 마찬가지로 그 효율성을 고려하여, Chunk는 어떠한 생성자나, 소멸자, 또는
대입 연산자 등도 정의하고 있지 않습니다. Chunk의 상위 구조에서는 Chunk를 vector 자료 구조를 통해 다루게 되므로
이러한 효율성의 문제는 중요한 이슈가 될 수 있는 것입니다.
blockAvailable_과 firstAvailableBlock_이 unsigned char로 표현되는 정수이기 때문에, 그 결과로
Chunk가 가질 수 있는 블록의 최대 숫자는 최대 255개로 제한되게 됩니다. 왜 블록의 개수를 255개로 제한하는 것일까요?
우리가 unsigned char 대신 unsigned short 같은 자료형을 사용하는 경우를 가정해 봅시다. 그러면 우리는
다음의 두 가지 문제점에 부딪히게 됩니다.
-
우리는 sizeof(unsigned short)보다 크기가 작은 블록을 할당할 수가 없게 됩니다. 우리는 지금
작은 크기의 객체에 대한 할당기를 만들고 있는 것이므로, 이것은
사소하긴 하지만 곤란한 문제가 될 수 있습니다.
-
우리는 메모리 정렬 문제에 대해서 생각해 보아야 합니다. 5byte 크기의 블록에 대한 할당기를 구성하는 경우를
생각해 봅시다. 이 경우에, 이 블록을 가리키는 포인터를 unsigned int로 형변환하는 작업은 예기치 못한
동작을 초래하게 됩니다. 이것은 상당히 심각한 문제가 될 것입니다.
여기에 대한 해답은 '숨겨진 index'를 위한 자료형으로 unsigned char를 사용하기만 하면 됩니다. 정의에 의하면 char
자료형은 1byte의 크기를 가지고 있으며, 메모리에 대한 가장 근본적인 참조 포인터 역시 unsigned char 자료형을
가리키고 있으므로 메모리 정렬에 관련한 어떤 문제도 발생하지 않게 됩니다.
Chunk 내의 블록은 사용되는 상태이거나, 그렇지 않은 상태일 수 있습니다. 이 때 사용되지 않는 블록에는 우리가 원하는
그 어떤 정보라도 담을 수가 있습니다. 우리는 이러한 이점을 이용해 보는 것이 좋을 것 같습니다. 우선, 할당되지 않은
블록의 첫째 바이트가 그 뒤의 할당되지 않은 블록의 index를 저장하도록 하는 것은 어떨까요? 우리는 이미 firstAvailableBlock_
멤버에 할당되지 않은 첫 번째 블록의 index를 가지고 있기 때문에, 결국 추가 메모리를 사용하지 않고도 할당되지 않은
블록에 대한 단일 연결 리스트 자료 구조를 가지게 된 것입니다.
이런 방식으로 단일 연결 구조의 리스트를 Chunk의 데이터 구조 속에 융화시키는 것은, 할당/해제를 할 때 추가적인 메모리
공간이 필요하지 않고 상수시간만을 소요하는 효율적인 결과를 얻을 수 있게 해줍니다.
Chunk 예제 코드 보기
-
- void Chunk::Init(std::size_t blockSize, unsigned char blocks)
- {
- pData_ = new unsigned char[blockSize * blocks];
- firstAvailableBlock_ = 0;
- blockAvailable_ = blocks;
- unsigned char i = 0;
- unsigned char* p = pData_;
-
- for (; i != blocks; p += blockSize)
- {
- *p = ++i;
- }
- }
-
-
-
- void* Chunk::Allocate(std::size_t blockSize)
- {
- if (!blockAvailable_) return 0;
- unsigned char* pResult =
- pData_ + (firstAvailableBlock_ * blockSize);
-
-
- firstAvailableBlock_ = *pResult;
- --blockAvailable_;
- return pResult;
- }
-
-
-
-
- void Chunk::Deallocate(void* p, std::size_t blockSize)
- {
- assert(p >= pData_);
- unsigned char* toRelease = static_cast<unsigned char*>(p);
-
-
- assert((toRelease - pData_) % blockSize == 0);
- *toRelease = firstAvailableBlock_;
- firstAvailableBlock_ = static_cast<unsigned char>(
- (toRelease - pData_) / blockSize);
-
-
- assert(firstAvailableBlock_ ==
- (toRelease - pData_) / blockSize);
-
- ++blockAvailable_;
- }
-
-
Chunk 예제 코드 접기
-
FixedAllocator는 고정된 크기의 블록을 어떻게 할당하고, 또 어떻게 해제할지를 담당하게 됩니다. 하지만 Chunk와의
차이점은 이것이 Chunk의 크기에 제한을 받지 않는다는 것입니다. 이러한 목표를 달성하기 위해, FixedAllocator는
Chunk 객체들의 vector로 이루어져 있습니다. 메모리 할당의 요청이 발생할 때마다 FixedAllocator는 해당 요청을
수용할 수 있는 Chunk를 찾게 됩니다. 만일, 모든 Chunk가 꽉 차 있다면, FixedAllocator는 새로운 Chunk를
만들어 vector에 추가시키게 됩니다.
예제 코드 보기
- class FixedAllocator
- {
- ...
-
- private:
- std::size_t blockSize_;
- unsigned char numBlocks_;
-
- typedef std::vector<Chunk> Chunks;
-
- Chunks chunks_;
- Chunk* allocChunk_;
- Chunk* deallocChunk_;
- };
예제 코드 접기
필요한 할당 공간을 찾을 때, FixedAllocator는 보다 빠른 검색을 위해 chunks_ 전체를 뒤지지는 않습니다.
대신에, 할당에 사용된 마지막 Chunk에 대한 포인터를 allocChunk_에 기억하게 됩니다. 메모리 할당 요구가 발생하게 되면,
FixedAllocator::Allocate는 먼저 allocChunk_에 여유 공간이 있는지를 검사하게 됩니다. 만일 allocChunk_에
그러한 공간이 남아있다면, 이 메모리 할당 요청은 즉시 만족될 수 있을 것입니다. 하지만 그렇지 않은 경우에는, 순차적 검색이
필요하게 됩니다. 공간이 남아있는 Chunk가 하나도 없다면 새로운 Chunk 객체가 chunks_ vector에 덧붙여지게 됩니다.
어떠한 경우라도, allocChunk_는 발견된, 혹은 새로 추가된 Chunk 객체를 가리키도록 수정되어야 합니다.
FixedAllocator::Allocate 멤버 함수 예제 코드 보기
- void* FixedAllocator::Allocate(void)
- {
- if (allocChunk_ == 0 ||
- allocChunk_->blockAvailable_ == 0)
- {
-
-
- Chunks::iterator i = chunks_.begin();
- for (;; ++i)
- {
- if (i == chunks_.end())
- {
-
- chunks_.reserve(chunks_.size() + 1);
- Chunk newChunk;
- newChunk.Init(blockSize_, numBlocks_);
- chunks_.push_back(newChunk);
- allocChunk_ = &chunks_.back();
- deallocChunk_ = &chunks_.back();
- break;
- }
- if (i->blockAvailable_ > 0)
- {
-
- allocChunk_ = &*i;
- break;
- }
- }
- }
- assert(allocChunk_ != 0);
- assert(allocChunk_->blockAvailable_ > 0);
-
- return allocChunk_->Allocate(blockSize_);
- }
-
FixedAllocator::Allocate 멤버 함수 예제 코드 접기
이러한 전략을 사용하여, FixedAllocator는 대부분의 할당 요청을 상수 시간 내에 수행하게 되며, 가끔씩 새로운 블록을 찾아내거나
추가하는 경우가 생길 때에만 조금 느린 동작을 보일 것입니다. 메모리 할당과 해제가 특정한 최악의 유형으로 반복될 경우, 이러한
방식이 오히려 비효율적이 될 수도 있지만, 실제 상황에서 그런 유형의 메모리 할당과 해제는 좀처럼 나타나지 않을 것입니다.
그 어떠한 메모리 할당기도 자신만의 아킬레스건을 가지고 있다는 사실을 기억해주시기 바랍니다.
메모리를 해제하는 과정에는 좀 더 문제가 있을 것처럼 보입니다. 우리가 알고 있는 것은 오직 해제할 메모리에 대한 포인터뿐이며,
이 포인터가 가리키는 객체가 어떤 Chunk에 포함되어 있는 지는 알 수가 없는 상태입니다. 이를 해결하기 위해, 우리는 chunks_를
뒤져서 주어진 포인터가 해당 Chunk의 소속인지를 검사해 볼 필요가 있을 것입니다. 하지만 이런 작업은 많은 시간을 필요로 합니다.
우리는 해제될 블록에 보조적인 캐시 메모리를 사용할 수 있을 것입니다. 사용자의 코드가
메모리 블록을 해제하게 되면 그 블록을 캐시에 담아뒀다가, 할당 요청이 왔을 때 캐시에 담긴 블록을 반환하는 것입니다.
캐시가 비어 있을 때에만 그 할당 요청을 Chunk에게 전달하게 됩니다. 이것은 매우 유망한 전략이 될 수 있겠지만, 작은 객체를
사용하면서 자주 발생할 수 있는 특정한 할당 및 해제 유형에 대해서 좋지 않은 성능을 보일 수 있습니다.
작은 크기의 객체의 할당 방식에 대한 유형은 크게 다음과 같이 4가지로 정리해 볼 수 있습니다.
-
일괄 할당 및 해제: 다수의 작은 객체들이 동시에 할당되는 경우입니다. 예를 들어,
이것은 작은 객체들에 대한 포인터를 집합적으로 초기화하는 경우에 발생합니다.
-
할당 순서와 동일한 순서로 발생하는 해제 작업: 다수의 작은 객체들이 그것이 할당된
순서와 같은 순서로 해제되어야 하는 경우입니다. 이것은 대부분의 STL 컨테이너 객체가 소멸될 때 발생합니다.
-
할당 순서와 역순으로 발생하는 해제 작업: 다수의 작은 객체들이, 그것이 할당된
순서와 반대되는 순서로 해제되어야 하는 경우입니다. 이러한 유형은 C++ 프로그램에서 작은 객체들을 다루는 함수를
호출하는 경우에 자연적으로 발생하게 됩니다.
-
불규칙 할당과 해제: 각 객체들이 어떠한 순서를 따르지 않고 생성되고, 또 파괴되는
경우 입니다. 이것은 프로그램이 동작하면서 작은 크기의 객체들을 우발적으로 필요로 할 때 발생하게 됩니다.
앞서 말한 캐시를 이용하는 전략은 '불규칙 할당과 해제'의 유형과 매우 잘 어울리는 전략입니다. 하지만, 대량 할당과
해제 작업에 대해서 캐시는 전혀 도움이 되지 못합니다. 오히려 캐시 자신의 메모리를 지우는 데 그 나름의 시간을 소모하기
때문에, 메모리 해제 작업을 더 느리게 만들수도 있습니다.
보다 나은 전략은 메모리 할당에 사용된 것과 동일한 아이디어를 사용해 보는 것입니다. FixedAllocator::deallocChunk_는
메모리 해제에 마지막으로 사용된 Chunk 객체를 가리키게 됩니다. 메모리 해제 요청이 발생하면, deallocChunk_가 가장
먼저 검사됩니다. 그리고 주어진 포인터가 해당되는 Chunk가 아닐 경우, 선형 탐색을 수행하고, 요청을 만족시킨 다음
deallocChunk_의 값을 새로운 포인터로 수정하게 됩니다.
위에 나열된 메모리 할당의 유형들에게, 메모리 해제를 더 효율적으로 개선하기 위한 두 가지의 수정 사항이 있을 수 있습니다.
-
첫 번째 수정 사항은 Deallocate 함수가 선형 탐색을 수행할 때에 deallocChunk_ 멤버 근처에서부터 적당한
Chunk를 검색하도록 만드는 것입니다. 즉, chunks_에 대한 탐색을 deallocChunk_로부터 시작해서 두 개의
반복자를 가지고 그 위쪽과 아래쪽을 양방향으로 검색하도록 구현한다는 뜻입니다. 일괄 할당의 경우에, Allocate
함수는 Chunk를 순서대로 더해주게 되므로, 이에 대한 일괄적인 메모리 해제가 발생하면, deallocChunk_는
대부분의 경우에 해당 Chunk를 바로 발견해 내며, 그렇지 못할 경우에도 바로 다음 검색 작업에서 해당되는 Chunk를
발견할 수 있을 것입니다.
-
두 번째 수정 사항은 할당 용량의 경계점에서 발생할 수 있는 부하를 회피하자는 것입니다. 메모리를 해제할 때에는,
두 개의 빈 Chunk가 생겼을 때에만 Chunk를 해제하도록 하는 것이 좋습니다.
만일 비어 있는 Chunk가 한 개뿐일 때에는, 그것을 chunks_ vector의 제일 뒤로 옮기는 것이 효과적일 것입니다.
이렇게 하면, vector<Chunk>::erase에 대한 연산이 항상 마지막 원소에 대해 작용하도록 만들어지므로,
부하를 줄이는 데 큰 효과를 보게 됩니다.
다음 예제를 보세요.
예제 코드 보기
-
-
- for (...)
- {
-
- SmartPtr p;
- ... use p ...
- }
-
-
-
예제 코드 접기
물론, 특정 상황에서는 이러한 개선 사항이 전혀 의미가 없을 수도 있습니다. 예를 들어, SmartPrt에 대한 적당한
크기의 vector를 반복문 내에서 할당하게 될 경우에는 역시 같은 문제가 발생할 소지가 있습니다. 하지만, 이런 상황은
그다지 일어나기 힘든 상황입니다.
여기서 선택된 메모리 해제 전략은 불규칙한 할당의 경우에도 적절히 대응이 가능한 전략입니다. 심지어 어떤 규칙이 없이 데이터를
할당하는 경우에도, 프로그램은 어떤 지역성을 가지게 됩니다. allocChunk_와
deallocChunk_ 포인터는 마치 캐시처럼 작용하여 이러한 상황을 효과적으로 다룰 수 있게 해줍니다.
-
SmallObjAllocator는 몇 개의 FixedAllocator 객체를 집합적으로 사용함으로써 객체의 크기에 상관없이 자유롭게
할당과 해제를 수행할 수 있습니다. SmallObjAllocator가 메모리 할당 요청을 받게 되면, 그 요청은 해당되는
FixedAllocator 객체로 전달되거나, 또는 기본 할당 연산자인 ::operator new에 전달됩니다.
SmallObjAllocator 예제 코드 보기
- class SmallObjAllocator
- {
- public:
- SmallObjAllocator(
- std::size_t chunkSize,
- std::size_t maxObjectSize);
-
- void* Allocator(std::size_t numBytes);
- void Deallocator(void* p, std::size_t size);
- ...
-
- private:
- std::vector<FixedAllocator> pool_;
- ...
- };
-
-
-
SmallObjAllocator 예제 코드 접기
조금 이상하게 보이겠지만, Deallocate 함수는 그 인자로 해제할 메모리의 크기를 받도록 되어 있습니다. 이렇게 함으로써
메모리 해제 연산은 매우 빠르게 동작할 수 있을 것입니다. 그렇지 않으면, SmallObjAllocator::Deallocate는 주어진
포인터가 어디에 속하는지를 찾기 위해, pool_에 속한 모든 FixedAllocator를 검색해야 할 것입니다. 다음 섹션에서
보게 되겠지만 이런 인자는 컴파일러 자체의 특징을 이용하여 우아하게 처리될 수 있습니다.
블록의 크기가 주어졌을 때 어떤 FixedAllocator가 그 크기의 블록을 메모리 할당과 해제를 담당하는지 어떻게 알아낼 수 있을까요?
간단하고 효과적인 아이디어는 pool_[i]가 i 크기의 객체를 다루는 FixedAllocator 객체를 담도록 하는 것입니다.
하지만 이 방법은 사용되지 않는 크기의 FixedAllocator를 많이 만들어낼 수 있습니다. 예를 들어 4byte와 64byte의 크기를
갖는 객체들만을 생성해야 한다고 생각해 봅시다. 이러한 경우에, pool_에서 실제로 사용되는 것은 pool_[4]와 pool_[64]뿐이지만,
pool_에는 64개의 FixedAllocator가 필요하게 됩니다.
메모리 정렬과 이에 따른 추가 바이트에 대한 문제가 pool_의 공간을 낭비하는 결과를 불러올 수도 있습니다. 예를 들어, 많은
컴파일러들이 모든 사용자 정의 자료형을 특정 수(2나 4등)의 배수 크기가 되도록 추가 바이트를 붙여 주고 있습니다.
만일 컴파일러가 모든 구조체를 4의 배수 크기로 가지도록 만든다면, 최악의 경우 pool_의 25%만을 사용하고, 나머지는 낭비되는
경우가 생길 수 있습니다.
따라서 메모리의 절약을 위해 약간의 검색 속도를 희생하는 것이 보다 나은 접근법이 될 수 있습니다. 최소한 한 번이라도 요청된
크기에 대한 FixedAllocator만을 생성하고, pool_이 가지는 FixedAllocator들은 블록 크기에 의해 정렬된 상태를
유지하도록 하는 것이 좋을 것입니다.
또한, 검색 속도를 높이기 위해서 FixedAllocator에서 사용된 것과 동일한 전략을 사용해 볼 수 있습니다. 즉,
SmallObjAllocator가 마지막으로 사용된 FixedAllocator에 대한 포인터와 FixedDeallocator에 대한 포인터를
기억하도록 만들자는 것입니다. 다음의 코드에 SmallObjAllocator가 가지는 멤버변수가 모두 나열되어 있습니다.
SmallObjAllocator 예제 코드 보기
- class SmallObjAllocator
- {
- ...
-
- private:
- std::vector<FixedAllocator> pool_;
- FixedAllocator* pLastAlloc_;
- FixedAllocator* pLastDalloc_;
- };
-
-
-
SmallObjAllocator 예제 코드 접기
-
SmallObject는 new와 delete 연산자를 오버로드하고 있습니다. 이렇게 하여, SmallObject를 상속받은 객체를
생성할 때마다, 우리가 정의한 오버로드 함수가 호출되어 FixedAllocator 객체로 그 요청을 넘겨주도록 할 수 있습니다.
SmallObject 클래스는 흥미롭게도 아주 간단하게 정의됩니다.
SmallObject 예제 코드 보기
- class SmallObject
- {
- public:
- static void* operator new(std::size_t size);
- static void operator delete(void* p, std::size_t size);
- virtual ~SmallObject(void) {};
- };
SmallObject 예제 코드 접기
위의 예제에서 한 가지 이상한 점을 찾을 수 있습니다. 많은 C++ 서적이 클래스에서 delete 기본 연산자를 오버로드하고자 한다면,
그것은 반드시 void에 대한 포인터만을 그 유일한 인자로 받아야 한다고 설명하고 있습니다.
C++ 표준에 의하면, delete 연산자를 오버로드할 때에는 다음의 두 가지 방법이 가능합니다.
void operator delete(void* p);
또는,
void operator delete(void* p,
std::size_t size);
만일 첫 번째 형태를 사용하게 되면, 메모리 해제에 있어서 블록의 크기를 알 수 없게 됩니다. 하지만, 불행하게도 우리는
SmallObjAllocator에 넘겨주기 위한 블록의 크기를 꼭 알아야만 합니다. 따라서, SmallObject는 두 번째
형태를 사용해야 합니다.
컴파일러가 어떻게 이 두 번째 인자에 자동으로 객체 크기를 제공해 줄 수 있는 것일까요? 언뜻 생각하기에는 컴파일러가
각 객체마다 일정량의 메모리 공간의 오버헤드를 더해주게 될 것 같기도 합니다.
하지만, 실제로는 오버헤드가 전혀 없습니다. 다음의 코드를 생각해 보도록 합시다.
class Base
{
int a_[100];
public:
virtual ~Base(void) {}
};
class Derived : public Base
{
int b_[200];
public:
virtual ~Derived(void) {}
};
...
Base* p = new Derived;
delete p;
기반 클래스 Base와 그를 상속받은 Derived 클래스는 서로 다른 크기를 가지고 있습니다. p가 가리키는 객체의 실제 크기를
저장하는 오버헤드를 방지하기 위해서, 컴파일러는 그 크기를 곧바로 계산해 내는 코드를 만들어 냅니다. 이러한 계산을 가능하게
하는 데 다음에 제시된 바와 같이 4가지의 기법이 사용될 수 있습니다(때때로 컴파일러 개발자의 입장에서 생각해 보는 일은
상당히 재미있는 일입니다. 일반적인 프로그래머들에게는 불가능한 작은 기적들이 갑자기 가능해지곤 하지요).
-
"객체를 파괴시킨 후에, delete 연산자를 부를지 말지를" 가리키는 Boolean 값을 소멸자에게 넘겨주도록 합니다.
Base의 소멸자는 가상 함수입니다. 따라서, 예제의 경우에, delete p는 올바른 객체 Derived에 접근하게 됩니다.
바로 이 때에 해당 객체의 크기는 sizeof(Derived)라는 상수 값으로 곧바로 파악이 가능합니다. 그리고
컴파일러는 단순히 이 상수값을 delete 연산자에게 넘겨주게 됩니다.
-
소멸자가 해당 객체의 크기를 반환하게 합니다. 여러분은 객체가 파괴될 때에, 각 소멸자가 sizeof(Class)를
반환하도록 수정해 줄 수 있습니다. 역시, 이러한 가정은 잘 동작할 수 있을 것입니다. 왜냐하면 기반 클래스
Base의 소멸자가 가상으로 선언되어 있기 때문입니다. 올바른 소멸자를 호출한 후에, 그 소멸자의 결과 값을 받아,
그 값을 delete 연산자에게 넘겨주게 됩니다.
-
객체의 크기를 얻어주는 숨겨진 가상 멤버 함수 _Size()를 구현하도록 합니다. 이러한 경우에는, 실시간에 이 함수를
호출하여 그 결과를 저장한 후, 해당 객체를 소멸시키고 나서 저장된 값을 이용하여 delete 연산자를 호출하게
됩니다. 이런 식의 구현은 다소 비효율적으로 보일 수 있으나, 컴파일러가 _Size()를 다방면의 목적으로도 사용할
수 있기 때문에 오히려 장점이 될 수도 있습니다.
-
객체의 크기를 각 클래스의 vtable(가상 함수 테이블) 어딘가에 직접적으로 저장하도록 합니다. 이러한 해법은
유연하고도 효율적입니다. 하지만 대신 구현하기가 조금 까다롭지요.
여기서 볼 수 있듯이, 컴파일러는 delete 연산자에게 해당 객체에 적합한 크기를 알려주기 위해서 다양한 노력을 기울이고 있습니다.
SmallObjAllocator는 해제시킬 블록의 크기를 필요로 합니다. 그리고 컴파일러가 이것을 제공하고, SmallObject는 이것을
받아서 FixedAllocator에게 그대로 전달해 주기만 하면 됩니다.
여기서 열거된 대부분의 해법들은 Base에 대한 소멸자가 가상으로 선언되었다고 가정하고 있습니다. 이것은 다형성 클래스들이
가상으로 선언된 소멸자를 가지는 것이 얼마나 중요한 일인지를 잘 설명해 주고 있습니다. 만약 이것이 가상으로 선언되어 있지
않다면, 실제로는 상속된 클래스의 객체를 가리키고 있는 기반 클래스에 대한 포인터 자료형의 경우에, 그에 대한 delete
연산이 예측할 수 없는 동작을 행하게 될 것입니다.
그렇기 때문에, SmallObject는 자신의 소멸자를 가상으로 선언하고 있습니다.
우리는 전체 애플리케이션에 대한 유일한 SmallObject를 필요로 합니다. Loki는 이러한 문제를
6장에서 다루게 될 SingletonHolder 템플릿을 통해 해결하고 있습니다.
만일 X라는 클래스가 있을 경우, 이것을 Singleton<X>를 통해 그 인스턴스를 생성할 수 있습니다.
그리고 그 클래스에 대한 유일한 객체에 접근하려면, Singleton<X>::Instance()를 호출하면 됩니다.
SingletonHolder를 사용하면 SmallObject는 지극히 간단하게 구현될 수 있습니다.
SmallObject 예제 코드 보기
- typedef Singleton<SmallObjAllocator> MyAlloc;
-
- void* SmallObject::operator new(std::size_t size)
- {
- return MyAlloc::Instance().Allocate(size);
- }
-
- void SmallObject::operator delete(void* p, std::size_t size)
- {
- MyAlloc::Instance().Deallocate(p, size);
- }
SmallObject 예제 코드 접기
-
SmallObject의 구현은 상당히 간단합니다. 하지만, 다중 스레딩을 지원하기 위해서는 그렇게 간단하게만 처리해서는 좀 곤란할 것입니다.
appendix에서 논의하고 있는 바에 따르면, 이런 경우에 우리는 별도로 판단을 내려야만 합니다. 우리는
각 계층의 구현으로 되돌아가서 서로 간섭하는 연산을 찾아내어, 적절하게 잠금 장치를 걸어야 하는 것입니다.
어쨌든, 다중 스레딩이 작업을 다소 복잡하게 만든다는 것은 사실이겠지만, Loki가 가지고 있는 고수준의 객체 동기화 메커니즘을
활용하면 그렇게까지 복잡한 작업은 아닙니다. 우선 Loki의 Threads.h를 include시키기로 합시다. 그리고 SmallObject에
다음의 수정을 가합시다.
수정된 SmallObject 예제 코드 보기
-
- template <template <class T> class ThreadingMode>
- class SmallObject : public ThreadingMode<SmallObject>
- {
- ... 기존 코드와 같음 ...
- };
-
-
- template <template <class T> class ThreadingMode>
- void* SmallObject<ThreadingMode>::operator new(std::size_t size)
- {
- Lock lock;
- return MyAlloc::instance().Allocate(size);
- }
-
- template <template <class T> class ThreadingMode>
- void SmallObject<ThreadingMode>::operator delete(void* p, std::size_t size)
- {
- Lock lock;
- MyAlloc::instance().Deallocate(p, size);
- }
-
수정된 SmallObject 예제 코드 접기
-
이번 섹션은 SmallObj.h 파일을 애플리케이션에서 사용하는 방법에 대해 논의하고 있습니다.
SmallObject를 사용하기 위해서는 SmallObjAllocator의 생성자에게 필요한 Chunk의 크기와 작은 객체로 분류될
수 있는 최대 크기를 각각 인자를 통해 정해주어야 합니다. 작은 객체라는 것을 어떻게 정의해야 할까요?
우리는 기본 할당기가 초래하는 공간과 시간의 비효율성을 줄이고자 했었습니다. 가장 일반화된 할당기에 있어서,
각 객체마다 생기는 오버헤드는 일반적인 데스크탑 컴퓨터의 경우 약 4byte에서 32byte정도가 됩니다.
객체당 16byte의 오버헤드를 가지는 경우를 가정하면, 64byte의 객체는 메모리의 25%를 낭비하게 됩니다.
따라서 64byte 객체는 작다고 간주할 수 있습니다.
반면에, SmallObjAllocator가 지나치게 큰 객체를 다루게 된다면, 필요한 양보다 훨씬 많은 양의 메모리를 할당하게
됩니다(FixedAllocator는 작은 객체가 해제된 후에도 최소한 하나의 Chunk를 유지하려 한다는 사실을 기억해 주시기
바랍니다).
Loki는 사용자가 직접 적절한 기본 값을 제공하도록 선택의 기회를 부여하고 있습니다. SmallObj.h 파일은
[표 4.1]에 표시된 바와 같이 세 개의 전처리 심볼을 사용하고 있습니다. 하나의 프로젝트 안에서는 모두 같은
전처리 심볼을 정의하여 사용하여야 합니다. 이렇게 하지 않을 경우, 다른 크기를 갖는 필요 이상의 FixedAllocator를
만들어 버리게 될 것입니다.
각각의 기본 값들은 적당한 양의 물리 메모리를 가지고 있는 일반적인 데스크탑 컴퓨터를 기준으로 정해져 있습니다.
만일 MAX_SMALL_OBJECT_SIZE나 DEFAULT_CHUNK_SIZE를 0 값으로 #define하게 될 경우, SmallObj.h는
조건부 컴파일을 통해 아무 오버헤드 없이 new와 delete의 기본 연산자를 사용하도록 코드를 생성해 낼 것입니다.
SmallObject 클래스 템플릿은 보통 하나의 템플릿 인자를 가집니다. 하지만 서로 다른 Chunk 크기와 객체의 크기를
지원하기 위해서, 두 개의 추가적인 템플릿 인자를 가질 수 있습니다.
Loki의 SmallObject 예제 코드 보기
- template
- <
- template <class T>
- class ThreadingModel = DEFAULT_THREADING,
- std::size_t chunkSize = DEFAULT_CHUNK_SIZE,
- std::size_t maxSmallObjectSize = MAX_SMALL_OBJECT_SIZE
- >
- class SmallObject;
Loki의 SmallObject 예제 코드 접기
[표 4.1] SmallObj.h에서 사용하는 전처리기 심볼 보기
-
어떤 C++ 기법들은 자유로운 저장 공간에 다수의 작은 객체들을 할당하여 사용하게 됩니다. 그 이유는 C++에서
다형성이라는 개념이 동적 메모리 할당과 그에 대한 포인터/참조 구문을 통해 표현되기 때문입니다. 하지만,
언어가 제공하는 기본 할당기는 보통 작은 객체보다는 크기가 큰 객체에 맞추어 최적화되어 있습니다. 즉, 작은 객체들을
할당하기에는 속도가 느리며, 메모리의 공간적 오버헤드 또한 무시할 수 없는 수준입니다.
이에 대한 해결책은 작은 객체를 위해 고안된 별도의 할당기를 사용하는 것입니다. 이 할당기는 큰 덩어리의 메모리 뭉치를
사용하며 공간적, 시간적 낭비를 줄이기 위한 교묘한 기법들로 이것을 다루게 됩니다. C++의 실시간 코드가 자동으로
해제될 블록의 크기를 제공하여 이 할당기의 동작에 도움을 주게 됩니다.
Loki가 제공하는 이 작은 객체에 대한 할당기가 가장 빠른 속도로 동작한다고 말할수는 없습니다. 메모리 정렬과 같은 문제는
매우 신중하게 다루어져야 합니다. 신중하다는 것은 최적화된 성능보다는 조금 낮은 성능을 보일 수밖에 없습니다.
어쨌든, Loki는 비교적 빠르며, 간단하고, 또한 튼튼한, 그러면서도 자유로운 이식성이라는 장점을 가진 멋진 할당기를
제공한다 할 수 있을 것입니다.
-
-
Loki가 제공하는 할당기는 4개의 계층 구조를 가지고 있습니다. 첫 번째 계층은 Chunk라는 자료형으로
구성됩니다. 이것은 동일한 크기의 블록으로 구성된 메모리 뭉치를 구성합니다. 두 번째 계층은 FixedAllocator입니다.
이것은 다수의 Chunk를 vector로 다루게 되며, 여유 공간의 크기를 넘어서는 메모리 할당 요청도 만족시켜 줄 수
있는 능력을 가지고 있습니다. 세 번째 계층은 SmallObjAllocator이며, 이것은 서로 다른 크기의 객체에 대한
할당 기능을 제공해 주기 위해 다수의 FixedAllocator 객체를 사용하게 됩니다. 작은 객체들은 FixedAllocator를
통해 할당되며, 커다란 객체들에 대한 할당 요청은 ::operator new 연산자에게 곧바로 전달되게 됩니다.
마지막으로, 네 번째 계층인 SmallObject는 SmallObjAllocator 객체를 감싸는 포장 클래스 템플릿이라
할 수 있습니다.
-
SmallObject 클래스 템플릿의 개요는 다음과 같습니다.
SmallObject 클래스 템플릿 예제 코드 보기
- template
- <
- template <class T>
- class ThreadingModel = DEFAULT_THREADING,
- std::size_t chunkSize = DEFAULT_CHUNK_SIZE,
- std::size_t maxSmallObjectSize = MAX_SMALL_OBJECT_SIZE
- >
- class SmallObject
- {
- public:
- static void* operator new(std::size_t size);
- static void operator delete(void* p, std::size_t size);
- virtual ~SmallObject(void) {}
- };
SmallObject 클래스 템플릿 예제 코드 접기
-
SmallObject를 상속받는 것을 통해 작은 객체의 할당기의 이점을 간단하게 누릴 수 있습니다. SmallObject
클래스 템플릿은 기본 템플릿 인자를 통해 구체화할 수도 있고, 스레딩 모델이나 메모리 할당에 관련된 템플릿 인자를
별도로 제공하여 구체화시킬 수도 있습니다.
-
다중 스레딩 환경에서 new를 통해 객체를 생성할 경우에는, ThreadingModel 인자에 다중 스레드를 사용하라는
전처리기 심볼을 넘겨주어야 합니다. ThreadingModel과 관련된 상세 정보에 대해서는
부록을 참조하기 바랍니다.
-
DEFAULT_CHUNK_SIZE의 기본 값은 4096입니다.
-
MAX_SMALL_OBJECT_SIZE의 기본 값은 64입니다.
-
DEFAULT_CHUNK_SIZE나 MAX_SMALL_OBJECT_SIZE는 기본 값에서 다른 값으로 별도로 #define을 통해
정의될 수 있습니다. 이 #define 정의는 반드시 std::size_t로 변환 가능한 상수 자료형이어야 합니다.
-
만일 DEFAULT_CHUNK_SIZE나 MAX_SMALL_OBJECT_SIZE를 0으로 #define하게 되면, SmallObj.h
파일은 조건부 컴파일을 통해 기본 할당기를 직접 사용하는 코드를 생성하게 됩니다. 하지만 각 객체의 인터페이스는
그대로 남게 됩니다. 이러한 성질은 프로그램이 자신만의 할당기를 가질 때와 그렇지 않을 때의 동작을 비교하는 데에
도움이 됩니다.
-
-
일반화 함수자란 C++가 허용하는 모든 호출 작업을 담는 객체입니다. 이것은
자료형에 대해 안전한 방법으로 캡슐화된 뛰어난 객체입니다. 이에 대한 보다 상세한
정의를 내리자면 다음과 같습니다.
-
일반화 함수자는 모든 호출 작업을 캡슐화합니다. 일반화 함수자는 전역 함수나 멤버 함수,
그리고 같은 함수자나 심지어 다른 종류의 함수자에 대한 포인터까지 적용될 수 있습니다.
-
일반화 함수자는 자료형에 대한 안전성을 제공합니다. 일반화 함수자는 결코 잘못된
자료형의 인자를 옳지 않은 함수에게 대응시키는 일이 없습니다.
-
일반화 함수자는 값의 성격을 갖는 객체입니다. 즉, 일반화 함수자는 복사 및 대입 연산을
완벽히 지원하며, 이를 이용한 "call-by-value" 함수 호출이 가능합니다. 이것은 자유롭게 복사가 가능하며, 가상 멤버
함수를 노출하거나 하지 않습니다.
일반화 함수자는 어떤 처리 작업에 대한 요청을 하나의 값으로 저장하고, 그것을 인자로 전달하며, 또한 그 생성 시점과는 다른 시간에
이것을 발동시킬 수 있게 해줍니다. 일반화 함수자와 함수 포인터의 차이점은, 일반화 함수자의 경우 자신의 상태를 저장할 수 있으며,
객체에 대한 멤버 함수까지 호출이 가능하다는 점입니다.
이 장을 읽고 나면 다음과 같은 일들이 가능해 질 것입니다.
-
Command 디자인 패턴을 이해하게 되며, 아울러 일반화 함수자가 Command와 어떻게 관련되어 있는지를 알 수 있을 것입니다.
-
어떤 경우에 Command 디자인 패턴과 일반화 함수자가 유용하게 사용될 수 있는지를 알게 될 것입니다.
-
C++의 다양한 기능 요소들이 갖는 각종 메커니즘을 이해하게 되며, 이것들을 어떻게 일관된 인터페이스로 캡슐화해야 하는지에 대해
알 수 있을 것입니다.
-
요청된 작업과 그와 함께 주어진 인자를, 어떤 방법으로 하나의 객체 형태로 저장하고, 그것을 전달하며, 나중에 이것을
자유롭게 호출할 수 있는 지를 배우게 될 것입니다.
-
다중의 지연된 호출을 하나로 엮어서 순서대로 수행되게 만드는 방법에 대해 알게 됩니다.
-
위에 기술된 강력한 기능들을 구현한 결과물인, Functor 클래스 템플릿을 사용하는 방법에 대해 배우게 될 것입니다.
-
Command 디자인 패턴은 객체에 대한 요청을 캡슐화하기 위해 만들어진 패턴입니다.
Command 객체란, 작업이 실제로 수행되는 쪽과는 분리되어 있는, 작업이 저장되는 쪽과 관련된 객체입니다.
Command 디자인 패턴의 일반적인 구조는 [그림 5.1]을 통해 잘 나타나 있습니다.
[그림 5.1] Command 디자인 패턴 보기
이 패턴의 핵심은 Command 클래스 자체입니다. 이것의 가장 중요한 목적은, 호출자와 수신자 사이의 종속성을 줄여주는 것입니다.
Command의 전형적인 동작은 다음과 같습니다.
-
애플리케이션(클라이언트)은 ConcreteCommand 객체를 만들고, 거기에 작업의 수행에 필요한 충분한 정보를 넘겨줍니다.
[그림 5.1]에 점선으로 표시된 부분은 클라이언트가 ConcreteCommand의 상태에 영향을 미친다는 사실을 나타내고
있습니다.
-
애플리케이션은 ConcreteCommand 객체의 Concrete 인터페이스를 호출자에게 넘겨줍니다. 호출자는 이 인터페이스를
저장합니다.
-
그 후에, 호출자는 그것을 실행해야 할 시간을 정하고, Command 객체의 Execute 가상 멤버 함수를 불러주게 됩니다.
ConcreteCommand가 수신자 객체(실제로 작업을 수행하는 객체)에 도착하게 되면, 그 수신자 객체를 사용하여
Action 멤버 함수를 호출하는 것과 같은 실질적인 처리 작업을 수행하게 됩니다. 그렇지 않고, ConcreteCommand
객체가 자기 스스로 모든 작업을 처리하도록 만들 수도 있습니다. 이러한 경우는 [그림 5.1]에서 수신자 Receiver
객체가 제거된 모습으로 표현될 수 있습니다.
중요한 것은, 실시간에 일어나는 다양한 동작들을 호출자에게 연결시키고자 한다면, 단순히 호출자가 가지고 있는 Command 객체를
다른 것으로 교체해 주기만 하면 된다는 것입니다.
주어진 수신자에 대해 서로 다른 호출자를 사용한다거나, 또는 주어진 호출자에 대해 서로 다른 수신자를 사용할 수 있습니다.
물론, 이러한 작업은 호출자와 수신자가 서로에 대해 전혀 알지 못하는 상태에서도 문제없이 구현될 수 있습니다.
함수에 대해 호출을 시작하는 순간은 그 개념의 호출 구성 요소(객체, 멤버 함수 그리고 그 인자)를 한데 모으는 순간과 분리될 수
없습니다. 하지만 Command 디자인 패턴의 경우에, 호출자는 이 호출에 대한 각 구성 요소만을 가지게 되며, 실질적인 호출 작업은
막연한 시점으로 지연되게 됩니다.
Command 디자인 패턴 예제 코드 보기
-
-
- window.Resize(0, 0, 200, 100);
-
-
-
-
- Command resizeCmd(
- window,
- &window::Resize,
- 0, 0, 200, 100);
-
- ...
-
- resizeCmd.Execute();
Command 디자인 패턴 예제 코드 접기
이러한 점으로부터 Command 패턴은 다음의 중요한 두 요소를 가지고 있다고 말할 수 있을 것입니다.
-
인터페이스 분리: 호출자와 수신자는 서로 분리되어 있어야 합니다.
-
시간적 분리: Command는 나중에 수행되도록 준비된 어떤 작업의 요청을
저장해야 합니다.
ConcreteCommand 객체는 필요한 환경 요소를 자신의 상태 값으로서 저장하고, 또 작업을 실행할 때에는 그 환경 요소에
접근할 수 있어야 합니다. ConcreteCommand가 더 많은 환경을 저장하면 할수록, 더 많은 독립성을 지닐 수가 있습니다.
구현의 관점에서 본다면, 두 가지 종류의 Command 클래스가 있을 수 있습니다. 하나는 단지 수신자에게 작업을 대리시키는 역할만
합니다. 그것이 하는 일은 Receiver 객체의 멤버 함수를 호출해 주는 것이 전부입니다. 우리는 그것을
Forwarding Command라고 부릅니다. 다른 하나는 좀 더 복잡한 작업을 수행하게 됩니다.
이것은 다른 객체에 대한 멤버 함수를 호출할 수도 있어야 하며, 또한 단순한 Forwarding 이상의 로직을 포함해야 합니다.
이제부터 이것을 Active Command라고 부르도록 합시다.
Active Command는 그 정의에 따라 애플리케이션마다 다른 코드를 포함하게 되므로, 라이브러리에 의해 미리 준비될 수가 없습니다.
반면에, Forwarding Command의 경우에는 사용자에게 도움이 될 수 있는 라이브러리 코드로 만들어 내는 것이 가능합니다.
Forwarding Command가 함수 포인터 및 함수자와 매우 비슷한 동작을 하기 때문에, 우리는 이것을
일반화 함수자라고 부릅니다.
이번 장에서 다룰 Functor 클래스 템플릿은, 어떤 객체와 그 객체에 대한 멤버 함수, 그리고 그에 속한 인자들도 모두
캡슐화하여 담을 수 있는 함수자의 한 종류입니다.
수작업으로 구현된 Command 패턴은 사실 확장성이 떨어지는 구조를 갖고 있습니다. 모든 객체와 멤버 함수를 그 종류에 관계없이
자유롭게 전달해 줄 수 있는 제네릭한 스타일의 Functor 클래스는 이러한 Command 패턴을 디자인하는 데 큰 도움이 될 것입니다.
일부 특별한 Active Command는 마찬가지로 Functor 클래스 템플릿을 통해 구현될 필요가 있습니다.
-
Command 패턴과 관련된 친숙한 예를 들어보면, 윈도우 환경을 꼽을 수 있을 것입니다. 객체지향적 GUI 프레임워크는 오랫동안
Command 패턴의 일종을 사용해 왔습니다.
원도우 시스템 작성자는 사용자의 동작(입력)을 애플리케이션으로 전달해 줄 제네릭한 스타일의 방법을 필요로 하게 됩니다.
사용자가 특정 행동을 취하면, 윈도우 시스템은 내부의 프로그램 로직에게 그것을 알려주어야만 합니다. 윈도우 시스템의 입장에서
보았을 때에, "도구" 메뉴의 "옵션" 명령은 어떤 특별한 의미를 가지지 않습니다. 만일 그것이 특별한 의미를 지니게 된다면,
애플리케이션은 매우 확장성 없는 프레임워크에 발이 묶이는 꼴이 될 것입니다. 윈도우 시스템을 애플리케이션과 분리하는 효과적인
방법은 사용자의 동작을 전달하는 방법으로 Command 객체를 사용하는 것입니다.
윈도우 시스템의 예에서, 호출자란 곧 사용자 인터페이스의 각 요소들입니다(버튼 체크박스, 메뉴 아이템 그리고 기타 위젯 등).
그리고 수신자는 사용자 인터페이스가 주는 명령에 반응해야 하는 각 객체가 됩니다(예를 들어 대화상자나 애플리케이션 자신과 같은).
Command 객체는 사용자 인터페이스와 애플리케이션이 공통으로 사용하는 하나의 공용어로서
작용하게 됩니다. Command는 양방향으로의 유연성을 제공합니다. 그 중 하나는, 애플리케이션 로직의 변화 없이도 새로운
유저 인터페이스의 요소를 추가해 넣을 수 있다는 것입니다. 이러한 프로그램은 제품 자체의 디자인을 바꾸지 않고도 새로운
skin을 적용시킬 수 있는 구조를 가질 수 있을 것입니다.다른 하나는, Command 객체가 서로 다른 애플리케이션에 대해
동일한 사용자 인터페이스를 쉽게 재사용할 수 있도록 만들어 준다는 것입니다.
-
Command를 전달하는 것은 일종의 일반화된 callback이라 할 수 있습니다.
Callback은 한 번 전달된 후에 언제나 호출될 수 있는 함수에 대한 포인터이며, 다음의 예처럼 표현될 수 있습니다.
함수 포인터 예제 코드 보기
- void Foo(void);
- void Bar(void);
-
- int main(void)
- {
-
-
- void (*pF)(void) = &Foo;
-
- Foo();
- Bar();
- (*pF)();
-
- void (*pF2)(void) = pF;
-
- pF = &Bar;
- (*pF)();
- (*pF2)();
-
- return 0;
- }
-
-
함수 포인터 예제 코드 접기
간단한 callback과 더불어, C++는 함수 호출 연산(operator() 연산자)을 지원하는 다음과 같은 다양한 요소들을 정의하고 있습니다.
-
C 스타일의 함수
-
C 스타일의 함수 포인터
-
함수에 대한 참조형(이것은 본질적으로 함수 포인터의 const 형과 동일하게 동작합니다)
-
함수자. 즉, operator() 연산자를 정의한 객체
-
식의 우변에서, 멤버 함수에 대한 포인터를 가지고 operator.*이나 operator->* 연산자를 적용한 결과
우리는 operator() 연산자를 지원하는 객체들을 '호출 가능 객체'라고 부릅니다.
이번 장의 목표는 모든 호출 가능 객체에 대한 호출을 저장하고, 전달할 수 있는 Forwarding Command를 구현하는
것입니다.
Functor 클래스 템플릿의 구현은 일반적인 함수 호출, 함수자에 대한 호출(Functor 클래스 템플릿(자신)에 대한 호출 포함),
그리고 멤버 함수에 대한 호출을 다룰 수 있어야 합니다.
-
Functor의 사용자가 관리해야 할 그 생명주기의 문제를 덜어주기 위해서는, Functor를 값의 존재(복사와 대입 연산이 가능한
객체)로 사용할 수 있도록 만들어 주어야 합니다. Functor는 다형적 클래스를 통해 구현되어 있지만, 그러한 다형성은
자신의 내부에 잘 숨겨져 있습니다. 우리는 이러한 구현을 담은 기반 클래스를 FunctorImpl이라 부르도록 하겠습니다.
Command 패턴의 Command::Execute는 C++에서, 사용자 정의된 operator() 연산자로 표현되어야 할 것입니다.
이는 문법적 통일성을 위한 것으로, 이를 통해 Functor는 다른 Functor를 담을 수 있게 됩니다. 따라서 이제부터는,
Functor가 호출 가능 객체의 일종으로 취급된다고 봐야 합니다.
Functor::operator()의 반환 자료형, 인자의 자료형, 인자의 개수를 고정시켜야 할 이유는 어디에도 없을 것입니다.
따라서, Functor는 템플릿 인자를 통해 반환 자료형과, 인자의 자료형, 그리고 인자의 개수를 정할 수 있도록 하는 것이
좋을 것입니다.
하지만 불행하게도, 가변 템플릿 인자라는 것이 존재하지 않습니다(이 도서가 출판된 시점에서). C의 가변 인자 함수를
C++에서도 쓸 수 있긴 하지만 format 문자열이 명시하고 있는 인자의 자료형이나 개수에 맞지 않게 호출할 위험성이 있으며,
심지어 클래스 객체를 생략 기호(...)와 함께 사용하는 것은 정의되지 않은 동작을 유발할 수 있습니다. 그 뿐만 아니라,
참조 자료형에 대한 지원도 불가능해지죠.
여기서 하나의 대안으로, Functor가 가질 수 있는 인자의 개수를 적절한 수준으로 제한하는 방법이 있습니다.
보통 하나의 함수에 최대 12개까지의 인자를 사용하게 되므로, 우리는 그 제한 숫자를 15개로 정하도록 합시다.
이런 결정을 내린 후에도, C++가 동일한 이름으로 다른 수의 인자를 갖는 템플릿을 허용하지 않는다는 문제점이 있습니다.
3장에서 우리는 Typelist를 정의하였다는 사실을 떠올려 봅시다. Functor의 인자에 쓰인
자료형들을 자료형의 집합(Typelist)으로 표현하면 어떨까요?
Functor 예제 코드 보기
-
- class Functor
- {
- public:
- void operator()(void);
-
-
- private:
-
- };
-
-
-
-
- template <typename ResultType>
- class Functor
- {
- public:
- ResultType operator()(void);
-
-
- private:
-
- };
-
-
-
-
-
-
- template <typename ResultType>
- class Functor
- {
- ...
- };
-
- template <typename ResultType, typename Param1>
- class Functor
- {
- ...
- };
-
-
-
-
-
-
- template <typename ResultType, class TList>
- class Functor
- {
- ...
- };
-
-
-
- typedef Functor<double, TYPELIST_2(int, double)> MyFunctor;
Functor 예제 코드 접기
Typelist의 도움에도 불구하고, Functor를 모든 개수의 인자에 대해 구현해 주기 위해서는 약간의 고통스러운 반복 작업을
거쳐야만 합니다. 예제 코드에서는 단순화를 위해 최대로 2개의 인자를 갖는다고 가정해 보도록 하겠습니다. Loki가 제공하는
Functor.h 파일은 여기서 다루는 내용을 15개의 인자에 적용되도록 확장시킨 정의를 담고 있습니다.
Functor 예제 코드 보기
-
- template <typename R, class TList>
- class FunctorImpl;
-
-
- template <typename R>
- class FunctorImpl<R, NullType>
- {
- public:
- virtual R operator()(void) = 0;
- virtual FunctorImpl* Clone(void) const = 0;
- virtual ~FunctorImpl(void) {}
- };
-
- template <typename R, typename P1>
- class FunctorImpl<R, TYPELIST_1(P1)>
- {
- public:
- virtual R operator()(P1) = 0;
- virtual FunctorImpl* Clone(void) const = 0;
- virtual ~FunctorImpl(void) {}
- };
-
- template <typename R, typename P1, typename P2>
- class FunctorImpl<R, TYPELIST_2(P1, P2)>
- {
- public:
- virtual R operator()(P1, P2) = 0;
- virtual FunctorImpl* Clone(void) const = 0;
- virtual ~FunctorImpl(void) {}
- };
-
-
-
-
-
-
- template <typename R, class TList>
- class Functor
- {
- public:
- Functor(void);
- Functor(const Functor&);
- Functor& operator=(const Functor&);
- explicit Functor(std::auto_ptr<Impl> spImpl);
- ...
-
- private:
-
-
- typedef FunctorImpl<R, TList> Impl;
- std::auto_ptr<Impl> spImpl_;
- };
-
-
-
-
-
Functor 예제 코드 접기
-
Functor는 자신의 요청을 FunctorImpl::operator()에게 전달해 주기 위한 operator() 연산자를 필요로 합니다.
우리는 여기에 FunctorImpl을 구현하면서 했던 것처럼, Functor가 필요로 하는 인자의 개수에 따라서 여러 벌의 부분 특화된
템플릿을 제공하는 방법을 쓸 수 있습니다. 하지만, Functor는 그 코드의 양이 매우 많은 편이기 때문에 이러한 방법은 적절하지
못하다고 할 수 있습니다.
어쨌든, 우선 인자의 자료형부터 정의해 보도록 합시다. 여기에도 Typelist가 큰 도움이 됩니다.
Functor의 인자 자료형 정의 예제 코드 보기
- template <typename R, class TList>
- class Functor
- {
- typedef TList ParamList;
- typedef typename TypeAtNonStrict<TList, 0, EmptyType>::Result Param1;
- typedef typename TypeAtNonStrict<TList, 1, EmptyType>::Result Param2;
- ... 위와 같이 반복 ...
- };
-
-
-
Functor의 인자 자료형 정의 예제 코드 접기
이제 operator() 연산자를 구현하기 위해서 한 가지 재미있는 트릭을 활용해야 할 필요가 있습니다. 모든 인자의 개수에 대한
모든 버전의 operator() 연산자를 다음과 같이 Functor 정의 내부에서 정의하는
경우를 살펴보도록 합시다.
Functor의 operator() 연산자 예제 코드 보기
- template <typename R, class TList>
- class Functor
- {
- ... 위와 같음 ...
-
- public:
- R operator()(void)
- {
- return (*spImpl_)();
- }
- R operator()(Param1 p1)
- {
- return (*spImpl_)(p1);
- }
- R operator()(Param1 p1, Param2 p2)
- {
- return (*spImpl_)(p1, p2);
- }
- };
Functor의 operator() 연산자 예제 코드 접기
주어진 Functor 템플릿이 특정 인스턴스로 구체화될 때 이 중에서 오직 하나의 operator()만이 올바른 정의가 될 것입니다.
FunctorImpl은 Functor와 달리 모든 버전의 operator()를 정의하고 있지 않기 때문에, Functor의 올바르지 않은
operator()는 컴파일에 실패합니다. 여기서 사용된 트릭이란, 바로 C++에서 템플릿 클래스의 멤버 함수는 그것이
실제로 사용되기 전에는 구체화되지 않는다는 사실을 이용하는 것입니다. 즉, 라이브러리 사용자가
잘못된 버전의 operator()를 호출하지 않는다면 컴파일러는 아무런 불평도 하지 않고 정상적으로 컴파일을 수행할 것입니다.
다음의 예제를 보기 바랍니다.
예제 코드 보기
- typedef Functor<double, TYPELIST_2(int, double)> myFunctor;
-
- double result = myFunctor(4, 5.6);
-
- double result = myFunctor();
-
예제 코드 접기
-
함수자 객체를 받아들이는 Functor의 생성자는 그 함수자가 갖는 자료형과 동일한 템플릿 인자를 가져야 합니다.
이 생성자를 구현하기 위해서, 우리는 FunctorImpl<R, TList>를 상속받는 FunctorHandler라는 간단한
클래스 템플릿을 정의해야 할 필요가 있습니다. 그 클래스는 Fun 자료형의 객체를 저장하고, operator()를 그곳으로
전달해 줄 것입니다. 우리는 바로 앞 섹션에서 operator()를 정의하는 데 사용했던 것과 동일한 기법을 사용해서 이를
구현할 것입니다.
FunctorHandler 예제 코드 보기
다음 섹션에서 FunctorHandler가 코드에 일반성을 부여하는 데 얼마나 큰 도움이 되는지가 잘 설명될 것입니다.
이 Functor의 생성자로 진입하는 시점에서는, 템플릿 인자 Fun을 통해 fun의 자료형에 대한 모든 정보를 가질 수가 있습니다.
하지만, 이 생성자를 빠져나갈 때에는 이 자료형에 대한 정보를 모두 잃게 됩니다. 왜냐하면, Functor가 아는 것은 오직
기반 클래스인 FunctorImpl을 가리키는 포인터 spImpl_ 뿐이기 때문입니다. 겉으로 보기에는 자료형 정보를 잃은 것 같지만,
생성자는 자료형에 대한 정보를 확실히 알고 있으며, 그 정보를 다형적인 동작으로 변환시켜 주는 일종의 공장과도 같은 역할을
하게 됩니다.
Functor 시험 운전 예제 코드 보기
- #include
- #include
-
- using namespace std;
-
- struct TestFunctor
- {
- void operator()(int i, double d)
- {
- cout << "TestFunctor::operator()(" << i
- << ", " << d << ") called.\n";
- }
- };
-
- int main(void)
- {
- TestFunctor f;
- Functor<void, TYPELIST_2(int, double)> cmd(f);
- cmd(4, 4.5);
-
- return 0;
- }
-
-
- TestFunctor::operator()(4, 4.5) called.
Functor 시험 운전 예제 코드 접기
-
앞 섹션을 읽으면서, '왜 가장 단순한 경우(예를 들어, 평범한 함수 포인터를 지원하는 것)부터 시작하지 않고 처음부터 함수자,
템플릿 등의 내용으로 바로 건너뛴 것일까?' 하는 생각이 들었을 것입니다. 대답은 간단합니다. 이미, 평범한 함수 포인터에
대한 내용은 저절로 구현되어져 있다는 것입니다! 앞 섹션의 테스트 프로그램(마지막
예제 코드)을 다음과 같이 살짝 변형해 보도록 합시다.
변형된 시험 운전 예제 코드 보기
- #include
- #include
- using namespace std;
-
- void TestFunction(int i, double d)
- {
- cout << "TestFunction(" << i
- << ", " << d << ") called.\n";
- }
-
- int main(void)
- {
- Functor<void, TYPELIST_2(int, double)> cmd(TestFunction);
-
-
- cmd(4, 4.5);
-
- return 0;
- }
변형된 시험 운전 예제 코드 접기
이 놀랍도록 멋진 결과는 템플릿 인자에 대한 컴파일러의 연역 작업으로 인해 가능한 일이라고 설명할 수 있겠습니다.
Fun(fun_의 자료형)은 void (&)(int, double)로 연역됩니다. 그리고 사용자가
FunctorHandler<...>::operator()를 불러주게 되면, 그것은 곧바로 fun_()으로 전달될 것입니다.
그리고 이것은 결과적으로 함수 포인터를 이용하여 함수를 호출하는 올바른 문법을 구성하게 됩니다.
정리해 보면, FunctorHandler는 다음의 두 가지 요소를 통해 함수 포인터를 지원한다고 말할 수 있을 것입니다.
그 첫째 요소는 바로 함수 포인터와 함수자가 갖는 구문상의 상호 유사성이며, 둘째 요소는 C++가 사용하는 자료형 추론의
메커니즘입니다.
그러나 여기에도 한 가지 문제가 남아 있습니다. 만일 Functor에 넘겨주는 함수가 오버로드된 함수라면, 모호성의 문제가
발생하게 됩니다. 그 이유는 오버로드된 함수의 이름이 가리키는 자료형은 명시적으로 정의될 수 없기 때문입니다.
이를 해결하는 방법은 두 가지가 있습니다. 그 하나는 초기화(혹은 대입)를 사용하는 것이고, 다른 하나는 형변환을
이용하는 것입니다.
오버로드된 함수를 Functor에 넘겨주는 예제 코드 보기
- void TestFunction(int i, double d)
- {
- cout << "TestFunction(" << i
- << ", " << d << ") called.\n";
- }
-
- void TestFunction(int);
-
-
-
-
-
- int main(void)
- {
-
- typedef void (*TpFun)(int, double);
-
-
- TpFun pF = TestFunction;
- Functor<void, TYPELIST_2(int, double)> cmd1(pF);
- cmd1(4, 4.5);
-
-
- Functor<void, TYPELIST_2(int, double)> cmd2(
- static_cast<TpFun>(TestFunction));
-
- cmd2(4, 4.5);
-
- return 0;
- }
오버로드된 함수를 Functor에 넘겨주는 예제 코드 접기
-
우리는 Functor에 대해 다음과 같은 코드가 잘 동작하는 것을 기대할 수 있습니다.
예제 코드 보기
- #include
- #include
- #include
- using namespace std;
-
- const char* TestFunction(double, double)
- {
- static const char buffer[] = "Hello, world!";
-
-
-
- return buffer;
- }
-
- int main(void)
- {
- Functor<string, TYPELIST_2(int, int)> cmd(TestFunction);
-
-
- cout << cmd(10, 10).substr(7);
-
- return 0;
- }
예제 코드 접기
이 예제 코드는 기대한 것처럼 문제없이 컴파일되며, 정상적으로 실행됩니다. 위의 예제 코드에서 템플릿이 모두 구체화되고 나면,
과연 어떤 일이 벌어지는지를 살펴보도록 합시다.
예제 코드 보기
- string Functor<...>::operator()(int i, int j);
-
-
- string FunctorHandler<...>::operator()(int i, int j)
-
-
- return fun_(i, j);
-
-
-
예제 코드 접기
-
흔한 경우는 아니지만, 멤버 함수에 대한 포인터가 필요한 경우가 때때로 발생합니다.
이것은 함수 포인터와 매우 유사하게 동작하지만, 그것을 호출할 때 함수 인자와 더불어 동작의 주체가 되는 객체를 함께
넘겨주어야 한다는 차이점이 있습니다.
멤버 함수에 대한 포인터 예제 코드 보기
- #include
- using namespace std;
-
- class Parrot
- {
- public:
- void Eat(void)
- {
- cout << "Tsk, knick, tsk...\n";
- }
- void Speak(void)
- {
- cout << "Oh Captain, my Captain!\n";
- }
- };
-
- int main(void)
- {
-
-
-
- typedef void (Parrot::*TpMemFun)(void);
-
-
-
- TpMemFun pActivity = &Parrot::Eat;
-
-
- Parrot geronimo;
-
-
- Parrot* pGeronimo = &geronimo;
-
-
-
-
-
- (geronimo.*pActivity)();
-
-
-
-
- (pGeronimo->*pActivity)();
-
-
- pActivity = &Parrot::Speak;
-
-
- (geronimo.*pActivity)();
-
- return 0;
- }
멤버 함수에 대한 포인터 예제 코드 접기
멤버 함수에 대한 포인터 및 그와 관련된 연산자(.*와 ->*)를 면밀히 살펴보면 상당히 오묘한 특징이 드러나게 됩니다.
그 특징이란 바로 geronimo.*pActivity 및 pGeronimo->*pActivity 연산의 결과 값을 담을 수 있는 C++의
자료형은 존재하지 않는다는 사실입니다. 이 두 연산이, 함수 호출 연산자를 적용시킬 수 있는 '무언가'를 반환해 주긴 하지만,
그 '무언가'라는 것은 자료형이라 부르기가 조금 곤란합니다.
C++에서는 다른 모든 객체가 자료형을 가지는 반면에, operator->*혹은 operator.* 연산자의 결과만은 특별한
예외로 자료형을 가지지 않습니다.
어떤 C++ 컴파일러 제작사들은 다음과 같은 문법을 통해 operator.* 연산자의 결과 값을 저장할 수 있도록 하는 새로운
자료형을 자신들만의 확장 규격으로 정의하고 있습니다.
확장 규격 예제 코드 보기
- void (__closure::*geronimosWork)() =
- geronimo.*pActivity;
-
- geronimosWork();
-
-
-
확장 규격 예제 코드 접기
사실, 이러한 언어의 확장 규격은 Functor 클래스의 일종이라고 할 수 있습니다만,
객체와 멤버 함수에만 적용된다는 제한점이 있습니다.
이제 Functor 클래스가 멤버 함수에 대한 포인터에도 적용될 수 있도록 만들어 보도록 합시다.
함수자와 함수들을 다루면서 얻은 경험에 의하면 우선적으로 일반적인 접근법을 유지하는 것이 유리하며,
너무 성급하게 세부 구현으로 넘어가는 것은 좋지 않은 결과를 가져온다는 사실을 알 수 있습니다.
따라서 MemFunHandler를 구현할 때 해당 객체의 자료형은 템플릿 인자가 되어야 합니다.
더 나아가 멤버 함수에 대한 포인터 역시 템플릿 인자로 만들어 주어야 합니다.
이렇게 함으로써, 우리는 자동 형변환이라는 특성을 그대로 얻게 됩니다.
MemFunHandler 예제 코드 보기
- template <class ParentFunctor, typename PointerToObj,
- typename PointerToMemFn>
- class MemFunHandler
- : public FunctorImpl
- <
- typename ParentFunctor::ResultType,
- typename ParentFunctor::ParamList
- >
- {
- public:
- typedef typename ParentFunctor::ResultType ResultType;
-
- MemFunHandler(const PointerToObj& obj, PointerToMemFn pMemFn)
- : pObj_(pObj), pMemFn_(pMemFn) {}
-
- MemFunHandler* Clone(void) const
- { return new MemFunHandler(*this); }
-
- ResultType operator()(void)
- {
- return ((*pObj_).*pMemFn_)();
- }
-
- ResultType operator()(typename ParentFunctor::Param1 p1)
- {
- return ((*pObj_).*pMemFn_)(p1);
- }
-
- ResultType operator()(typename ParentFunctor::Param1 p1,
- typename ParentFunctor::Param2 p2)
- {
- return ((*pObj_).*pMemFn_)(p1, p2);
- }
-
- private:
- PointerToObj pObj_;
- PointerToMemFn pMemFn_;
- };
-
-
-
- template <class ParentFunctor, typename Obj,
- typename PointerToMemFn>
- class MemFunHandler
- : public FunctorImpl
- <
- typename ParentFunctor::ResultType,
- typename ParentFunctor::ParamList
- >
- {
- private:
- Obj* pObj_;
- PointerToMemFn pMemFn_;
-
- public:
- MemFunHandler(Obj* pObj, PointerToMemFn pMemFn)
- : pObj_(pObj), pMemFn_(pMemFn) {}
-
- ...
- };
-
-
-
-
-
-
MemFunHandler 예제 코드 접기
이제 새로 구현된 특성들을 테스트 해 볼 차례가 되었습니다.
예제 코드 보기
-
- #include
- #include
- using namespace std;
-
- class Parrot
- {
- public:
- void Eat(void)
- {
- cout << "Tsk, knick, tsk...\n";
- }
-
- void Speak(void)
- {
- cout << "Oh Captain, my Captain!\n";
- }
- };
-
- int main(void)
- {
- Parrot geronimo;
-
-
- functor<...>
- cmd1(&geronimo, &Parrot::Eat),
- cmd2(&geronimo, &Parrot::Speak);
-
-
- cmd1();
- cmd2();
-
- return 0;
- }
-
예제 코드 접기
-
예를 들어, 어떤 Functor의 자료형을 다른 것으로 변환시키는 기능을 원할 수가 있을 것입니다. 이러한 변환 과정을
우리는 바인딩(binding)이라고 부릅니다. 두 개의 정수를 받아들이는 Functor가
주어진 상태에서, 하나의 정수를 어떤 고정 값으로 바인딩시키고, 남은 하나만을 변수로 남겨두는 것이 가능합니다.
Binding 예제 코드 보기
- void f(void)
- {
-
- Functor<void, TYPELIST_2(int, int)> cmd1(something);
-
-
- Functor<void, int> cmd2(BindFirst(cmd1, 10));
-
-
- cmd2(20);
-
-
- Functor<void> cmd3(BindFirst(cmd2, 30));
-
-
- cmd3();
- }
Binding 예제 코드 접기
Binding은 매우 강력한 특성입니다. 여러분은 이것을 통해 각종 호출 가능 요소들뿐만 아니라, 거기에 쓰이는 인자들 전체나 혹은
그 일부도 저장할 수 있습니다.
예를 들어, 어떤 텍스트 에디터에서 redo 기능을 구현한다고 가정해 봅시다. 사용자가 'a'라는 문자를 입력하였다면, 프로그램은
Document::InsertChar('a')와 같이 멤버 함수를 호출하게 됩니다. 그런 후에는 Document에 대한 포인터 변수와
멤버 함수인 InsertChar, 그리고 그 인자인 실제 문자 데이터를 포함하도록 준비된 Functor를 덧붙여 줍니다.
이 때, 사용자가 redo 기능을 부르게 되면, 프로그램은 단지 앞에서 준비된 Functor를 다시 불러주는 것만으로, 원하는 일을
수행해 낼 수 있습니다. 5.14 섹션에서 undo와 redo에 대한 심도 깊은 논의가 다루어질 것입니다.
지금까지 Functor는 함수에 대한 포인터를 저장하는 방식으로 그 계산을 지연시켜 왔습니다. 하지만, Functor는 단지 그 계산만을
저장할 뿐, 그 계산에 필요한 환경은 저장하지 않았습니다. Binding을 이용하면, Functor가 그
'환경'의 일부분을 '계산'과 함께 저장할 수 있게 되며, 따라서 실제 호출 단계에서 필요하게
되는 환경은 그 양이 점점 줄어들게 됩니다.
Functor<R, TList>를 구체화할 때 우리는 첫 번째 인자(TList::Head)를 어떤 고정된 값으로 Binding시킬
것입니다. 따라서, 그 반환 자료형은 당연히 Functor<R, TList::Tail>이 됩니다.
그렇다면 BinderFirst 클래스 템플릿을 구현하는 것은 매우 쉬운 일일 것 같습니다. 우리는 입력용 Functor와, 출력용
Functor만 신경쓰면 됩니다. 입력용 Functor 자료형은 ParentFunctor라는 인자의 형태로 넘겨지고, 출력용 Functor는
내부 계산에 의해 추론되게 됩니다.
예제 코드 보기
- template <class Incoming>
- class BinderFirst
- : public FunctorImpl<typename Incoming::ResultType,
- typename Incoming::Arguments::Tail>
- {
- typedef Functor<typename Incoming::ResultType,
- Incoming::Arguments::Tail> Outgoing;
-
- typedef typename Incoming::Param1 Bound;
- typedef typename Incoming::ResultType ResultType;
-
- public:
- BindFirst(const Incoming& fun, Bound bound)
- : fun_(fun), bound_(bound)
- {
- }
-
- BindFirst* Clone(void) const
- { return new BindFirst(*this); }
-
- ResultType operator()(void)
- {
- return fun_(bound);
- }
-
- ResultType operator()(typename Outgoing::Param1 p1)
- {
- return fun_(bound_, p1);
- }
-
- ResultType operator()(typename Outgoing::Param1 p1,
- typename Outgoing::Param2 p2)
- {
- return fun_(bound_, p1, p2);
- }
-
- private:
- Incoming fun_;
- Bound bound;
- };
-
-
-
-
- template <class Fctor>
- typename Private::BinderFirstTraits<Fctor>::BoundFunctorType
- BindFirst(
- const Fctor& fun,
- typename Fctor::Param1 bound)
- {
- typedef typename
- Private::BinderFirstTraits<Fctor>::BoundFunctorType
- Outgoing;
-
- return Outgoing(std::auto_ptr<typename Outgoing::Impl>(
- new BinderFirst<Fctor>(fun, bound)));
- }
-
-
-
-
- const char* Fun(int i, int j)
- {
- cout << "Fun(" << i << ", " << j << ") called\n";
- return "0";
- }
-
- int main(void)
- {
- Functor<const char*, TYPELIST_2(char, int)> f1(Fun);
- Functor<std::string, TYPELIST_1(double)> f2(
- BindFirst(f1, 10));
-
-
- f2(15);
-
- return 0;
- }
예제 코드 접기
-
GoF의 디자인 패턴(Gamma et al.1995)은 MacroCommand 클래스라는 예제를 제공합니다. 이것은 Commands에 대한
1차원 집합(리스트나 벡터와 같은)을 담는 클래스입니다. MacroCommand가 실행될 때 그것은 자신이 가지고 있는 각
Command들을 순차적으로 실행하게 됩니다.
이러한 특성은 매우 유용하게 사용될 수 있습니다. 예를 들어, 다시 한 번 undo/redo를 구현하는 경우를 생각해 보도록 합시다.
하나의 "do" 작업을 되돌리기 위한 "undo" 작업을 할 때, 실제로 다수의 작업이 필요할 수도 있습니다. 예를 들어, 하나의
문자를 삽입하는 경우에 이것은 텍스트 화면을 자동으로 스크롤시킬 수도 있습니다. 그리고 그 작업을 "undo" 하면, 삽입된 문자만
제거하는 것이 아니라 스크롤도 되돌리는 것이 좋을 것입니다. 이를 위해서 다수의 Command를 하나의 Functor 객체에 저장하고,
그것을 마치 하나의 단위처럼 실행시킬 수 있어야 합니다.
Loki는 FunctorChain과 그것을 위한 도움 함수인 Chain을 정의하고 있습니다. 우선 Chain의 선언문을 살펴보면
다음과 같습니다.
template <class Fun1, class Fun2>
Fun2 Chain(
const Fun1& fun1,
const Fun2& fun2);
FunctorChain 클래스의 구현은 사실 별 것 아닙니다. 그것은 두 개의 함수자를 저장하고, FunctorChain::operator()를
정의하여 두 개의 함수자를 차례대로 불러주도록 만든 것뿐입니다. 다수의 함수자를 연결하고자 한다면, 이 Chain 함수를 반복적으로
호출하여 그 결과를 얻어낼 수 있을 것입니다.
BindFirst 뿐만 아니라, Chain 역시 Functor의 코드에 어떠한 변경 사항도 가하지 않는다는 사실을 주목하기 바랍니다.
그것은 바로, 여러분들 또한 Functor의 사용을 위한 자신만의 유사한 장치들을 개발해 낼 수 있다는 사실을 의미합니다.
-
이제부터는 Functor가 가능한 한 효율적으로 동작하도록 최적화하는 데 집중해 봅시다.
한 번 Functor의 한 operator() 오버로드 함수에 초첨을 맞춰 보도록 합시다. 이 오버로드 함수는 주어진 호출을 스마트
포인터로 전달하게 됩니다.
예제 코드 보기
- R operator()(Param1 p1, Param2 p2)
- {
- return (*spImpl_)(p1, p2);
- }
예제 코드 접기
위의 코드에서는 매번 operator()가 호출될 경우에, 각 인자에 대한 불필요한 복사 연산이 뒤따르게 됩니다.
만일, Param1과 Param2가 복사에 많은 부하를 요구하는 자료형이라면, 이 코드는 그 성능에 문제점을 드러내게 됩니다.
이상한 일이지만 Functor의 operator() 연산자가 인라인으로 정의되어 있다 하더라도, 컴파일러가 이 추가적인 복사 연산을
최적화와 함께 없애 버릴 수는 없습니다.
그렇다면 인자를 참조형으로 받으면 어떨까요? 아무 문제 없어 보이지만, 다음과 같은 코드를 만나면 문제가 발생합니다.
예제 코드 보기
-
-
- R operator()(Param1& p1, Param2& p2)
- {
- return (*spImpl_)(p1, p2);
- }
-
-
-
-
- void testFunction(std::string&, int);
- Functor<void, TYPELIST_2(std::string&, int)> cmd(testFunction);
- ...
- string s;
- cmd(s, 5);
예제 코드 접기
컴파일러는 마지막 줄에서 다음과 같은 에러를 내며 멈출 것입니다. "참조형에 대한 참조는 허용되지 않습니다(실제 에러 메시지는
이보다는 좀 더 모호한 표현일 수도 있습니다)." 여기서 문제가 된 것은 이 구체화가 Param1이 std::string을 참조하도록 만들며,
이에 따라 p1은 std::string의 참조형에 대한 참조형이 되어 버린다는 사실입니다. 알다시피 C++에서 참조형에 대한 참조는
금지되어 있습니다.
다행스럽게도 2장에서 이런 문제에 정확한 해답을 제시할 수 있는 도구를 제공하고 있습니다.
2장은 TypeTraits<T>라는 클래스 템플릿을 통하여 자료형 T와 관련된 다양한 자료형을
얻어내는 방법을 소개하고 있습니다. 여기에서 함수의 인자로 쓰일 수 있는 안전하고, 효율적인 자료형으로는 ParameterType이
정의되어 있습니다. 다음의 표는 TypeTraits의 인자로 주어지는 자료형과 그에 따른 ParameterType의 실제 정의를
나타내 주고 있습니다. 여기서 U는 const 한정자나 &가 포함되지 않은 보통의 클래스나 혹은 기본 자료형이라고
가정하도록 하겠습니다.
ParameterType 표 보기
T |
TypeTraits<T>::ParameterType |
U |
U가 기본 자료형인 경우에 U, 그렇지 않으면 const U& |
const U |
U가 기본 자료형인 경우에 U, 그렇지 않으면 const U& |
U& |
U& |
const U& |
const U& |
ParameterType 표 접기
만일 이 표의 오른쪽에 나오는 자료형을 전달 함수의 인자로 대신 사용하게 된다면, 우리가 우려하는 그 어떤 경우에서도 올바르게
동작할 수 있을 것이며, 복사 작업에 의한 부하 또한 안전하게 피해 갈 수 있을 것입니다.
TypeTraits<T>::ParameterType을 적용한 예제 코드 보기
-
이번에는 Functor를 생성하고, 복사하는 데 필요한 부하에 초점을 맞추어 보도록 합시다. Functor는 Heap 영역의
메모리 할당을 필요로 합니다. 모든 Functor는 new로 할당되는 객체에 대한 포인터(혹은 스마트 포인터)를 담게 되며,
이 Functor가 복사될 때는 FunctorImpl::Clone을 사용하여 Deep Copy가 수행되게 됩니다.
우리가 사용하는 객체들의 크기를 고려해 본다면, 이것은 상당히 성가신 문제가 됩니다. 대부분의 경우에 Functor는 함수에 대한
포인터를 담게 되거나, 혹은 객체에 대한 포인터와 멤버 함수에 대한 포인터 쌍을 담게 됩니다. 즉 작은 메모리 조각을
할당하게 됩니다.
우리는 4장에서, 기본 메모리 할당기가 작은 조각의 메모리를 할당하는 데 적합하지 않다는 사실과,
작은 객체에 대한 효율적인 할당기인 SmallObject에 대해서 다루었습니다. 4장의 내용에 따르면 이 할당기를 사용하기
위해서는 자신의 클래스를 SmallObject 클래스 템플릿을 상속받도록 구성하면 된다는 사실을 알 수 있습니다.
그 방법은 간단하지만, 우리는 일단 Functor와 FunctorImpl에 템플릿 인자 하나를 추가하여 메모리 할당에 쓰이는
스레딩 모델을 선택할 수 있도록 만들어 주어야 합니다.
SmallObject를 적용한 예제 코드 보기
-
- template
- <
- class R,
- class TL,
- template <class T>
- class ThreadingModel = DEFAULT_THREADING
- >
- class FunctorImpl : public SmallObject<ThreadingModel>
- {
- public:
- ... 앞의 내용과 같음 ...
- };
-
-
-
- template
- <
- class R,
- class TL,
- template <class T>
- class ThreadingModel = DEFAULT_THREADING
- >
- class Functor
- {
- ...
- private:
-
- std::auto_ptr<FunctorImpl<R, TL, ThreadingModel> spImpl_;
- };
SmallObject를 적용한 예제 코드 접기
-
undo와 redo에 대한 기본적인 아이디어는 undo용 스택과 redo용 스택을 유지하자는 생각입니다. 사용자가 문자를 입력하는 것과
같은 특정 작업을 수행하면, 그에 맞는 Functor를 undo 스택에 넣어주자는 것입니다. 이것은 실제 동작을 구현하는 멤버
함수에게는 부담이 되는 일이긴 하지만, Functor의 입장에서는 undo 작업을 어떻게 수행해야 할지에 대한 정보를 전혀 알지도
못해도 상관없는 일종의 자유가 주어지는 것입니다.
추가적으로 Document 객체와 입력된 문자를 고정 인자로 바인딩시킨 Document::InsertChar 멤버 함수에 대한 포인터를
함게 묶어 구성한 Functor를 redo 스택에 넣어 줄 필요도 있습니다. 특정 편집기들이 제공하는 "재입력" 기능을 구현하기
위해서 이러한 작업이 필요합니다. 우리가 Functor를 위해 구성한 바인딩이라는 개념은 주어진 문자에 대한
Document::InsertChar의 호출을 단일 Functor 객체에 완전히 포장하여 저장하는 데 적절하게 쓰일 수 있습니다.
또한, 마지막에 입력된 문자를 반복 입력하는 것 뿐만 아니라, 일정 블록의 문자열을 통째로 반복해서 입력하는 것도 가능해야
합니다. Functor 간의 체인(Chain)화라는 기법이 바로 여기서 쓰일 수 있습니다. 사용자가 문자를 입력하면, 이것을
같은 Chain을 이용하여 Functor에 추가시켜 주기만 하면 됩니다. 이러한 방법을 사용하면 다수의 키 입력 과정을
하나의 동작으로 취급하는 것이 가능해집니다.
Document::InsertChar는 기본적으로 적절한 되돌리기 동작의 Functor를 구성하여 undo 스택에 쌓게 됩니다.
그리고 사용자가 Undo를 선택하게 되면, 스택에 저장된 Functor가 실행되어 undo 기능을 수행하며, 이 때 자기
자신의 동작은 다시 redo 스택에 쌓게 됩니다.
-
세부 구현의 내용들을 다시 돌이켜 보면, 제네릭한 스타일의 코드를 작성할때 새겨두어야 할 몇 가지 교훈을 발견하게 됩니다.
-
자료형을 다룰 때 자료형에 대한 선택을 잠시 미루고 대신 그것을 템플릿화 하십시오. 제네릭한 스타일의 생각을
해야 합니다. FunctorHandler와 MemFunHandler는 이러한 템플릿화를 통해 정말 많은 것을 얻을 수
있었습니다. 대표적인 예로 함수 포인터에 대한 지원이 저절로 가능해진 사실을 들 수 있습니다. Functor가
제공하는 기능에 비교해 볼 때 그 기본 코드의 양은 매우 작습니다. 이것들은 모두 템플릿을 사용하고, 가능한
한 컴파일러가 자료형을 추론하도록 함으로써 얻을 수 있는 특징들입니다.
-
중요한 의의를 놓치지 마십시오. 예를 들어, 우리가 구현한 Functor의 경우에 오직 FunctorImpl에 대한
포인터만을 가지고 작업하는 것은 엄청난 두통을 수반하는 일이 될 것입니다. 여러분 스스로가 바인딩(Binding)과
체인(Chain)화 기법을 직접 구현한다고 가정하십시오. 그러면 여러분이 왜 그런 기법들을 구현했는지, 그리고
그것을 언제, 어디서 사용해야 하는지를 항상 인식할 수 있을 것입니다.
절묘한 기법들은 또한, 단순성의 이점을 가지고 있어야 합니다. 템플릿, 상속, 바인딩 그리고 메모리 관리에 대한 모든 논의들을
잘 추려 낸 결과로, 우리는 간단하고, 사용하기 쉬우며, 명료한 라이브러리를 얻어낼 수 있었습니다.
간단히 말해서, Functor는 함수, 함수자 또는 멤버 함수에 대한 지연된 호출을 제공하는 객체 입니다. Functor는 피호출자를
저장하고, 그것을 불러주는 operator() 연산자를 노출합니다.
-
-
Functor 클래스 템플릿은 최대 15개까지의 인자를 수용하는 함수자의 일종입니다. Functor의 첫 번째 템플릿
인자는 반환 자료형입니다. 그리고 두 번째 템플릿 인자는 호출에 쓰이는 인자들의 자료형을 담고 있는
Typelist입니다. 마지막으로 세 번째 템플릿 인자는 Functor의 메모리 할당기가 사용할 스레딩 모델을 가리킵니다.
Typelist의 세부 내용에 대해서는 3장에서 잘 설명되고 있으며, 스레딩 모델에 대해서는
Appendix를 참조하면 됩니다. 그리고 4장에서는 여기서 사용한
작은 객체에 대한 사용자 정의 할당기에 대해서 자세히 다루고 있습니다.
-
Functor는 함수나 함수자, 혹은 다른 Functor, 또는 객체에 대한 포인터 및 그 멤버 함수에 대한 포인터를
가지고 초기화 될 수 있습니다. 다음의 예제 코드를 살펴보기 바랍니다.
Functor 예제 코드 보기
- void Function(int);
-
- struct SomeFunctor
- {
- void operator()(int);
- };
-
- struct SomeClass
- {
- void MemberFunction(int);
- };
-
- void example(void)
- {
-
- Functor<void, TYPELIST_1(int)> cmd1(Function);
-
-
- SomeFunctor fn;
- Functor<void, TYPELIST_1(int)> cmd2(fn);
-
-
-
- SomeClass myObject;
- Functor<void, TYPELIST_1(int)> cmd3(&myObject,
- &SomeClass::MemberFunction);
-
-
- Functor<void, TYPELIST_1(int)> cmd4(cmd3);
- }
Functor 예제 코드 접기
-
또한 Functor는 std::auto_ptr<FunctorImpl<R, TList> >를 가지고도 초기화될 수 있습니다.
이러한 특징은 사용자 정의로 확장 기능을 구현하는 것을 가능하게 합니다.
-
Functor는 그 인자와 반환 값에 대한 자동 형변환 기능을 지원합니다. 예를 들어, 앞의 예에서 Function,
SomeFunctor::operator() 그리고 SomeClass::MemberFunction은 모두 그 인자로 int 대신
double 형의 값을 받는 것도 가능할 것입니다.
-
오버로드 함수가 존재할 경우에는 수동으로 직접 모호성을 제거해 주어야 합니다.
-
Functor는 다음의 중요한 문맥들을 완벽하게 제공합니다. 복사, 대입 그리고 값에 의한 전달 등이 완벽하게
지원됩니다. Functor는 다형성을 지니지 않으며, 다른 클래스가 Functor를 기반 클래스로 삼도록 디자인된 것이
아닙니다. 만일 Functor의 기능을 확장하고자 한다면 FunctorImpl을 상속받아야 합니다.
-
Functor는 인자의 바인딩을 제공합니다. BindFirst에 대한 호출은 그 첫 번째 인자를 주어진 고정 값으로
연결짓게 됩니다. 그리고 그 결과로는 나머지 함수 인자들을 인자 목록으로 가지는 Functor가 반환됩니다.
다음의 예제 코드를 살펴보기 바립니다.
BindFirst 예제 코드 보기
- void f(void)
- {
-
- Functor<void, TYPELIST_3(int, int, double)> cmd1(
- someEntity);
-
-
- Functor<void TYPELIST_2(int, double)> cmd2(
- BindFirst(cmd1, 10));
-
-
- cmd2(20, 5.6);
- }
BindFirst 예제 코드 접기
-
다수의 Functor가 Chain 함수를 이용하여 하나의 Functor 객체로 연결될 수 있습니다.
Chain 예제 코드 보기
- void f(void)
- {
- Functor<> cmd1(something);
- Functor<> cmd2(somethingElse);
-
-
- Functor<> cmd3(Chain(cmd1, cmd2));
-
-
- cmd3();
- }
Chain 예제 코드 접기
-
Functor를 사용하는 데 드는 부하는 단지 하나의 간접 접근의 연산(궁극적으로 포인터를 통한 호출이므로)뿐입니다.
바인딩 작업이 있을 경우에는 각 바인딩마다 가상 호출을 하는 부하가 추가됩니다. 또한, 체인화 작업의 경우에도 마찬가지로
가상 호출의 부하가 추가됩니다. 각 호출 인자들은 형변환이 필요할 경우를 제외하면 복사 작업의 부하를 발생시키지
않습니다.
-
FunctorImpl은 4장에서 정의한 작은 객체에 대한 메모리 할당기를 이용하고 있습니다.
-
싱글톤이란 발전된 형태의 전역변수입니다. 일반 전역변수에 비해 싱글톤이 가지는 개선점은 프로그램 상에서 동일한 싱글톤 자료형을 갖는 객체의
두 번째 인스턴스를 만들어 낼 수 없다는 것입니다.
클라이언트 프로그램의 관점에 있어서 싱글톤 객체는 자기 자신을 소유하게 됩니다. 싱글톤 객체는 자기 자신의 생성과 파괴에 대한 책임을 스스로
지니게 됩니다.
이번 장에서는 다음과 같은 C++에서 다양한 싱글톤의 변형들을 디자인하고, 또 구현하는 것과 관련된 가장 중요한 논점들을 다루고 있습니다.
-
싱글톤이 단순한 전역 객체와 구별되는 특징들
-
싱글톤을 지원하기 위한 기본적인 C++ 기법들
-
싱글톤의 유일성을 강제하기 위한 더 나은 해법
-
싱글톤의 파괴 및 파괴된 이후의 접근에 대한 감별
-
싱글톤 객체의 생명주기를 관리하기 위한 진보된 해법의 구현
-
다중 스레딩과 관련된 논점들
우리는 각 논점에 해당하는 테크닉들을 개발해 낼 것입니다. 그리고 최종적으로는 이러한 테크닉들을 제네릭한 스타일의 SingletonHolder
클래스 템플릿을 구현하는 데 사용하게 될 것입니다.
싱글톤 디자인 패턴을 구현하기 위한 최선의 방법이란 존재하지 않습니다. 다양한 모든 싱글톤들이 주어진 문제에 따라서는 가장 적절한
구현물이 될 수 있습니다. 이번 장에서의 접근은, 제네릭한 스타일의 뼈대 위에서 단위전략 기반의 디자인을 추구하며 이를 구현해 내는
방법으로 이루어질 것입니다.
-
언뜻 보기에, 싱글톤에 대한 요구는 static 멤버 함수와 static 멤버 변수를 사용하는 것으로 쉽게 대체될 수 있을 것
같습니다.
예제 코드 보기
- class Font { ... };
- class PrinterPort { ... };
- class PrintJob { ... };
-
- class MyOnlyPrinter
- {
- public:
- static void AddPrinterJob(PrintJob& newJob)
- {
- if (printQueue_.empty() && printingPort_.available())
- {
- printingPort_.send(newJob.Data());
- }
- else {
- printQueue_.push(newJob);
- }
- }
-
- private:
-
- static std::queue<PrintJob> printQueue_;
- static PrinterPort printingPort_;
- static Font defaultFont_;
- };
-
- PrintJob somePrintJob("MyDocument.txt");
- MyOnlyPrinter::AddPrinterJob(somePrintJob);
예제 코드 접기
그러나 이 해법은 특정 상황에서는 여러 가지 약점을 가지게 됩니다. 가장 큰 문제는 static으로
선언된 함수는 가상 함수가 될 수 없다는 것입니다. 따라서 MyOnlyPrinter의 클래스 코드를 직접 수정하지 않으면
그 기능에 필요한 수정을 가할 수가 없게 됩니다.
또한, 초기화 코드나 정리(cleanup) 코드를 작성하기가 어렵다는 문제도 있습니다. 초기화 작업이나 정리 작업은 결코 사소한
일이 아닙니다. 예를 들어, defaultFont_나 printingPort_의 속도에 따라 다르게 주어지는 경우도 생각해 볼 수 있을
것입니다.
따라서, 우리가 구현하는 싱글톤은 중복된 생성을 방지하면서, 유일한 객체를 생성하고, 또 그것을 관리하는 데 집중적으로 관여하게
될 것입니다.
-
때때로, C++에서 싱글톤은 다음과 같은 기법을 이용하여 구현되곤 합니다.
싱글톤 예제 코드 보기
- class Singleton
- {
- public:
- static Singleton* Instance(void)
- {
- if (!pInstance_) {
- pInstance_ = new Singleton;
- }
- return pInstance_;
- }
-
- ... 동작 코드 ...
-
- private:
- Singleton(void);
- Singleton(const Singleton&);
-
-
- static Singleton* pInstance_;
- };
-
- Singleton* Singleton::pInstance_ = 0;
싱글톤 예제 코드 접기
모든 생성자가 private로 선언되어 있기 때문에 사용자의 코드는 Singleton 객체를 직접 생성할 수 없습니다. 대신
Singleton 자신의 멤버 함수 만이 자기 자신의 객체를 생성할 수 있습니다. 이와 같이, Singleton 객체의 유일성은
컴파일 시간에 미리 보장받을 수 있습니다. 이것이 바로 C++를 이용한 싱글톤 디자인 패턴의 핵심입니다.
만일 이것이 전혀 사용되지 않는다면(Instance 함수를 호출하는 코드가 전혀 없다면), Singleton 객체는 생성조차
되지 않을 것입니다. 이러한 최적화의 대가로 필요한 것은 Instance 함수 시작 부분에서 포인터의 유효성을 검사하는
것뿐입니다. 이러한 방법은, Singleton이 그 생성 작업에 큰 부하를 요구하는 반면, 사용 빈도는 그다지 높지 않을 경우에,
그 장점이 드러나게 됩니다.
만일 앞의 예에서 pInstance_ 포인터를 Singleton 객체로 교체하여 더 단순화시키려 한다면, 그것은 불행하게도 실패로
귀결되는 시도가 될 것입니다.
잘못된 싱글톤 예제 코드 보기
- class Singleton
- {
- public:
- static Singleton* Instance(void)
- {
- return &instance_;
- }
-
- int DoSomething(void);
-
- private:
- static Singleton instance_;
- };
-
- Singleton Singleton::pInstance_;
잘못된 싱글톤 예제 코드 접기
이것은 좋은 방법이 아닙니다. instance_가 Singleton에서 static으로 선언된 멤버라 하더라도, 위의 두 버전에는
중요한 차이점이 존재합니다. pInstance_가 가리키는 Singleton 객체는 명확하게 초기화 시점이 정해져 있습니다.
그것은 Singleton::Instance() 함수가 처음으로 호출되는 순간입니다. 반면에, instance_는 초기화 시점이
명확하게 정해져 있지 않습니다.
C++은 서로 다른 컴파일 단위에 위치한 정적 객체(함수 내의 정적 지역 객체 제외)의 초기화 순서를 정해 놓고 있지 않습니다.
그리고 이것은 중요한 문제의 원인이 됩니다. 다음 코드의 경우를 생각해 보시기 바랍니다.
초기화 순서에 따른 문제 예제 코드 보기
-
Singleton 객체의 유일성을 보장하는 데 몇 가지의 언어적 테크닉이 필요하게 됩니다. 우리는 이미 그 중 몇 가지를 사용해
보았습니다. 그것은 바로 기본 생성자와 복사 생성자를
private로 선언하는 것입니다.
만일 복사 생성자를 정의하지 않게 되면, 컴파일러가 알아서 public으로 선언된 복사 생성자를 선언해 줄 것입니다. 하지만 복사
생성자를 명시적으로 선언해 주게 되면, 컴파일러의 자동 생성 기능은 동작하지 않으며, 따라서 그 생성자를 private 영역에
선언한 것이 일종의 트릭으로 작용하여 에러를 유발시키게 됩니다.
또 다른 작은 개선점으로는, Instance가 포인터 대신 참조값을 반환하도록 하는 문제가 있을 것입니다. Instance가 포인터를
반환하게 되면 고의든 아니든 간에 그것을 삭제하여 문제가 발생할 수 있습니다.
컴파일러에 의해 백그라운드 작업으로 생성된 또 하나의 멤버 함수는 바로 대입 연산자입니다. Singleton 객체의 대입 연산자는
오직 자신을 스스로에게 대입하는 역할밖에 수행할 수 없습니다(Singleton 객체는 단 하나뿐이므로). 그리고 그러한 연산은
아무런 의미도 갖지 못합니다. 따라서, 대입 연산자 역시 private로 선언하고, 그 구현에 대해서는 신경쓰지 않는 것이 좋을
것입니다.
마지막 남은 보호 장치는 소멸자를 private로 선언하는 것입니다. 이러한 조치는 Singleton 객체를 가지고 있는 클라이언트
코드가 이에 대한 delete 연산을 호출하는 우연한 사고를 막기 위한 것입니다.
여기에 열거된 조치들이 취해지고 나면, Singleton의 인터페이스는 다음과 같이 표시될 것입니다.
여러 조치들이 취해진 싱글톤 인터페이스 예제 코드 보기
- class Singleton
- {
- static Singleton& Instance(void);
-
- ... 동작 코드 혹은 그에 대한 정의 ...
-
- private:
- Singleton(void);
- Singleton(const Singleton&);
- Singleton& operator=(const Singleton&);
- ~Singleton(void);
- };
여러 조치들이 취해진 싱글톤 인터페이스 예제 코드 접기
-
Singleton은 언제 자신의 인스턴스를 파괴해야 할까요? John Vlissides의 저서 『Pattern Hatching』(1998)에
의하면, 이 문제는 매우 고통스러운 문제인 것으로 보입니다.
실질적으로, Singleton이 소멸되지 않는다고 해서 메모리의 누수 현상이 문제가 되지는 않습니다. Singleton 객체는
어플리케이션이 종료할 때까지 언제나 접근할 수 있으며, 최신 운영 체제들은 프로세스의 종료 시 그 곳에 할당된 모든 메모리를
완벽하게 해제시킬 수 있습니다.
어쨌든, 누수되는 것이 한 가지 있기는 합니다. 그것은 바로 리소스의 누수 현상입니다.
Singleton이 할당받은 네트워크 연결이나, 운영체제가 제공하는 뮤텍스에 대한 핸들, 혹은 그 밖의 프로세스 간 통신 수단,
그리고 프로세스 외부의 CORBA나 COM 객체에 대한 참조 등은 누수가 일어날 수 있습니다.
이를 해결하기 위한 유일하고 올바른 해결책은 애플리케이션이 종료하는 시점에서 Singleton 객체를 소멸시키는 것입니다.
Singleton의 파괴와 관련된 문제의 가장 간단한 해결책은 정적 지역 변수를 이용하는 것입니다.
이 단순하면서도 우아한 구현법은 Scott Meyers에서 처음 소개되었습니다. 따라서 우리는 이것을
Meyers의 싱글톤이라 부릅니다.
Meyers의 싱글톤 예제 코드 보기
Meyers의 싱글톤은 컴파일러가 부리는 마법을 이용합니다. 함수 내부에 정의된 static 객체는 프로그램이 흐름이 그 정의를 처음
지나가게 될 때 초기화됩니다. 실행 시간에 초기화되는 static 변수와 컴파일 시간의 상수로 초기화되는 static 변수를 서로
혼동하지는 마시기 바랍니다. 예를 들어,
int Fun(void)
{
static int x = 100;
return ++x;
}
이런 경우에, x는 프로그램의 어떤 코드도 실행되기 이전인 실행 모듈의 로딩 시간에 초기화됩니다. 맨 처음 호출되었을 때에,
x의 값은 이미 예전부터 100인 상태인 것입니다. 이와 달리, 컴파일 시간의 상수로 초기화되는 경우가 아니라, 그 static
변수가 생성자를 지닌 객체 자신일 경우에는 그것은 프로그램의 흐름이 자신에 대한 정의 코드를 처음으로 지나가는 순간에
초기화 된다는 것입니다.
추가적으로, 컴파일러는 초기화 후에 런타임 코드가 그 변수의 파괴를 지원하도록 하는 코드를 생성시킵니다. 컴파일러가 생성해 낸
코드를 C++ 의사 코드로 나타내 본다면 아마 다음과 같을 것입니다(두 개의 언더바 "_" 문자로 시작하는 변수는 숨겨진 변수로
간주되어야 합니다. 말하자면, 그 변수들은 오직 컴파일러에 의해서만 다루어지는 존재라는 뜻입니다).
예제 코드 보기
- Singleton& Singleton::Instance(void)
- {
-
- extern void __ConstructSingleton(void* memory);
- extern void __DestroySingleton(void);
-
-
- static bool __initialized = false;
-
-
-
- static char __buffer[sizeof(Singleton)];
-
- if (!__initialized)
- {
-
-
-
- __ConstructSingleton(__buffer);
-
-
- atexit(__DestroySingleton);
-
- __initialized = true;
- }
- return *reinterpret_cast<Singleton*>(__buffer);
- }
예제 코드 접기
이 코드에서 핵심은 atexit 함수를 호출하는 부분입니다. 표준 C 라이브러리에서 제공하는 atexit 함수는 프로그램 종료 시에
자동으로 해당 함수가 호출될 수 있도록 예약해 주는 역할을 합니다. 그리고 그 호출은 후입선출(LIFO, Last In First Out)
방식의 순서에 따라 이루어지게 됩니다. atexit의 프로토타입은 다음과 같습니다.
// 함수 포인터를 넘겨 받아
// 성공할 경우 0을 반환하고,
// 에러가 발생할 경우 0이 아닌
// 값을 반환합니다.
int atexit(void (*pFun)(void));
한편, 컴파일러는 __buffer 메모리 영역에 위치한 Singleton 객체를 파괴시키는 일을 수행하는 __DestroySingleton이라는
함수를 생성해 냅니다. 그리고 이 함수의 주소를 atexit에게 넘겨주게 됩니다.
그러면 과연 atexit 함수는 어떤 식으로 동작을 하게 될까요? atexit에 대한 호출이 일어날 때 넘겨받은 인자는 C 런타임
라이브러리가 관리하는 내부적인 스택 영역에 저장됩니다. 애플리케이션이 종료되는 시점이 되면, 런타임 지원 코드가 atexit에
의해 예약된 함수들을 불러와, 차례대로 호출해 주게 됩니다.
Meyers의 싱글톤은 애플리케이션의 종료 시점에 Singleton을 파괴할 수 있는 가장 단순한 수단을 제공합니다. 그리고 이것은
대부분의 경우에서 원활하게 동작할 것입니다. 그러나 우리는 이 방법이 가지는 문제점을 연구할 것이며, 특별한 경우에 더 나은
개선점이나 아니면 또 다른 대안을 제시해 보도록 할 것입니다.
-
우리의 프로그램이 키보드, 디스플레이 장치, 그리고 로그(Log) 시스템, 이렇게 세 개의 Singleton 객체를 사용한다고
가정합시다.
Log 객체의 생성은 일정량의 오버헤드를 가져온다고 가정하도록 하겠습니다. 따라서, 실제로 에러가 발생할 경우에만 Log 객체의
인스턴스를 생성하는 것이 좋은 선택이 될 것입니다.
이 프로그램은 키보드나 디스플레이 객체의 생성이나 파괴 과정에서 발생한 에러도 모두 Log 객체를 통해 보고하게 될 것입니다.
우리가 이 세 가지 싱글톤 객체들을 Meyers의 싱글톤을 사용하여 구현한다고 가정해 봅시다. 그러면 프로그램은 올바르게 동작한다고
말할 수 없게 됩니다. 예를 들어, 키보드 객체가 성공적으로 생성된 후, 디스플레이 객체가 생성에 실패했다고 가정해 봅시다.
그러면 생성에 실패한 디스플레이 객체의 생성자가 Log 객체를 만들어 내고, 그 에러가 보고될 것입니다. 그리고 나서 애플리케이션은
수행을 중단하고 종료되려 할 것입니다. 이 종료 시점에서, Log 객체는 키보드 객체보다 먼저 파괴되어야 합니다(LIFO).
그런데 만일, 어떤 이유로 인해 키보드 객체가 정상적으로 해제되지 못하고 에러 Log를 보고하려 한다면, Log::Instance의
호출은 이미 파괴된 Log의 쭉정이를 반환하게 될 것입니다. 자, 이제 프로그램은 정의되지 않은 동작이라는 어두운 세계에 접어들게
됩니다. 이러한 문제를 일컬어 바로 "참조 무효화 현상"이라고 부릅니다.
이러한 상황에서는 키보드나 로그, 그리고 디스플레이 객체의 생성이나 소멸의 순서가 미리 알려져 있지 않습니다. 하지만 우리에게
필요한 것은 Log 객체만은 C++의 기본 규칙(마지막으로 생성된 객체가 먼저 가장 먼저 파괴된다는)을 따르지 않도록 하는 일입니다.
올바른 싱글톤이라면 최소한 무효화된 참조를 감지해 낼 수 있어야 합니다. Boolean
멤버변수인 destroyed_를 사용하여 객체의 소멸 여부를 추적하게 되면, 이러한 문제를 찾아낼 수가 있습니다.
그럼 이제 무효화된 참조를 감지해 낼 수 있는 Singleton을 구현해 보도록 합시다.
무효화된 참조를 감지해 낼 수 있는 Singleton 예제 코드 보기
- class Singleton
- {
- public:
- static Singleton& Instance(void)
- {
- if (!pInstance_)
- {
-
- if (destroyed_)
- {
- OnDeadReference();
- }
- else
- {
-
- Create();
- }
- }
- return pInstance_;
- }
-
- private:
-
-
- static void Create(void)
- {
-
- static Singleton theInstance;
- pInstance_ = &theInstance;
- }
-
-
- static void OnDeadReference(void)
- {
- throw std::runtime_error("Dead Reference Detected");
- }
-
- virtual ~Singleton(void)
- {
- pInstance_ = 0;
- destroyed_ = true;
- }
-
-
- static Singleton* pInstance_;
- static bool destroyed_;
-
- ... 사용 금지된 생성/소멸자 및 대입 연산자의 선언 ...
- };
-
- Singleton* Singleton::pInstance_ = 0;
- bool Singleton::destroyed_ = false;
무효화된 참조를 감지해 낼 수 있는 Singleton 예제 코드 접기
-
만일 앞 섹션에서 제시한 해결책을 KDL(키보드, 디스플레이 장치 및 로그 시스템)의 문제에 적용시켜 보면, 그 결과는 사실
만족스럽다고 말할 수 없습니다. 물론 우리는 정의되지 않은 동작은 피할 수 있었지만, 우리의 요구가 완전히 만족된 것은 아닙니다.
우리는 Log 객체가 언제 생성되었는지 상관없이, 그것을 언제나 사용할 수 있기를 바랍니다. 우리는 필요에 따라 Log 객체가
파괴된 경우, 다시 생성하길 원할 수도 있습니다. 이러한 내용이 바로 Phoenix 싱글톤 디자인 패턴의 기본 아이디어가 됩니다.
Phoenix 싱글톤을 static 변수를 이용하여 구현하는 것은 매우 간단합니다. 끊어진 참조가 발견될 경우, 새로운 Singleton
객체를 이전 객체가 차지하던 메모리 공간에 다시 만들면 됩니다(static으로 선언된 객체의 메모리 공간은 프로그램의 마지막 순간까지
유지됩니다). 그리고 여기에 다시 만들어진 새로운 객체의 파괴 함수를 atexit를 통해 등록되도록 하면 되는 것입니다.
Phoenix Singleton 예제 코드 보기
- class Singleton
- {
- ... 앞의 정의과 같음 ...
- void KillPhoenixSingleton(void);
- };
-
- void Singleton::OnDeadReference(void)
- {
-
- Create();
-
-
-
-
- new(pInstance_) Singleton;
-
-
- atexit(KillPhoenixSingleton);
-
-
- destroyed_ = false;
- }
-
- void Singleton::KillPhoenixSingleton(void)
- {
-
-
-
- pInstance_->~Singleton();
- }
-
-
Phoenix Singleton 예제 코드 접기
이제 Phoenix 싱글톤을 부활시키기 위해 재배치 생성자를 사용하게 되면, 그것을 파괴하는 데 더 이상 컴파일러가 관여할 수
없게 됩니다. 컴파일러가 부리는 마법은 오직 초기에 생성된 static 멤버 변수에만 적용될 수 있습니다. 대신에, 우리는 그
소멸 코드를 수동으로 작성하고, 수동으로 불러주도록 해야 합니다. 위의 예에서 이것을 보장해 주는 코드가 바로
atexit(KillPhoenixSingleton)인 것입니다.
-
앞 섹션에서 주어진 코드와 Loki 라이브러리가 제공하는 실제 코드를 비교해 보면, 하나의 차이점을 발견하게 됩니다.
Loki의 코드에서는 다음과 같이 atexit를 호출하는 코드 주변이 #ifdef 전처리기 명령어로 감싸져 있습니다.
Loki의 atexit 호출 부분 예제 코드 보기
만일 #define ATEXIT_FIXED의 정의를 사용하지 않는다면, 새롭게 생성된 Phoenix 싱글톤은 파괴되지 않고, 대신에
메모리 누수 현상이 발생하게 될 것입니다.
이러한 방법은 불행하게도 C++의 표준에서 언급되지 않은 어떤 특징과 관련이 있습니다. C++ 표준은 다른 atexit
예약 작업의 영향으로 호출된 함수 내에서, 다시 atexit를 이용하여 예약을 시도할 경우에 대해서 아무런 기술도
해 주지 않고 있습니다.
표준 동작 범위를 벗어난 예제 코드 보기
- #include
-
- void Bar(void)
- {
- ...
- }
-
- void Foo(void)
- {
- std::atexit(Bar);
- }
-
- int main(void)
- {
- std::atexit(Foo);
-
- return 0;
- }
표준 동작 범위를 벗어난 예제 코드 접기
C/C++의 표준 라이브러리들은 라이브러리 내부에 대한 재진입 코드에 대해 안전하지 못합니다. 위의 코드에서 Bar가 더
나중에 예약되었으므로 Bar가 Foo보다 먼저 호출되어야 합니다. 하지만 그것이 예약되는 시점을 따져보면 실제로 Bar가
먼저 호출되기에는 너무 늦은 시점입니다. 왜냐하면 Foo는 이미 호출된 상태이기 때문이지요.
컴파일러 제작사가 이런 문제의 해결책을 찾아내는 데 어느 정도의 시간이 필요합니다. 따라서 지금 이 시점에서는,
임시 방편으로 #define 정의를 사용하였습니다. 어떤 종류의 컴파일러에서는, Phoenix 싱글톤이 부활하게 되는
경우에 때때로 메모리 누수 현상이 발생할 수 있습니다.
-
Phoenix 싱글톤은 다양한 경우에 만족스러운 해결책이 될 수 있지만, 여전히 몇 가지 불리한 점을 가지고 있습니다.
Phoenix 싱글톤은 일반적으로 정의된 기본적인 싱글톤의 생명주기 법칙을 위반하고 있습니다. 만일 어떤 상태 값을 유지해야
하는 싱글톤의 경우라면, 파괴와 재생성의 과정에서 기억된 상태가 소실될 수 있는 것입니다. Phoenix 싱글톤 전략을 사용하는
경우, 싱글톤 객체의 파괴와 재생성 과정에서 그 상태를 유지하도록 하는 데 특별한 주의가 필요할 것입니다.
우리는 다양한 싱글톤 객체들의 생명주기를 제어할 수 있는 손쉬운 방법이 필요합니다. 만일 그것이 가능하다면, 우리는 KDL의
문제를 Log 객체에게 디스플레이나 키보드 객체보다 더 긴 수명을 부여함으로써 쉽게 해결할 수 있습니다.
이 문제는 비단 싱글톤의 경우뿐만 아니라, 일반적인 전역 객체들의 경우에도 해당됩니다. 여기서 사용된 개념은
"수명 제어"라고 불릴 수 있으며, 이것은 사실 싱글톤과는 독립된 개념입니다.
이것은 제어될 객체가 싱글톤이든, 동적으로 할당된 또 다른 전역 객체이든 상관하지 않습니다. 그러면 우리는 다음과 같은
코드를 작성할 수가 있을 것입니다.
수명 제어 예제 코드 보기
- class SomeSingleton { ... };
-
- class SomeClass { ... };
-
- someClass* pGlobalObject(new SomeClass);
-
- int main(void)
- {
- SetLongevity(&SomeSingleton().Instance(), 5);
-
-
-
- SetLongevity(pGlobalObject, 6);
- ...
-
- return 0;
- }
-
-
-
-
- template <typename T>
- void SetLongevity(T* pDynObject, unsigned int longevity);
수명 제어 예제 코드 접기
여기에 주의할 점은 일반적인 전역 객체나 static 객체, 혹은 자동 객체들과 같이 컴파일러에 의해 그 생명주기가 제어되는
객체들에게는 SetLongevity 함수를 적용해선 안 된다는 것입니다. 컴파일러가 이미 그러한 객체들을 파괴하는 코드를
생성하기 때문에, 그것들에 대해 SetLongevity를 호출하게 되면 두 번 중복하여 객체를 파괴하는 결과를 가져옵니다.
SetLongevity는 new 연산자를 사용하여 할당된 객체에 적용되도록 만들어졌습니다. 또한, 어떤 객체에 대해 SetLongevity를
호출하게 되면, 사용자 코드가 직접 그 객체를 delete시켜서는 안됩니다.
이 문제에 대한 다른 대안으로는 종속성 관리자를 만드는 방법이 있을 수 있습니다. DependencyManager는 다음과 같이
제네릭한 스타일의 SetDependency 함수를 노출하게 될 것입니다.
종속성 관리자 예제 코드 보기
- class DependencyManager
- {
- public:
- template <typename T, typename U>
- void SetDependency(T* dependent, U& target);
- ...
- };
-
종속성 관리자 예제 코드 접기
이 DependencyManager에 기초한 방법은 한 가지 중요한 결점을 가지고 있습니다. 그것은 바로 그 두 객체가 실존해야
한다는 것입니다. 이것은 즉, 프로그래머가 키보드 객체와 Log 객체 사이의 종속 관계를 설정하고자 한다면, Log 객체가
반드시 생성된 상태여야 한다는 뜻입니다.
이 문제를 해결하려고 시도해 보면, 먼저 Log 객체의 생성자에 Keyboard-Log간 종속 관계를 설정해 주어야 합니다.
하지만 이런 식으로, 키보드와 로그 객체 사이의 관계가 연결되는 것은 바람직하지 못합니다. Keyboard 클래스는 Log
클래스의 정의를 필요로 합니다(왜냐하면, Keyboard 클래스 코드 내부에서 Log 객체를 사용하기 때문입니다). 그리고
다시 Log 역시 Keyboard 클래스의 정의를 필요로 하게 됩니다(Log 클래스 코드에서 종속 관계를 설정해 주기 위해서입니다).
이와 같은 현상을 순환 종속 관계라 부릅니다. 그리고 이러한 순환 종속 관계는 마땅히 피해가야만 합니다.
그렇다면, 다시 "수명 제어"라는 논점으로 되돌아가도록 합시다. SetLongevity가 atexit와 잘 어울려 동작해야 하기 때문에,
우리는 이 두 함수 간의 관계를 주의 깊게 정의해 주어야 합니다. 예를 들어, 다음 프로그램의 예에서 소멸자 호출의 정확한
순서를 정의해 보도록 합시다.
예제 코드 보기
- class SomeClass { ... };
-
- int main(void)
- {
-
- SomeClass* pObj1 = new SomeClass;
- SetLongevity(pObj1, 5);
-
-
-
- static SomeClass obj2;
-
-
-
- SomeClass* pObj3 = new SomeClass;
- SetLongevity(pObj3, 6);
-
-
-
- return 0;
- }
예제 코드 접기
이 세 객체들 간의 합당한 파괴 순서를 정의하는 것은 쉬운 일이 아닙니다. 왜냐하면, atexit 함수를 사용하는 것과 달리, 우리는
컴파일러가 생성한 런타임 지원 코드가 관리해 주는 숨겨진 스택을 다룰 수 있는 방법을 전혀 가지고 있지 못하기 때문입니다.
주어진 제한점들을 주의 깊게 분석해 보면, 우리는 다음과 같은 결론에 도달합니다.
-
SetLongevity에 대한 호출이 발생할 때마다 atexit에 대한 호출이 이루어져야 합니다.
-
더 적은 수명 값을 지닌 객체는 더 큰 수명 값을 지닌 객체보다 먼저 파괴되어야 합니다.
-
같은 수명 값을 가지는 객체들은 C++의 기본 규칙에 따라 파괴되어야 합니다. 즉, 더 나중에 생성된 객체가
더 먼저 파괴되어야 합니다.
예제 코드에서, 규칙에 의하면 주어진 객체들은 *pObj1, obj2, pObj3의 순서로 파괴되는 것이 보장됩니다.
SetLongevity에 대한 첫 번째 호출은 *pObj3을 파괴시키기 위한 atexit 호출을 발생시킬 것이며, 이에 따라 두 번째
SetLongevity 호출은 *pObj1을 파괴시키기 위한 atexit 호출을 발생시킬 것입니다.
-
일단 SetLongevity의 기능 명세가 완성되고 나면, 그 구현 과정은 그리 어려운 일이 아닙니다. SetLongevity는
접근 불가능한 atexit stack과는 별도로, 숨겨진 우선순위 큐를 관리하게 됩니다.
그리고 SetLongevity는 각 순서마다 동일한 함수 포인터를 넘겨 주면서 atexit 함수를 호출하게 됩니다. 그 함수
포인터는 바로 그 우선순위 큐로부터 하나의 원소를 꺼내어 삭제하는 기능을 하는 함수를 가리킵니다.
이것의 핵심 코드는 우선순위 큐 데이터 구조입니다. 같은 수명 값이 주어질 경우에는, 이 큐가 일종의 stack으로
동작하게 됩니다. 따라서, 우리는 std::priority_queue 클래스를 사용할 수가 없습니다. 왜냐하면, 그것은
같은 우선순위 원소에 대해 후입선출(LIFO)의 특성을 보장해 주지 않기 때문입니다.
이 데이터 구조가 저장하는 각 원소들은 LifetimeTracker 자료형에 대한 포인터입니다. 이것의 인터페이스는 순수 가상 소멸자와
비교 연산자로 구성되어 있습니다.
LifetimeTracker 예제 코드 보기
- namespace Private
- {
- class LifetimeTracker
- {
- public:
- LifetimeTracker(unsigned int x) : longevity_(x) {}
- virtual ~LifetimeTracker(void) = 0;
- friend inline bool Compare(
- unsigned int longevity,
- const LifetimeTracker* p)
- { return p->longevity_ > longevity; }
-
- private:
- unsigned int longevity_;
- };
- }
-
- inline LifetimeTracker::~LifetimeTracker(void) {}
-
-
-
-
- namespace Private
- {
- typedef LifetimeTracker** TrackerArray;
- extern TrackerArray pTrackerArray;
- extern unsigned int elements;
- }
LifetimeTracker 예제 코드 접기
Tracker 자료형의 인스턴스는 오직 하나만 존재합니다. 따라서, pTrackerArray는 지금까지 논의된 모든 싱글톤의 문제들을
동일하게 가지게 됩니다. 이 문제를 해결하기 위해서는, SetLongevity가 pTrackerArray를 저순위 함수인 std::malloc
함수군(malloc, realloc, 그리고 free 함수)을 이용하여 주의 깊게 다루어 줄 필요가 있습니다. 다행스럽게도, 이
할당기(C의 힙 공간 할당기)는 애플리케이션의 전체 생명주기 동안 항상 올바르게 동작해 줄 것이라고 언어적 표준에 의해 보장되어
있습니다. SetLongevity는 구체적인 Tracker 객체를 생성하고, 그것을 stack에 넣고, atexit호출을 통해 등록해 주기만
하면 됩니다.
다음의 코드는 Tracker에 의해 추적된 객체를 파괴하는 것과 관련하여 함수자의 개념을
도입하고 있습니다. 왜냐하면, 객체를 해제하는 데 항상 delete 연산자만을 사용하지 않기 때문입니다.
SetLongevity 예제 코드 보기
- namespace Private
- {
-
- template <typename T>
- struct Deleter
- {
- static void Delete(T* pObj)
- { delete pObj; }
- };
-
-
- template <typename T, typename Destroyer>
- class ConcreteLifetimeTracker : public LifetimeTracker
- {
- public:
- ConcreteLifetimeTracker(T* p,
- unsigned int longevity,
- Destroyer d)
- : LifetimeTracker(longevity),
- , pTracker_(p)
- , destroyer_(d)
- {}
-
- ~ConcreteLifetimeTracker(void)
- {
- destroyer_(pTracker_);
- }
-
- private:
- T* pTracker_;
- Destroyer destroyer_;
- };
-
- void AtExitFn(void);
- }
-
- template <typename T, typename Destroyer>
- void SetLongevity(T* pDynObject, unsigned int longevity,
- Destroyer d = Private::Deleter<T>::Delete)
- {
- TrackerArray pNewArray = static_cast<TrackerArray>(
- std::realloc(pTrackerArray, sizeof(T) * (elements + 1)));
-
- if (!pNewArray) throw std::bad_alloc();
-
- pTrackerArray = pNewArray;
-
- LifetimeTracker* p = new ConcreteLifetimeTracker<T, Destroyer>(
- pDynObject, longevity, d);
-
- TrackerArray pos = std::upper_bound(
- pTrackerArray, pTrackerArray + elements, longevity, Compare);
-
- std::copy_backward(pos, pTrackerArray + elements,
- pTrackerArray + elements + 1);
-
- *pos = p;
- ++elements;
- std::atexit(Private::AtExitFn);
- }
SetLongevity 예제 코드 접기
위의 함수는 새로 생성된 ConcreteLifetimeTracker에 대한 포인터를 정렬된 상태를 유지하면서 pTrackerArray가 가리키는
배열에 삽입해 줍니다. 그리고 이와 함께 에러나 오류 발생에 대한 처리도 이루어집니다.
AtExitFn 함수는 가장 작은 수명 값을 가지는 객체(즉, 배열의 마지막 원소)를 뽑아내어 그것을 삭제하게 됩니다.
LifetimeTracker에 대한 포인터의 삭제는 ConcreteLifetimeTracker의 소멸자를 부르게 되며, 이에 따라
그 소멸자는 추적되는 실제 객체를 파괴하게 됩니다.
AtExitFn 예제 코드 보기
- static void AtExitFn(void)
- {
- assert(elements > 0 && pTrackerArray != 0);
-
-
- LifetimeTracker* pTop = pTrackerArray[elements - 1];
-
-
-
-
- pTrackerArray = static_cast<TrackerArray>(std::realloc(
- pTrackerArray, sizeof(T) * --elements));
-
-
- delete pTop;
- }
AtExitFn 예제 코드 접기
여기에 사용된 트릭은, AtExitFn이 실제 해당 객체를 삭제하기 이전에 스택과 관련된
연산을 완료하게 만드는 것입니다. 이렇게 하면, 파괴되는 객체가 다른 객체를 생성하여 스택에 그 원소를 집어넣는 일도
가능해집니다. 이런 일은 언뜻 보기에 불필요해 보일 수도 있지만, 키보드 객체의 소멸자가 Log 객체를 사용하고자 할 때에
실제로 발생하게 되는 경우입니다.
수명 제어가 가능한 싱글톤은 SetLongevity를 다음과 같은 방법으로 사용하게 됩니다.
SetLongevity 사용 예제 코드 보기
- class Log
- {
- public:
- static void Create(void)
- {
-
- pInstance_ = new Log;
-
-
- SetLongevity(*this, longevity_);
- }
-
-
-
-
- private:
-
- static const unsigned int longevity_ = 2;
- static Log* pInstance_;
- };
-
-
SetLongevity 사용 예제 코드 접기
아직 해결하지 못한 문제가 있습니다. 여러분의 애플리케이션이 다중 스레드를 사용하게 되면 어떻게 될까요?
-
Singleton은 스레드와 함께 다루어져야 합니다. 우리가 이제 막 시작된 애플리케이션을 가지고 있고, 두 개의 스레드가
다음의 싱글톤 객체에 접근한다고 가정해 봅시다.
예제 코드 보기
- Singleton& Singleton::Instance(void)
- {
- if (!pInstance_)
- {
- pInstance_ = new Singleton;
- }
- return *pInstance_;
- }
-
-
-
-
-
-
예제 코드 접기
노련한 스레드 프로그래머들은 여기서 고전적인 경쟁 상태(race condition)를 발견해 낼 것입니다. 싱글톤 디자인 패턴은
스레드의 요구를 만족시켜 주어야 합니다. 싱글톤 객체란 분명히 전역적으로 공유되는 자원입니다. 그리고 전역에 정의된 모든
공유 자원은 경쟁 상태나 다중 스레딩의 문제에 노출될 가능성을 항상 가지고 있습니다.
-
앞 섹션의 예제 코드를 멀티 스레드 환경에서도 잘 동작하도록 고쳐 봅시다. 고전적인 방법으로 다음과 같은 코드를
만들 수 있을 것입니다.
고전적인 해결법 예제 코드 보기
- Singleton& Singleton::Instance(void)
- {
-
-
- Lock guard(mutex_);
- if (!pInstance_)
- {
- pInstance_ = new Singleton;
- }
- return *pInstance_;
- }
고전적인 해결법 예제 코드 접기
이제 모든 것은 부드럽게 잘 동작하게 되었습니다. 하지만, 여기서의 문제점은 바로 효율성의 부재입니다.
경쟁 상태라는 것이 여기서는 오직 전체 생명주기 상에서 단 한 번밖에 일어날 수 없는 일임에도 불구하고
Instance에 대한 모든 호출들은 해당 동기화 객체에 대한 잠금/해제 작업을 일일이 반복하게 됩니다.
이러한 동작은 대개 if (!pInstance_)를 통한 단순한 검사 작업에 비해 상당히 무거운 부하를 발생시킵니다.
이러한 오버헤드를 피할 수 있는 해법으로는 다음과 같은 코드가 가능할 것 같습니다.
잘못된 해법 예제 코드 보기
- Singleton& Singleton::Instance(void)
- {
- if (!pInstance_)
- {
- Lock guard(mutex_);
- pInstance_ = new Singleton;
- }
- return *pInstance_;
- }
잘못된 해법 예제 코드 접기
이제 오버헤드는 사라졌지만, 우려했던 경쟁 상태가 다시 가능해졌습니다. 이 코드는 앞 섹션의 예제 코드에서 발생할 수
있던 문제점을 그대로 가지고 있습니다.
이것은 해결책이 없는 어려운 문제인 것처럼 보이지만, 사실은 굉장히 간단하고 우하한 해결책이 있습니다. 그것은 바로
"이중 검사 동기화 패턴"이라 불리는 방법입니다.
이중 검사 동기화 패턴 예제 코드 보기
- Singleton& Singleton::Instance(void)
- {
- if (!pInstance_)
- {
- Lock guard(mutex_);
- if (!pInstance_)
- {
- pInstance_ = new Singleton;
- }
- }
- return *pInstance_;
- }
-
-
-
-
-
-
-
-
-
이중 검사 동기화 패턴 예제 코드 접기
안타깝게도 이중 검사 동기화 패턴은 이론적으로는 문제가 없지만, 일부 시스템에서는 항상 올바르게 동작하지 않을 수
있습니다. 특정한 대칭적 다중 CPU 환경(말하자면 느슨한 메모리 모델의 특징을 가진 환경)에서는 위에 씌여진 코드가
실행되면서 메인 메모리에 대한 쓰기 작업이 차례대로 실행되지 않고, 한꺼번에 뭉쳐서 발생할 수가 있습니다.
이렇게 메모리에 대한 쓰기 작업이 한꺼번에 발생하게 되면, 실제로 각 작업이 발생한 순서대로가 아니라, 메모리 주소에
대해 오름차순으로 그 쓰기 작업이 수행되게 됩니다. 이런 이유로 인해, 특정 순간에 어떤 CPU가 바라보는 메모리는
다른 CPU에게는 올바르지 않은 순서로 쓰기 작업이 진행된 것처럼 보일 수가 있습니다. 구체적으로, 어떤 CPU 상에서
수행된 pInstance_에 대한 대입 연산이 Singleton 객체가 완전히 초기화되기 전에 일어날 수 있다는 것입니다.
결론적으로, 이중 검사 동기화 패턴을 구현하기 전에, 프로그래머는 자신이 사용하는 컴파일러의 상세 명세 문서를
체크해 보아야 합니다. 일반적으로는 플랫폼이 이에 대한 대안으로, 동시성 문제의 해결 방안을 별도로 제공하지만,
이런 방법은 포팅 작업의 문제성을 안게 됩니다. 우리가 할 수 있는 최소한의 일은 volatile 한정자를
pInstance_ 옆에 붙여주는 것이 될 것 같습니다. 올바른 컴파일러라면 volatile이 적용된 객체에 대해서는
정확하고, 위험하지 않은 코드를 생성해 내야만 합니다.
-
Loki가 제공하고 있는 SingletonHolder 클래스 템플릿은 사용자가 싱글톤 디자인 패턴을 사용하는 것을 보조해 주는
일종의 싱글톤 컨테이너입니다. SingletonHolder는 1장에서 다룬 단위전략 기반의 디자인
방식을 따라 사용자 정의 싱글톤 객체를 정의할 수 있도록 고안된 다목적의 컨테이너입니다.
이번 장에서는 서로 간에 독립적인 소수의 주제를 다루어 보았습니다. 그렇다면, 어떻게 싱글톤이 더 이상 가공 없이
그렇게 많은 경우를 구현할 수 있게 될까요? 그 열쇠는 바로 1장에서 논의했던 바와 같이
싱글톤을 단위전략으로 쪼개는 작업입니다. 여기서, 싱글톤 구현물의 통합이란, do-it-all 인터페이스를 갖는 클래스를
말하는 것이 아닙니다. 우리는 궁극적으로 오직 필요한 특징들만이 실제로 생성된 코드에 포함되도록 할 것입니다.
-
지금까지 우리는 생성의 문제, 생명주기의 문제, 그리고 스레드의 문제를 인지하였습니다. 이 세 가지는 싱글톤의
개발에 있어서 가장 중요한 사항입니다. 따라서 세 가지의 단위전략은 다음과 같이 정의될 수 있을 것입니다.
-
Creation. 싱글톤은 다양한 방법으로 생성이 가능합니다.
일반적으로, Creation 단위전략은 new 연산자를 사용하여 객체를 생성합니다. 객체의 생성
과정을 단위전략으로 분리하는 것은 필수적입니다. 왜냐하면, 그렇게 함으로써 다형성 객체에 대한
생성이 가능해지기 때문입니다.
-
Lifetime. 다음에 열거된 생명주기 단위전략이 가능합니다.
-
C++의 기본 법칙을 따름(선입후출, Last In First Out)
-
재생(Phoenix 싱글톤)
-
사용자 제어(수명 제어 싱글톤)
-
영원불멸(메모리가 누수되는 싱글톤으로 이 객체는 절대로 파괴되지 않음)
-
ThreadingModel. 싱글톤이 단일 스레드인지, 표준적인 다중
스레드용인지(뮤텍스와 이중 검사 동기화 패턴을 사용하는), 아니면 포팅 불가능한 스레드 모델을 사용하는지를
나타냅니다.
각각의 싱글톤 구현물들은 그 유일성을 강제하기 위해서 앞서 언급된 동일한 예방 조치를 사용합니다.
-
이제 SingletonHolder가 자신의 단위전략에게 부과하는 필요한 요구 사항들을 정의해 보도록 합시다.
Creation 단위전략은 객체를 생성하고 파괴하는 일을 담당합니다. 따라서, Creation은 이에 동반하는 두 개의
함수를 노출해야 합니다. 만일, Creator<T>가 Creation 단위전략을 따르는 클래스라고 가정하면,
Creator<T>는 반드시 다음의 호출을 지원해야 합니다.
T* pObj =
Creator<T>::Create();
Creator<T>::Destroy(pObj);
Create와 Destroy가 반드시 Creator가 갖는 두 개의 static 멤버여야 함을 주의하시기 바랍니다.
싱글톤은 Creator 객체를 기억하지 않습니다. 이렇게 하면 이 싱글톤의 생명주기가 영원할 수 있도록 만들
수 있습니다.
Lifetime 단위전략의 기능은 간단히 애플리케이션의 생명주기 안에서 특정 시간에 싱글톤 객체를 파괴하는 것으로
요약될 수 있습니다. 여기에 더하여, Lifetime 단위전략은 애플리케이션이 싱글톤 객체에 대한 생명주기의 법칙을
어겼을 경우, 어떤 동작을 취해야 하는지도 결정하게 됩니다. 따라서,
-
만일 싱글톤이 C++ 기본 규칙에 따라 파괴되길 원하면, Lifetime은 atexit와 비슷한 메커니즘을
사용하게 됩니다.
-
Phoenix 싱글톤의 경우에, Lifetime은 역시 atexit와 비슷한 메커니즘을 사용하며, 단지 싱글톤
객체의 재생성이 가능하다는 점만 차이가 있습니다.
-
수명 제어 싱글톤의 경우에, Lifetime은 6.7 섹션과
6.8 섹션에서 설명된 바와 같이 SetLongevity 함수에 대한 호출을
필요로 하게 됩니다.
-
영원불멸의 싱글톤일 경우에, Lifetime은 아무런 동작도 수행하지 않습니다.
결론적으로 Lifetime 단위전략은 두 개의 함수를 규정짓게 됩니다. 첫째로, ScheduleDestruction은 객체 파괴와
관련된 적합한 시점을 설정하는 데 사용되며, OnDeadReference는 참조 무효화 현상이 발생했을 때에 어떤 동작을
취해야 하는지를 결정하는 데 사용됩니다.
만일 Lifetime<T>가 Lifetime 단위전략을 구현하고 있는 클래스라고 한다면, 다음의 구문이 의미를
가지게 됩니다.
예제 코드 보기
- void (*pDestructionFunction)(void);
- ...
- Lifetime<T>::ScheduleDestruction(pDestructionFunction);
- Lifetime<T>::OnDeadReference(void);
예제 코드 접기
ScheduleDestruction 멤버 함수는 파괴 작업을 수행하는 실제 함수에 대한 포인터를 받습니다. 이런 방법으로,
우리는 Lifetime 단위전략을 Creation과 함께 조합시킬 수 있습니다.
OnDeadReference는 Phoenix 싱글톤이 아닌 모든 경우에 대해 예외를 발생시킵니다. Phoenix 싱글톤의 경우에는
여기서 아무 문제도 발생하지 않습니다.
ThreadingModel 단위전략은 appendix에서 잘 설명되고 있습니다. SingletonHolder는
객체 수준에서의 동기화를 지원하지 않으며, 오직 클래스 수준의 동기화만을 지원합니다. 이것은 싱글톤의 경우에는 어쨌든
오직 하나의 객체만이 존재하기 때문입니다.
-
이제, 드디어 SingletonHolder 클래스 템플릿을 정의해야 할 때가 왔습니다. 우리는 템플릿 인자 T를 싱글톤으로
작용하여야 하는 실제 자료형으로 간주할 것입니다.
SingletonHolder 예제 코드 보기
- template
- <
- class T,
- template <class> class CreationPolicy = CreateUsingNew,
- template <class> class LifetimePolicy = DefaultLifetime,
- template <class> class ThreadingModel = SingleThreaded
- >
- class SingletonHolder
- {
- public:
- static T& Instance(void);
-
- private:
-
- static void DestroySingleton(void);
-
-
- SingletonHolder(void);
- ...
-
-
- typedef ThreadingModel<T>::VolatileType InstanceType;
- static InstanceType* pInstance_;
- static bool destroyed_;
- };
-
-
-
-
-
-
-
-
-
-
- template <class T> class SingleThreaded
- {
- ...
-
- public:
- typedef T VolatileType;
- };
-
-
SingletonHolder 예제 코드 접기
이제 세 가지의 단위전략을 한데 엮는 Instance 멤버 함수를 정의하도록 합시다.
Instance 멤버 함수 예제 코드 보기
- template <...>
- T& SingletonHolder<...>::Instance(void)
- {
- if (!pInstance_)
- {
- typename ThreadingModel<T>::Lock guard;
- if (!pInstance_)
- {
- if (destroyed_)
- {
- LifetimePolicy<T>::OnDeadReference();
- destroyed_ = false;
- }
- pInstance_ = CreationPolicy<T>::Create();
- LifetimePolicy<T>::ScheduleDestruction(&DestroySingleton);
- }
- }
- return *pInstance_;
- }
-
-
Instance 멤버 함수 예제 코드 접기
DestroySingleton은 단순히 Singleton 객체를 파괴시키고, 할당된 메모리를 깨끗이 청소해 주며, destroyed_의
값을 true로 설정합니다. SingletonHolder는 절대로 DestroySingleton을 직접 호출하지 않고, 단지
"LifetimePolicy<T>::ScheduleDestruction에
DestroySingleton의 주소만을 넘겨줍니다.
DestroySingleton 멤버 함수 예제 코드 보기
- template <...>
- void SingletonHolder<...>::DestroySingleton(void)
- {
- assert(!destroyed_);
- CreationPolicy<T>::Destroy(pInstance_);
- pInstance_ = 0;
- destroyed_ = true;
- }
DestroySingleton 멤버 함수 예제 코드 접기
SingletonHolder는 pInstance_와 DestroySingleton의 주소를 LifetimePolicy<T>에게
넘겨줍니다. 그 의도는 LifetimePolicy 단위전략에게 알려진 동작을 구현하는 데 충분한 정보를 부여해 주기
위한 것입니다. 알려진 동작들이란 다음과 같습니다.
-
C++ 규칙.
LifetimePolicy<T>::ScheduleDestruction은
DestroySingleton의 주소를 넘겨주면서 atexit를 호출합니다. OnDeadReference는
std::logic_error 예외를 발생시킵니다.
-
재생. 위와 같지만, OnDeadReference가 예외를 발생시키는 대신,
싱글톤 객체를 재생성시킬 것입니다.
-
사용자 제어.
LifetimePolicy<T>::ScheduleDestruction은
SetLongevity(pInstance_)를 호출하게 될 것입니다.
-
영원불멸.
LifetimePolicy<T>::ScheduleDestruction은
아무 기능도 구현하지 않습니다.
이제 SingletonHolder의 전체 구현이 완료되었습니다.
-
일반적인 싱글톤 클래스를 구현할 수 있는 단위전략 클래스들을 모아보도록 합시다. [표 6.1]은 미리 정의된
SingletonHolder의 단위전략들을 보여주고 있습니다. 굵은 글꼴로 표시된 단위전략은 기본 템플릿 인자를 뜻합니다.
[표 6.1] 미리 정의된 SingletonHolder 단위전략들 보기
이제 남은 것은 작고 강력한, SingletonHolder 템플릿을 어떻게 사용하고 또 확장하느냐를 알아보는 일 뿐입니다.
-
SingletonHolder 클래스 템플릿은 특정 애플리케이션에 특화된 기능을 제공하지 않습니다. 이것은 단지 이번 장에서
T로 표현된 다른 클래스에 대해 싱글톤으로 특화된 서비스를 제공할 뿐입니다. 우리는 여기서 T를
클라이언트 클래스라 부르도록 합시다.
클라이언트 클래스는 기본 생성자, 복사 생성자, 대입 연산자, 소멸자 그리고 포인터 연산자(&)를 private로 선언하여,
의도하지 않은 생성이나 파괴가 일어나지 않도록 해야 합니다.
이러한 보호 장치를 사용하게 되면, 사용자는 Creator 단위전략 클래스를 friend로 선언해 주어야 할 필요가 생깁니다.
특정한 싱글톤의 구현과 관계되는 디자인 결정 사항들은 다음과 같은 사용자 정의 자료형에 잘 반영되게 됩니다.
예제 코드 보기
- class A { ... };
- typedef SingletonHolder<A, CreateUsingNew> SingleA;
-
예제 코드 접기
상속된 클래스의 객체를 반환하는 싱글톤을 제공하는 것은 Creator 단위전략을 수정하는 것으로 간단히 해결될 수 있습니다.
상속된 클래스의 객체를 반환하는 싱글톤 예제 코드 보기
- class A { ... };
- class Derived : public A { ... };
-
- template <class T> struct MyCreator : public CreateUsingNew<T>
- {
- static T* Create(void)
- {
- return new Derived;
- }
- };
-
- typedef SingletonHolder<A, StaticAllocator, MyCreator> SingleA;
상속된 클래스의 객체를 반환하는 싱글톤 예제 코드 접기
이와 비슷하게, 여러분은 생성자에 별도의 인자를 제공하거나, 또는 메모리 할당시에 다른 할당 전략을 사용할 수도 있습니다.
Singleton은 각 단위전략에 따라 마음대로 변형이 가능합니다.
SingletonWithLongevity 단위전략 클래스는 namespace 수준에서 GetLongevity 함수를 정의해줄 것을 요구합니다.
GetLongevity 예제 코드 보기
복잡하게 얽혀 있던 KDL 문제가 그동안 우리의 실험 대상이 되어 왔습니다. 이제 주어진 SingletonHolder를 이용하여
이 문제를 해결해 보도록 합시다.
KDL 문제의 해결 예제 코드 보기
- class KeyboardImpl { ... };
- class DisplayImpl { ... };
- class LogImpl { ... };
- ...
- inline unsigned int GetLongevity(KeyboardImpl*) { return 1; }
- inline unsigned int GetLongevity(DisplayImpl*) { return 1; }
-
- inline unsigned int GetLongevity(LogImpl*) { return 2; }
-
- typedef SingletonHolder<KeyboardImpl, SingletonWithLongevity> Keyboard;
- typedef SingletonHolder<DisplayImpl, SingletonWithLongevity> Display;
- typedef SingletonHolder<LogImpl, SingletonWithLongevity> Log;
-
-
KDL 문제의 해결 예제 코드 접기
-
이번 장에서는 싱글톤에 대해 가장 널리 알려진 C++의 구현 방법들을 설명하였습니다. 가장 난해한 문제는
싱글톤의 생명주기를 관리하는 것이었습니다.
파괴 후의 접근을 감지하는 것은 별로 어렵지 않으며, 그 부하 또한 작습니다. 이러한 무효화된 참조를 감지해 내는 것은 모든
싱글톤 구현 작업에 포함되어야 합니다.
우리는 싱글톤에 대한 4개의 중요한 변형물을 주제로 다루어 보았습니다. 컴파일러에 의해 제어되는 싱글톤, Phoenix 싱글톤,
수명 제어 싱글톤 그리고 메모리 누수 싱글톤이 바로 그것입니다. 이 각 싱글톤들은 별도의 강점과 약점을 가지고 있습니다.
싱글톤 디자인 패턴과 관련된 스레딩 모델의 문제는 상당히 중요합니다. 이중 검사 동기화 패턴은 스레드 간 안정성을 보장하는
싱글톤을 구현하는 데 커다란 도움을 줍니다.
마지막으로, 우리는 싱글톤의 각 변형된 버전들을 통합하고, 또 분류하였습니다. 그리고 이러한 작업을 통해 각 단위전략을 정의하고,
주어진 싱글톤을 이러한 단위전략으로 나누어 낼 수 있었습니다. 우리는 싱글톤과 관련된 세 가지 단위전략을 찾아내었습니다.
Creation, Lifetime 그리고 ThreadingModel이 바로 그것들입니다. 우리는 그 단위전략들을 4개의 템플릿 인자(단위전략
3개와 클라이언트 클래스의 자료형 1개)를 가진 SingletonHolder 클래스를 이용해 구현해 내었습니다. 이
SingletonHolder 클래스 템플릿은 우리가 다룬 싱글톤 디자인 상의 모든 결정 사항들에 대응할 수 있는 통합된 장치입니다.
-
-
SingletonHolder의 선언문은 다음과 같습니다.
SingletonHolder의 선언 예제 코드 보기
- template <
- class T,
- template <class> class CreationPolicy = CreateUsingNew,
- template <class> class LifetimePolicy = DefaultLifetime,
- template <class> class ThreadingModel = SingleThreaded
- >
- class SingletonHolder;
SingletonHolder의 선언 예제 코드 접기
-
SingletonHolder의 인스턴스를 만들기 위해서는 첫 번째 템플릿 인자로 사용자의 클래스를 넘겨주어야 합니다.
그리고 디자인의 선택 사항들을 변경하기 위해서 나머지 세 인자를 조합해 주어야 합니다.
SingletonHolder 인스턴스 만들기 예제 코드 보기
-
싱글톤 객체의 기본 생성자를 반드시 정의해야 합니다. 또는 Creator 단위전략이 제공하는 기본 구현물과는 다른
생성자를 사용해야 합니다.
-
세 가지 단위전략에 따라 준비되어 있는 싱글톤의 구현은 [표 6.1]에 표시된 바와 같습니다.
또한, 기본적인 요구 사항을 준수하기만 한다면, 사용자 임의로 여기에 자신만의 단위 전략 클래스를 추가할 수도 있습니다.
-
간단히 말해서, 스마트 포인터란 원시 포인터 자료형(C++ 언어가 기본으로 제공하는 포인터 자료형)을 흉내내어 만든 C++ 객체로서,
주로 operator-> 연산자와 operator* 연산자를 구현해 주는 방법을 통해 포인터로서의 역할을 수항하게 됩니다. 스마트
포인터가 포인터로서의 역할 뿐만 아니라, 다른 유용한 작업(예를 들어, 메모리 관리 작업이나 다중 스레드 사이에서 공유 자원에 대한
잠금 장치(Lock)를 걸어 주는 역할)을 수행해 주기도 합니다.
이번 장에서는 스마트 포인터에 대한 논의에서 그치는 것이 아니라, 실제 SmartPtr 클래스 템플릿의 구현에 대해서도 다루게 될 것입니다.
SmartPrt은 단위전략(1장)을 이용하여 디자인되었습니다.
이번 장을 읽고 나면, 여러분들은 다음과 같은 스마트 포인터와 관련된 논점들에 대해 전문적인 지식을 얻을 수 있을 것입니다.
-
스마트 포인터의 장점과 단점
-
소유권의 관리에 대한 다양한 전략
-
암묵적 형변환
-
오류 검사 및 대소 비교
-
다중 스레드 환경에서의 문제
이번 장의 내용은 SmartPrt이라는 제네릭한 스타일의 클래스 템플릿을 구현하는 것을 목표로 하고 있습니다. 각 섹션마다 하나씩의 구현
문제를 독립적으로 다루고, 최종적으로 모든 내용들을 통합하게 될 것입니다.
-
스마트 포인터란 문법적으로, 그리고 또 문맥적으로 원시 포인터를 흉내내면서, 거기에 더불어 부가적인 기능을 가지고 있는
C++ 객체를 말합니다. 각기 다른 자료형에 대한 스마트 포인터라 할지라도, 서로 간에 많은 부분의 공통 코드를 지니게
마련이므로, 실제로 대부분의 유용한 스마트 포인터들은 다음과 같이 템플릿 코드로 구현되어져 있습니다.
SmartPrt 예제 코드 보기
- template <class T>
- class SmartPtr
- {
- public:
- explicit SmartPtr(T* pointee) : pointee_(pointee);
- SmartPtr& operator=(const SmartPtr& other);
- ~SmartPtr(void);
-
- T& operator*(void) const
- {
- ...
- return *pointee_;
- }
-
- T* operator->(void) const
- {
- ...
- return pointee_;
- }
-
- private:
- T* pointee_;
- ...
- };
-
-
-
-
- class Widget
- {
- public:
- void Fun(void);
- };
-
- SmartPtr<Widget> sp(new Widget);
- sp->Fun();
- (*sp).Fun();
-
-
SmartPrt 예제 코드 접기
-
스마트 포인터는 단순 포인터형에서는 지원하지 않는, 이른바 값으로서의 의미를 가지고 있습니다.
여기서 값의 의미를 가지는 객체란 복사와 대입 연산이
가능한 객체를 뜻합니다.
new를 통하여 할당된 객체를 가리키는 포인터의 경우에는, 이러한 값의 의미를 갖지 못합니다. 만을 다음과 같은 코드를
작성하게 되면,
Widget* p = new Widget;
여기서 p는 단지 Widget 객체를 위해 할당된 메모리를 가리키는 것뿐 아니라, 그 메모리에 대한 소유권을 가지게 됩니다.
여기서 만일 다음과 같은 코드를 작성하게 되면,
p = 0; // p 에 다른 값을 대입
p는 이전에 자신이 가리키고 있던 객체에 대한 소유권을 상실하게 됩니다. 그리고 이젠 그 객체에 다시 접근할 수 있는 방법은
더 이상 존재하지 않게 됩니다. 즉, 리소스의 누수 현상이 발생한다는 것입니다.
더 나아가서, p를 다른 변수에 복사를 하는 경우에도, 컴파일러는 이것이 가리키는 객체에 대한 소유권 관리에는 전혀 신경을
쓰지 않을 것입니다. 아시다시피 할당된 하나의 객체에 대해 delete 연산을 중복하여 수행하게 되면, 메모리 누수 현상으로
생기는 문제보다 더욱 비극적 결과를 초래하게 됩니다. 결론적으로 말하자면, 임의로 할당된 객체에 대한 포인터는 값으로의 의미를
가질 수 없습니다. 이러한 포인터의 경우에는 프로그래머가 아무렇게나 복사나 대입 연산을 수행해서는 안됩니다.
반면, 대부분의 스마트 포인터는 원시 포인터를 흉내낼 뿐만 아니라, 소유권의 관리 작업도
대행해 줍니다.
스마트 포인터는 주어진 문제의 유형에 딱 들어맞는 다양한 방법으로 소유권을 관리해 줄 수 있습니다. 어떤 종류의 스마트 포인터는
자동적으로 소유권을 이전해 줄 수도 있습니다. 즉, 이런 종류의 스마트 포인터를 복사하게 되면, 원래의 스마트 포인터는 null을
가리키게 되고, 복사를 받은 스마트 포인터가 해당 객체를 가리킴과 동시에 그에 대한 소유권 또한 가지게 되는 것입니다.
이러한 동작은 표준적으로 제공되는 std::auto_ptr(현재의 std::unique_ptr)에서 구현된 것과 동일한 특성입니다.
또 다른 스마트 포인터는 참조 카운터의 기능을 제공합니다. 이러한 스마트 포인터는 동일한 객체를 가리키는 스마트 포인터의 개수를
추적하여, 그 개수가 0이 되는 순간에 해당 객체에 대한 delete 연산을 수행하게 됩니다. 마지막으로, 또 다른 종류의 스마트
포인터들은 포인터에 대해 복사 연사을 수행할 때마다 그 대상 객체를 함께 복제하기도 합니다.
우리의 목표는 스마트 포인터가 가능한 한 원시 포인터 자료형과 가까운 모습이 되도록, 그러나 그 한계를 넘지는 않도록
만들어 내는 것입니다.
스마트 포인터와 원시 포인터 사이의 호환 기능을 구현하는 데 있어서, 모든 호환성 목록을 만족시키는 것과 혼돈의 길에
빠져버리는 것 사이에는, 단지 백지장만큼의 작은 차이가 존재합니다. 이제, 언뜻 보기에 중요해 보이는 특징을 추가하는 일이
자칫 라이브러리 사용자를 위험에 노출시킬 수도 있다는 사실을 발견하게 될 것입니다.
-
pointee_의 자료형이 꼭 T* 자료형이어야 할까요? 만일 그렇지 않다면, 다른 어떤 자료형이 가능할까요?
제네릭 프로그래밍을 하는 데에 있어서, 프로그래머는 항상 이와 같은 질문을 스스로에게 던지도록 해야 합니다.
제네릭한 스타일로 구현된 코드에 숨어 있는 하드 코딩된 자료형들은 그 코드의 일반성에 해를 끼치는 중요한 요인이 됩니다.
어떤 상황에서는, 포인터가 가리키는 자료형을 별도로 커스터마이징 하는 것이 의미있는 일이 될 수 있습니다. 예를 들어,
자료형에 비표준 한정자가 붙는 경우를 들 수 있습니다.
또 다른 예로는, 스마트 포인터를 계층화시켜서 사용하는 경우가 있겠습니다. 만일 다른 사람이 구현한
LegacySmartPtr<T>가 주어졌을 때, 이를 더 개선시켜 사용하고자 한다면 어떻게 해야 할까요?
이 경우에 상속은 다소 위험한 판단 입니다. 대신, 그 스마트 포인터를 자신이 만든 스마트 포인터로 포장하여 사용하는 것이
훨씬 좋은 방법입니다. 외부에 포장 클래스로 존재하는 스마트 포인터의 관점에서는 pointee_의 자료형이 T*가 아니라,
대신 LegacySmartPtr<T>가 되는 편이 보다 제네릭한 스타일의 해법입니다.
스마트 포인터의 계층화라는 흥미로운 응용법은, 사실 다음과 같은 operator-> 연산자의 독특한 동작 메커니즘을 통해
가능한 일입니다. C++의 원시 포인터형이 아닌 어떤 사용자 사료형에 대해 operator-> 연산자를 적용하게 되면,
먼저 사용자 정의된 operator-> 연산자를 찾아 내어 이를 적용한 다음 컴파일러는 그 결과 값에 다시 연속적으로
operator-> 연산자를 적용합니다. 컴파일러는 이 같은 동작을 원시 포인터형을 만나게 될 때까지 지속적으로 반복하게
됩니다. 그리고 원시 포인터형을 만나게 되면, 그 때 비로소 자료형에 대한 멤버 접근을 시도하게 됩니다.
만일 operator-> 연산자의 결과 값으로 PointerType 형의 객체를 반환하였다면, 프로그램은 다음과 같은
수행 과정을 거치게 됩니다.
-
PointerType의 생성자를 호출합니다.
-
PointerType::operator->가 호출되어 PointeeType 객체에 대한 포인터를 반환합니다.
-
PointeeType에 대한 멤버 접근, 예를 들어 함수 호출이 이루어집니다.
-
PointerType의 파괴자가 호출됩니다.
이러한 기법은 다중 스레딩 환경에서 리소스의 접근이 서로 충돌하지 않도록 제어하는 데 매우 유용하게 사용될 수 있습니다.
즉, PointerType의 생성자를 통해 해당 리소스에 대한 잠금 장치를 마련하고, 그 리소스에 접근한 다음 최종적으로
PointerType의 파괴자에서 그 잠금을 해제하면 되는 것입니다.
스마트 포인터에 사용되는 자료형 전체를 일반화시키기 위해서, 우리는 잠재적으로 다른 성질을 가질 수 있는 자료형들을
별도로 구분해 주어야 합니다.
-
저장 자료형(Storage Type): 이것은 pointee_의 자료형을 말합니다.
일반적인 스마트 포인터에서는 디폴트 값으로 C++의 단순 자료형을 가리키게 됩니다.
-
포인터 자료형(Pointer Type): 이것은 operator->가 반환하게 되는
자료형을 의미합니다. 이것은 포인터 대신 프록시 객체를 반환하고자 하는 경우 등에서 저장 자료형과 다른 자료형을
가리킬 수 있습니다.
-
참조 자료형(Reference Type): 이것은 operator*에 의해 반환되는
자료형입니다.
만일 스마트 포인터가 이렇게 일반화된 개념을 유연한 방법으로 제공하게 된다면, 매우 유용할 것입니다. 따라서, 여기에 언급된
세 개의 자료형들은 Storage라 불리는 단위전략을 통하여 추상화되어야 합니다.
-
스마트 포인터에게 있어서 멤버 함수란 그다지 적절한 존재가 아닙니다. 그 이유는 스마트 포인터가 가리키는 객체에 대해
멤버 함수로 접근하는 방식은 상당한 혼란을 초래할 수 있기 때문입니다.
예를 들어, 다음과 같은 상황을 가정해 보기 바랍니다. 우리가 Printer라는 클래스를 가지고 있으며, 이 객체는 Acquire와
Release라는 멤버 함수를 제공합니다. Acquire를 사용하면 프린터에 대한 소유권을 얻어 올 수 있으며, Release 함수를
사용하면 이 소유권을 내어놓게 됩니다. 이제 이러한 인터페이스로 Printer 객체에 대한 스마트 포인터를 사용하게 되면
문법적으로 너무나 유사한, 그러나 그 의미는 전혀 다른 구문이 쓰여지게 됩니다.
예제 코드 보기
- SmartPtr<Printer> spRes = ...;
- spRes->Acquire();
-
- ... 프린터 출력 코드 ...
-
- spRes->Release()
- spRes.Release()
예제 코드 접기
원시 포인터들은 아무런 멤버 함수를 가지지 않습니다. 따라서 C++ 프로그래머들의 눈은 operator. 연산자에 의한 호출과
operator-> 연산자를 통한 호출의 차이를 구분하고, 감지해 내는 데 그다지 익숙하지 않습니다. 즉, 실수하기 쉽다는
의미입니다. 이에 대한 처방은 단순합니다. 스마트 포인터는 멤버 함수를 사용해서는 안된다는 것입니다. 그 대신 스마트 포인터에
대한 friend 함수를 사용하는 편이 좋습니다.
오버로드된 함수들은 스마트 포인터에 대한 멤버 함수들 만큼이나 혼란스러운 면이 있습니다. 하지만, C++ 프로그래머들은 이미
오버로드 함수들을 자유롭게 사용하고 있다는 차이점이 존재합니다. 즉, C++ 프로그래머라면 Release(*sp)나
Release(sp)와 같은 코드를 작성하거나, 되짚어 보는데 충분하고 주의 깊게 대처가 가능하다는 것입니다.
SmartPrt의 멤버 함수로 남을 필요가 있는 함수는 오직, 그 생성자와 파괴자, 그리고 operator=, operator->,
operator* 연산자 뿐입니다. SmartPrt에 대한 다른 모든 동작들은 friend 함수로 작성되어야 합니다.
이렇게 명료성의 이유로 인해, SmartPrt은 별도의 이름을 가진 멤버 함수를 전혀 가지고 있지 않습니다. 내부 포인터
객체(pointee)에 접근할 수 있는 함수는 오직, GetImpl, GetImplRef, Reset 그리고 Release와 같이
네임스페이스 영역에서 정의된 외부 함수들뿐입니다.
SmartPrt의 friend 함수들 예제 코드 보기
- template <class T> T* GetImpl(SmartPtr<T>& sp);
- template <class T> T*& GetImplRef(SmartPtr<T>& sp);
- template <class T> void Reset(SmartPtr<T>& sp, T* source);
- template <class T> void Release(SmartPtr<T>& sp, T*& destination);
-
-
-
-
-
-
-
-
SmartPrt의 friend 함수들 예제 코드 접기
-
소유권의 관리라는 것은 종종 스마트 포인터의 가장 중요한 존재 의미가 되기도 합니다. 보통, 사용자의 관점에서 볼 경우,
스마트 포인터는 자신이 가리키는 객체에 대한 소유권을 가진다고 말할 수 있습니다. 스마트 포인터는 자신이 가리키고 있는
객체에 대한 삭제 연산을 책임질 수 있는 가장 중요하고, 또 유일한 정보입니다. 또한, 스마트 포인터의 사용자는 관련 도움
함수를 호출함으로써 포인터가 가리키는 객체의 생명주기를 관리하는 데 개입하는 것이 가능합니다.
소유권의 자동 관리를 구현하고자 한다면, 스마트 포인터는 자신이 가리키는 객체의 복사, 대입, 그리고 삭제 연산을 주의 깊게
추적할 수 있어야 합니다. 이러한 추적 작업은 시간적, 그리고 공간적으로 어느 정도의 오버헤드를 초래하게 됩니다.
-
스마트 포인터에서 응용 가능한 가장 단순한 소유권 전략은 스마트 포인터를 복사할 때마다, 그것이 가리키는 객체를
함께 복사해 주는 것입니다. 이렇게 하면, 각각의 객체를 가리키는 스마트 포인터는 오직 한 개씩만 존재하게 됩니다.
때문에 스마트 포인터의 파괴자는, 자신이 가리키고 있는 객체 역시 안전하게 삭제할 수가 있습니다.
언뜻 보기에, 완전 복사 전략은 조금 어리석게 보이기도 합니다. 이런 경우 굳이 스마트 포인터를 쓰지 않더라도
C++ 객체의 값에 의한 전달(call-by-value)을 쓰는게 나아 보입니다.
하지만, 스마트 포인터를 사용함으로써 다형성을 지원할 수 있습니다.
현재 다루고 있는 객체의 정확한 동작이나 상태를 정확히 알지 못하더라도 그것을 복제할 필요가 있다면, 이 스마트
포인터는 매우 훌륭한 수단이 되어 줄 것입니다.
완전 복사가 주로 다형성 객체를 다루게 되기 때문에, 다음과 같은 다소 원시적인 복사 생성자는 올바른 구현이라
할 수 없을 것입니다.
스마트 포인터의 잘못된 복사 생성자 예제 코드 보기
- template <class T>
- class SmartPtr
- {
- public:
- SmartPtr(const SmartPtr& other)
- : pointee_(new T(*other.pointee_))
- {
- }
- ...
- };
-
-
-
-
스마트 포인터의 잘못된 복사 생성자 예제 코드 접기
8장에서 이러한 종류의 복제에 대한 내용을 보다 상세히 다루게 될 것입니다.
8장에서 살펴 보는 바와 같이, 다형성 객체의 복사본을 얻어내는 고전적인 방법은
복제 작업을 수행해 주는 Clone이라는 가상 함수를 클래스 내에 작성해 주는 것입니다.
Clone 함수 예제 코드 보기
- class AbstractBase
- {
- ...
- virtual Base* Clone(void) = 0;
- };
-
- class Concrete : public AbstractBase
- {
- ...
- virtual Base* Clone(void)
- {
- return new Concrete(*this);
- }
- };
-
-
Clone 함수 예제 코드 접기
제네릭한 스타일의 스마트 포인터라면 복제에 쓰이는 멤버 함수의 정확한 이름을 굳이 알아내야 할 필요가 없어야 할 것입니다.
따라서 가장 유연하게 대처할 수 있는 접근법은 바로 복제 작업에 쓰이는 단위전략을 SmartPrt의 템플릿 인자로 만들어
주는 방법이 될 것입니다.
-
Copy on Write(COW)는 불필요한 복제 작업을 피할 수 있는 일종의 최적화 기법입니다. COW 방식의 기본 아이디어는
해당 객체에 대한 최초의 수정 작업이 이루어질 때 그것을 복제하자는 것입니다. 그리고 그러한 변형이 이루어지기 전까지는
다수의 포인터가 같은 메모리의 객체를 가리키도록 하자는 것입니다.
스마트 포인터는 COW 전략을 구현하는 데 적임자가 될 수 없습니다. 왜냐하면, 스마트 포인터는 자신이 가리키는 객체에
대한 const 및 비 const 멤버 함수 호출을 구별해내지 못하기 때문입니다.
예제 코드 보기
- template <class T>
- class SmartPtr
- {
- public:
- T* operator->(void) { return pointee_; }
- ...
- };
-
- class Foo
- {
- public:
- void ConstFun(void) const;
- void NonConstFun(void);
- };
-
- ...
- SmartPtr<Foo> sp;
-
- sp->ConstFun();
- sp->NonConstFun();
예제 코드 접기
동일한 operator-> 연산자를 통해 두 함수를 모두 호출할 수 있었습니다. 따라서, 스마트 포인터 자체는 COW를
사용할지, 안할지를 결정할 수 있는 단서가 될 수 없습니다.
이번 장에서 구현하고 있는 SmartPrt에서는 COW에 대한 지원은 하지 않도록 하겠습니다.
-
참조 카운팅은 스마트 포인터에서 사용되는 가장 보편적인 소유권 관리 전략입니다. 참조 카운팅 기법에서는 동일한
객체를 가리키는 스마트 포인터의 개수를 정확하게 추적하게 됩니다. 그리고 그 숫자가 0이 될 때 해당 객체는
소멸하게 됩니다.
[그림 7.2] 동일한 객체를 가리키고 있는 세 개의 참조 카운팅 스마트 포인터 보기
[그림 7.2]에서 표현되어 있는 바와 같이, 스마트 포인터 객체들 사이에는 실질적인 카운터 변수가 공유되어야 합니다.
각 스마트 포인터는 실제 객체에 대한 포인터와 아울러, 카운터 변수에 대한 포인터를 담게 됩니다. 그리고 이러한 방법은
자연히 스마트 포인터의 메모리 사용량을 두 배로 부풀리게 될 것입니다.
게다가, 여기에는 이보다 더 미묘한 오버헤드의 문제가 존재합니다. 참조 카운트 기능을 가지는 스마트 포인터는 실제로
카운터 변수를 자유 공간(일반적으로 heap 영역)에 저장해야만 합니다. C++가 제공하는 기본 할당자는 이런 작은
카운터 변수를 다루는 데 눈에 띄게 속도가 느리며, 공간적인 낭비도 무시하지 못할 수준으로 발생합니다.
[그림 7.3] 참조 카운팅 스마트 포인터의 또 다른 구조 보기
크기에 있어서의 상대적인 오버헤드는 [그림 7.3]에 표시된 것과 같이, 포인터가 카운와 카운팅 변수를 함께
담아 두는 방법으로 어느 정도 완화될 수 있습니다. 하지만 이러한 구조는 그 대가로, 접근 속도의 저하가 나타납니다.
스마트 포인터가 가리키는 객체가 추가된 간접 계층 구조 아래로 숨겨지기 때문입니다.
![[그림 7.4] 참견꾼 참조 카운터(Intrusive reference counting) [그림 7.4] 참견꾼 참조 카운터(Intrusive reference counting)](./BS4/P/7.4.PNG)
[그림 7.4] 참견꾼 참조 카운터(Intrusive reference counting)
가장 효율적인 해법은 [그림 7.4]에서와 같이 포인팅 받는 객체 자신이 참조 카운팅 변수를 담는 것입니다.
이러한 방법을 쓰면 SmartPrt은 보통의 포인터 크기 외에 다른 추가적인 오버헤드를 가지지 않게 됩니다.
이러한 기법을 참견꾼 참조 카운터(intrusive reference counting)라고 부릅니다. 왜냐하면, 참조 카운터가
그 의미상으로는 포인터에 소속되는 개념이지만, 실제로는 포인팅 받는 객체의 내부에 끼어 든 형태로 존재하기 때문입니다.
이 기법의 경우, 포인팅 받는 객체가 참조 카운터를 지원하기 위해서는 미리 그러한 용도로 디자인되어야 합니다.
제네릭한 스타일의 스마트 포인터라면 사용 가능한 경우에는 참견꾼 참조 카운터를 사용해야 하며, 그렇지 않은 경우에는
대안으로 참견꾼 구조가 아닌 일반적인 참조 카운팅 기법도 사용할 수 있어야 합니다. 참견꾼 구조가 아닌 경우에
대해서는 4장에서 제공된 작은 객체에 대한 할당자가 커다란 도움이 될 것입니다.
-
참조 연결 리스트라는 기법은 어떤 동일한 객체를 가리키는 스마트 포인터 객체의 실질적인 개수가 꼭 필요한 정보는
아니라는 심층적 고찰에 기초하여 개발된 방법입니다. 우리에게 실제로 필요한 것은 그 숫자가 0으로 떨어지는 시점이
언제인가를 검출해 내는 것이지 참조 카운터의 숫자 그 자체가 아닙니다. 이러한 사실은 [그림 7.5]에서와 같은
"소유권 리스트"를 유지하는 구조와 같은 새로운 아이디어를 가져오게 됩니다.
[그림 7.5] 참조 연결 리스트 구조의 동작 보기
이 경우에 주어진 객체를 가리키는 모든 SmartPrt 객체는 이중 연결 리스트 구조를 구성하게 됩니다.
이중 연결 리스트의 구조는 포인터의 참조 여부를 추적하는 데 그야말로 안성맞춤인 방법입니다. 단일 연결 구조의
리스트는 임의의 원소를 삭제하는 데 선형 시간을 필요로 하고, 같은 이유에 추가로, SmartPrt 객체들이 서로
인접하여 생성되리라는 보장이 없기 때문에 벡터 역시 좋은 해결책이 되기 힘듭니다.
참조 연결 리스트 방식이 참조 카운팅 방식에 비해 가지는 장점은 구현자가 추가적인 자유 저장 공간(heap)을 필요로
하지 않는다는 것이며, 이러한 사실은 스마트 포인터를 보다 신뢰성 있게 만들어 줍니다. 참조 연결 리스트 방식의
스마트 포인터를 생성하는 작업은 절대로 실패하지 않습니다. 반면 단점은, 더 많은 메모리의 양을 요구하게 된다는
것입니다. 또한 참조 카운터의 방법을 사용하는 경우에는, 단지 한 번의 간접 접근과 정수에 대한 증가 연산만을
필요로 하므로 참조 연결 리스트의 경우보다 조금 더 빠른 수행 속도를 보일 것입니다. 결론적으로, 참조 연결
리스트는 오직 자유 저장 공간이 매우 부족할 경우에만 사용되어야 합니다.
참조 카운팅 전략이 가지는 중대한 결점인 순환 참조(cyclic reference)에
대해서도 살펴보도록 합시다. A라는 객체가 B 객체에 대한 스마트 포인터를 가지고 있다고 가정해 봅시다.
또한, B 객체 역시 A에 대한 스마트 포인터를 가지고 있다고 합시다. 그러면 이 두 객체는 순환 참조라는 문제를
지니게 됩니다. 이 두 객체를 더 이상 전혀 사용하지 않게 되더라도, 그 두 객체는 서로가 서로를 사용하고 있게
됩니다. 이는 결국, 두 객체가 가지는 자원이 누수된 것과 마찬가지입니다.
-
파괴 복사(destructive copy)는 복사 작업이 수행되는 동안, 그것은 복제되고 있는 원본 객체를 파괴시킵니다.
스마트 포인터의 경우에 있어서, 파괴 복사는 원본이 되는 스마트 포인터를 파괴하고, 포인팅 받는 객체를 새로운 대상
스마트 포인터에게 넘겨주게 됩니다. 표준적으로 제공되는 std::auto_ptr 클래스 템플릿이 이러한 파괴 복사의
특성을 가지고 있습니다.
파괴 복사를 잘못 사용할 경우, 프로그램의 데이터 및 그 동작의 정확성에 좋지 않은 영향을 끼칠 수 있습니다.
어떤 주어진 객체를 가리키는 스마트 포인터는 어느 시점에서든 오직 하나뿐이라는 규칙을 보장해 주고 싶다면
이 파괴 복사를 사용할 수 있습니다. 다음의 코드는 파괴 복사의 특성을 가지는 SmartPrt의 복사 생성자
및 대입 연산자에 대한 간단한 예를 나타내고 있습니다.
파괴 복사의 특성을 가지는 SmartPrt 예제 코드 보기
- template <class T>
- class SmartPtr
- {
- public:
- SmartPtr(SmartPtr& src)
- {
- pointee_ = src.pointee_;
- src.pointee_ = 0;
- }
-
- SmartPtr& operator=(SmartPtr& src)
- {
- if (this != &src)
- {
- delete pointee_;
- pointee_ = src.pointee_;
- src.pointee_ = 0;
- }
- return *this;
- }
- ...
- };
파괴 복사의 특성을 가지는 SmartPrt 예제 코드 접기
C++에서는 복사 생성자나 대입 연산자의 오른편 인자로서 원본 객체에 대한 상수 참조형이 오도록 하는 것이 상례입니다.
하지만 파괴 복사를 구현하는 클래스라면 분명 이러한 기본 가정을 위배해야만 합니다. 왜냐하면 C++의 그러한 상례는 그
규칙을 깰 경우에 부정적인 결과가 올 수 있다는 사실을 말해주는 것이며, 파괴 복사를 통해 우리가 달성하고자 하는
기능이 바로 거기서 말하는 부정적인 결과와 일치하기 때문입니다. 다음의 예제 코드를 보세요.
예제 코드 보기
- void Display(SmartPtr<Something> sp);
- ...
- SmartPtr<Something> sp(new Something);
- Display(sp);
-
-
-
예제 코드 접기
이와 같이 파괴 복사 기능을 갖는 스마트 포인터는 값의 의미를 가질 수 없으며, 그로 인해 이러한 스마트 포인터는
컨테이너에 저장될 수가 없습니다. 일반적으로 파괴 복사 기능을 갖는 스마트 포인터는 거의 원시 포인터만큼이나
주의 깊게 다루어야 합니다.
반대로 희망적인 측면을 살펴보자면, 파괴 복사 기능을 갖는 스마트 포인터는 다음과 같은 장점을 가지고 있습니다.
-
오버헤드가 거의 존재하지 않습니다.
-
소유권의 이전이라는 의미를 강조하는 데 매우 적절하게 쓰입니다. 이 경우에는, 앞서 기술된
"소용돌이 효과"가 오히려 득이 될 수가 있습니다. 그리고 프로그래머는 자신이 작성한 함수가
전달받은 포인터에 대한 소유권을 완전히 접수했다는 사실을 분명히 인지해야 할 것입니다.
-
함수의 반환 값으로 사용하기에 적절합니다. 만일 스마트 포인터가 특정한 트릭을 사용하게 되면,
함수의 반환값으로 파괴 복사 기능의 스마트 포인터를 사용할 수 있게 됩니다. 이러한 방법을 통해,
반환 값을 돌려 받은 호출자가 더 이상 이것을 사용하지 않게 되면, 포인팅 받는 객체가 자동으로
파괴될 것이라는 사실을 확인할 수 있게 됩니다.
-
다중의 반환 경로를 가지는 함수에서 스택 변수로 사용하기에 매우 좋습니다. 포인팅 받는 객체를
수동으로 일일이 지워 주어야 하는 지의 여부를 기억할 필요가 없어집니다. 스마트 포인터가 프로그래머를
대신하여 삭제 작업을 책임져 줄 것입니다.
이러한 이유로 인해, SmartPrt의 단위전략을 구현하는 클래스 템플릿에는 파괴 복사의 의미를 지원하는
클래스 템플릿이 존재해야 합니다.
스마트 포인터는 다양한 종류의 소유권 전략을 사용하고 있으며, 각각의 전략들은 고유의 트레이드 오프를 가지고
있습니다. 여기에 사용된 가장 중요한 기법은 완전 복사(Deep Copy), 참조 카운팅, 참조 연결 리스트 그리고
파괴 복사(destructive copy)입니다. SmartPrt은 OwnerShip 단위전략을 통하여 이 모든 전략들을
구현하고 있습니다. 이 중에서 기본적으로 선택되는 디폴트 전략은 참조 카운팅 전략입니다.
-
스마트 포인터를 가능하면 언어가 제공하는 원시 포인터와 같은 모습으로 보여지도록 노력을 기울이다 보면, 라이브러리
디자이너는 단항 연산자인 operator&, 즉 주소 추출 연산자(address-of
operator)를 오버로딩할지를 두고 고민하게 됩니다.
스마트 포인터의 개발자는 이 연산자를 오버로드해야 한다고 판단하고, 다음과 같은 코드를 작성하게 될 것입니다.
operator& 예제 코드 보기
- template <class T>
- class SmartPtr
- {
- public:
- T** operator&(void)
- {
- return &pointee_;
- }
- ...
- };
-
-
-
- void Fun(Widget** pWidget);
- ...
- SmartPtr<Widget> spWidget(...);
- Fun(&spWidget);
-
operator& 예제 코드 접기
단항 연산자 operator&를 오버로드 하는 것은 득보다는 실이 많을 수 있습니다. 그 이유는 크게 두 가지가 존재합니다.
첫째, 포인팅 받는 객체의 메모리 주소를 노출시킨다는 것은 소유권에 대한 자동 관리를 포기하겠다는 말과 같습니다.
클라이언트 코드에서 원시 포인터의 주소를 자유롭게 접근할 수 있게 되면, 참조 카운트와 같은 스마트 포인터의 보조적
구조물들이 그 의미를 상실하게 됩니다.
둘째로 보다 실용적인 예를 들자면, 단항 연산자 operator&를 오버로드 하게 되면, 스마트 포인터를 STL 컨테이너와 함께
사용할 수가 없게 됩니다. 실질적으로, 단항 연산자 operator&를 오버로드 하게 되면, 많은 부분에서 해당 자료형을
제네릭 프로그래밍과 함께 사용하기가 힘들어 집니다. 왜냐하면, 어떤 객체의 주소라는 것은 원천적으로 너무나 기본적인 객체의
속성이기 때문입니다. 대부분 제네릭 프로그램의 코드들은 &를 자료형 T의 어떤 객체에 적용하면, T* 자료형의 값이
얻어진다고 가정하고 있습니다.
이와 같이, 스마트 포인터 뿐만 아니라 보편적인 다른 모든 객체에 대해서도 단항 연산자 operator&를 오버로드 하는 것은
전혀 권장할 만한 일이 아닙니다. 따라서 우리가 작성하는 SmartPrt은단항 연산자 operator&을 오버로드 하고 있지 않습니다.
-
다음과 같은 코드를 생각해 보시기 바랍니다.
예제 코드 보기
- void Fun(Something* p);
- ...
- SmartPtr<Something> sp(new Something);
- Fun(sp);
예제 코드 접기
"최대한의 호환성"이라는 측면에서 생각해 보면, 이 코드가 컴파일되도록 해야 할 것입니다.
기술적으로 보면, 위와 같은 코드가 문제없이 컴파일 되게 만드는 것은 간단합니다.
암묵적 형변환 예제 코드 보기
- template <class T>
- class SmartPtr
- {
- public:
- operator T*(void)
- {
- return pointee_;
- }
- ...
- };
암묵적 형변환 예제 코드 접기
그러나 이것으로 이야기가 간단히 끝나는 것은 아닙니다.
사용자 정의 형변환은 사용하기가 힘들고, 잠재적인 위험 요소를 가진다는 특성이 있습니다. 사용자 정의 형변환이 내부적인
데이터에 대한 핸들을 노출하는 경우 특히 위험성을 지니게 됩니다. 그리고 이러한 경우는 위의 예제 코드에서 operator
T*의 경우에 정확히 해당합니다.
원시 포인터를 넘겨주는 것은 스마트 포인터의 내부 동작에 악영향을 끼치게 됩니다. 일단, 포장 클래스의 틀에서 벗어나게 되면,
원시 포인터는 다시 스마트 포인터가 도입되지 않았을 때와 마찬가지로 프로그램의 정상적인 동작에 위협을 가하게 됩니다.
또 다른 위험성은 사용자 정의 형변환이 프로그래머가 원치 않았던 예기치 않은 상황에서 발생할 수 있다는 사실입니다.
다음과 같은 코드를 살펴보기 바랍니다.
의도하지 않은 동작을 하는 예제 코드 보기
컴파일 시간에 delete 연산자의 호출을 방지하는 데 몇 가지 방법이 존재합니다. 그 중에는 매우 독창적인 방법도 존재합니다.
매우 효과적이고 구현하기 쉬운 방법은 의도적으로 delete의 호출에 모호성을 부여해 버리는
것입니다.
스마트 포인터에 대한 delete 연산자의 호출을 방지하는 예제 코드 보기
하지만 delete 연산자의 사용을 금지하는 것이 이 논의의 전부가 아닙니다. 원시 포인터로의 자동 형변환은, 수용하기에는 너무
위험하고, 배제시키기에는 너무나도 편리한 기능입니다. 우리는 최종적인 SmartPrt 구현물에서는 사용자에게 이러한 선택권을
부여하게 될 것입니다.
어쨌든, 자동 형변환을 금지한다고 해서 꼭 원시 포인터에 대한 모든 접근 경로를 차단할 필요는 없습니다.
때때로는 이러한 접근이 필요한 경우가 있습니다. 따라서, 모든 스마트 포인터는 자신이 포장하고 있는 포인터에 대한 명시적
접근을 다음과 같은 함수 호출을 통해 허용하고 있습니다.
원시 포인터형으로의 명시적 형변환 예제 코드 보기
-
이제 스마트 포인터의 동치 여부를 검사하는 경우를 생각해 보도록 합시다. 스마트 포인터는 원시 포인터에서 사용되었던
것과 동일한 비교 문법을 지원해야만 합니다. 따라서 프로그래머들은 다음과 같은 코드가 원시 포인터와 마찬가지로 잘
컴파일되고, 또 실행될 것이라 기대할 것입니다.
예제 코드 보기
- SmartPtr<Something> sp1, sp2;
- Something *p;
- ...
-
- if (sp1)
- ...
-
- if (!sp1)
- ...
-
- if (sp1 == 0)
- ...
-
- if (sp1 == sp2)
- ...
-
- if (sp1 == p)
- ...
-
-
예제 코드 접기
불행하게도, 앞에서 다루었던 문제(delete 연산의 수행을 컴파일 시간에 방지하는 것)에 대한 해법과 지금 다루고 있는 문제의
해법 사이에는 서로 충돌하는 부분이 존재합니다. 내부 포인터 자료형에 대한 자동 형변환이 하나만 존재할 경우에는 테스트 4번을
제외한 대부분의 비교 구문이 잘 컴파일 되며, 실제로 우리가 예상한 바와 같이 동작하게 됩니다. 대신 이 경우에는 사용자가
스마트 포인터에 대해 delete를 호출할 수 있다는 맹점이 생깁니다. 한편, 자동 형변환이 두 개가 존재하는 경우에는 delete를
호출하는 실수를 방지할 수 있지만, 위의 테스트 코드 중 그 어떤 것도 컴파일 되지 않게 됩니다. 이러한 테스트 코드 역시 앞선
문제와 동일한 모호성을 유발시키기 때문입니다.
bool 자료형으로의 사용자 정의 형변환 연산자를 추가하게 되면, 이러한 문제의 해결에 도움이 될 수 있지만, 이것은 새로운
문제를 발생시킵니다.
새로운 문제 예제 코드 보기
- template <class T>
- class SmartPtr
- {
- public:
- operator bool(void) const
- {
- return pointee_ != 0;
- }
- ...
- };
-
-
-
- SmartPtr<Apple> sp1;
- SmartPtr<Orange> sp2;
-
- if (sp1 == sp2)
-
- ...
-
- if (sp1 != sp2)
- ...
-
- bool b = sp1;
-
- if (sp1 * 5 == 200)
- ...
새로운 문제 예제 코드 접기
이런 딜레마를 해결할 수 있는 완전하고 튼튼한 해결책이 있다면, 그것은 생각할 수 있는 모든 경우에 대해 각각의 연산자를
따로따로 정의해 주는 방법일 것입니다.
예제 코드 보기
- template <class T>
- class SmartPtr
- {
- public:
- bool operator!(void) const
- {
- return pointee_ == 0;
- }
-
- inline friend bool operator==(const SmartPtr& lhs, const T* rhs)
- {
- return lhs.pointee_ == rhs;
- }
- inline friend bool operator==(const T* lhs, const SmartPtr& rhs)
- {
- return lhs == rhs.pointee_;
- }
- inline friend bool operator!=(const SmartPtr& lhs, const T* rhs)
- {
- return lhs.pointee_ != rhs;
- }
- inline friend bool operator!=(const T* lhs, const SmartPtr& rhs)
- {
- return lhs != rhs.pointee_;
- }
- ...
- };
-
예제 코드 접기
하지만 우리는 아직 문제를 완전히 해결해 낸 것은 아닙니다. 만일 내부 포인터 자료형으로의 자동 형변환 기능을 제공하고자 하면,
여전히 모호성의 문제가 위험 요소로 남게 됩니다. 가령, Base 클래스와 그것을 상속하는 Derived라는 파생 클래스가 있다고
가정해 봅시다. 그러면 다음과 같은 코드는 모호성으로 인한 문제가 발생하게 됩니다.
모호성 문제가 있는 예제 코드 보기
하지만, 우리는 여기에서 멈출 수 없습니다. operator== 및 operator!= 연산자를 정의하는 것과 아울러, 우리는 다음과
같이 그것에 대한 템플릿 버전을 추가해 주어야 할 것입니다.
템플릿 버전의 비교 연산자 예제 코드 보기
- template <class T>
- class SmartPtr
- {
- public:
-
- ... 위와 같음 ...
-
- template <class U>
- inline friend bool operator==(const SmartPtr& lhs, const U* rhs)
- {
- return lhs.pointee_ == rhs;
- }
-
- template <class U>
- inline friend bool operator==(const U* lhs, const SmartPtr& rhs)
- {
- return lhs == rhs.pointee_;
- }
-
- ... 비슷한 방식으로 operator!=를 정의해 줍니다 ...
-
- };
템플릿 버전의 비교 연산자 예제 코드 접기
템플릿으로 구현된 연산자들은 "탐욕스럽다" 라고 할 수 있습니다. 그것은 주어진 포인터의 자료형에 상관없이, 모든 포인터에 대하여
대응될 수 있는 비교 연산자를 찾아내게 되며, 이에 따라 모호성의 문제도 자연스럽게 해결됩니다.
템플릿 비교 연산자들이 탐욕스럽긴 하지만, 여전히 비 템플릿 비교 연산자들이 사용되는 경우가 있습니다. 예를 들어,
if (sp == 0)과 같은 비교문의 경우에 컴파일러는 다음과 같은 순서로 대응되는 연산자를 찾게 될 것입니다.
-
템플릿 연산자: 0이란 값은 포인터 자료형이 아니기 때문에 적절한 대응을 찾아내지
못하게 됩니다. 실제로 0은 포인터 자료형으로 암묵적 형변환이 가능하지만, 템플릿의 대응 관계를 계산하는 데 자동
형변환까지 검토하게 되지는 않습니다.
-
비 템플릿 연산자: 템플릿 연산자가 고려 대상에서 제외된 후에, 컴파일러는
비 템플릿 연산자 중에서 알맞은 후보를 찾게 됩니다. 그리고 이 중 한 연산자가 0에서 내부 포인터 자료형으로의
형변환에 반응하게 될 것입니다. 만일 비 템플릿 연산자가 존재하지 않는다면, 이 비교문은 에러를 발생시킬 것입니다.
결론적으로, 우리는 템플릿이 아닌 비교 연산자와 템플릿으로 된 비교 연산자 양쪽 모두가 필요하다고 말할 수 있겠습니다.
그럼 이제 서로 다른 자료형에 대해 구체화된 두 개의 SmartPrt을 비교하게 되면 어떤 일이 발생하는 지를 살펴보도록 합시다.
모호성 킬러 예제 코드 보기
- SmartPtr<Apple> sp1;
- SmartPtr<Orange> sp2;
-
- if (sp1 == sp2)
- ...
-
-
-
-
-
- template <class T>
- class SmartPtr
- {
- public:
-
- template <class U>
- bool operator==(const SmartPtr<U>& rhs) const
- {
- return pointee_ == rhs.pointee_;
- }
-
-
- ...
- };
-
-
-
-
-
- SmartPtr<Apple> sp1;
- SmartPtr<Orange> sp2;
-
- if (sp1 == sp2)
-
- ...
모호성 킬러 예제 코드 접기
하지만 여전히 충족되지 못한 문법적 요소가 한 가지 남아 있습니다. 그것은 바로 if (sp)와 같이 포인터에 대해서 참 거짓의
여부를 직접적으로 검사하는 구문입니다. 이 구문이 컴파일 되게 하려면 수치 자료형이나 원시 포인터형으로의 자동 형변환 연산자를
정의해 주어야 합니다.
앞에서 언급했듯이, 수치 자료형으로의 자동 형변환을 정의하는 것은 바람직 하지 못합니다. 만일 포인터 자료형으로의 자동 형변환
연산자를 제공하고자 한다면, 둘 중 하나를 선택하여야 합니다. delete 연산자에 대한 호출 위험을 감수하든지, if (sp) 검사
구문의 사용을 포기해야만 합니다. 물론, 두 경합의 승자는 보다 안전한 쪽이 되어야 마땅하므로, if (sp)와 같은 구문을
사용해서는 안된다는 결론이 내려집니다. 이를 대신하여 if (sp != 0)을 사용하거나, if (!!sp)를 사용해야 할 것입니다.
그런데 만일 포인터 자료형으로의 자동 형변환을 제공하지 않는다면, if (sp) 구문을 가능하게 할 수 있는 재미있는 트릭이
존재합니다.
재미있는 트릭 예제 코드 보기
- template <class T>
- class SmartPtr
- {
- class Tester
- {
- void operator delete(void*);
- };
-
- public:
- operator Tester*(void) const
- {
- if (!pointee_) return 0;
- static Tester test;
- return &test;
- }
- ...
- }
-
-
재미있는 트릭 예제 코드 접기
결론적으로 SmartPrt은 동치/부등 조건에 대한 검사를 다음과 같은 방법을 통해 만족시키게 됩니다.
-
operator==와 operator!= 연산자를 두 가지 방식으로 정의합니다(템플릿 버전과 비 템플릿 버전).
-
operator! 연산자를 정의합니다.
-
내부 포인터 자료형으로의 형변환을 허용한다면, delete 연산자의 사용을 막기 위해 고의적으로 모호성을 유발해야
합니다. 이를 위해 추가적으로 void*로의 자동 형변환도 함께 정의해 줍니다. 그리고 이러한 자동 형변환이
필요가 없는 경우에는, private 영역에 Tester 클래스를 정의하고, 역시 이에 대한 private 연산자인
operator delete를 정의합니다. 그리고 SmartPrt에 대해 Tester*로의 자동 형변환 연산자를 정의하여,
오직 SmartPrt이 내부적으로 null을 가리킬 때에만 이 연산자가 null을 반환하도록 만들어 줍니다.
-
포인터는 자신 안에 반복자의 속성과 별명의 속성 두 가지를 모두 가지고 있습니다. 포인터 간의 수치적 연산(비교 연산을 포함)을
통하여 이러한 반복자로서의 특징이 보장됩니다. 그리고, 객체 접근 연산자(dereferencing operator)인 *와 ->
연산자가 이러한 별명의 개념을 충족시켜 주는 도구가 됩니다.
스마트 포인터의 비교 연산자를 정의하는 문제는 다음과 같은 질문으로 요약될 수 있습니다. "하나의 배열에 속한 객체들에 대해,
둘 이상의 스마트 포인터를 정의하는 것이 의미를 가집니까?" 일단 그 대답은 '아니오'입니다. 별개의 소유권을 가지는 객체들이
동일한 배열에 속하는 경우는 그리 흔하지 않습니다. 따라서, 이런 의미 없는 비교를 사용자에게 허용한다는 것은 위험스런 일이
될 것입니다.
만일 정말로 비교 연산이 필요하다면, 원시 포인터에 대한 명시적 접근을 통하여 이를 해결할 수 있습니다.
만일 SmartPrt의 사용자가 암묵적 형변환을 허용하도록 선택하였다면, 다음과 같은 코드가 컴파일에 성공하는 경우가 생깁니다.
예제 코드 보기
- SmartPtr<Something> sp1, sp2;
-
- if (sp1 < sp2)
-
- ...
예제 코드 접기
이러한 비교 연산을 금지시키는 가장 쉬운 방법은 이 연산자를 선언은 하지만, 정의는 하지 않는 것입니다.
비교 연산을 금지시키는 예제 코드 보기
- template <class T>
- class SmartPtr
- { ... };
-
- template <class T, class U>
- bool operator<(const SmartPtr<T>&, const U&);
-
- template <class T, class U>
- bool operator<(const T&, const SmartPtr<U>&);
비교 연산을 금지시키는 예제 코드 접기
하지만, 다른 모든 비교 연산에 대해서라면, 그것을 정의하지 않은 채로 남겨두는 것보다는 operator< 연산자를 통하여
정의해 두는 것이 보다 현명한 선택이 됩니다. 이렇게 하면, 비교 연산을 지원해야겠다고 마음을 바꿨을 때 operator< 연산자만을
정의하는 것만으로 나머지 비교 연산자들도 덩달아 사용할 수 있게 됩니다.
예제 코드 보기
- template <class T, class U>
- bool operator<(const SmartPtr<T>& lhs, const SmartPtr<U>& rhs)
- {
- return lhs < GetImpl(rhs);
- }
-
- template <class T, class U>
- bool operator>(const SmartPtr<T>& lhs, const U& rhs)
- {
- return rhs < lhs;
- }
-
- ... 다른 연산자들도 비슷한 방식으로 정의합니다 ...
-
-
-
-
- inline bool operator<(const SmartPtr<Widget>& lhs, const Widget* rhs)
- {
- return GetImpl(lhs) < rhs;
- }
-
- inline bool operator<(const Widget* lhs, const SmartPtr<Widget>& rhs)
- {
- return lhs < GetImpl(rhs);
- }
-
예제 코드 접기
아직 한 가지 우리가 관심을 가져야 할 세부 문제가 남아 있습니다. 때때로, 같은 배열에 포함되어 있지는 않지만, 임의적으로
할당된 객체들 사이에도 비교 연산이 유용하게 사용되는 때가 있습니다. 예를 들어, 객체의 주소 값에 따라 정렬되는 맵(map)
구조가 필요한 경우가 있습니다.
표준 C++의 정의 중 하나가 바로 이러한 디자인을 구현하는 데 큰 도움이 됩니다. 임의로 할당된 객체들에 대해서는
비록 포인터 간의 비교 연산을 정의하지 않을 것이지만, 그 대신 C++ 표준에서 제공하는 std::less 함수를 통해
같은 자료형을 가지는 두 포인터 자료형에 대한 의미 있는 결과를 반환받을 수 있습니다.
SmartPrt은 std::less의 템플릿 인자를 자신에 대해 특화시키고 있습니다.
std::less의 특화 예제 코드 보기
- namespace std
- {
- template <class T>
- struct less<SmartPtr<T> >
- : public binary_function<SmartPtr<T>, SmartPtr<T>, bool>
- {
- bool operator()(const SmartPtr<T>& lhs,
- const SmartPtr<T>& rhs) const
- {
- return Less<T*>()(GetImpl(lhs), GetImpl(rhs));
- }
- };
- }
std::less의 특화 예제 코드 접기
요약하면, 기본적으로 SmartPrt은 비교 연산자를 정의하고 있지 않습니다. SmartPrt은 제네릭한 스타일의 두 operator<
비교 연산자들에 대한 선언만을 해 놓고, 그것에 대한 구현 코드는 제공하지 않습니다. 그리고 다른 모든 비교 연산자들은
operator<를 통하여 구현해 놓고 있습니다. 따라서 사용자가 operator<에 대한 제네릭한 스타일의, 또는 특화된
버전을 정의할 수 있도록 하고 있습니다.
또한, SmartPrt은 std::less 함수를 특화시킴으로써, 임의의 스마트 포인터 객체에 대한 대소 비교 기능을 제공할 수 있도록
잘 배려해 주고 있습니다.
-
애플리케이션을 작성할 때 다양한 수준에 있어서 스마트 포인터의 안전성을 요구하게 됩니다. 어떤 프로그래머들은
포인터에 대한 집중적인 연산을 필요로 하기 때문에 수행 속도에 대한 최적화를 원합니다. 대부분의 다른 프로그래머들은
수행 속도 보다는, 보다 나은 실시간 오류 검사 기능을 바랄 것입니다.
때때로, 하나의 애플리케이션 내부에서 이 두 가지 모델이 모두 필요하게 될 수도 있습니다.
우리는 스마트 포인터에 대한 오류 검사의 문제를 두 가지 분류로 나누어 생각할 수 있을 것입니다. 그 하나는 초기화
검사(initialization checking)이며, 다른 하나는 객체 접근 전 검사(checking before dereference)입니다.
-
스마트 포인터가 null값이 될 수 없도록 보장해 주는 것은 쉽게 구현될 수 있으며, 실제의 경우에 상당히
유용하게 쓰일 수 있습니다. 이것은 GetImplRef 함수를 써서 원시 포인터를 직접 조작하지 않는 이상,
모든 스마트 포인터가 항상 올바른 객체를 가리키고 있다는 것을 의미하게 됩니다. 그리고 이러한 기능은
null 포인터를 넘겨받은 경우, 예외를 발생시키도록 생성자를 만들어 줌으로써 쉽게 구현될 수 있습니다.
null 포인터를 넘겨받으면 예외를 발생시키는 예제 코드 보기
다른 한편으로, null 값은 "올바른 포인터가 아님"이라는 뜻을 담는 용도로 편리하게 사용될 수 있으며, 이것은
때때로 매우 유용하게 쓰일 수 있습니다.
또한 null 값을 허용할지의 여부는 기본 생성자의 동작에도 영향을 미치게 됩니다. 기본 생성자는 보통 코딩에서
생략될 수 있지만, 그렇게 되면 스마트 포인터를 다루기가 보다 어려워질 것입니다. 스마트 포인터가 null 값을
허용하지 않는다면, 커스터마이징 된 초기화 함수를 만들어 적절한 기본 값을 설정해 줘야 한다고 말 할 수 있겠습니다.
-
null 값을 갖는 포인터에 대한 객체 접근 연산은 정의되지 않은 동작을 유발시키기 마련입니다. 따라서 이러한
객체 접근 전 검사 작업은 상당히 중요한 내용이라 할 수 있겠습니다. 우리가 할 일은 객체 접근 연산이 일어나기
전에 포인터의 유효성을 검사해 주는 것입니다. 이러한 객체 접근 전 검사 작업은 스마트 포인터의 operator->
연산자와 operator*의 오버로드 과정에서 처리되어야 합니다.
초기화 검사와는 달리, 객체 접근 전 검사는 프로그램의 성능에 있어서 치명적인 병목 현상을 유발시킬 수 있습니다.
왜냐하면, 스마트 포인터 객체를 생성하는 작업보다는 그것을 사용하는 횟수가 훨씬 많기 때문입니다. 따라서, 우리는
안정성과 수행 속도 사이의 균형을 유지시키는 문제에 주목해야 합니다. 가장 중요한 규칙은 엄격하게 검증된 포인터로부터
코딩을 시작하자는 것이며, 또한 필요에 따라서는 선택된 스마트 포인터의 경우에 검사 과정을 생략할 필요가 있다는
것입니다.
초기화 검사와 객체 접근 전 검사는 서로 연관성이 존재합니다. 만일, 초기화 과정에서 엄격한 검사 과정을 강제하게
되면, 그 포인터는 항상 유효하다고 판단할 수 있으므로 객체 접근 전 검사 작업은 좀 더 느슨하게 수행해도 문제가
없을 것입니다.
-
오류를 보고하는 데 의미 있는 유일한 선택은 예외를 발생시키는 것입니다.
프로그래머는 오류를 피해 가기 위한 목적으로 소스 코드에 별도의 처방을 내릴 수 있습니다. 예를 들어, 만일
포인터의 객체 참조 연산이 이루어지는 경우에 그것이 null 값을 가리키고 있다면, 바로 그 순간에 해당 객체를
초기화시켜 줄 수 있습니다. 이런 방법은 바로 느린 초기화(lazy
initialization)라 불리는 유효하고, 또 가치있는 전략입니다.
만일 오직 디버그 과정에서만 오류를 검사하고자 한다면, 표준으로 제공되는 assert 함수나, 그와 비슷한 것들을
사용할 수 있을 것입니다. 그러면 배포본을 위한 컴파일에서는 컴파일러가 이러한 검사 작업을 생략하게 될 것입니다.
SmartPrt은 오류 검사를 위해 Checking이라는 단위전략을 사용합니다. 이 단위전략은 오류 검사 함수와 오류 보고
전략을 구현해 주게 됩니다.
-
원시 포인터는 두 가지 방법으로 상수성(constness)을 부여할 수 있습니다. 한 가지 방법은 포인팅 받는 객체에 대해
const를 선언해 주는 것이며, 다른 하나는 포인터 자신에 대해 const를 선언해 주는 것입니다. 이와 마찬가지로,
SmartPrt 역시 두 가지 방법으로 상수성을 부여할 수 있습니다. 다음의 코드는 이러한 두 가지 방법의 예를 나타내 주고
있습니다.
포인터의 두 가지 상수성 예제 코드 보기
- const Something* pc = new Something;
- pc->ConstMemberFunction();
- pc->NonConstMemberFunction();
- delete pc;
-
- Something* const cp = new Something;
- cp->NonConstMemberFunction();
- cp = new Something;
-
- const Something* const cpc = new Something;
- cpc->ConstMemberFunction();
- cpc->NonConstMemberFunction();
- cpc = new Something;
-
-
-
-
- SmartPtr<const Something> spc(new Something);
-
- const SmartPtr<Something> scp(new Something);
-
- const SmartPtr<const Something> scpc(new Something);
포인터의 두 가지 상수성 예제 코드 접기
SmartPrt 클래스 템플릿은 부분 특화를 사용하거나, 혹은 2장에서 정의된 TypeTraits 템플릿을 사용하여
포인팅 받는 객체가 상수 자료형인지의 여부를 감지해 낼 수 있습니다. 물론, TypeTraits을 사용하는 방법이 더 좋습니다.
왜냐하면, 부분 특화의 경우에는 어느 정도의 중복 코드가 필요한 반면, 이것은 소스 코드의 중복이 전혀 필요하지 않기 때문입니다.
-
많은 경우에 있어서 힙 영역에 할당된 배열 및 new[]와 delete[]를 다루는 것보다는 std::vector를 사용하는 것이
훨씬 편리합니다.
하지만, 어떤 경우에는 동적으로 할당된 배열만이 프로그래머가 원하는 전부인 경우도 있습니다. 이런 특수한 경우에 스마트
포인터의 능력을 활용할 수 없다면, 매우 안타까운 일이 될 것입니다.
배열에 대한 스마트 포인터라는 관점에서 보면, 중요한 과제는 오직 파괴자 내에서 delete pointee_를 호출하는 대신
delete[] pointee_를 호출하도록 만들어 주는 것뿐입니다. 그리고 이러한 과정은
Ownership 단위전략을 통하여 추적된다는 사실을 우리는 이미 살펴보았습니다.
2차적인 문제로 스마트 포인터의 operator[] 연산자를 오버로드 하여 색인에 의한 접근법을 제공하는 것에 대해서도
살펴보아야 합니다. 스마트 포인터가 배열을 가리키게 되는 경우는 매우 드물게 나타납니다. 따라서 이러한 경우에는,
우리가 이미 가지고 있는 GetImpl을 통해서, 원시 포인터를 이용한 색인 접근법을 사용하는 것이 더 나아 보입니다.
SmartPrt<Widget> sp = ...;
// sp가 가리키는 배열의
// 6번째 원소에 접근
Widget& obj = GetImpl(sp)[5];
SmartPrt은 OwnerShip 단위전략을 통하여 커스터마이즈가 가능한 파괴자를 허용하고 있습니다. 따라서 프로그래머는 배열에
특화된 파괴 코드인 delete[]를 여기에 작성해 줄 수 있습니다. 하지만, SmartPrt은 스마트 포인터 간의 직접적인
수치 연산은 지원하지 않을 것입니다.
-
스마트 포인터는 객체를 공유하는 목적으로 활용되는 경우가 자주 발생합니다. 그리고 이러한 공유 방식을 결정하는 데 항상 다중
스레딩의 문제를 고려해 주어야 합니다.
스마트 포인터와 다중 스레딩 사이의 상호 동작은 두 레벨에 걸쳐 다루어져야 합니다. 하나는 포인팅 받는 객체의 레벨이며,
다른 하나는 내부 데이터의 레벨입니다.
-
만일 여러 개의 스레드가 동일한 객체에 대해 접근을 하고, 그 객체에 대한 접근이 스마트 포인터를 통하여 이루어진다면,
operator-> 연산자에 의해 멤버 함수가 호출되는 기간 동안 그 객체에 대해 잠금 장치를 거는 것이 바람직할
것입니다. 이것은 스마트 포인터가 원시 포인터를 반환하는 대신 대리 객체를 반환하게 함으로써 구현될 수 있습니다.
대리 객체의 생성자는 포인팅 받는 객체에 대한 잠금 장치를 걸게 되며, 대리 객체의 소멸자가 그 장치를 해제하게 될
것입니다.
먼저, Lock과 Unlock이라는 두 개의 기본 연산을 가지고 있는 Widget 클래스를 생각해 보도록 합시다.
이 두 멤버함수는 뮤텍스와 같은 역할을 할 것입니다.
class Widget
{
...
void Lock(void);
void Unlock(void);
};
다음으로 LockingProxy라는 클래스 템플릿을 정의하도록 합시다. 이것은 어떤 객체에 대해 LockingProxy 객체가
살아 있는 동안 잠금 장치를 걸어주는 것입니다.
LockingProxy 예제 코드 보기
- template <class T>
- class LockingProxy
- {
- public:
- LockingProxy(T* pObj) : pointee_(pObj)
- { pointee_->Lock(); }
-
- ~LockingProxy(void)
- { pointee_->Unlock(); }
-
- T* operator->(void) const
- { return pointee_; }
-
- private:
- LockingProxy& operator=(const LockingProxy&);
- T* pointee_;
- };
-
LockingProxy 예제 코드 접기
LockingProxy는 스마트 포인터와 비슷하게 보이기는 하지만, 여기에는 또 다른 계층인 SmartPrt 클래스 템플릿이
존재합니다.
LockingProxy를 사용하는 SmartPrt 예제 코드 보기
operator-> 연산자의 동작 메커니즘을 설명하고 있는 7.3 섹션을 돌이켜 보면,
컴파일러는 원시 포인터를 만날 때까지 연속해서 operator->를 여러 번 적용하게 된다는 사실을 기억해 낼 수
있습니다. 이제, 다음과 같은 호출이 이루어지는 경우를 생각해 봅시다.
SmartPrt<Widget> sp = ...;
sp->DoSomething();
Widget의 멤버 함수인 DoSomething이 호출되는 동안, 임시 객체인 LockingProxy<T>는 자신의
생명주기를 유지하게 되고, 그동안 해당 객체에 대한 안전한 잠금 장치를 걸게 됩니다. 그 후 DoSomething 함수가
반환되면, 즉시 임시 객체인 LockingProxy<T>는 파괴되고, 이에 따라 Widget에 걸린 잠금 장치는
해제됩니다.
이러한 자동 잠금 장치는 계층화된 스마트 포인터의 좋은 활용 예라고 할 수 있습니다. 여러분들도 이와 같은 방법으로
Storage 단위전략을 변경해 줌으로써 스마트 포인터를 계층화시킬 수 있습니다.
-
때때로 스마트 포인터는 포인팅 받는 객체와 함께 스마트 포인터 자신에게 필요한 부가 데이터들을 다루게 됩니다.
7.5 섹션에서 살펴본 바와 같이, 참조 카운터를 사용하는 스마트 포인터는 참조
카운트와 같은 부가 데이터를 공유해야 합니다. 만일, 어떤 한 객체를 가리키는 스마트 포인터들이 여러 스레드에서
사용되고 있는 경우, 참조 카운트를 위한 잠금 장치를 마련해 주어야 합니다. 한편, 참조 카운트 변수의 경우에는
사용자가 접근 가능한 영역이 아닙니다. 따라서, 그것은 전적으로 스마트 포인터가 관리해야 합니다.
참조 연결 리스트 방식의 스마트 포인터 역시, 복사하거나, 대입하거나, 파괴할 때에는 항상 적절한 잠금 장치를
마련해 주어야 합니다.
결론적으로, 다중 스레딩의 문제는 궁극적인 스마트 포인터의 구현 자체에 영향을 미치게 됩니다.
-
다중 스레드 상에서의 참조 카운팅
여러 스레드 간에 스마트 포인터를 복사하는 경우에는, 서로 다른 스레드가 참조 카운트를 증가시키는
작업이 결국 프로그래머조차 예측할 수 없는 시간에 발생할 수 있습니다.
부록에서 설명하고 있는 바와 같이, 하나의 정수 값을 증가시키는 작업조차도
CPU의 입장에서 볼 경우 하나의 단위 작업이라고 볼 수 없습니다. 다중 스레드 환경에서 정수 값을
증가시키거나, 감소시키기 위해서는 반드시 ThreadingModel<T>::IntType과
AtomicIncrement 그리고 AtomicDecrement 함수를 사용해야만 합니다.
다중 스레딩 환경 하에서 참조 카운팅을 사용하는 경우에는 OwnerShip과 ThreadingModel 요소들이
서로 너무 밀접하게 연관되어 있습니다. 예를 들어 다중 스레딩 모델을 선택하게 되면 카운트 변수는 반드시
ThreadingModel<T>::IntType이 되어야만 합니다. 그리고 operator++이나
operator--를 쓰는 대신, AtomicIncrement나 혹은 AtomicDecrement 함수를 사용해야만
합니다. 스레딩 문제와 참조 카운팅의 문제는 이처럼 서로 복잡하게 얽혀 있습니다. 이 둘은 서로 분리해서
생각하기가 너무 힘든 개념입니다.
결국 선택할 수 있는 최선의 방법은 OwnerShip 단위전략 안에 다중 스레딩에 대한 지원 기능을
포함시키는 것입니다. 그렇게 되면 우리는 참조 카운터와 관련된 두 가지의 단위전략 클래스인
RefCounting 및 RefCountingMT를 가지게 됩니다.
-
다중 스레드 상에서의 참조 연결 리스트
참조 연결 리스트를 가지고 있는 스마트 포인터는 다음과 같은 모양을 가지고 있을 것입니다.
참조 연결 리스트 방식의 스마트 포인터 예제 코드 보기
- template <class T>
- class SmartPtr
- {
- public:
- ~SmartPtr(void)
- {
- if (prev_ == next_)
- {
- delete pointee_;
- }
- else
- {
- prev_->next_ = next_;
- next_->prev_ = prev_;
- }
- }
- ...
-
- private:
- T* pointee_;
- SmartPtr* prev_;
- SmartPtr* next_;
- };
-
-
-
참조 연결 리스트 방식의 스마트 포인터 예제 코드 접기
만일 다중 스레드가 서로 위와 같은 스마트 포인터를 삭제하고자 한다면, 분명 그 소멸자는 단위 동작(다른
스레드에 의해 간섭받지 않는)으로 구성되어야만 합니다. 비슷하게, SmartPrt의 복사 생성자나 대입 연산자의
경우에도, 단위 동작으로 구성되어야 합니다.
하지만 흥미로운 사실은, 우리가 여기에 객체 수준의 잠금 장치를 적용시킬 수 없다는 점입니다. 왜냐하면,
리스트를 관리하는 데 필요한 동작들이 많게는 세 개까지의 객체를 동시에 다루게 되기 때문입니다. 현재
추가되거나 제거되려는 객체, 리스트의 앞 노드에 있는 객체, 그리고 리스트의 뒷 노드에 있는 객체, 이렇게
모두 세 개의 객체를 다루어야 합니다.
만일 객체 수준의 잠금 장치를 사용하고자 한다면, 먼저 포인팅 받는 객체마다 별도로 하나씩의 뮤텍스가
존재해야 한다는 사실을 발견하게 됩니다. 왜냐하면 포인팅 받는 객체마다 하나씩의 연결 리스트가 존재하기
때문입니다. 우리는 각 객체마다 동적으로 하나씩의 뮤텍스를 할당시켜 줄 수 있지만, 이렇게 되면 참조
카운터와 비교해서 참조 연결 리스트가 갖는 장점(힙 영역의 자유 저장 공간을 별도로 필요로 하지 않는 점)을
잃게 됩니다.
다른 방편으로, 참견꾼 참조 카운트와 비슷한 접근 방법을 사용할 수도 있을 것입니다. 하지만, 참조 카운터를
통한 스마트 포인터가 보다 안전하고 효과적인 방법이므로, 굳이 이러한 기능을 제공할 필요는 없다고
판단됩니다.
우리가 다룰 SmartPrt은 참조 연결 리스트에 대해서는 스레드 간 안정성을 보장해 주지 않을 것입니다.
-
우리는 1장에서 설명한 내용과 같이 단위전략에 기초한 클래스 디자인 방식을 이용할 것입니다.
앞 섹션들의 내용을 되새기면서, SmartPrt의 다양한 요소들을 나열해 보도록 합시다. 그 각각의 요소들은 모두 다음과 같은
단위전략으로 표현될 것입니다.
-
Storage 단위전략(7.3 섹션).
기본 설정으로는 저장 자료형은 T*(T는 SmartPrt의 첫 번째 템플릿 인자)이고, 포인터 자료형 역시 T*이며,
참조 자료형은 T&가 됩니다. 그리고 포인팅 받는 객체를 파괴하는 방법으로는 delete 연산자를 사용합니다.
-
OwnerShip 단위전략(7.5 섹션).
이 단위전략의 가장 보편적인 버전에는 완전 복사(Deep Copy), 참조 카운팅, 참조 연결 리스트, 그리고
파괴 복사(Destructive Copy)가 있습니다. OwnerShip 단위전략이 객체의 파괴 메커니즘 자체에는
관여하지 않는다는 사실에 주의하시기 바랍니다. 그것은 Storage 단위전략이 할 일입니다. OwnerShip이
관리하는 것은 오직 객체의 파괴가 일어나는 순간을 결정해 주는 것입니다.
-
Conversion 단위전략(7.7 섹션).
어떤 애플리케이션은 원시 포인터로의 자동 형변환을 필요로 하며, 어떤 애플리케이션은 그런 기능을 필요로 하지 않습니다.
-
Checking 단위전략(7.10 섹션).
이 단위전략은 SmartPrt의 초기화 인자가 적법한 값을 가지고 있는지, 그리고 객체에 대한 접근이 발생하는 순간에
SmartPrt이 적법한 객체를 가리키고 있는지의 여부에 대한 일련의 판단 과정을 제어해 줍니다.
이 밖에 다른 논제들은 독립된 단위전략으로 구성할 만한 가치가 없거나, 또는 다른 부가적인 해법을 가지고 있는 경우입니다.
-
주소 추출 연산자(address-of operator, 7.6 섹션)는 오버로드 되지 않는 것이
가장 좋습니다.
-
동치 여부의 검사는 7.8 섹션에서 소개한 트릭을 통해 다루어집니다.
-
대소 비교 연산(7.9 섹션)은 그 구현을 미완의 상태 그대로 남겨 두었습니다.
하지만 Loki는 std::less를 SmartPrt에 대해 특화시켜 놓고 있으므로 STL의 map 컨테이너 등과 함께
사용하는 데 아무런 지장이 없습니다. 또한, 사용자가 operator< 연산자를 정의하게 되면, Loki가
operator<를 이용하여 자동으로 모든 대소 비교 연산을 완성시키게 될 것입니다.
-
Loki는 SmartPrt에 대한 상수성(constness)을 지원하고, 포인팅 받는 객체에 대한 상수성도 지원합니다.
그리고 양쪽 모두에 대한 const 정의 또한 지원하고 있습니다.
-
SmartPrt은 배열을 지원하기 위한 별도의 장치는 제공하지 않습니다. 하지만, Storage 단위전략을 구현하는
별도의 단위전략 클래스를 정의하여 그 소멸자에 delete[] 연산자를 사용하게 되면, 배열로 선언된 객체를
안전하게 파괴시킬 수 있습니다.
SmartPrt은 우선 첫 번째 템플릿 인자로 포인팅 받는 객체의 자료형이 주어지도록 정의되어 있으며, 나머지 템플릿 인자들에
각 단위전략이 따라오게 됩니다. 따라서 SmartPrt은 다음과 같이 선언될 것입니다.
SmartPrt 선언 예제 코드 보기
- template
- <
- typename T,
- template <class> class OwnershipPolicy = RefCounted,
- class ConversionPolicy = DisallowConversion,
- template <class> class CheckingPolicy = AssertCheck,
- template <class> class StoragePolicy = DefaultSPStorage
- >
- class SmartPtr;
-
SmartPrt 선언 예제 코드 접기
-
Storage 단위전략은 스마트 포인터의 저장 구조를 추상화합니다. 이 단위전략은 몇 가지 사용자 정의 자료형을 정의하고
있으며, 실제 pointee_ 객체를 저장하는 주체입니다.
만일 StorageImpl이라는 클래스가 Storage 단위전략의 구현 클래스이고, storageImpl이
StorageImpl<T> 자료형의 객체라면, [표 7.1]의 내용이 적용됩니다.
Storage 단위전략 예제 코드 보기
- template <class T>
- class DefaultSPStorage
- {
- protected:
- typedef T* StoredType;
- typedef T* PointerType;
- typedef T& ReferenceType;
-
- public:
- DefaultSPStorage(void) : pointee_(Default()) {}
- DefaultSPStorage(const StoredType& p) : pointee_(p) {}
-
- PointerType operator->(void) const { return pointee_; }
- ReferenceType operator*(void) const { return *pointee_; }
-
- friend inline PointerType GetImpl(const DefaultSPStorage& sp)
- { return sp.pointee_; }
-
- friend inline const StoredType& GetImplRef(const DefaultSPStorage& sp)
- { return sp.pointee_; }
-
- friend inline StoredType& GetImplRef(DefaultSPStorage& sp)
- { return sp.pointee_; }
-
- protected:
- void Destroy(void)
- { delete pointee_; }
-
- static StoredType Default(void)
- { return 0; }
-
- private:
- StoredType pointee_;
- };
Storage 단위전략 예제 코드 접기
위와 같은 DefaultSPStorage와 더불어 Loki는 다음의 Storage 단위전략들을 정의해 주고 있습니다.
-
ArrayStorage: 함수 내에서 delete 대신 delete[] 연산자를
사용합니다.
-
LockedStorage: 스마트 포인터의 계층화를 이용하여 저장된 데이터에
객체 접근이 이루어지는 동안 잠금 장치를 걸어주는 역할을 합니다(7.13.1
섹션을 참고하세요).
-
HeapStorage: 저장 중인 데이터에 대해서 명시적으로 소멸자를
호출하고, 실제 할당된 메모리의 해제를 위해서는 std::free 함수를 사용합니다.
[표 7.1] Storage 단위전략의 구성 보기
-
Ownership 단위전략은 보통의 참조 카운팅 기법뿐만 아니라, 참견꾼 참조 카운터도 지원할 수 있어야 합니다.
따라서, 이 단위전략은 생성자/소멸자의 기법을 사용하기보다는 명시적인 함수 호출의 방법을 사용하고 있습니다.
그 이유는 생성자와 소멸자는 오직 정해진 시간에만 자동으로 호출되는 반면, 멤버 함수에 대한 호출은 언제든지
자유롭게 이루어 질 수 있기 때문입니다.
Ownership 단위전략을 구현하는 클래스 템플릿은 포인터 자료형에 따르는 하나의 템플릿 인자를 받게 됩니다.
즉, SmartPrt이 StoragePolicy<T>::PointerType을 Ownership 단위전략에게 템플릿
인자로 넘겨주게 되는 것입니다. 여기서 OwnershipPolicy의 템플릿 인자가 해당 객체에 대한 자료형이
아니라, 포인터 자료형인 것에 주의하기 바랍니다.
만일 OwnershipImpl이 Ownership 단위전략을 구현한 클래스이고, ownershipImpl이
OwnershipImpl<P> 자료형의 객체라고 하면, [표 7.2]의 내용이 성립합니다.
[표 7.2] Ownership 단위전략의 구성 보기
참조 카운팅을 지원하는 Ownership 단위전략에 대한 단위전략 클래스 템플릿은 대략 다음과 같은 코드를 가지게 될 것입니다.
참조 카운팅을 지원하는 Ownership 단위전략 예제 코드 보기
- template <class P>
- class RefCounted
- {
- unsigned int* pCount_;
-
- protected:
- RefCounted(void) : pCount_(new unsigned int(1)) {}
-
- P Clone(const P& val)
- {
- ++*pCount_;
- return val;
- }
-
- bool Release(const P&)
- {
- if (!--*pCount_)
- {
- delete pCount_;
- return true;
- }
- return false;
- }
-
- enum { destructiveCopy = false };
- };
참조 카운팅을 지원하는 Ownership 단위전략 예제 코드 접기
또한, 이와 조금 다른 개념을 가지는 참조 카운팅 단위전략도 매우 쉽게 구현될 수 있습니다. 예를 들어, COM 객체에
대한 Ownership 단위전략 클래스를 한 번 작성해 보도록 합시다.
COM 객체에 대한 Ownership 단위전략 클래스 예제 코드 보기
-
-
- template <class P>
- class COMRefCounted
- {
- public:
- static P Clone(const P& val)
- {
- val->AddRef();
- return val;
- }
-
- static bool Release(const P& val)
- {
- val->Release();
- return false;
- }
-
- enum { destructiveCopy = false };
- };
COM 객체에 대한 Ownership 단위전략 클래스 예제 코드 접기
정리하면, Loki는 다음과 같은 Ownership 단위전략 클래스 템플릿을 정의하고 있습니다.
-
DeepCopy, 7.5.1 섹션:
DeepCopy는 포인팅 받는 객체가 Clone 멤버 함수를 노출하고 있다고 가정합니다.
-
RefCounted, 7.5.3 섹션:
참조 카운팅에 대한 내용은 이번 섹션에서도 자세히 다루어졌습니다.
-
RefCountedMT:
참조 카운팅에 대한 다중 스레드 버전입니다.
-
COMRefCounted:
이번 섹션에서 다룬 참견꾼 참조 카운터의 한 변형입니다.
-
RefLinked, 7.5.4 섹션
-
DestructiveCopy, 7.5.5 섹션
-
NoCopy:
이것은 Clone을 정의하지 않습니다. 따라서 어떤 형태의 복사도 허용하지 않습니다.
-
Conversion 단위전략은 컴파일 시간에 주어지는 상수인 bool 값을 정의하여 이 SmartPrt이 원시 포인터
자료형으로의 자동 형변환을 지원하는지의 여부를 가리키도록 하고 있습니다.
만일 ConversionImpl이 Conversion 단위전략 클래스의 하나라면, [표 7.3]의 내용이 성립합니다.
Loki는 다음과 같이 두 개의 Conversion 단위전략 클래스를 정의하고 있습니다.
-
AllowConversion
-
DisallowConversion
[표 7.3] Conversion 단위전략의 구성 보기
-
7.10 섹션에서 논의된 바와 같이 스마트 포인터 객체의 안정성을 검사하는 위치로는 두 곳의
장소가 있을 수 있습니다.하나는 초기화를 하는 부분이며, 다른 하나는 포인팅 받는 객체에 대한 접근이 이루어지는 곳입니다.
그러한 검사 작업에는 assert 함수, 예외(exception), 또는 느린 초기화 등의 기법을 사용할 수 있으며, 심지어 아무
일도 하지 않는것도 가능합니다.
Checking 단위전략은 Storage 단위전략에서 정의된 PointerType이 아니라 StoredType을 가지고 동작하게
됩니다(Storage 단위전략에 대해서는 7.14.1 섹션을 참고하기 바랍니다).
만일 S가 Storage 단위전략 클래스에서 정의된 저장 자료형이라면, 그리고 CheckingImpl이 Checking
단위전략 클래스의 일종이고, checkingImpl이 CheckingImpl<S> 자료형의 한 객체라고 한다면,
[표 7.4]의 내용이 성립할 것입니다.
Loki는 Checking 단위전략에 대해 다음과 같은 단위전략 클래스 템플릿을 정의하고 있습니다.
-
AssertCheck: 객체 접근 연산(dereferencing)시에 assert
함수를 통하여 포인터의 유효성 검사를 수행합니다.
-
AssertCheckStrict: 스마트 포인터 초기화 시에 assert 함수를
통하여 포인터의 유효성 검사를 수행합니다.
-
RejectNullStatic: OnDefault를 정의하지 않습니다.
따라서, SmartPrt에 대해 기본 생성자를 사용하게 되면, 컴파일 하는 동안에 에러가 발생하게 됩니다.
-
RejectNull: 만일 null 포인터에 대해 객체 접근 연산을
하게 되면 예외를 발생시킵니다.
-
RejectNullStrict: 스마트 포인터의 초기화 시에 null 포인터를
받아들이지 않습니다(역시 예외를 발생시킵니다).
-
NoCheck: 고전적인 C/C++의 방법에 따라 오류를 다루게 됩니다.
즉, 이 경우에는 결국 아무런 검사도 수행하지 않습니다.
[표 7.4] Checking 단위전략의 구성 보기
-
스마트 포인터는 C++ 언어가 제공하는 포인터를 문법적으로, 그리고 또 문맥적으로 흉내내는 객체입니다. 거기에 더하여, 스마트
포인터는 소유권의 관리나 혹은 잘못된 값에 대한 오류 검사 등, 원시 포인터로는 할 수 없는 많은 작업들을 수행할 수 있습니다.
스마트 포인터의 개념은 실제로 원시 포인터의 동작 양식을 넘어서 그보다 더 많은 내용을 담고 있습니다. 스마트 포인터는
모니커(moniker, 포인터 문법을 따르지 않는 핸들. 리소스에 접근하는 방법에 있어서는 포인터와 유사하다)와 같은 리소스라는
개념으로 보다 일반화될 수도 있습니다.
스마트 포인터는 수동으로 다루기에는 힘든 많은 작업들을 멋지게 자동화시켜 줍니다. 따라서, 스마트 포인터는 성공적이고 튼튼한
애플리케이션을 제작하는 데 필수적인 요소가 될 수 있을 것입니다.
이것이 바로 라이브러리 개발자가 왜 가능한 한 최대한의 주의와 노력을 기울여서 스마트 포인터를 만들어 내야 하는지의 이유가
됩니다. 이러한 스마트 포인터를 개발하는 작업은 아마 상당히 긴 시간을 필요로 할 것입니다. 이와 마찬가지로, 스마트 포인터의
사용자는 스마트 포인터가 만들어진 기본 개념을 충분히 이해해야 하며, 그러한 개념에 따라 스마트 포인터를 유효 적절하게 이용해야
합니다.
여기에서 제공된 스마트 포인터 클래스 템플릿은 그 기능적인 요소들을 분리하여 독립적인 단위전략으로 분해하는 데 초점을 맞추어
작성되었습니다. 따라서, 그 중심이 되는 SmartPrt은 이러한 단위전략들을 잘 취사선택하여 사용하게 됩니다.
-
-
SmartPrt의 선언문은 다음과 같습니다.
SmartPrt의 선언 예제 코드 보기
- template
- <
- typename T,
- template <class> class OwnershipPolicy = RefCounted,
- class ConversionPolicy = DisallowConversion,
- template <class> class CheckingPolicy = AssertCheck,
- template <class> class StoragePolicy = DefaultSPStorage
- >
- class SmartPtr;
SmartPrt의 선언 예제 코드 접기
-
T는 SmartPrt이 가리키는 객체의 자료형입니다. T는 언어의 기본 자료형이 될 수도 있고, 사용자 정의 자료형이
될 수도 있습니다. 또한, void 자료형도 허용됩니다.
-
T를 제외한 나머지 템플릿 인자들의 경우에는, 사용자가 자신만의 고유한 단위전략을 구현하여 사용할 수도 있고,
7.14.1 섹션부터 7.14.4 섹션까지에서
설명된 바와 같이 Loki가 기본으로 제공하는 단위전략을 사용할 수도 있습니다.
-
OwnershipPolicy는 소유권 관리 전략을 제어합니다. 사용할 수 있는 미리 정의된 클래스는
7.14.2절에서 설명된 바와 같이 DeepCopy, RefCounted, RefCountedMT,
COMRefCounted, RefLinked, DestructiveCopy 그리고 NoCopy가 있습니다.
-
ConversionPolicy는 원시 포인터로의 암묵적 자동 형변환을 허용할지의 여부를 제어합니다. 기본 값은 암묵적
자동 형변환을 금지하는 것입니다. 하지만 GetImpl 함수를 직접 호출하여 여전히 포인팅 받는 객체에 직접 접근이
가능합니다. 사용할 수 있는 단위전략 클래스로는 AllowConversion과 DisallowConversion이
있습니다(7.14.3 섹션 참조).
-
CheckingPolicy는 사용하고자 하는 오류 검사 전략을 정의해 줍니다. 기본적으로 제공되는 단위전략 클래스에는
AssertCheck, AssertCheckStrict, RejectNullStatic, RejectNull, RejectNullStrict
그리고 NoCheck가 있습니다(7.14.4 섹션).
-
StoragePolicy는 포인팅 받는 객체가 어떻게 저장되고, 또 접근되어야 하는지에 대한 세부 내용을 정의하게 됩니다.
기본 값은 자료형 T를 통해 구체화되는 DefaultSPStorage로서 참조 자료형으로 T&를, 저장 자료형으로
T*를, 그리고 operator->가 반환해 주는 자료형으로 T*를 정의하고 있습니다. Loki에 의해 정의된 다른
저장 자료형에는 ArrayStorage, LockedStorage 그리고 HeapStorage가
있습니다(7.14.1 섹션).
-
가상 함수라는 것은 해당 객체에 대한 동적 자료형의 정보가 미리 알려져 있어야 동작할 수 있지만, 아직 객체가 생성되지 않은 생성자의
입장에서는 이러한 정보에 접근할 수 있는 방법이 전혀 없습니다.
대부분의 경우에, 다형성 객체는 자유 저장 공간에 new 연산자를 사용하여 생성됩니다.
예제 코드 보기
- class Base { ... };
- class Derived : public Base { ... };
- class AnotherDerived : public Base { ... };
-
- ...
-
- Base* pB = new Derived;
-
-
-
-
예제 코드 접기
우리는 new 연산자를 보다 동적으로 동작하도록 만들어줄 수 없습니다. 이것을 사용할 때에는 항상 자료형의 이름을 직접 넘겨주어야 하고,
그 자료형은 컴파일 시간에 항상 정확하게 알려진 상태여야 합니다.
가상 멤버 함수는 유동적이고 동적입니다. 따라서 프로그래머는 호출자의 입장을 바꾸지 않고도 그 동작을 변경시킬 수 있습니다. 반면,
각 객체의 생성 작업은 융통성 없이 정적으로 코딩되어야 하는 일종의 걸림돌입니다. 다형성이 주는 효과 중 하나는 가상 함수의 호출자는
기반 클래스에 대한 인터페이스만 신경을 써주면 된다는 것입니다. 하지만 C++에 있어서, 최소한 객체를 생성해 내는 동안에는 호출자가
구체적인 파생 클래스와 관여하게 되는 일을 피해 갈 방법이 존재하지 않습니다.
객체의 생성 작업에 착수하기 위해서 프로그래머는 자신이 원하는 일이 정확히 무엇인지를 알고 있어야만 합니다. 그러나 때때로 다음과 같은
경우도 있을 수 있습니다.
-
정확한 정보를 알아야 하는 책임을 다른 주체에게 떠넘기고 싶어하는 경우가 있습니다. 예를 들어, new를 직접적으로 호출하는 대신
보다 상위 객체의 Create와 같은 가상 함수를 호출할 수도 있습니다. 이렇게 하면, 클라이언트 코드가 다형성적 특성을 통하여
원하는 동작을 수행할 수 있게 됩니다.
-
자료형에 대한 정보가 이미 주어져 있지만, 그것이 C++에서 표현 가능한 형태가 아닌 경우가 있습니다. 예를 들어,
"Derived"라는 단어를 포함한 문자열이 주어져 있다면, 자료형에 대한 구분자가 아닌 이런 단순한 문자열 데이터는
new 연산자의 후속 인자로 사용할 수가 없습니다.
이러한 두 가지 사항이 객체 팩토리(Object Factory)가 해결하고자 하는 가장 근본적인 문제들입니다. 그리고 우리는 이번 장에서
다음과 같은 주제들을 통해 이 객체 팩토리에 대해 상세하게 다루게 될 것입니다.
-
객체 팩토리가 요구되는 상황의 예
-
가상 생성자가 C++로 구현되기 어려운 이유
-
자료형을 대변하는 값 인자를 통해 객체를 생성하는 방법
-
제네릭한 스타일의 객체 팩토리의 구현
제네릭한 스타일의 객체 팩토리는 다양한 레벨로 커스터마이징 될 수 있습니다. 생산 결과물의 자료형이나 그 생성 방식, 그리고 결과 자료형을
나타내는 방식 등이 모두 커스터마이징 가능한 요소들입니다.
-
기본적으로 객체 팩토리가 필요한 경우는 다음의 두 가지 상황으로 요약될 수 있습니다. 첫 번째로, 라이브러리가 사용자 정의
자료형으로 된 객체를 다루게 될 뿐만 아니라, 그것을 생성하는 일까지 담당해야 하는 경우입니다. 예를 들어, 다중 창을 갖는
문서 편집기의 프레임워크를 개발하는 경우를 생각해 봅시다. 프레임워크는 쉽게 확장이 가능해야 하므로, Document라는 가상
클래스를 제공하고, 사용자가 그것을 상속받아 TextDocument나 HTMLDocument와 같은 파생 클래스를 만들도록 디자인을
해야 할 것입니다. 또한, 열려 있는 모든 문서의 리스트를 관리하기 위해 DocumentManager라는 클래스를 별도로 제공해
주어야 할 것입니다.
여기에 적용될 수 있는 훌륭한 규칙은 애플리케이션에 존재하는 각 문서에 대한 정보를 DocumentManager가 알고 있어야
한다는 것입니다.
DocumentManager 예제 코드 보기
- class DocumentManager
- {
- ...
-
- public:
- Document* NewDocument(void);
-
- private:
- virtual Document* CreateDocument(void) = 0;
- std::list<Document*> listOfDocs_;
- };
-
- Document* DocumentManager::NewDocument(void)
- {
- Document* pDoc = CreateDocument();
- listOfDocs_.push_back(pDoc);
- ...
- return pDoc;
- }
-
-
-
-
-
-
-
- Document* GraphicDocumentManager::CreateDocument(void)
- {
- return new GraphicDocument;
- }
-
-
DocumentManager 예제 코드 접기
이번에는 기획된 프레임워크를 가지고 기존에 디스크에 저장된 문서를 얼어보는 과정을 생각해 보도록 합시다. 어떤 객체를
파일로 저장하게 될 때 실제 그 자료형의 정보를 문자열이나 정수 등의 어떤 구분자로 변환하여 저장하게 될 것입니다.
이와 같이 자료형에 대한 정보가 존재함에도 불구하고, 그 형태는 C++ 객체를 생성하는 데 쓰이기에는 부적절한 경우가
존재합니다.
특정한 저장 공간에서 객체를 읽어들이는 경우에, 그 객체의 자료형은 프로그램 실행 시간에 어떤 정보 자료로써 홀로 주어지게
됩니다. 프로그래머는 이 자료형에 관한 정보를 실제 객체로 변환해 주어야 합니다. 마지막으로, 이렇게 생성된 임시 객체의
가상 함수를 이용하면, 저장 공간으로부터 실제 객체의 내용을 읽어들이는 작업은 쉽게 구현될 수 있습니다.
객체를 순수한 자료형 정보로부터 생성해 내는 작업, 그리고 이를 이용하여 고정된 C++
자료형에 대해 동적인 정보를 적용시키는 작업은 객체 팩토리를 작성하는 데 쓰이는 가장
중요한 주제가 됩니다.
-
C++에 있어서 클래스와 객체는 명확히 분리되어 있는 개념입니다. 클래스는 프로그래머가 생성해 내는 존재이며, 객체는
프로그램 코드가 생성해 내는 존재입니다. 실행 시간에 새로운 클래스를 생성해 낸다는 것은 불가능합니다. 그리고 컴파일
시간에 객체를 생성한다는 것 역시 말이 되지 않습니다.
이와 반대로, 특정 언어에서는 클래스 자체가 객체가 되기도 합니다. 그런 언어에서는 실행 시간에 새로운 클래스를 생성할
수 있으며, 클래스 자체를 복사하거나 변수로 저장하는 일 등이 가능합니다. 만일 C++가 그런 종류의 언어였다면, 아마
다음과 같은 코드가 가능했을 것입니다.
실행 시간에 새로운 클래스를 생성하는 예제 코드 보기
- Class Read(const char* fileName)
-
- Document* DocumentManager::OpenDocument(const char* fileName)
- {
- Class theClass = Read(fileName);
- Document* pDoc = new theClass;
- ...
- }
실행 시간에 새로운 클래스를 생성하는 예제 코드 접기
이런 동적인 언어들은 자료형과 관련된 안정성 문제와 성능상의 문제 사이에 트레이드 오프를 가지게 됩니다. C++은 이와는 다르게,
정적 자료형 체계를 사용하면서, 그러한 환경 속에서 가능한 충분한 유연성을 제공하고자 노력하고 있습니다.
따라서 C++에 있어서 객체 팩토리를 구현한다는 것은 매우 복잡한 일입니다. 우리는 어떻게든 자료형을 객체로서 표현할 수 있는
대체 수단이 필요합니다. 그렇다면, 우리는 특정한 트릭을 사용하여 값을 올바른 자료형으로 바꾸어 줄 수 있는 방법이 필요하다는
이야기가 됩니다.
우리는 앞으로 자료형을 나타내는 객체를 일컬어 타입 식별자(type identifier)라
부르도록 하겠습니다(이것과 typeid를 혼동하지 마시기 바랍니다). 타입 식별자는 객체 팩토리가 적절한 자료형의 객체를
생성하는 데 도움을 줍니다. 그리고 때로는, 지금 가지고 있는 자료형이 무엇인지, 그리고 앞으로 가지게 될 자료형이 무엇인지
정확히 모르는 상황에서도, 타입 식별자 객체를 변화시킬 수 있을 것입니다.
-
여러분이 간단한 그림판 애플리케이션을 작성한다고 생각해 봅시다. 이것은 선, 커브, 폴리곤 등으로 구성되는 벡터 그래픽을
편집할 수 있어야 합니다. 고전적인 객체지향적 방법에 따르면, 모든 도형 클래스의 기반 클래스가 되는 Shape라는 추상 클래스를
정의해야 할 것입니다.
그리고 나면 복잡한 그리기 연산 기능을 담당하는 Drawing이라는 클래스를 정의할 수 있을 것입니다. Drawing 클래스는
근본적으로 Shape의 포인터를 리스트나 벡터 혹은 그 밖의 계층 구조 등을 통해 저장하고 있어야 합니다. 그리고 필요한
여러 동작들을 제공해 주어야 합니다. 보통 예측할 수 있는 전형적인 동작 두 가지는, 파일로 그림을 저장하는 작업과 기존에
저장된 파일로부터 그림을 읽어들이는 작업을 들 수 있을 것입니다.
도형을 저장하는 것은 매우 쉽습니다. Shape::Save(std::ostream&)와 같은 순가상 함수를 제공하기만 하면 됩니다.
Shape와 Drawing 예제 코드 보기
- class Shape
- {
- public:
- virtual void Draw(void) const = 0;
- virtual void Rotate(double angle) = 0;
- virtual void Zoom(double zoomFactor) = 0;
- ...
- };
-
- class Drawing
- {
- public:
- void Save(std::ofstream& outFile);
- void Load(std::ifstream& inFile);
- ...
- };
-
- void Drawing::Save(std::ofstream& outFile)
- {
- 저장 작업을 위한 준비 코드;
- for (각 그림 원소들을 모두 순환)
- {
- (현재 선택된 원소)->Save(outFile);
- }
- }
Shape와 Drawing 예제 코드 접기
우선, 그림 파일을 읽어들이는 가장 직관적인 구현은 Shape를 상속받은 객체들이 그 시작에 정수로 된 타입 식별자 ID를 저장하게
만드는 것입니다. 물론, 각 겍체들은 자신만의 고유 ID를 가지고 있습니다. 그러면 파일을 읽는 작업은 다음과 같이 코딩될 것입니다.
바람직하지 않은 객체 팩토리 예제 코드 보기
- namespace DrawingType
- {
- const int
- LINE = 1,
- POLYGON = 2,
- CIRCLE = 3;
- };
-
- void Drawing::Load(std::ifstream& inFile)
- {
-
- while (inFile)
- {
-
- int drawingType;
- inFile >> drawingType;
-
-
- Shape* pCurrentObject;
- switch (drawingType)
- {
- using namespace DrawingType
- case LINE:
- pCurrentObject = new Line;
- break;
- case POLYGON:
- pCurrentObject = new Polygon;
- break;
- case CIRCLE:
- pCurrentObject = new Circle;
- break;
- default:
- 알 수 없는 자료형에 대한 에러 처리
- }
-
-
- pCurrentObject->Read(inFile);
-
- 이제 이 객체를 컨테이너에 집어넣는 코드를 작성합니다;
- }
- }
바람직하지 않은 객체 팩토리 예제 코드 접기
위 예제 코드의 문제점은 바로 이 방법이 자체적으로 객체지향의 가장 중요한 규칙을 어기고 있다는 점입니다.
-
여기서는 자료형의 종류에 따른 switch 문을 사용하고 있습니다. 그리고 이에 따라 객체 지향 프로그램에서 지양하고자
하는 문제점이 저절로 발생합니다.
-
이것은 Shape의 모든 파생 클래스들에 대해서 프로그램 내의 한 소스파일이 모든 자료형 정보를 알고 있어야 한다는
사실을 가정하고 있습니다. 물론, 이것 역시 마땅히 피해가아 햘 방법입니다.
-
이 방법은 확장성이 떨어집니다. 한 번 Ellipse(타원)와 같은 새로운 도형에 대한 지원 코드를 추가한다고 생각해
보시기 바랍니다. 이 경우 클래스 자신을 추가해 주는 것과 더불어 DrawingType 네임스페이스에 별도로 정수로 된
타입 식별자 상수를 추가해 주어야 합니다. 그리고 Ellipse 객체를 저장할 때 그 상수 값을 같이 저장해 주어야
하며, 여기에 대한 switch 문의 case를 Drawing::Load 함수에 작성해 주어야 합니다. 그리고 이 모든
작업들이 단 하나의 함수로 구현되어야 합니다.
우선 추구해 볼 만한 가치가 있는 한 구체적인 목표로는 switch 구문을 분리해 내는 것을 들 수 있습니다. 그렇게 하면 우리는
Line을 생성하는 구문을 Line 클래스의 구현 소스 파일에 담아 낼 수 있을 것입니다. 그리고 Polygon이나 Circle 등의
클래스들도 자신의 소스 파일에서 이를 처리하도록 만들어 줄 수 있을 것입니다.
따로따로 떨어진 코드의 각 조각을 하나로 다룰 수 있는 가장 보편적인 방법은 5장에서 자세히 다루었던
함수 포인터를 이용하는 것입니다. 그리고 여기에 사용할 커스터마이징 가능한 코드의 기본 단위는 다음과 같은 모습의 함수로
추상화될 수 있습니다.
Shape* CreateConcreteShape(void);
쉽게 말해서 팩토리는 위와 같은 프로토타입을 가지는 함수 포인터 목록을 유지하게 됩니다. 그리고 해당 자료형의 타입 식별자와
거기에 맞는 객체를 생성해 주는 생성 함수 사이에 서로 간의 상관 관게를 맺어주게 됩니다. 즉, 우리에게 필요한 것은
상관 관계를 이루는 집합, 쉽게 말하자면 맵(map) 자료구조입니다. switch 구문과는
달리, map은 실행 시간에 구조가 추가될 수 있습니다. 우리는 빈 map에서 시작하여, 각 Shape의 파생 클래스가 자신을
이 목록에 추가시키도록 만들 수 있습니다.
여기에서 벡터를 사용하지 않는 이유는, 벡터의 인덱스가 항상 인접한 값이어야 한다는 제약이 있기 때문입니다.
우리는 이제 ShapeFactory라는 클래스를 작성하고자 합니다.
ShapeFactory 예제 코드 보기
- class ShapeFactory
- {
- public:
- typedef Shape* (*CreateShapeCallback)(void);
-
- private:
- typedef std::map<int, CreateShapeCallback> CallbackMap;
-
- public:
-
- bool RegisterShape(int shapeId,
- CreateShapeCallback createFn);
-
-
- bool UnregisterShape(int shapeId);
-
- Shape* CreateShape(int shapeId);
-
- private:
- CallbackMap callbacks_;
- };
-
-
-
-
-
-
- Shape* CreateLine(void)
- {
- return new Line;
- }
-
-
-
-
- namespace
- {
- Shape* CreateLine(void)
- {
- return new Line;
- }
-
-
- const int LINE = 1;
-
-
-
- const bool registered =
- TheShapeFactory::Instance().RegisterShape(LINE, CreateLine);
- }
-
-
-
-
- bool ShapeFactory::RegisterShape(int shapeId,
- CreateShapeCallback createFn)
- {
- return callbacks_.insert(
- CallbackMap::value_type(shapeId, createFn)).second;
- }
-
- bool ShapeFactory::UnregisterShape(int shapeId)
- {
- return callbacks_.erase(shapeId) == 1;
- }
ShapeFactory 예제 코드 접기
만일 여러분께서 std::map 클래스 템플릿에 익숙하지 않다면, 위의 코드를 이해하는 데 약간의 설명이 필요할 것입니다.
-
std::map은 key와 data의 순서쌍을 저장합니다. 우리의 경우에는, key 값이 곧 정수로 된 도형의 타입 식별자
ID가 되며, data는 이에 대한 생성 함수의 함수 포인터로 구성됩니다. 우리가 사용하는 순서쌍은
std::pair<const int, CreateShapeCallback> 자료형으로 정의될 수 있습니다.
그리고 insert 함수를 호출할 때에는 이 순서쌍 자료형의 객체를 넘겨주어야 합니다. 이 템플릿 자료형은
매우 많은 타이핑을 필요로 하기 때문에, 대신 std::map 내부에 정의되어 있는 사용자 정의 자료형(typedef)
value_type을 사용하는 것이 좋을 것입니다. 또한, 이를 대신하여 std::make_pair를 사용할 수도 있을
것입니다.
-
우리가 호출하는 insert 멤버 함수는 또 다른 형태의 key-data 순서쌍을 반환합니다. 여기서 반환되는
순서쌍은, 방금 추가된 원소를 가리키는 반복자와 하나의 bool 값으로 이루어져 있습니다. 이 중 bool 값은
지금 추가된 원소가 새로운 원소라면 true를 가리키며, 이미 존재하던 원소라면 false를 가리키게 됩니다.
그리고 .second 멤버에 접근하면 이 결과를 나타내는 bool값을 얻게 되고, 이것을 다시 RegisterShape
함수의 반환 값으로 전달시키게 됩니다.
-
erase는 실제로 삭제된 원소의 개수를 반환해 줍니다.
CreateShape 멤버 함수는 단순히 주어진 타입 식별자 ID로부터 적절한 생성 함수의 포인터를 찾아내고 그것을 호출해
주는 작업만을 수행합니다. 만일 에러가 발생하는 경우에는 예외를 발생시키게 될 것입니다.
ShapeFactory::CreateShape 예제 코드 보기
- Shape* ShapeFactory::CreateShape(int shapeId)
- {
- CallbackMap::const_iterator i = callbacks_.find(shapeId);
-
- if (i == callbacks_.end())
- {
-
- throw std::runtime_error("Unknown Shape ID");
- }
-
- return (i->second)();
- }
ShapeFactory::CreateShape 예제 코드 접기
-
이제 남아있는 유일한 문제는 타입 식별자(Type Identifiers)를 관리해 주는 일입니다. 여전히 타입 식별자를 추가해 주는
일에는 중앙에서 관리되는 특별한 규칙이 필요합니다. 새로운 Shape의 파생 클래스를 작성할 때마다 프로그래머는 기존에 존재하는
모든 타입 식별자를 검색하여 충돌하지 않는 값을 찾아내야 합니다.
우리는 int보다 더 일반화된 자료형을 통하여 타입 식별자를 표현함으로써 이러한 문제를 해결할 수 있습니다. 어떤 자료형이
맵의 key 값으로 사용되기 위해서 필요한 일은 단지 operator==및 operator< 연산자를 제공하는 것뿐입니다(이것이
벡터 대신 맵을 선택한 이유 중 하나입니다). 예를 들어, 우리는 정수 대신 문자열을 key 값으로 사용할 수 있으며, 각각의
클래스가 자신의 이름으로 표현될 수 있다는 규칙을 세울 수가 있습니다.
std::type_info 클래스가 가지는 name 멤버 함수를 이용해 보는 것은 어떨까요? 하지만, 이는 좋지 못한 선택입니다.
이것의 문제는 다른 모든 C++ 컴파일러에게도 적용되는 사실은 아니라는 점입니다. type_info::name이 반환하는 문자열이
실제적인 클래스의 이름이 된다거나 하는 보장은 전혀 없습니다. 게다가, 그 문자열이 애플리케이션 내에서 유일성을 가진다는
보장도 없습니다. 가장 치명적인 문제는, 그 반환되는 이름이 시간의 흐름에 따라 변치 않는다는 보장도 없다는 것입니다.
다시 돌아와서, 타입 식별자를 생성해 내는 데 사용할 수 있는 지방 분권적 해법은 별도의 고유 값 생성기를 사용하는 것입니다.
예를 들어, 무작위 숫자나, 혹은 무작위 문자열을 생성해 내는 생성기가 있을 수 있습니다. 언뜻 보기에 불안정한 해법으로
보이지만, 만일 무작위 문자열 생성기가 1000년 동안 중복된 값을 만들어 낼 확률이 1/1020 정도라면, 완벽한 구조의
팩토리를 사용하는 것보다 오류가 일어날 확률이 오히려 더 적다고 판단할 수 있습니다.
여기서 도출될 수 있는 유일한 결론은 바로 타입 식별자의 관리라는 것이 객체 팩토리가 책임져야 할 일이 아니라는 것입니다.
C++가 유일하고 지속적인 고유 ID를 보장해 줄 수 없기 때문에, 자료형 ID의 관리는 언어 외적인 문제로 프로그래머에게
남겨지게 됩니다.
-
이제 객체 팩토리와 관련된 보다 세부적인 요소들을 나열해 보도록 합시다.
-
구체적 결과물(Concrete product): 객체 팩토리는 정해진 객체의 형태로
결과물을 만들어 낼 수 있어야 합니다.
-
추상적 결과물(Abstract product): 객체 팩토리가 생성하는 결과물은
보통 어떤 기반 자료형을 상속하는 파생 클래스 입니다. 따라서 이 결과물과 기반 자료형은 서로 동일한 계층 구조에
속하는 자료형을 가져야 합니다. 여기서 계층 구조의 기반 클래스 자료형을 일컬어 바로 추상적 결과물이라 볼 수
있습니다. 객체 팩토리는 다형성을 지원하는 동작을 수행한다고 말할 수 있는데, 이것은 객체 팩토리가 구체적 결과물의
자료형 정보를 알려주는 대신, 단지 추상적 결과물에 대한 포인터를 반환한다는 뜻입니다.
-
결과 타입 식별자(Product type identifier): 이것은 구체적 결과물의
자료형을 나타내는 객체입니다. 이미 논의된 바와 같이, C++의 자료형 체계에 의하여 우리는 결과물을 생산하기 위해
이러한 별도의 타입 식별자를 가져야만 했습니다.
-
결과물 생성자(Product creator): 이 함수 혹은 함수자는 정확히 한 가지
자료형의 객체를 생성하는 용도로 특화된 함수(또는 함수자)입니다. 우리는 앞의 예제에서 이미 결과물 생성자로 함수
포인터를 사용하는 모델을 사용해 보았습니다.
나열된 개념들은 Factory 템플릿 클래스의 템플릿 인자로 표현될 수 있을 것처럼 보이지만, 여기에는 한 가지 예외가 있습니다.
Factory로서는 구체적인 결과물에 대한 정보가 사실 필요가 없습니다. 우리는 Factory를 구체적인 자료형으로부터 분리시키려는
시도를 하는 중입니다. 즉, 오직 서로 다른 추상 자료형에 대해서만 서로 다른 Factory 자료형을 가지게 되기를 원한다는 것입니다.
Factory 예제 코드 보기
- template
- <
- class AbstractProduct,
- typename IdentifierType,
- typename ProductCreator
- >
- class Factory
- {
- public:
- bool Register(const IdentifierType& id, ProductCreator creator)
- {
- return associations_.insert(
- AssocMap::value_type(id, creator)).second;
- }
-
- bool Unregister(const IdentifierType& id)
- {
- return associations_.erase(id) == 1;
- }
-
- AbstractProduct* CreateObject(const IdentifierType& id)
- {
- typename AssocMap::const_iterator i =
- associations_.find(id);
-
- if (i != associations_.end())
- {
- return (i->second)();
- }
- 오류 처리 코드;
- }
-
- private:
- typedef std::map<IdentifierType, ProductCreator>
- AssocMap;
-
- AssocMap associations_;
- };
Factory 예제 코드 접기
이제 남은 유일한 일은 오류에 대한 처리뿐입니다. 만일 우리가 이 팩토리에 등록된 적절한 생성자를 찾아내지 못했다면 다음과
같은 일 중 하나를 할 수 있을 것입니다. 예외를 발생시킴, null 포인터를 반환, 프로그램을 중단시킴, 필요한 라이브러리를
동적으로 로드하여 그 생성자를 즉시 등록시킨 후 재시도
제네릭한 스타일의 객체 팩토리는 사용자가 이러한 동작 중에서 원하는 것을 고를 수 있도록 하는 커스터마이징 기능을 가지고
있어야 합니다. 따라서, 오류 처리 코드는 CreateObject 멤버 함수가 아닌 별도의 FactoryError
단위전략(1장 참조)으로 분리시켜 다루어져야 합니다. 이 단위전략은 오직 하나의 함수인
OnUnknownType만을 정의하고 있습니다.
FactoryError로 정의된 단위전략은 매우 간단합니다. FactoryError는 두 개의 템플릿 인자를 사용합니다. 그 하나는
IdentifierType이며, 다른 하나는 AbstractProduct입니다. 만일 FactoryErrorImpl이 FactoryError
단위전략에 대한 구현 클래스라면 다음과 같은 구문이 적용될 수 있을 것입니다.
FactoryError 예제 코드 보기
- FactoryErrorImpl<IdentifierType, AbstractProduct> factoryErrorImpl;
- IdentifierType id;
- AbstractProduct* pProduct = factoryErrorImpl.OnUnknownType(id);
-
-
-
-
FactoryError 예제 코드 접기
그러면 이번에는 이 추가 사항 및 변경 사항이 적용된 코드를 작성해 보도록 합시다.
Factory::CreateObject 예제 코드 보기
-
- template
- <
- class AbstractProduct,
- typename IdentifierType,
- typename ProductCreator,
- template <typename, class>
- class FactoryErrorPolicy
- >
- class Factory
- : public FactoryErrorPolicy<IdentifierType, AbstractProduct>
- {
- public:
- AbstractProduct* CreateObject(const IdentifierType& id)
- {
- typename AssocMap::const_iterator i = associations_.find(id);
-
- if (i != associations_.end())
- {
- return (i->second)();
- }
-
- return OnUnknownType(id);
- }
-
- private:
- ... 나머지 함수 및 데이터 멤버들은 앞의 코드와 동일합니다 ...
- };
Factory::CreateObject 예제 코드 접기
FactoryError 단위전략에 대한 기본 구현 클래스가 하는 일은 예외를 발생시키는 일입니다. 이 예외 클래스는 다른 모든
자료형들과 구분되도록 잘 구성되어 있기 때문에, 클라이언트 코드는 이것을 원활히 구분해 낼 수 있습니다. 또한, 이 예외
클래스는 표준 예외 클래스를 상속받고 있으므로, 클라이언트 코드는 모든 종류의 오류를 단 하나의 catch 문 블록을 통해
잡아낼 수 있습니다.
DefaultFactoryError 예제 코드 보기
- template <class IdentifierType, class ProductType>
- class DefaultFactoryError
- {
- public:
- Exception : public std::exception
- {
- public:
- Exception(const IdentifierType& unknownId)
- : unknownId_(unknownId)
- {
- }
-
- virtual const char* what(void)
- {
- return "Unknown object type passed to Factory.";
- }
-
- const IdentifierType GetId(void)
- {
- return unknownId_;
- }
-
- private:
- IdentifierType unknownId_;
- };
-
- protected:
- ProductType* OnUnknownType(const IdentifierType& id)
- {
- throw Exception(id);
- }
- };
-
DefaultFactoryError 예제 코드 접기
-
사실, Loki의 Factory 구현 코드는 std::map을 사용하고 있지 않습니다. Loki는 map을 대신해서 간단히 사용할 수 있는
AssocVector를 사용하고 있습니다. AssocVector는 각 원소의 삽입 과정이 드물게 발생하고, 대신 검색 작업이 자주
발생하는 경우에 최적화된 자료 구조입니다(AssocVector에 대한 자세한 설명은 11장을 참고하세요).
Factory의 초기 버전에서는, 템플릿 인자의 덕으로 map 자료형이 사용자의 필요에 따라 커스터마이징 가능했었습니다. 하지만,
점차 AssocVector가 우리의 요구에 훨씬 잘 맞는다는 사실을 알게 되었고, 더불어 표준 컨테이너를 템플릿 템플릿 인자로
사용하는 것은 사실 표준 용법이라고 할 수 없다는 사실을 깨달았습니다. 왜냐하면, 표준 컨테이너의 구현자들은 언제나 새로운
템플릿 인자가 추가될 가능성을 내포하고 있기 때문입니다.
그러면 이제 ProductCreator 템플릿 인자를 살펴보도록 합시다. 이것의 주된 요구 사항은, 이것이 아무런 인자를 받지 않고
AbstractProduct*로 형변환이 가능한 포인터를 반환하는, 함수로서의 동작을 수행해야 한다는 것입니다. 일반적인 new를
통한 객체의 생성 작업일 경우에는 단순한 함수 포인터만으로도 만족스럽게 동작할 것입니다. 따라서 우리는 다음과 같은 자료형을
정의하고,
// 어떤 인자도 받지 않고, 반환 타입이
// AbstractProduct*인 함수포인터
// 자료형
AbstractProduct* (*)(void)
이것을 ProductCreator의 기본 자료형으로 사용하게 됩니다.
그런데 5장에서 다루었던 Functor를 ProductCreator의 템플릿 인자로 사용하면, 훨씬
뛰어난 유연성을 얻을 수 있습니다. Functor<AbstractProduct*>를 사용하면, 단순한 전역 함수를 불러 줄 수도
있고, 어떤 객체에 대한 멤버 함수를 사용할 수도 있고, 함수자를 사용할 수도 있으며, 심지어 함수자의 인자를 적절한 값으로
바인딩시킬 수도 있습니다.
Factory 클래스 템플릿의 선언문은 이제 다음과 같이 표현될 것입니다.
Factory 클래스 템플릿의 선언 예제 코드 보기
- template
- <
- class AbstractProduct,
- class IdentifierType,
- class ProductCreator = AbstractProduct* (*)(void),
- template <typename, class>
- class FactoryErrorPolicy = DefaultFactoryError
- >
- class Factory;
Factory 클래스 템플릿의 선언 예제 코드 접기
-
다형성 객체에 대한 포인터만을 가지고, 그것에 대한 정확한 복사 작업을 통해 객체를 생성하는 상황을 생각해 봅시다. 우리는
코딩 과정에서 다형성 객체의 구체적인 정확한 자료형 정보를 알지 못하기 때문에, 정확히 어떤 객체를 새로 생성해야 하는지를
컴파일 시간 이전에 예측할 수는 없습니다.
우리가 이미 실제 객체를 가지고 있기 때문에, 이를 이용하여 다형적 규칙에 의한 전통적인 접근 방법을 사용할 수 있습니다.
기반 클래스에 Clone 멤버 함수를 가상으로 선언하여, 모든 파생 클래스가 그것을 오버라이드 하도록 만드는 것입니다.
Clone 예제 코드 보기
- class Shape
- {
- public:
- virtual Shape* Clone(void) const = 0;
- ...
- };
-
- class Line : public Shape
- {
- public:
- virtual Line* Clone(void) const
- {
- return new Line(*this);
- }
- ...
- };
-
-
-
Clone 예제 코드 접기
이와 같은 기법은 잘 동작하지만, 다음과 같은 몇 가지 중대한 단점이 존재합니다.
-
만일 기반 클래스가 복제 기능을 지원하지 않도록 디자인되었다면(즉, Clone과 같은 기능을 하는 함수를 가상으로
선언하고 있지 않다면), 게다가 여기에 수정을 가할 방법도 없는 상황이라면, 이 기법은 적용될 수 없습니다.
주어진 라이브러리가 제공하는 기반 클래스를 상속받아 자신의 클래스를 작성하여 사용하는 애플리케이션을 작성하는
경우에 이와 같은 경우가 발생할 수 있습니다.
-
모든 클래스에 코드 수정을 가할 수 있다 하더라도 이 기법은 매우 고도의 주의력을 필요로 합니다. 어떤 파생
클래스에서 Clone을 구현하는 일을 잊고 지나간다 하더라도, 컴파일러는 이 오류를 발견해 낼 수 없으며,
실행 시간에 무언가 기괴하고도 치명적인 오류를 유발하게 될 것입니다.
두 번째 문제를 해결할 수 있는 조금 복잡한 방법이 있기는 합니다. 우선 Clone을 가상 함수가 아닌 상태로 public
영역에 정의합니다. 그리고 그 안에서 private으로 선언된 가상 함수 DoClone을
호출하도록 합니다. 그리고 최종적으로 실행 시간에 동적 자료형이 일치하는지의 여부를 검사하도록 합니다.
개선된 Clone 예제 코드 보기
- class Shape
- {
- ...
-
- public:
- Shape* Clone(void) const
- {
-
- Shape* pClone = DoClone();
-
-
-
- assert(typeid(*pClone) == typeid(*this));
-
- return pClone;
- }
-
- private:
- virtual Shape* DoClone(void) const = 0;
- };
-
-
개선된 Clone 예제 코드 접기
하지만, 쉽게 예상하는 바와 같이 Clone을 오버라이드 하거나, DoClone을 public으로 선언하는 등의 프로그램 오류들이
숨어 있을 가능성은 여전히 남아 있습니다.
복제 팩토리는 앞서 언급한 두 가지 문제점을 가지지 않는 훌륭한 해법을 제시해 줍니다. 이것은 가상 호출을 사용하는 대신에,
map 자료구조를 가지고 필요한 자료형 검색 작업 및 함수 포인터를 통한 함수 호출 작업을 수행하게 됩니다.
지금까지 다루었던 Factory와 복제 팩토리는 몇 가지 차이점을 가지고 있습니다. 복제 팩토리의 IdentifierType은
AbstractProduct에 대한 포인터 자료형입니다. 즉, 복제 Factory에 넘겨주는
자료형은 포인터이며, 반환받는 값 역시 복제된 객체에 대한 또 다른 포인터라는 것입니다. 또 중요한 차이점은, 복제
팩토리에서는 결과물 생성자가 복제될 객체에 대한 포인터를 요구한다는 점입니다.
그러면 맵 자료 구조에서 키 값의 역할은 누가 하게 될까요? 당연하지만, AbstractProduct에 대한 포인터는 그 후보가 될 수
없습니다. 우리에게 필요한 것은 오직 복제될 객체에 대한 자료형 개수만큼의 맵 공간입니다. 이러한 사실은 우리에게 다시
std::type_info 클래스에 대한 기억을 환기시켜 줍니다.
정리해 봅시다.
-
CloneFactory는 std::type_info 대신 TypeInfo를 사용합니다(2장 참조).
-
더 이상 IdentifierType은 필요하지 않습니다. 왜냐하면 그것은 이미 암시적으로 정의되고 있기 때문입니다.
-
ProductCreator 템플릿 인자의 기본 자료형은 AbstractProduct* (*)(AbstractProduct*)입니다.
-
IdToProductMap은 이제 AssocVector<TypeInfo, ProductCreator>를 사용하여 정의됩니다.
결과적으로 CloneFactory 클래스 템플릿은 다음과 같은 정의를 가지게 될 것입니다.
CloneFactory 예제 코드 보기
- template
- <
- class AbstractProduct,
- class ProductCreator =
- AbstractProduct* (*)(AbstractProduct*),
- template <typename, class>
- class FactoryErrorPolicy = DefaultFactoryError
- >
- class CloneFactory
- {
- public:
- AbstractProduct* CreateObject(const AbstractProduct* model);
-
- bool Register(const TypeInfo&,
- ProductCreator creator);
-
- bool UnRegister(const TypeInfo&);
-
- private:
- typedef AssocVector<TypeInfo, ProductCreator>
- IdToProductMap;
-
- IdToProductMap associations_;
- };
CloneFactory 예제 코드 접기
-
Functor가 전역적인 속성을 가지고 있기 때문에, 이것을 6장에서 다룬 SingletonHolder와
함께 사용한다는 것은 매우 자연스러운 일입니다. 이들을 연동하여 사용할 때에는 typedef를 이용하면 매우 편리합니다.
SingletonHolder와 Factory의 조합 예제 코드 보기
8.6 섹션에서 언급된 바와 같이, Factory와 Functor 역시 흥미로운 방법으로 조합되어
사용할 수 있습니다.
Functor와 Factory의 조합 예제 코드 보기
-
객체 팩토리(Object Factory)는 다형성의 개념을 사용하는 프로그램에 있어서 매우 중요한 컴포넌트 요소입니다. 팩토리는
생성하고자 하는 객체의 자료형이 알려져 있지 않거나, 또는 언어가 요구하는 구조화 호환되지 않는 형태로 제공되는 경우에 큰
도움이 됩니다.
객체 팩토리는 다양한 객체지향 프레임워크나 라이브러리에서 사용되고 있으며, 또한 객체를 보조 저장 공간에 스트림의 개념으로
저장하여 사용하는 데에서도 유용하게 사용될 수 있습니다. 근본적인 해법은 자료형에 따른 선택문을 각 클래스의 구현 파일 속으로
분배시키는 것입니다. 팩토리가 객체를 생성하는 중앙집권적 기능을 담당하고 있음에도 불구하고, 클래스 계층 구조상의 모든
자료형에 대한 정보를 알고 있어야 할 의무는 가지지 않습니다. 대신에, 각 자료형들이 자기 자신을 Factory에 등록시켜야
한다는 책임을 가지게 됩니다.
C++ 애플리케이션이 실행되는 동안에 자료형에 대한 정보를 쉽게 저장하고 읽어 올 수 있는 직접적인 방법은 존재하지 않습니다.
때문에, 사용될 자료형을 나타내는 타입 식별자가 대신 사용됩니다. 이것들은 5장에서 설명된 일반화
함수자의 형태로 호출 가능한 결과물 생성자(Product creator) 객체와 연결됩니다. 이러한 개념에서 시작한 객체 팩토리는
구체적으로 Factory라는 일반화된 클래스 템플릿의 형태로 구현되어 있습니다.
마지막으로, 우리는 다형성 객체를 복사할 수 있는 복제 팩토리에 대해서도 논의를 해 보았습니다.
-
-
Factory의 선언문은 다음과 같습니다.
Factory의 선언 예제 코드 보기
- template
- <
- class AbstractProduct,
- class IdentifierType,
- class ProductCreator = AbstractProduct* (*)(void),
- template <typename, class>
- class FactoryErrorPolicy = DefaultFactoryError
- >
- class Factory;
Factory의 선언 예제 코드 접기
-
AbstractProduct는 Factory에게 제공되는 클래스 계층 구조의 최상위 기반 클래스입니다.
-
IdentifierType은 계층 구조상의 특정 자료형을 나타내는 하나의 "인터넷 쿠기"와 같은 개념의 자료형입니다.
이것은 정렬 가능한 자료형이어야 합니다(std::map에 저장되기 위해서). 이 식별자의 용도로 사용되는 자료형으로는
보통 문자열이나 정수 자료형 등이 쓰일 수 있습니다.
-
ProductCreator는 실제로 결과물 객체를 생성해 주는 호출 가능 객체입니다. 이 자료형은 반드시 함수 호출 연산자인
operator()를 제공해 주어야 합니다. 그리고 그 함수 호출 연산자는 별도의 인자를 가지지 않으며,
AbstractProduct에 대한 포인터를 반환하여야 합니다. ProductCreator 객체는 항상 타입 식별자와 함께
묶여서 Factory에 등록되게 됩니다.
-
Factory는 다음과 같은 기본 함수를 제공합니다.
Factory가 제공하는 함수 예제 코드 보기
- bool Register(const IdentifierType& id, ProductCreator creator);
-
-
-
- bool Unregister(const IdentifierType& id);
-
-
-
- AbstractProduct* CreateObject(const IdentifierType& id);
-
-
-
-
Factory가 제공하는 함수 예제 코드 접기
-
-
CloneFactory의 선언문은 다음과 같습니다.
CloneFactory의 선언 예제 코드 보기
- template
- <
- class AbstractProduct,
- class ProductCreator =
- AbstractProduct* (*)(AbstractProduct*),
- template <typename, class>
- class FactoryErrorPolicy = DefaultFactoryError
- >
- class CloneFactory;
CloneFactory의 선언 예제 코드 접기
-
AbstractProduct는 복제 팩토리에서 제공되는 클래스 계층 구조의 최상위 기반 클래스입니다.
-
ProductCreator는 인자로 받은 객체를 복제하는 역할을 수행하며, 복제된 객체에 대한 포인터를 반환해 줍니다.
-
CloneFactory는 다음과 같은 기본 함수를 제공합니다.
CloneFactory가 제공하는 함수 예제 코드 보기
- bool Register(const TypeInfo&, ProductCreator creator);
-
-
-
- bool Unregister(const TypeInfo& typeInfo);
-
-
-
- AbstractProduct* CreateObject(const AbstractProduct* model);
-
-
-
CloneFactory가 제공하는 함수 예제 코드 접기
-
추상 팩토리는 구조적으로 매우 중요한 역할을 하는 기반 컴포넌트라고 할 수 있습니다. 그 이유는 추상 팩토리를 이용하여 시스템 전체에서
구체적인 객체들을 올바른 방법으로 생성해 낼 수 있기 때문입니다. 예를 들어, ConventionalDialog라는 기본 대화상자 클래스에
FunkyButton이라는 변형된 디자인의 버튼 클래스가 생성되거나 하는 일은 바람직하지 않겠지요. 우리가 원하는 것은 FunkyButton이
오직 funkyDialog 위에서만 생성되어야 한다는 등의 적법한 생성 규칙입니다. 추상 팩토리를 이용하면 코드 상의 아주 작은 부분을
제어하는 것만으로도 우리가 원하는 바를 달성할 수 있습니다.
이번 장을 마치고 나면, 여러분들은 다음과 같은 지식을 얻을 수 있을 것입니다.
-
추상 팩토리 디자인 패턴의 응용 범위에 대한 이해
-
추상 팩토리 컴포넌트를 정의하고 구현하는 방법
-
Loki가 제공하는 제네릭한 스타일의 추상 팩토리를 사용하는 방법 및 그것을 사용자의 요구에 맞도록 확장하는 방법
-
여러분이 지금 "찾아라, 그리고 척살하라!"라는 주제의 1인칭 액션 형식의 게임을 디자인하는 일을 맡고 있다고 가정해 봅시다.
여러분은 지극히 평범한 대중들이 이 게임을 즐길 수 있도록 만들어야 합니다. 동시에, 하드코어 게이머들에게도 이 게임을 어필할
수 있어야 합니다. 따라서, 게임의 난이도를 두 개의 레벨로 만드는 것이 좋을 것입니다.
그러면 이와 같은 가상 세계를 모델링하는 경우를 생각해 봅시다. 여러분은 가장 기반이 되는 클래스로 Enemy를 정의하고 이를
상속하여 Soldier, Monster 그리고 SuperMonster와 같은 인터페이스를 정의해 주어야 할 것입니다. 그리고 Easy
레벨을 위해서 이 인터페이스를 상속하여 SillySoldier, SillyMonster 그리고 SillySuperMonster와 같은 파생
클래스를 만들어 주어야 합니다. 또한, Diehard 레벨을 위해서는 BadSoldier, BadMonster, BadSuperMonster와
같은 파생 클래스를 만들어 주어야 합니다. [그림 9.1]에 이와 같은 계층 구조가 잘 나타나 있습니다.
[그림 9.1] 두 가지 난이도 레벨을 가진 게임을 위한 계층 구조 보기
여기서 BadSoldier와 SillySoldier 클래스의 인스턴스가 게임 내에 동시에 존재해야 할 이유가 없습니다. Easy 레벨
안에서는 오직 SillySoldier, SillyMonster, SillySuperMonster 만을 만나게 될 것이고, Diehard 레벨에서는
오직 BadSoldier, BadMonster, BadSuperMonster 만을 만나게 될 것입니다.
만일 이와 같은 상황을 일관되게 유지시킬 수 있다면 참으로 멋진 일이 될 것 같습니다. 그렇지 않으면, 프로그래머가 조금이라도
실수를 하게 된 경우에, 기쁜 마음으로 SillySoldier를 학살 중이던 초보 게이머가 느닷없이 BadMonster를 만나 어이없이
게임 오버가 되는 경우도 생길 수 있습니다.
따라서 여러분은 다음과 같이 게임 상의 모든 객체를 단일한 인터페이스를 통해 생성하도록 만드는 것이 좋습니다. 그리고 나서
각각의 게임 난이도에 대해서, 주어진 난이도에 따라 올바른 적 캐릭터들을 생성해 주는 구체적인 팩토리를 구현하면 될 것입니다.
최종적으로, AbstractEnemyFactory의 포인터를 적절한 구체적인 클래스로 초기화해 주면 됩니다.
예제 코드 보기
- class AbstractEnemyFactory
- {
- public:
- virtual Soldier* MakeSoldier(void) = 0;
- virtual Monster* MakeMonster(void) = 0;
- virtual SuperMonster* MakeSuperMonster(void) = 0;
- };
-
- class EasyLevelEnemyFactory : public AbstractEnemyFactory
- {
- public:
- Soldier* MakeSoldier(void)
- { return new SillySoldier; }
-
- Monster* MakeMonster(void)
- { return new SillyMonster; }
-
- SuperMonster* MakeSuperMonster(void)
- { return new SillySuperMonster; }
- };
-
- class DieHardLevelEnemyFactory : public AbstractEnemyFactory
- {
- public:
- Soldier* MakeSoldier(void)
- { return new BadSoldier; }
-
- Monster* MakeMonster(void)
- { return new BadMonster; }
-
- SuperMonster* MakeSuperMonster(void)
- { return new BadSuperMonster; }
- };
-
- class GameApp
- {
- ...
- void SelectLevel(void)
- {
- if (사용자가 Easy 난이도를 선택할 경우)
- {
- pFactory_ = new EasyLevelEnemyFactory;
- }
- else
- {
- pFactory_ = new DieHardLevelEnemyFactory;
- }
- }
-
- private:
-
- AbstractEnemyFactory* pFactory_;
- };
예제 코드 접기
이러한 디자인의 장점은 AbstractEnemyFactory의 두 구현 클래스 안에서, 각 객체들을 필요에 따라 올바르게 대응시키고,
또 생성하는 과정에 관련된 모든 세부 사항을 포괄하고 있다는 점입니다. 이와 같은 방법이 바로 추상 팩토리(Abstract Factory)
디자인 패턴의 전형적인 예라 할 수 있을 것입니다.
추상 팩토리 인터페이스(Soldier, Monster 및 SuperMonster 클래스)가 만들어 내는 결과 자료형은 "추상적
결과물(abstract product)"이라고 불립니다. 그리고 각 구현 클래스가 실제로 생성하는 결과 자료형(SillySoldier,
BadSoldier, SillyMonster 등등)은 "구체적 결과물(concrete product)"이라고 불립니다.
추상 팩토리의 가장 큰 단점은 그것이 결과 자료형에 종속적이라는 것입니다. 추상 팩토리의 기반 클래스는 자신이 생성하고자
하는 추상적 결과물에 대한 자료형 정보를 모두 알고 있어야만 합니다.
우리는 8장에서 설명된 기법을 적용하여 이러한 종속성을 경감시킬 수 있습니다. 하지만, 종속성을
점점 제거해 낼수록 코드들은 보다 적은 자료형 정보만을 가지고 구성되게 됩니다. 그리고 이것은 부지부식 간에 각 자료형에
따른 디자인의 안정성에 점점 구멍이 뚫릴 수 있다는 가능성을 시사합니다.
대부분, 올바른 해법을 얻어내는 과정은 서로 경쟁 관계에 있는 두 이득 사이의 트레이드 오프와 관련되기 마련입니다.
가장 중요한 규칙은 가능한 많은 부분을 정적으로 코딩하고, 어쩔 수 없는 경우에만 동적 코딩을 하라는 것입니다.
-
3장에서 살짝 소개된 바에 따르면, Typelist라는 장치가 제네릭한 스타일의 추상 팩토리를
구현하는 과정에 크게 기여할 수 있다는 사실을 알 수 있습니다.
앞 섹션에서 살펴본 예제는 추상 팩토리 디자인 패턴의 전형적인 사용 예입니다. 그 내용을 다시 살펴보면,
-
각 결과 자료형마다 하나씩의 순가상 함수를 가지는 추상 클래스(추상 팩토리 클래스)를 정의합니다. 자료형 T에
따르는 가상 함수는 보통 T* 자료형의 값을 반환하고, 그 이름은 대개 CreateT, MakeT 혹은 그 비슷한
식으로 정의될 것입니다.
-
추상 팩토리로 정의된 인터페이스를 구현하는 구체적인 구현 클래스로 하나 이상의 팩토리를 정의할 수 있습니다.
그리고 그 각각의 멤버 함수들은 필요한 파생 클래스들의 객체를 새로 생성하는 코드로 구현됩니다.
이것은 간단해 보이지만, 추상 팩토리가 생성해 내야 할 결과물들의 종류가 많아짐에 따라, 그 코드는 점점 더 관리하기가
힘들어집니다. 더 나아가서, 다른 형태의 할당 방식을 사용하거나, 매 순간마다 다른 형태의 프로토타입 객체를 사용하는
버전을 포함시킬지의 여부도 결정해주어야 할 것입니다.
3장에서 살펴보았던 GenScatterHierarchy 클래스 템플릿이 추상 팩토리 인터페이스를
정의하는 데 매우 유용하게 사용될 수 있습니다. 우리는 하나의 자료형에 대해 그 객체를 생성하는 인터페이스를 정의하고,
GenScatterHierarchy를 이용하여 그 인터페이스를 다수의 자료형에 대해 적용되도록 확장시킬 수 있는 것입니다.
제네릭한 스타일의 자료형 T를 생성할 수 있는 단위(unit) 인터페이스는 다음과 같이 정의됩니다.
단위 인터페이스 예제 코드 보기
- template <class T>
- class AbstractFactoryUnit
- {
- public:
- virtual T* DoCreate(Type2Type<T>) = 0;
- virtual ~AbstractFactoryUnit(void) {}
- };
-
단위 인터페이스 예제 코드 접기
제네릭한 스타일의 AbstractFactory 인터페이스는 다음과 같이 GenScatterHierarchy와 AbstractFactoryUnit을
연동하여 사용하게 됩니다.
AbstractFactory 예제 코드 보기
- template
- <
- class TList,
- template <class> class Unit = AbstractFactoryUnit
- >
- class AbstractFactory : public GenScatterHierarchy<TList, Unit>
- {
- public:
- typedef TList ProductList;
-
- template <class T> T* Create(void)
- {
- Unit<T>& unit = *this;
- return unit.DoCreate(Type2Type<T>());
- }
- };
-
-
-
-
- typedef AbstractFactory
- <
- TYPELIST_3(Soldier, Monster, SuperMonster)
- >
- AbstractEnemyFactory;
AbstractFactory 예제 코드 접기
3장에서 설명하고 있는 바에 따르면, AbstractFactory 템플릿은 [그림 9.2]에 묘사된 그림과
같은 계층 구조를 생성해 낼 것입니다. AbstractEnemyFactory는 AbstractFactoryUnit<Soldier>,
AbstractFactoryUnit<Monster> 그리고 AbstractFactoryUnit<SuperMonster>를 모두
상속받고 있습니다. 그리고 그 각각은 가상 멤버 함수인 Create를 정의하고 있으며, 따라서 AbstractEnemyFactory는
세 개의 Create 오버로드 함수를 가지게 되는 셈이 됩니다.
AbstractFactory의 템플릿 멤버 함수 Create는 객체 생성의 요청을 적합한 기반 클래스로 전달해 주는 일종의
분배기 역할을 하게 됩니다.
AbstractEnemyFactory* p = ...;
Monster* pOgre =
p->Create<Monster>();
이렇게 자동으로 생성된 버전의 추상 팩토리가 갖는 중요한 장점은 첫째, AbstractEnemyFactory가 각 단위별로 아주
잘 구분된 인터페이스를 제공한다는 것입니다. 예를 들어, 특정 모듈(Surprises.cpp라고 합시다)의 경우에는 오직
SuperMonster 객체만을 생성하는 경우가 있을 수 있습니다. 여러분은 AbstractFactory<SuperMonster>에
대한 포인터 혹은 참조 값을 가지고 그 모듈과 원활히 통신할 수 있습니다. 그리하여 Surprises.cpp는 Soldier 및
Monster와는 전혀 상관 관계를 가지지 않도록 만들어 줄 수 있습니다.
두 번째 중요한 장점은 인터페이스뿐만 아니라, 구현 코드 역시 자동화시킬 수 있다는 사실입니다.
[그림 9.2] AbstractEnemyFactory의 생성을 위한 클래스 계층 구조 보기
-
인터페이스를 정의하기 위해서 Typelist를 사용하였으므로 AbstractFactory의 제네릭한 스타일의 구현 클래스를 만드는
자연스런 방법 역시, 물론 구체 결과물들에 대한 Typelist를 사용하는 것일 겁니다. 실제로, Easy 레벨에 대한 구체적인
팩토리 구현물을 만드는 것은 다음의 코드처럼 간단합니다.
EasyLevelEnemyFactory 예제 코드 보기
- typedef ConcreteFactory
- <
-
- AbstractEnemyFactory,
-
-
-
- OpNewFactoryUnit,
-
-
- TYPELIST_3(SillySoldier, SillyMonster, SillySuperMonster)
- >
- EasyLevelEnemyFactory;
EasyLevelEnemyFactory 예제 코드 접기
ConcreteFactory 클래스 템플릿에 대한 세 가지 템플릿 인자들은 완전한 팩토리를 구현하기 위한 충분한 정보를 담고 있다고
말할 수 있습니다.
-
AbstractEnemyFactory는 구현하고자 하는 추상 팩토리의 인터페이스를 제공하며, 또한 암시적으로 결과 자료형에
대한 목록을 말해 주기도 합니다.
-
OpNewFactoryUnit은 실제로 해당 객체가 어떤 방식으로 생성될 지를 나타내는 단위전략입니다(단위전략에
대한 자세한 내용은 1장을 참고하세요).
-
Typelist는 이 팩토리가 생성하여야 할 구체적인 클래스들의 집합을 나타내고 있습니다. Typelist 상에 구체적으로
나타난 각 자료형들은 AbstractFactory가 가진 Typelist 상에 존재하는 추상 자료형들과 같은 색인(index)
값끼리 1:1로 대응되게 됩니다.
간단히 계산을 해 보면, 구체적인 결과물에 따라 그 수 만큼의 순가상 함수를 오버라이드 해야 한다는 결론을 내릴 수 있습니다.
따라서, ConcreteFactory는 Typelist 안에 존재하는 각 자료형을 가지고 정의된 모든 OpNewFactoryUnit(DoCreate를
구현할 의무를 가지는 단위전략)을 상속받아야 합니다.
바로 여기에서 3장에서 다루었던 GenLinearHierarchy 클래스 템플릿이 매우 유용하게 사용될
수 있습니다. 이것은 각 구현 클래스 인스턴스의 생성 작업과 관련된 모든 세부 내용을 대신 처리해 줄 수 있기 때문입니다.
AbstractEnemyFactory는 반드시 이 계층 구조의 최상위 노드여야 합니다. OpNewFactoryUnit의 각 인스턴스들은
AbstractEnemyFactory에 의해 정의된 세 개의 DoCreate 순가상 함수 중에 어느 한 가지씩을 오버라이드 해야 합니다.
OpNewFactoryUnit은 생성해야 할 자료형을 템플릿 인자로 가지는 클래스 템플릿입니다. 또한, GenLinearHierarchy는
OpNewFactoryUnit이 별도의 템플릿 인자를 받아서 그것을 계승하도록 요구하고 있습니다.
OpNewFactoryUnit 예제 코드 보기
- template <class ConcreteProduct, class Base>
- class OpNewFactoryUnit : public Base
- {
- typedef typename Base::ProductList BaseProductList;
-
- protected:
- typedef typename BaseProductList::Tail ProductList;
-
- public:
- typedef typename BaseProductList::Head AbstractProduct;
-
- ConcreteProduct* DoCreate(Type2Type<AbstractProduct>)
- {
- return new ConcreteProduct;
- }
- };
OpNewFactoryUnit 예제 코드 접기
여기서, OpNewFactoryUnit::DoCreate 함수가 반환하는 것이 AbstractProduct에 대한 포인터가 아니라는 사실에
주목하기 바랍니다. 그 대신, OpNewFactoryUnit::DoCreate는 ConcreteProduct 객체에 대한 포인터를 반환해 줄
것입니다. 이것은 C++가 제공하는 공변 반환형(Covariant return types)을
이용한 것입니다. 이것은 기반 클래스의 포인터를 반환하는 함수를 오버라이드하여
파생 클래스의 포인터를 반환하도록 만들어 줄수 있습니다.
ConcreteFactory는 반드시 GenLinearHierarchy를 이용하여 계층 구조를 생성해 내야 합니다.
ConcreteFactory 예제 코드 보기
- template
- <
- class AbstractFact,
- template <class, class> class Creator = OpNewFactoryUnit,
- class TList = typename AbstractFact::ProductList
- >
- class ConcreteFactory
- : public GenLinearHierarchy<
- typename TL::Reverse<TList>::Result, Creator, AbstractFact>
- {
- public:
- typedef typename AbstractFact::ProductList ProductList;
- typedef TList ConcreteProductList;
- };
-
-
-
-
-
-
ConcreteFactory 예제 코드 접기
여기서 ConcreteFactory에 대해 GenLinearHierarchy가 생성하는 클래스 계층 구조는 [그림 9.3]에 나타난 바와 같습니다.
[그림 9.3] EasyLevelEnemyFactory를 위해 생성된 클래스 계층 구조 보기
-
우리는 프로토타입 객체를 복제함으로써 새로운 객체를 얻어낼 수 있습니다. 그리고 여기서 가장 중요한 것은, 그 복제 함수가
가상으로 선언된다는 것입니다.
프로토타입에 기반하여 우리의 예제에 나오는 적 캐릭터들을 만들어 내는 접근 방법을 사용하려면, 각 기반 클래스인 Soldier,
Monster 그리고 SuperMonster에 대한 포인터를 저장하고 있어야 합니다.
예제 코드 보기
- class GameApp
- {
- ...
- void SelectLevel(void)
- {
- if (사용자가 Easy 난이도를 선택할 경우)
- {
- protoSoldier_.reset(new SillySoldier);
- protoMonster_.reset(new SillyMonster);
- protoSuperMonster_.reset(new SillySuperMonster);
- }
- else
- {
- protoSoldier_.reset(new BadSoldier);
- protoMonster_.reset(new BadMonster);
- protoSuperMonster_.reset(new BadSuperMonster);
- }
- }
-
- Soldier* MakeSoldier(void)
- {
-
-
- return protoSoldier_->Clone();
- }
-
- ... MakeMonster 및 MakeSuperMonster 역시 비슷한 방법으로 정의됩니다 ...
-
- private:
-
- auto_ptr<Soldier> protoSoldier_;
- auto_ptr<Monster> protoMonster_;
- auto_ptr<SuperMonster> protoSuperMonster_;
- };
예제 코드 접기
프로토타입에 기초한 추상 팩토리의 구현은 각 결과 자료형에 대한 포인터를 프로토타입의 용도로 수집하게 되며, 새로운 결과물을
생성하기 위하여 Clone 함수를 사용하게 됩니다.
프로토타입을 사용하는 ConcreteFactory의 경우에는, 더 이상 구체적인 자료형을 제공해야 할 필요가 없어집니다. 이와 같은
사실은 구체적인 팩토리가 가지는 구체적인 결과물에 대한 자료형 종속성을 줄여주게 됩니다.
GenLinearHierarchy의 생성 메커니즘이 올바르게 동작하도록 만들기 위해서, 물론 Typelist가 필요할 것입니다.
여기서 잠시 ConcreteFactory에 대한 선언문을 돌이켜 보도록 합시다.
ConcreteFactory의 선언 예제 코드 보기
TList는 구체적인 결과물에 대한 자료형 목록을 담고 있는 Typelist입니다. 만일 여기에 프로토타입 디자인 패턴을 사용하게
되면, TList의 존재는 사실상 무의미해 집니다. 하지만 GenLinearHierarchy는 추상 결과물 목록에 존재하는 각 자료형마다
하나씩의 클래스를 자동 생성해 주기 위하여 여전히 TList를 필요로 할 것입니다.
이런 경우에, 자연스러운 해법은 ConcreteFactory에게 추상 결과물 목록을 TList의 인자로 넘겨주는 것입니다.
예제 코드 보기
- template
- <
- class AbstractFact,
- template <class, class> class Creator,
- class TList = typename AbstractFact::ProductList
- >
- class ConcreteFactory;
-
예제 코드 접기
이제 PrototypeFactoryUnit을 구현해 보도록 합시다. 이것은 프로토타입 객체를 저장하고 Clone을 호출해 주는 단위
템플릿입니다.
PrototypeFactoryUnit 예제 코드 보기
- template <class ConcreteProduct, class Base>
- class PrototypeFactoryUnit : public Base
- {
- typedef typename Base::ProductList BaseProductList;
-
- protected:
- typedef typename BaseProductList::Tail ProductList;
-
- public:
- typedef typename BaseProductList::Head AbstractProduct;
-
- PrototypeFactoryUnit(AbstractProduct* p = 0)
- : pPrototype_(p)
- {}
-
- friend void DoGetPrototype(const PrototypeFactoryUnit& me,
- AbstractProduct*& pPrototype)
- {
- pPrototype = me.pPrototype_;
- }
-
- friend void DoSetPrototype(PrototypeFactoryUnit& me,
- AbstractProduct* pObj)
- {
- me.pPrototype_ = pObj;
- }
-
- template <class U>
- void GetPrototype(U*& p)
- {
- return DoGetPrototype(*this, p);
- }
-
- template <class U>
- void SetPrototype(U* pObj)
- {
- DoSetPrototype(*this, pObj);
- }
-
- AbstractProduct* DoCreate(Type2Type<AbstractProduct>)
- {
- assert(pPrototype_);
- return pPrototype_->Clone();
- }
-
- private:
- AbstractProduct* pPrototype_;
- };
PrototypeFactoryUnit 예제 코드 접기
PrototypeFactoryUnit 클래스 템플릿은 여러분이 만날 수 있는 실제 상황에는 적용되지 않을 수 있는 몇 가지 가정을 사용하고
있습니다. 먼저, PrototypeFactoryUnit은 자신이 가지는 프로토타입 객체에 대한 소유권을 가지지 않습니다. 둘째로,
PrototypeFactoryUnit은 Clone 함수가 결과물 객체를 복제해 줄 것이라는 가정을 가지고 이것을 사용하고 있습니다.
만일 자신만의 프로토타입 기반 추상 팩토리를 커스터마이즈 하고 싶다면, PrototypeFactoryUnit과 유사한 새로운 템플릿
코드를 작성해 주기만 하면 될 것입니다.
예를 들어, 만일 DoCreate 함수를 커스터마이즈 하여 프로토타입 포인터가 null일 경우, 그대로 null 포인터를 반환하도록
만들고자 한다면 다음과 같은 코드를 작성할 수 있습니다.
DoCreate 함수의 커스터마이즈 예제 코드 보기
- template <class AbstractProduct, class Base>
- class MyFactoryUnit
- : public PrototypeFactoryUnit<AbstractProduct, Base>
- {
- public:
-
- AbstractProduct* DoCreate(Type2Type<AbstractProduct>)
- {
- return pPrototype_ ? pPrototype_->Clone() : 0;
- }
- };
DoCreate 함수의 커스터마이즈 예제 코드 접기
다시 우리가 다루었던 게임의 예로 돌아가 봅시다. 구체적인 팩토리를 정의하기 위해서 애플리케이션 상에서 여러분이 작성해야 할
것은 오직 다음과 같은 코드 뿐입니다.
// 애플리케이션 코드
typedef ConcreteFactory
<
AbstractEnemyFactory,
PrototypeFactoryUnit
>
EnemyFactory;
결론적으로, AbstractFactory와 ConcreteFactory의 듀엣은 다음과 같은 특징을 제공해 줍니다.
-
Typelist를 사용하여 팩토리를 매우 쉽게 정의할 수 있습니다.
-
AbstractFactory가 각 단위 객체들을 상속받고 있기 때문에 그 인터페이스는 매우 훌륭한 개별적 특징을 가지게
됩니다. 프로그래머는 독립적인 "단위 생성자(AbstractFactoryUnit<T> 하부 객체에 대한 포인터,
혹은 참조 값)"들을 서로 다른 모듈에게 전달할 수 있으며, 이를 통해 코드 간의 간섭성을 최소화시킬 수 있습니다.
-
AbstractFactory에 대한 구현 코드를 얻기 위해 ConcreteFactory를 사용할 수 있습니다. 이것은 생성
방식을 결정하는 단위전략 템플릿을 제공해 줌으로써 이루어질 수 있습니다. 정적으로 고정된 생성 전략을 위해서,
프로그래머는 팩토리가 생성해야 할 구체적인 결과 자료형 목록을 Typelist의 형태로 전달해 주어야 합니다.
-
또 다른 유용한 생성 전략은 프로토타입 디자인 패턴을 적용하는 것입니다. 프로그래머는 준비된
PrototypeFactoryUnit 클래스 템플릿을 이용하여 ConcreteFactory에 프로토타입 패턴을
쉽게 응용할 수가 있습니다.
-
추상 팩토리 디자인 패턴은 서로 관련된, 혹은 종속성을 지닌 다형성 객체들을 집합적으로 다루는 생성 인터페이스를 구성해 줍니다.
추상 팩토리를 사용하여, 서로 연관성 없는 집합들에 대한 구현 클래스들을 별도로 분리해 줄 수 있습니다.
Typelist 및 단위전략 템플릿을 사용하여 제네릭한 스타일의 추상 팩토리 인터페이스를 구현하는 것이 가능했습니다.
Typelist는 결과 자료형 목록(추상 결과물 및 구체 결과물에 대한 모든 것)을 제공하며, 단위전략은 객체의 생성
방식을 결정해 줍니다.
AbstractFactory 클래스 템플릿은 추상 팩토리를 정의하는 데 필요한 뼈대를 제공해 주며, AbstractFactoryUnit
클래스 템플릿과 연동되어 사용됩니다. AbstractFactory는 사용자가 제공하는 추상 결과물에 대한 Typelist를 필요로
합니다. 내부적으로, AbstractFactory는 GenScatterHierarchy(3장 참조)를 사용하여
추상 결과 자료형 목록에 있는 각 자료형 T에 대해 AbstractFactoryUnit<T>를 상속받는 개별적인 인터페이스를
생산해 냅니다. 애플리케이션의 많은 부분에서, 이러한 구조는 개별적인 팩토리 단위 객체를 직접 전달함으로써 발생하는 서로
간의 간섭성을 해소시켜 줄 수 있습니다.
ConcreteFactory 템플릿은 AbstractFactory 인터페이스를 가지는 구현 코드를 만들어 내는 데 커다란 도움이 됩니다.
ConcreteFactory는 객체를 생성하기 위하여 FactoryUnit이라는 단위전략을 사용합니다. 내부적으로,
ConcreteFactory는 GenLinearHierarchy(3장 참조)를 사용하고 있습니다. Loki는
FactoryUnit 단위전략을 구현하는 두 개의 미리 정의된 클래스를 제공합니다. OpNewFactoryUnit은 new 연산자를
사용하여 객체를 생성해 주며, PrototypeFactoryUnit은 프로토타입 객체를 복제하여 객체를 생성해 줍니다.
-
-
AbstractFactory의 선언문은 다음과 같습니다.
AbstractFactory의 선언 예제 코드 보기
- template
- <
- class TList,
- template <class> class Unit = AbstractFactoryUnit
- >
- class AbstractFactory;
-
-
- typedef AbstractFactory<TYPELIST_3(Soldier, Monster, SuperMonster)>
- AbstractEnemyFactory;
-
AbstractFactory의 선언 예제 코드 접기
-
AbstractFactoryUnit<T>는 순가상 함수로 이루어진 인터페이스를 정의합니다. 그리고 그 형태는
T* DoCreate(Type2Type<T>)
의 모습을 하고 있습니다. 일반적으로 프로그래머는 DoCreate 함수를 직접적으로 호출하지 않고, 대신
AbstractFactory::Create 함수를 사용하게 됩니다.
-
AbstractFactory는 Create라는 템플릿 함수를 노출합니다. 프로그래머는 추상 결과물 목록에 해당하는
모든 자료형에 대한 Create 함수의 인스턴스를 사용할 수 있습니다. 예를 들어, 다음과 같은 코드가 가능합니다.
AbstractEnemyFactory*
pFactory = ...;
Soldier* pSoldier =
pFactory->Create<Soldier>();
-
AbstractFactory가 정의하고 있는 인터페이스를 실제로 구현해 주기 위하여, Loki는 ConcreteFactory
템플릿을 제공합니다.ConcreteFactory의 선언문은 다음과 같습니다.
ConcreteFactory의 선언 예제 코드 보기
- template
- <
- class AbstractFact,
- template <class, class> class FactoryUnit = OpNewFactoryUnit,
- class TList = AbstractFact::ProductList
- >
- class ConcreteFactory;
-
-
ConcreteFactory의 선언 예제 코드 접기
-
FactoryUnit 단위전략에 대한 구현 클래스는 자신이 생성하는 추상 결과물 및 구체 결과물에 모두 접근할 수
있어야 합니다. Loki는 두 개의 생성 단위전략을 정의하고 있습니다. 하나는
OpNewFactoryUnit(9.3 섹션)이며, 다른 하나는
PrototypeFactoryUnit(9.4 섹션)입니다. 이것들은 또한 FactoryUnit
단위전략을 구현하는 다른 커스터마이징된 클래스들의 좋은 예로 사용될 수 있습니다.
-
OpNewFactoryUnit은 객체의 생성을 위하여 new 연산자를 사용합니다. 만일 OpNewFactoryUnit을 사용하게
되면, 구체 결과 자료형 목록을 ConcreteFactory의 세 번째 템플릿 인자로 전달해 주어야 합니다.
그 예는 다음과 같습니다.
OpNewFactoryUnit의 사용 예제 코드 보기
- typedef ConcreteFactory
- <
- AbstractEnemyFactory,
- OpNewFactoryUnit,
- TYPELIST_3(SillySoldier, SillyMonster, SillySuperMonster)
- >
- EasyLevelEnemyFactory;
OpNewFactoryUnit의 사용 예제 코드 접기
-
PrototypeFactoryUnit은 추상 결과 자료형들에 대한 포인터를 저장하고, 해당되는 프로토타입에 대해 Clone
멤버 함수를 호출함으로써 새로운 객체를 생성해 줍니다. 이것은 PrototypeFactoryUnit이 각 추상 결과 자료형
T가 T*을 반환하는 가상 함수 Clone을 정의하고 있을 것을 요구합니다. 그리고 이 가상 함수 Clone은 객체의
복사 작업을 담당하게 됩니다.
-
PrototypeFactoryUnit을 가지고 ConcreteFactory를 사용하게 될 때 ConcreteFactory의 세 번째
인자를 제공해야 할 필요가 없습니다. 따라서 다음과 같은 사용이 가능합니다.
typedef ConcreteFactory
<
AbstractEnemyFactory,
PrototypeFactoryUnit
>
EnemyFactory;
-
비지터 패턴을 이용하면 기존 클래스 코드 혹은 클라이언트 코드를 다시 컴파일하는 과정 없이, 클래스에 추가적인 가상 함수를 제공해 줄 수가
있습니다. 하지만 이 패턴을 사용할 때, 클래스 계통 구조에 새로운 파생 클래스를 말단으로 추가하고자 한다면, 전체 클래스 계층 구조 및
클라이언트 코드를 다시 컴파일해야 한다는 제한점이 생깁니다. 따라서, 비지터가 유용하게 쓰일 수 있는 영역은 클래스를 새로 추가할 일이
거의 없고, 새로운 가상 함수를 추가하는 일이 빈번하게 발생하는 경우에 국한됩니다.
이번 장을 읽고 나면 여러분은 다음의 내용을 얻게 될 것입니다.
-
비지터(visitor)가 동작하는 방법을 이해하게 될 것입니다.
-
언제 비지터 디자인 패턴을 적용시킬 수 있는지, 그리고 적용시켜선 안되는 때는 언제인지를 알게 될 것입니다.
-
기초적인 비지터 구현 코드에 대해 이해하게 될 것입니다.
-
GoF의 비지터 구현 코드가 가지는 몇 가지 결점을 극복하는 방법을 알게 됩니다.
-
비지터 패턴을 구현하는 데에 관계된 대부분의 결정 사항들을 어떻게 라이브러리 코드 속으로 포함시킬 수 있는지를 배우게 됩니다.
-
여러분이 당면한 문제에 꼭 들어맞는 비지터 패턴을 구현하는 데 커다란 도움이 되는 강력하고 제네릭한 스타일의 컴포넌트를
알게 될 것입니다.
-
여러분이 그 기능을 강화하고자 하는 어떤 클래스 계층 구조를 한 번 가정해 보도록 합시다. 기능 강화를 위해서, 여러분은
새로운 클래스를 추가하거나, 또는 기존 클래스에 새로운 가상 멤버 함수를 추가하게 될 것입니다.
새로운 클래스를 추가하는 것은 아주 쉽습니다. 계층 구조 말단의 클래스를 상속받아서 여기에 필요한 가상 함수를 정의하고
구현해 주면 됩니다. 그러면 기존의 클래스를 수정하거나, 재컴파일 할 필요가 전혀 없을 것입니다.
반면에, 새로운 가상 함수를 추가해 주는 것은 매우 어렵습니다. 새로 만들고자 하는 가상 함수에 대한 코드를, 최상위 클래스로부터
필요한 다른 수많은 클래스에 일일이 추가해 주어야만 합니다. 궁극적으로, 여러분은 이 세상 전체를 새로 컴파일해야 할지도
모릅니다.
하지만, 여러분이 새로운 클래스는 거의 추가되지 않고, 가상 함수를 추가하는 작업은 빈번하게 발생하는 계층 구조를 가지고
있다고 합시다. 이러한 경우에 비지터 디자인 패턴이 도움이 될 수 있습니다. 비지터는 계층 구조상에 새로운 가상 함수를
쉽게 추가할 수 있도록 만들어 주며, 반면에 새로운 클래스를 추가하는 작업은 보다 힘들게 합니다. 이런 놀라운 기능이
요구하는 실시간의 부하는 오직 한 번의 추가적인 가상 호출뿐입니다.
비지터가 잘 적용될 수 있는 구조는 객체들이 필요한 동작에 따라 잘 구분되어서 서로 연관성이 적게 구성되어 있는 경우라 할 수
있겠습니다. 이와 같은 경우에는, 개념적으로 같은 동작을 하는 구현 코드들을 함께 유지시키는 것이, 그런 동작들을
클래스 계층 구조 전체에 산개시키는 것보다 더 큰 의미를 가지게 되는 것입니다.
예를 들어, 문서 편집기를 개발하는 경우를 생각해 보도록 합시다. 문단, 벡터 그래픽 그리고 비트맵 등과 같은 문서 상의 구성
요소들은 공통적인 DocElement 기반 클래스로부터 파생된 클래스로 표현되어야 합니다. 그리고 그 문서는 DocElement에
대한 포인터의 구조적인 집합으로 구성될 것입니다. 그리고 프로그래머는 이러한 자료 구조 사이에서 반복자 연산을 수행하여야
하며, 각종 동작들을 수행해야 할 필요도 있을 것입니다. 이러한 동작들이 기존 코드를 수정해서가 아니라, 새로운 코드를
추가함으로써 구현되어야 마땅합니다. 더 나아가서, 개념적으로 같은 동작을 하는 코드들을 모두 한 곳에 위치시킴으로써,
코드의 관리 작업을 보다 쉽게 만들 수 있다면 더 좋을 것 같습니다.
문서의 통계 자료는 예를 들어, 사용된 문자의 총 개수, 공백 문자를 제외한 문자의 총 개수, 단어의 총 개수, 그림 객체의 총
개수 등으로 구성된다고 가정할 수 있습니다. 그리고 이것은 당연히 DocStats라는 클래스가 그 처리를 담당해야 하겠지요.
만일 통계 자료를 얻기 위하여 객체지향적인 접근법을 사용한다면, DocElement에 통계의 수집과 관련된 가상 함수를 정의하게
될 것입니다. 그러면 구체적인 각 문서 요소의 클래스들은 이 함수를 자신의 방법에 맞게 정의하여야 합니다.
객체지향적인 접근법을 이용한 통계 기능 예제 코드 보기
- class DocStats
- {
- unsigned int
- chars_,
- nonBlankChars_,
- words_,
- images_;
-
- ...
-
- public:
- void AddChars(unsigned int charsToAdd)
- {
- chars_ += charsToAdd;
- }
-
- ... 비슷한 방법으로 AddWords, AddImages 등을 정의합니다 ...
-
-
- void Display(void);
- };
-
- class DocElement
- {
- ...
-
-
- virtual void UpdateStats(DocStats& statistics) = 0;
- };
-
-
-
-
- void Paragraph::UpdateStats(DocStats& statistics)
- {
- statistics.AddChars(주어진 문단에 있는 문자의 총 개수);
- statistics.AddWords(주어진 문단에 있는 단어의 총 개수);
- }
-
- void RasterBitmap::UpdateStats(DocStats& statistics)
- {
-
-
- statistics.AddImages(1);
- }
-
-
-
-
- void Document::DisplayStatistics(void)
- {
- DocStats statistics;
-
- for (문서상에 존재하는 모든 DocElement를 순환)
- {
- element->UpdateStats(statistics);
- }
- statistics.Display();
- }
객체지향적인 접근법을 이용한 통계 기능 예제 코드 접기
이것은 통계 기능을 구현하는 데 매우 괜찮은 방법이라 할수 있지만, 다음과 같은 몇 가지 단점이 발견됩니다.
-
이것은 DocElement와 그 파생 클래스들이 DocStats 클래스의 정의 코드에 접근해야 한다는 사실을 요구합니다.
따라서, DocStats의 코드를 수정할 때마다 DocElement 계층 구조상의 모든 클래스를 재컴파일 해야 합니다.
-
통계 자료를 수집하는 실질적인 동작 코드들이 UpdateStats라는 구현 코드를 통해 계층 구조상의 클래스 전체에
산개되어 있습니다.
-
통계 자료 수집과 유사한 다른 동작들을 추가한다는 상황에서 볼 때 이와 같은 구현 기법은 확장성이 떨어진다고 할 수
있습니다. "글꼴 크기를 1 포인트 증가"하는 것과 같은 동작을 추가해 주기 위해서는 DocElement에 또 다른 가상
함수를 추가해 주어야 합니다.
DocElement가 DocStats에 대해 가지는 종속성에서 탈피하기 위한 해법은 모든 동작 코드를 DocStats에 담아서
그것이 구체적인 각 자료형에 대해 어떤 일을 수행해야 할지를 판단하게 만드는 것입니다. 이것은 DocStats가 void
UpdateStats(DocElement&)와 같은 함수를 정의해야 한다는 사실을 의미합니다.
이제 남은 문제는 UpdateStats의 구현 코드가 이른바 자료형 선택 스위치(type
switch)라 불리는 방식에 의존해야 한다는 것뿐입니다.
자료형 선택 스위치 예제 코드 보기
- void DocStats::UpdateStats(DocElement& elem)
- {
- if (Paragraph* p = dynamic_cast<Paragraph*>(&elem))
- {
- chars_ += p->NumChars();
- words_ += p->NumWords();
- }
- else if (dynamic_cast<RasterBitmap*>(&elem))
- {
- ++images_;
- }
- else ...
-
- 검사해야 할 모든 객체에 대해 하나씩의 if 문을 추가해야 합니다.
- }
-
-
자료형 선택 스위치 예제 코드 접기
자료형 선택 스위치는 결국 바람직한 해법이 되지 못합니다(8장 참조).
여기서 비지터 패턴이 유용하게 사용될 수 있습니다. 우리는 가상 함수로 동작할 새로운 함수가 필요한 경우라 하더라도, 각 동작마다
새로운 가상 함수를 정의하고 싶지는 않습니다. 이러한 상황에 작용하기 위해서 우리는단일 중계
함수(unique bouncing virtual function)를 DocElement 계층 구조 내에 가상으로 선언하고 또 구현해
주어야 합니다. 여기서 DocElement 계층 구조는 비지터가 순회하는 계층 구조라고 불리게
되며, 그에 다른 동작 코드는 새로운 비지터(visitor) 계층 구조에 포함되게 됩니다.
Visitor 패턴 예제 코드 보기
- class DocElementVisitor
- {
- public:
- virtual void VisitParagraph(Paragraph&) = 0;
- virtual void VisitRasterBitmap(RasterBitmap&) = 0;
- ... 다른 유사한 함수들 ...
- };
-
-
- class DocElement
- {
- public:
- virtual void Accept(DocElementVisitor&) = 0;
- ...
- };
-
- void Paragraph::Accept(DocElementVisitor& v)
- {
- v.VisitParagraph(*this);
- }
-
- void RasterBitmap::Accept(DocElementVisitor& v)
- {
- v.VisitRasterBitmap(*this);
- }
-
-
- class DocStats : public DocElementVisitor
- {
- public:
- virtual void VisitParagraph(Paragraph& par)
- {
- chars_ += par.NumChars();
- words_ += par.NumWords();
- }
-
- virtual void VisitRasterBitmap(RasterBitmap&)
- {
- ++images_;
- }
- ...
- };
-
-
- void Document::DisplayStatistics(void)
- {
- DocStats statistics;
-
- for (문서상의 모든 DocElement 원소를 순회합니다)
- {
- element->Accept(statistics);
- }
- statistics.Display();
- }
Visitor 패턴 예제 코드 접기
여기서 사용된 동작 함수는 실질적으로는 가상 멤버 함수가 아닙니다. 그렇기 때문에 VisitParagraph 함수 안에서는 오직
Paragraph 클래스의 public 멤버에만 접근이 가능합니다.
이 결과로 얻어지는 클래스 구조는 [그림 10.1]에 잘 나타나 있습니다.
이와 같이, DocElementVisitor의 파생물은 객체화된 함수라 할 수 있습니다. 그리고 이것이 바로 비지터 디자인 패턴이
동작하는 근본적인 원리입니다.
[그림 10.1] 비지터 계층 구조와 비지터가 순회하는 계층 구조, 그리고 동작 코드 간에 텔레포트가 이루어 지는 방법 보기
-
DocElementVisitor에 있어서, 우리는 비지터가 순회하는 자료형마다 하나씩의 멤버 함수를 정의하였습니다. 그 예로
VisitParagraph(Paragraph&), VisitRasterBitmap(RasterBitmap&) 등이 그것입니다.
이 함수들은, 순회 당하는 객체의 이름과 함수의 이름이 중복성을 가지고 있습니다.
보통 이러한 중복성은 피해가는 것이 좋습니다. 우리는 이것을 C++의 오버로딩 기능을 이용하여 해결할 수 있습니다.
단순하게 모든 함수의 이름을 Visit로 바꿔봅시다.
오버로딩을 이용한 비지터 예제 코드 보기
- class DocElementVisitor
- {
- public:
- virtual void Visit(Paragraph&) = 0;
- virtual void Visit(RasterBitmap&) = 0;
- ... 유사한 다른 함수들 정의 ...
- };
-
-
- void Paragraph::Accept(DocElementVisitor& v)
- {
- v.Visit(*this);
- }
-
- void RasterBitmap::Accept(DocElementVisitor& v)
- {
- v.Visit(*this);
- }
-
-
-
-
오버로딩을 이용한 비지터 예제 코드 접기
오버로딩을 이용하는 것은 매우 흥미로운 아이디어를 떠오르게 합니다. 우리는 DocElementVisitor에게 다음과 같은
깔때기(catch-all) 오버로드 함수를 제공할 수 있습니다.
깔때기 함수 예제 코드 보기
- class DocElementVisitor
- {
- public:
- ... 앞의 예제와 같습니다 ...
-
- virtual void Visit(DocElement&) = 0;
- };
깔때기 함수 예제 코드 접기
만일 여러분이 DocElement를 직접적으로 상속받는 새로운 클래스를 만들고, 여기에 대한 적절한 오버로드 함수를
DocElementVisitor 내부에 제공해 주지 않았다면, 오버로딩 된 Visit 함수들 중 깔때기 함수가 호출될 것입니다.
만일 깔때기 함수가 없다면, 컴파일 오류가 발생할 것입니다.
-
앞서 살펴봤던 예제에 대하여 그 종속성의 문제를 분석해 보면 다음과 같은 몇 가지 사실이 드러나게 됩니다.
-
DocElement 클래스 정의문이 컴파일 되도록 하기 위해서는, 그것이 DocElementVisitor의 정의를 알 수
있도록 해야 합니다. 왜냐하면, DocElementVisitor가 DocElement::Accept 멤버 함수의 정의문에
등장하기 때문입니다. 이것은 전방 선언만으로도 만족될 수 있을 것입니다.
-
DocElementVisitor 클래스 정의문이 컴파일 되도록 하기 위해서는, 그것이 DocElement를 기반으로 한
게층 구조상의 모든 구체적인 파생 클래스들을 알고 있어야만 합니다. 왜냐하면, 해당 클래스의 이름이
DocElementVisitor의 VisitXxx 멤버 함수에 나타나기 때문입니다.
이런 종류의 종속 관계를 순환 종속(cyclic dependency)이라고 부릅니다.
DocElement는 DocElementVisitor를 필요로 하고, 다시 DocElementVisitor는 DocElement의 계층 구조상의
모든 클래스를 필요로 합니다. 따라서, 결과적으로 DocElement가 자신의 파생 클래스들에 대한 종속성을 가지는 경우가
발생합니다. 정확히 말하자면 이것은 이름에 의한 순환 종속(cyclic name dependency)
이라고 말할 수 있습니다. 즉, 클래스에 대한 정의문이 컴파일 되기 위해서, 서로의 이름을
필요로 한다는 뜻입니다. 따라서 클래스를 각 소스 파일에 나누는 유효한 방법은 다음과 같을 것입니다.
비지터의 순환 종속성 예제 코드 보기
- class DocElement;
- class Paragraph;
- class RasterBitmap;
- ... DocElement의 모든 파생 클래스들에 대한 전방 선언 ...
-
- class DocElementVisitor
- {
- public:
- virtual void VisitParagraph(Paragraph&) = 0;
- virtual void VisitRasterBitmap(RasterBitmap&) = 0;
- ... 다른 유사한 함수들의 정의 ...
- };
-
-
- class DocElementVisitor;
-
- class DocElement
- {
- public:
- virtual void Accept(DocElementVisitor&) = 0;
- ...
- };
-
-
-
비지터의 순환 종속성 예제 코드 접기
따라서, 정말로 곤란한 작업은 바로 DocElement의 새로운 파생 클래스를 만드는 작업이 됩니다. 만일 DocElement를 계승받는
VectorGraphic이라는 클래스를 추가시키고자 할 때에 여러분이 해야 할 일은 다음과 같습니다.
-
DocElementVisitor.h를 열고 VectorGraphic에 대한 전방 선언을 삽입합니다.
-
DocElementVisitor에 새로운 오버로드 함수를 순가상으로 선언합니다.
새로운 순가상 함수 예제 코드 보기
- class DocElementVisitor
- {
- public:
- ... 앞의 코드와 동일 ...
- virtual void VisitVectorGraphic(VectorGraphic&) = 0;
- };
새로운 순가상 함수 예제 코드 접기
-
비지터 계층 구조상의 모든 구체적인 파생 클래스에 VisitVectorGraphic 함수를 구현해 줍니다. 그 작업 내용에
따라 이 함수는 아무 일도 하지 않는 함수가 될 수도 있습니다. 그런 경우라면, 여러분은
DocElementVisitor::VisitVectorGraphic을 가상 함수로
정의하는 대신 아무 일도 수행하지 않는 함수로 정의할 수도 있습니다. 하지만, 이렇게 하면 컴파일러가 비지터 계층
구조의 각 파생 클래스에서 해당 함수를 구현했는지의 여부를 자동으로 감시해 낼 수 없게 된다는 사실에 주의하기 바랍니다.
-
VectorGraphic 클래스에 Accept 함수를 구현합니다. 절대로 이 작업을 잊어서는
안됩니다. 만일 DocElement로부터 직접 상속을 받았다면 컴파일러가 에러를 찾아낼 것이지만, 또 다른
파생 클래스, 예를 들어 Graphic으로부터 상속받은 경우라면, 여러분이 VectorGraphic을 방문 가능한 상태로
만들어 놓지 않았다는 사실을 컴파일러가 발견해 내는 것이 불가능해집니다. 이러한 버그는 그 코드가 실제로 실행되어
비지터 프레임워크가 VisitVectorGraphic 클래스의 어떤 구현 코드도 호출해주지 못한다는 사실을 직접 발견하기
전에는 숨겨진 채로 남아있게 됩니다.
Rovert Martin(1996)은 비지터 패턴에 대한 매우 흥미로운 변형 버전을 개발하였습니다. 이것은 dynamic_cast를
사용하여 순환 종속성을 제거해 주는 것입니다. 그가 제시한 접근법은 일종의 파이프 역할을 하는 기반 클래스 BaseVisitor를
비지터 계층 구조에 정의하자는 것입니다.
비순환 비지터 예제 코드 보기
- class DocElementVisitor
- {
- public:
- virtual void ~DocElementVisitor(void) {}
- };
-
-
-
-
-
-
-
-
- class ParagraphVisitor
- {
- public:
- virtual void VisitParagraph(Paragraph&) = 0;
- };
-
-
- void Paragraph::Accept(DocElementVisitor& v)
- {
- if (ParagraphVisitor* p = dynamic_cast<ParagraphVisitor*>(&v))
- {
- p->VisitParagraph(*this);
- }
- else
- {
- 깔때기 함수에 대한 추가적인 호출;
- }
- }
-
-
-
-
-
- class DocStats :
- public DocElementVisitor,
- public ParagraphVisitor,
- public RasterBitmapVisitor
- {
- public:
- void VisitParagraph(Paragraph& par)
- {
- chars_ += par.NumChars();
- words_ += par.NumWords();
- }
-
- void VisitRasterBitmap(RasterBitmap&)
- {
- ++images_;
- }
- };
비순환 비지터 예제 코드 접기
결과적인 클래스 구조도는 [그림 10.2]에 잘 나타나 있습니다. 수평으로 이어진 점선은 서로의 종속 관계를, 굵은 수직선은
두 계층 사이의 분리를 나타냅니다.
[그림 10.2] 비순환 비지터에 대한 클래스 구조도 보기
이벤트의 흐름에 따라 이 모든 것을 다시 살펴보도록 합시다. 실제로는 Paragraph 객체를 가리키고 있는 DocElement 형의
포인터 pDocElem을 가지고 있다고 가정하겠습니다. 그리고 다음의 코드가 수행된다고 생각해 보십시오.
DocStats stats;
pDocElem->Accept(stats);
-
stats 객체는 DocElementVisitor 객체에 대한 참조 값으로 자동 형변환이 됩니다.
-
가상 함수인 Paragraph::Accept가 호출됩니다.
-
Paragraph::Accept가 자신이 전달받은 DocElementVisitor 객체를 가늠하기 위하여
dynamic_cast<ParagraphVisitor*>의 형변환을 시도하게 됩니다. 주어진 객체의 동적 자료형이
실제로 DocStats이고, 이것이 DocElementVisitor와 ParagraphVisitor를 public하게 상속받고
있으므로, 이 형변환은 성공하게 됩니다(여기서 바로 텔레포트의 마법이 발생합니다).
-
이제 Paragraph::Accept가 DocStats 객체 중에서 ParagraphVisitor에 해당하는 부분의 포인터를
얻게 되었습니다. 따라서 Paragraph::Accept는 그 포인터가 가리키는 객체에 대해 VisitParagraph
가상 함수를 호출하게 됩니다.
-
그러면 이 가상 함수에 대한 호출은 DocStats::VisitParagraph 안으로 들어오게 됩니다.
DocStats::VisitParagraph는 또한 비지터가 순회하게 될 객체인 Paragraph에 대한 참조 값을 인자로
받게 됩니다. 이제 비지터의 방문 작업이 모두 완료됩니다.
이제 새로운 종속성 관계를 검사해 보도록 합시다.
-
DocElement 클래스의 정의문은 DocElementVisitor의 클래스 이름을 필요로 합니다. 오직 이름에 의해
종속된다는 것은 DocElementVisitor에 대한 전방 선언만 있으면 충분하다는 사실을 의미합니다.
-
ParagraphVisitor, 그리고 일반적으로 모든 XxxVisitor 기반 클래스들은 마찬가지로 자신이 방문하고자
하는 클래스에 그 이름으로 종속되게 됩니다.
-
Paragraph::Accept 구현 코드는 ParagraphVisitor 클래스에 완전히 종속됩니다. 완전히 종속된다는
뜻은 이 코드를 컴파일 하기 위하여 해당 클래스의 전체 정의문이 필요하다는 것을 의미합니다.
-
비지터 계층 구조의 모든 구체적 파생 클래스를 정의하는 코드들은 DocElementVisitor 및 자신이 방문하고자
하는 모든 XxxVisitor 기반 클래스에 완전하게 종속됩니다.
비순환 비지터 패턴은 순환적 종속 관계를 제거시켜 주는 대신, 프로그래머에게 추가적인 작업을 부담지우게 됩니다. 기본적으로,
이제부터 프로그래머는 병렬적인 두 개의 클래스 집합을 관리해야 하는 것입니다. 이렇게
병렬적인 두 클래스 계층 구조를 다루는 것은 상당한 주의력을 필요로 하기 때문에 그리 바람직하다고 할 수 없을 것입니다.
보통의 비지터와 비순환 비지터를 효율성의 측면에서 비교해 보자면, 비순환 비지터가 그 동작 과정에서 추가적인 동적 형변환을
한 번 더 필요로 합니다. 만일 효율성이 중요시되는 경우라면 이러한 부하의 문제는 매우 중요하게 부각됩니다. 따라서, 특정
경우에 있어서는 어쩔 수 없이 보통의 비지터를 사용해야 할 것입니다.
이러한 난처한 청사진 때문에, 비지터가 상당한 논쟁의 여지가 있는 디자인 패턴이라는 사실은 그리 놀라운 일이 아닙니다.
-
우선 구현 코드를 두 개의 주된 단위로 나누어 보도록 합시다.
-
비지터가 순회할 클래스(visitable classes): 우리가 방문하고자 하는(즉,
동작을 추가시키고자 하는) 계층 구조에 포함되는 클래스들입니다.
-
비지터 클래스(visitor classes): 실질적인 동작을 정의하고 구현하는
클래스들입니다.
우리는 가능한 많은 요소들을 뽑아내어 라이브러리화 시키고자 하고 있습니다. 이것은, 비지터 클래스와 그것이 순회할 클래스,
두 부분이 서로 종속성을 가지게 만드는 대신, 양자가 모두 라이브러리에게만 종속되도록 만들 수 있습니다.
[표 10.1] 컴포넌트 이름 보기
우리는 제네릭한 비지터의 구현에 대해 논의를 하면서, [표 10.1]에 정의된 이름들을 사용할 것입니다.
먼저 비지터 계층 구조에 초점을 맞추어 보도록 합시다. 우리가 해야 하는 일은 사용자에게 어떤 기반 클래스를 제공하는
것입니다. 거기에 더하여, 우리는 파이프 클래스를 제공하여 비순환 비지터 패턴이 요구하는 dynamic_cast의 기능이
동작하도록 만들어 주어야 합니다.
Visitor 예제 코드 보기
- class BaseVisitor
- {
- public:
- virtual ~BaseVisitor(void) {}
- };
-
-
-
- template <class T>
- class Visitor
- {
- public:
- virtual void Visit(T&) = 0;
- };
-
-
- template <class T, typename R = void>
- class Visitor
- {
- public:
- typedef R ReturnType;
-
- virtual ReturnType Visit(T&) = 0;
- };
-
-
- class SomeVisitor :
- public BaseVisitor,
- public Visitor<RasterBitmap>,
- public Visitor<Paragraph>
- {
- public:
- void Visit(RasterBitmap&);
- void Visit(Paragraph&);
- };
Visitor 예제 코드 접기
컴파일러는 여러분이 필요한 모든 Visit 함수를 정의하지 않을 경우에는 이 SomeVisitor 클래스의 인스턴스를 만드는 것
자체를 허용하지 않을 것입니다. SomeVisitor의 클래스 정의문과 그것이 방문하는 클래스들의 이름(RasterBitmap이나
Paragraph 같은) 사이에는 모종의 이름에 의한 종속성이 존재하게 됩니다.
이제 우리는 비지터가 순회해야 할 계층 구조를 살펴보아야 할 것 같습니다. 앞 섹션에서 논의된 바와 같이, 비순환 비지터
패턴의 구현에 있어서 비지터가 순회하는 계층 구조는 다음과 같은 처리를 할 책임을 지게 됩니다.
-
기반 클래스에 Visitor에 대한 참조 값을 인자로 받는 순가상 함수 Accept를 선언합니다.
-
각 파생 클래스마다 이 Accept를 오버라이드 하여 그 내용을 구현해 줍니다.
첫 번째 책임을 완수하기 위하여 우리는 BaseVisitable 클래스에 순가상 함수를 추가할 것입니다. 그리고 애플리케이션
공간에서 비지터가 순회하는 계층 구조의 기반 클래스가 BaseVisitable를 상속받도록 만들어야 합니다.
BaseVisitable 예제 코드 보기
- template <typename R = void>
- class BaseVisitable
- {
- public:
- typedef R ReturnType;
-
- virtual ~BaseVisitable(void) {}
- virtual ReturnType Accept(BaseVisitor&) = 0;
- };
BaseVisitable 예제 코드 접기
우리가 두 번째 의무를 수행하고자 할 때 매우 흥미로운 일을 할 수 있습니다. 그것은 바로 Accept 함수를 클라이언트 코드가
아니라, 라이브러리 코드 내에 구현해 내는 것입니다. 그러나, 10.2 섹션에서 언급된 바와 같이,
Accept를 단순히 BaseVisitable 클래스에 정의하게 되면 그것은 제대로 동작할 수가 없게 됩니다.
우리는 라이브러리 내에서 Accept를 구현하고, 이것을 각 애플리케이션의 DocElement 계층 구조로 축출시킬 수 있는 모종의
방법이 필요하게 되었습니다. 하지만 안타깝게도 C++는 이러한 메커니즘을 만족시킬 수 있는 직접적인 방법을 가지고 있지 않습니다.
가상 상속을 이용하면 어떻게든 해결될 수 있을 것도 같지만, 이렇게 하는 것은 그리 현명한 방법이 아니며, 무시할 수 없는
대가가 요구될 것입니다.
매크로를 사용하는 것 또한 그리 쉬운 결론은 아닙니다. 하지만 다른 어떤 해법도 이만큼의 유용성을 제공해 주지는 못한다면
이것도 선택할 수 있는 한 방편이 될 것입니다.
매크로를 정의하는 데 가장 중요한 하나의 규칙은, 매크로 자신의 역할은 가능한 적게 만들고, 실제의 코딩물(함수, 혹은
클래스)에게 최대한 빨리 그 역할을 넘겨 주라는 것입니다. 이에 따라 다음과 같은 매크로를 작성할 수 있을 것입니다.
Accept 함수를 위한 매크로 예제 코드 보기
- #define DEFINE_VISITABLE()\
- virtual ReturnType Accept(BaseVisitor& guest)\
- { return AcceptImpl(*this, guest); }
Accept 함수를 위한 매크로 예제 코드 접기
클라이언트 코드는 비지터가 순회하는 계층 구조상의 모든 클래스에 이와 같은 DEFINE_VISITABLE() 매크로를 추가해 주어야
합니다. 매크로의 Accept 함수가 호출하는 AcceptImpl은 *this의 자료형을 템플릿 인자로 하여 만들어진 템플릿 함수입니다.
우리는 AcceptImpl을 계층 구조의 최상위에 있는 BaseVisitable 안에 정의해 줄 수 있습니다. 다음의 코드에 이와 같이
수정된 BaseVisitable의 내용이 잘 나타나 있습니다.
수정된 BaseVisitable 예제 코드 보기
- template <typename R = void>
- class BaseVisitable
- {
- public:
- typedef R ReturnType
-
- virtual ~BaseVisitable(void) {}
- virtual ReturnType Accept(BaseVisitor&) = 0;
-
- protected:
- template <class T>
- static ReturnType AcceptImpl(T& visited, BaseVisitor& guest)
- {
-
- if (Visitor<T>* p = dynamic_cast<Visitor<T>*>(&guest))
- {
- return p->Visit(visited);
- }
- return ReturnType();
- }
- };
수정된 BaseVisitable 예제 코드 접기
결과적으로 Visitor/Visitable을 통한 디자인은 사용자에게 불편한 세부 사항들을 숨겨주고, 사용상의 불필요한 주의점들을
가지지 않는 일종의 "부적"처럼 동작한다는 것입니다. 그러면, 여기에 우리의 제네릭한 스타일의 비순환 비지터를 구현하는 기반
코드를 소개할까 합니다.
제네릭한 스타일의 비순환 비지터를 구현하는 기반 코드 보기
- class BaseVisitor
- {
- public:
- virtual ~BaseVisitor(void) {}
- };
-
- template <class T, typename R = void>
- class Visitor
- {
- public:
- typedef R ReturnType;
-
- virtual ReturnType Visit(T&) = 0;
- };
-
- template <typename R = void>
- class BaseVisitable
- {
- public:
- typedef R ReturnType;
-
- virtual ~BaseVisitable(void) {}
- virtual ReturnType Accept(BaseVisitor&) = 0;
-
- protected:
- template <class T>
- static ReturnType AcceptImpl(T& visited, BaseVisitor& guest)
- {
-
- if (Visitor<T>* p = dynamic_cast<Visitor<T>*>(&guest))
- {
- return p->Visit(visited);
- }
- return ReturnType();
- }
- };
-
- #define DEFINE_VISITABLE()\
- virtual ReturnType Accept(BaseVisitor& guest)\
- { return AcceptImpl(*this, guest); }
제네릭한 스타일의 비순환 비지터를 구현하는 기반 코드 접기
이제 이 코드의 동작을 테스트 할 준비가 되셨나요? 그러면 다음의 코드를 살펴보도록 합시다.
예제 코드 보기
- class DocElement : public BaseVisitable<>
- {
- public:
- DEFINE_VISITABLE();
- };
-
- class Paragraph : public DocElement
- {
- public:
- DEFINE_VISITABLE();
- };
-
- class MyConcreteVisitor :
- public BaseVisitor,
- public Visitor<DocElement>,
- public Visitor<Paragraph>
- {
- public:
- void Visit(DocElement&) { std::cout << "Visit(DocElement&) \n"; }
- void Visit(Paragraph&) { std::cout << "Visit(Paragraph&) \n"; }
- };
-
- int main(void)
- {
- MyConcreteVisitor visitor;
- Paragraph par;
- DocElement* d = ∥
- d->Accept(visitor);
- }
-
-
-
예제 코드 접기
비지터가 순회하는 계층 구조를 정의하기 위하여 우리가 수행해야 하는 작업들에 대해 다시 한 번 복습해 보도록 합시다.
-
사용자 계층 구조의 최상위 클래스를, BaseVisitable<YourReturnType>을 상속받아 만들도록 합니다.
-
비지터가 순회하는 계층 구조상의 모든 클래스 SomeClass에 DEFINE_VISITABLE() 매크로를 삽입합니다.
-
각 구체적인 비지터 클래스인 SomeVisitor를 BaseVisitor를 상속받아 정의합니다. 또한, 방문하고자 하는
모든 클래스 X에 대하여, SomeVisitor는 Visitor<X, YourReturnType>을 상속받아야만 합니다.
그리고 그것이 순회하는 모든 자료형에 대해 Visit 멤버 함수의 오버라이드 버전을 작성해 주어야 합니다.
이 결과로 생기는 종속 관계는 매우 간단합니다. SomeVisitor에 대한 클래스 정의는 자신이 순회하게 될 클래스들의 이름만을
필요로 합니다. 그리고 Visit 멤버 함수의 구현 코드는 그들이 다루게 되는 클래스에 대해 완전한 종속성을 가지게 됩니다.
특별한 경우에 있어서, 여러분은 DEFINE_VISITABLE 매크로를 사용하는 대신 Accept 함수를 직접적으로 구현하기를 바랄
수도 있습니다.
Accept 함수를 직접 구현한 예제 코드 보기
-
-
-
- class Section : public DocElement
- {
- ...
- virtual void Accept(BaseVisitor& v)
- {
- for (이 Section에 속하는 각 Paragraph를 모두 순환)
- {
- current_paragraph->Accept(v);
- }
- }
- };
Accept 함수를 직접 구현한 예제 코드 접기
-
앞 섹션에서 정의한, 비순환 비지터에 대한 제네릭한 스타일의 구현 코드는 분명 대부분의 경우를 만족시킬 수 있을 것입니다.
하지만, 여러분이 속도에 매우 민감한 애플리케이션을 개발하고 있다면, Accept 함수에서 사용되고 있는 dynamic_cast
형변환 작업을 생각할 때에 다소 고민에 빠질 수 밖에 없습니다. 또한, 실제로 그 속도를 측정해 보게 되면 상당한 실망감을
안게 될 것입니다.
이것은 우리의 비지터 구현 코드가 전통적인 순한 비지터 패턴에도 적용될 수 있도록 만들어주어야 한다는 사실을 의미합니다.
전통적인 비지터 패턴이 어떻게 동작했는지의 내용을 되새겨 보도록 합시다.
-
BaseVisitor 클래스가 더 이상 파이프 클래스의 역할을 수행하지 않습니다. 이것은 각 비지터가 순회하는 자료형에
대해 하나씩의 순가상 함수 Visit을 정의하게 됩니다(오버로딩 기법 사용).
-
AcceptImpl 함수가 수정되어야 합니다. 반면, DEFINE_VISITABLE() 매크로는 바뀌지 않은 채로 남았으면
좋겠습니다.
요컨데, 우리는 방문(순회)하고자 하는 자료형들의 집합을 보유하고 있습니다. 우리가 이 자료형의 집합을 어떻게 표현할 수
있을까요? 이 질문은 3장에서 다루었던 Typelist를 통해 해결될 수 있습니다. 우리는
CyclicVisitor 클래스 템플릿에 이 Typelist를 템플릿 인자로 사용할 것입니다.
CyclicVisitor 예제 코드 보기
- class DocElement;
- class Paragraph;
- class RasterBitmap;
-
- typedef CyclicVisitor
- <
- void,
- TYPELIST_3(DocElement, Paragraph, RasterBitmap)
- >
- MyVisitor;
-
-
-
-
-
-
- template <typename R, class TList>
- class CyclicVisitor : public GenScatterHierarchy<TList,
- Private::VisitorBinder<R>::Result>
- {
- public:
- typedef R ReturnType;
-
- template <class Visited>
- ReturnType Visit(Visited& host)
- {
- Visitor<Visited>& subObj = *this;
- return subObj.Visit(host);
- }
- };
-
-
-
-
-
-
-
-
-
- typedef CyclicVisitor
- <
- void,
- TYPELIST_3(DocElement, Paragraph, RasterBitmap)
- >
- MyVisitor;
-
- class DocElement
- {
- public:
- virtual void Visit(MyVisitor&) = 0;
- };
-
- class Paragraph : public DocElement
- {
- public:
- DEFINE_CYCLIC_VISITABLE(MyVisitor);
- };
CyclicVisitor 예제 코드 접기
이제 우리의 방식으로 구현한 비지터 패턴에서 어떤 계층 구조가 비지터에 의한 순회 작업이 가능하도록 만들기 위해서는 다음의
일들을 처리해 주면 됩니다.
-
계층 구조상에 존재하는 모든 클래스들에 대한 전방 선언 처리를 해줍니다.
-
필요한 반환 자료형과 방문하고자 하는 자료형의 Typelist로 인스턴스화 되는 CyclicVisitor에 대해
typedef를 선언해 줍니다.
-
기반 클래스에 순가상 함수 Visit를 정의해 줍니다.
-
DEFINE_CYCLIC_VISITABLE(MyVisitor)를 계층 구조상의 전체 클래스에 삽입하거나, 또는 기본 동작과는
다르게 동작하기를 바랄 경우 해당 클래스에 대해 Accept 함수를 수동으로 구현합니다.
-
각 구체적인 파생 비지터 클래스들이 MyVisitor를 상속받도록 만듭니다.
-
비지터가 순회할 계층 구조에 새로운 클래스를 추가할 때마다 MyVisitor 템플릿 인스턴스를 새롭게 정의합니다(즉,
typedef 정의 구문을 새롭게 고칩니다). 이것은 안타깝게도 전체 소스 코드의 재컴파일을 필요로 합니다.
-
비지터 패턴은 수많은 변형 버전 및 커스터마이즈 버전을 가지고 있습니다. 이번 섹션은 우리가 만들어 낸 구현 코드에 이러한
변경의 여지를 추가할 수 있도록 대응하는 데 바쳐지고 있습니다.
-
우리는 10.2 섹션에서 이미 깔때기(Catch-all) 함수에 대한 내용을 다루어 보았습니다.
그러면 전통적인 비지터 패턴과, 비순환 비지터 패턴의 구현 코드에 대해서 이러한 깔때기 함수의 문제를 분석해 보도록
합시다.
전통적인 비지터 패턴의 경우에는 그 내용이 상당히 단순합니다. CyclicVisitor에게 넘겨지는 Typelist에
비지터가 순회하는 계층 구조의 기반 클래스를 포함시킨다면, 여러분은 거기에 깔때기 함수의 구현을 추가할 수 있을
것입니다. 또는 그렇지 않고, 컴파일 과정에서 에러가 발생하도록 만들 수 있습니다.
전통적인 비지터의 깔때기 함수 예제 코드 보기
- class DocElement;
- class Paragraph;
- class RasterBitmap;
- class VectorizedDrawing;
-
- typedef CyclicVisitor
- <
- void,
- TYPELIST_3(Paragraph, RasterBitmap, VectorizedDrawing)
- >
- StrictVisitor;
-
-
-
- typedef CyclicVisitor
- <
- void,
- TYPELIST_4(DocElement, Paragraph, RasterBitmap,
- VectorizedDrawing)
- >
- NonStrictVisitor;
-
-
전통적인 비지터의 깔때기 함수 예제 코드 접기
이제, 제네릭한 스타일의 비순환 비지터 구현물에서의 깔때기 함수에 대하여 논의해 보도록 합시다.
우리의 비순환 비지터 구현 코드 중에서 AcceptImpl의 코드를 다시 살펴보도록 합시다.
비순환 비지터의 AcceptImpl 함수 예제 코드 보기
- template <typename R = void>
- class BaseVisitable
- {
- ... 위와 같음 ...
-
- template <class T>
- static ReturnType AcceptImpl(T& visited, BaseVisitor& guest)
- {
- if (Visitor<T>* p = dynamic_cast<Visitor<T>*>(&guest))
- {
- return p->Visit(visited);
- }
- return ReturnType();
- }
- };
비순환 비지터의 AcceptImpl 함수 예제 코드 접기
여러분이 VectorizedDrawing을 여러분의 DocElement 계층 구조에 추가시키는 경우를 생각해 봅시다. 여러분은
이에 대한 어떤 구체적인 비지터 클래스도 발견할 수 없습니다. 매번 VectorizedDrawing이 방문될 때마다,
Visitor<VectorizedDrawing>에 대한 dynamic_cast는 실패하게 될 것입니다. 즉, 이것들은 아직
Visitor<VectorizedDrawing>을 상속받고 있지 않고 있는 것입니다. 동적 형변환이 실패했기 때문에
이 코드는 이를 대체하는 코드로 넘어가서 ReturnType 자료형의 기본 값을 반환하게 됩니다. 여기가 바로 깔때기
함수가 등장하기에 가장 적절한 장소인 것입니다.
AcceptImpl 함수가 ReturnType()을 반환하도록 하드코딩되어 있는데, 여기에 깔때기 함수의 동작을 지시할 수
있는 단위전략을 만들어 사용한다면 괜찮을 것 같습니다.
CatchAll 단위전략 예제 코드 보기
- template
- <
- typename R = void,
- template <typename, class> class CatchAll = DefaultCatchAll
- >
- class BaseVisitable
- {
- ... 위와 같음 ...
-
- template <class T>
- static ReturnType AcceptImpl(T& visited, BaseVisitor& guest)
- {
- if (Visitor<T>* p = dynamic_cast<Visitor<T>*>(&guest))
- {
- return p->Visit(visited);
- }
-
-
- return CatchAll<R, T>::OnUnknownVisitor(visited, guest);
- }
- };
-
-
-
-
-
-
- template <class R, class Visited>
- struct DefaultCatchAll
- {
- static R OnUnknownVisitor(Visited&, BaseVisitor&)
- {
-
-
-
- return R();
- }
- };
CatchAll 단위전략 예제 코드 접기
-
때때로 여러분은 Typelist에 주어지는 모든 자료형 중 일부만 방문하기를 바랄 수도 있습니다. 즉, 여러분은 다음과
같은 선택 사항을 가지기를 원할 것입니다. 어떤 자료형에 대해 Visit를 구현해 주던가, 그렇지 않을 경우에는 사용자가
제공한 CatchAll 구현코드를 통해 OnUnknownVisitor가 자동으로 호출되는 것 말입니다.
이와 같은 류의 상황을 해결해 주기 위하여, Loki는 BaseVisitorImpl이라는 클래스를 제공하고 있습니다.
이것은 BaseVisitor를 상속받아, 보다 융통성 있는 자료형 목록의 사용을 위하여 Typelist의 여러 기법들을
사용하고 있습니다. 여러분은 그러한 구현 코드를 Loki 라이브러리에서 찾아볼 수 있을 것입니다(Visitor.h
파일을 참조하세요).
-
이번 장에서는 비지터 패턴과 그에 따르는 다양한 문제들에 대한 내용을 다루어 보았습니다. 근본적으로, 비지터는 여러분이 어떤
클래스 계층 구조에 전혀 수정을 가하지 않고도 필요한 가상 함수를 추가시킬 수 있도록 만들어 줍니다.
어쨌든, 비지터 패턴은 다양한 문제를 내재하고 있습니다. 정적으로 주어진 클래스 계층 구조에 이것을 적용시키는 것은 굉장히
어려운 일이 됩니다. 여기서 약간의 효율성을 대가로, 비순환 비지터가 큰 도움이 될 수 있습니다.
주의 깊은 디자인과 보다 진보된 구현 기법을 사용하여, 여러분은 최선의 제네릭한 스타일의 비지터 컴포넌트를 얻어낼 수
있었습니다. 그 구현 코드가 비지터 패턴의 모든 능력을 그대로 지니고 있음에도 불구하고, 선천적으로 가지고 있던 결점들도
충분히 완화시킬 수 있었습니다.
실제 애플리케이션에서, 여러분이 원하는 것이 오직 최적화된 속도가 아니라면, 비순환 비지터가 더 바람직한 선택이 될 것입니다.
만일 속도가 보다 중요한 요소라면, 여러분은 전통적인 비지터(GoF 비지터)에 대한 제네릭한 스타일의 구현 코드를 사용할 수
있을 것입니다. 하지만 이것은 유지보수가 힘들며 컴파일에 소요되는 시간 비용도 만만치 않을 것입니다.
이 제네릭한 스타일의 구현 코드들은 dynamic_cast, Typelist 그리고 템플릿의 부분 특화 등 진보된 C++ 프로그래밍
기법들의 힘을 빌어 구현되어 있습니다. 그러한 결과로 우리는 비지터를 구현하는 데 필요한 모든 공통적이고 반복적인 부분을
라이브러리 속으로 잘 포장해 넣을 수 있었습니다.
-
-
비순환 비지터를 구현하기 위해서는 BaseVisitor(파이프 역할의 기반 클래스), Visitor 그리고 Visitable을
사용해야 합니다.
예제 코드 보기
- class BaseVisitor;
-
- template <class T, typename R = void>
- class Visitor;
-
- template
- <
- typename R = void,
- template <class, class> class CatchAll = DefaultCatchAll
- >
- class BaseVisitable;
예제 코드 접기
-
Visitor의 두 번째 템플릿 인자와 BaseVisitable의 첫 번째 템플릿 인자는 각각 Visit와 Accept 멤버 함수의
반환 자료형입니다. 그리고 그 기본 자료형은 void입니다.
-
BaseVisitable의 두 번째 템플릿 인자는 깔때기(Catch-all)의 문제를 다루게 되는 단위
전략입니다(10.2 섹션 참조).
-
여러분이 가진 클래스 계층 구조의 최상위 기반 클래스가 BaseVisitable을 상속받도록 만들어 주어야 합니다.
그리고 DEFINE_VISITABLE() 매크로를 그 계층 내의 모든 클래스에 삽입해 주어야 합니다. 아니면, 그 대신
Accept(BaseVisitor&) 함수의 코드를 직접 작성해 주어야 합니다.
-
여러분의 구체 비지터 클래스가 BaseVisitor를 상속받도록 만들어 주어야 합니다. 또한, 이것은 자신이 방문하고자
하는 각각의 자료형 T에 대해, 모든 Visitor<T> 클래스를 상속받아야 합니다.
-
전통적인(GoF) 비지터 패턴에 대해서는 CyclicVisitor 템플릿을 사용할 수 있습니다.
CyclicVisitor의 선언 예제 코드 보기
-
비지터가 순회하게 될 자료형들을 TList 템플릿 인자로 명시해 주어야 합니다.
-
고전적인 GoF의 비지터 패턴을 사용하고자 할 경우에 여러분의 코드에 CyclicVisitor를 사용하도록 하십시오
-
GoF의 비지터의 특정 부분(엄격하지 않은 버전)만을 직접 구현하기를 원한다면, 여러분의 비지터가 순회하게 될
여러분의 계층 구조가 BaseVisitorImpl을 상속받도록 만들어 주십시오. BaseVisitorImpl은 모든 Visit
오버로드 함수가 OnUnknownVisitor를 호출하도록 구현하고 있습니다. 여러분은 이러한 동작 중에 필요한
부분만을 오버라이드 시킬 수 있습니다.
-
CatchAll 단위전략의 OnUnknownVisitor 스태틱 멤버 함수는 비순환 비지터에 대해 일종의 깔때기 역할을 수행합니다.
기본적으로 제공되는 OnUnknownVisitor의 구현 코드는 여러분이 선택한 정해진 기본 값을 반환하도록 되어 있습니다.
여러분은 여기에 자신만의 CatchAll 단위전략 클래스를 제공하여 이러한 동작을 오버라이드 할 수 있습니다.
-
C++의 가상 함수 메커니즘을 이용하면 주어진 한 객체의 자료형에 따라서 각 함수들을 필요한 곳으로 분배(디스패치)시키는 것이 가능합니다.
멀티 메소드의 특징은 하나의 객체가 아니라, 다수로 주어지는 객체의 각 자료형에 따라 함수 호출을 필요한 곳으로 분배시킬 수 있다는
것입니다. 통상적으로, 훌륭한 구현 코드들은 언어 자체의 지원을 필요로 하기 마련이며, C++에서는 이러한 언어적 지원이 부족합니다.
이번 장에서는 몇 가지 전형적인 해법들과 아울러 각각에 대한 제네릭한 스타일의 구현법을 다루어 보도록 하겠습니다. 각 해법들은 그 속도와
유연성, 그리고 종속성의 관리 작업에 있어서 다양한 트레이드 오프를 지니는 서로 다른 특성을 나타냅니다. 다수의 객체에 대해 함수 호출을
분배시키는 기법을 호칭하기 위하여 이 책에서는 멀티 메소드(multimethod) 및
다중 디스패치(multiple dispatch)라는 용어를 사용하도록 하겠습니다.
이번 장에서는 다음과 같은 주제들을 다루게 됩니다.
-
멀티 메소드를 정의합니다.
-
다중 객체에 대해서 다형성의 동작이 필요해지는 상황을 구분해 냅니다.
-
서로 다른 트레이드 오프 관계를 나타내는 세 개의 이중 디스패치 엔진에 대해 논의하고 또 구현해 냅니다.
-
이중 디스패치 엔진을 개선하여 강화시킵니다.
이번 장에서는 멀티 메소드를 두 개의 객체에 대한 것(이중 디스패치)으로만 한정짓고 있습니다. 여러분은 지원 가능한 객체의 숫자를
셋 이상으로 늘리기 위해, 여기에 사용된 기반 기술들을 응용할 수 있을 것입니다.
-
C++에서, 근본적으로 다형성이란 주어진 함수 호출이 컴파일 시간, 혹은 실행 시간에 따라 서로 다른 구현 코드로 연결될
수 있는 성질을 의미합니다.
C++에서는 다음과 같은 두 종류의 다형성이 구현되어 있습니다.
-
컴파일 시간에서의 다형성: 오버로드 함수 및 템플릿 함수를 통해 지원됩니다.
-
실행 시간에서의 다형성: 가상 함수를 통해 지원됩니다.
오버로딩은 다형성의 간단한 한 형태로서 같은 이름을 가지는 다수의 함수가 동일한 영역 내에 공존하는 것을 허용합니다.
만일 이 함수들이 서로 다른 인자 목록을 가지고 있다면, 컴파일 하는 순간에 컴파일러가 이들을 구별해 낼 수 있을 것입니다.
템플릿 함수는 디스패치 기능을 달성해 주는 하나의 정적인 메커니즘입니다. 이것은 보다 복잡한 방식으로 컴파일 시간에서의
다형성을 제공해 줍니다.
그리고 가상 멤버 함수는 C++의 실시간 지원 코드가 지원하는 기능으로서 실행 시간에 실질적으로 각 함수 구현 코드들 중
어떤 것을 호출할지를 결정해 주기 위한 것입니다.
오버로딩과 템플릿 함수는 근본적으로 다수의 객체들에 대해 자연스럽게 적용될 수 있습니다. 그러나 불행하게도, 가상
함수는 오직 하나의 객체에 대해서만 동작하도록 디자인 되어 있습니다. 심지어 그 호출 문법[obj.Fun(인자 목록)] 조차도
obj 객체에게 인자 목록에 우선하는 어떤 권한을 부여하고 있는 것입니다.
우리는 멀티 메소드와 다중 디스패치를 다음과
같이 정의하였습니다. 이것은 함수 호출에 관여하는 다수의 객체들의 동적 자료형에 따라 구체적인 특정 함수가 호출되도록
잘 분배해 주는 메커니즘이라는 것입니다. 여기서는 오직 실시간 수행 코드에 있어서의 다형성만을 구현해 보도록 하겠습니다.
-
여러분이 수행해야 할 작업이 여러 개의 다형성 객체를 다루는 데 그들의 기반 클래스에 대한 포인터, 혹은 참조값을 사용하고
있다고 가정합시다. 여러분은 이러한 작업에 대해서 그 둘 이상의 객체의 자료형에 따라 서로 다른 동작을 하기를 바랄 수가
있습니다.
이른 바 '충돌 검사'라고 불리는 것은 이러한 유형의 문제에 대한 전형적인 예로서, 멀티 메소드를 이용하면 최적화시켜 해결해
낼 수 있습니다. 예를 들어, 여러분이 게임 프로그램을 만들면서, 움직이는 모든 객체들을 GameObject라는 추상 클래스로부터
파생시켜 만들었다고 가정해 봅시다. 여러분은 그들의 충돌이 실제로 충돌하는 두 객체의 자료형에 따라 서로 다르게 처리되기를
바랄 것입니다. 예를 들어, 우주선과 소행성의 충돌, 우주선과 우주 정거장의 충돌, 또는 소행성과 우주 정거장의 충돌은
모두 다른 방식으로 처리되어야 할 것입니다.
또 다른 예로, 여러분이 도형 객체들 사이에서 서로 겹쳐지는 영역을 표시하고자 하는 경우를 생각해 봅시다. 여기서 우리에게
필요한 것은 여러 알고리즘의 대기 목록을 구성하는 것입니다. 그리고 이 목록의 각 알고리즘들은 두 자료형(예를 들어,
사각형-사각형, 사각형-다각형, 사각형-타원, 다각형-다각형, 다각형-타원, 타원-타원과 같은)에 대해 특화된
알고리즘입니다(예를 들어 사각형과 타원이 겹쳐지는 영역을 표시하는 알고리즘보다 사각형과 사각형이 겹쳐지는 영역을 표시하는
알고리즘이 간단하고 빠를 것입니다). 실행 시간에 있어서, 사용자가 화면상의 도형을 움직이면 여러분은 이 중에서 적절한
알고리즘을 선택하여 실행시켜, 겹쳐지는 영역을 재빠르게 계산하고 표시할 수 있도록 하길 원할 것입니다.
이 모든 도형 객체들은 일괄적으로 추상 클래스 Shape에 대한 포인터를 통하여 다루어지고 있기 때문에, 여러분은 주어진
상황에서 적절한 알고리즘을 선택하기 위해 충분한 자료형 정보를 가지고 있지는 못합니다. 게다가, 두 객체가 함께 연관되어
있기 때문에, 단순한 가상 함수는 이 문제에 대한 해결책이 될 수 없습니다. 따라서 여러분은 이중 디스패치 기법을 사용해야
합니다.
-
이중 디스패치 기법을 구현하는 가장 직접적인 접근 방법은 이중 자료형 스위치를 이용하는 것입니다. 여러분은 첫 번째 객체를
가지고, 이것이 가질 수 있는 가능한 모든 자료형 목록에 대하여 차례대로 동적 형변환을 시도하게 됩니다. 그 각 경우에 대하여,
두 번째 인자에 대해서도 동일한 작업을 수행해 주어야 합니다. 그리고 두 객체의 자료형을 모두 발견하게 되면, 여러분은
이제 어떤 함수를 호출해야 할지를 알게 됩니다.
Brute Force 이중 디스패치 예제 코드 보기
- void DoHatchArea1(Rectangle&, Rectangle&);
- void DoHatchArea2(Rectangle&, Ellipse&);
- void DoHatchArea3(Rectangle&, Poly&);
- void DoHatchArea4(Ellipse&, Ellipse&);
- void DoHatchArea5(Ellipse&, Poly&);
- void DoHatchArea6(Poly&, Poly&);
-
- void DoubleDispatch(Shape& lhs, Shape& rhs)
- {
- if (Rectangle* p1 = dynamic_cast<Rectangle*>(&lhs))
- {
- if (Rectangle* p2 = dynamic_cast<Rectangle*>(&rhs))
- {
- DoHatchArea1(*p1, *p2);
- }
- else if (Ellipse* p2 = dynamic_cast<Ellipse*>(&rhs))
- {
- DoHatchArea2(*p1, *p2);
- }
- else if (Poly* p2 = dynamic_cast<Poly*>(&rhs))
- {
- DoHatchArea3(*p1, *p2);
- }
- else
- {
- Error("Undefined Intersection");
- }
- }
- else if (Ellipse* p1 = dynamic_cast<Ellipse*>(&lhs))
- {
- if (Rectangle* p2 = dynamic_cast<Rectangle*>(&rhs))
- {
- DoHatchArea2(*p2, *p1);
- }
- else if (Ellipse* p2 = dynamic_cast<Ellipse*>(&rhs))
- {
- DoHatchArea4(*p1, *p2);
- }
- else if (Poly* p2 = dynamic_cast<Poly*>(&rhs))
- {
- DoHatchArea5(*p1, *p2);
- }
- else
- {
- Error("Undefined Intersection");
- }
- }
- else if (Poly* p1 = dynamic_cast<Poly*>(&lhs))
- {
- if (Rectangle* p2 = dynamic_cast<Rectangle*>(&rhs))
- {
- DoHatchArea3(*p2, *p1);
- }
- else if (Ellipse* p2 = dynamic_cast<Ellipse*>(&rhs))
- {
- DoHatchArea5(*p2, *p1);
- }
- else if (Poly* p2 = dynamic_cast<Poly*>(&rhs))
- {
- DoHatchArea6(*p1, *p2);
- }
- else
- {
- Error("Undefined Intersection");
- }
- }
- else
- {
- Error("Undefined Intersection");
- }
- }
Brute Force 이중 디스패치 예제 코드 접기
보시다시피, 이와 같은 Brute-Force 방식의 접근 방법에서는 그 내용은 비록 하찮은 코드이지만 양에 있어서는 상당한 분량의
코드를 작성해야 할 필요가 생기게 됩니다. 게다가 이 방법은 가능한 클래스의 양이 그다지 많지 않은 경우에만 속도상의 이점을
얻을 수 있습니다. 속도의 관점에서 볼 때 DoubleDispatch 함수는 선택 가능한 자료형의 개수에 따라서 선형 시간의 탐색
속도를 보여 줍니다.
이러한 Brute-Force식 접근법의 가장 큰 문제는 그 코드의 크기에 있습니다. 이 방법을 사용할 경우, 클래스의 개수가
증가함에 따라 코드의 유지보수는 거의 불가능해질 것입니다.
DoubleDispatch 함수의 또 다른 문제는 이것의 종속성 관리에 있어서 상당한 병목 현상을 유발한다는 점입니다. 이 구현
코드는 해당 계층 구조에 존재하는 모든 클래스들의 존재를 알고 있어야만 합니다.
DoubleDispatch가 가져오는 세 번째 문제는 if 구문의 순서가 문제가 될 수 있다는 점입니다. 예를 들어, 여러분이
RoundedRectangle 객체를 Rectangle로부터 상속받은 경우를 가정해 보도록 합시다. 객체가 RoundedRectangle인지를
판별하는 if 구문이, 객체가 Rectangle인지를 판별하는 if 구문보다 나중에 검사되는 경우에 문제가 발생합니다.
이런 경우 Rectangle에 대해 처리하는 코드가 RoundedRectangle까지 모두 처리해 버리게 됩니다.
이를 해결할 수 있는 해법으로는 다음과 같이 검사의 코드를 조금 바꾸는 방법이 있을 수 있습니다.
예제 코드 보기
- void DoubleDispatch(Shape& lhs, Shape& rhs)
- {
- if (typeid(lhs) == typeid(Rectangle))
- {
- Rectangle* p1 = dynamic_cast<Rectangle*>(&lhs);
- ...
- }
- else ...
- }
예제 코드 접기
앞에서는 정확한 자료형과 파생 자료형에 대해 모두 동작하던 코드가 이제는 실질적인 정확한 자료형에 대해서만 동작하도록 수정이
되었습니다.
그러나 아뿔싸! 이번에는 너무나 엄격한 자료형 검사를 통해서 동작하게 되었습니다. 만일 특정 자료형에 대한 지원 코드를
DoubleDispatch에 직접 추가시키는 대신, DoubleDispatch가 가장 가까운 기반 자료형에 대해서 동작하도록
만들기를 바란다면 어떻게 해야 할까요? 기본적으로, 특별한 오버라이드 코드를 작성하지 않았을 경우, 파생 클래스는 분명
기반 클래스가 하는 일을 그대로 따라 할 수 있어야 합니다. 그런데 지금의 코드는 이런 기본 특성을 위배하게 되는 문제가
발생하게 되는 것입니다.
이것은 멀티 메소드에 대한 Brute-Force적인 구현법에 두 가지 단점을 더 추가해 주게 됩니다. 첫째로, DoubleDispatch
및 Shape 계층 구조 사이의 종속성 문제가 더 심화된다는 것입니다. DoubleDispatch는 이제 클래스의 이름뿐들 뿐만 아니라,
클래스 상호간의 상속 관계까지도 파악해야 하는 처지가 되었습니다(파생 클래스가 더 먼저 검사되도록 하기 위해). 둘째로,
dynamic_cast의 적절한 순서를 유지해주는 작업은 코드 유지/보수자의 어께에 얹혀지는 추가적인 짐이 된다는 것입니다.
-
특정 상황에서는 Brute-Force 접근법에 의해 얻어지는 빠른 수행 속도를 포기할 수 없는 경우도 있습니다. 그런 경우에는
이러한 디스패치 엔진을 주의 깊게 구현해주는 일도 의미있는 작업이 될 수 있습니다. 그리고 바로 여기에
Typelist가(3장 참조) 큰 도움으로 작용합니다.
멀티 메소드에 대한 Brute-Force적인 구현 코드는 클라이언트 프로그래머가 제공한 Typelist를 사용하여 계층 구조상의
각 클래스들을 지시해 줄 수 있습니다. 그러면 반복적으로 정의된 템플릿 코드가 필요한 if-else 구문을 자동으로 생성해
주게 될 것입니다.
일반적인 경우에, 우리는 서로 다른 자료형 집합에 대해서 디스패치 작업을 수행하게 됩니다. 따라서 왼쪽 인자에 대한
Typelist가 오른쪽 인자에 대한 Typelist와 다르게 정의될 수도 있습니다.
StaticDispatcher<...>::Go 예제 코드 보기
- template
- <
- class Executor,
- class BaseLhs,
- class TypesLhs,
- class BaseRhs = BaseLhs,
- class TypesRhs = TypesLhs,
- typename ResultType = void
- >
- class StaticDispatcher
- {
- typedef typename TypesLhs::Head Head;
- typedef typename TypesLhs::Tail Tail;
-
- public:
- static ResultType Go(BaseLhs& lhs, BaseRhs& rhs, Executor exec)
- {
- if (Head* p1 = dynamic_cast<Head*>(&lhs))
- {
- return StaticDispatcher<Executor, BaseLhs,
- TypesLhs, BaseRhs, TypesRhs, ResultType>::DispatchRhs(
- *p1, rhs, exec);
- }
- else
- {
- return StaticDispatcher<Executor, BaseLhs,
- Tail, BaseRhs, TypesRhs, ResultType>::Go(
- lhs, rhs, exec);
- }
- }
- ...
- };
StaticDispatcher<...>::Go 예제 코드 접기
StaticDispatcher는 여섯 개의 템플릿 인자를 가지고 있습니다. Executor는 실질적인 처리를 담당하는 객체의 자료형입니다.
Executor에 대한 자세한 내용은 조금 있다 다루겠습니다.
BaseLhs 및 BaseRhs는 각각 왼쪽 및 오른쪽 인자로 주어지는 객체들에 대한 기반 자료형입니다. 그리고 TypesLhs와
TypesRhs는 그 두 인자가 취할 수 있는 실질적인 자료형 목록을 담고 있는 Typelist입니다.
ResultType은 이중 디스패치 동작의 결과 자료형을 의미합니다.
StaticDispatcher::Go는 lhs로 주어지는 자료형에 대해서 TypesLhs에서 발견되는 첫 번째 자료형으로의 dynamic_cast를
시도하게 됩니다. 만일 이 동적 형변환 작업이 실패한다면, Go는 TypesLhs의 나머지 부분에게 동작을 위임시키도록 자신에 대한 재귀
호출(컴파일 단계에서의)을 수행하게 됩니다.
그리고 대응되는 자료형을 찾아내게 되면, Go는 DispatchRhs 함수를 호출합니다. DispatchRhs는 rhs의 동적 자료형
정보를 얻기 위한 자료형 연역 과정에 관여하는 두 번째이자, 마지막 과정입니다.
StaticDispatcher<...>::DispatchRhs 예제 코드 보기
- template <...>
- class StaticDispatcher
- {
- ... 앞과 동일 ...
-
- template <class SomeLhs>
- static ResultType DispatchRhs(SomeLhs& lhs, BaseRhs& rhs, Executor exec)
- {
- typedef typename TypesRhs::Head Head;
- typedef typename TypesRhs::Tail Tail;
-
- if (Head* p2 = dynamic_cast<Head*>(&rhs))
- {
- return exec.Fire(lhs, *p2);
- }
- else
- {
- return StaticDispatcher<Executor, BaseLhs,
- TypesLhs, BaseRhs, Tail, ResultType>::DispatchRhs(
- lhs, rhs, exec);
- }
- }
- };
StaticDispatcher<...>::DispatchRhs 예제 코드 접기
DispatchRhs는 Go 함수가 lhs에 대해 수행했던 것과 동일한 알고리즘을 rhs에 대해 수행하게 됩니다. 그와 더불어,
rhs에 대한 동적 형변환이 성공하게 되는 경우에, DispatchRhs는 찾아낸 자료형의 두 인자와 함께 Executor::Fire를
호출해 주게 됩니다. 결국 Go 함수는 TypesLhs의 각 자료형마다 하나씩의 if-else 구문을 만들어 내고, DispatchRhs
함수는 TypesLhs의 각 자료형마다 한 벌씩의 if-else 구문을 만들어 냅니다. 이것은 한편으로는 장점이기도 하지만,
다른 한편으로는 잠재적인 위험성을 내포하기도 합니다. 즉, 지나친 자동 생성 코드가 컴파일 시간과 프로그램 크기, 그리고
프로그램 수행 시간에까지 악영향을 미칠 수도 있다는 것입니다.
컴파일 단계에서의 재귀 동작을 중단시켜 주는 어떤 제한 조건을 도입시키기 위해서 우리는 StaticDispatcher를 두가지
경우에 대해 특화시켜 줄 필요가 있습니다.
StaticDispatcher의 특화 예제 코드 보기
-
- template
- <
- class Executor,
- class BaseLhs,
- class BaseRhs,
- class TypesRhs,
- typename ResultType
- >
- class StaticDispatcher<Executor, BaseLhs, NullType,
- BaseRhs, TypesRhs, ResultType>
- {
- public:
- static void Go(BaseLhs& lhs, BaseRhs& rhs, Executor exec)
- {
- exec.OnError(lhs, rhs);
- }
- };
-
-
-
- template
- <
- class Executor,
- class BaseLhs,
- class TypesLhs,
- class BaseRhs,
- typename ResultType
- >
- class StaticDispatcher<Executor, BaseLhs, TypesLhs,
- BaseRhs, NullType, ResultType>
- {
- public:
- static void DispatchRhs(BaseLhs& lhs, BaseRhs& rhs, Executor exec)
- {
- exec.OnError(lhs, rhs);
- }
- };
StaticDispatcher의 특화 예제 코드 접기
이제, 우리가 정의해 낸 이중 디스패치 엔진의 이점을 누리기 위하여 Executor 클래스가 구현해 주어야 할 것이 무엇인지를
논의해 보아야 할 때가 왔습니다.
올바른 자료형 및 객체를 찾아낸 후에, StaticDispatcher는 그것들을 Executor::Fire에 대한 함수 호출의 인자로
넘겨주게 됩니다. 이에 따른 각 호출들을 구분시켜 주기 위하여 Executor는 Fire 함수에 대한 여러 개의 오버로드 함수를
구현해 주어야 합니다.
Executor의 구현과, StaticDispatcher의 사용 예제 코드 보기
- class HatchingExecutor
- {
- public:
-
- void Fire(Rectangle&, Rectangle&);
- void Fire(Rectangle&, Ellipse&);
- void Fire(Rectangle&, Poly&);
- void Fire(Ellipse&, Rectangle&);
- void Fire(Ellipse&, Ellipse&);
- void Fire(Ellipse&, Poly&);
- void Fire(Poly&, Rectangle&);
- void Fire(Poly&, Ellipse&);
- void Fire(Poly&, Poly&);
-
-
- void OnError(Shape&, Shape&);
- };
-
-
-
-
- typedef StaticDispatcher<HatchingExecutor, Shape,
- TYPELIST_3(Rectangle, Ellipse, Poly)> Dispatcher;
-
- Shape* p1 = ...;
- Shape* p2 = ...;
-
- HatchingExecutor exec;
-
- Dispatcher::Go(*p1, *p2, exec);
-
-
Executor의 구현과, StaticDispatcher의 사용 예제 코드 접기
이에 따른 멋진 부대 효과의 하나로, StaticDispatcher는 또한, 컴파일 하는 동안에 발생하는 오버로딩과 관련된 모든
모호성의 오류를 밝혀내 줄 것입니다.
만일 실행 시간에 오류가 발생하는 경우에는 어떤 일이 일어날까요? 우리의 예제에 있어서, 이것은 곧
HatchingExecutor::OnError(Shape&, Shape&)이 오류 처리 코드가 된다는 사실을 의미합니다.
앞 섹션에서 논의된 바와 같이, Brute-Force 디스패치 엔진에 있어서 클래스의 상속은 문제를 가중시키게 됩니다. 즉, 다음과
같이 인스턴스화된 StaticDispatcher는 버그를 가지게 된다는 뜻입니다.
버그가 있는 StaticDispatcher 인스턴스 예제 코드 보기
이 문제를 해결하려면, Typelist 내에서 RoundedRectangle의 순서를 Rectangle보다 앞에 오도록 배치해야 합니다.
이 규칙을 일반화시켜 말한다면, 보다 파생 클래스가 되는 쪽을 Typelist의 앞부분에 위치시켜야 한다는 것입니다.
그리고 3장에서 다루었던 DerivedToFront라는 컴파일 시간에 적용되는 알고리즘을 사용하면
이러한 작업을 자동화 할 수 있습니다.
DerivedToFront를 적용한 StaticDispatcher 예제 코드 보기
- template <...>
- class StaticDispatcher
- {
- typedef typename DerivedToFront<TypesLhs>::Result::Head Head;
- typedef typename DerivedToFront<TypesLhs>::Result::Tail Tail;
-
- public:
- ... 앞의 코드와 같음 ...
- };
DerivedToFront를 적용한 StaticDispatcher 예제 코드 접기
이 편리한 자동 정렬 도구를 사용하여 얻어낼 수 있는 것은, 모두 코드를 생성해 내는 것과 관련된 부분이라는 사실을 잊지
마시기 바랍니다. 따라서, 종속성의 문제는 여전히 우리를 괴롭힐 것입니다. 이것의 장점은, 계층 구조상의 자료형이
지나치게 많은 경우가 아닌 경우에 속도가 빠르다는 점과, StaticDispatcher를 사용하기 위해 계층 구조상에 수정을
가할 필요가 전혀 없다는 점입니다.
-
두 도형 사이에 겹치는 영역에 음영 표시를 할 때 여러분은 사각형이 타원을 덮는 경우와 타원이 사각형을 덮는 경우를 다르게
표시해 주길 원할 수도 있습니다. 또는, 그 반대로 타원과 사각형의 영역이 겹치는 경우라면, 순서에 상관하지 않고 모두 같은
방식으로 음영을 표시하길 원할 수도 있습니다. 후자의 경우에, 여러분은 대칭적 멀티
메소드가 필요하게 될 것입니다.
대칭성이란 두 인자의 자료형 목록이 정확히 일치하는 경우에만 적용될 수 있습니다.
앞 섹션에서 정의한 Brute-Force 버전의 StaticDispatcher는 비대칭적 특성을 가집니다. 예를 들어, 여러분이 다음과
같은 클래스를 정의한다고 가정해 봅시다.
비대칭적 특성을 갖는 StaticDispatcher 예제 코드 보기
- class HatchingExecutor
- {
- public:
- void Fire(Rectangle&, Ellipse&);
- ...
-
-
- void OnError(Shape& Shape&);
- };
-
- typedef StaticDispatcher
- <
- HatchingExecutor,
- Shape,
- TYPELIST_3(Rectangle, Ellipse, Poly)
- >
- HatchingExecutor;
비대칭적 특성을 갖는 StaticDispatcher 예제 코드 접기
여기서 HatchingExecutor는 Ellipse를 왼쪽 인자로, 그리고 Rectangle을 오른쪽 인자로 넘겨줄 경우에는 Fire
함수를 호출해 내지 못합니다.
우리는 클라이언트 코드 상에서 인자의 순서를 바꾸어 정의하고 한쪽 오버로드 함수가 다른 쪽 오버로드 함수를 그대로 호출해 주는
방법을 통하여 이 대칭성의 문제를 수정해 줄 수 있습니다.
클라이언트 코드의 대칭성 보장 예제 코드 보기
- class HatchingExecutor
- {
- public:
- void Fire(Rectangle&, Ellipse&);
-
-
- void Fire(Ellipse& lhs, Rectangle& rhs)
- {
-
-
- Fire(rhs, lhs);
- }
- ...
- };
클라이언트 코드의 대칭성 보장 예제 코드 접기
그러나 이 작은 전달 함수는 사실 관리해 주기가 매우 까다로운 존재입니다. 이상적으로라면, 템플릿 인자에 bool 값을 사용하여,
StaticDispatcher가 스스로 대칭성을 위한 추가적인 지원을 해 주도록 한다면 좋을 것 같습니다.
우리에게 필요한 것은, 특별한 경우에 따라 StaticDispatcher가 콜백 함수를 호출할 때 인자의 순서를 뒤집어 주도록
하는 일입니다. 앞의 예제를 다시 분석해 보도록 합시다.
예제 코드 보기
- typedef StaticDispatcher
- <
- HatchingExecutor,
- Shape,
- TYPELIST_3(Rectangle, Ellipse, Poly),
- Shape,
- TYPELIST_3(Rectangle, Ellipse, Poly),
- void
- >
- HatchingDispatcher
예제 코드 접기
대칭적 디스패치 엔진을 위해서 적절한 인자의 짝을 선택하는 알고리즘은 다음과 같습니다. 두 번째 Typelist에 속하는
자료형들은 그 index가 오직 첫 번째 Typelist에 속한 자료형보다 같거나 클 때에만
선택되게 하는 것입니다.
이 알고리즘을 따르면, 다음과 같이 여러분은 오직 이렇게 선택된 조합에 대한 함수만을 구현하게 됩니다.
예제 코드 보기
- class HatchingExecutor
- {
- public:
- void Fire(Rectangle&, Rectangle&);
- void Fire(Rectangle&, Ellipse&);
- void Fire(Rectangle&, Poly&);
- void Fire(Ellipse&, Ellipse&);
- void Fire(Ellipse&, Poly&);
- void Fire(Poly&, Poly&);
-
-
- void OnError(Shape&, Shape&);
- };
예제 코드 접기
StaticDispatcher는 앞서 설명한 알고리즘에 의해 제거된 모든 조합을 자동으로 감지해 낼 수 있어야 합니다. 이 조합에
대해서만, StaticDispatcher는 주어진 인자의 순서를 뒤집어 주어야 하는 것입니다.
앞서 설명한 알고리즘을 되새겨 보면, 이러한 순서 변환 조건이 다음과 같음을 알 수 있습니다.
자료형 T와 U에 대해서, TypesRhs에서 U가 위치하는 index가, TypesLhs에서 T가 위치하는 index보다 작을 경우에,
이 인자는 순서가 바뀌어야 합니다.
Typelist라는 우리의 편리한 도구는 이미 컴파일 시간에 적용시킬 수 있는 IndexOf라는 기능을 제공하고 있습니다.
먼저, 우리는 이 디스패치 엔진이 대칭적인지 그렇지 않은지를 나타내주는 새로운 템플릿 인자를 추가해야 할 것입니다.
그리고 우리는 작고 간단한 InvocationTraits라는 클래스 템플릿을 추가해 줄 것입니다. 이것은 Executor::Fire
멤버 함수를 호출할 때에 주어진 조건에 따라 인자의 순서를 바꿔주거나, 아니면 그대로 유지해 주는 역할을 합니다.
대칭적 특성을 갖는 StaticDispatcher 예제 코드 보기
- template
- <
- class Executor,
- bool symmetric,
- class BaseLhs,
- class TypesLhs,
- class BaseRhs = BaseLhs,
- class TypesRhs = TypesLhs,
- typename ResultType = void
- >
- class StaticDispatcher
- {
- template <bool swapArgs, class SomeLhs, class SomeRhs>
- struct InvocationTraits
- {
- static ResultType DoDispatch(SomeLhs& lhs, SomeRhs& rhs, Executor exec)
- {
- return exec.Fire(lhs, rhs);
- }
- };
-
- template <class SomeLhs, class SomeRhs>
- struct InvocationTraits<true, SomeLhs, SomeRhs>
- {
- static ResultType DoDispatch(SomeLhs& lhs, SomeRhs& rhs, Executor exec)
- {
- return exec.Fire(rhs, lhs);
- }
- };
-
- public:
- template <class SomeLhs>
- static ResultType DispatchRhs(SomeLhs& lhs, BaseRhs& rhs, Executor exec)
- {
- typedef typename TypesRhs::Head Head;
- typedef typename TypesRhs::Tail Tail;
-
- if (Head* p2 = dynamic_cast<Head*>(&rhs))
- {
- enum {
- swapArgs = symmetric &&
- IndexOf<TypesRhs, Head>::value <
- IndexOf<TypesLhs, SomeLhs>::value
- };
-
- typedef InvocationTraits<swapArgs, SomeLhs, Head> CallTraits;
-
- return CallTraits::DoDispatch(lhs, *p2, exec);
- }
- else
- {
- return StaticDispatcher<Executor, symmetric, BaseLhs,
- TypesLhs, BaseRhs, Tail, ResultType>::DispatchRhs(
- lhs, rhs, exec);
- }
- }
- };
-
-
-
-
대칭적 특성을 갖는 StaticDispatcher 예제 코드 접기
-
만약 여러분이 Brute-Force식 접근 방법에 따르는 굉장히 무거운 종속성의 문제를 피하고자 한다면, 좀더 동적인 접근 방법을
찾아보아야 할 것입니다. 컴파일 시간에 코드를 생성해 주는 대신, 실행 시간의 동적 구조체를 유지하고, 또한 자료형에 대한
동적 디스패치 작업을 위하여 실행 시간의 알고리즘을 사용하는 것이 그 방법이 될 것입니다.
RTTI(Runtime Type Information)가 여기서 훌륭한 도움이 될 것입니다. RTTI는 dynamic_cast와 각 자료형의
구별법을 제공할 뿐만 아니라, std::type_info의 before 멤버 함수를 통하여 실시간에 자료형의 순서를 결정할 수 있는
기능도 제공해 주기 때문입니다.
여기서의 구현 코드는 Scott Meyers의
『More Effective C++』(1996a) 서적의
'항목 31'에서
발견되는 코드와 매우 유사합니다. 하지만 다음과 같은 몇 가지 개선점이 있습니다. 여기서는, 핸들러 함수를 호출할 때의
형변환 과정이 자동화되어 있으며, 구현 코드들도 보다 더 제네릭한 스타일로 구성되어 있습니다.
우리는 여기에서 2장에서 다루었던 OrderedTypeInfo라는 클래스를 사용하도록 할 것입니다.
이 클래스는 값으로서의 의미를 가지며, 제한 없이 사용할 수 있는 대소 비교 연산자를 제공합니다.
Meyers의 접근법은 간단합니다. 디스패치에 사용하고자 하는 객체들의 std::type_info 각 쌍에 대해서, 더블 디스패치
엔진에 적절한 함수에 대한 포인터를 등록하자는 것입니다. 더블 디스패치 엔진은 이러한 정보를 std::map을 통해 저장하게
될 것입니다.
그러면 이러한 원리 하에 동작하는 제네릭한 스타일의 엔진 구조를 정의해 보도록 합시다. 우리는 이 엔진에서 두 인자의
기반 자료형을 템플릿화시켜야 합니다. 이 엔진을 BasicDispatcher라 부르도록 하겠습니다.
BasicDispatcher 예제 코드 보기
- template <class BaseLhs, class BaseRhs = BaseLhs,
- typename ResultType = void>
- {
- typedef std::pair<OrderedTypeInfo, OrderedTypeInfo>
- KeyType;
-
- typedef ResultType (*CallbackType)(BaseLhs&, BaseRhs&);
- typedef CallbackType MappedType;
- typedef std::map<KeyType, MappedType> MapType;
-
- MapType callbackMap_;
-
- public:
- ...
- };
-
-
BasicDispatcher 예제 코드 접기
만일 콜백(callback) 자료형이 템플릿화 될 수 있다면, BasicDispatcher는 보다 더 일반화된 구조를 가질 수 있을
것입니다. 내부 자료형 CallbackType의 사용자 정의 부분을 템플릿 인자로 전환시킨다면, BasicDispatcher는 함수자를
수용할 수 있게 됩니다.
그리고 여기에 또 다른 중요한 개선점이 있다면 std::map 자료형 대신 정렬된 벡터를 사용하는 것이 있을 것입니다.
정렬된 벡터는 원소의 삽입이나 삭제 연산에 비해 검색 연산이 훨씬 많이 일어나는 경우에 유리합니다(공간적 시간적 모두).
대부분의 경우에, 이중 디스패치 엔진은 단 한 번 구조체를 구성한 후, 많은 횟수의 읽기 작업을 거치게 됩니다. 따라서,
이중 디스패치 엔진의 내부 자료구조로 std::map을 사용하는 것보다 정렬된 벡터를 사용하는 것이 더 나아 보입니다.
Loki는 AssocVector 클래스 템플릿을 제공함으로써 수동으로 정렬된 벡터 구조를 유지해야 하는 문제로부터 우리를
구원해 줍니다. AssocVector가 map과 다른 부분은 그 동작적인 측면에 있어서는 erase 함수가 다르며, 복잡도의
측면에 있어서는 insert와 erase 함수가 다릅니다. AssocVector는 std::map과 상당한 정도의 호환성을 가지고
있기 때문에, 우리는 이중 디스패치 엔진이 포함하는 데이터 구조를 언급할 때 앞으로도 맵(map)
이라는 단어를 사용하도록 할 것입니다.
여기에 수정된 BasicDispatcher의 코드를 소개하도록 하겠습니다.
수정된 BasicDispatcher 예제 코드 보기
- template
- <
- class BaseLhs,
- class BaseRhs = BaseLhs,
- typename ResultType = void,
- typename CallbackType = ResultType (*)(BaseLhs&, BaseRhs&)
- >
- class BasicDispatcher
- {
- typedef std::pair<TypeInfo, TypeInfo>
- KeyType;
-
- typedef CallbackType MappedType;
- typedef AssocVector<KeyType, MappedType> MapType;
-
- MapType callbackMap_;
-
- public:
- ...
- };
-
-
-
-
- template <...>
- class BasicDispatcher
- {
- ... 위와 같음 ...
-
- template <class SomeLhs, class SomeRhs>
- void Add(CallbackType fun)
- {
- const KeyType key(typeid(SomeLhs), typeid(SomeRhs));
- callbackMap_[key] = fun;
- }
- };
-
-
-
-
-
-
- typedef BasicDispatcher<Shape> Dispatcher;
-
- void HatchRectanglePoly(Shape& lhs, Shape& rhs)
- {
- Rectangle& rc = dynamic_cast<Rectangle&>(lhs);
- Poly& pl = dynamic_cast<Poly&>(rhs);
-
- ... rc와 pl을 사용하는 코드 ...
- }
- ...
- Dispatcher disp;
- disp.Add<Rectangle, Poly>(HatchRectanglePoly);
-
-
-
-
- template <...>
- class BasicDispatcher
- {
- ... 위와 같음 ...
-
- ResultType Go(BaseLhs& lhs, BaseRhs& rhs)
- {
- MapType::iterator i = callbackMap_.find(
- KeyType(typeid(lhs), typeid(rhs));
-
- if (i == callbackMap_.end())
- {
- throw std::runtime_error("Function not found");
- }
- return (i->second)(lhs, rhs);
- }
- };
수정된 BasicDispatcher 예제 코드 접기
-
BasicDispatcher는 상속의 개념과 함께 사용될 경우 올바르게 동작하지 못합니다. 만일 여러분이
HatchRectanglePoly(Shape& lhs, Shape& rhs)만을 BasicDispatcher에 등록해 주었을
경우, 여러분은 오직 Rectangle과 Poly 자료형에 대해서만 적절한 디스패치 결과를 얻을 수 있을 것입니다.
이 문제를 수정할 수 있는 소수의 방법이 있기는 하지만, 현재까지는 아직 완전한 해결책은 나와있지 않습니다.
여러분은 BasicDispatcher에 모든 종류의 자료형 쌍을 등록할 수 있도록 세심한 주의를 기울여야 할 것입니다.
-
BasicDispatcher는 이제 사용할 만한 도구가 되었지만, 아직 충분히 만족스럽지는 못합니다. 여러분이
Rectangle과 Poly의 겹치는 영역을 다룰 수 있는 함수를 등록했음에도 불구하고, 그 함수는 기반 자료형인
Shape&를 인자로 받아야만 합니다. 사용자 코드가 Shape에 대한 참조 값을 올바른 자료형으로 직접
형변환하도록 한다는 것은 매우 거북한 일일뿐만 아니라, 오류를 유발하기에도 딱 알맞은 일입니다.
다른 한편으로, 콜백 함수를 담고 있는 맵은 각 원소별로 서로 다른 형태의 함수 혹은 함수자를 저장할 수가
없습니다. 따라서 우리는 통일된 형태로 표현된 자료형에 의존해야만 하는 것입니다.
우리는 간단한 콜백 함수(함수자가 아닌)의 문맥에 대해서 이 형변환의 문제에 대한 해법을 구현하고자 합니다.
여기에 도움이 될 수 있는 아이디어는 발돋움 함수(trampoline function, 이 함수는 thunk라고도
알려져 있음)을 사용하자는 것입니다. 여기서 발돋움 함수란 다른 함수를 호출하기 전에 관여하여 간단한
보정 작업을 수행하는 작은 함수를 뜻합니다.
발돋움 함수를 이용하면 적절한 형변환을 수행하고, 올바른 형태의 함수를 호출해 줄 수 있지만, 이제 callbackMap_이
함수 포인터 두 개를 저장해야 한다는 문제가 생깁니다. 이것은 그 수행 속도에
악영향을 미칠 수 있습니다.
하지만 흥미로운 새로운 마법이 우리를 구제해 줄 것입니다. 템플릿은 전역 객체에 대한 포인터(함수 포인터 포함)를
비자료형 템플릿 인자로 받는 것을 허용하고 있습니다. 여기서 필요한 유일한 조건은 그 주소를 템플릿 인자로 사용하는
함수는 외부 모듈로 노출될 수 있는 형태(external linkage)이어야 한다는 점뿐입니다. 여러분은 해당 컴파일
단위에만 속하는 static 함수 선언에서 static 지시어를 떼어내고 그것을 무명 네임스페이스에 포함시킴으로써,
이것을 손쉽게 외부 노출 함수의 형태로 전환시킬 수 있습니다.
함수 포인터를 비자료형 템플릿 인자로 사용한다는 것은, 다시 말하면 이것을 map에 저장시킬 필요가 없다는 사실을
의미합니다. 우리는 이러한 아이디어를 BasicDispatcher를 근간으로 하는 새로운 클래스로 구현해 낼 것입니다.
새로운 클래스 FnDispatcher는 함수에 대한 디스패치만을 담당하도록 조정된 디스패치 엔진입니다. 즉, 함수자에
대해서는 동작하지 않습니다.
FnDispatcher 예제 코드 보기
- template <class BaseLhs, class BaseRhs = BaseLhs,
- typename ResultType = void>
- class FnDispatcher
- {
- BasicDispatcher<BaseLhs, BaseRhs, ResultType> backEnd_;
- ...
-
- public:
- template <class ConcreteLhs, class ConcreteRhs,
- ResultType (*Callback)(ConcreteLhs& ConcreteRhs&)>
- void Add(void)
- {
- struct Local
- {
- static ResultType Trampoline(BaseLhs& lhs, BaseRhs& rhs)
- {
- return Callback(
- dynamic_cast<ConcreteLhs&>(lhs),
- dynamic_cast<ConcreteRhs&>(rhs));
- }
- };
-
- return backEnd_.Add<ConcreteLhs, ConcreteRhs>(&Local::Trampoline);
- }
- };
-
-
-
-
-
-
-
- typedef FnDispatcher<Shape> Dispatcher;
-
- void HatchRectanglePoly(Rectangle& lhs, Poly& rhs)
- {
- ...
- }
-
- Dispatcher disp;
- disp.Add<Rectangle, Poly, HatchRectanglePoly>();
FnDispatcher 예제 코드 접기
-
FnDispatcher의 동적인 성질에 의해, 여기에 대칭성에 대한 지원을 추가하는 작업은 StaticDispatcher에서의 경우에
비해 훨씬 쉽게 구현될 수 있습니다.
우리가 대칭성 지원을 위하여 해야 하는 일은 두 개의 발돋움 함수를 등록해 주는 일 뿐입니다. 하나는 실제 동작 함수를 정상적인
인자의 순서로 호출해 주는 것이며, 다른 하나는 호출하기 전에 그 인자의 순서를 바꾸어 주는 것입니다.
FnDispatcher와 대칭성 예제 코드 보기
- template <class BaseLhs, BaseRhs = BaseLhs,
- typename ResultType = void>
- class FnDispatcher
- {
- ...
-
- template <class ConcreteLhs, class ConcreteRhs,
- ResultType (*callback)(ConcreteLhs&, ConcreteRhs&),
- bool symmetric>
- bool Add(void)
- {
- static Local
- {
- ... 앞에서 선언된 발돋움 함수와 같음 ...
-
- static void TrampolineR(BaseRhs& rhs, BaseLhs& lhs)
- {
- return Trampoline(lhs, rhs);
- }
- };
- Add<ConcreteLhs, ConcreteRhs>(&Local::Trampoline);
-
- if (symmetric)
- {
- Add<ConcreteRhs, ConcreteLhs>(&Local::TrampolineR);
- }
- }
- };
FnDispatcher와 대칭성 예제 코드 접기
보다시피 FnDispatcher의 대칭성은 각 함수 레벨에서 개별적으로 부여될 수 있습니다.
-
때때로 여러분은 자신의 콜백 객체를 단순한 함수 포인터가 아닌 보다 근본적인 객체로 승화시킬 필요가 있을 것입니다.
함수자(5장 참조)는 함수 호출 연산자인 operator()를 오버로드하여 문법적으로 일반 함수의
동작을 모방해 주는 클래스입니다. 추가적으로, 함수자는 자신의 상태를 저장하고, 또 다룰 수 있는 별도의 멤버 변수를
사용할 수도 있습니다. 불행하게도, 발돋움 함수를 이용한 트릭은 함수자에 대해서는 사용할 수 없습니다. 이것은 발돋움
함수가 자신의 상태를 저장할 수 없기 때문입니다.
클라이언트 코드는 적절한 함수자 자료형을 통해 인스턴스화 된 BasicDispatcher를 직접적으로 사용할 수 있습니다.
예제 코드 보기
- struct HatchFunctor
- {
- void operator()(Shape&, Shape&)
- {
- ...
- }
- };
-
- typedef BasicDispatcher<Shape, Shape, void, HatchFunctor>
- HatchingDispatcher;
-
-
예제 코드 접기
이것의 진정한 단점은 클라이언트 코드에서 디스패치 엔진이 할 수 있었던 모종의 자동처리 특성을 잃는다는 것입니다. 즉,
형변환에 대한 처리나 혹은 대칭성의 지원 등이 불가능하게 됩니다.
5장에서는 모든 종류의 함수자 및 함수 포인터, 그리고 다른 Functor 객체들마저도 한 곳에
모을 수 있는 Functor 클래스 템플릿을 정의하였습니다. 여러분은 또한 FunctorImpl 클래스로부터 파생된 자신만의
특화된 Functor 객체도 정의할 수 있습니다.
이제 모든 종류의 Functor 객체에 대해 디스패치 작업을 수행할 수 있는 FunctorDispatcher를 정의해 보도록 합시다.
이 디스패치 엔진은 Functor 객체를 담는 BasicDispatcher 클래스 객체를 멤버로 가지게 됩니다.
FunctorDispatcher 예제 코드 보기
- template <class BaseLhs, class BaseRhs = BaseLhs,
- typename ResultType = void>
- class FunctorDispatcher
- {
- typedef Functor<ResultType,
- TYPELIST_2(BaseLhs&, BaseRhs&)>
- FunctorType;
-
- typedef BasicDispatcher<BaseLhs, BaseRhs, ResultType,
- FunctorType>
- BackEndType;
-
- BackEndType backEnd_;
-
- public:
- template <class SomeLhs, class SomeRhs, class Fun>
- void Add(const Fun& fun)
- {
- typedef FunctorImpl<ResultType, TYPELIST_2(BaseLhs&, BaseRhs&)>
- FunctorImplType;
-
- class Adapter : public FunctorImplType
- {
- Fun fun_;
-
- virtual ResultType operator()(BaseLhs& lhs, BaseRhs& rhs)
- {
- return fun_(
- dynamic_cast<SomeLhs&>(lhs),
- dynamic_cast<SomeRhs&>(rhs));
- }
-
- virtual FunctorImplType* Clone(void) const
- { return new Adapter; }
-
- public:
- Adapter(const Fun& fun) : fun_(fun) {}
- };
-
- backEnd_.Add<SomeLhs, SomeRhs>(
- FunctorType((FunctorImplType*)new Adapter(fun));
- }
- };
-
-
-
-
-
- typedef FunctorDispatcher<Shape> Dispatcher;
-
- struct HatchRectanglePoly
- {
- void operator()(Rectangle& r, Poly& p)
- {
- ...
- }
- };
-
- struct HatchEllipseRectangle
- {
- void operator()(Ellipse& e, Rectangle& r)
- {
- ...
- }
- };
-
- ...
-
- Dispatcher disp;
- disp.Add<Rectangle, Poly>(HatchRectanglePoly());
- disp.Add<Ellipse, Rectangle>(HatchEllipseRectangle());
-
-
FunctorDispatcher 예제 코드 접기
FunctorDispatcher에게 대칭성을 부여해 주는 작업은 FnDispatcher에 대칭성을 구현해 주었던 과정과 거의 흡사합니다.
FunctorDispatcher::Add는 새로운 ReverseAdapter 객체를 정의하여 형변환 및 호출 인자의 순서를 바꿔주는 일을
담당시키도록 할 것입니다.
-
앞에서 살펴본 모든 코드에서 형변환의 동작은 안전한 dynamic_cast를 이용하여 구현되었습니다. 하지만 dynamic_cast의
경우에 그 안정성은 실행 시간의 효율성을 대가로 얻어지는 것입니다.
그런데 여기서 단순한 static_cast가 훨씬 적은 시간에 동일한 목적을 달성할 수 있는 경우가 있습니다. 하지만,
static_cast가 사용될 수 없으며, 오직 dynamic_cast에만 의존해야 하는 대표적인 두 가지 경우가 있습니다.
첫 번째 경우는 여러분이 가상 상속을 사용할 때 발생하게 됩니다.
가상 상속을 사용한 다이아몬드 형의 상속 구조 예제 코드 보기
- class Shape { ... };
-
- class Rectangle : virtual public Shape { ... };
- class RoundedShape : virtual public Shape { ... };
-
- class RoundedRectangle : public Rectangle, public RoundedShape { ... };
가상 상속을 사용한 다이아몬드 형의 상속 구조 예제 코드 접기
우리의 디스패치 엔진에 사용된 dynamic_cast를 static_cast로 대체시킨다면, Shape&를 자신의 파생
클래스(Rectangle&, RoundedShape& 또는 RoundedRectangle&)로 형변환시키려 할 때마다
컴파일 에러를 접하게 될 것입니다. 그 이유는 가상 상속은 보통의 상속과는 매우 다르게 동작하기 때문입니다.
다중 상속의 어떤 구현법에서는 각 파생 클래스 객체들이 자신의 기반 객체에 대한 포인터를 담게 됩니다. 그리고 여러분이
파생 객체로부터 기반 객체로 형변환을 할 때 컴파일러는 그 포인터를 사용하게 됩니다. 하지만 기반 객체는 자신의 파생
객체에 대한 포인터를 가질 수가 없습니다. 이 모든 사실은, 여러분이 파생 객체로부터 가상의 기반 객체로 형변환을 수행한
후 다시 원래의 파생 객체로 돌아올 수 있는 컴파일 시간의 메커니즘이 전혀 존재하지 않는다는 사실을 뜻하게 됩니다.
여러분은 가상 기반 클래스로부터 파생 클래스로의 형변환을 위하여 static_cast를 사용할 수 없습니다.
반면, dynamic_cast는 클래스들 간의 관계를 얻어내기 위한 보다 진보된 수단을 사용하고 있으며, 따라서 가상 기반 클래스가
존재하는 경우에도 문제없이 멋지게 동작할 수 있습니다.
둘째로, 이와 비슷한 클래스 계층 구조를 가졌으나, 가상 상속은 사용하지 않는 경우를 분석해 보도록 합시다.
가상 상속을 사용하지 않은 다이아몬드 형의 상속 구조 예제 코드 보기
- class Shape { ... };
-
- class Rectangle : public Shape { ... };
- class RoundedShape : public Shape { ... };
-
- class RoundedRectangle : public Rectangle, public RoundedShape { ... };
가상 상속을 사용하지 않은 다이아몬드 형의 상속 구조 예제 코드 접기
클래스 계통 구조가 거의 비슷한 모양을 가지고 있음에도 불구하고, 이 경우에 각 객체들의 구조는 전혀 다르게 구성됩니다.
RoundedRectangle은 이제 두 개의 명확히 구별되는 Shape의 하부 객체를 가지게 되었습니다. 이것은 이제
RoundedRectangle에서 Shape로의 형변환이 모호성을 가지게 되었다는 사실을 뜻합니다. 우리가 원하는 것은
RoundedShape의 기반 클래스일까요? 아니면 Rectangle의 기반 클래스일까요? 마찬가지로, 여러분은 Shape&로부터
RoundedRectangle&로의 형변환조차도 static_cast로는 수행할 수 없습니다. 컴파일러로서는 두 하부 객체들 중
어떤 쪽의 Shape 객체를 선택해야 할 지 알수 없기 때문입니다.
반면, 역시 dynamic_cast는 이런 상황에서도 아무런 문제 없이 형변환을 수행할 수 있습니다.
하지만, 추가적인 두 가지 고려 사항이 존재합니다.
-
실질적인 프로그래밍에 있어서 다이아몬드 형 상속 구조를 가지는 클래스 계층 구조는 매우 드물게 존재합니다.
그러한 클래스 계층 구조는 매우 복잡하며, 그로 인해 발생하는 문제점이 장점보다 더 많은 것이 보통입니다.
-
dynamic_cast는 static_cast에 비해 매우 느리게 동작합니다. 반면, 세상에는 단순한 계층 구조를 가지고
보다 빠른 수행 속도를 원하는 고객들이 훨씬 많이 존재합니다.
Loki가 취하고 있는 해법은 형변환과 관련된 선택을 하나의 단위전략(CastingPolicy)에
위임시키는 것입니다(단위전략에 관해서는 1장을 참조하기 바랍니다). 여기서, 이 단위전략은
두 개의 인자를 가지는 클래스 템플릿이 됩니다. 첫 번째 인자는 형변환이 이루어지기 전의 원본 자료형이며, 두 번째 인자는
형변환을 시키고자 하는 목표 자료형입니다. 이 단위전략이 노출하는 유일한 함수는 Cast라 불리는 static 함수가 됩니다.
FnDispatcher와 FunctorDispatcher의 디스패치 엔진은 1장에서 언급된 가이드라인을 따라
CastingPolicy를 사용하게 될 것입니다.
CastingPolicy를 적용한 FunctorDispatcher 예제 코드 보기
- template <class To, class From>
- struct DynamicCaster
- {
- static To& Cast(From& obj)
- {
- return dynamic_cast<To&>(obj);
- }
- };
-
-
- template
- <
- class BaseLhs,
- class BaseRhs = BaseLhs,
- typename ResultType = void,
- template <class, class> class CastingPolicy = DynamicCaster
- >
- class FunctorDispatcher
- {
- ...
-
- template <class SomeLhs, class SomeRhs, class Fun>
- void Add(const Fun& fun)
- {
- typedef FunctorImpl<ResultType, TYPELIST_2(BaseLhs&, BaseRhs&)>
- FunctorImplType;
-
- class Adapter : public FunctorImplType;
- {
- Fun fun_;
-
- virtual ResultType operator()(BaseLhs& lhs, BaseRhs& rhs)
- {
- return fun_(
- CastingPolicy<SomeLhs, BaseLhs>::Cast(lhs),
- CastingPolicy<SomeRhs, BaseRhs>::Cast(rhs));
- }
- ... 앞의 코드와 동일 ...
- };
- backEnd_.Add<SomeLhs, SomeRhs>(
- FunctorType(new Adapter(fun));
- }
- };
CastingPolicy를 적용한 FunctorDispatcher 예제 코드 접기
이 형변환을 위한 단위전략의 기본 값이 DynamicCaster로 되어 있다는 사실에 주의를 기울려 주기 바랍니다.
최종적으로, 여러분은 형변환 단위전략을 이용하여 매우 흥미로운 일을 해낼 수가 있습니다. [그림 11.4]에 나타난 것과
같은 클래스 계층 구조를 한 번 고려해 보도록 합시다.
[그림 11.4] 부분적인 다이아몬드 형 구조를 가지고 있는 클래스 계층 구조 보기
이 계층 구조에는 두 부류의 형변환이 있을 수 있습니다. 어떤 것은 다이아몬드 형태의 구조를 가지지 않기 때문에
static_cast가 안전하게 동작할 것입니다.
그러면 이러한 특별한 계층 구조를 위한 별도의 형변환 단위전략 템플릿(ShapeCaster라는 이름의)을 정의하는 과정을
생각해 보도록 합시다. 여러분은 이것의 기본 동작을 dynamic_cast를 사용하도록 만들고, 특별한 경우에 대해 이 단위전략의
동작을 특화시켜 줄 수 있을 것입니다.
ShapeCaster 예제 코드 보기
- template <class To, class From>
- struct ShapeCaster
- {
- static To& Cast(From& obj)
- {
- return dynamic_cast<To&>(obj);
- }
- };
-
- template <>
- class ShapeCaster<Triangle, Shape>
- {
- static Triangle& Cast(Shape& obj)
- {
- return static_cast<Triangle&>(obj);
- }
- };
-
ShapeCaster 예제 코드 접기
-
우리에게는 절대적인 속도와 확장성이 필요합니다. 그리고 이 경우에 여러분이 치뤄야 할 대가는 여러분의 클래스를 수정해야 하는
일입니다. 여러분은 이제부터 살펴볼 이중 디스패치 엔진이 사용자의 클래스 내에서 어떤 갈고리를 심어서 나중에 이것을 손쉽게
끌어 낼 수 있도록 만들고자 합니다.
이중 디스패치란 무엇인가요? 아시다시피 이차원 공간에 존재하는 핸들러 함수(또는 함수자) 중에서 적절한 하나를 골라내는 것입니다.
여기서 말하는 이차원 공간의 한쪽 축은 왼쪽 인자가 되며, 또 다른 축은 오른쪽 인자가 될 것입니다. 그리고 이 두 축의 자료형들이
교차하는 지점에서 우리가 원하는 핸들러 함수를 찾아낼 수 있는 것입니다.
이러한 이차원 공간 안에서 상수 시간의 탐색을 달성하기 위해서는 이차원 행렬에 대해서 색인(index)으로 접근하는 방법에
의존해야 한다는 사실을 알아내는 데 그리 오랜 시간이 걸리지 않습니다. 이 정수 값은 반드시 각 클래스마다 상수 시간에
접근할 수 있어야 합니다. 여기서 바로 가상 함수가 도움이 될 수 있습니다.
하지만, 이 중의 몇 가지 세부 사항들은 곧바로 얻어내기에 조금 곤란한 특성을 가집니다. 예를 들어, 색인 값들을
관리해 주는 일은 매우 곤란한 일입니다. 각 클래스에 대해서, 여러분은 유일한 정수 ID를 부여해 주어야 하며, 오류
방지를 위해 컴파일 시간에 중복되는 ID를 검출해 낼 수 있어야 할 것입니다. 또한, 정수 ID는 0부터 시작해서 차곡차곡
쌓아져야 하며, 중간에 할당되지 않은 값이 존재해서는 안됩니다. 그렇지 않으면, 행렬 공간을 낭비하는 결과가 발생합니다.
보다 나은 해법은 색인 값의 관리를 디스패치 엔진 자신이 담당하는 것입니다. 각 클래스는 static 정수 멤버 변수를
저장하도록 합니다. 그리고 그 초기 값으로는 "할당되지 않음"을 의미하는 값으로 -1을 부여합니다. 그리고 가상 함수를
이용하여 그 static 변수를 참조할 수 있도록, 그리하여 디스패치 엔진이 실행 시간에 그 값을 수정할 수 있도록
만들어 주어야 합니다.
다음에서 제공되는 간단한 매크로들은 모두 여러분의 클래스 계층 구조상의 각 클래스마다 빠짐 없이 삽입되어야 합니다.
예제 코드 보기
- #define IMPLEMENT_INDEXABLE_CLASS(SomeClass) \
- static int& GetClassIndexStatic(void)\
- {\
- static int index = -1;\
- return index;\
- }\
- virtual int& GetClassIndex(void)\
- {\
- assert(typeid(*this) == typeid(SomeClass));\
- return GetClassIndexStatic();\
- }
-
-
예제 코드 접기
BasicFastDispatcher 클래스 템플릿은 앞서 다루었던 BasicDispatcher와 정확히 동일한 기능을 가지는 함수들을
노출하고 있습니다. 하지만 이것은 전과는 전혀 다른 저장 구조와 검색 메커니즘을 사용합니다.
BasicFastDispatcher 예제 코드 보기
- template
- <
- class BaseLhs,
- class BaseRhs = BaseLhs,
- typename ResultType = void,
- typename CallbackType = ResultType (*)(BaseLhs&, BaseRhs&)
- >
- class BasicFastDispatcher
- {
- typedef std::vector<CallbackType> Row;
- typedef std::vector<Row> Matrix;
-
- Matrix callbacks_;
- int columns_;
-
- public:
- BasicFastDispatcher(void) : columns_(0) {}
-
- template <class SomeLhs, class SomeRhs>
- void Add(CallbackType pFun)
- {
- int& idxLhs = SomeLhs::GetClassIndexStatic();
-
- if (idxLhs < 0)
- {
- callbacks_.push_back(Row());
- idxLhs = callbacks_.size() - 1;
- }
- else if (callbacks_.size() <= idxLhs)
- {
- callbacks_.resize(idxLhs + 1);
- }
-
- Row& thisRow = callbacks_[idxLhs];
- int& idxRhs = SomeRhs::GetClassIndexStatic();
-
- if (idxRhs < 0)
- {
- thisRow.resize(++columns_);
- idxRhs = thisRow.size() - 1;
- }
- else if (thisRow.size() <= idxRhs)
- {
- thisRow.resize(idxRhs + 1);
- }
- thisRow[idxRhs] = pFun;
- }
- };
BasicFastDispatcher 예제 코드 접기
BasicFastDispatcher::Add 함수는 다음과 같은 순서로 동작을 수행하게 됩니다.
-
GetClassIndexStatic 함수를 호출하여 각 클래스의 ID값을 얻어냅니다.
-
만일 하나 이상의 색인 값이 초기화되지 않은 상태라면, 적절한 초기화 및 수정 작업을 수행합니다. 초기화되지 않은
색인 값에 대해, Add는 콜백 행렬 공간을 추가적인 원소를 저장할 수 있도록 확장시키게 됩니다.
-
행렬 상의 정확한 위치에 콜백 함수의 주소를 삽입해 줍니다.
이제 BasicFastDispatcher::Go를 구현하는 일은 매우 쉬워졌습니다. 중요한 차이점은 Go가 가상 함수 GetClassIndex를
사용한다는 것입니다.
BasicFastDispatcher::Go 함수 예제 코드 보기
- template <...>
- class BasicFastDispatcher
- {
- ... 위와 같음 ...
-
- ResultType Go(BaseLhs& lhs, BaseRhs& rhs)
- {
- int& idxLhs = lhs.GetClassIndex();
- int& idxRhs = rhs.GetClassIndex();
-
- if (idxLhs < 0 || idxRhs < 0 ||
- idxLhs >= callbacks_.size() ||
- idxRhs >= callbacks_[idxLhs].size() ||
- callbacks_[idxLhs][idxRhs] == 0)
- {
- ... 오류 처리 루틴 ...
- }
-
- return callbacks_[idxLhs][idxRhs](lhs, rhs);
- }
- };
BasicFastDispatcher::Go 함수 예제 코드 접기
-
수행 속도가 중요시되는 경우라면 BasicFastDispatcher 엔진이 BasicDispatcher보다 선호될 것입니다.
하지만, FnDispatcher나 FunctorDispatcher와 같이 진보된, 그리고 유용한 클래스들은 모두 BasicDispatcher를
기준으로 작성되었습니다. 그렇다면 우리가 BasicFastDispatcher를 기반으로 하는 새로운 두 개의 클래스를 더
개발해 내야 할까요?
보다 나은 아이디어가 있다면, FnDispatcher와 FunctorDispatcher가 주어진 템플릿 인자에 따라
BasicDispatcher 혹은 BasicFastDispatcher에 선택적으로 적용될 수 있도록 만들어 주는 것이 좋을 것입니다.
다음의 코드는 이에 맞도록 수정된 FnDispatcher의 선언문입니다.
수정된 FnDispatcher의 선언문 예제 코드 보기
-
- template
- <
- class BaseLhs,
- class BaseRhs = BaseLhs,
- typename ResultType = void,
- template <class, class>
- class CastingPolicy = DynamicCaster,
- template <class, class, class, class>
- class DispatcherBackend = BasicDispatcher
- >
- class FnDispatcher;
-
수정된 FnDispatcher의 선언문 예제 코드 접기
이제 DispatcherBackend 단위전략의 요구사항을 명확하게 점검해 보도록 합시다. 먼저, DispatcherBackend가
네 개의 템플릿 인자로 구성되어야 한다는 사실은 너무도 명확합니다. 그리고 각 인자의 의미는 차례대로 다음과 같을 것입니다.
-
왼쪽의 인자 자료형
-
오른쪽의 인자 자료형
-
콜백의 반환 자료형
-
콜백 자신의 자료형
[표 11.1]에 BackendType이 갖는 디스패치 엔진의 최종 템플릿의 인스턴스를 나타내 보았으며, 여기에서 backEnd는 그
자료형으로 된 변수를 나타내도록 하였습니다.
[표 11.1] DispatcherBackend 단위전략의 요구 사항 보기
-
우리는 이중 디스패치 엔진과 관련된 우리의 발견을 채택하고, 그것을 진정으로 제네릭한 스타일의 다중 디스패치 엔진에
적용시킬 수 있을 것입니다.
이것은 실제로 그리 어려운 일이 아닙니다. 이번 장에서는 세 가지 형태의 이중 디스패치 엔진을 정의하였습니다.
-
정적(static)인 디스패치 엔진: 두 개의 Typelist에 의해 제어됩니다.
-
맵에 기초한 디스패치 엔진: std::type_info 객체의 쌍을 키 값으로 가지는 맵에 의해 제어됩니다.
-
행렬에 기초한 디스패치 엔진: 유일하게 주어지는 클래스 ID 값에 따라 색인으로 접근되는 행렬에 의해 제어됩니다.
이 디스패치 엔진들을 일반화시키는 일은 다음과 같이 쉽게 구현될 수 있습니다. 여러분은 정적 디스패치 엔진을 두 개의
Typelist에 의해 제어되는 것이 아니라, Typelist의 Typelist로 제어되도록 만들어 줄 수 있습니다.
Typelist도 일종의 자료형이기 때문에, 여러분은 Typelist의 Typelist를 손쉽게 만들어 줄 수 있습니다.
Typelist의 Typelist 예제 코드 보기
-
- typedef TYPELIST_3
- (
- TYPELIST_3(Shape, Rectangle, Ellipse),
- TYPELIST_3(Screen, Printer, Plotter),
- TYPELIST_3(File, Socket, Memory)
- )
- ListOfLists;
-
Typelist의 Typelist 예제 코드 접기
또한, 여러분은 맵에 기초한 디스패치 엔진을 std::type_info에 대한 벡터 자료형이 키 값이 되도록 일반화시켜 줄 수가 있습니다.
그리고 그 벡터의 크기는 물론 해당 디스패치 동작에 관여하는 인자 객체들의 숫자만큼이 될 것입니다.
일반화된 BasicDispatcher 예제 코드 보기
여러분은 행렬에 기반한 디스패치 엔진을 행렬 대신 다차원 배열을 사용하도록 일반화시켜 줄 수 있을 것입니다. 여러분은
다차원 배열을 템플릿의 재귀적 용법을 통해 쉽게 표현할 수 있습니다. 그리고 각 자료형에 대해 ID를 부여해 준다는
설정은 동일하게 유지될 것입니다.
-
멀티 메소드는 일반화된 가상 함수라고 말할 수 있습니다. C++가 제공하는 실행 시간 지원 코드가 하나의 클래스 단위로
가상 함수를 지원하는 것과는 달리, 멀티 메소드는 다수의 클래스에 대해 비슷한 디스패치 기능을 수행해 줍니다. 이것은
하나의 자료형이 아니라 여러 자료형에 대해 동시에 가상 함수를 구현해 줄 수 있는 방법을 제공해 주는 것과 같습니다.
멀티 메소드를 필요로 하는 애플리케이션은 두 개 이상의 객체에 따라 다르게 적용되는 알고리즘을 호출해야 하는 프로그램이라
할 수 있습니다. 다형성 객체들 간의 충돌이나, 교집합, 그리고 디스플레이 장치의 종류에 따라 객체를 표시해 주는 등의 경우가
멀티 메소드가 유용하게 쓰일 수 있는 전형적인 예입니다.
우리가 논의한 디스패치 엔진의 유형에는 다음과 같은 것들이 있습니다.
-
Brute-Force 디스패치 엔진: 이것은 정적인 자료형 정보에 의존하여
동작하며, 정확한 자료형을 찾아내기 위하여 선형 탐색을 수행합니다. 해당 자료형이 일단 발견되고 나면, 디스패치
엔진은 핸들러 객체에 존재하는 적절한 오버로드 멤버 함수를 호출하게 될 것입니다.
-
맵에 기반한 디스패치 엔진: 이것은 std::type_info 객체들의 조합을
키로 가지는 맵 자료 구조를 사용합니다. 그 키를 통해 찾게 되는 값은 콜백 객체입니다. 자료형을 찾아내는
과정에서는 이진 탐색 알고리즘을 사용합니다.
-
상수 시간에 동작하는 디스패치 엔진: 이것은 가장 빠른 디스패치 엔진입니다.
하지만 여러분이 여기에 쓰이는 클래스들을 목적에 맞도록 수정해 줄 것을 요구합니다. 필요한 수정 사항은 미리 준비된
매크로를 이 디스패치 엔진에 사용하고자 하는 각 클래스마다 추가해 주는 일입니다. 이 디스패치 과정에서 소요되는
부하는 두 개의 가상 호출과 몇 개의 수치 검사, 그리고 행렬 원소에 대한 색인 접근뿐입니다.
위에 언급된 마지막 두 디스패치 엔진에 기초하여 보다 고수준의 장치가 다음과 같이 구현될 수 있습니다.
-
자동화된 형변환(Automated conversion): (자동 형변환,
automatic conversion과 혼동하지 마시기 바랍니다) 그 일관성으로 인해, 위에 언급된 디스패치 엔진들은
클라이언트 코드가 직접 그들의 기반 자료형으로부터 실제 파생 자료형으로의 형변환을 수행하도록 요구하고 있습니다.
그러나 여기에 형변환의 계층을 추가하여 이러한 형변환을 담당해 주기 위한 발돋움 함수(trampoline
function)를 제공하도록 만들어 줄 수가 있습니다.
-
대칭성(Symmetry): 어떤 이중 디스패치 엔진은 대칭적인 속성을 가질 수
있습니다. 대칭성이란 이중 디스패치 엔진의 양쪽 인자가 동일한 기반 자료형에 근거한 것일 경우에, 각 인자의
순서와는 상관없이 같은 결과를 수행해야 하는 성질입니다. 라이브러리 내에서 이러한 대칭적 특성에 대한 지원 코드를
포함하게 된다면, 클라이언트 코드는 훨씬 작고, 에러 없는 코드가 될 것입니다.
Brute-Force 디스패치 엔진은 이러한 고수준의 특성을 자신이 직접적으로 제공합니다. 이것은 Brute-Force 디스패치 엔진이
확장성 있는 사용 가능한 자료형 정보를 가지고 있기 때문에 가능합니다. 다른 두 디스패치 엔진은 자동화된 형변환과 대칭성을
구현해주기 위하여 서로 다른 방법을 사용하며 추가적인 계층 구조를 사용하고 있습니다. 함수자에 대한 이중 디스패치 엔진에 비해서,
함수에 대한 이중 디스패치 엔진은 이 추가 계층을 조금 다르게 구현해 주고 있습니다.
[표 11.2]에서는 이번 장에서 다룬 세 가지 디스패치 엔진을 일목요연하게 비교해 주고 있습니다.
[표 11.2] 다양한 이중 디스패치 엔진의 비교 보기
-
-
Loki는 다음고 같은 세 가지 이중 디스패치 엔진을 정의하고 있습니다.
StaticDispatcher, BasicDispatcher 그리고 BasicFastDispatcher
-
StaticDispatcher의 선언문은 다음과 같습니다.
StaticDispatcher의 선언 예제 코드 보기
- template
- <
- class Executor,
- class BaseLhs,
- class TypesLhs,
- class BaseRhs = BaseLhs,
- class TypesRhs = TypesLhs,
- typename ResultType = void
- >
- class StaticDispatcher;
-
-
-
-
-
-
StaticDispatcher의 선언 예제 코드 접기
-
Executor는 오류 처리를 위하여 반드시 OnError(BaseLhs&, BaseRhs&) 함수를 정의해야 합니다.
StaticDispatcher는 자신이 알 수 없는 자료형을 만나게 되면 이 Executor::OnError 함수를 호출하게 됩니다.
-
예제(Rectangle과 Ellipse가 Shape를 상속받고 있으며, Printer와 Screen이 OutputDevice를
상속받고 있다고 가정):
예제 코드 보기
- struct Painter
- {
- bool Fire(Rectangle&, Printer&);
- bool Fire(Ellipse&, Printer&);
- bool Fire(Rectangle&, Screen&);
- bool Fire(Ellipse&, Screen&);
-
- bool OnError(Shape&, OutputDevice&);
- };
-
- typedef StaticDispatcher
- <
- Painter,
- Shape,
- TYPELIST_2(Rectangle, Ellipse),
- OutputDevice,
- TYPELIST_2(Printer, Screen),
- bool
- >
- Dispatcher
예제 코드 접기
-
StaticDispatcher는 BaseLhs&와 BaseRhs&, 그리고 Executor&을 받아서 디스패치
동작을 수행하는 Go 멤버 함수를 구현하고 있습니다. 예제(앞의 정의를 그대로 사용):
예제 코드 보기
- Dispatcher disp;
-
- Shape* pSh = ...;
- OutputDevice* pDev = ...;
-
- bool result = disp.Go(*pSh, *pDev);
예제 코드 접기
-
BasicDispatcher와 BasicFastDispatcher는 사용자가 실행 시간에 핸들러 함수를 추가해 줄 수 있는
동적인 디스패치 엔진을 구현하고 있습니다.
-
BasicDispatcher는 로그 시간 안에 해당하는 핸들러를 찾아낼 수 있습니다. 그리고 BasicFastDispatcher는
상수 시간 안에 필요한 핸들러를 찾아낼 수 있지만, 대신 사용자가 디스패치 될 모든 클래스마다 그 정의에 필요한
수정을 가할 것을 요구합니다.
-
이 두 클래스들은 동일한 인터페이스를 구현하고 있습니다. 여기서는 우선 BasicDispatcher의 인터페이스만을
소개합니다.
BasicDispatcher의 선언 예제 코드 보기
- template
- <
- class BaseLhs,
- class BaseRhs = BaseLhs,
- typename ResultType = void,
- typename CallbackType = ResultType (*)(BaseLhs&, BaseRhs&)
- >
- class BasicDispatcher;
-
-
-
BasicDispatcher의 선언 예제 코드 접기
-
이 두 디스패치 엔진은 [표 11.1]에서 언급된 바와 같은 함수들을 구현해 주고 있습니다.
-
기본적인 세 개의 디스패치 엔진과 더불어, Loki는 또한 두 가지 진보된 계층을 정의해 주고 있습니다.
FnDispatcher와 FunctorDispatcher가 바로 그것입니다. 이것은 자신의 단위전략으로 BasicDispatcher와
BasicFastDispatcher 둘 중 하나를 사용하게 됩니다.
-
FnDispatcher와 FunctorDispatcher는 다음과 같이 거의 유사한 선언문을 가지고 있습니다.
FnDispatcher의 선언 예제 코드 보기
- template
- <
- class BaseLhs,
- class BaseRhs = BaseLhs,
- typename ResultType = void,
- template <class To, class From>
- class CastingPolicy = DynamicCast,
- template <class, class, class, class>
- class DispatcherBackend = BasicDispatcher
- >
- class FnDispatcher;
-
-
-
-
FnDispatcher의 선언 예제 코드 접기
-
FnDispatcher와 FunctorDispatcher는 모두 Add 멤버 함수를 제공하거나 또는 자신들의 기본 핸들러
자료형을 제공합니다. FnDispatcher의 경우에 기본 핸들러 자료형은 ResultType (*)(BaseLhs&,
BaseRhs&)입니다. FunctorDispatcher의 경우에 기본 핸들러 자료형은 Functor<ResultType,
TYPELIST_2(BaseLhs&, BaseRhs&)> 입니다. Functor에 대한 설명은
5장을 참고하시기 바랍니다.
-
더불어, FnDispatcher는 디스패치 엔진에 각 callback 함수들을 등록시켜 주기 위한 템플릿 함수를 제공하고
있습니다.
FnDispatcher::Add 멤버 함수 예제 코드 보기
-
만일 앞의 코드에서와 같이 Add 멤버 함수를 통해 핸들러를 등록하게 되면, 자동화된 형변환과 선택적인 대칭성의
이점을 누릴 수 있게 됩니다.
-
FunctorDispatcher는 다음과 같은 Add 템플릿 멤버 함수를 제공합니다.
FunctorDispatcher::Add 멤버 함수 예제 코드 보기
-
F의 자리에는 또 다른 Functor 인스턴스를 포함하여 함수자 객체(5장 참조)가 수용 가능한
어떤 자료형이라도 올 수가 있습니다. 자료형 F를 가지는 객체는 반드시 BaseLhs&및 BaseRhs&를 인자로
가지고, ResultType으로 형변환 가능한 자료형을 반환하는 함수 호출 연산자를 적용시킬 수 있어야 합니다.
-
만일 적절한 핸들러가 발견되지 않으면, 여기서 언급된 모든 디스패치 엔진은 std::runtime_error라는 예외를
발생시킬 것입니다.
-
사용자들의 요구사항을 만족시킬수록, 다중 스레드 기법은 프로그래밍하기가 매우 어려워지기 마련이며, 그것을 디버그하는 일은 심지어
그보다 더욱 어려워집니다. 게다가, 다중 스레드는 애플리케이션 디자인까지 영향을 미치게 됩니다. 다중 스레드 환경에서도 안전하게
동작하도록 라이브러리를 만들어 주는 일은, 라이브러리 코드 외부에서 처리해 줄 수 있는 일이 아닙니다. 그것은 라이브러리 자신은
스레드를 별도로 사용하지 않는다 하더라도, 반드시 라이브러리 코드 내에서 고려되어야 하는 문제입니다.
최근의 애플리케이션들이 다중 스레드의 동작을 사용하는 비율이 점점 증가하고 있기 때문에, 프로그래머 자신의 귀찮음 때문에
다중 스레드 문제를 간과하고 넘어간다는 것은 참으로 안타까운 일이라 할 수 있을 것입니다. 이 부록은 C++를 통해 포팅 가능한
객체지향형 다중 스레드 어플리케이션을 작성하기 위해 안전하고 튼튼한 기반 코드를 구성할 수 있는 도구 및 기법을 제공하는 것을
목적으로 합니다.
-
다중 스레딩이 단일 프로세서 환경에서도 중요하게 여겨지는 이유는 "효율적인 자원의
활용"이라는 측면 때문입니다. 최근의 전형적인 컴퓨터에서는 프로세서 수에 비해 훨씬 더 많은 종류의 자원이
존재합니다. 이것들은 모두 물리적으로 독립된 장치들이기 때문에, 이 자원들은 제각기 동시에 동작할 수가 있습니다.
하지만, 여러분의 애플리케이션과 운영체제가 프로세스 독점적인 단일 스레드 모델로 동작하고 있다면, 프로세서는 그러한
자원(장치들)을 다루는 동작을 하는 동안 다른 아무 일도 수행할 수 없는 상황에 처하게 됩니다.
이와 비슷한 특징으로, 프로세서가 사용되지 않는 시간을 잘 분석해 보면, 여기에는 더 다양한 예가 존재할 수 있습니다.
만일 여러분이 3D 이미지를 편집하는 경우를 생각해 본다면, 마우스를 움직이거나 클릭하는 사이의 짧은 간격의 시간들마저도
프로세서 입장에서는 영원과도 같은 긴 시간이 될 수도 있는 것입니다.
다중 스레딩을 대체할 수 있는 대표적인 방법으로 비동기 동작(asynchronous execution)
기법이 있을 수 있습니다. 이 비동기 동작은 콜백 모델을 통해 구성됩니다. 다중 스레드 기법에 비교할 때, 이 방법의 주된
단점은 상태 변수가 지나치게 난무하는 프로그램이 된다는 것입니다. 비동기적 동작 기법을 사용하게 되면, 어떤 상황에서
다른 상황으로 이어지는 단일한 알고리즘을 따라가는 것이 불가능해 집니다. 대신, 여러분은 필요한 상태를 저장하고, 또 각
콜백 함수들이 그 상태를 바꾸어주는 방식으로 프로그램을 구성할 수밖에 없게 됩니다. 이러한 상태 변수들을 관리해 주는 일은
아주 단순한 작업이 아닌 이상 다양한 문제를 발생시키는 원인이 됩니다.
하지만 다중 스레드에서는 이러한 문제가 발생하지 않습니다. 각 스레드는 자신의 실행 줄기가 가지는 암시적인 상태를 가지고
있습니다. 여러분은 스레드가 마치 하나의 단순한 함수인 것처럼 쉽게 그 스레드의 동작을 따라갈 수 있습니다. 결론적으로,
다중 스레드 프로그램은 동기적 수행 모델을 그대로 준수할 수 있다는 것이며, 이것은 매우 바람직한 특성이라는 것입니다.
다른 한편으로는, 스레드는 그들이 메모리 데이터와 같은 자원을 공유하고자 할 때에 그 즉시 커다란 문제점을 드러내게 됩니다.
스레드는 원칙적으로 그 어떤 순간에라도 다른 스레드에 의해 중단될 수가 있기 때문에, 지금까지 여러분이 단위 작업이라고
생각해 왔던 부분들도, 다중 스레드 환경에서는 더 이상 단위 작업으로 간주할 수가 없을 것입니다.
단일 스레드 프로그래밍의 경우에서는 하나의 함수를 단위 동작으로 생각하는 것이 자연스러운 반면, 다중 스레드 프로그래밍에서는
실제로 어떤 동작이 완전히 단위 동작이라 불릴 수 있는지를 명확히 해 주어야만 합니다. 결론적으로, 다중 스레드 프로그램은
자원을 공유하는 데 커다란 문제점을 가지게 됩니다. 이것은 물론 매우 좋지 않은 특성이지요.
다중 스레딩에 관련된 대부분의 프로그래밍 기법들은 동기화 객체를 제공하는 데 초점을 맞추고 있습니다.
동기화 객체란 공유 자원에 대한 일련화된 접근을 가능하게 만들어 주는 객체를 뜻합니다.
여러분이 단위 동작이 되어야 하는 코드를 수행할 때마다, 이 동기화 객체를 통해 잠금 장치를 걸어주어야 합니다. 만일 다른
스레드가 동일한 동기화 객체를 잠그고자 한다면, 그 스레드는 일시적으로 중단되게 될 것입니다. 그러면 여러분은 원하는 데이터를
수정하고 그러고 나서 그 잠금 장치를 해제해 주게 됩니다. 그러면, 그 순간에, 다른 스레드가 그 동기화 객체에 잠금 장치를
걸게 되며, 이에 따라 그 데이터에 대한 접근 권한을 가지게 될 것입니다.
-
스레딩과 관련된 문제를 다루기 위해서 Loki는 ThreadingModel 단위전략을 정의하고
있습니다. ThreadingModel은 하나의 인자를 가지는 템플릿으로 표현됩니다. 이 인자는 해당 스레딩 모델에서 접근하게 되는
C++ 자료형입니다.
template <typename T>
class SomeThreadingModel
{
...
};
다음 절에서부터 여기에 필요한 각 개념들과 기능들을 가지고 점차적으로 이 ThreadingModel 템플릿을 채워나갈 것입니다.
또한 Loki는 단일 스레딩 모델에 대한 내용도 정의해 주고 있으며 이것은 대부분의 Loki 코드에 있어서 기본 선택 값이 됩니다.
-
X가 int 자료형의 변수라고 가정하고 다음의 구문을 생각해 보도록 합시다.
++x;
x 값을 증가시키기 위하여 프로세서는 세 가지 연산을 수행해야 합니다.
-
메모리로부터 해당 변수를 가져옵니다.
-
수치 연산기(ALU, Arithmetic Logic Unit)를 통해 해당 값을 증가시킵니다. ALU는 연산이 이루어질 수 있는
유일한 장소입니다. 메모리는 그 자체로서 수치 연산을 수행할 수가 없습니다.
-
계산된 값을 다시 메모리에 저장합니다.
이러한 연산의 삼총사를 일컬어 RMW(Read-Modify-Write) 연산이라고 부릅니다.
이제 이 증가 연산이 다중 프로세서 아키텍쳐에서 일어나는 경우를 가정해 보도록 합시다. 효율을 극대화시키기 위하여, RMW 연산 중
두 번째 수정 작업 동안에 프로세서는 메모리 버스의 잠금 장치를 해제합니다.
그러나 불행하게도, 또 다른 프로세서가 동일한 정수 값에 대하여 RMW 연산을 시작할 수도 있습니다. 예를 들어 x에 대한 두 개의
증가 연산이 있고, 이 x의 초기 값은 0이며, 두 연산이 각각 P1, P2 두 개의 프로세서에서 다음과 같은 순서로 일어나게 된다고
가정해 봅시다.
-
P1이 메모리 버스를 잠그고 x를 가져옵니다.
-
P1이 메모리 버스를 해제합니다.
-
P2가 메모리 버스를 잠그고 x를 가져옵니다(x의 값은 여전히 0입니다). 동시에, P1이 ALU 내부에서 x의 값을
증가시킵니다. 물론, 그 결과는 1이 됩니다.
-
P2가 메모리 버스를 해제합니다.
-
P1이 메모리 버스를 잠그고 x에 대한 계산 결과인 1을 저장합니다. 동시에, P2가 ALU 내부에서 x의 값을
증가시킵니다. P2가 얻어 온 x의 값은 0이었기 때문에 이 결과는 다시 1이 됩니다.
-
P1이 메모리 버스를 해제합니다.
-
P2가 메모리 버스를 잠그고 다시 x에 1을 저장합니다.
-
P2가 메모리 버스를 해제합니다.
결과적으로 두 개의 증가 연산이 발생했음에도 불구하고 x의 값은 0에서 시작하여 최종적으로 1로 끝나게 됩니다. 이것은 분명
잘못된 결과입니다.
하지만 증가 연산을 단위 동작으로 만들 수 있는 여러 가지 방법이 존재합니다. 가장 효율적인 방법은 프로세서의 능력을 빌리는
것입니다. 어떤 프로세서들은 버스에 잠금 장치를 거는 연산을 제공합니다. 그러면 RMW 연산은 앞에서 설명한 바와 같이
일어나지만, 메모리 버스는 그 연산 전체를 통해 잠겨진 상태로 유지될 것입니다.
이와 같은 저 수준의 기능은 보통 운영체제에 의해 단위 동작의 증가와 단위 동작의 감소 연산을 제공하는 C 함수로 잘 포장되기
마련입니다.
만일 운영체제가 단위 연산을 정의하고 있다면, 그것은 보통 메모리 버스와 같은 폭과 같은 크기를 가지는 정수 자료형(대부분
int)에 대한 것이기 마련입니다. Loki의 스레딩 시스템(Threads.h 파일에 정의된)은 각 ThreadingModel 구현 코드
내에서 이와 같은 IntType이란 자료형을 정의하고 있습니다.
IntType 예제 코드 보기
- template <typename T>
- class SomeThreadingModel
- {
- public:
- typedef int IntType;
-
- static IntType AtomicAdd(volatile IntType& lval, IntType val);
- static IntType AtomicSubtract(volatile IntType& lval, IntType val);
-
- ... AtomicMultiply, AtomicDivide, AtomicIncrement, AtomicDecrement 등에 대한 유사한 정의 코드 ...
-
- static void AtomicAssign(volatile IntType& lval, IntType val);
- static void AtomicAssign(IntType& lval, volatile IntType& val);
- };
-
-
-
-
-
-
-
- volatile int counter;
- ...
- SomeThreadingModel<Widget>::AtomicAdd(counter, 5);
- if (counter == 10) ...
-
-
-
-
-
-
- if (AtomicAdd(counter, 5) == 10) ...
-
-
-
IntType 예제 코드 접기
-
뮤텍스(Mutex)는 스레드가 올바른 방식으로 공유 자원에 접근할 수 있도록 만들어 주는 가장 기본적인 동기화 객체입니다.
Loki의 나머지 부분에서 뮤텍스가 직접적으로 사용되는 경우는 없습니다. 대신, Loki는 뮤텍스를 이용하여 쉽게 구현할
수 있는 보다 고수준의 동기화 수단을 정의할 것입니다.
뮤텍스의 가장 기본적인 함수는 Acquire와 Release 입니다. 공유 자원에 대해 배타적 접근이 필요한 각 스레드는
뮤텍스에 대해 Acquire 함수를 호출해 주어야 합니다. 그러면 그 스레드가 Release를 호출하여 잠금을 풀어주기 전까지는
같은 뮤텍스에 대해 Acquire를 호출하려는 스레드는 대기 상태로 잠시 멈춰지게 됩니다.
코드 상에서 mtx.Acquire() 및 mtx.Release()를 호출하는 사이에 존재하는 모든 부분은 해당 mtx 객체에 대해
단위 동작의 특성을 지니게 됩니다.
이것은 여러분이 스레드 간에 공유하길 원하는 각 자원에 대해 저마다 하나씩의 뮤텍스 객체를 할당해 주어야 한다는 사실을
의미하게 됩니다.
예를 들어, 여러분이 BankAccount라는 클래스를 가지고 있으며, 이것이 Deposit 및 Withdraw와 같은 멤버 함수를
제공하고 있다고 가정해 봅시다. 이 연산들은 단순히 double 멤버 변수에 증가 연산을 하거나 감소 연산을 하는 작업 외에,
해당 거래에 따른 추가적인 정보를 기록하는 작업도 해야 할 것입니다. 만일 BankAccount가 다수의 스레드에 의해
사용되는 객체라면, 이 연산은 분명 단위 동작으로 구성되어야 할 것입니다.
BankAccount 예제 코드 보기
- class BankAccount
- {
- public:
- void Deposit(double amount, const char* user)
- {
- mtx_.Acquire();
-
- ... 예금 거래에 필요한 동작을 수행 ...
-
- mtx_.Release();
- }
-
- void Withdraw(double amount, const char* user)
- {
- mtx_.Acquire();
-
- ... 출금 거래에 필요한 동작을 수행 ...
-
- mtx_.Release();
- }
-
- private:
- Mutex mtx_;
- ...
- };
BankAccount 예제 코드 접기
각 Acquire 함수마다 Release 함수를 호출해야 하는 사실을 잊게 된다면, 치명적인 파급 효과에 당면하게 될 것입니다.
그렇게 되면 여러분은 그 뮤텍스에 대한 소유권을 가지고서 놓아주지 않게 되는 것이며, 이것은 그 소유권을 원하는 다른
스레드들이 영원히 중단된 채로 놓여진다는 결과를 의미하게 됩니다.
이식성의 이유로 인해, Loki는 자기 자신의 뮤텍스 객체를 직접 가지고 있지는 않습니다. 여러분이 이미 사용하고 있는
다중 스레딩 라이브러리가 아마도 자신의 뮤텍스 객체를 가지고 있을 것입니다. Loki는 기존의 뮤텍스를 사용하여 보다
고수준의 잠금 장치를 제공하게 됩니다.
-
동기화 객체는 공유 자원과 연결되기 마련입니다. 그리고 객체지향 프로그램에서 자원이란 곧, 객체를 말합니다. 따라서,
객체지향 프로그램에서 동기화 객체는 필연적으로 애플리케이션 객체와 연결되어야 합니다. 이것은 각 공유 객체들이
동기화 객체를 포함해야 하며, 값을 변화시키는 모든 멤버 함수에 대해서 적절하게 잠금 장치를 걸어 주어야 한다는 사실을
의미하게 됩니다. 이러한 구조는 객체 단위의 동기화(object-level locking)라고
불리는 방식으로써, 각 객체마다 하나씩의 동기화 객체를 가지도록 구성됩니다.
그러나 어떤 경우에는 모든 객체마다 하나씩의 뮤텍스를 저장하는 것이 지나친 오버헤드가 될 수도 있습니다. 이러한 경우에는,
클래스마다 하나씩의 뮤텍스를 저장하는 전략이 도움이 될 수가 있습니다. 이러한 전략을 일컬어
클래스 단위의 동기화(class-level locking)라고 부릅니다.
Loki는 ThreadingModel 단위전략으로 두 개의 구현 코드를 정의하고 있습니다. 그 하나는 ClassLevelLockable이며,
다른 하나는 ObjectLevelLockable입니다. 이들은 각각 객체 단위의 동기화 및 클래스 단위의 동기화의 개념을 잘
캡슐화해 주고 있습니다.
ClassLevelLockable과 ObjectLevelLockable 예제 코드 보기
- template <typename Host>
- class ClassLevelLockable
- {
- public:
- class Lock
- {
- public:
- Lock(void);
- Lock(Host& obj);
- ...
- };
- ...
- };
-
- template <typename Host>
- class ObjectLevelLockable
- {
- public:
- class Lock
- {
- public:
- Lock(Host& obj);
- ...
- };
- ...
- };
ClassLevelLockable과 ObjectLevelLockable 예제 코드 접기
기술적으로, Lock 함수는 뮤텍스를 잠긴 상태로 유지시켜 주게 됩니다. 두 구현 코드의 차이점은 객체 단위의 동기화에 대해서
여러분이 T 객체를 넘겨주지 않을 경우, ObjectLevelLockable<T>::Lock을 사용할 수 없다는 것입니다.
그 이유는 ObjectLevelLockable이 말 그대로 객체 단위의 동기화를 사용하기 때문입니다.
Lock을 사용하는 클래스는 Lock 객체의 생명주기가 끝나는 순간까지 해당 객체(혹은 클래스)에 잠금 장치를 걸어 놓게 됩니다.
애플리케이션에서, 여러분은 이 두 ThreadingModel 중 한쪽을 선택하여 상속받을 수 있습니다. 그러면, 여러분은 그 내부
클래스인 Lock을 직접적으로 사용할 수 있게 됩니다.
ThreadingModel의 사용 예제 코드 보기
정확한 동기화 전략은 여러분이 ThreadingModel 구현 클래스 중 어떤 것을 선택하여 상속받느냐에 따라 달라집니다.
[표 A.1]에 각 구현 클래스의 특징을 요약 정리해 보았습니다.
[표 A.1] ThreadingModel 단위전략의 구현 크래스들 보기
여러분은 이제 다음 예제에서 보시는 바와 같이 동기화된 멤버 함수를 매우 쉽게 작성할 수 있게 되었습니다.
ThreadingModel을 사용하는 BankAccount 예제 코드 보기
- class BankAccount : public ObjectLevelLockable<BankAccount>
- {
- public:
- void Deposit(double amount, const char* user)
- {
- Lock lock(*this);
-
- ... 예금 거래에 필요한 동작을 수행 ...
- }
-
- void Withdraw(double amount, const char* user)
- {
- Lock lock(*this);
-
- ... 출금 거래에 필요한 동작을 수행 ...
- }
- ...
- };
-
-
ThreadingModel을 사용하는 BankAccount 예제 코드 접기
허깨비 객체인 SingleThreaded에 의해 얻어지는 통일된 형태의 인터페이스는 문법적인 일관성을 보장해 주게 됩니다.
여러분은 자신의 코드를 다중 스레딩 환경을 고려하여 작성할 수 있으며, 단지 스레딩 모델을 바꿔주는 것만으로 이 디자인
결정 사항을 손쉽게 변경할 수 있는 것입니다.
이 ThreadingModel 단위전략은 4장(작은 객체에 대한 메모리 할당) 및
5장(일반화 함수자), 그리고 6장(싱글톤의 구현)에서 이미 사용된 바가 있습니다.
-
C++는 volatile이라는 한정자를 제공하여 여러 스레드에 의해 공유되는 객체에 대해 이것을 사용하도록 규정하고 있습니다.
하지만, 단일 스레드 모델에서는 이 volatile 한정자를 사용하지 않는 편이 좋습니다. 왜냐하면, volatile 한정자를
사용하면 컴파일러가 몇 가지 중요한 최적화 과정을 생략하기 때문입니다.
이것이 바로 Loki가 내부 클래스로 VolatileType을 정의하고 있는 이유입니다. SomeThreadingModel<Widget>
내부에서 VolatileType을 이용하면 ClassLevelLockable 및 ObjectLevelLockable을 위해 volatile
Widget 자료형을 얻어낼 수 있으며, SingleThreaded를 위해서는 보통의 Widget 자료형을 얻어낼 수가 있습니다.
여러분은 6장을 통해 VolatileType의 사용법을 익힐 수가 있습니다.
-
다중 스레딩에 대한 Loki의 지원은 여기까지가 전부입니다. 일반적인 다중 스레딩 라이브러리들은 훨씬 더 풍부한 조합의
동기화 객체 및 함수들을 제공합니다. 또한, Loki는 분명 새로운 스레드를 기동시키는 함수가 누락되어 있습니다.
사실을 말하자면, Loki는 스레드 안정성을 추구하기는 하지만, 그 스스로 스레드를 사용하지는 않습니다.
훌륭하고, 이식성이 높은 다중 스레딩 라이브러리에 대해서는 ACE(Adaptive Communication Environment)를
검토해 보는 게 더 좋을 것이라 생각합니다(Schmidit 2000).
-
다중 스레드 프로그램에 있어서 동기화의 문제는 애플리케이션은 물론 라이브러리까지 디자인에까지 영향을 미치게 됩니다.
여기서의 문제점은, 다양한 종류의 각 운영체제가 지원하는 스레딩 모델이 서로 상당히 다르다는 것입니다. 따라서, Loki는
고수준의 동기화 메커니즘만을 정의하여, 외부에서 제공되는 스레딩 모델과는 최소한의 교집합만을 유지하도록 작성되어 있습니다.
ThreadingModel 단위전략과 그것을 구현하는 세 개의 클래스 템플릿들은 서로 다른 스레딩 모델을 지원할 수 있는
제네릭한 스타일의 컴포넌트를 구성하는 하나의 플랫폼을 정의하게 됩니다. 컴파일 시간에 여러분은 객체 단위의 동기화를
선택할지, 혹은 클래스 단위의 동기화를 선택할지, 아니면 동기화 기능을 사용하지 않을 지의 여부를 선택할 수가 있습니다.
객체 단위의 동기화 전략을 사용하면 모든 애플리케이션 객체마다 하나씩의 동기화 객체를 할당하게 됩니다. 클래스 단위의
동기화 전략을 사용하면, 각 클래스의 종류마다 하나씩의 동기화 객체를 할당하게 됩니다. 전자의 전략이 보다 빠른 수행
속도를 보이며, 후자의 전략은 메모리 리소스를 보다 덜 차지합니다.
ThreadingModel의 구현 클래스들은 모두 동일한 문법의 인터페이스를 제공합니다. 이것은 라이브러리 및 클라이언트 코드가
보다 수월하게 일관적인 문법을 사용하도록 만들어 줍니다. 여러분은 내부 구현 코드를 바꾸는 일 없이 어떤 클래스의 다중 스레드
지원 여부를 수정해 줄 수 있습니다. 이와 같은 목적을 위해, Loki는 단일 스레드 모델을 지원해 주기 위하여 아무 일도 하지
않는 허깨비 구현 클래스를 함께 정의해 주고 있습니다.