항상 느끼는 것 이지만, C 나 C++ 개발자들은 정말 성능 변태가 분명합니다.
특히 템플릿을 배우기 시작하는 분들은 느끼시겠지만, 정말 성능 하나만을 위해 변태같은 짓을 하는것을 서슴치 않는 사람들이 바로 C++ 개발자들 입니다.
이번에 소개해드릴 템플릿 패턴은 CRTP 입니다.
CRTP 는 동적 다형성(polymorphism), 즉 기반 클래스 포인터에 파생 클래스 객체의 포인터를 대입해 사용하는 것을 피하면서 가상(virtual) 함수의 override 를 흉내내는 템플릿 기법입니다.
CRTP 는 템플릿에 의한 정적 다형성 이므로, 가상함수의 호출에 드는 오버헤드 비용을 없엘 수 있습니다.
CRTP 의 약자는 Curiously Recursing Template Pattern 으로, 굳이 의역한다면 기묘하게 재귀하는 템플릿 패턴 정도가 되겠네요.
주로 표현식 템플릿(Expression Template) 에서 지연된 계산(Delayed Evaluation)을 사용할 때 사용한다고 합니다.
구현 원리는 파생 클래스는 자기자신의 타입을 템플릿 인자로 갖는 기반 클래스를 상속하고,
그 기반 클래스는 자신의 (원래 동적 다형성에서 가상메서드로 선언할)메서드가 템플릿 인자의 동일한 함수를 호출하(고 반환하)는 형식입니다.
말로 하니까 어려우니 예시를 한 번 보겠습니다.
class Animal {
public:
Animal() = default;
virtual void bark() const = 0;
};
class Dog : public Animal {
public:
Dog() = default;
void bark() const override {
std::cout << "Bow" << std::endl;
}
};
Dynamic Polymorphism 을 이용해 정석적으로 Animal 인터페이스를 정의 해 주고, 파생 클래스인 Dog 클래스에서 가상 메서드를 override 해 준 모습입니다.
생성 및 bark() 함수 호출은 아시다시피 아래 처럼 이루어 집니다.
std::unique_ptr<Animal> animal = std::make_unique<Dog>();
animal->bark();
물론, 이러한 방식은 실제로도 많이 이용되고, 다른 언어에서도 다형성은 모두 제공하는 기능입니다(Python은 상속과 상관 없이 이름만 같으면 다형성을 흉내 낼 수 있다고 알 고 있습니다)
하지만, 이러한 가상함수를 이용한 동적 다형성은 치명적인 단점을 안고 있습니다.
어떠한 객체가 실제로 어떤 타입인지가 프로그램 런타임에 결정되기 때문에, override 된 메서드를 호출하면 실제로 그 메서드가 어떤 객체의 가상함수인지 상속 트리를 타고 올라가면서 하나 하나 찾게 됩니다.
상용으로 개발되는 프로그램들은 많으면 수 십 개의 상속 트리를 가지고 있으니, 만약 어떤 가상함수가 매우 자주 불리는 함수라면 이는 프로그램 성능에 영향을 미칠 수 있게 됩니다.
골수 C++ 개발자들은 이러한 상황에 분노했고, 성능 최적화를 위한 방법을 찾아 내고야 말았습니다.
실제로 이 패턴에 CRTP 라고 명명한 사람이 다른 사람들의 코드를 보던 와중에 매우 자주 발견해서, 이름을 짓게 되었다고 하는군요
예제를 한번 보겠습니다.
template<typename T>
class TAnimal {
public:
TAnimal() = default;
void bark() const {
return static_cast<const T&>(*this).bark();
}
};
class TDog : public TAnimal<TDog> {
public:
TDog() = default;
void bark() const {
std::cout << "Bow" << std::endl;
}
};
구별을 위해 클래스 이름의 앞에 T를 붙였습니다.
먼저 TAnimal 클래스를 살펴보겠습니다.
TAnimal 클래스가 인터페이스로 사용할 클래스이고, bark() 메서드가 마찬가지로 dynamic polymorphism 이였으면 가상 함수로 선언하였을 메서드 입니다.
TAnimal::bark() 함수는 자기 자신을 const T&로 캐스트 한 후, T의 bark() 를 호출하고 그 값을 반환하고 있습니다.
(C++ 에서 void 함수가 void 함수의 반환 값을 반환하는것은 적법한 코드입니다. 사실 여기선 굳이 반환 안 해도 됩니다)
bark() 가 상수(const) 메서드 이므로 타입 캐스팅도 const T& 로 캐스팅 되고 있습니다.
여기서 가상함수와의 차이점이 보입니다.
실제로 컴파일하면 어떻게 될지는 모르겠지만(똑똑한 컴파일러라면 바로 파생클래스의 메서드를 호출하게 바꿔버릴 수 도 있습니다), 로직 상으로는 기반 클래스의 bark() 함수를 한번 거친 후에, 파생 클래스의 bark()를 호출하고 있는 모습입니다.
대충 감이 오셨나요?
파생 클래스인 TDog클래스는 TAnimal<Tdog>을 상속 하고 있네요.
TDog::bark() 함수는 이 것 자체만 보면 그냥 메서드일 뿐입니다.
그럼 이 메서드가 어떻게 (정적) 다형성에 이용되는 것 일까요?
아래 예제를 보시죠
TDog dog;
dog.bark(); // 이건 당연히 TDog::bark() 를 호출하게 됩니다.
동적 다형성이 아니므로, TDog 객체를 그냥 바로 생성합니다.
여기서 dog.bark() 를 호출하면, 이건 뭐 다형성도 아닌 그냥 객체의 메서드를 호출한 것에 불과할 뿐입니다.
이를 제대로 사용하려면, 다음과 같이 템플릿 함수에 넘겨줬을때 그 진가가 드러납니다.
template<typename T>
void make_bark(const TAnimal<T>& animal){
animal.bark();
}
TDog dog;
make_bark(dog);
어떤가요?
make_bark 함수는 기반 클래스인 TAnimal 타입을 인자로 받고 있습니다.
이 함수에 TDog 객체를 넘겨 주면, 기반 클래스 포인터로 받아지고 템플릿 타입 유추(Template type deduction)에 의해, T가 TDog 타입으로 정해지게 됩니다.
그러면 TAnimal 의 설계에 의해, animal.bark() 메서드가 호출되고, 그 메서드는 TDog::bark() 메서드를 호출하게 되는 것이죠.
사실, 딱히 엄청난 개념이 숨어있는것은 아닙니다.
동적 다형성처럼 눈에 보이지 않는(소스코드 상에서 드러나지 않는) 가상함수의 동작 구조인 vtable 을 이용한 것이 아닌 소스 코드 상에 그 원리가 바로 드러나기 때문에, 이해하고 받아들이는것은 오히려 더 쉽지 않나요?
아닌가요? ㅎㅎ..
다음에는 이 기법이 자주 쓰이는 템플릿 기법인 표현식 템플릿(Expression Template) 에 대해 알아보겠습니다
'C++ > 템플릿(Template)' 카테고리의 다른 글
C++ 템플릿(Template): SFINAE (0) | 2020.11.01 |
---|---|
C++ 템플릿(Template): 꼬리표 분배(Tag Dispatching) 기법 (0) | 2020.10.26 |