[c++] CRTP를 이용한 다단계 정적 상속으로 코드 최적화하기

Posted by 적분 ∫2tdt=t²+c
2019. 3. 7. 20:41 프로그래밍/테크닉

상속은 객체지향 프로그래밍의 꽃이라고 할 수 있습니다. 상속을 통해 공통되는 코드를 통합하고, 다형성을 확보하는 등 다양한 작업이 가능하지요. C++에서는 일반적으로 클래스와 가상 함수, 상속이라는 문법적 장치를 통해 이러한 개념들이 구현됩니다. 어떤 Data에 대한 처리를 수행하는 클래스 ModelA이 있다고 생각해봅시다.


ModelA는 두 종류의 가상 함수를 가집니다. loadData()는 어딘가로부터 데이터를 읽어와 myData에 채워넣는 일을 하고, work()는 채워진 myData를 가지고 어떠한 작업을 수행하는 일을 합니다. loadAndWork()를 호출하면 loadData()와 work()가 호출되는 식이죠. loadData()와 work()는 가상함수이기 때문에 상속받은 클래스에서 이 녀석들을 override할 수 있습니다.

그래서 만약 Data에 대해 조금 다른 처리를 해야하는 경우가 있다고 하면 ModelA2와 같이 상속을 통해서 ModelA의 기능을 확장할 수 있습니다.

ModelA2는 ModelA가 될 수 있기 때문에 ModelA이든 ModelA2이든 ModelA*로 추상화하여 ModelA::loadAndWork()를 호출할 수 있습니다. ModelA냐 ModelA2냐에 따라 내부 동작은 달라질 수 있겠지만, 그것까지 복잡하게 고민할 필요가 전혀 없습니다. 상속을 사용하여 아주 훌륭한 설계를 했다고 할 수 있습니다.

그런데 문제가 조금 복잡해지기 시작합니다. ModelA가 Data를 가지고 작업을 잘 수행하고 있는데, 기존의 Data에다가 몇개의 추가 필드를 필요로하는 ModelB를 만들어야 합니다.

ModelB는 ModelA와 대부분 하는 작업이 동일하고 그 중 몇 가지 기능만 확장된 거라서 대부분의 코드를 재사용하고 싶습니다. 그런데 ModelA::myDatastd::vector<Data>이기 때문에 DataB를 담을 수 없습니다. 그래서 전체 클래스를 일일히 새로 작성했습니다. 설계가 뭔가 잘못된것 같습니다. 어떻게 하면 ModelA를 재사용하여 ModelB를 만드는데에 쓸 수 있을까요? 여러 가지 선택지가 있겠죠.

  1. 그냥 Datafloat boo를 추가하고, ModelBModelA를 상속받는다
  2. Datavirtual 소멸자를 추가하고, ModelA::myDatastd::vector<Data*>로 바꾸어 myDataData Data의 하위 타입을 담을 수 있게 한다.
  3. ModelAModelB에 공통적으로 사용되는 코드들을 ModelBase 추상 클래스로 따로 뽑아내고 ModelA ModelB가 이를 상속받도록 한다.

1번의 경우, 추가적으로 들어가야할 것들이 더 생길 때마다 Data 구조체를 수정해야합니다. 그에 따라 전체 클래스의 내용도 다 바뀌게 됩니다. 지금은 추가된 데이터가 float boo 하나지만, 이런게 더 늘어날수록, ModelA의 경우 사용하지도 않을 데이터들을 myData에 다 담고 있어야합니다. 올바른 선택지는 아닌듯합니다.


2번의 경우 Data를 POD에서 가상함수를 사용하는 class로 바꾸어버립니다. myData가 Data의 하위타입들을 저장할 수 있게 되어 매우 유연한 구조를 띄게 됩니다. 성능을 제외한다면 완벽한 선택지가 될겁니다. 성능을 제외한다면 말이죠... 큰 문제는 Data가 더이상 POD 타입이 아니게 되고, myData는 Data*를 들고 있게 되어 성능 상의 손실이 크다는 것입니다. Data*를 생성할 때마다 메모리할당이 일어나게 되고, Data* 내의 필드에 접근하기 위해서 간접 참조가 발생하죠. 또한 ModelB에서는 Data*를 사용할 때마다 DataB*인지 검사하여 캐스팅하는 작업이 필요합니다.


3번의 경우 ModelA와 ModelB가 일부 다를 경우 이를 묶어내기가 까다롭다는 문제가 있습니다. ModelA::loadData()는 Data타입인 벡터에 대해 로딩 작업을 수행할 것이고, ModelB::loadData()는 DataB 타입인 벡터에 대해 로딩 작업을 수행할 것입니다. 그렇기 때문에 두 작업의 공통 부분을 묶어내기 위해서는 std::vector<Data>에 대한 접근과 std::vector<DataB>에 대한 접근을 추상화해야 합니다. 지금은 2가지라서 문제 없지만 DataC, DataD가 더 생긴다면 쉽지 않은 작업이 되겠죠..?


높은 성능을 추구하면 구조가 지저분해지고, 깔끔한 구조를 찾으면 성능을 포기하게 되는 딜레마에 봉착하게 됐습니다. (사실 객체 지향 프로그래밍이라는 것이 본디 성능을 약간 포기하여 유지보수가 편한 코드를 짜기 위한것이긴 합니다.) 다행히도 C++에는 template이라는 강력한 메타 프로그래밍 문법이 있어서 성능과 구조 두 마리 토끼를 모두 잡는 것이 가능합니다. (그대신 코딩 난이도와 컴파일 시간은 놓쳐버리게 되지만요)

CRTP(Curiously Recurring Template Pattern; 기묘하게 재귀하는 템플릿 패턴)이라 불리는 c++ 테크닉이 있습니다. 이는 다음과 같이 클래스 상속과 템플릿을 꼬아서 사용하는 방법을 가리키는 표현입니다.


class Bar : public Foo<Bar> 가 이 기법의 핵심입니다. 굉장히 기묘해보이지만, 사실 class Bar 부분에서 이미 Bar가 선언되었으므로 뒤의 Foo<Bar>에서 Bar를 파라미터로 사용하는게 합법입니다. 따라서 Foo<Bar>::call()에서는 자연히 Bar::impl()을 호출하게 되고, 이를 통해 다형성을 성취할 수 있게 됩니다. 이 기법을 사용할 경우 virtual 함수가 없어지므로 모든 함수 호출은 정적으로 바인딩되어, 간접 참조가 줄어들고, 또한 컴파일 타임에 어떤 함수가 불릴지 알 수 있으므로 추가적인 컴파일 타임 최적화가 가능해집니다. 짜잔!


이 기법을 활용해서 위의 문제를 해결해보도록 하겠습니다.


이렇게 해서 ModelA2가 ModelA를 상속받게 할 수 있습니다. 그런데 그냥 ModelA를 쓰고 싶을때는 어떻게 해야할까요..?? ModelA<??, Data>라고 써야할텐데, 상속받은게 아니라 자기 자신을 그대로 쓰는거라서 ??에 무엇을 넣어야할지 모르겠습니다. 이를 해결하기 위해 다음과 같이 코드를 고쳐봅시다.


여기서는 using 구문이 포인트가 됩니다. _Derived가 void이면 자기 자신의 멤버 함수들을 호출하고, void가 아니면 상속하는 클래스의 멤버 함수를 호출하도록 컴파일 타임에 분기합니다. 참고로 using 구문은 c++11에서 지원하는, 타입 이름에 별칭을 붙여주는 구문입니다. typedef이 업그레이드 버전이라고 생각하시면 될것 같습니다.

std::conditional<A, B, C>의 경우 A가 참이면 B 타입, 거짓이면 C타입을 돌려주는 템플릿 세계의 삼항 연산자(?:) 같은 녀석입니다. std::is_same<A, B>는 그 이름 그대로 A, B가 같은 타입이면 참, 다른 타입이면 거짓을 돌려주는 템플릿 세계의 비교 연산자이구요. 즉 일반 연산자를 이용해서 표현하자면 Derived = (_Derived == void) ? ModelA : _Derived; 와 같은 의미의 템플릿 구문입니다.

마지막으로 std::condtional 앞에 typename을 붙인것은, 이것이 템플릿 파라미터에 종속되어 있는 scope의 타입(type in dependent scope)이기 때문에, 뒤에 오는 것이 type이라는것을 알려주기 위해 붙인 것입니다. 컴파일러는 코드 해석 중 이름(identifier)들을 만나면 이게 타입인지 변수명인지를 구분하려고 합니다. 그래야 int a;와 같은 구문이 변수 선언 구문인지를 알 수 있으니까요. 그런데 템플릿 클래스 scope 안에 속한 이름의 경우 템플릿을 해석하기 전까지는 이게 타입을 가리키는 이름인지 변수를 가리키는 이름인지 알 수가 없습니다. 일단 코드 전체를 해석해야 템플릿을 해석할 수 있는데, 코드 해석 단계에서 해당 이름이 타입 이름인지 모르므로 코드 해석 자체가 불가능해집니다. 이런 경우를 막기 위해서, 템플릿에 종속되어 있는 scope의 타입은 반드시 그 앞에 typename을 붙여 이것이 타입을 가리킨다는 것을 미리 명시하도록 되어있습니다. 설명은 여기까지 하고 ModelB도 같은 방법으로 구현해봅시다.



그런데, 한 가지 문제가 또 있습니다. ModelB를 상속받는 ModelB2를 만들고 싶은데, ModelB는 템플릿 클래스가 아니라 일반 클래스라 CRTP 패턴을 사용할 수가 없습니다. 이를 해결하기 위해선 어떻게 ModelB를 고쳐야할까요?

정답은 위와 같이 ModelB에도 템플릿 파라미터를 붙이는 것입니다. 그러면 이를 이용해 ModelB2도 정적 상속이 가능합니다. ModelB2의 상속 계보도를 보면, 먼저 ModelB2는 ModelB<ModelB2>를 상속받습니다. 그리고 ModelB<ModelB2>는 ModelA<ModelB2, DataB>를 상속받지요. 따라서 

ModelA<ModelB2, DataB> → ModelB<ModelB2> → ModelB2

로 핏줄이 이어집니다. 여기에 추가적으로 상속이 더 필요하다면, ModelB2도 템플릿 클래스화하면 됩니다.


마지막 문제가 하나 남았는데, 이렇게 상속받은 각각의 클래스들은 실제로는 공통의 조상이 하나도 없습니다. 따져보면

  • ModelA<> : 최종 조상 ModelA<void, Data>
  • ModelA2 : 최종 조상 ModelA<ModelA2, Data>
  • ModelB<> : 최종 조상 ModelA<ModelB<>, DataB>
  • ModelB2 : 최종 조상 ModelA<ModelB2, DataB>

와 같이 각자의 조상들이 다릅니다. 따라서 이들을 하나의 포인터 타입 ModelA*에 담을 수 없다는 문제가 있지요. 정적 상속을 통해 정적인 다형성을 확보한 대신 동적인 다형성을 잃어버린 것이죠. 만약 동적인 다형성까지 필요로 한다면, 여기에 공통적인 인터페이스(Interface)를 추가해주는 방법을 사용할 수 있습니다.


이렇게 Model의 공통 함수들을 묶어낼 IModel 인터페이스를 정의한 후 최종 조상인 ModelA 템플릿 클래스가 IModel을 구현하도록 코드를 수정하면, ModelA, ModelA2, ModelB, ModelB2가 모두 공통 조상인 IModel을 가지게 됩니다. 따라서 IModel*로 모든 종류의 Model을 다룰 수 있게 됩니다.


이렇게 CRTP와 템플릿 상속을 이용하면 기존의 class 상속만으로는 불가능했던 코드 재사용 및 최적화가 가능해집니다. (Java 같은 언어에는 없는, 대단히 강려크한 기능..! 이 정도는 되어야 코드 좀 재사용해봤다고 할 수 있는거죠.) 물론 이 방법도 단점이 있습니다. 크게 정리하면 다음과 같은 단점들이 있겠네요.

  1. 부모 클래스가 템플릿 클래스여야만 하므로, 모든 부모 클래스 구현이 헤더 파일에 들어가야한다. 즉, 구현체 전체가 다 헤더로 공개될 수 밖에 없다.
  2. 템플릿이 복잡하게 얽혀있을 경우 오류가 발생하면 이를 잡기가 까다롭다. (유지 보수 난이도 상승)
  3. 각 클래스에 따른 실행 코드가 따로 생성되므로 실행 파일의 크기가 커질 수 있다.

따라서 결론은 "적재적소에 적절하게 사용하면 좋다"가 되겠습니다.

이 댓글을 비밀 댓글로
    • prismatic
    • 2019.08.07 14:27
    오랜만에 발도장 찍고 갑니다. 잘 계신지? ㅎㅎ
    • 정말 오랜만이네요! 저는 무탈히 잘 살고 있습니다. 잘 지내고 계십니까~
      • prismatic
      • 2019.08.07 17:04
      회사가 어렵고 미래가 어둡다는 것 빼고는 뭐 ㅎㅎ 그보다는 프로그래밍을 놓은 지가 꽤 돼서 부끄럽네요 ㅎㅎ
      그래도 적분횽 블로그에서 CRPT를 오랜만에 봐서 너무 좋았음 집에 가면 공부해야지 ㅠㅜ
    • 잘되실겁니다! 화이팅이에요!!