View Categories

Polling и события

Существует два основных подхода к организации логики программы: опрос в суперцикле (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) - в нём точно так же проверяются события и вызываются подключенные обработчики, просто это полностью скрыто от программиста.

Синхронность и асинхронность #

Событийный подход с обработчиком также позволяет делать некоторые процессы асинхронными - например есть какая-то задача, которая выполняется относительно долго. Например отправка данных, ожидание ответа от другого устройства, подключение к веб-серверу, ожидание указанного времени и так далее. В синхронном подходе программа будет ждать завершения задачи и не перейдёт к выполнению остальной части программы. Условный пример:

void setup() {
    // синхронно подключаемся и получаем реузльтат
    bool res = connect("www.host.ru");  // ОЖИДАЕМ подключения
    if (res)... // здесь результат подключения известен
}
void loop() {
}

В асинхронном же мы ставим задачу, подключаем функцию-обработчик и программа работает дальше, а задача выполняется в "фоне". Как только она будет завершена - вызовется обработчик. Условный пример:

// обработчик
void handler(bool res) {
    // сюда придёт результат попытки подключения
}

void setup() {
    // ставим задачу и подключаем обработчик
    connect("www.host.ru", handler);
    // программа сразу переходит сюда, не дожидаясь подключения
}
void loop() {
}

Эти понятия часто встречаются в библиотеках, поэтому стоит их понимать и различать

Более реальный пример - обыкновенный delay, задержка. Это ведь тоже синхронная задача - ожидать время, чтобы потом перейти к выполнению кода. Например вывод слова Stop через 2 секунды после слова Start:

void setup() {
    Serial.begin(115200);
    Serial.println("Start");
    delay(2000);
    Serial.println("Stop");
}

void loop() {
}

В прошлом уроке мы как раз разбирали конструкцию асинхронного таймера. Как один из вариантов готового инструмента - библиотека GTimer, вот так с ней будет выглядеть "асинхронная" версия предыдущего примера с задержкой:

#include <GTimer.h>

GTimerCb<millis> tmr;

void onTimer() {
    Serial.println("Stop");
}

void setup() {
    Serial.begin(115200);
    Serial.println("Start");
    tmr.startTimeout(2000, onTimer);
}

void loop() {
    tmr.tick(); // тикер
}

Свой класс с callback #

Напишем свой класс с поддержкой подключения обработчика:

class Foo {
  public:
    // подключить обработчик
    void attach(void (*callback)()) {
        _callback = callback;
    }

    // вызвать обработчик
    void call() {
        if (_callback) _callback();  // вызвать если подключен
    }

  private:
    void (*_callback)() = nullptr;  // указатель на обработчик
};
// обработчик
void func() {
    // код...
}

void setup() {
    Foo foo;
    foo.attach(func);   // подключаем внешнюю функцию
    foo.call();         // вызовется func

    // можно через лямбда-функцию
    foo.attach([]() {
        // код...
    });
    foo.call();         // вызовется лямбда
}

void loop() {
}

Как не запутаться в скобках лямбды? Очень просто: лямбда - это квадратные/круглые/фигурные скобки - [](){}. Просто вставляем их в функцию подключения обработчика foo.attach( [](){} ); и переносим строку (Enter) внутри фигурных скобок

Функции также могут быть с параметрами, тогда объект сможет отправить нам какие-то данные или даже себя - через указатель или ссылку. Для удобства и сокращения кода указатель на функцию можно обернуть в новый тип данных через 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
}

void setup() {
    Foo foo;
    foo.attach(func);
    foo.call();
}
void loop() {
}

Передача самого объекта в обработчик по указателю или ссылке - тоже очень распространённый подход: он позволяет получить доступ к объекту, даже если тот находится вне области определения обработчика, то есть если создан динамически, находится в другом файле или объявлен локально, как в примере выше.

Дополнительно #

Полезные страницы #

0 0 голоса
Рейтинг статьи
Подписаться
Уведомить о
guest

0 комментариев
Старые
Новые Популярные
Межтекстовые Отзывы
Посмотреть все комментарии
Прокрутить вверх