2020/08/30

Slot - Delegate의 재 발명


Signals and Slots를 구현해 본지 2년이 됐는데 문득 아쉬웠던 부분 들이 떠올라서 심심삼아 바퀴를 다시 발명해 보기로 했다. Signals and Slots는 Multicast Delegate와 비슷한 개념이다. Delegate의 특별한 응용 분야라고 생각할 수도 있다. 그래서, 일단 Delegate 구현시 부족했던 부분을 다시 채워 보기로 했다.

이전 버전에서는 Slot이 독립적인 Delegate 역할을 하는데는 한계가 있었다. 가장 찜찜했던 부분은 역시나 표준 c++을 따르지 않는 것이었다. 문제는 표준 c++에서 멤버 함수의 pointer를 직접 저장할 수 있는 방법이 없다는 것이다. Template을 이용해서 멤버 함수를 컴파일러가 바인딩하도록 해주면 실행 속도도 빨라지고 문제가 해결되긴 하지만 Delegate 사용시 interface가 매우 불편하다. 궁극적으로 Qt의 Signals and Slots 스타일로 사용할 수 없다. 사실, reinterpret_cast<>나 union을 이용해서 type-punning(억지로 형 바꾸기)한 변수들을 사용하는 부분은 모두 표준 c++에 위배된다. 억지로 바꾸려다 형 한테 맞는다???

흠, 혹시나해서 구글링해 보니 그 간에도 수 많은 넘들이 자신 만의 Delegate을 발명하고 있더라. Delegate가 흥미로운 놈인건 사실이다. 특히나 c++을 배우고 있다면 한번 쯤 자신의 바퀴를 발명해 보기 바란다. c++ 언어 자체가 계속 버전업 되다 보니 새로운 방식으로 도전해 보는 이들도 있다. 이를 테면 c++20의 concept을 활용해 볼 수도 있겠다. 여기서는 너무 멀리는 가지 않고 c++17의 if constexpr와 std::invocable을 이용해서 코드를 단순화 시켰다. 

근본적인 문제를 해결했는데, c++ 표준에서 함수/멤버 함수 pointer는 void* 포인터로 저장할 수 없지만, object instance는 void* 포인터로 저장할 수 있다는 점을 이용한 것이다. void*는 객체를 저장하기 위한 포인터이기 때문에, 비객체 포인터를 void*에 강제 할당하는 것은 표준 c++에서 벗어난다. 여기를 보면 c++ 객체에 대해 올바르게 이해할 수 있다. 또, 억지로 형 바꾸기를 하면 안되는 이유도 알 수 있다.  즉, 함수/멤버 함수 포인터를 객체 class로 감싸주면 void*에 저장할 수 있게 된다.

void*에 데이터를 저장하게 되면 객체들의 type을 모두 잃어 버리게 되는데, template type deduction을 이용해서 type을 복원해 주어야 한다. 형을 지웠다가 다시 쓸 수 있게 하는 넘들을 Type Erasure(형 지우개)라 하더라. Delegate Pattern에서는 inheritance를 사용하는데 다중 상속시 발생하는 문제와  virtual table 참조에 의한 성능 문제 때문에 자신 만의 Delegate를 만드는 넘들이 생겨났다. 

이렇게 만들어진 Delegate는 실상 std::function<>을 다시 발명한 것이다. 원래 std::function<>이 표준 Delegate인 셈이다. 하지만, std::function<>은 상당히 무겁고 느린 편이다. 더구나 안전을 보장하기 위해 모든 Callable 객체를 복사해 두기 때문에, Callable 객체들을 비교해야 한다면 추가적인 성능 부담이 생길 수 밖에 없는 구조다. 여기를 보니까 std::function<>을 다시 발명하고자 할 때 고려해야 할 점들을 잘 정리했더라. lambda도 Delegate의 역할을 일정부분 수행할 수는 있지만 Signals & Slots를 포함한 다양한 분야에 적용하는데 한계가 있다.

결론적으로, 아래와 같이  Slot이라는 나만의 Delegate를 다시 만들었다. SBO(Small Buffer Optimization) 라든가 Placement new라든가 하는 소소한 기법들이 들어가 있다. 사용법은 std::function<>과 거의 동일하고, 멤버 함수의 경우 std::bind<>를 사용하지 않고도 바로 초기화해서 사용할 수 있다. 

재미삼아 만들긴 했지만 이전 버전과 비교해서 여러가지 감안해도 두배 이상 소스가 늘어났다. 표준을 따르는게 얼마나 고된 일인가? 그니까 표준을 잘 만들어라~!!!

// Slot (Delegate or Callback) C++ Implementation
//
// This program is copyright (c) 2020 by Umundu @ https://zapary.blogspot.com .
// It is distributed under the terms of the GNU LGPL version 3, as detailed in
// https://opensource.org/licenses/lgpl-3.0.html .
//
// There are so many c++ Delegate implementaions. -  What's the point in this Slot?
// => Make simple interface without losing performance while keeping c++ standard.
//
//  o Simple usage can be extended to Signals and Slots(Qt) style interface: e.g)
//    Slot<double(int)> s1, s2(&obj, &Derived::doWork), s3([&c](int i){ return c+i; }), s4;
//    s1 = lambda; s4 = free_function; double result = s3(100);
//  o Comparison of two Slots is based on the IDs that are created from the source objects.
//  o Can be used as a light weight version of std::function<>.
//
//  * Compiler requirements: c++17 - supporting c++11 could be handy by replacing two parts:
//    - std::is_invocable_r<> => can be replaced to std::is_convertible<> things.
//    - if constexpr()        => can be replaced by using the SNIFAE.

#include <functional>
#include <memory>

template <typename T> class Slot;
template <typename TR, typename... TAs>
class Slot<TR(TAs...)>
{
  static constexpr size_t BufferMaxSize = 32;
  using TypeID = size_t;
  using TypePF = TR(*)(TAs...);
  using TypeCallback = TR(*)(void*, TAs&&...);
  using TypeCleaner = void(*)(void*);
  using TypeOPF = struct { TypePF pf; };

  // All the callables should be std::is_invocable_r<TR, TO, TAs...> OK.
  // Non-static member function is the most special citizen among the callables.
  // Comparing with the type of a function pointer(TypePF):
  //   Callables =>  |free fn|static member fn|lambda w/o capture|functor & lambda w/ capture
  //   is_same       |  yes  |      yes       |       no         |     no
  //   is_assignable |  yes  |      yes       |       yes        |     no
  template <typename T>
  using TypeIfFunction = typename std::enable_if<std::is_assignable<TypePF&, T>{}>::type;
  template <typename T>
  using TypeIfFunctor = typename std::enable_if<!std::is_assignable<TypePF&, T>{}
    && !std::is_same<std::decay<Slot>::type, std::decay<T>::type>{} // use default ctors.
    && std::is_invocable_r<TR, T, TAs...>{}>::type; // since c++17.

public:
  // Use default constructors for Slot according to the rule of zero.
  Slot() = default;
  Slot(const std::nullptr_t) noexcept : Slot() {};

  // for free functions, lambdas without capture and static member functions.
  template <typename TF, typename = TypeIfFunction<TF>>
  explicit Slot(TF pf) noexcept { bind(pf); }

  // for functors including lambdas with capture and std::function<>.
  template <typename TF, typename = TypeIfFunctor<TF>>
  Slot(TF&& pobj) noexcept { bind(std::forward<TF>(pobj)); }

  // for static member functions
  template <typename TB>
  explicit Slot(TB*, TypePF pmf) noexcept { bind(pmf); }

  // for non-static member functions
  template <typename TB, typename TO>
  Slot(TO* pobj, TR(TB::*pmf)(TAs...)) noexcept { bind(pobj, pmf); }
  template <typename TB, typename TO>
  Slot(const TO* pobj, TR(TB::*pmf)(TAs...) const) noexcept { bind(pobj, pmf); }

  Slot& operator=(TypePF pf) { bind(pf); return *this; }
  template <typename TF, typename = TypeIfFunctor<TF>>
  Slot& operator=(TF&& fn) noexcept { bind(std::forward<TF>(fn)); return *this; }

  explicit operator bool() const { return m_obj; }
  bool operator==(const Slot& rhs) const { return m_id == rhs.m_id; }
  bool operator!=(const Slot& rhs) const { return !operator==(rhs); }
  bool operator==(const std::nullptr_t) const { return !m_obj; }
  bool operator!=(const std::nullptr_t) const { return m_obj; }

  TypeID id() const { return m_id; }
  TR operator()(TAs&&... args) const noexcept { return emit(std::forward<TAs>(args)...); }
  TR emit(TAs&&... args) const noexcept
  {
    if(!m_obj) return TR();
    if(m_callback) return m_callback(m_obj, std::forward<TAs>(args)...);
    return (*static_cast<TypeOPF*>(m_obj)->pf) (std::forward<TAs>(args)...);
  }

protected:
  void bind(TypePF pf) noexcept
  {
    m_id = hash(reinterpret_cast<void*>(pf), nullptr);
    // Using storage for function pointer types is a design choice(cf. using template).
    // NB: type-punning by reinterpret_cast<> for a function pointer violates the c++ standard.
    store<TypeOPF>(std::move(TypeOPF{pf}));
  }

  template <typename TF, typename = TypeIfFunctor<TF>>
  void bind(TF&& fn) noexcept
  {
    using typeF = typename std::decay<TF>::type;
    // NB: any valid objects can be referenced to the generic pointer(void*).
    //   - functions, member functions and references are not objects.
    //   - so their pointers can't be converted to the generic pointer.
    m_obj = &fn;
    auto pmf = &typeF::operator();
    m_id = hash(m_obj, reinterpret_cast<void*>(reinterpret_cast<void(*&)()>(pmf)));
    m_callback = callFunctor<typeF>;
    // Storage is required for RValue lambdas with capture.
    // LValue functors including lambdas with capture can be called directly.
    if constexpr(!std::is_reference<TF>{}) store<typeF>(std::move(fn)); // since c++17.
  }

  template <typename TB, typename TPMF>
  void bind(TB&& pobj, TPMF&& pmf) noexcept
  {
    using typeTB = typename std::decay<TB>::type;
    using typeMF = struct { typeTB obj; typename std::decay<TPMF>::type pmf; };
    // Somthing weird but possible way.
    m_id = hash(pobj, reinterpret_cast<void*>(reinterpret_cast<void(*&)()>(pmf)));
    m_callback = callMember<typeMF>;
    // Using storage for non-static member functions is a design choice.
    // This is trade-off in using interfaces between fast but inconvenient template style
    // versus rather slow but more convenient std::function<> style.
    // NB: type-punning by reinterpret_cast<> for pointer to member functions violates
    //     the c++ standard.
    store<typeMF>(std::move(typeMF{std::forward<TB>(pobj), std::forward<TPMF>(pmf)}));
  }

private:
  size_t hash(const void* obj, const void* pmf) const
  { return reinterpret_cast<size_t>(obj) ^ reinterpret_cast<size_t>(pmf); }

  template <typename TF>
  static TR callFunctor(void* vobj, TAs&&... args) noexcept  // Why static?
  { return (static_cast<TF*>(vobj)->operator())(std::forward<TAs>(args)...); }

  template <typename TP>
  static TR callMember(void* vobj, TAs&&... args) noexcept   // Why static? => Your homework!
  {
    TP* pobj = static_cast<TP*>(vobj);
    return (pobj->obj->*pobj->pmf)(std::forward<TAs>(args)...);
  }

  template <typename TF>
  void store(TF&& fn)
  {
    using typeF = typename std::decay<TF>::type;
    // Use a fixed small buffer for normal cases - so called SBO(Small Buffer Optimization).
    if constexpr(sizeof(typeF) <= BufferMaxSize) {  // since c++17.
      m_size = sizeof(typeF);
      if(m_cleaner) m_cleaner(&m_buffer);
      new (m_buffer) typeF(std::move(fn));
      m_obj = m_buffer;
      m_cleaner = cleaner<TF>;
    }
    // Allocate heap memory only if the current buffer does not fit.
    else {
      if(sizeof(typeF) > m_size || m_data.use_count() > 1) {
        m_size = sizeof(typeF);
        m_data.reset(operator new(m_size), deleter<TF>);
      }
      else m_cleaner(m_data.get());
      new (m_data.get()) typeF(std::move(fn));
      m_obj = m_data.get();
      m_cleaner = cleaner<TF>;
    }
  }

  template <typename T>
  static void cleaner(void* p) { static_cast<T*>(p)->~T(); }
  template <typename T>
  static void deleter(void* p) { static_cast<T*>(p)->~T(); operator delete(p); }

private:
  void* m_obj{nullptr};                    // object pointer
  TypeCallback m_callback{nullptr};        // callback pointer
  TypeCleaner m_cleaner{nullptr};          // pointer to a storage cleaner
  char m_buffer[BufferMaxSize];            // small buffer storage
  std::shared_ptr<void> m_data{nullptr};   // big data storage
  size_t m_size{0};                        // size of data storage
  TypeID m_id{0};                          // Slot id
};