2018/10/20

Ubuntu 18.10 upgrade


(2019/01/31 추가) 

아래의 Nvidia 드라이버 문제는 다시 구글링 해 보니 쉽게 해결할 수도 있었다.

즉, /etc/gdm3/custom.conf 파일에서 Wayland를 사용하지 않도록 수정해서 저장하면 된다. 아래 내용처럼 해도 되는데 부팅 속도가 좀 느려지는 문제가 있었다.

아무튼, 이런 소소한 점이 문제이긴 하지만, 지금까지 우분투 18.10을 사용해 본 바로는 시스템 안정성이나 성능이나 UI나 매우 만족스럽다.

아래엔 비추천이었지만 강력 추천으로 바뀌었다.

--------------------------------------------------------------------------------------------------------------

어제(현지 시간 10/18) 우분투 18.10 우징어(Cosmic Cuttlefish)가 공식 출시됐다. 데스크탑에서 18.04와 크게 달라진 것은 없지만 Gnome memory leak에 대한 버그를 잡고 GUI 성능 개선에 공을 많이 들였단다. 아무튼 Clean install 보다 Upgrade가 나을 듯해서 업그레이드 했다. 전체 업그레이드 시간은 우분투 Main 서버에서 패키지 받는데 40분, 설치에 40분 해서 80분이 걸렸는데 국내 미러 사이트를 이용하면 1시간도 채 안걸릴듯 하다.

다만, Nvidia 드라이버 문제가 있어서 일반 사용자들에게 우분투 18.10을 추천하기는 어렵다. 이게 새로운 기능이면서 버그일 수도 있다.

우분투 18.10 데스크탑의 새로운 점들

사실은 테마가 Yaru로 바뀌어서 많이 달라진 것 처럼 보인다. Yaru는 일본어로 할 일(To do)이란 뜻이란다(Suru도 같은 뜻인데 친한 사람끼리 쓴다나...). 새로운 점들에 대한 자세한 사항은 우분투 18.10 Release Note를 참고하는 것이 좋겠다.
  • OpenSSL 1.1.1 적용 - TLS v1.3 지원
  • Linux kernel 4.18 탑재 - AMD Radeon, Raspberry Pi, USB 3.2 등 새로운 Hardware 지원
  • 개발 Toolchain 업그레이드 - gcc 8.2, clang 7.0, python 3.6.7/3.7.1 등
  • Gnome 3.30 - dynamic H/W detection, memory leak fix, GUI 성능 개선 등
  • 우분투 커뮤니티가 만든 새로운 테마 Yaru 적용 - 둥글어진 아이콘
  • 노트북 배터리 사용 시간에 대한 고려
  • 지문 인식 스캐너 지원
  • Snap 패키지 지원 강화 - Snapcraft store website를 이용한 sanp 패키지 설치 지원
  • 설치 및 부팅 시간 단축 - 새로운 압축 알고리즘(LZ4/ztsd) 적용
  • Firefox 63.0, LibreOffice 6.1.2 등 수많은 패키지 upgrade
우분투 설치 iso로 부팅/설치시 주의 사항

아래와 같이 initrd에서 예전에 initrd.lz를 사용했는데 그냥 initrd를 사용하는 것으로 바뀌었다.
menuentry "HDD Ubuntu 64-bit iso" {
   set isofile="/boot-isos/ubuntu-18.10-desktop-amd64.iso"
   loopback loop (hd0,9)$isofile
   linux (loop)/casper/vmlinuz boot=casper iso-scan/filename=$isofile noprompt noeject
   initrd (loop)/casper/initrd
}
하드디스크나 USB로 부팅시 부트 메뉴 엔트리를 위와 같이 수정해야 한다. 하드디스크로 부팅시에는 /etc/grub.d/40_custom 파일에서 위의 내용으로 수정해 주고 나서,

$update-grub

하고 재부팅하면 된다.

Nvidia 드라이버 문제

업그레이드 하고 나서 재부팅했더니 fsckd-cancel message...가 뜨면서 먹통이 됐다. 강제로 재부팅했더니 처음엔 문제가 없어 보였는데 혹시나해서 몇번 시험삼아 부팅했더니 똑같이 먹통이 반복된다. fsck가 문제인 줄 알고 복구 모드에서 fsck했더니 아무 문제가 없을 뿐만 아니라 결과도 금방 나온다. 그래서,  Nvidia 드라이버 문제라고 추정하게 됐고 nvidia site에서 410.66 버전을 받아서 390 기본 탑재 버전 지우고 재설치 해봤는데 여전히 먹통이었다.

역시나 30년 가까이 이어져 온 리눅스의 전통이자 매력은 삽질 중독증에 걸리게 만드는 것이다. 이전 글의 방법대로 nouveau 드라이버로 복구하고 나서 여러 가지 시험해 보기로 했다. 문제의 근본 원인은 커널 DRM 모드 셋팅이 제대로 이루어지지 않기 때문으로 추정된다. gdm을 띄우지 못하고 먹통이 되기 때문이다. 그래서 Wayland 사용시 설정했던 아래의 옵션을 사용해 보았다.

options nvidia-drm modeset=1

흠, 잘된다. 여러번 부팅해 봤는데 잘된다. 참고로 위의 옵션에서 modeset=0으로 하면 기본 Nvidia 설정으로 부팅하는 것과 같기 때문에 당연히 먹통이 된다. 단, 위의 설정은 update-initramfs 명령을 사용하기 때문에 우분투 복구모드로 진입하면 그래픽이 깨져서 아무것도 할 수 없는 문제가 생긴다. 그래서, 아예 커널 파라미터로 넣고 부팅하는게 낫겠다 싶었다.

/etc/default/grub 파일에서 아래의 내용과 같이 위의 옵션을 넣어 줄 수 있다.

GRUB_CMDLINE_LINUX_DEFAULT="quiet splash nvidia-drm.modeset=1"

grub 설정이 바뀌어야 하므로 저장한 후 아래의 명령을 해주고 재부팅해야 한다.

$ update-grub

이렇게 해서 Nvidia 문제가 해결됐다. 참고로 410.66 버전을 사용하고 있지만 기존의 390 버전에서도 문제가 없으리라 추정한다. 구글링해 보니 18.10 설치 후 Nvidia 사용자 들의 버그 리포트가 많이 보인다. 그리고, Nvidia 사이트에서 이와 관련된 단서를 하나 찾았는데 Prime 기능 지원에 대한 내용이다.

참고로, 18.04 내지는 17.10이후 <Ctrl>+<Alt>+<F3> ~ <F6>가 콘솔 모드로 진입하는 키이고 <Ctrl>+<Alt>+<F1> ~ <F2>키가 다시 GUI로 복귀하는 키 조합이 되었다.

그리고, 굳이 Nvidia 드리이버를 사용할 필요가 없는 사용자들은 nouveau도 충분히 빨라졌기 때문에 드라이버 설치에 시간을 허비할 필요는 없다. 더구나 Wayland를 사용하면 성능 향상이 눈에 띈다.

ibus 설정 참고 사항

시험삼아 VirtualBox에 우분투 18.10을 설치해 봤는데 설치/부팅 시간이 빨라졌더라. 기본 한글 입력기인 ibus 설정도 잘 된다.

Settings > Region & Language > Manage Installed Languages 를 click 하면 한글 언어 팩이 없그레이드 된다. Gnome Topbar의 사용자 계정을 click 해서 Log Out 했다가 재로그인 한 후,

Settings > Region & Language > Input Sources> [+] 를 click 해서 Korean(Hangul)을 추가해 주면 곧바로 한글 입력이 된다.

물론, fcitx도 잘된다.

기타 참고 사항

Gnome Shell이 3.30이 되면서 Gnome shell extensions가 문제가 될 수 있는데 내가 사용하는 두 가지 모두 처음엔 오류가 떴다.

Hide Top Bar는 다행히 update가 있어서 문제가 사라졌는데  Pixel Saver는 update가 없더라. 다행히, 해당 git 사이트에 갔더니 patch는 올라왔는데 Gnome 사이트에 반영이 안됐단다. 이슈 게시판에 해결책이 올라와 있어서 문제를 해결했다.

그리고, 우분투 18.04 이후 VirtualBox의 guest ubuntu 환경에서도 Wayland를 체험해 볼 수 있다(host 환경과는 무관함).

맺음말

우분투 18.10은 성능 향상과 새로운 테마가 특징이다. 하지만, 불안정한 부분이 남아 있기 때문에 일반 사용자들은 18.04 LTS를 사용하는 것이 좋겠다.

고급 사용자들에게는 Cannonical이 의도한 바는 아니지만, Nvidia 환경에서 Wayland를 사용해 볼 수 있는 기회가 생겼다. 하지만, 화면 잠금 모드로 들어가는 순간 먹통이 되는 문제는 여전하다. Wayland 환경에서 앱들은 하나씩 빠르게 적응해 가는데 상용 비디오 드라이버 지원 문제가 걸림돌이다.

2018/10/14

Signals & Slots - SigLots multithreading 지원


공부도 할겸해서 siglots에 multithread 기능을 넣어 보았다. 구현하면서 부딪치는 문제들을 해결하는 쏠쏠한 재미가 있다. 그 문제 들을 정리하면 아래와 같다.
  • single thread와 multi thread를 동시에 지원하려면? dummy mutex를 만들어서 일종의 template policy로 해결했다.
  • std::atomic_flag를 std::mutex 대신 써보면 어떨까? 기특하게 잘 돌아간다. Signal 객체가 기본적으로 multi thread로 동작하도록 하고 필요시 single thread를 사용할 수 있도록 했다.
  • siglots에 threadpool을 적용해 보면 어떨까? 잘 돌아간다. 덤으로 Qt의 Direct/Queued/ BlockingQueued Connection을 쉽게 구현할 수 있었다. Unique Connection은 siglots가 기본적으로 사용하는 방식이다. 다만 Auto Connection은 나중에 생각해 볼 여지가 있다.
  • Signal 객체에 multi thread를 지원하면서 mutex와 thread 객체를 포함하게 됐기 때문에 Signal 객체 자체는 copy/move가 안된다. 해결 방법은? 나중에 여기를 참고해서...
  • Qt의 QObject::moveToThread()에 대한 생각은? Tracker가 QObject 처럼 시조할배 노릇을 하게되면 구현할 수 있다. 하지만 siglots가 괴물 framework이 될 수도...
  • siglots의 threadpool에서 std::function과 std::packaged_task, std::future 등등을 사용할 필요가 있나? 시간이 되면 나중에 시도해 볼 수도...
  • single이든 multi thread이든 Slot 들의 실행 결과를 활용할 방법은? 필요시 검토...
  • clang++ 6.0.0에서는 lambda가 아무 문제 없는데 g++ 7.3.0에서는 lambda가 rvalue이면서 capture를 사용할때 문제가 생기더라. g++ 버그인가? 좀 찾아보니 lambda는 clang이 좀 안정적인듯... => 참고로, ubuntu 18.10의 gcc/g++ 8.2.0에서는 문제가 해결됐다.

Signal class에서 바뀐 주요 사항

multi thread를 기본으로 사용하면서 Signal class의 prototype이 아래 소스와 같이 바뀌었다. 따라서 single thread를 사용하려면 아래의 예와 같이 변수를 선언해야 한다.

Signal<void(int), SingleThread> signal_single; // for single-threaded Signal
Signal<void(int)> signal_multi;                          // for multi-threaded Signal (default)

아래 소스에서 LockGuard<TM>이 SingleThread/MultiThread의 template parameter 설정에 따라 dummy 또는 atomic mutex를 사용하게 된다. 그리고, 기존의 signal 변수에 SingleThread 옵션을 주면 connect() 함수의 muti thread 관련 설정들은 모두 무시해 버리기 때문에 안전하게 single thread로 실행할 수 있다. 즉, 기존의 multi thread 설정을 single thread로 바꾸려면, Signal 변수 선언 부분의 SingleThread 옵션만 주면 다른 소스 부분을 손댈 필요가 없다. 흠... 물론 Signal 변수 갯수가 밤하늘의 별처럼 많다면(?) 문제가 좀 있겠네... 사실은, 별볼일 없는 세상이고, single thread 신경 쓸 필요 없는 세상일 수도...???

소스에서는 ThreadPool에 thread 2개를 기본으로 사용하고 있지만 1개 이상이면 항상 multi thread를 사용하게 되는 셈이다. CPU core 수에 따라 적절히 조정하면 된다. ThreadPool은 내부적으로 thread queue 1개를 갖고 있다. main thread가 queue에 연결된 Slot들을 넣어 주면 ThreadPool 내의 thread 들이 알아서 일을 해준다. 주의할 점은, main thread는 이후에 자기할 일을 계속하는데, 일(Slots)을 맡겨 놓고 ThreadPool에서 일을 하고 있는 도중에 일을 없애(disconnect) 버릴 수도 있게 된다. 이러면 crash가 생길 수 있다. 그래서 소스에서 보듯이 disconnect() 함수에서 waitforThreads()를 이용해서 ThreadPool의 작업이 종료되지 않은 상황에서는 main thread가 기다렸다가 disconnect()를 수행하도록 하고 있다.

여기서, waitforThreads() 함수 사용시 각별한 주의가 필요하다. milli 초 주기로 ThreadPool이 Slot 실행을 완료했는지 확인하기 때문에 Slot 실행이 금방 끝나는 경우엔 효율적일 수 있지만, 예를 들어 30초 이상 작업 시간이 걸린다면 큰 문제가 될 수 있다. CPU 점유율 100%의 쓴 맛을 보게 될 것이다. std::condition_variable을 사용하는게 좋을 수도 있지만 dead lock 문제가 생길 수 있다. 아무튼 현재 조건에서 최선의 방법은, Slot이 30초 이상 작업을 해야 한다면, 다음에 설명할 BlockingQueued ConnectMode는 사용하지 말아야 한다. 그리고, disconnect() 함수들은 프로그램 종료시가 아니고서는 웬만하면 사용할 필요가 없기 때문에 큰 문제가 되지는 않는다.

connect() 함수에서 Slot의 ConnectMode를 옵션으로 설정할 수 있도록 했고, emit() 함수에서 ConnectMode 또는 SingleThread 여부를 판단해서 signals and slots가 원활히 동작하도록 해준다.

template<typename T, typename TM = MultiThread> class Signal;
template<typename TM, typename TR, typename... TAs>
class Signal<TR(TAs...), TM>
{
  ......
  // Waits for the threads in a ThreadPool to finish work.
  // Disconnecting Slots on which the threads are working may cause disaster.
  void waitforThreads()
  { while(m_threads.running()) std::this_thread::sleep_for(std::chrono::milliseconds(1)); }

  template<typename TO, typename TPMF>
  TypeID connect(TO&& pobj, TPMF&& pmf, ConnectMode mode = Queued)
  {
    LockGuard<TM> lock{m_mutex};
    TypeSlot slot(pobj, std::forward<TPMF>(pmf));
    if(!slot.object()) return 0;
    TypeID id = contains(slot);
    if(!id) {
      slot.setMode(mode);
      m_slots.push_back(slot);
    }
    attachTracker(pobj);
    addToMap(slot.object(), std::forward<TO>(pobj));
    return slot.id();
  }

  // Rvalue-lambda with capture shows abnormal behavior on gcc 7.3.0.
  // Just fine for all cases on clang 6.0.0.
  template<typename TO>
  TypeID connect(TO&& pobj, ConnectMode mode = Queued)
  {
    LockGuard<TM> lock{m_mutex};
    TypeSlot slot(pobj);
    if(!slot.object()) return 0;
    TypeID id = contains(slot);
    if(!id) {
      slot.setMode(mode);
      m_slots.push_back(slot);
    }
    addToMap(slot.object(), std::forward<TO>(pobj));
    return slot.id();
  }
 
  // Disconnects all the Slots connected to this Signal.
  void disconnect()
  {
    LockGuard<TM> lock{m_mutex};
    waitforThreads();
    while(!m_slots.empty()) {
      detachTracker();
      m_slots.pop_back();
    }
  }
  ......
private:
  mutable TM m_mutex;
  ThreadPool m_threads{2};
};

template<typename TM, typename TR, typename... TAs>
void Signal<TR(TAs...), TM>::emit(TAs... args)
{
  LockGuard<TM> lock{m_mutex};
  for(auto it = m_slots.begin(); it != m_slots.end();) {
    ......
    if(it->blocked()) { ++it; continue; }
    if(it->mode() == ConnectMode::Direct || it->mode() == ConnectMode::Auto ||
       std::is_same<SingleThread, TM>::value)
      //TODO: we can do somthing more with returned results if TR is not void.
      //TR result = (*it++).emit(args...);
      (*it++).emit(args...);
    else if(it->mode() & ConnectMode::Queued) {
      //TODO: we can do somthing more with returned results if TR is not void.
      //TR reult = m_threads.run(*it, args...).get();
      m_threads.run(*it, args...);
      if(it->mode() == ConnectMode::BlockingQueued) waitforThreads();
      ++it;
    }
  }
}

siglots에서 multi-thread 사용 예

아래의 소스를 참고하면 Slot ConnectMode가 어떻게 동작하는지 이해할 수 있다. Direct는 Signal에 연결된 Slot 함수를 직접 호출해서 실행한다. Queued는 thread queue에 Slot을 쌓아 놓고 실행되도록 한다. BlockingQueued는 Queued와 동일하지만 ThreadPool이 해당 Slot의 실행을 마친 후에야 Signal을 호출한 thread가 다른 일을 할 수 있다.

아래 소스를 실행해 보면, Signal 객체(sig) 자체가 main thread에서 생성되었기 때문에 single thread와 Direct 방식은 결과가 비슷하다. ThreadPool을 사용하지 않기 때문에 t1과 t2 thread가 일을 한다. BlockingQueued나 Queued는 main thread와 t1, t2 thread, 그리고 ThreadPool의 기본 thread 2개를 합쳐 5개의 thread id가 나타날 것이다. 이 두 방식의 차이는 t2 쓰레드 부분을 모두 comment 처리해서 실행해 보면 확연히 드러난다. 즉, BlockingQueued 방식을 사용하면 Slot 들의 실행 순서를 정해 줄 수 있게 된다.

#include "Signal.h"
std::mutex MX;
#define LOG(x) {std::lock_guard<std::mutex> l{MX}; std::cout << std::this_thread::get_id()\
                 << ' ' << __FUNCTION__ << '(' << __LINE__ << ")> " << x << '\n';}

using namespace siglots;

Signal<void(int, std::thread::id)> sig;
// Signal<void(int, std::thread::id), SingleThread> sig;

void emitN(int n)
{
  for(int i = 0; i < n; ++i) sig.emit(i, std::this_thread::get_id());
}

int main()
{
  auto fl = [](int i, std::thread::id tid) { LOG(i << " - emitter id: " << tid); };

  sig.connect(fl, Queued);
//  sig.connect(fl, Direct);
//  sig.connect(fl, BlockingQueued);
  std::thread t1{emitN, 20};
  std::thread t2{emitN, 10};
  LOG("main thread id: " << std::this_thread::get_id() << '\n');
  LOG("t1 id: " << t1.get_id() << ", t2 id: " << t2.get_id());
  t1.join();
  t2.join();
}

기타 multi-thread 사용시 참고 사항

앞서 multi-thread 환경에서 Slot이 실행되는 중에 그 Slot을 disconnect() 시키면 crash가 생길 수 있다고 했다. 이와 마찬가지로 Slot 객체를 new로 memory allocation 해서 Signal에 연결해서 사용하다가 어디선가 delete 해버리면 crash가 발생하게 된다. 이는 Signal에 국한된 문제는 아니다. 하지만, 이런 문제가 생기면 근본적으로 원인을 찾기가 어렵다. 특히나 multi-thread 환경에서는 엉뚱한 곳에서 crash가 발생하는 듯이 보이기 때문이다. 그니까 raw pointer는 잘 모르고 함부로 사용하면 안된다.

그리고, multi-thread 환경에서는 deadlock이 발생할 수 있는데 crash가 아니고, 말 그대로 프로그램이 멈춰서 동작하지 않는 경우이다. 흔한 경우는 한 thread가 lock을 걸어 놓은 상태에서 다시 lock을 걸 때인데, 함수 내에서 lock_guard를 사용할 때 조심해야 한다. 그 함수 내에서 호출하는 함수에 lock_guard를 다시 설정해 놓는 실수를 하게 된다. 또, 한 가지 흔한 경우는 두 개의 thread가 각각의 자원에 lock을 걸어 놓은 상태에서 상대방 thread의 자원을 요구하는 경우이다. 순환 참조 형태는 늘 문제를 유발시킨다. 순서를 정해 실행되도록 해야 한다.

끝으로, multi-thread 환경에서 공유 자원을 사용할 때는 lock을 사용하는 것이 원칙이지만, 이는 순서대로 thread들이 실행되도록 하는 것이므로 매우 비효율적인 것이기도 하다는 점이다. lock-free multi-threading이 이상적이지만 현실과는 괴리가 좀 있다. 가령, siglots의 ThreadPool이 실행 중인지를 main thread가 알고 싶을때 read-only로 running 상태만 알면 되기 때문에 굳이 lock을 사용할 필요는 없다.

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

2018/04/30

Ubuntu 18.04 LTS 설치


지난 주에 우분투 18.04 LTS가 배포됐다. 향후 5년간 지원될 버전이니 이것 만으로도 18.04로 갈아탈 충분한 이유가 될 수 있다. 다만, 17.10 사용자라면 17.10에서 많은 변화가 있었기에 18.04에서 달라진 걸 크게 느끼지 못할 것이다. 원래는 Upgrade하려고 Sofware Update를 실행했는데 뭐가 문제인지 부분 Upgrade를 시킬 것이란 경고가 떴다. 찜찜해서 그냥 하드디스크의 iso 설치 이미지를 이용한 Clean install을 감행했다. 그런데, iso 이미지로 부팅시 오류가 발생해서 부팅이 안된다. iso 이미지 파일 내의 리눅스 커널 명이 vmlinuz.efi에서 vmlinuz로 다시 바뀌었기 때문이다. efi를 지원하면서 vmlinuz.efi로 바뀌었었는데 이제 보편화됐다고 생각한 건지 다시 vmlinuz로 돌아왔다. 다만, Release Note에서 변경 사항이 누락된 점은 아쉽다.

우분투 설치 iso Grub boot menu entry 수정

아래의 예와 같이 Grub boot menu entry에서 linux (loop)/casper/vmlinuz.efi ... 부분을 수정해 주어야 한다. USB에서 우분투 설치 iso 파일을 사용해 설치할 경우에도 커널 명을 수정해 주어야 한다.
menuentry "HDD Ubuntu 64-bit iso" {
   set isofile="/boot-isos/ubuntu-18.04-desktop-amd64.iso"
   loopback loop (hd0,9)$isofile
   linux (loop)/casper/vmlinuz boot=casper iso-scan/filename=$isofile noprompt noeject
   initrd (loop)/casper/initrd.lz
}
하드디스크의 우분투 iso 파일을 이용하는 경우에는 grub.cfg 파일을 직접 수정하는 것이 아니므로,

$ sudo update-grub

명령을 실행한 후 재부팅해야 한다. 자세한 내용은 이전 글을 참고하는 것이 좋다.

우분투 18.04의 새로운 점들

리눅스 커널 4.15가 채택되었다. 보안 이슈 해결이나 새로운 하드웨어 지원을 위해 필요한 부분이다. 우분투 자체적으로는 커널 4.0 이후 지원되는 Kernel Live Patching 기능을 Ubuntu One을 통해 지원한다. 우분투 설치시에 사용할 지 물어 본다.

Wayland 대신 X 서버가 다시 기본 display server가 되었고 Wayland는 Option이 되었다. Wayland를 지원하는 앱들이 부족해서 LTS 버전에 적합하지 않다고 판단한 것이다. 원격 데스크탑이나 화면 및 비디오 캡춰 소프트웨어 등이 Wayland 환경에서 동작하지 않는 문제가 대표적이다. 안정성 측면에서도 아직 Wayland를 사용하기에는 이르다는 생각이다.

설치 시에 minimal install 옵션을 제공하는 것도 달라진 점이다. 설치 iso 이미지가 우분투 17.10은 1.5GB였는데 18.04는 1.9GB가 되었다. 아무튼 Web browser와 핵심 시스템 유틸리티만 설치된다고 한다.

Gnome Shell 3.28을 채택했다. 다만 File 관리자(Nautilus)는 구 버전을 customize 했는데 최신 버전에서 Desktop Icon을 지원하지 않기 때문이다. LTS 버전이라 사용자들에게 급격한 변화를 강요하지 않으려는 관점이 들어간 건데 Gnome을 채택했으면 그대로 가져다 쓰는 것이 더 낫다는 관점도 수용할 필요가 있어 보인다. Gnome 3.28은 Thunderbolt 3 기기들을 지원한다. Gnome Shell의 Ubuntu Dock extension에서 앱 아이콘을 click하면 앱이 실행되고 다시 click 하면 minimize가 안되는데 아래와 같이 설정하면 minimize 기능을 사용할 수 있다.

$ gsettings set org.gnome.shell.extensions.dash-to-dock click-action 'minimize'

그런데 위 명령은 오류가 발생했고, dconf-editor를 사용해서 위의 key 값을 변경해 주면 잘 된다.

Color 이모티콘(Emoji)을 지원한다. 우분투 17.10부터 흑백 이모티콘을 지원하기 시작했다. Characters 앱이 이전의 Character Map 앱을 대체했는데 Color 이모티콘을 보다 원활히 지원하려는 의도일 수 있다.

우분투 Software 앱에서 Snap 앱들에 대한 지원을 강화했다. Calculator, Charaters, Logs, System Monitors 앱들은 Snap 앱으로 설치된다.

$ ls -l /snap

이 밖에도 Calendar 앱에서 날씨 예보를 지원하고, To Do 앱이 추가됐다.

또한, 17.10 이후 systemd 로그를 지원하기 위해 Logs 앱이 System Log를 대체했고, Disk Usage Analyzer, Files(Nautilus), Remmina, Settings, Ubuntu Software 앱 들의 UI가 새로운 디자인으로 바뀌었다.

새로운 점들에 대한 더 자세한 내용은 우분투 18.04 Release Note를 참고하기 바란다.

한글 입력기 및 fonts

기본 입력기인 ibus를 사용하거나 fcitx-hangul을 설치해서 사용하면 된다. 우분투 17.10과 설치 방법은 동일하다. ibus 입력기의 경우 Gnome Top Panel에 한글 입력기를 표시하거나 제거하는 올바른 방법은 Settings > Region& Language > Input Sources > + 또는 - [버튼] 으로 Korean (Hangul)을 추가하거나 삭제하면 된다. 다만 fcitx-hangul을 설치할 경우 여전히 ibus 프로세스가 살아 있는 문제는 남아 있으므로 /usr/bin/ibus-daemon 파일을 rename해 줄 필요가 있다.

한글 폰트는 Noto Sans 폰트가 기본 폰트가 되면서 나눔 글꼴 패키지가 기본으로 설치되지 않더라. 아래와 같이 설치할 수 있다.

$ sudo apt install fonts-nanum fonts-nanum-coding fonts-nanum-extra

그런데 우분투 전반적으로 폰트가 커지고 bold체가 강해진 느낌이라 한글 Web Site들이 좀 불편해졌다. firefox 폰트 설정에서 "Allow pages to choose their own fonts, instead of your selections above" 옵션을 끄면 내가 설정한 폰트를 사용할 수 있다.


2018/01/04

c++ Delegate 내지는 Callback


바퀴를 발명하지 말라는 격언이 있지만 공부할 때는 바퀴를 다시 발명해 보는 것도 좋은 방법일 수 있다. Qt의 Signals & Slots를 c++에서 쉽게 구현할 수 있는 방법이 있을까 궁금해서 찾다가 15년이 다 돼가는 아주 오래된 글이지만 옷깃으로 눈물을 훔칠 만큼 감동적인 글을 보게 되었다.

FastDelegate 이란 건데 같은 codeproject 사이트에서 Delegate로 검색해 보니 FastestDelegate로 알려질 만큼 유명한 글이었다. 자세한 설명은 링크와 소스 코드를 보는게 좋다. c++11 버전으로 내가 이해할 수 있게 다시 각색해 보았다. 다만, 원래 소스는 대부분의 c++ compiler를 지원하지만 내가 각색한 소스는 g++에서만 동작할 수도 있다. 특히, 원저자가 발명한(?) horrible_cast는 해커들이나 쓰는 방식이다.  적어도 Delegate에 관한 한 원저자가 주장하듯이 표준 c++을 따르는것 보다 portable code가 더 중요하다는 관점에 동의하지 않을 수 없다. 참고로, 표준 c++을 따르는 Delegate에 관한 글과 이를 modern c++로 구현한 글도 codeproject 사이트에 올라와 있다. 표준 방식의 문제는 과도한 template 사용으로 인해 사용자 인터페이스가 많이 불편하다는 것이다.

문제의 근원은 표준 c++ class의 member function pointer가 일반 pointer와는 달리 특정한 address를 갖지 못한다는데 있다. 구글링하다 보니 누군가 c++20에 넣어 달라는 글도 보이긴 하더라. 표준 c++에 Qt의 Signals & Slots를 넣어 달라는 요청도 번번히 거절 당해 왔는데 그 이유는 표준 방식으로도 Observer Pattern이나 Delegate Pattern을 사용해서 구현할 수 있다는 것이었다. 실상은 Signals & Slots와 같이 범용적으로 쓰기에는 제약사항이 많다.

여기서 Delegate는 Callback 함수와 동일한 개념이다. 표준 방식으로 std::function을 이용해서 구현할 수도 있는데 memory allocation이 사용되는 경우 느려질 수 있고, Signals & Slots와 같이 event callback에 사용하기에는 어려운 점이 많다. 참고로 Qt는 meta object compiler(moc)를 사용해서 Signals & Slot을 구현했다. Qt framework을 사용할 수 없는 경우에 표준 c++로 Signals & Slots를 구현하기란 쉽지 않은 일인 것이다.

아무튼, FastDelegate를 이용하면 Signals & Slots를 한결 수월하게 구현할 수 있을 듯 하다. 참고로, FastDelegate은 일반 함수(static function)와 class member 함수의 Callback을 모두 지원한다. 다만, 당시에는 lambda나 variadic template이 표준 c++이 아니었는데 variadic template 지원 부분은 추가 되었고, lamda의 경우엔 capture가 없으면 사용할 수 있다. 가령, 아래의 예와 같이 사용할 수 있다.

// lambda example : no captures only
  Delegate<double(int, double)> d1([](int i, double x) -> double { return x + i; });
  Delegate<double(int, double)> d2, d3;
  auto lambda = [](int i, double x) -> double { return 2*x + i; };
  d2 = lambda;
  d3 = [](int i, double x) -> double { return x*x + i; };

  std::cout << d1(10, 3) << " " << d2(5, 6) << " " << d3(1, 1) << "\n";
// Rewrite FastDelegate.h by Don Clugston for g++ only(unportable).
// Original FastDelegate is portable to almost all the compilers.
// See http://www.codeproject.com/cpp/FastDelegate.asp for more information.

namespace HIDDEN
{

class TGeneric;
using TGenericP = TGeneric*;
using TGenericPvMF = void(TGeneric::*)();

class Memento
{
public:
  Memento() = default;
  Memento(const Memento& rhs) : m_object(rhs.m_object), m_pmf(rhs.m_pmf) {}
  Memento& operator=(const Memento& rhs)
  {
    m_object = rhs.m_object;
    m_pmf = rhs.m_pmf;
    return *this;
  }
  
  bool operator!() const { return !m_object && !m_pmf; }
  bool operator==(const Memento &rhs) const
    { return m_object == rhs.m_object && m_pmf == rhs.m_pmf; }
  bool operator<(const Memento &rhs) const
  {
    if(m_object != rhs.m_object) return m_object < rhs.m_object;
    return std::memcmp(&m_pmf, &rhs.m_pmf, sizeof(m_pmf)) < 0;
  }
  bool operator>(const Memento& rhs) const { return rhs.operator<(*this); }
  size_t hash() const
    { return reinterpret_cast<size_t>(m_object) ^ unsafe_cast<size_t>(m_pmf); }

protected:
  TGenericP m_object{nullptr};
  TGenericPvMF m_pmf{nullptr};
  
private:
  template<class TO, class TI>
  static TO unsafe_cast(TI in)
  {
    union { TO out; TI in; } u;
    u.in = in;
    return u.out;
  }
};

template<typename TGPMF, typename TPF>
class Closure : public Memento
{
public:
  TGenericP object() const  { return m_object; }
  TGPMF pmf() const  { return reinterpret_cast<TGPMF>(m_pmf); }
  TPF function() const { return horrible_cast<TPF>(this); }

  template<class TB, class TPMF>
  void bind(const TB* pobj, TPMF pmf)
  {
    static_assert(sizeof(TGenericPvMF) == sizeof(pmf), "Unsupported conversion");
    m_pmf = reinterpret_cast<TGenericPvMF>(pmf);
    m_object = reinterpret_cast<TGenericP>(const_cast<TB*>(pobj));
  }

  template<class TB, class TPMF>
  void bind(TB* pobj, TPMF pmf, TPF pf)
  {
    if(!pf) { m_object = nullptr; m_pmf = nullptr; return; }
    bind(pobj, pmf);
    m_object = horrible_cast<TGenericP>(pf);
  }

private:
  template<class TO, class TI>
  static TO horrible_cast(TI in)
  {
    union { TO out; TI in; } u;
    static_assert(sizeof(TI) == sizeof(u) && sizeof(TI) == sizeof(TO),
      "Unsupported conversion");
    u.in = in;
    return u.out;
  }
};

template<typename TR, typename... TArgs>
class DelegateImpl
{
  using TypePF = TR(*)(TArgs...);
  using TypePMF = TR(TGeneric::*)(TArgs...);

public:
  DelegateImpl() = default;
  DelegateImpl(const DelegateImpl& rhs) : m_closure(rhs.m_closure) {}
  template <typename TB, typename TO>
  DelegateImpl(TO* pobj, TR(TB::*pmf)(TArgs... args)) { bind(pobj, pmf); }
  template <typename TB, typename TO>
  DelegateImpl(const TO* pobj, TR(TB::*pmf)(TArgs... args) const){ bind(pobj, pmf); }
  template<class TPF>
  DelegateImpl(TPF pf) { bind(pf); }

  void operator=(const DelegateImpl& rhs) { m_closure = rhs.m_closure; }  
  template<class TPF>
  DelegateImpl& operator=(TPF pf) { bind(pf); }
  bool operator!() const { return !m_closure; }
  bool operator<(const DelegateImpl& rhs) const { return m_closure < rhs.m_closure; }
  bool operator>(const DelegateImpl& rhs) const { return !operator<(rhs); }
  bool operator==(const DelegateImpl& rhs) const
    { return m_closure == rhs.m_closure; }
  bool operator==(TypePF pf) const { return m_closure == pf; }
  bool operator!=(const DelegateImpl& rhs) const { return !operator==(rhs); }
  bool operator!=(TypePF pf) const { return !operator==(pf); }
  TR operator()(TArgs... args) const
    { return (m_closure.object()->*m_closure.pmf())(args...); }

  template <typename TB, typename TO>
  void bind(TO *pobj, TR(TB::*pmf)(TArgs... args))
    { m_closure.bind(implicit_cast<TB*>(pobj), pmf); }
  template <typename TB, typename TO>
  void bind(const TO* pobj, TR(TB::*pmf)(TArgs... args) const)
    {  m_closure.bind(implicit_cast<const TB*>(pobj), pmf); }
  template<class TPF>
  void bind(TPF pf) { m_closure.bind(this, &DelegateImpl::function, pf); }

private:
  template <class TO, class TI>
  static TO implicit_cast(TI in) { return in; }
  TR function(TArgs... args) const { return (*m_closure.function())(args...); }

  Closure<TypePMF, TypePF> m_closure;
};

} // namespace HIDDEN

template<typename T> class Delegate;
template<typename TR, typename... TArgs>
class Delegate<TR(TArgs...)> : public HIDDEN::DelegateImpl<TR, TArgs...>
{
  using TypeBase = HIDDEN::DelegateImpl<TR, TArgs...>;

public:
  using TypeBase::TypeBase;

  Delegate() = default;
  template <typename TB, typename TO>
  Delegate(TO* pobj, TR(TB::*pmf)(TArgs... args)) : TypeBase(pobj, pmf) {}
  template <typename TB, typename TO>
  Delegate(const TO* pobj, TR(TB::*pmf)(TArgs... args) const) : TypeBase(pobj, pmf) {}
  Delegate(TR(*pf)(TArgs... args)) : TypeBase(pf) {}
};

template <typename TR, typename... TArgs>
Delegate<TR(TArgs...)> makeDelegate(TR(*pf)(TArgs...))
  { return Delegate<TR(TArgs...)>(pf); }
template <typename TR, typename TB, typename TO, typename... TArgs>
Delegate<TR(TArgs...)> makeDelegate(TO* pobj, TR(TB::*pmf)(TArgs...))
  { return Delegate<TR(TArgs...)>(pobj, pmf); }
template <typename TR, typename TB, typename TO, typename... TArgs>
Delegate<TR(TArgs...)> makeDelegate(TO* pobj, TR(TB::*pmf)(TArgs...) const)
  { return Delegate<TR(TArgs...)>(pobj, pmf); }