Сигналы и слоты: асинхронные, тип-безопасные коллбэки с libsigc++
Введение⌗
Концепция сигналов/слотов (Signals/Slots), т.е. асинхронных коллбэков (callback) широко применяется в различных программных архитектурах: от системного уровня до высокоуровневых многокомпонентных библиотек вроде GUI (например, QT) или игровых движков.
В общем виде идея заключается в следующем:
- Объекты
A
иB
работают параллельно. - Объект
A
регистрирует свой некоторый метод или анонимную функцию (callback-код) у объектаB
. В момент регистрации объектA
может передать также данные, которые должны быть доступны callback-коду. - Объекты продолжают работу параллельно (без специальной взаимосвязи либо синхронизации).
- В определённый момент своей жизни объект
B
вызывает зарегистрированный код объектаA
, передавая в качестве входных параметров указанные при регистрации данные и, может быть, некоторые собственные дляB
данные. - Для объекта
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 **