Введение

Концепция сигналов/слотов (Signals/Slots), т.е. асинхронных коллбэков (callback) широко применяется в различных программных архитектурах: от системного уровня до высокоуровневых многокомпонентных библиотек вроде GUI (например, QT) или игровых движков.

В общем виде идея заключается в следующем:

  1. Объекты A и B работают параллельно.
  2. Объект A регистрирует свой некоторый метод или анонимную функцию (callback-код) у объекта B. В момент регистрации объект A может передать также данные, которые должны быть доступны callback-коду.
  3. Объекты продолжают работу параллельно (без специальной взаимосвязи либо синхронизации).
  4. В определённый момент своей жизни объект B вызывает зарегистрированный код объекта A, передавая в качестве входных параметров указанные при регистрации данные и, может быть, некоторые собственные для B данные.
  5. Для объекта A такой вызов является асинхронным, т.е. нежданным; callback-код из A начинает работу и обрабатывает поступившие данные.

libsigc++

Известная библиотечка libsigc++ реализует именно это.

Подход libsigc++ вкратце таков:

  • имеется шаблонный класс сигнала под названием signal
  • такой параметризованный signal однозначно определяет сигнатуру, т.е. тип возномжного callback-кода (метода, функции, etc.)
  • первым параметром шаблона signal<> идёт тип возвращаемого коллбэком кода (чаще всего – void), далее – входные параметры по порядку
  • где-то имеется экземпляр класса (объект) signal
  • методом connect() в сигнале регистрируются любые подходящие по сигнатуре коллбэки: “безхозные” глобальные функции, методы объектов, static или virtual методы
  • когда кто-нибудь вызывает метод emit() этого сигнала, вызываются все зарегистрированные в сигнале коллбэки; соответствующие параметры вызывающий передаёт в emit()
  • разумеется, имеющие signal объекты могут давать доступ к своим личным сигналам другим объектам – так объекты подключают себя к и сигнализируют друг-другу
  • обо всём остальном заботится библиотека libsigc++

Пример применения libsigc++

В качестве иллюстрации простоты использования либы рассмотрим следующий код. Представим, что у нас есть класс Terminal читателей/писателей из/в стандартного ввода/вывода, а также класс Analyzer анализаторов строковых данных. Terminal умеет только работать с терминалом (потоками std::cin и std::cout), Analyzer умеет только работать со строками std::string. Имеется интерфейс ITerminal “терминальных сигналов”, который реализует Терминал. Анализатор инициализируется имплементором ITerminal и использует интерфейс для “вклинивания” в ввод/вывод.

#include <iostream>
#include <chrono>
#include <thread>

#include <sigc++/sigc++.h>

/*
 * Чистый "сигнальный" интерфейс к терминалам.
 * Подразумевается манипулирование работой терминала через сигналы sigc++.
 */
class ITerminal {
public:
  /* виртуальные методы, возвращающие сигналы (объекты signal<T,T1,T2,...>) */
  virtual sigc::signal<void, std::string> getInputLine() = 0;
  virtual sigc::signal<void, std::string> getOutputLine() = 0;
  virtual sigc::signal<void> getQuitLine() = 0;
};


/*
 * Терминал стандартного ввода-вывода.
 * Реализует ITerminal, для чего имеет приватные объекты signal<T1, T2, ...>
 */
class Terminal : public ITerminal {
private:
  sigc::signal<void, std::string> sigNewInput_;
  sigc::signal<void, std::string> sigOutput_;
  sigc::signal<void> sigQuit_;
  bool exiting_;

public:
  Terminal() : exiting_(false) {
    std::cout << "Terminal on-line." << std::endl;
  }

  void run() {
    std::string istr;
    
    /*
     * Подключаем к сигналу коллбэк: лямбда-функцию
     */
    sigOutput_.connect([](std::string ostr) {
      std::cout << ostr << std::endl;
    });

    /*
     * Подключаем лямбда-функцию с захватом this
     */
    sigQuit_.connect([&] {
      exiting_ = true;
    });

    while (! exiting_) {
      std::cin >> istr;
      
      /*
       * "Испускаем" сигнал со свежесчитанной строкой
       */
      sigNewInput_.emit(istr);
    }
  }

  // ITerminal interface
public:
  sigc::signal<void, std::string> getInputLine() override
  {
    return sigNewInput_;
  }
  sigc::signal<void, std::string> getOutputLine() override
  {
    return sigOutput_;
  }
  sigc::signal<void> getQuitLine() override
  {
    return sigQuit_;
  }
};


/*
 * Анализатор строк, работающий с любыми терминалами через сигналы sigc++.
 */
class Analyzer {
private:
  /* интерфейс терминала, к которому подключен данный анализатор */
  ITerminal* terminal_;

  void analyze(std::string s) {
    std::string news{s};

    /*
     * Трансформация строки в верхний регистр и задержка для красоты
     */
    std::transform(news.begin(), news.end(), news.begin(), ::toupper);
    std::this_thread::sleep_for(std::chrono::milliseconds(300));

    /*
     * Манипуляция терминалом через его сигналы signal<>
     */
    if (news == "EXIT") {
      terminal_->getOutputLine().emit("** Analyzer signals EXIT **");
      terminal_->getQuitLine().emit();
    }
    else
      terminal_->getOutputLine().emit(news);
  }

public:
  Analyzer(ITerminal* t) : terminal_(t) {
    /*
     * Регистрируем коллбэк: приватный метод.
     * С помощью sigc::mem_fun() передаём указатель на конкретный объект 
     * (в данном случае this, т.е. на нас самих) и ссылку на метод класса.
     */
    terminal_->getInputLine().connect(sigc::mem_fun(this, &Analyzer::analyze));
  }
};

Как видно, наш Анализатор просто-напросто преобразует сигнализированную Терминалом строку в верхний регистр и сигнализирует ему полученную строку обратно (на печать). Если Анализатор распознал специальную строку выхода (EXIT), он задействует другую сигнальную линию Терминала для “завершения работы” (quit) последнего.

int main() {
  Terminal* terminal = new Terminal;
  new Analyzer{terminal};

  terminal->run();

  return 0;
}

Имплементор ITerminal подключается к Анализатору в его конструкторе, поэтому указатель на объект класса Analyzer в нашем примере можно не сохранять.

Результат:

Terminal on-line.
> Hello, all!!
HELLO,
ALL!!
> I want to exit the terminal!!!
I
WANT
TO
** Analyzer signals quit **