2018/09/29

Signals & Slots - class에서 SigLots 사용법


여기서는 class 들 간의 Signals & Slots를 연결해 주기 위해서 SigLots를 어떻게 사용하는지에 대해 정리한다. 간단한 예를 드는 것이 효과적이겠지만 내 스스로의 매뉴얼이기도 하기 때문에 사용 예가 좀 길어졌다. 소스 중간에 comment를 참조하면 도움이 될 것이다. 여기서 잠깐 Signals & Slots가 뭐 그리 대단한 놈이냐를 다시 짚어 볼 필요가 있다.

Signals & Slots 굳이 필요할까...

객체지향 언어의 장점은 소스에 대한 재사용성이 높다는 것인데, c++의 실상은 그렇지 못할 가능성이 매우 높다. 기업들이 내부 시스템 개발에 Java를 많이 채택하게 된 이유가 재사용성과 유지보수 편리성 때문이다. 물론 c++ 언어는 복잡하고 어렵기 때문에 채택하기 어려운 점도 분명히 있다. 2000년대 초에 등장한 Spring Framework이 Java의 입지를 굳히는데 큰 역할을 했다. Inversion of Control(IoC), Dependency Injection(DI), ... 등등 혁신적인 개념을 실현했기 때문에 시장을 주도하는데 10년도 걸리지 않았다. Qt는 1995년 처음 나왔는데 Signals & Slots를 언제부터 사용했는지는 모르겠다. Qt 도 두가지 개념을 Signals & Slots를 통해 실현했기 때문에 나름 성공했다. 하지만 오픈 소스로써의 Qt Project는 2011년 이후이기 때문에 Java가 이미 대세가 된 후의 일이었다. 아래의 예를 보면 Dependency Injection(의존성 주입)과 Inversion of Control의 개념을 이해할 수 있다.

관리자(DeviceManager)가 온도계(Thermometer)와 캠코더(Camcorder)를 관리하고 비상시 전화(Phone)를 사용해야 한다. 온도계와 캠코더는 비상시 Recorder를 통해 이벤트 이력을 저장한다. 온도계는 고온 감지시 alarm을 울리고 이력을 남긴다. 캠코더는 화재나 움직임을 감시하는데, 감지시 snapshot을 관련자 들에게 보내주고 이력을 남긴다. 코딩 관점에서는 비상상황이 발생했을때 온도계나 캠코더가 직접 119와 관리자에게 전화를 걸수도 있을 것이다. 하지만, 온도계와 캠코더가 원래 하는 일은 아니다. 아무튼 Signals & Slots가 없다면 온도계와 캠코더가 전화를 걸도록 코딩하는 것이 현실적인 대안이 될 것이다. 아니면, 좀 비효율적일 수 있지만 온도계와 캠코더가 관리자에게 비상 상황을 알려주고 관리자가 전화를 걸도록 해도 된다. 주목할 점은, 두 가지 방식 모두 전화기 정보나 관리자 정보를 온도계와 캠코더가 알고 있어야만 가능하다. 아래의 예에서 Signals & Slots를 사용했기 때문에 온도계와 캠코더는 자기 일만 하면 된다. 전화기나 관리자에 대한 의존성이 사라졌다는 의미다. 관리자 관점에서는 Signals & Slots를 통해 비상 상황을 관리할 수 있게 된다. 의존성이 주입되는 것이다. 이와 함께 온도계와 캠코더는 전화 번호를 바꾼다거나 관리자가 맘에 안든다고 바꿔달라고 할 근거가 없어졌다. 제어권이 관리자에게 가버렸다. 이것이 Inversion of Control 개념이다. 이 관리자는 결국 Framework이라는 괴물이 되었다.

Dependency Injection과 Inversion of Control이 가능해짐으로써 개발자 들은 객체 지향 언어의 장점을 극대화 할 수 있게 된다. Class 간의 의존성은 신경쓰지 않고 나중에 처리할 수 있기 때문이다. 이것이 Qt의 Signals & Slots가 c++ 역사에 의미를 갖는 대목이다. 처음 개념이 탄생했을때 표준 c++로 구현할 수 없었기 때문에 moc(meta object compiler)가 탄생될 수 밖에 없었다. 지금 내가 구현한 SigLots도 결국 c++ 비표준 방식까지 동원했음을 감안하면, Boost나 Qt 또는 아류의 오픈 소스 Signals & Slots를 사용하지 않는 c++ 소스들은 대부분 의존성 문제에서 자유롭지 못할 것이다.

Class 에서 SigLots 사용

아래의 사용 예가 좀 복잡해 보이지만, 자세한 설명이 다 들어 있다. 다만, 사용법을 쉽게 이해하기 위해 Qt의 세가지 확장 키워드(signals/slots/emit)를 다시 생각해 보자. 이것들이 필요한가? 이들은 단순한 #define macro 들이다.

#define signals protected
#define slots
#define emit

뭐 Qt 내부적으로 필요할지 몰라도 표준 c++ 관점에서는 불필요해 보인다. 하지만, 사용자들이 얻는 잇점은 상당히 크다. 언제 사용할지 확실하게 알려 주기 때문이다. SigLots에서는 macro를 사용하지 않기 때문에 이 3가지 키워드를 대신해 줄 방법이 필요하다.

일단, signals 키워드는 Signal member 변수 선언으로 커버가 된다. emit 키워드도 signal.emit(...)와 같이 Signal 객체를 사용하기 때문에 커버가 된다. slots 키워드는? 사실상 아무짝에도 쓸모가 없다. 모든 멤버 함수가 Slot이 될 수 있기 때문이다. 하지만 Signal에 연결될 멤버 함수를 따로 구분하고 싶다면 위의 slots macro를 갖다 써도 된다.

그런데, Qt와 근본적으로 다른 게 한가지 있다. Qt의 Signal은 멤버 변수가 아니라, 멤버 함수이다. 그럴 수 밖에 없는 이유가 있다. signals 키워드가 protected인 이유와 비슷한데, class 멤버 변수들이 맘대로 싸돌아 다니는 것을 막기 위한 것이다. 아래 예에서도 나타나지만, Signal 멤버 변수를 public으로 사용하면 사용하기는 편리하지만 보안 문제나 코드 변경시 관리상의 문제가 발생할 수 있다. 그래서 특히 public으로 Signal 멤버 변수를 사용하려면 getter() 함수를 만들어서 사용하는 것이 좋겠다. 이 때문에 helper connect() 함수 들이 필요하게 된다. helper 함수들을 사용하면 겉보기에 Qt 사용법과 똑같아 보인다. 하지만 c++은 Java와 다르기 때문에 반드시 모든 Signal 멤버 변수들에 대한 getter() 함수를 정의해서 쓰는 것은 바람직하지 않아 보인다. 그래서, 내가 생각한 방법은 class 내에서는 아래의 예에서와 같이 Signal 멤버 변수를 직접 사용하되, 이 변수가 Signal 변수라는 것을 알기 쉽도록 s_ 로 변수명을 지었다. 멤버 변수를 일반 변수와 구분하기 위해서 m_ 으로 시작하는 것과 같은 맥락이다. static member 변수 앞에 s_를 붙이는 경우도 있지만 Signal이든 static이든 특별하기 때문에 s_를 사용해도 큰 무리는 없다.

Boost signals2에서도 Signal이 멤버 변수인데, 이 점에서 SigLots와 사용법이 유사하다. 하지만 SigLots에서는 Slot connect시에 std::bind() 류의 함수를 사용하지 않는다. Qt처럼 표준 c++ 방식을 사용하면 된다. 아무튼 SigLots의 사용 방법은 표준 c++ 방식이기 때문에 사용 방법도 직관적이고 문제가 생기면 compiler가 바로 알려 주기 때문에 쓰는데 어려움은 없어 보인다. 우습지만 표준을 잘 지키도록하기 위해서는 표준을 깨야만 할 수도 있다. 표준의 역설이랄까...

SigLots 만의 고유 기능

우선, Signal과 Slot 사이의 연결 관리를 Slot ID로 해결하고 있다. 이와 함께 각각의 Slot 들을 index로 참조하여 Slot들의 실행 결과를 활용할 수 있게 하였다. Boost에서는 connection 객체를 만들어서 사용하던데 특별한 이유가 있는지는 모른다. 다만, 표준 c++ 방식으로 구현하기 위한 어쩔 수 없는 선택이었을지도 모른다.

그리고, Slot 객체들의 life cycle 관리를 Tracker plugin class를 통해서 자동화 할 수 있다는 점이다. 말 그대로 plugin이기 때문에 사용안해도 그만이다. 하지만 사용하지 않을 경우에는 connect() 한 Slot 들에 대해서는 disconnect()를 수동으로 해 주어야 할 경우가 반드시 생긴다. dangling pointer가 Signal container에 garbage로 남기 때문이다.

또 한가지는, Slot 객체가 std::shared_ptr인 경우에는 Tracker가 없어도 자동으로 life cycle 관리를 해준다. 이것은 shared_ptr를 사용하는 의미와 일치하도록 하기 위한 것이다.


#include "Signal.h"
#include <iostream>
#include <memory>

using namespace ZAPARY;

struct Phone //: public Tracker // Tracker is a optional plugin.
{
// Slots
  void call119() { std::cout << "Phone: call to 119...\n"; }
  void callManager() { std::cout << "Phone: call to Manager...\n"; }
  static void working() { std::cout << "Phone: hard working...\n"; } // static Slot
};

struct Recorder //: public Tracker // Tracker is a optional plugin.
{
  Recorder() { s_onEvent.connect(this, &Recorder::saveEvent); } // connect virtual Slot
  virtual ~Recorder() = default;
  // calling pure virtual methods in constructor or destructor may cause crash.
  virtual void saveEvent(const std::string&) {}; // Slot: virtual member function

protected:
// Signal member variable
  Signal<void(const std::string&)> s_onEvent; // base Signal
};

class Thermometer : public Recorder
{
public:
  // connect local Slots to local Signals.
  Thermometer() { s_highTempDetected.connect(this, &Thermometer::alarm); }
  void checkTemp()
  {
    m_temp = 40;
    if(m_temp > 30) {
      s_highTempDetected.emit(); // emit Signal
      s_onEvent.emit("Thermometer: checkTemp() - High temp detected. "); // emit base Signal
    }
  }
// Slots
  void alarm() const // Slot: const member function
  { std::cout << "Thermometer::alarm(): High temp attention: " << m_temp << '\n'; }
  virtual void saveEvent(const std::string& msg) // virtual Slot
  { std::cout << msg << "saveEvent() - Current Temp: " << m_temp << '\n'; }

// public Signal
  Signal<void()> s_highTempDetected;

private:
  double m_temp {0};
};

class Camcorder : public Recorder
{
public:
  Camcorder()
  {
    s_fireDetected.connect(this, &Camcorder::sendSnapshot);

    // Just for example : using Phone object here breaks the Dependency Injection rule.
    Phone phone; // local phone declared
    s_fireDetected.connect(&phone, &Phone::callManager);
    s_fireDetected.emit();
    s_fireDetected.disconnect(&phone); // local phone should be disconnected manually
    // If Phone class were inherited from Tracker, disconnect() would not be required here.
    // Tracker enables automatic deletion of garbage Slots.
  }

// Signal getter() for public use: note the return type - non-const Signal refernece
  Signal<void()>& motionDetected() { return s_motionDetected; }

  void checkFire()
  {
    std::string state = "Nothing happened";
    // do something to check fire...

    state = "Fire";
    if(state == "Fire") {
      m_snapshot = state;
      s_fireDetected.emit(); // emit Signal
      s_onEvent.emit("Camcorder: checkFire() - Fire detected. "); // emit base Signal
    }
  }
  void checkMotion(bool motion = false)
  {
    if(motion) {
      s_motionDetected.emit(); // emit Signal
      s_onEvent.emit("Camcorder: checkMotion() - Motion detected. "); // emit base Signal
    }
  }
// Slots
  void sendSnapshot() const { std::cout << "Camcorder::sendSnapshot(): Snapshot sent.\n"; }
  virtual void saveEvent(const std::string& msg) // virtual Slot
  { std::cout << msg << "saveEvent() - Fire snapshot: " << m_snapshot << '\n'; }
// public Signal
  Signal<void()> s_fireDetected;
private:
  std::string m_snapshot {"None"};
// private Signal
  Signal<void()> s_motionDetected;
};

class DeviceManager
{
public:
  DeviceManager()
  {
    // Dependency Injection(DI) is made here.
    // direct access to public Signal members - is it safe? it depends.
    m_camcorder.s_fireDetected.connect(&m_phone, &Phone::call119); // connect a shared pointer
    m_thermomter.s_highTempDetected.connect(&m_phone, &Phone::callManager); // Note: &mphone
    // By using the reference to a shared pointer, the connected Slot's life is
    // tracked automatically on SigLots. So disconnect() is not necessary even after
    // mphone.reset() is called somewhere.
    // Tracker is not required for removing garbage Slots of shared pointers on SigLots.
    // And the simultaneous usage of both Tracker and shared pointers is safe.

    m_camcorder.s_fireDetected.connect(&Phone::working); // connect static Slot

    // connect by helper connect() for non-public Signals : Signal getter() required here.
    // Qt style connect() using Signal getter()
    connect(&m_camcorder, &Camcorder::motionDetected, &m_camcorder, &Camcorder::sendSnapshot);

    // SigLots style connect() using Signal getter()
    // the Slot was connected just before, so duplicated and ignored here.
    connect(m_camcorder.motionDetected(), &m_camcorder, &Camcorder::sendSnapshot);

    // How about this style??? - more readble and natural!!!
    // connect(&m_camcorder.motionDetected, &m_camcorder.sendSnapshot);
    // This is your homework~!!!
  }

  void doWork()
  {
    for(bool done = false; !done; done = true) { // do never ending works - but just once here
      m_thermomter.checkTemp();
      m_camcorder.checkFire();
      m_camcorder.checkMotion(); // Signal is not emitted
    }
  }

private:
  std::shared_ptr<Phone> m_phone = std::make_shared<Phone>();
  Camcorder m_camcorder;
  Thermometer m_thermomter;
};

int main()
{
  DeviceManager devMan;
  devMan.doWork();
}

2018/09/28

Signals & Slots - 가칭 SigLots 기본 사용법


가칭 SigLots의 사용법을 정리한다. Boost Signals2 Tutorial을 보면 사용법이 비슷하므로 도움이 될 둣하다. Qt의 Signals & Slot 사용법도 비슷하다. 하지만, 구현방식이 다르고 구현이 안된 부분들이 있을 수 있기 때문에 3가지 모두 사용법이 다를 수 밖에 없다. Boost 사용자 들중에는 macro를 써서 Qt 방식을 흉내 내기도 하더라.

SigLots는 현재 Signal.h header file 하나뿐이라 이것만 include해서 사용하면 된다. 지원되지 않는 컴파일러가 많으니 큰 기대는 금물이다. 우분투를 비롯한 리눅스는 문제 없겠고, Windows에서도 Mingw로 시험해 봤는데 잘 굴러간다.

기본 사용법

아래 Hello World 에서 알 수 있듯이, Signal을 선언해서 Slot을 연결하고, Signal을 방출하면 Slot(여기서는 lambda) 함수가 실행된다. 연결을 끊는 것은 수동으로도 가능하고 프로그램 종료시 자동으로 해제된다.

#include "Signal.h"
#include <iostream>

using namespace ZAPARY;

int main()
{
  Signal<void()> s; // Signal 선언
  s.connect([] { std::cout << "Hello, "; }); // Slot#1 연결
  s.connect([] { std::cout << "world~!\n"; }); // Slot#2 연결
  s.emit(); // Signal 방출 ("Hello world~!" 출력)
  s.disconnect(); // 모든 Slot 삭제 - 자동으로 호출되므로 사용안해도 됨
}

모든 호출 가능한 함수 객체를 Signal에 연결할 수 있는데, Signal은 함수형 템플릿이므로 연결하려는 함수 객체 들은 동일한 signature를 가져야만 한다. std::function과 유사하지만 Signal은 다수의 함수 객체를 저장할 수 있는 container이다.

아래의 소스는 기본적인 사용법을 보여 주기 위한 것이다. 앞서 올린 소스를 좀더 사용자 관점에서 수정했다. SigLots를 구현하면서 lambda를 다루는 게 생각보다 쉽지 않다는 것을 알았는데 그 만큼 활용도가 높다는 뜻일 거다. 아무튼 lambda도 잘 굴러간다.

아래 예제에서는 한 개의 Signal에 언제든지 여러 종류의 함수 객체 Slot이 connect/disconnect 할 수 있고, 다양한 방법으로 disconnect 할 수 있으며, 특정 Slot을 block/unblock 할 수 있음을 보여 주고 있다. 또한, Signal container 내의 Slot 들을 for loop으로 일괄 처리할 수도 있고, Slot의 실행 결과 값을 활용할 수도 있다. 주의할 점은 Slot ID는 Slot 고유 식별자이므로 container index와는 다르다. container에는 수시로 Slot들이 채워졌다 사라질 수 있기 때문이다. 그래서 인덱스는 signal[i]로 표현하고, id를 참조할 때는 signal(id)로 참조한다. Boost Tutorial 보니까 Signal 호출시 signal(...)과 같이 사용하던데, SigLots에서는 signal.emit(...)를 사용하고, ()연산자는 id 참조용이다.

내 생각에는 signal.emit(...)를 사용하는 것이 명확한 의미를 전달하는 듯이 보인다. Qt의 영향일 듯... Qt는 아예 moc의 keyword로 emit를 사용하기 때문이다. Qt의 3가지 c++ 확장 키워드가 signals/slots/emit 이다. Qt creator 사용해 보면 완전히 c++ 키워드인줄 알게 만들 정도다. 아래의 예는 쉬운 사용법이고, class 객체 간의 Signals & Slots에 적용될 때 큰 힘을 발휘하게 된다. Qt가 UI 중심 framework이다 보니 Signals & Slots가 framework의 중심 축이될 수 밖에 없는 것도 사실이다. 그래서 Qt의 예제도 모두 class에서 시작한다.

class 에서 SigLots 사용법은 다음에 이어서 정리한다.

#include "Signal.h"
#include <iostream>
#include <functional>

using namespace ZAPARY;

double sum(int n, double x)
{
  double result = n + x;
  std::cout << "function sum: " << result << '\n';
  return result;
}

double product(int n, double x)
{
  double result = n * x;
  std::cout << "function product: " << result << '\n';
  return result;
}

int main()
{
  Signal<double(int, double)> s;

  int id_product = s.connect(&product); // +Slot #1: static function, Slot ID stored.

  std::function<double(int, double)> f_sum = &sum, f_product = &product;
  f_sum(1, 2);
  s.connect(&f_sum); // +Slot #2: std::function

  int num = 1000;
  auto lambda = [](int n, double x) {
    double result = n + x;
    std::cout << "uncaptured lambda: " << result << '\n';
    return result;
  };

  lambda(1, 2);
  s.connect(&lambda); // +Slot #3: uncaptured lambda with variable
  std::cout << "+++++ Signal emit: begin +++++\n";
  s.emit(1, 2);
  std::cout << "----- Signal emit: end   -----\n\n";

  std::cout << "***** Looping each Slots *****\n"; // a Signal is container of Slots
  double sum = 0.; // use each Slot's return value
  for(int i = 0; i < s.size(); ++i) sum += s[i].emit(1, i*10); 
  std::cout << "sum = " << sum << '\n';

  s.disconnect(&f_sum); // -Slot #2, disconnect by variable name
  std::cout << "+++++ Signal emit: begin +++++\n";
  s.emit(1, 1);
  std::cout << "----- Signal emit: end   -----\n\n";

  s.disconnect(id_product); // -Slot #1, disconnect by stored Slot ID
  std::cout << "+++++ Signal emit: begin +++++\n";
  s.emit(2, 2);
  std::cout << "----- Signal emit: end   -----\n\n";

  s.connect([num] (int n, double x) // +Slot #4: captured lambda without variable
  {
    double result = n + x + num;
    std::cout << "captured lambda: " << result << '\n';
    return result;
  });
  int id_fp = s.connect(&f_product); // +Slot #5: std::function, Slot ID stored
  std::cout << "+++++ Signal emit: begin +++++\n";
  s.emit(3, 3);
  std::cout << "----- Signal emit: end   -----\n\n";

  std::cout << "Current # of Slots = " << s.size() << ",
               f_product id = " << id_fp << "\n\n";

  s(id_fp).block(); // blocking a Slot by using the stored Slot ID
  std::cout << "+++++ Signal emit: begin +++++\n";
  s.emit(4, 4);
  std::cout << "----- Signal emit: end   -----\n\n";

  std::cout << "Current # of Slots = " << s.size() << ",
               f_product id = " << s[s.size()-1].id() << "\n\n";

  s(id_fp).unblock(); // unblock the blocked Slot by using the stored Slot ID
  std::cout << "+++++ Signal emit: begin +++++\n";
  s.emit(5, 5);
  std::cout << "----- Signal emit: end   -----\n\n";
}

2018/09/18

Signals & Slots - dangling pointer 예방


이전 글에 이어서 여기서는 Signals & Slot에서 dangling pointer가 발생하지 않도록 하기 위한 방법을 구현해 보았다. 참고로, 객체 관점에서 보면 Signals & Slots란 용어 때문에 각각의 객체가 분리된 듯 느껴지는데 실상은 한 객체가 여러 개의 Signal과 여러 개의 Slot을 동시에 가질 수도 있다. 하나의 Signal에 여러 개의 Slot이 연결될 수 있고, 또한 특정 Slot이 여러 개의 Signal에 연결될 수도 있다.

기본적인 발상은, 특정 Signal에 Slot 들을 연결할 때 각각의 Slot 쪽에도 해당 Signal pointer를 저장하는 것이다. 그리고 나서 특정 Slot 객체 소멸시에 Signal 객체 내의 해당 Slot pointer를 삭제해 주면 된다. 그런데, Slot 쪽에 Signal 정보를 저장하면서 파생되는 문제가 있는데, 이제는 Signal 객체가 Slot 객체보다 먼저 소멸시에 Slot 쪽에 좀비 Signal pointer가 발생하게 된다. 즉, Signal 객체 소멸시에 연결된 Slot들에 저장된 Signal pointer도 삭제해야 한다.

이렇듯 Observer Pattern에서는 상호참조(circular reference) 문제가 발생하게 된다. Smart pointer 사용시에는 shared pointer만 사용시 서로 counter를 물고 있어서 pointer 삭제가 안되므로 한쪽은 weak pointer를 사용하여 문제를 해결한다. 하지만, 여기서는 raw pointer를 사용해야만 하기 때문에 Signal이든 Slot이든 객체 소멸시에 참조 pointer들을 삭제해 주면 된다.

Tracker class

Slot 객체에 Signal pointer를 저장하려면 저장소를 달아 주어야 하는데 기존의 class가 아래 소개한 Tracker class를 상속받으면 된다. Qt에서는 시조 할배 class인 QObject란 놈이 있는데 이런 시조 할배 류의 class에 매달아 놓으면, 대대 손손 모든 객체의 Signals & Slots 좀비 pointer들을 예방할 수 있게 된다. QObject는 사실 Tracker의 역할을 포함하고 있다.

참고로, Qt에서 Signals & Slots를 사용하려면 class 마다 QObject class를 상속하고 Q_OBJECT macro를 선언해 주어야 한다. macro는 moc(meta object compiler)에서 사용할 meta 정보에 대한 class member 변수와 함수들을 추가하기 위한 것이다. moc가 build시에 Signals와 Slots간의 연결 meta 정보를 생성한 후, 나중에 link하여 binary 파일을 생성하게 해준다. 덕분에 container를 사용하지 않음으로써 container 내부적으로 memory allocation/deallocation 시 발생하는 system call 성능 부담이 줄어든단다.

아무튼, Tracker를 상속받은 class들 간에 다중 상속을 한다면 그 class 들은 virtual public Tracker로 상속해야 한다. QObject의 경우도 마찬가지인데, 다중 상속시에 Diamond problem이 생기기 때문이다. 대부분의 경우, 다중 상속을 피하려 하기 때문에 굳이 virtual로 상속할 일은 거의 없을 것이다.

그리고, Tracker를 상속하지 않은 class 객체를 memory allocation해서 Slot으로 사용하면, 그 객체가 Signal 객체보다 먼저 소멸한 후 Signal을 발생시키면 crash가 날 것이다. 이것이 Tracker가 필요한 이유이다. Tracker의 virtual destructor가 dangling pointer들을 자동 삭제하도록 했기 때문에, 성능이 문제가 된다면 Tracker의 virtual destructor를 삭제하고 Slot 객체 소멸시 Tracker의 destroyTracker()를 Slot의 destroyed와 같은 Signal에 미리 connect해 주는 것도 한가지 방법이 될 수 있겠다.

그리고, shared_ptr를 사용하여 Slot을 Signal에 연결할 경우에는 Signals & Slots의 구조적인 문제때문에 weak_ptr를 사용할 수 없는데 weak_ptr를 사용하지 않고도 Slot 객체 소멸시 dangling pointer를 제거할 수 있는 방법을 시험해 보고 있는데 잘 된다. 그니까, Slot 연결시 shared_ptr를 사용하면 Tracker를 사용할 필요가 없겠다. 하지만, shared_ptr를 쓰지 않는 경우도 있으니까, Tracker와 같이 사용하는 것이 좋겠다. 일종의 garbage collector 역할이다.

Signal::connect()

이 Tracker의 저장소(m_tracker)는 다름아닌 Signal 객체이다. 특정 Signal에 Slot 객체를 연결하면 Signal에는 Slot 객체와 Slot으로 사용할 member function에 대한 pointer가 저장된다. 특정 Signal 객체에 Slot 객체를 연결(connect)시, Tracker가 달린 Slot 객체에 연결중인 Signal 객체와 이 놈이 이 놈에 연결된 Slot(Tracker)을  제거(disconnect)할 함수 정보를 저장했다가, Slot(Tracker) 객체 소멸시 연결된 모든 Signal 내의 Slot(Tracker) 정보를 삭제하도록 하면 되는 것이다. Tracker 입장에서 보면  Tracker 자신이 Signal이 되고, 여기에는 연결된 Signal과 그 Signal이 Tracker를 삭제하기 위한 방법이 곧 Slot 객체로써 저장된 셈이다. 그니까 원래 Signals와 Slots 객체를 구분하는 것이 아무 의미가 없다.

Qt에서는 객체 소멸시에 destroyed 란 Signal을 발생시킨다(emit). 나중에 객체 정리 작업에 필요한 Slot과 연결해서 사용할 수 있도록 하기 위한 것이다. 여기서 구현한 방법은 Tracker내의 m_tracker Signal이 바로 destroyed란 Signal에 해당하는 것이고, Slot에 해당하는 역할은, 단순히 연결된 Signal 들에 저장된 Tracker pointer 자신을 자동으로 제거하게 하는 것이다. Signal class의 connect() 함수 부분은 Tracker가 Slot 객체 class에 달려 있는지 compile 시에 check해서 Tracker 객체가 생성될 때만 Signal 정보를 Tracker에 저장하도록 한다.

Signal::disconnect()

 Signal 객체 소멸시에는 disconnect() 함수를 자동 호출해서 연결된 모든 Slot 객체를 제거하게 되는데, 그 전에 Tracker 객체가 존재하는 Slot에 대해서는 Tracker에 이미 저장된 Signal과 그 제거 방법을 호출하도록 해주면 된다. 문제는 Signal에 Slot 객체 pointer는 갖고 있지만 Tracker pointer는 갖고 있지 않다는 것이다. Slot이 Tracker를 상속받고 있으니까 reinterpret_cast든  c-style cast든 Slot 객체를 Tracker 객체로 casting 해주면 대개의 경우 문제가 없는데, 다중 상속인 경우 문제가 되더라. 특히 virtual 상속을 받는 경우 Tracker 주소를 못찾는다. 그래서 어쩔 수 없이 Slot의 Store class에 Tracker에 대한 pointer(m_ptr)를 저장하도록 함으로써 문제를 해결했다.

맺음말

설명과 소스까지 분리되어 있어서 내가 읽어도 뭔 소린지 모르겠네...

근본적으로는 표준 c++의 member function pointer를 일반 pointer 처럼 쓸수 있다면 좀더 쉽게 문제를 해결할 수 있겠지만 현실적으로는 불가능하다. 다만, 여기서는 gcc/g++ 처럼 고정 크기 member function pointer를 사용하는 compiler라면 상대적으로 쉽게 Signals & Slots를 구현할 수 있음을 알아낸 것으로 만족해야겠다. macro나 moc 같은 compiler가 없이 순수 c++ 언어로 Signals & Slots를 구현할 수 있지 않을까 하는 호기심이 풀렸다.

Boost Signals2 소스를 내려받아서 비교해 보려고 했는데 소스량도 많지만 한마디로 난독 코드다. Template만으로도 부족해서 macro language 그 자체다. Qt도 뭐 macro를 과도하게 쓰는 편이었는데 c++11 이후 macro 대신 c++ 언어 표준에 충실해 지려고 노력하는 티가 보인다.

아무튼 c++ 표준 방식은 아니지만 Signals & Slots에서 dangling pointer가 발생하지 않도록 하기 위한 방법까지 구현해 보았다. 여기서 참고할 점은 Tracker class는 일종의 plugin 처럼 동작한다. 그니까 Slot class가 Tracker class를 상속하든 안하든 전체 Signals & Slots는 정상 동작한다. Tracker plugin을 자동으로 탐지하고, 탐지가 되면 자동으로 객체 소멸시에 Signal 객체든 Slot 객체든 불필요한 좀비 pointer가 남지 않도록 해 준다.

Signals & Slot 관점에서 추가적으로 필요한 기능들이 많지만 이만 접겠다.
  • muti-thread 지원
  • Slot이 여러 개일 경우 return 값들에 대한 handler 지원
  • event queue 연계 - async Signals 지원
다만, 현재 구현된 것만도 쓰일 곳이 있으니까 사용법에 대해서도 나중에 정리할 예정이다. 사실, 여기 구현된 Tracker 자동화에 이미 Signals & Slots가 활용되고 있으니까 소스를 잘 쳐다보면 어떻게 사용하는지도 알 수 있긴 하겠다.


// Signals & Slots C++ Implementation
//
// This program is copyright (c) 2018 by Umundu @ https://zapary.blogspot.com .
// It is distributed under the terms of the GNU GPL version 3, as detailed in
// https://opensource.org/licenses/gpl-3.0.html .

// 추가된 Class
class Tracker;

class Store
{
  ......
protected:
  // 추가된 member 변수
  Tracker* m_ptr {nullptr};     // pointer to a Tracker for this Slot
};

template<typename TR, typename... TArgs>
class Slot : public Store
{
  ......
  // 추가된 함수들
  Tracker* ptracker() const { return m_ptr; }
  void setPTracker(Tracker* ptr) { m_ptr = ptr; }
  ......
};

template<typename T> class Signal;
template<typename TR, typename... TArgs>
class Signal<TR(TArgs...)>
{
  ......
  // 수정된 함수들
  template<typename TB, typename TO>
  void connect(TO* pobj, TR(TB::*pmf)(TArgs...));

  template<typename TB, typename TO>
  void connect(const TO* pobj, TR(TB::*pmf)(TArgs...) const);
  ......
  void disconnect();
  ......
  //추가된 함수
  void disconnect(TGenC* pobj)
  {
    if(!pobj) return;
    for(auto it = m_slots.cbegin(); it != m_slots.cend();) {
      if(it->object() == pobj || reinterpret_cast<TGenC*>(it->ptracker()) == pobj)
        it = m_slots.erase(it);
      else ++it;
    }
  }
  ......
};

// dangling pointer 예방을 위한 Slot plugin class - Slot에 대한 base class로 사용
class Tracker
{
public:
  Tracker() = default;
  Tracker(const Tracker&) = default;
  Tracker& operator=(const Tracker&) = default;
  Tracker(Tracker&&) = default;
  Tracker& operator=(Tracker&&) = default;

  virtual ~Tracker() { destroyTracker(); }

  Signal<void(TGenC*)>& tracker() { return m_tracker; }

  void destroyTracker() { m_tracker.emit(reinterpret_cast<TGenC*>(this)); }

private:
  Signal<void(TGenC*)> m_tracker;
};

template<typename TR, typename... TArgs>
void Signal<TR(TArgs...)>::disconnect()
{
  while(m_slots.size()) {
    if(Tracker *ptr = m_slots.back().ptracker()) {
      Signal<void(TGenC*)>& (Tracker::*ptmf)() = &Tracker::tracker;
      (ptr->*ptmf)().disconnect(reinterpret_cast<TGenC*>(this));
    }
    m_slots.pop_back();
  }
}

template<typename TR, typename... TArgs>
template<typename TB, typename TO>
void Signal<TR(TArgs...)>::connect(TO* pobj, TR(TB::*pmf)(TArgs...))
{
  if(!pobj) return;
  TypeSlot tmp(implicit_cast<const TB*>(pobj), pmf);
  if(contains(tmp)) return;
  m_slots.push_back(std::move(tmp));

  if(std::is_convertible<decltype(const_cast<TO*>(pobj)), Tracker*>::value) {
    Tracker* ptr = (Tracker*)(pobj); // only effective casting for multiple inheritances.
    m_slots.back().setPTracker(ptr);
    Signal<void(TGenC*)>& (Tracker::*ptmf)() = &Tracker::tracker;
    // static_cast<> is required for template type deduction of overloaded functions.
    (ptr->*ptmf)().connect(this, static_cast<TR(Signal<TR(TArgs...)>::*)(TGenC*)>
                                 (&Signal<TR(TArgs...)>::disconnect));
  }
}

template<typename TR, typename... TArgs>
template<typename TB, typename TO>
void Signal<TR(TArgs...)>::connect(const TO* pobj, TR(TB::*pmf)(TArgs...) const)
{
  if(!pobj) return;
  TypeSlot tmp(implicit_cast<const TB*>(pobj), pmf);
  if(contains(tmp)) return;
  m_slots.push_back(std::move(tmp));

  if(std::is_convertible<decltype(const_cast<TO*>(pobj)), Tracker*>::value) {
    Tracker* ptr = (Tracker*)(const_cast<TO*>(pobj)); // only effective casting...
    m_slots.back().setPTracker(ptr);
    Signal<void(TGenC*)>& (Tracker::*ptmf)() = &Tracker::tracker;
    // static_cast<> is required for template type deduction of overloaded functions.
    (ptr->*ptmf)().connect(this, static_cast<TR(Signal<TR(TArgs...)>::*)(TGenC*)>
                                 (&Signal<TR(TArgs...)>::disconnect));
  }
}

2018/09/14

c++ Signals and Slots 구현


이전 글의 Delegate 개념을 이용해서 Signals and Slots를 구현해 보았다. 구글링해 보면 Qt의 Signals and Slots를 구현해 보려고 했던 과거의 여러 시도들을 찾을 수 있다. Libsigc++이 대표적인 사례이다. 물론, Boost signals2/signals도 있다. 잘 굴러가는 바퀴들이 많은 데 잘 굴러가지 않을 가능성이 높은 바퀴를 또 만들고 싶은 것은 단순한 호기심 때문이다. 그 호기심의 근원은 저렇게 복잡하게 별의 별짓을 다해 가면서 구현한 방법들이 맘에 들지 않았고, 더 쉬운 방법이 있지 않을까 하는 것이다.

c++에서 가능한 모든 종류의 함수 객체(function object)들을 저장했다가 원하는 시점에 한꺼번에 호출할 수 있는 방법이 Signals and Slots이다. 근본적으로는 class 간의 coupling 문제를 해결해 주기 때문에 program 설계와 재사용 측면에서 매우 중요한 개념이기도 하다. 여기서, 함수 객체는 일반 static/global 함수와 class member 함수 뿐만 아니라, lambda(capture 포함), std::function이나 사용자가 만든 functor 등의 모든 호출 가능한 객체들을 의미한다.

앞서 Delegate에 대한 글에서 얘기했듯이 표준 c++에서 member function에 대한 pointer 크기가 가변적이므로 Delegate나 Signal & Slots를 표준 c++에서 구현하기가 매우 어렵다. c++ 언어의 장점이 막강한 유연성을 기반으로 구현 못할 것이 없는 언어인데 member function pointer에 대한 제약이 있다는 것은 굉장히 놀라운 사실이다. 물론, modern c++가 추구하는 바는 가능한 raw pointer를 사용하지 못하게 하는 것이긴 하지만, 언어 자체에 제약사항이 있는 것은 바람직하지 않다고 본다. 더구나 pointer가 없는 c/c++는 앙꼬빠진 찐빵이다. 추측컨대 MS를 비롯한 상용 컴파일러 Vendor들이 자신들의 컴파일러가 갖고 있는 제약사항이기 때문에 표준으로 채택하지 않았을 가능성이 매무 높다고 본다. 왜냐하면 gcc/g++나 clang에서는 표준이 아님에도 이미 고정 크기 member function pointer를 사용하기 때문이다. 이것이 중요한 이유는 모든 함수 객체를 쉽게 저장했다가 나중에 호출할 수 있기 때문이다.

이 글은 표준 c++ 방식은 아니지만 member function pointer를 저장하는 것이 Signals and Slots를 구현하는데 얼마나 편리한지를 보여주는 구체적인 예가 될 것이다. 컴파일 오류가 나면 컴파일러가 지원하지 않는다는 것을 알 수 있다.

모든 종류의 함수 객체를 저장하는 데는, 객체 자신에 대한 pointer와 class member function의 경우 이에 대한 pointer를 포함해서, 두 개의 저장소면 충분하다. 가령, static/global 함수는 객체 pointer에 저장할 수 있다. std::function이나 functor, lambda는 모두 class object와 동일하다.

c++은 strictly typed language이기 때문에 저장했던 pointer들을 원래의 type으로 변환해야 함수 객체들을 실행할 수 있는데, 같은 type에 대한 pointer의 크기가 동일하다면 generic class 객체의 pointer를 이용해서 원래의 함수 객체들을 호출할 수 있다. 이것이 reinterpret_cast를 사용하는 이유이고 또한 이를 사용할 경우 컴파일러에 대한 portability가 낮아질 수 밖에 없는 이유이기도 하다.

Signal 객체 하나는 동일한 arguments와 return type을 갖는 서로 다른 종류의 Slot 함수 객체들을 std::vector container에 저장한다. 얼핏 생각하면 서로 다른 종류의 함수 객체들을 std::vector에 저장하려면 c++17의 std::any 객체를 사용해야 하는 것이 아닌가 할 수도 있지만, template type으로는 모두 동일한 type이 되므로 std::any객체를 사용할 필요는 없다. std::set이나 std::unordered_set이 container로써 적합해 보이지만, Signal에 connect되는 순서에 따라 Slot 객체 들의 실행 순서가 결정되기 때문에 vector를 사용하되, 이미 container에 있는 객체는 다시 connect해도 중복으로 container에 저장되지 않도록 했다.

아래 소스에는 dangling pointer에 대해 고려하지 않았다. Signals & Slots도 Observer Pattern의 일종이라 생각할 수도 있는데, Signal에 연결된 Slot 객체가 Signal 보다 먼저 소멸했을때 Signal에 저장된 Slot pointer 들이 좀비가 되어 문제가 생길 수 있다. multi-thread를 사용하지 않는 한 크게 문제되지는 않겠지만, 여기서는 smart pointer를 사용하지 않는 방식이기 때문에 나중에 고려해 볼 예정이다.

아무튼 여기서 소개한 Signals & Slots의 장점은, code가 직관적이고, Slot 객체에 대해 복사 또는 복제하는 방식이 아니고 메모리를 직접 참조하므로 성능이 좋을 가능성이 높다. 단점은 c++ 표준 방식이 아니므로 compiler 호환성 또는 이식성이 떨어질 수 밖에 없다.


// Signals & Slots C++ Implementation
//
// This program is copyright (c) 2018 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 .

#include <vector>

class TGenC;

class Store
{
public:
  bool operator!() const { return !m_obj && !m_pmf; }
  bool operator==(const Store& rhs) const
  { return m_obj == rhs.m_obj && m_pmf == rhs.m_pmf; }

protected:
  using TGvPMF = void(TGenC::*)();

  TGenC* m_obj {nullptr};       // pointer to an object or a function
  TGvPMF m_pmf {nullptr};       // pointer to a member function
};

template <typename TR, typename... TArgs>
class Slot : public Store
{
private:
  using TypePF = TR(*)(TArgs...);
  using TypePMF = TR(TGenC::*)(TArgs...);
  template<typename TF>
  using TypeIfRValue = typename std::enable_if<!std::is_reference<TF>{}>::type;

public:
  Slot(TypePF pf) { bind(pf); }

  template <typename TB, typename TPMF>
  Slot(const TB* pobj, TPMF pmf) { bind(pobj, pmf); }

  template<typename TB, typename TPMF, typename = TypeIfRValue<TB>>
  Slot(TB&& pobj, TPMF pmf) { bind(std::forward<TB>(pobj), pmf); }

  TGenC* object() const { return m_obj; }
  TypePMF pmf() const { return reinterpret_cast<TypePMF>(m_pmf); }

  TR operator()(TArgs... args) const { emit(args...); }

  TR emit(TArgs... args) const
  {
    if(!m_obj) return;
    if(m_pmf) return (m_obj->*(pmf()))(args...);
    else return (*reinterpret_cast<TypePF>(m_obj))(args...);
  }

  void bind(TypePF pf)
  {
    static_assert(sizeof(TGenC*) == sizeof(pf), "Compiler unsupported.");
    m_obj = reinterpret_cast<TGenC*>(pf);
  }

  template <typename TB, typename TPMF>
  void bind(const TB* pobj, TPMF pmf)
  {
    static_assert(sizeof(TGvPMF) == sizeof(pmf), "Compiler Unsupported.");
    m_pmf = reinterpret_cast<TGvPMF>(pmf);
    m_obj = reinterpret_cast<TGenC*>(const_cast<TB*>(pobj));
  }

  template<typename TB, typename TPMF>
  TypeIfRValue<TB> bind(TB&& pobj, TPMF pmf)
  {
    static_assert(sizeof(TGvPMF) == sizeof(pmf), "Compiler Unsupported.");
    m_pmf = reinterpret_cast<TGvPMF>(pmf);
    m_obj = reinterpret_cast<TGenC*>(&pobj);
  }
};

template <typename T> class Signal;
template <typename TR, typename... TArgs>
class Signal<TR(TArgs...)>
{
private:
  using TypePF = TR(*)(TArgs...);
  using TypeSlot = Slot<TR, TArgs...>;
  template<typename TF>
  using TypeIfRValue = typename std::enable_if<!std::is_reference<TF>{}>::type;

public:
  Signal() = default;

  Signal(TypePF pf) { if(!*this) connect(pf); }

  template <typename TF>
  Signal(TF* pobj) { if(!*this) connect(pobj); }

  template <typename TB, typename TO>
  Signal(TO* pobj, TR(TB::*pmf)(TArgs...))
  { if(!*this) connect(pobj, pmf); }

  template <typename TB, typename TO>
  Signal(const TO* pobj, TR(TB::*pmf)(TArgs...) const)
  { if(!*this) connect(pobj, pmf); }

  ~Signal() { disconnect(); }

  template <typename TPF>
  Signal& operator=(TPF pf) { if(!*this) connect(pf); }

  template <typename TF>
  Signal& operator=(TF* pobj) { if(!*this) connect(pobj); }

  const TypeSlot& operator[](int i) const { return m_slots.at(i); }
  bool operator!() const { return m_slots.empty(); }
  bool operator==(const Signal& rhs) const { return m_slots == rhs.m_slots; }
  bool operator!=(const Signal& rhs) const { return !operator==(rhs); }
  void operator()(TArgs... args) const { emit(args...); }

  void emit(TArgs... args) const
  {
    for(auto it = m_slots.cbegin(); it != m_slots.cend(); ++it) (*it)(args...);
  }

  int size() const { return m_slots.size(); }

  bool contains(TypeSlot& slot)
  {
    for(auto it = m_slots.cbegin(); it != m_slots.cend(); ++it)
      if(*it == slot) return true;
    return false;
  }

  void connect(TypePF pf)
  {
    if(!pf) return;
    TypeSlot tmp(pf);
    if(!contains(tmp)) m_slots.push_back(std::move(tmp));
  }

  template <typename TB, typename TO>
  void connect(TO* pobj, TR(TB::*pmf)(TArgs...))
  {
    if(!pobj) return;
    TypeSlot tmp(implicit_cast<const TB*>(pobj), pmf);
    if(!contains(tmp)) m_slots.push_back(std::move(tmp));
  }

  template <typename TB, typename TO>
  void connect(const TO* pobj, TR(TB::*pmf)(TArgs...) const)
  {
    if(!pobj) return;
    TypeSlot tmp(implicit_cast<const TB*>(pobj), pmf);
    if(!contains(tmp)) m_slots.push_back(std::move(tmp));
  }

  template <typename TF>
  void connect(TF* pobj)
  {
    if(!pobj) return;
    TypeSlot tmp(pobj, &TF::operator());
    if(!contains(tmp)) m_slots.push_back(std::move(tmp));
  }

  template<typename TF>
  TypeIfRValue<TF> connect(TF&& pobj)
  {
    TypeSlot tmp(std::forward<TF>(pobj), &TF::operator());
    if(!contains(tmp)) m_slots.push_back(std::move(tmp));
  }

  template <typename TB, typename TO>
  void disconnect(const TO* pobj, TR(TB::*pmf)(TArgs...))
  {
    if(!pobj) return;
    TypeSlot tmp(pobj, pmf);
    for(auto it = m_slots.cbegin(); it != m_slots.cend(); ++it) {
      if(*it == tmp) {
        m_slots.erase(it);
        return;
      }
    }
  }

  template <typename TB, typename TO>
  void disconnect(const TO* pobj, TR(TB::*pmf)(TArgs...) const)
  {
    if(!pobj) return;
    TypeSlot tmp(implicit_cast<const TB*>(pobj), pmf);
    for(auto it = m_slots.cbegin(); it != m_slots.cend(); ++it) {
      if(*it == tmp) {
        m_slots.erase(it);
        return;
      }
    }
  }

  template <typename TO>
  void disconnect(const TO* pobj)
  {
    if(!pobj) return;
    TGenC* tmp = reinterpret_cast<TGenC*>(const_cast<TO*>(pobj));
    for(auto it = m_slots.cbegin(); it != m_slots.cend();) {
      if(it->object() == tmp) it = m_slots.erase(it);
      else ++it;
    }
  }

  void disconnect(TypePF pf)
  {
    if(!pf) return;
    TypeSlot tmp(pf);
    for(auto it = m_slots.cbegin(); it != m_slots.cend(); ++it) {
      if(*it == tmp) {
        m_slots.erase(it);
        return;
      }
    }
  }

  void disconnect() { m_slots.clear(); }

private:
  template <typename TO, typename TI>
  TO implicit_cast(TI in) { return in; }

  std::vector<TypeSlot> m_slots;
};