Аквариумный стенд управлялся с ноутбука через Serial-консоль: «svet», «nasos», «korm», «temp»... Первые три команды жили в трёх ветках if — читалось терпимо. К двенадцатой loop превратился в лестницу на сотню с лишним строк, справки help не было вовсе, а добавить «температура раз в час» значило разрезать лестницу в середине и молиться, что ничего не задел. Каждый новый отклик стенда стоил дороже предыдущего — верный признак, что вместо кода мне нужна таблица.
Идея диспетчера помещается в три строки: struct Komanda { String imja; void (*deistvie)(); }; — имя и адрес функции-обработчика в одном типе. Таблица — массив таких структур. Диспетчер читает слово из Serial, находит его в таблице и вызывает deistvie(). Новая команда — новая функция и одна строка в таблице; loop при этом не меняется никогда. Указатели как механизм мы уже трогали в уроке про указатели и ссылки, struct как упаковку полей — в уроке про структуры и enum; здесь склейка, которая даёт расширяемость.
Разберу по порядку: синтаксис типа «указатель на функцию», который пугает всех ровно один вечер; таблицу и вызов по адресу; справку help, которая печатает сама себя; и границы — когда таблица уместна, а когда честнее лестница из if.
Указатель на функцию: имя без скобок
Запись void (*deistvie)() читается изнутри наружу: deistvie — указатель на функцию без параметров, возвращающую void. Присваивание простое: имя функции без скобок и есть её адрес — deistvie = komSvet; а вызов выглядит как обычный — deistvie(). Скобки у имени означали бы «вызвать и взять результат», без скобок — «взять адрес». Тот же механизм вы уже использовали сотню раз, не замечая: attachInterrupt(digitalPinToInterrupt(2), obsluga, FALLING) принимает обработчик именно указателем на функцию — мы просто завели собственное поле в структуре, куда его можно класть.
typedef убирает страшноту из объявлений: typedef void (*Handler)(); — и дальше Handler это обычное имя типа: таблица из структур описывается без звёздочек и скобок в каждой строке. Указатели на функции с параметрами устроены так же: void (*h)(int) принимает int, и в таблице могут жить команды с аргументами — «svet 30» разбирается диспетчером на слово и число, число уходит обработчику. Приём чтения числа из той же строки Serial разобран в уроке про parseInt и числа из Serial.
Массив указателей на функции можно завести и без структуры — для пары кнопок хватит: void (*knopki[2])() = { komSvet, komNasos };. Скобки вокруг (*knopki[2]) обязательны: квадратные скобки в C++ связывают крепче звёздочки, и без обрамления компилятор прочитает void *knopki[2] как массив указателей на void. Дальше — загадочная ругань про невозможность преобразования там, где вам мерещится обычная функция. У меня на второй день захотелось держать рядом с адресом слово-имя, и два параллельных массива переехали в struct: одно поле — имя, другое — адрес.
switch по строке, каким просится писать диспетчер, в C++ не существует: метки case требуют целую константу, компилятор отвечает «switch quantity not an integer» и отказывается собирать. Отсюда развилка приёмов: команды нумеруют и гоняют switch по номеру — либо кладут адреса обработчиков в таблицу, как здесь. Таблица выигрывает у нумерации тем, что слово из консоли не надо вручную превращать в номер: сравнение и вызов живут в одном цикле, параллельного списка кодов, который надо не забывать синхронизировать, не заводится.
Одно правило дисциплины: обработчики короткие и одинаково устроенные — сделали дело, напечатали ответ, вышли. Никаких delay внутри обработчика: диспетчер при этом не читает новые команды, консоль вяжнет, и пользователь шлёт команду второй раз, ухудшая всё. Долгие дела — включить свет на полчаса — обработчик не выполняет, он лишь переворачивает признак, а время считается в loop на millis.
typedef void (*Handler)(); // тип: указатель на функцию void без параметров struct Komanda { String imja; // слово из консоли Handler deistvie; // что вызывать}; const int PIN_SVET = 12;const int PIN_NASOS = 9;boolean svet = false;boolean nasos = false; void komHelp(); // прототип: справка печатает таблицу, объявленную ниже // --- обработчики: короткие, без delay, каждый отвечает в консоль ---void komStatus() { Serial.print(F("STATUS: svet=")); Serial.print(svet ? F("vkl") : F("vykl")); Serial.print(F(", nasos=")); Serial.println(nasos ? F("vkl") : F("vykl"));} void komSvet() { svet = !svet; digitalWrite(PIN_SVET, svet ? HIGH : LOW); Serial.println(svet ? F("svet vkl") : F("svet vykl"));} void komNasos() { nasos = !nasos; digitalWrite(PIN_NASOS, nasos ? HIGH : LOW); Serial.println(nasos ? F("nasos vkl") : F("nasos vykl"));} void komHelp() { Serial.println(F("KOMANDY:")); for (int i = 0; i < N_KOMAND; i++) { Serial.print(F(" - ")); Serial.println(TB[i].imja); }} // --- таблица: новая команда = функция выше + одна строка здесь ---const byte N_KOMAND = 4;Komanda TB[N_KOMAND] = { { "status", komStatus }, { "svet", komSvet }, { "nasos", komNasos }, { "help", komHelp },}; void setup() { pinMode(PIN_SVET, OUTPUT); pinMode(PIN_NASOS, OUTPUT); Serial.begin(9600); Serial.println(F("stend gotov, help - spisok komand"));} void loop() { if (Serial.available()) { String cmd = Serial.readString(); cmd.trim(); if (cmd.length() > 0) { byte naiden = 255; for (byte i = 0; i < N_KOMAND; i++) { if (TB[i].imja == cmd) { naiden = i; break; } } if (naiden != 255) { TB[naiden].deistvie(); // вызов по адресу из таблицы } else { Serial.print(F("neizvestno: ")); Serial.print(cmd); Serial.println(F(" (help - spisok)")); } } }}Смотрите, что получилось: loop вообще не знает про команды — он только ищет слово в таблице и вызывает адрес. Справка help строится циклом по той же таблице, поэтому физически не может отстать от списка команд. Добавление «korm» — это функция komKorm и строка { "korm", komKorm }: тридцать секунд работы, ноль правок в диспетчере. Именно за это я и люблю таблицу — скетч, в который не страшно вносить изменения, дороже скетча, который однажды идеально заработал.
Про память честно: имена в таблице — String, каждый держит свою копию в куче, «status» стоит порядка шести-семи байт. На Uno с четырьмя командами это мелочь; на тридцати командах уже сотня с лишним байт, и хочется экономии. Вариант первый — фиксированные поля char imja[8] вместо String: таблица становится куском статической памяти нулевой кучи, сравнение через strcmp. Вариант второй — имена во флеше через F() и strcmp_P: механика чтения из флеша разобрана в предыдущем уроке кластера, а таблицы во флеше с косвенным чтением указателей — в уроке про PROGMEM-таблицы. Причём тут подводный уровень: массив структур с указателями на функции сам может лежать в PROGMEM, но вызов по адресу тогда достаётся через pgm_read_word — это уже техника для плат, где каждый байт на счету.
Бюджет ATmega328 таблицу держит легко: указатель на функцию — два байта, имя-String с буфером — восемь-десять, тридцать команд умещаются в порядка четырёх сотен байт из двух килобайт RAM. Во флеше картина зеркальная лестнице: цикл поиска, сравнение и вызов написаны один раз и обслуживают хоть тридцать, хоть пятьдесят команд — новая добавляет только тело обработчика. В лестнице if каждая ветка тащит собственное сравнение строк, и код растёт с каждой командой, хотя делает то же самое. Дюжина команд таблицей у меня вышла на треть компактнее, чем та же дюжина ветками.
Хранить словарь команд можно не только во флеше. PROGMEM — когда имена зашиты раз и навсегда: перепрошил скетч — словарь новый. EEPROM — когда словарём владеет пользователь: переименовал «svet» в «lampa» с консоли — имя легло в энергонезависимую память и пережило выключение питания. Механика та же, что в уроке про меню настроек с записью в EEPROM: при старте имена читаются в таблицу, вызовы по адресам не меняются. Только помните про ресурс: около ста тысяч циклов записи на ячейку — переименование раз в неделю EEPROM переживёт, перезапись каждую секунду — повод вернуться к PROGMEM.
Когда таблица не нужна — тоже честно. Две-три команды, которые не будут расти: лестница if короче и читается без подготовки. А когда у команд много общего состояния и своей памяти — очереди, таймеры, собственные настройки, — наступает очередь классов: живой пример перехода от функции к объекту разобран в уроке про класс Button. Таблица выигрывает на отрезке от четырёх-пяти команд и везде, где список будет расти.
Расширение без страха: добавляем команду целиком
Процедура, которую я теперь делаю не задумываясь. Шаг один: функция-обработчик с именем вида kom<что-то>, короткая, с ответом в консоль. Шаг два: строка в таблице — слово и имя функции. Всё, шага три нет: help обновился сам, диспетчер не тронут, остальные команды не заметили соседа. Для сравнения, в лестнице из if та же операция выглядела как хирургия: вырезать кусок loop, вставить ветку, проверить, что не сломал соседние, перезалить. Разница не в красоте — в цене изменения, а цена изменения и есть главный критерий архитектуры.
Второй выигрыш обнаруживается через неделю: обработчики можно звать откуда угодно. Кнопке на корпусе я назначил команду status, таймеру раз в час — команду temp, и оба вызова свелись к одной строке TB[i].deistvie() — того же вызова, что делает диспетчер. Один код отвечает и консоли, и кнопке, и таймеру — три источника, один ответ, ноль рассинхронов. Так скетч постепенно превращается из последовательности веток в набор сервисов с общим входом.
И ещё одна дисциплинарная мелочь, которая спасает консоль: всё, что печатает обработчик, держите в одном стиле — сначала слово-эхо или STATUS, потом значения. Когда ответы из разных обработчиков свалены в журнал без ярлыков, через месяц по нему невозможно понять, кто напечатал строку. Ярлык из одного слова стоит ничего, а читается потом как отчёт.
Аргументы встраиваются в ту же схему без ломки конструкции. Меняем тип поля на void (*deistvie)(int), диспетчер режет строку на слово и число — «svet 30» — и зовёт обработчик с параметром: включить свет на тридцать минут. Разбор числа из той же строки уже разобран в уроке про parseInt; в таблице при этом меняется ровно одна строка — тип Handler. Когда команды начнут различаться сигнатурами, я смиряюсь и привожу их к одному виду: единообразие таблицы дороже красоты исключений.
| Диспетчер | Строк на новую команду | Справка help | Когда брать |
|---|---|---|---|
| Лестница if (cmd == ...) | ветка в loop плюс правка соседей | писать руками и не забывать обновлять | две-три команды, которые не растут |
| switch по номеру | один case | писать руками | команды фиксированы, имена заранее превращены в номера |
| Таблица struct + указатель | функция и одна строка таблицы | печатается циклом по таблице сама | от четырёх-пяти команд и везде, где список растёт |
- 1. Что записать в поле таблицы, чтобы диспетчер мог вызвать функцию?
- 2. Что нужно, чтобы добавить команду в таблицу диспетчера?
- 3. Почему таблицу команд с полями String не положишь целиком в PROGMEM?
- 4. Сколько RAM примерно съест String-имя status в таблице?
- 5. Когда лестница из if честнее таблицы команд?
- void (*h)() — указатель на функцию: имя без скобок есть адрес, вызов — как обычная функция; тот же механизм работает в attachInterrupt
- struct Komanda { String imja; Handler deistvie; } и массив таких структур — диспетчер: loop ищет слово и вызывает адрес, не зная про команды ничего
- Новая команда — функция плюс одна строка таблицы; справка help строится циклом по таблице и не может отстать
- Обработчики короткие, без delay: долгие дела переворачивают признак, время считает loop на millis
- Имена-String стоят по 6-7 байт кучи за слово: при десятках команд переходите на char-массивы фиксированной длины или strcmp_P
Частые вопросы по теме урока
- Что записать в поле таблицы, чтобы диспетчер мог вызвать функцию?
- Имя без скобок — адрес функции, он и кладётся в поле Handler. Скобки означали бы вызов прямо здесь и запись в таблицу результата, а не адреса. имя функции без скобок — это её адрес
- Что нужно, чтобы добавить команду в таблицу диспетчера?
- Диспетчер и loop не меняются никогда: пишете обработчик, добавляете ячейку { имя, функция }. Help строится по таблице и обновляется сам. функцию-обработчик и одну строку в таблице
- Почему таблицу команд с полями String не положишь целиком в PROGMEM?
- String выделяет память в куче при создании, поэтому объект с полем String живёт в RAM. Для флеша берут фиксированные char-массивы и strcmp_P или читают указатели через pgm_read_word. у String есть конструктор: такие структуры инициализируются в RAM
- Сколько RAM примерно съест String-имя status в таблице?
- String держит буфер длиной текст+1 плюс служебные поля. Для четырёх команд это мелочь, для тридцати — уже повод перейти на char-массивы фиксированной длины. порядка шести-семи байт: текст плюс терминатор
- Когда лестница из if честнее таблицы команд?
- На паре команд таблица — лишний слой: лестница короче и читается без подготовки. Таблица окупается от четырёх-пяти команд и там, где список будет расти. при двух-трёх командах, которые точно не будут расти