C++/템플릿(Template)

C++ 템플릿(Template): CRTP

kim선달 2020. 10. 28. 23:10
Select Language

 

 

항상 느끼는 것 이지만, 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) 에 대해 알아보겠습니다