Сигнализация гаража в версии 1.0 занимала 214 строк, и 46 из них — сплошной loop() с четырьмя вложенными if. Мигалка с дежурным ритмом, кнопка постановки и зуммер жили одним потоком сознания: глобальные с именами x1, t3, t4, магические 700, 3000 и 60 без единого комментария. Каждый из трёх механизмов по отдельности я умел писать ещё в уроке про мигалку — но собранные вместе в один loop() они превратились в кашу. Когда через полгода я захотел добавить второй зуммерный режим, я час искал, где именно пищит не то, — и нашёл лишнюю ветку, которая сбрасывала чужой таймер. Скетч работал; менять его было невозможно. Это разные вещи, и вторая дороже.
Рефакторинг — перекладка кода без смены поведения. Ниже живой случай: тот же скетч после четырёх шагов получает loop() на шесть строк, а каждая функция влезает в один экран. Правила: выделить функции, глобальные заменить параметрами, состояния собрать в enum, магические числа превратить в именованные константы. Millis-механику мигалок и неблокирующих пауз я подробно разбирал в уроке про millis() — здесь она берётся готовой, заняты мы раскладкой.
int x1 = 0; long t3 = 0, t4 = 0; void setup() { pinMode(2, INPUT_PULLUP); pinMode(12, OUTPUT); pinMode(8, OUTPUT);} void loop() { if (digitalRead(2) == LOW) { delay(20); if (digitalRead(2) == LOW) { x1 = !x1; tone(8, x1 == 1 ? 1500 : 800, 120); } } if (x1 == 1) { if (millis() - t3 > 700) { t3 = millis(); digitalWrite(12, !digitalRead(12)); } if (millis() - t4 > 3000) { t4 = millis(); tone(8, 2000, 60); } } else { digitalWrite(12, LOW); } // ... ещё 30 строк других обязанностей в том же loop()}Четыре шага перекладки
Шаг первый — функции: каждый законченный кусок поведения (антидребезг кнопки, дежурное мигание, тревожный бип) становится функцией с говорящим именем. Правило размера — один экран: функция, которая не влезает в 20-25 строк видимого окна, намекает, что внутри два поведения, и их пора разводить. Шаг второй — глобальные в параметры: функция получает то, что ей нужно, аргументами, а результат возвращает через return. Глобальными остаются только те переменные, что действительно живут между вызовами — таймеры и режим; всё остальное — локальное.
Шаг третий — состояния в enum: вместо int x1 со значениями 0 и 1 — enum Rezhim { SNYATO, OHRANA }; приёмы из урока про структуры и enum. Опечатка в имени валит сборку, а не чудит на железе. Шаг четвёртый — числа в константы: 700 становится MIG_PERIOD, 3000 — TRIVOIGA_PERIOD, и первая же правка «хочу медленнее» сводится к одной строке наверху файла. Ноль магических чисел в теле функций проверяется глазами за минуту.
Про порядок шагов, потому что он не случайный: функции — первыми, потому что после них видно, какие глобали на самом деле нужны. Я пробовал начинать с переименования глобалей и застревал: часть «переименованных» после выделения функций умирала, и работа уходила впустую. И про имена: функция — глагол, migajDezhurno(), proveritTrevogu(), smenaRezhima() — по имени слышно поведение, а не внутренности. Существительное в имени функции — почти всегда признак, что внутри прячется глобаль с тем же именем.
const int KNOPKA = 2;const int LED = 12;const int ZUMMER = 8; const unsigned long MIG_PERIOD = 700; // дежурное мигание, мсconst unsigned long TRIVOIGA_PERIOD = 3000; // между тревожными бипамиconst unsigned int DEB = 50; // окно антидребезга enum Rezhim { SNYATO, OHRANA };Rezhim rezhim = SNYATO; unsigned long migS = 0;unsigned long trivS = 0;byte tek = HIGH, stab = HIGH;unsigned long lastSmena = 0;int svet = 0; bool pressFront() { // true один раз на нажатие byte c = digitalRead(KNOPKA); if (c != tek) { lastSmena = millis(); tek = c; } if (millis() - lastSmena > DEB && c != stab) { stab = c; return stab == LOW; } return false;} void smenaRezhima() { if (!pressFront()) return; rezhim = (rezhim == SNYATO) ? OHRANA : SNYATO; tone(ZUMMER, rezhim == OHRANA ? 1500 : 800, 120); Serial.println(rezhim == OHRANA ? "ohrana ON" : "ohrana OFF"); trivS = millis();} void migajDezhurno() { if (millis() - migS < MIG_PERIOD) return; migS = millis(); if (rezhim == OHRANA) { svet = !svet; digitalWrite(LED, svet); } else { svet = 0; digitalWrite(LED, LOW); }} void proveritTrevogu() { if (rezhim == OHRANA && millis() - trivS >= TRIVOIGA_PERIOD) { trivS = millis(); tone(ZUMMER, 2000, 60); Serial.println("bip trevogi"); }} void setup() { pinMode(KNOPKA, INPUT_PULLUP); pinMode(LED, OUTPUT); pinMode(ZUMMER, OUTPUT); Serial.begin(9600);} void loop() { smenaRezhima(); migajDezhurno(); proveritTrevogu();}Обратите внимание на loop(): шесть строк вызовов — это карта скетча, которую вы читаете за пять секунд. Каждая функция начинается с раннего return: migajDezhurno() молчит, пока период не вышел, proveritTrevogu() молчит вне охраны. Вложенность нигде не превышает одного if — против четырёх в версии до. Имя функции отвечает, что она делает; как — видно внутри её одного экрана, и для этого не нужен комментарий.
Числа в таблице ниже я снял с обоих скетчей честно, до байта строк. Отдельно смотрится строка про RAM: перекладка не съела ни байта — функции компилируются в те же инструкции, а enum занимает те же два байта, что int до него. Скомпилированный код перекладки не оплачивает: вы платите только временем на правки, и это самая дешёвая инвестиция в скетч, которую я знаю.
Тест на качество раскладки простой: добавление поведения не трогает существующие функции. Второй зуммерный режим — одна новая функция и одна строка вызова в loop(); в версии-«до» та же задача стоила правок в пяти местах. Кстати, планировщик задач из отдельного урока вырастает из этого скетча естественно: у вас уже есть по функции на поведение, осталась общая очередь.
Функция pressFront() заслуживает отдельной оговорки: это тот же антидребезг на millis() с окном в 50 мс, что в классе Button из первого урока тройки, только развёрнутый в обычную функцию. Для прерываний та же механика существует в ISR-варианте — урок про антидребезг в прерывании — и в отреакторенном скетче её место ровно там же: одна функция, один экран, ноль зависимостей от соседей. А следующий виток для двух-трёх режимов — автомат состояний из урока про кнопку-автомат: там состояния переходят по событиям, и раскладка по функциям подготовлена заранее.
| Метрика | До | После |
|---|---|---|
| loop() | 46 строк, 4 вложенных if | 6 строк, ноль if |
| глобальные | 11 с именами x1, t3, t4 | 8 с говорящими именами |
| магические числа | 14: 700, 3000, 60, 20... | 0 — всё в константах наверху |
| добавить второй режим | правки в пяти местах | одна функция + одна строка в loop() |
| поиск «почему пищит не то» | час по всему loop() | один экран proveritTrevogu() |
Когда перекладывать рано — и когда поздно
Не рефакторьте то, что ещё не работает: сначала добейтесь поведения, потом раскладывайте. Перекладка сырого скетча — правка цели, которая ещё движется: вы дважды сделаете одну работу. Второе исключение — одноразовый скетч-пробник: проверил датчик, записал показания, выкинул. Если пробник живёт дольше недели — он перестал быть пробником, и тогда переносите раскладку: у меня пробники доживают до статуса «стоят в шкафу и работают» подозрительно часто.
И обратная граница: перекладывать поздно — значит, когда правка уже страхует дублирование. Мой сигнал — копипаста второго одинакового куска в loop(): это не экономия, а расписка в том, что поведению нужна своя функция. Ещё сигнал — имена с индексами: t2, t3, flag1. Как только появились суффиксы-номера, вместо них просятся параметры и массивы — или состояния из enum. Дешевле ловить эти сигналы на трёхстах строках, чем на трёх тысячах: перекладка масштабируется хуже, чем её запуск вовремя.
Мой календарный ориентир — правило трёх правок: если скетч пережил три содержательные правки и у каждой был поиск по всему файлу, время раскладки пришло. Первая правка — ещё смазка, вторая — звонок, третья — счёт к оплате. Сигнализация из первого абзаца получила этот счёт ровно на четвёртой правке, когда час поиска стал стоить дороже получаса перекладки. После раскладки четвёртая правка заняла двадцать минут — и половина из них ушла на свежий Serial-журнал, а не на блуждание по loop().
Как перекладывать, не сломав
Первое правило перекладки: страховка до правок. Я вешаю Serial-печать ключевых событий — смена режима, каждый бип — и фиксирую журнал версии-«до» на десятке нажатий. Потом перекладываю по одному шагу и после каждого сверяю журнал: строки должны совпасть символ в символ. Это дешевле любого стенда и ловит большую часть перекладочных промахов, включая мой любимый — перепутанный знак в условии раннего return.
Второе правило — по одному шагу за раз: выделение функций отдельно от переименования глобальных, enum отдельно от констант. Компилируйте после каждого шага: сборка между правками — непрерывный контроль. И не перекладывайте то, что не понимаете: если кусок кода тёмный, сначала выясните на том же Serial-журнале, что он делает, потом двигайте. Бюджет RAM после перекладки проверьте freeRam из урока про утечки — функции сами по себе RAM не едят, но лишние копии строк в логах едят быстро.
Когда функций становится десять и больше, скетч в одном файле перестаёт читаться — и IDE даёт вкладки: стрелка под Serial-монитором, новая вкладка, туда уезжает класс или пачка функций. Препроцессор Arduino склеивает вкладки в один файл при сборке, так что ни #include, ни прототипов не нужно — порядок объявлений решается сам. Мой потолок одной вкладки — примерно пятьсот строк; дальше раскладываю по смыслу: кнопки на одну, таймеры на другую. Это последний шаг раскладки, и он ничего не стоит: байтов столько же, сборка та же, а навигация по проекту превращается из прокрутки в выбор вкладки.
- Рефакторинг — перекладка без смены поведения; страховка — Serial-журнал версии-«до», сверяется после каждого шага
- Правило одного экрана: функция — 20-25 строк и одна обязанность; loop() после перекладки — карта из шести вызовов
- Глобальными остаются только таймеры и режим; всё остальное — параметры и return
- Состояния — в enum, числа — в именованные константы: правка периода сводится к одной строке наверху файла
- Добавление поведения не трогает существующие функции: новый режим — одна функция и одна строка вызова
- 1. С чего начинается безопасный рефакторинг рабочего скетча?
- 2. Какое правило размера для функции после перекладки?
- 3. Что в отреакторенном скетче делают числа 700, 3000 и 60?
- 4. loop() отреакторенной сигнализации занимает...
- 5. Что стоит добавление второго зуммерного режима в чистой раскладке?
Частые вопросы по теме урока
- С чего начинается безопасный рефакторинг рабочего скетча?
- Журнал ключевых событий версии-«до» фиксирует текущее поведение; после каждого шага перекладки строки должны совпадать. Это ловит промахи дешевле любого стенда. с Serial-журнала версии-«до» — страховки для сверки
- Какое правило размера для функции после перекладки?
- Функция, которая не влезает в один экран из 20-25 строк, почти всегда содержит два поведения — их разводят по разным функциям. один экран: 20-25 строк и одна обязанность
- Что в отреакторенном скетче делают числа 700, 3000 и 60?
- MIG_PERIOD, TRIVOIGA_PERIOD и длительности тонов вынесены в константы: правка периода — одна строка, тело функций свободно от магии. становятся именованными константами наверху файла
- loop() отреакторенной сигнализации занимает...
- После перекладки loop() — карта скетча: smenaRezhima(), migajDezhurno(), proveritTrevogu(). Логика живёт внутри функций по одному экрану. шесть строк вызовов функций
- Что стоит добавление второго зуммерного режима в чистой раскладке?
- Тест на качество раскладки: новое поведение не трогает существующие функции. В версии-«до» та же задача требовала правок в пяти местах. одну новую функцию и одну строку вызова в loop()