Существует два основных подхода к организации логики программы: опрос в суперцикле (superloop polling) и событийно-ориентированный подход (event-based, event-driven).
Polling #
Опрос - это когда мы вручную опрашиваем в суперцикле какие-то функции или методы объектов и ожидаем от них результат. Например - опрос кнопки или доступность Serial
для чтения:
void loop() {
if (buttonClick()) {
// обработка клика по кнопке
}
if (Serial.available()) {
// чтение данных
}
}
Это самый простой вариант, у него очень компактная реализация - нет лишнего кода и лишних действий в программе. В то же время он визуально занимает много места - если писать всю программу в loop
таким образом - она превратится в нечитаемое полотно из опросов и реакций на их результат.
Функция-обработчик #
Одно из решений - вынести всю логику обработки в отдельную функцию, которую потом можно будет перенести в отдельный файл для удобства:
void processClick() {
// обработка клика по кнопке
}
void loop() {
if (buttonClick()) processClick();
}
Получилась функция для обработки события (event) - клика по кнопке, функция будет вызвана, когда произойдёт событие. Такая функция называется обработчиком.
Callback #
Здесь мы всё ещё вручную опрашиваем кнопку и вручную вызываем обработчик, но во многих библиотеках предусмотрено подключение обработчика: можно указать, какую функцию нужно вызвать при наступлении события. Такая функция-обработчик, переданная объекту, называется callback - объект запоминает её к себе в память и вызывает при наступлении события.
Существует также термин event listener - это тоже функция-обработчик события, но событие в данном случае рассылается всем, кто его ожидает. Грубо говоря, callback - это когда начальник попросил подчинённого лично сообщить ему о выполненной работе. А event listener - когда работник сделал работу и написал об этом публично, и все кому это интересно - прочитают данную информацию, "подпишутся на рассылку"
Это может выглядеть примерно так (используется выдуманный класс кнопки, но мы напишем его позже в другом уроке):
// создание кнопки
Button btn;
void processClick() {
// обработка клика по кнопке
}
void setup() {
// подключение обработчика
btn.onClick(processClick);
}
void loop() {
// опрос кнопки, может отсутствовать
btn.tick();
}
Тикер #
В примере выше btn.tick()
- некий системный метод, который занимается обработкой кнопки, он должен вызываться в суперцикле как можно чаще - такие штуки часто называют тикерами. Это неблокирующая функция, которая занимается обработкой данных, переключением таймеров и машин состояний внутри себя и обеспечивает работу объекта. Тикера может и не быть - если опрос кнопки реализован на прерываниях (о них - в следующих уроках). Во многих библиотеках используется подобный механизм "тикер + обработчики" - в суперцикл помещается тикер, подключаются обработчики и оно работает.
Это удобно - позволяет разгрузить суперцикл и легко структурировать программу, сделать её более читаемой. Но есть и недостатки - на вызов функции тратится дополнительное время, а также нужно хранить указатель на обработчик в оперативной памяти
Некоторые языки, например JavaScript, изначально событийно-ориентированные - в нём нет суперцикла и не нужно его делать: всё работает на событиях и обработчиках. Но "под капотом" цикл всё же имеется (и даже называется event loop) - в нём точно так же проверяются события и вызываются подключенные обработчики, просто это полностью скрыто от программиста.
Свой класс с callback #
Напишем свой класс с поддержкой подключения обработчика:
class Foo {
public:
// подключить обработчик
void attach(void (*callback)()) {
_callback = callback;
}
// вызвать обработчик
void call() {
if (_callback) _callback(); // вызвать если подключен
}
private:
void (*_callback)() = nullptr; // указатель на обработчик
};
// обработчик
void func() {
// код...
}
int main() {
Foo foo;
foo.attach(func); // подключаем
foo.call(); // вызовется func
// можно через лямбду
foo.attach([]() {
// код...
});
foo.call(); // вызовется лямбда
}
Функции также могут быть с параметрами, тогда объект сможет отправить нам какие-то данные или даже себя - через указатель или ссылку. Для удобства и сокращения кода указатель на функцию можно обернуть в новый тип данных через typedef
или using
, это можно сделать прямо в объявлении класса:
class Foo {
// тип Callback - указатель на функцию (Foo&, int)
typedef void (*Callback)(Foo&, int);
//using Callback = int (*)(Foo&, int); // или так
public:
// подключить обработчик
void attach(Callback callback) {
_callback = callback;
}
// вызвать обработчик
void call() {
if (_callback) _callback(*this, 123); // отправляем себя и какое-то число например
}
private:
Callback _callback = nullptr;
};
// обработчик
void func(Foo& f, int val) {
// здесь f - объект foo, который вызвал обработчик
// val в данном случае придёт 123
}
int main() {
Foo foo;
foo.attach(func);
foo.call();
}
Передача самого объекта в обработчик по указателю или ссылке - тоже очень распространённый подход: он позволяет получить доступ к объекту, даже если тот находится вне области определения обработчика, то есть если создан динамически, находится в другом файле или объявлен локально, как в примере выше.