본문 바로가기

3.구현/C or C++

[c++] 함수자 구현 고찰

들어가기

함수자(Functor)은 함수처럼 동작하는 객체를 말한다. 기존 함수들을 함수자로 만들어서 처리하는 방법을 살펴보려고 한다. Aleksei Trunov님의 글을 정리한 거라서 내용이 누락되거나 잘못 되어 있을 수 있습니다.

작성자: ospace114@empal.com, http://ospace.tistory.com/

함수자 도입

다음과 같은 형태를 함수자로 처리해보자.

void f1(int, int) { ... }
struct A {
  void f2(int, int) { ... }
};

struct B {
  void f3(int, int) { ... }
  void f4(int, int)  const { ... }
};

struct C {
  void operator()(int, int) { ... }
};

// 함수 포인터
void (*pf1)(int, int) = &f1; // 정적함수
void (A::*pf2)(int, int) = &A::f2; // 멤버함수
void (B::*pf3)(int, int) = &B::f3; // 멤버함수

C c; // 연산자 () 함수호출

// 호출
pf1(1,2)
A a;
B *pb = new B;
(a.*pf2)(1,2);
(bp->*pf3)(1,2);
c(1,2);

함수자로 처리하기위한 일반적인 방법으로 다음과 같다.

  • 방법1: 특수한 컨테이너 구현
  • 방법2: 어뎁터 클래스 설계

방법1인 특정 함수에 대한 컨테이너에 넣어서 단일 메소드로 호출할 수 도 있다. 방법2가 좀더 제너릭한데 이는 어뎁터 클래스가 다른 어플리케이션에서도 사용가능하기 때문이다. 이 어뎁터 클래스 클래스를 일반화된 함수자라고 한다.

typedef Functor< .../*템플릿인자*/ > Functor2;
Functor2 fun1(&f1);
Functor2 fun2(&a, &A::f2);
Functor2 fun3(pb, &B::f3);
Functor2 fun4(c);

fun1(1, 2);
fun2(1, 2);
fun3(1, 2);
fun4(1, 2);

// 함수 할당
Functor2 fun;
fun = fun1

함수자를 컨테이너로 관리해보자.

class FunctorSet {
  typedef std::vector<Functor2> VecFun;
  VecFun funs_;

public:
  FunctorSet& operator += (Functor2 const& f) {
    funs_.push_back(f);
    return *this;
  }

  void operator() (int i1, int i2) const {
    VecFun::const_iterator end(funs_.end());
    for(VecFun::const_iterator i=funs_.begin(); i != end; ++i) {
      (*i)(i1, i2);
    }
  }
};

FunctorSet fs;
fs += fun1;
fs += fun2;

//...

fs(1, 2);

모든 함수를 같은 형인 Functor2를 사용해서 서로 다른 호출 엔티티들을 간단하게 처리할 수 있다.

구현하는데 있어서 요구사항

  • (1) 모든 종류의 호출 엔티티 지원과 해당하는 생성자 지원
  • (2) 타입 안전하게 호출가능 엔티티에 대한 함수 호출
  • (3) 값 구문 지원. 이를 위해 기본생성자, 할당연산자, 복사 생성자/소멸자.

추가적인 이슈사항

  • (1) 다양한 타입 인자와 반환값에 대한 일반화 방법
  • (2) 다른 개수 인자 호출

알려진 구현 방법

많이 알려진게 구현 방법으로 Loki와 boost가 있다. 완전한 일반화 함수자 구문을 제공하지만 내부 구현은 전혀다르다.

이슈사항 (1)을 살펴보자. 두가지 방법으로 일반화된 타입인자와 반환값 형식을 처리할 수 있다.

  • 방법1: 함수 타입 사용
  • 방법2: 리턴형과 모든 인자 타입을 가지는 typelist를 사용

방법1인 경우 (boost)를 보자.

template <class F> class Functor;
typedef Functor<void (*)(int, int)> Functor2;

이 방법이 우아해보이지만, 몇가지 단점이 있다.

함수 시그니처가 같아도 다른 수식어(const volatile)가 있을 수 있다. 다음은 멤버함수에만 적용할 수 있다.

typedef Functor<void (*)(int,int) const> Functor2;

다른 수식어를 가진 함수는 다른 형으로 인스탄스화된 경우에도 다르다.

함수 타입은 너무 저수준 엔티티에 사용되기에 일반화된 함수자 같은 고수준 엔티티에 사용하기에는 부적합하다.

방법2인 경우를 보자.

template <class R, class TList> class Functor;
typedef Functor<void, TYPELIST_2(int, int)> Functor2; // (방법2-1)
typedef Functor<void, CreateTL<int, int>::Type> Functor2; // (방법2-2)

이 방법은 우아해보이지는 않지만 방법1의 단점은 없다. 함수 생성하는 함수를 제공함으로써 조금 좋아 보일 수 있다.

FunSet fs;
fs += MakeFunctor(f1);
fs += MakeFunctor(A::f2, &a);
fs += MakeFunctor(B::f3, pb);

함수 생성하는 MakeFunctor 함수를 구현하기 위해 공통 trait idiom을 사용해서 정의해보자.

template <typename F> struct FunTraits;
template <typename R, typename P1, P2>
struct FunTraits<R (*)(P1, P2)> {
  typedef NullType ObjType;
  typedef R ResultType,
  typedef P1 Param1;
  typedef P2 Param2;
  typedef TYPELIST_2(P1, P2) TypeListType;
};

template <class O, typename R, P1, P2>
struct FunTraits<R (O::*)(P1, P2)> {
  typedef O ObjType;
  typedef R ResultType,
  typedef P1 Param1;
  typedef P2 Param2;
  typedef TYPELIST_2(P1, P2) TypeListType;
};

함수자 생성하는 도우미 함수를 간단하게 구현할 수 있다.

template <typename F> inline
Functor <typename FunTraits<F>::ResultType, typename FunTraits<F>::TypeListType> MakeFunctor(F fun) {
  return Functor<typename FunTraits<F>::ResultType, typename FunTraits<F>::TypeListType>(fun);
}

template <typename MF, class P> inline
Functor <typename FunTraits<MF>::ResultType, typename FunTraits<MF>::TypeListType> MakeFunctor(MF memfun, P const& pobj) {
  return Functor<typename FunTraits<MF>::ResultType, typename FunTraits<MF>::TypeListType>(pobj, memfun);
}

요구사항 (1) 고려

요구사항(1)을 고려해보자. 일반화된 함수자 클래스의 생성자는 관련없는 타입까지 포함되어야 한다. 이는 생성자는 멤버 템플릿 처럼 정의되어야 한다.

template <class R, class TList>
class Functor {
public:
  template <typename F> Functor(F const& fun) { ... }
  template <typename P, typename MF> Functor(P const& pobj, MF memfun) { ... }
};

생성자는 호출 엔티티 타입을 알고 있지만, 생성자가 종료되면 잃어 버린다. 일부 연산자는 호출 엔티티의 타입을 알고있어야 한다. 그래서 생성자에서 타입 정보를 보편적인 형태로 저장해야 한다.

template <class R, class TList>
class Functor {
  struct FunImplBase {
    virtual R call( ... ) = 0;
  };

  template <typename F>
  class FunctorImpl : public FunImplBase {
    F fun_;

  public:
    FunctorImpl(F const& fun) : fun_(fun) {}
    virtual R call(...) { ... }
  };

  template <typename P, typename MF>
  class MemberFnImpl : public FunImplBase {
    P pobj_;
    MF memfun_;

  public:
    MemberFnImpl(P const& pobj, MF memfun) : pobj_(pobj), memfun_(memfun) {}
    virtual R call(...) { ... }
  };

  template <typename F> Functor(F const& fun) {
    pimpl_ = new FunctorImpl<F> (fun);
  }

  template <typename P, typename MF> Functor(P const& pobj, MF memfun) {
    pimpl_ = new FunctorImpl<P, MF> (pobj, memfun);
  }

  R operator(...) {
    return pimpl_->call(...);
  }
};

FunImplBase 추상 클래스는 호출 엔티티 타입에 대한 모든 연산을 정의한다.

FunctorImpl과 MemberFnImpl은 비멤버함수와 멤버함수에 대한 상속클래스이다.

요구사항(2)와 이슈사항(2) 고려

요구사항(2)와 이슈사항(2)를 보자. typelist를 사용해서 다른 타입을 포함한다. 임의 개수를 포함할 수 있다고 해서typelist 내부 설계에서는 고정되어 있다. c++ 내부도 인자 개수를 다룰 기능이 없기에 일반적인 방법으로 다룰 수 없다. 다른 방법이 필요하다.

  • 방법1: Functor 클래스 템플릿으로 부분특화로 가능한 타입 개수에 대해 정의
  • 방법2: 단일 front-end 함수자 템플릿을 정의하고 이를 오버로드

방법1인 경우를 보자.

template <class R, class TList> class Functor;
template <class R> class Functor <R, TYPELIST_0()> { ... };
template <class R, class P1> class Functor<R, TYPELIST_1(P1)> { ... };

이 경우 각 특화된 모든 인자에 대한 코드를 포함하게 된다.

방법2인 경우를 보자.

template <class R, class TList> class Functor {
  operator()()
  operator()(T1)
  operator()(T1, T2)
  ...
};

이 방법은 앞의 문제점을 해결하지만 잠재적 버그가 있다. 일부 라인이 누락되도 컴파일에는 에러가 없지만 런타임에 에러가 발생한다. 해결방법은 FunImplBase, FunctorImpl과 MemberFnImpl에

call 함수에 대해 가능한 타입 개수 만큼 특화을 정의한다.

template <class R, class TList> struct FunImplBase;
template <class R, typename P1, P2>
struct FunImplBase<R, TYPELIST_2(P1,P2)> {
  virtual R call_(P1 p1, P2 p2) = 0;
  ...
};

컴파일러가 Functor 템플릿에서 적당한 operator()를찾고, 해당 FunImplBase 특화에서 적당한 call_ 함수를 찾을 수없다면 실패하게 된다.

기존 라이브러리를 사용해서 구현했는데, 이에 대한 단점을 분석하고 제거해보자.

단점1: 인스탄스 할당으로 속도와 자원 낭비

FunctorImpl과 MemberFnImpl에 대한 인스탄스 할당으로 속도와 자원 낭비될 수 있다.

  • Loki: 최적화된 사용자 최소 객체 할당자를 제공
  • Boost: 비멤버 함수에 대한 일반화된 함수자에 대해 힙할당하지 않고, 일반화된 함수자의 힙할당자를 사용

힙할당을 제외? 가능한 방법이 함수자 클래스 템플릿 자체에 고정된 크기를 버퍼와 메모리 할당을 위한 풀을 같이 사용한다.

32비트 시스템에서 정적 함수는 4바이트, 대상 객체 인스탄스와 멤버함수에 대한 포인터는 4~20 바이트를 사용한다.

template <class R, class TList, unsigned int size = 4 * sizeof(void*)>
class Functor {
  struct Typeless {
    template <class T, class V T* init(V const& v) {
      new(&buffer[0]) T(v);
    }
    template <typename T> inline T const& get() const {
      return *reinterprett_cast<T cost*>(&buffer[0]);
    }
    template <typename T> inline T& get() {
      return *reinterpret_cast<T*>(&buffer[0]);
    }
  }

  Typeless val_;
  FunImplBase<R, TList> *pimpl_;

public:
  template <typename F> Functor(F const& fun) {
    pimpl_ = val_init<FunctorImpl<F> >(fun);
  }
//...
};

나머지 경우인 sizeOf(T) > size로 Typeless::init()에서 처리된다.

template <class R, class TList, unsigned int size = 4 * sizeof(void*)>
class Functor {
  struct Typeless {
    template <class T, class V T* init(V const& v) {
      new(&buffer[0]) T(v);
    }
    template <typename T> inline T const& get() const {
      return *reinterprett_cast<T cost*>(&buffer[0]);
    }
    template <typename T> inline T& get() {
      return *reinterpret_cast<T*>(&buffer[0]);
    }
  }

  template <typename T>
  struct ByValue {
    template <typename V>
    inline static T* init(Typeless& val, V const& v) {
      return val.template init<T>(v);
    }
    inline static T const& get(Typeless const& val) {
      return val.get<T>();
    }
    inline static T& get(Typeless& val) {
      return val.get<T>();
    }
  };

  template <typename T>
  struct NewAlloc {
    template <typename V>
    inine static T* init(Typeless& val, V const& v) {
      return *val.template init<T*>(new T(v));
    }
    inline static T const& get(Typeless const& val) {
      return val.get<T>();
    }
    inline static T& get(Typeless& val) {
      return val.get<T>();
    }
  };

  template <typename T>
  struct SelectStored {
    typedef typename Select<sizeof(T) <= sizeof(Typeless), ByValue<T>, NewAlloc<T> >:: Result Type;
  };

  struct Stored {
    template <typename T, typename V>
    inline T* init(V const& v) {
      return SelectStored<T>::Type::init(val_, v);
    }
    template <typename T>
    inline T const& get() const 
      return SelectStored<T>::Type::get(val_);
    }
    template <typename T> inline T& get() {
      return SelectStored<T>::Type::get(val_);
    }
    Typeless val_;
  };

  Stored val_;
  FunImplBase<R, TList> *pimpl_;

public:
  template <typename F> Functor(F const& fun) {
    pimpl_ = val_init<FunctorImpl<F> >(fun);
  }
  //...
};

단점2: val_, pimpl_은 중복으로 &val_이거나 val_과 연관.

val_, pimpl_은 중복으로 &val_이거나 val_과 연관된 부분를 해결하면 4바이트 절약할 수 있다.

template <class R, class TList, unsigned int size=4*sizeof(void*)>
class Functor {
  struct FunImplBase {
    struct VTable {
      R (*call_)(Functor const&, TList로부터 매개변수);
      ...
    };
  };

  templae <typename F>
  class FunctorImpl : public FunImplBase {
    F fun_;

   public:
    FunctorImpl(F const& fun):fun_(fun) {}
    static R Call(Functor const& f, TList로부터 매개변수) {
    FunctorImpl const& this_ = f.val_.template get<FunctorImpl const>();
    //...
  }
};

template <class P, class MF>
class MemberFnImpl_ : public FunImplBase {
  P pobj_;
  MF memfun_;

pubic:
  MemberFnImpl(P const& pobj, MF memfun) : pobj_(pobj), memfun_(fun) {}
  static R Call(Functor const& f, TList로 부터 매개변수) {
    MemberFuImpl const& this_ = f.val_.template get<MemberFnImpl const>();
    //...
  }
};

Stored val_;
typename FunImplBase::VTable* vptr_;

public:
  template <typename F> Functor(F const& fun) {
    FunImplBase *pimpl_ = val_.init<FunctorImpl<F> >(fun);
    static typename FunImplBase::VTable vtbl = {
      &FunctorImpl::Call,
      ...
    };

    vptr_ = &vtbl;
  }

  R operator( TList로부 매개변수) {
    return vptr_->call_(*this, TList로부터 매개변수);
  }
  ...
};

요구사항2와 이슈사항2에 대해 보자.

연산자 ()에서 FunctorImpl::Call과MemberFnImpl::Call 호출시 매개변수 복사가 된다. 함수 포인터에 의한 호출로 컴파일러에 의한 최적화가 안된다. 인자 복사에 대한 오버헤드가 발생한다.

template <class TList> struct CallParms;
template <typename P1, typename P2>
struct CallParms<TYPELIST_2(P1,P2)> {
  typedef InstantiateH<TYPELIST_2(P1,P2)> ParmsListType;
  static inline ParmListType Make(P1 p1, P2 p2) {
    //...
  }
};

template <typename CallType, typename R, class TList> struct FunctorCall;
template <typename CallType, typename R, P1, P2>
struct FunctorCall<CallType, R, TYPELIST_2(P1,P2)> {
  typedef InstantiteH<TYPELIST_2(P1,P2)> ParmsListType;
  template <class Fun>
  static inline R Call(Fun const& fun, ParmsListType& parms) {
    return fun( parms에서 언팩 );
  }
  template <class PObj>
  static inline R Call(PObj const& pobj, CallType memfun, ParmsListType& parms) {
    return ( (*pboj).*memfun)( parms에서 언팩 );
  }
};

//...

template <class R, class TList, unsigned int size = 4 * sizeof(void*)>
class Functor {
  typedef typename CallParms<TList>::ParmsListType ParmsListType;
  struct FunImplBase {
    struct VTable {
      R (*call_)(Functor const&, ParmsListType parms);
      ...
    };
  };

  templae <typename F>
  class FunctorImpl : public FunImplBase {
    F fun_;

  public:
    FunctorImpl(F const& fun):fun_(fun) {}
    static R Call(Functor const& f, ParmsTypeList parms) {
    FunctorImpl const& this_ = f.val_.template get<FunctorImpl const>();
      return FunctorCall<T, R, TList>::Call(this_.fun_, parms);
    }
  };

  template <class P, class MF>
  class MemberFnImpl_ : public FunImplBase {
    P pobj_;
    MF memfun_;

  public:
    MemberFnImpl(P const& pobj, MF memfun) : pobj_(pobj), memfun_(fun) {}
    static R Call(Functor const& f, ParamsListType parms) {
      MemberFuImpl const& this_ = f.val_.template get<MemberFnImpl const>();
      return FunctorCall<T,R,TList>::Call(this_.pobj_, this_.memfun_, parms);
    }
  };

  Stored val_;
  typename FunImplBase::VTable* vptr_;

  public:
    template <typename F> Functor(F const& fun) {
      FunImplBase *pimpl_ = val_.init<FunctorImpl<F> >(fun);
      static typename FunImplBase::VTable vtbl = {
        &FunctorImpl::Call,
        ...
      };
      vptr_ = &vtbl;
    }

    // TList에서 Parm1형 계산

    // TList에서 Parm2형 계산

    inline R operator()() {
      return vptr_->call_(*this, CallParms<TList>::Make());
    }
    inline R operator()(Parm1 p1) {
      return vptr_->call_(*this, CallParms<TList>::Make(p1));
    }
    inline R operator()(Parm1 p1, Parm2 p2) {
      return vptr_->call_(*this, CallParms<TList>::Make(p1, p2));
    }
    ...
};

InstantiateH은 Loki의 GenScatterHierarchy<> 형태이며 여기서는 튜플 생성 엔진으로 사용한다.

operator()의 인자들을 튜플로 패킹하고 전달한다. 이로 인해 구현이 간단해지고 함수자 연쇄와 바인딩 지원이 효과적이게 된다.

결론

구현을 말하고 있지만 완성된 코드는 아니라서 바로 사용하기에는 부적합하다. 실제 코드는 참고[1]에 링크를 따라 가면 다운로드 받을 수 있으니 참고 바랍니다. 2005년도 글이라서 C++11 관점에서는 다르게 구현될 수도 있다. 이 글이 여러분에게 도움이 되었으며 합니다. ospace.

참고

[1] Aleksei Trunov, Yet Another Generalized Functors Implementation in C++, https://www.codeproject.com/Articles/10650/Yet-Another-Generalized-Functors-Implementation-in

반응형