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();
}

댓글 없음:

댓글 쓰기