본문 바로가기

3.구현/C or C++

C++ Delegate 구현원리

Delegate란 생소한 분도 있을 것이다. 나도 처음에는 정확한 개념이 잡히지는 않았다. 즉, 사용할 줄은 알지만, 어떻게 동작하는지, 어떤 개념이지 알지 못했다. 그리고, 이전에 Delegate 비슷한 것을 구현한 적이 있는데, 지금 보면 상당히 위험한 구조였음을 알게되었다. 이 글은 Delegate에 대해서 더 자세히 알고 싶은 분을 위한 글이다.

그럼, Delegate 안으로 들어가보자.

작성: http://ospace.tistory.com/ (ospace114@empal.com), 2011.01.17

Delegate란

Delegate는 위임이라는 의미가 있다. C++에서 Delegate은 전역함수, 일반함수, 멤버함수, 함수자(functor)를 Delegate에 작업을 위임함으로서 일관된 관점을 통해 처리하도록 도와주는 인터페이스라고 할 수 있다. 다르게 이야기하면 구현되는 함수나 객체들은 Delegate로 묶어버리면, 이를 관리하는 곳에서는 Delegate만 알면된다는 의미이다. C#에서도 Delegate라는게 언어 자체에서 지원하고 있다. 이를 활용하면 상당히 유연한 구성을 할 수 있게 된다.

이 글에서는 완벽한 Delegate 라이브러리를 제공하는 것이 아니라, 기존 Delegate에 구현원리에 대해서 집중한다. 특히, 멤버함수 호출에 대해서만 다룰 것이다. 전역함수나, 함수자 같은 것은 특별히 다루지는 않겠다. 이를 지원하는 Delegate 인터페이스 구성도 다루지 않을 것이다. Delegate의 성능도 다루지 않을 것이다. 인자가 여러 개인 경우도 고려하지 않았다. 멀티케스트도 다루지 않는다. 테스트환경은 VC++이고, 다른 컴파일러에서 확인해보지 못했다. 하지 않은게 너무 많은 것 같다. ㅡ.ㅡ;

다음 내용으로 들어가기 전에 왜 멤버함수만 다루는지 이해가 필요할 것 같다. 다름 함수 형태는 C++의 overloading을 이용하면 같은 메소드로 여러 종류의 함수를 Delegate에 연결할 수 있다. 즉, 정적함수, 일반함수, 함수자는 호출하는데 특별한 무리가 없고, 단순 링크정보를 저장했다가 호출하면 된다. 예를 들어, 일반함수인 경우 함수 포인터를 저장해두었다가 그냥 호출하면 된다. 특별한 기술이 필요한 것은 아니다. 함수 포인터 크기도 고정이다. 그러나, 멤버함수는 틀리다. 일반 함수 포인터처럼 포인터를 저장해두었다가 호출하면 되겠다고 생각할지는 모르지만, 오류가 발생할 가능성이 있다. 일반 함수 포인터는 대부분 4byte(x86)으로 고정되어 있다. 어떤 경우에도 변하지 않는다. 그러나 멤버함수 포인터는 상황에 따라서 크기가 4, 8, 12, 16 byte로 가변적이다. 물론 컴파일러에 따라서 차이는 발생하는 것도 큰 문제점이다. 즉, 멤버함수 포인터를 저장할 공간을 가장 큰 16byte로 할당하는 것도, 나중에 메모리 관리 문제가 발생한다. 대부분 4byte가 많을 것인데 16byte에 저장한다면 필요없는 12byte가 소모된다. 그렇다고 4byte로 저장한다고 하면 언잰가 12, 16byte가 발생하면 프로그램은 죽어버릴 것이다. 치명적인 문제점이다. 이 경우 원인도 제대로 파악하기 힘들것이다.

멤버함수 크기가 궁금하다면 "C++멤버함수 포인터 크기 확인"이라는 글을 보시기 바란다.[6] 이 멤버함수 포인터를 정복하는 자만이 Delegate을 정복할 수 있다.

예제 클래스

#pragma once

#include <iostream>
#include <string>

class Hello
{
public:
    Hello() : msg("Hello") {}

    void execute(int n)
    {
        for(int i=0; i<n; ++i) {
            std::cout << msg;
        }
        std::cout << std::endl;
    }
private:
    std::string msg;
};

class World
{
public:
    World() : msg("World") {}

    void execute(int n)
    {
        for(int i=0; i<n; ++i) {
            std::cout << msg;
        }
        std::cout << std::endl;
    }
private:
    std::string msg;
};

class VirtualHello : virtual Hello
{
public:   
    void execute(int n) {
        std::cout << "VirtualHello" << std::endl;
    }
};

class HelloWorld : public Hello, public World
{
public:
    HelloWorld() {}

    void execute(int n)
    {
        Hello::execute(n);
        World::execute(n);
    }
};

class UnknownGreeting;
typedef void (UnknownGreeting::*UnknownExecute) (int n);

Delegate 원리 구분

여기서는 boost의 Function은 고려하지 못했다. 나중에 좀더 공부해서 추가해야겠다. ㅡ.ㅡ; 여기서 고려한 Delegate의 구현은 Don, Sergey, Jae의 Delegate이다.

Delegate 원리라고 해서 종류가 많은 것은 아니다. 멤버함수를 다루는 방식에 따라서 2가지 정도로 구분할 수 있다.

  • 동적형태: 멤버함수 포인터가 인자로 동적으로 넘겨져서 저장되어 있다가 호출됨.[1][3]
  • 정적형태: 템플린 인자로 멤버함수가 생성할 때부터 고정되어 호출됨.[4]

동적형태는 Don과 Jae의 Delegate이다. 정적형태는 Sergey의 Delegate이다. Jae의 Delegate은 Sergey의 변형이라고 할 수 있다. 나온 순서를 보면 Don, Sergey, Jae순 이다. 동적형태는 런타임에 임의 멤버함수를 연결할 수 있고, 제거할 수 있다. 정적형태는 멤버함수 입력 받을 때에 컴파일 타임에 호출할 멤버함수가 템플릿 인자로 입력된다. 그렇기 때문에 다른 멤버함수를 받아들이려면 이미 고정적으로 입력되어 있어야 한다. 동적형태의 좋은 점은 멤버함수가 포인터로 저장되기 때문에 비교 연산이 가능하다. 정적형태는 템플린 인자로 고정되기 때문에 비교연산이 불가능하다는 부분이다.

나온 순서는 Don을 먼저 다루고 Sergey, Jae순으로 하겠다. Don을 먼저 다룬 이유가 멤버함수가 왜 다루기 어려운지, 멤버함수에 대한 이해를 조금 하기 위해서이다.

자세한 부분을 모두 여기서 다루기에는 내용이 많고 어려워서 직접 Don의 문서나 나른 사이트에서 확인하기 바란다.

동적형태1

실행되는 중간에 멤버함수 포인터가 동적으로 변경이 가능하다. 다시 말하면, 컴파일타임에 없던 멤버함수라도 연동이 가능하다는 말이다. Don의 구현원리가 바로 이 형태이다. 특히 Don의 특별한 점은 수행속도를 높이기 위해서, 가상함수나 다중 인스탄스의 멤버함수, 가상 클래스의 멤버함수의 간접 호출 방식을 직접 호출 방식으로 변경했다는 부분이다. 이렇게 함으로서 Delegate에서 멤버함수 호출 부분이 어셈블러 2줄로 해결할 수 있게된다. 물론 컴파일러의 환경에 따라 달라질 수도 있다. 그러나 최적화가 잘되었다면, C++의 수십, 수백줄이 단 2줄로 요약된다는 것이 놀랍다. 그러나 멤버함수의 간접호출을 직접호출로 변경하기 위한 작업이 특정 컴파일러에 종속적이라는 부분이 치명적이다.

간접호출을 하는 이유는 C++의 상속 구조상 멤버함수를 호출하게 되면 특이한 this 객체가 호출된 멤버함수가 속한 인스탄스의 포인터를 가리킨다. 부모와 자식 클래스에 따라서 this 포인터 위치가 달라지게 된다. 바로 이 작업을 간접호출 할 때에 중간에서 다시 계산해주는 작업을 한다. 정리하면, 간접호출에 의해 this 포인터 위치 보정이 발생한다는 의미이다. 멤버함수 포인터에 이런 보정위한 정보를 저장해서 크기가 커지는 것이다. 말로해서는 표현하기 힘들다. 좀더 자세한 부분은 참조 [7]이 "가상함수 구조"를 살펴보기 바란다. 어느정도 이해하는데 도움이될 것이다.

Don은 이런 this 포인터 계산과 함수 몸체가 있는 포인터를 직접 계산해서 추출했다. 그리고, 컴파일러마다 모두 확인하여 컴파일러에 맞는 처리를 하였다. 그리고 horrible cast이라고 강제로 포인터 캐스팅을 통해서 포인터간 변환을 하였다. 말그대로 강제로 포인터를 캐스팅 했기 때문에 어떤 문제점이 발생할지 모른다. 이렇게 해서 원하는 포인터를 강제로 얻어서 처리하기 때문에 Don은 horrible hack이라고 하였다. 물론 함수포인터와 this 포인터도 마찬가지이지만...

여기서는 don의 delegate의 구현을 최대한 간략화하였다. 정말 많이 줄였다. 나의 노력에 박수를.. ㅠ.ㅠ

물론 don의 대부분 구현이 인자 개수가 여러 개인 경우를 고려한 코드가 반 이상이다. ㅡ.ㅡ;

horrible_cast

먼저 자주사용하게 될 horrible_cast를 보도록 하겠다.

template <class Output, class Input>
inline Output horrible_cast(const Input input) {
    union { Output output; Input input; }u;

    typedef int ERROR_CantHorrible_cast[sizeof(Input)==sizeof(u)&&
        sizeof(Input)==sizeof(Output)?1:-1];

    u.input = input;
    return u.output;
}

Don의 코드를 약간 변경했다. 정말 강제로 값을 변경해서 반환한다. 단, 변환되는 두 개의 자료형의 크기는 반드시 일치하게 했다. Output이 더 큰 자료형이라도 에러가 발생한다. 이는 컴파일 타임에서 확인할 수 있게 하였다.

재미있는 것은 크기 확인을 assert가 아닌 typedef를 이용해서 확인하고 있다는 부분이다. 재미는 부분이다.

GenericClass와 SimpleMemFunc

객체 인스탄스 처리와 멤버함수 추출할 때에 사용할 단순 클래스와 함수이다.

class GenericClass {};
const int SINGLE_MEMFUNCPTR_SIZE = sizeof(void (GenericClass::*)())

struct MicrosoftMFP {
    void (GenericClass::*codeptr)();
    int delta;
    int vtable_index;
};

template <int N>
struct SimpleMemFunc {
    template <class X, class XFuncType, class GenericMemFuncType>
    inline static GenericClass* Convert(X *pthis, XFuncType function_to_bind,
            GenericMemFuncType &bound_func) {
                typedef char ERROR_Unspported_member_function_pointer[N-100];
    }
};

모든 인스탄스와 멤버함수는 GenericClass에서 처리되고 저장된다. 함수 포인터로 따지면 void* 같은 격이랄까?

물론 더 많은 작업이 행해지고 있다. 그리고, 단순 클래스의 멤버함수 크기도 SINGLE_MEMFUNCPTR_SIZE에 저장하고 있다. 대부분(x86)은 4byte가 될 것이다.

MicrosoftMFP는 MS사의 member function pointer의 구조이다. codeptr이라는 멤버 함수 포인터가 보인다. 모든 멤버 함수 포인터는 인자가 없는 codeptr로 캐스팅되서 저장된다. 물론 나중에 호출 시점에 다시 인자가 있는 멤버함수 포인터로 캐스팅되서 호출된다. delta는 pthis에서 변경될 값이고 vtable_index는 가상 테이블의 인텍스 값이다.

SimpleMemFunc는 말은 simple이지만, 실제 수행될 코드는 절대 simple하지 않다. 나도 정확한 계산법을 알지 못하겠다.

일단 Convert 함수에 의해서 pthis은 function_to_bind와 연산하여 적절한 Generic Class 포인터로 변환이 되고(나중에 멤버함수 호출할 때 사용할 인스탄스, this가 된다) function_to_bind에서 멤버함수 포인터를 추출해서 bound_func에 저장된다. 컴파일별 멤버함수 구현 구조는 Don의 글을 참조하기 바란다. 내용이 어렵고 정확히 이해하지 못했기에 정리하지 않았다.[3] 특히 unknown인 경우의 계산법을 이해하기 어렵다. 이 때문에 정리하지 못했다.

위의 코드는 무조건 에러가 발생한다. 나중에 템블릿의 부분특화에 의해서 우리가 사용할 멤버함수를 구분해서 Convert 함수를 수행한다.

Single instance member function

단일 인스탄스의 멤버 함수를 살펴보자. 일반 함수처럼 멤버 함수 포인터만 가지고 있다.

// 단순 함수 포인터로 그냥 캐스팅하면 됨
template <> struct SimpleMemFunc {
    template  inline static GenericClass* Convert ( X *pthis, XFuncType function_to_bind, GenericMemFuncType &bound_func ) {
        bound_func = horrible_cast ( function_to_bind );
        return reinterpret_cast ( pthis );
    }
}; 

단일 인스탄스이기 때문에 멤버함수 크기는 4 byte이다. 그래서 SINGLE_MEMFUNCPTR_SIZE와 일치한다.

그냥 pthis는 GenericClass 포인터로 function_to_bind는 GenericMemFuncType으로 캐스팅해서 반환하고 저장하면 끝이다. 별다른 작업이 필요없다.

Multiple instance member function

이번에는 다중 인스탄스에서 멤버함수 계산이다.

template <>
struct SimpleMemFunc<SINGLE_MEMFUNCPTR_SIZE + sizeof(int)> {
    template <class X, class XFuncType, class GenericMemFuncType>
    inline static GenericClass* Convert(X *pthis, XFuncType function_to_bind,
        GenericMemFuncType &bound_func) {
            union {
                XFuncType func;
                struct {
                    GenericMemFuncType func_ptr;
                    int                delta;
                } s;
            } u;

            typedef int ERROR_CantUsehorrible_cast[sizeof(function_to_bind)==sizeof(u.s)?1:-1];
            u.func = function_to_bind;
            bound_func = u.s.func_ptr;
            return reinterpret_cast<GenericClass*>(reinterpret_cast<char*>(pthis) + u.s.delta);
    }
};

function_to_bind에서 GenericClass와 GenericMemFuncType을 계산하기 위한 정보를 획득한다. 멤버함수 포인터에서 끝 부분의 int 영역에 delta정보가 저장되어 있다. 이를 가지고 pthis 계산에 사용하고 있다.

Virtual instance member function

이번에는 가상으로 클래스를 상속할 경우이다.

struct GenericVirualClass : virtual public GenericClass
{
    typedef GenericVirualClass* (GenericVirualClass::*ProbePtrType)();
    GenericVirualClass* GetThis() { return this; }
};

template <>
struct SimpleMemFunc<SINGLE_MEMFUNCPTR_SIZE + 2*sizeof(int)> {
    template <class X, class XFuncType, class GenericMemFuncType>
    inline static GenericClass* Convert(X *pthis, XFuncType function_to_bind,
        GenericMemFuncType &bound_func) {
            union {
                XFuncType func;
                GenericClass *(X::*ProbeFunc)();
                MicrosoftMFP s;
            } u;
            u.func = function_to_bind;
            bound_func = reinterpret_cast<GenericMemFuncType>(u.s.codeptr);

            union {
                GenericVirualClass::ProbePtrType vfunc;
                MicrosoftMFP s;
            } u2;

            typedef int ERROR_CantUsehorrible_cast[sizeof(function_to_bind)==sizeof(u.s)&&
                sizeof(function_to_bind)==sizeof(u.ProbeFunc)&&
                sizeof(u2.vfunc)==sizeof(u2.s)?1:-1];

            u2.vfunc = &GenericVirualClass::GetThis;
            u.s.codeptr = u2.s.codeptr;

            return (pthis->*u.ProbeFunc)();
    }
};

조금 복잡해 보이지만 간단하다. 먼저 bound_func을 얻는 것은 쉽다. 그리고 변경된 this 포인터를 얻는게 재미 있다. 실제 virtual class인 GenricVirtualClass를 정의해서 바로 this 포인터를 획득하고 있다.

  1. GenericVirtualClass 에서 this 포인터 없는 GetThis 멤버함수 포인터 획득하고 u2.vfunc에 저장한다.
  2. u2에서 GetThis 멤버함수의 실제 멤버함수 구현된 포인터 위치를 얻어서 앞의 function_to_bind의 codeptr에 저장한다. 이는 this 포인터를 반환하는 GetThis멤버함수의 실제 구현부만 가져온 것이다. 껍때기는 빼버리고 알맹이만 가져온 것이라 할 수 있다.
  3. u에서 멤버함수 호출이되면 GenericVirtualClass의 GetThis 멤버함수 포인터가 저장되며, 그때 사용되는 클래스 인스탄스와 기타 정보는 pthis와 function_to_bind을 사용하게 된다. 그러면 클래스 X의 멤버함수 내의 수정된 this 포인터를 얻게 된다.

이렇게 하면, 컴파일러에서 사용하는 this 포인터 계산식을 사용하기에 별다른 연산이 필요없게 된다.

Unknown instance member function

Unknown 클래스의 멤버함수 포인터이다. Unknown 클래스는 클래스 선언만 있고 몸체가 없는 클래스이다. 그래서 어떤 클래스인지 알지 못한다는 의미이다.

template <>
struct SimpleMemFunc<SINGLE_MEMFUNCPTR_SIZE + 3*sizeof(int)> {
    template <class X, class XFuncType, class GenericMemFuncType>
    inline static GenericClass* Convert(X *pthis, XFuncType function_to_bind,
        GenericMemFuncType &bound_func) {
            union {
                XFuncType func;
                struct {
                    GenericMemFuncType func_ptr;
                    int delta;
                    int vtordisp;
                    int vtable_index;
                } s;
            } u;

            typedef int ERROR_CantUsehorrible_cast[sizeof(function_to_bind)==sizeof(u.s)?1:-1];

            u.func = function_to_bind;
            bound_func = u.s.func_ptr;

            int virtual_delta = 0;
            if (u.s.vtable_index) {
                const int* vtable = *reinterpret_cast<const int* const*> (
                    reinterpret_cast<const char*>(pthis) + u.s.vtordisp);

                virtual_delta = u.s.vtordisp + *reinterpret_cast<const int*> (
                    reinterpret_cast<const char*>(vtable) + u.s.vtable_index);
            }

            return reinterpret_cast<GenericClass*>(
                reinterpret_cast<char*>(pthis) + u.s.delta + virtual_delta);
    }
};

bound_func까지 얻는 것은 간단하다. 그러나 수정된 this 포인터 계산하는 것은 이해 불가이다. 먼저 vtordisp 자체을 모르겠고, 물론 vtable_index도 어떻게 사용하는지 모르겠다. this 포인터 계산도 모르겠다. 나보고 어떻게 하라고.. OTL

FastDelegate

이 부분이 실제코드보다 많이 간략화 되었다. 중간 코드도 모두 생략되었다. 그러나 작동은 한다. ㅡ.ㅡ;

class FastDelegate {
public:
    template  inline void bind ( const X* pthis, XMemFunc function_to_bind ) {
        pthis_ = SimpleMemFunc < sizeof ( function_to_bind ) > ::Convert ( const_cast<x*> ( pthis ), function_to_bind, pfunc_ );
    }

    void operator() ( int n ) {
        ( pthis_->*pfunc_ ) ( n );
    }

private:
    typedef void ( GenericClass::*GenericMemFuncType ) ( int );
    GenericClass *pthis_;
    GenericMemFuncType pfunc_;
};</x*>

단순하다. bind 멤버함수에 의해 원하는 인스탄스와 멤버함수 포인터가 넘어오면, 값을 계산해서 수정된 this 포인터는 pthis_에 실제 멤버함수 포인터는 pfunc_에 저장된다. 그리고 연산자 ()를 호출하고 인자를 넘기면, pthis_와 pfunc_의 협력으로 처리된다. 뭐 간단하다. 단지, GenericMemFuncType이라는 멤버함수 타입을 선언할 때 인자 int가 있다는 것 뿐이다.

사용예

사용예를 살펴보자. bind 메소드에 의해서 객체와 멤버함수를 받아서 처리한다. 앞의 예제는 멤버함수만 처리할 수 있다. 실행하는 부분은 연산자()에 의해서 구동된다.

이 구현의 장점은 언제든지 임의 객체와 멤버함수를 받을 수 있다는 부분이다.

Hello h;
HelloWorld hw;
VirtualHello v;

FastDelegate d;
d.bind(&h, &Hello::execute);
d(1);
d.bind(&hw, &HelloWorld::execute);
d(2);
d.bind(&v, &VirtualHello::execute);
d(3);

정적형태

Don은 구현은 너무 복잡하다. 이해하는데 너무 어렵다. 그리고 컴파일러의 내부 상세한 부분까지 알아야한다는 문제점이 있다. 물론 사용은 이해없이 그냥 사용하면된다.

Segey는 Don이 너무 표준에서 동떨어져 있고, 특정 컴파일러에 종속적인 부분이 많다는 문제점을 지적하면서, 표준에 의한 Delegate 구현을 했다. 근데 사실 Don에 비해서 호환되는 컴파일러가 적다고 한다. 참 아이러니한 부분이 아닐 수 없다. 표준을 기반으로 구현했지만, 완변히 호환이 되지 않는다는 것이다. 즉, 컴파일러마다 표준을 구현하는 방식, 혹은 지원되는 범위가 제각각이기 때문에 그런게 아닐까 생각이 든다.

Segey를 정적형태라고 한것은 멤버함수가 Delegate를 생성 될때에 정적으로 고정되기 때문이다. 구현은 간단하다.

class delegate
{
public:
    delegate() : objectPtr(0), subPtr(0)
    {}

    template <class T, void (T::*TMethod)(int) >
    void bind(T* objectPtr)
    {
        this->objectPtr = objectPtr;
        this->subPtr    = &methodStub<T, TMethod>; // methodStub 포인터가 저장됨
    }
    void operator () (int a1) const
    {
        return (*subPtr)(objectPtr, a1);
    }

private:
    typedef void (*subType) (void* objectPtr, int);

    void*    objectPtr; // 호출대상 객체
    subType  subPtr;    // 멤버함수 정보가 있는 메소

   // methodStub가 호출되면 objectPtr을 원래 타임으로 변경하고  TMethod에 저장된 멤버함수를 호출
    template < class T, void (T::*TMethod) (int) >
    static void methodStub (void* objectPtr, int a1)
    {
        T* p = static_cast<T*>(objectPtr);
        return (p->*TMethod)(a1);
    }
};

bind 멤버함수에 의해서 연동될 객체와 멤버함수를 받아들인다. 멤버함수는 템플릿 인자(TMethod)로 입력되어 methodStub로 멤버함수 정보가 담겨지고, methodStub 자체에 대한 포인터를 저장한다. 멤버함수를 호출하고 싶다면 methodStub를 호출하면 저장된 멤버함수 정보를 이용해서 구동이된다.

핵심이 methodStub 메소드이다. methodStub가 컴파일 시점에 TMethod를 통해서 멤버함수를 받아들인다. 이는 함수 자체 시그니처(?)로서 저장된다. methodStub가 호출될때에 static_cast에 의해서 objectPtr이 입력된 클래스 타임으로 변경이 되며 TMethod는 변경된 클래스 객체에 대해 호출하게 된다.

사용예를 살펴보자.

Hello h;

delegate d1;

d1.bind < Hello, &Hello::execute > (&h);
d1(1);

bind 호출할 때 클래스 타입과 멤버함수가 넘겨지는 것을 확인할 수 있다.

원리는 간단하다. 이렇게 해서 표준에 부합하는 Delegate가 만들어졌다. 그러나, 구현관점은 완벽하지만 일부 컴파일러에서는 안되는 부분이 있다고 한다. 이 부분은 제가 확인해보지 않아서 어느정도 까지인지 모르겠다. 그러나, 대부분 메이저 벤더인 경우는 표준에 거의 부합하기 때문에 지원할 것으로 생각이 든다.

나 중에 나올 Don에 비해서 일부 구현에서는 속도가 느리다는 단점이 있다. 특히 연산자()를 호출한 이후에 다시 methodStub를 호출하기 때문에 인자가 단순 복사로 넘겨지게 되면, 메모리 복제에 따른 속도 저하 및 리소스 사용량이 많아지게 된다. 그리고, 일부 호출 방식에서 Don 방식에 비해 코드가 길어지면서 속도가 단점도 있다.

멤 버함수가 methodStub 메소드에 고정되기 때문에 멤버함수를 가지고 연산 작업을 할 수 없다. Segry는 도리어 이런 연산 작업이 더 위함하다는 반론을 제기한다. 예측할 수 없는 결과를 가져올 수 있다는 것이다. 사용에 있어서 주의를 요한다.

동적형태2

동적형태에서 Jae가 구현한 예를 보자. 정적행태가 오다가 다시 동적형태가 오는게 이상할지 모르기만, Jae 구형방식이 Segry의 변형이다. 그렇기에 Segry를 이해하고 Jae 방식을 보아야 이해가 쉽다. Jae 구현방식은 앞에서 멤버함수가 템플린 인자로 적정형태로 다뤄지지만, 여기서는 멤버함수가 인자로서 입력되는 방식이다.

Jae가 동적으로 멤버함수를 인자로 받기 때문에 멤버함수를 저장할 공간이 필요하다. 그리고 이 멤버함수는 4~16까지 크기가 다양하다. 이를 보관하기 위한 방식이 필요하다. 저장방식이 8byte을 기존으로 작으면 스택에 저장해고 초과하면 동적할당하여 저장한다.

메타 템플릿

메타 템플릿에 의해서 멤버함수 크기를 컴파일 타임에서 결정할 수 있다. 먼저 메타 템플릿에서 조건에 따른 선택을 가능하게 하는 예를 보자.

// partial specialization version
template < bool condition_t, typename Then, typename Else > struct If;
template <typename Then, typename Else> struct If < true, Then, Else > { typedef Then Result; };
template <typename Then, typename Else> struct If < false, Then, Else > { typedef Else Result; };

// nested template struct version
template <bool condition_t, typename Then, typename Else >
struct If
{
    template < bool conditionInner_t > struct Selector;
    template < > struct Selector < true > { typename Then Result; };
    template < > struct Selector < false > { typename Else Result; };
    typedef typename Selector < condition_t >::Result Result;
};   

if문하고 비슷한 형태이다. 조건(condition_t)에 의해서 Then과 Else타입 중에 사용할 타입을 결정한다. 결정된 타임을 사용하는 것은 If::Result라는 typedef에 의해 정의된 타임을 사용하면 된다. 사용 코드 예를 간략히 보자.

class Foo;
class Bar;
typedef If <true, Foo, Bar>::Result type; // Foo가 type이 된다.

기본 형태

기본적인 골격을 보자. Segery과 (1),(2)을 제외하고 거의 비슷하다. 멤버함수가 bind 메소드의 method인자로 넘어온다는 것이 틀리다. (1)은 멤버함수를 저장하는 부분이다. 근데 그냥 저장하면 되지 복잡한 호출이 필요한가라는 생각이 든다. 앞에서 말한 다양한 크기의 멤버함수를 저장하기 위해서 필요하다. 먼저 멤버함수 크기가 8 이하인지 초과인자 판단이 필요하다. 그걸 위해서 SelectFp를 사용했다. (2)에서 실제 멤버함수를 호출하게 된다. 하나씩 차근차근 살표보도록 하자.

class delegate2
{
public:
    delegate2() : objectPtr(0), stubPtr(0)
    {}

    ~delegate2()
    {}

    template <class T >
    void bind(T* objectPtr, void (T::*method) (int))
    {

        SelectFp <T>::type::Init (*this, method); // (1)
        this->objectPtr = objectPtr;
        stubPtr = &methodStub<T>;
    }

    void operator () (int a1) const
    {
        return (*stubPtr) (*this, objectPtr, a1);
    }

private:

    void*    objectPtr;
    stubType  stubPtr;

    //...

    template <class T>
    static void methodStub(delegate2 const &dg, void* objectPtr, int a1)
    {
        T* p = static_cast<T*>(objectPtr);
        SelectFp <T>::type::Invoke (dg, p, a1); // (2)
    }

};

멤버함수 포인터 저장공간을 정의해보자.

enum {sizeBuf = 8 }; // 멤버함수 할당 판단위한 기준

// 멤버함수 저장위한 정적, 동적할당 영역을 공유
union
{
    void *fnPtr;
    unsigned char buf[sizeBuf];
};
bool isByMalloc; // 멤버함수 동적할당 여부

멤버함수 저장을 정적할당을 할지 동적할당을 할지를 판단하는 SelectFp를 보자.

template < class T >
struct SelectFp
{
    enum { condition = sizeof (void (T::*) (int)) <= sizeBuf };
    typedef fpByValue <T> Then;
    typedef fpByMalloc <T> Else;
    typedef typename If < condition, Then, Else >::Result type;
};

condition에는 클래스 타입 T의 멤버함수의 크기가 sizeBuf보다 이하인지 여부를 저장한다. condition 결과에 따라서 type이 Then이나 Else가 선택된다. sizeBuf보다 작다는 것은 참이되며 Then이 선택된다. Then은 fpByValue이며 정적할당을 책임진다. 반대로 sizeBuf보다 크면 거짓이되며 Else가 된다. Else는 fpByMalloc가 되어서 동적할당을 책임진다.

다시 앞의 (1) 코드를 살펴보면

SelectFp <T>::type::Init (*this, method); // (1)

SelectFp의 클래스 타입 T에 의해서 멤버함수 크기가 정해지고 크기에 따라서 할당 방식이 정해진다. 그러면 SelectFp::type이 결정된다. 그대 Init함수 호출에 의해서 method에 대한 동적 혹은 정적할당을 하여 멤버함수가 저장된다.

그럼 fpByValue와 fpByMalloc를 살펴보자.

fpByValue와 fpByMalloc

fpByValue를 먼저 보자. Init에 의해서 저장할 멤버함수를 받는다. delegate2의 스택영역인 buf에 저장한다. 그리고 isByMalloc를 false로 하여 동적할당이 아님을 지정한다. 이는 나중에 동적할당한 경우 delegate2 객체 소멸할 때에 같이 동적할당된 영역도 해제하기 위한 부분이다. 나중에 실제 멤버함수 호출에 사용할 Invoke도 단순하다. 저장된 객체를 적절한 클래스 타입으로 변환하고, 저장된 멤버함수 데이터(buf)을 가지고 호출하면 된다.

template < class T >
struct fpByValue
{
    inline static void Init(delegate2 &dg, void (T::*method)(int))
    {
        typedef void (T::*TMethod) (int);
        dg.isByMalloc = false;
        ::new (dg.buf) TMethod(method);
    }
    inline static void Invoke (delegate2 const &dg, T* objectPtr, int a1)
    {
        typedef void (T::*TMethod) (int);
        TMethod const method = *reinterpret_cast < TMethod const* > (dg.buf);
        return (objectPtr->*method) (a1);
    }
};

fpByMalloc를 보자. 앞와 비교해서 별다른 것이 없다. 단지 malloc에 의한 동적할당이 추가되었다. (주: malloc보다 C++내부의 new 사용을 더 권장)

그리고 Invoke 함수에서 멤버함수가 동적영역(fnPtr)에 저장되어 있기에 호출되는 영역이 달리질 뿐이다.

template < class T >
struct fpByMalloc
{
    inline static void Init (delegate2 &dg, void (T::*method) (int))
    {
        typedef void (T::*TMethod) (int);
        dg.fnPtr = ::malloc (sizeof(TMethod));
        dg.isByMalloc = true;
        new (dg.fnPtr) TMethod (method);
    }
    inline static void Invoke (delegate2 const &dg, T* objectPtr, int a1)
    {
        typedef void (T::*TMethod) (int);
        TMethod const method = *reinterpret_cast < TMethod const * > (dg.fnPtr);
        return (objectPtr->*method) (a1);
    }
};

fpByMalloc에서 동적할당이 되었기 때문에 delegate가 소멸할때 같이 동적할당 영역을 해제해줘야 한다. 방법은 간단하다. 소멸자에서 isByMalloc를 가지고 동적할당 여부를 판단하여 동적할당인 경우 해제해주면 된다.

~delegate2()
{
    if( isByMalloc ) {
        isByMalloc = false;
        ::free(fnPtr);
        fnPtr = 0;
    }
}

사용예

그럼 사용예를 보자.

Hello h;
delegate2 d3;
d3.bind(&h, &Hello::execute);
d3(3);

형태는 Don 방식과 동일하다. bind 메소드 호출할 때에 별다르게 멤버함수 타입을 템플릿 인자로 넘겨줄 필요는 없다. 꼍으로 보기에는 Don의 호출방식을 사용했고, 내부적으로 Segery의 방식을 차용했고, Don의 방식 일부를 수용했다. 그렇지만 Segergy의 단점이 아직도 남아 있다.

  • 4 byte의 멤버함수를 저장할 경우 리소스 손실이 있다.
  • 동적할당에 의한 부하가 발생한다.
  • 여전히 멤버함수 호출 코드가 Don 방식보다 크기에 호출부하가 있다.
  • 연산자()에 의해서 간접호출되기에 인자 복사에 따른 리소스 소모와 이에 따른 호출부하가 있다.

물론 장점도 있다.

  • Segery가 하지 못한 멤버함수 포인터에 의한 비교로 컨터이너에 의한 연산이 가능해진다.
  • Don에 비표준적인 구현을 표준적인 구현으로 하였다.
  • 기타 등등

결론

모두 정리하는데 시간이 많이 걸렸다. 특히, Don의 문서는 내용이 난이도는 너무 높았다. 똑똑한 놈이다. 아니, 똑똑한 분이다. 그 분의 구현은 끔찍한 구현이지만, 다른 구현 방식에 비해서 속도가 빠르고 호환성도 좋아서 많은 곳에서 사용하고 있다. 정말 아이러니하지 않을 수 없다.

여기서는 VC6에 대한 부분을 모두 무시한 코드로 작성을 하였다. 혹시나 해서 VC6로 하는 경우 참고할 부분을 간략하게 적어보았다.

VC6에서 템플릿 클래스에서 템플릿 함수를 사용하는 경우 문제가 발생한다. 이 경우 템플릿 클래스 안에 템플릿 클래스(구조체)를 사용하여 템플릿 함수 사용을 제거할 수 있다. 예를 들어,

// Bad in VC6

template <class T>
static void methodStub(delegate2 const &dg, void* objectPtr, int a1)
{
    T* p = static_cast<T*>(objectPtr);
    SelectFp <T>::type::Invoke (dg, p, a1);
}

//Good in VC6

template < class T >
struct DelegateStub_t
{
    static void methodStub(delegate2 const &dg, void* objectPtr, int a1)
    {
        T* p = static_cast<T*>(objectPtr);
        SelectFp <T>::type::Invoke (dg, p, a1);
    }
};

이 외에 다른 몇가지가 더 있다. 당연히 오래된 컴파일러와 최근 컴파일러는 다를 수 밖에 없다. 템플릿에 대한 부분도 처음 부터 완벽하게 지원된 부분도 아니다. 하나씩 찾으면서 해결해 나갈 수 밖에 없다. 가급적이면 최근에 나온 컴파일러를 사용하는 것이 정신건강에 좋을 것 같다. 그렇지 못한 상황이면 템플릿을 포기하는 것이 낳을 것 같다. 템플릿을 사용하면 구현하는데 더 간편하고 간략화하겠지만, 오류로 머리아프는 것보다는 사용하지 않는게 낳지 않을까하는 생각이 든다. 물론 개인의 선택이지만...

마지막으로, 여기서는 세가지 Delegate의 구현에 대한 원리를 살펴보기 위한 것이지, 만들기위한 목적은 아니다. 이미 머리좋으신 분들이 자신의 시간을 투자하면서 에러 없는 코드를 작성하느라 노력한 결과물이다. 그냥 갔다쓰는 것이 좋다. 물론 도전정신이 투철하여, 에러나 버그에 적극적으로 달려드는 분이라면 말리지는 않겠지만...

그냥 써서 감사의 글 하나 정도 보내는게 좋을 것이다. 모드 즐프하세요. ospace.

덧글

최재욱님의 글을 읽다가 Delegate에 대한 기능을 정리한 것이 있어서 여기에 다시 정리해봅니다. Delegate을 구현하시려는 분은 참고할만한 것 같습니다.

  • 힙메모리 대신에 스택 메모리를 사용
  • 세 가지 형태의 호출 가능한 객체 지원 (free function, member function, functor)
  • C++ 표준에 부합하면서 호환성 지원
  • STL 컨테이너에 저장가능 (copy-construtible & assignable)
  • delegate간에 동일/대/소 비교 가능 (STL set 컨테이너에 사용)
  • cv 지시자(const) 완벽 지원
  • 비표준 호출 규약 지원 (__stdcall, __fastcall, cdecl, pascal)
  • Preferred Syntax와 Portable Syntax 모두 지원하고 혼용 가능
  • 유현한 형검사
  • 디버그 기능 (컴파일타임에 적정 경고)
  • 멤버함수에 바인딩되는 객체의 포인터를 저장하거나 객체를 복사하여 별도의 복사본을 유지하고 복사본에 멤버 함수 호출하는 것이 가능
  • 멤버함수에 바인딩되는 객체 대신에 객체를 가리키는 스마트 포인터를 사용하여 자동 메모리 관리가 가능
  • 사용자 정의 메모리 할당자 (allocator)를 사용 가능
  • 사용자가 적절한 매크로 상수를 정의함으로써 delegate의 특징을 컴파일 타임에 결정 가능

중간에 정확히 어떤 기능인지 이해 안되는 부분도 있는데 단순 참고만 하세요. 모두 꼭 해야된다는 것은 아닙니다. 필요에 따라서 선택적으로 수용하면 되겠다. 물론 모든 것을 다 지원하면서 성능도 뛰어나고, 사용법도 쉽고, 유연하게 구성도 가능하며 내부 코드도 단순하여 디버깅도 간단하다면 최고겠지요.

참고

[1] 최재욱, 2006.03, Fast C++ delegate, http://www.devpia.com/MAEUL/Contents/Detail.aspx?BoardID=51&MAEULNo=20&no=7287&ref=7287

[2] 최재욱, 2006.04, Fast C++ Delegate #2 - Multicast, http://www.devpia.com/MAEUL/Contents/Detail.aspx?BoardID=51&MAEULNo=20&no=7301&ref=7301

[3] Don Clugston, 2005.04, Member Function Pointers and the Fastest Possible C++ Delegates, http://www.codeproject.com/KB/cpp/FastDelegate.aspx

[4] Sergey Ryazanov, 2005.07, The Impossibly Fast C++ Delegates, http://www.codeproject.com/KB/cpp/ImpossiblyFastCppDelegate.aspx

[5] JaeWook Choi, 2007.06, Fast C++ Delegate: Boost.Function 'drop-in' replacement and multicast, http://www.codeproject.com/KB/cpp/fastdelegate2.aspx

[6] ospace, 2011.01, C++멤버함수 포인터 크기 확인, http://ospace.tistory.com/217

[7] ospace, 2010.12, 가상함수 구조, http://ospace.tistory.com/216

반응형