Полгода я собирал светящиеся штуки одним способом: скопировать блок «включить — подождать — выключить — подождать» и вставить в loop() нужное число раз. Четыре светодиода на ёлочной звезде — четыре копии. Сменить темп — четыре правки. А потом была ночь перед выставкой, когда я поправил задержку в трёх местах из четырёх: первые три светодиода замигали вразвалочку, четвёртый продолжал жить в старом ритме, и вся конструкция выглядела сломанной, хотя в каждой отдельной строке ошибок не было. Ошибка была в самом подходе: одна и та же операция лежала в коде четырьмя экземплярами.
С массивами мы уже выгребли половину этой беды: массив пинов из урока про массивы и журнал показаний заменил пять переменных led1..led5 одной полкой. Но массив лечит дубли данных, а не дубли действий: цикл по массиву всё равно тащит внутри себя одни и те же четыре строки digitalWrite-delay-digitalWrite-delay. Вторая половина лечения — функция: один блок кода с именем, который вызывается по имени сколько угодно раз и принимает значения — пин, длительность, что угодно. Дальше по скетчу вместо стенографии из строк появляется глагол: blinkLed(3, 150) — и читающему не надо разворачивать в голове, что там внутри.
План такой. Сначала анатомия: имя, параметры, возвращаемое значение, локальные переменные — и грабля со столкновением имён. Потом рефакторинг на живом скетче: три светодиода с копипастой ужимаются в функцию blinkLed(pin, ms), с честным подсчётом строк. Дальше — функции с ответом: readPercent() переводит показания потенциометра в проценты, а среднее по журналу из прошлого урока становится функцией averageOf. И обязательно тупики: что функцией сделать не получится и во что это обходится.
Функция: имя, параметры и ответ
Свои функции вы уже писали, не зная об этом: setup() и loop() из первого урока — те же функции, просто их вызывает сама плата. У любой функции три части: заголовок с типом и именем, параметры в круглых скобках, тело в фигурных. Строка void blinkLed(int pin, int ms) читается как договор: «зови меня blinkLed, дай два целых числа — номер пина и миллисекунды, отвечать я ничего не буду». Слово void и означает «ответа нет»: функция делает работу и молчит.
Второй вид — функции-вопросы: они считают и отвечают. Вместо void пишется тип ответа — int, float — и в теле появляется return: return sum / count; одновременно отдаёт значение и завершает функцию, строки после return не исполняются. Ответ кладут в переменную — int pct = readPercent(); — или сразу печатают, а дальше с ним работают как с обычным числом. Тип ответа — не украшение: компилятор сверяет, что return действительно отдаёт int, и ругается, если функция обещала число, а молчит.
Теперь локальные переменные и грабля, на которую я наступал дважды. Переменная, объявленная внутри функции, рождается при входе и умирает при выходе: int sum внутри averageOf снаружи не существует, и это хорошо — функция не рассыпает мусор по скетчу. Плохое начинается при столкновении имён. Пусть глобально объявлен int flashes = 0, а внутри функции вы пишете своё int flashes = 0 — это две разные ячейки: внутри функции слово flashes означает локальную, глобальная затенена и стоит нетронутой. Я однажды полчаса смотрел на мигающий ночник и не понимал, почему счётчик вспышек на экране не двигается: функция честно прибавляла к своей локальной копии. Отсюда правило, к которому я пришёл: функция не правит глобальные данные, а отвечает return-ом — тогда за данными следит одно место.
Рефакторинг: три копии в одну функцию
void setup() { pinMode(2, OUTPUT); pinMode(3, OUTPUT); pinMode(4, OUTPUT);} void loop() { digitalWrite(2, HIGH); delay(150); digitalWrite(2, LOW); delay(150); digitalWrite(3, HIGH); delay(150); digitalWrite(3, LOW); delay(150); digitalWrite(4, HIGH); delay(150); digitalWrite(4, LOW); delay(150);}Считаем в редакторе: было 27 строк — setup с тремя pinMode и loop с тремя копиями блока по шесть строк плюс разделители. Стало 18: функция из шести строк, setup без изменений, loop из трёх вызовов. На этой мини-задаче экономия смешная, минус восемь строк, и если бы дело было только в строках, функцию не стоило бы трогать. Но меняются другие вещи. Темп мигания правится одним числом: сменить 150 на 300 — это одна правка вместо шести, а раньше я пропускал одну из шести, и четвёртый светодиод жил в старом ритме. Шестой светодиод — плюс одна строка вызова вместо семи. А опечатка в digitalWrite, размноженная копипастой на три места, теперь размножиться не может: тело функции существует в одном экземпляре.
Параметры в скобках — заполнители. При вызове blinkLed(3, 150) число 3 попадает в pin, 150 — в ms, и тело исполняется с этими значениями; три вызова — три прохода с разными числами. Имена pin и ms выбраны не для красоты: ms подсказывает, что число в миллисекундах, и через полгода в чужом коде вы не будете гадать, секунды это или вольты. Внутри тела работают привычные инструменты из урока про циклы: захотите мигнуть трижды — добавите третий параметр int times и обернёте тело в цикл for. По-моему, главная награда — чтение: loop() превращается в список дел, «мигнуть вторым, мигнуть третьим, мигнуть четвёртым», и ни одна строка не зависит от соседней.
Отдельно про цену. Функция не бесплатна: каждый вызов кладёт параметры и адрес возврата на стек — для blinkLed это считанные байты из тех же 2048, что и у глобальных переменных, а процессор при 16 МГц тратит на сам переход такты, которых на мигании не разглядеть. Настоящая цена другая: плохо названная функция прячет код так же надёжно, как размноженная копипаста его размножала. Мне не нравятся имена вроде f1 или doWork: через месяц вы сами не вспомните, что там происходило. Если имя не отвечает на вопрос «что делает», сначала переименуйте, потом наслаждайтесь компактностью.
void blinkLed(int pin, int ms) { digitalWrite(pin, HIGH); delay(ms); digitalWrite(pin, LOW); delay(ms);} void setup() { pinMode(2, OUTPUT); pinMode(3, OUTPUT); pinMode(4, OUTPUT);} void loop() { blinkLed(2, 150); blinkLed(3, 150); blinkLed(4, 150);}| Признак | Пример | Что получаем |
|---|---|---|
| Блок кода повторяется дважды и чаще | digitalWrite-delay-digitalWrite-delay в трёх местах | одно место правки и один экземпляр ошибок |
| У действия есть имя словами | мигнуть светодиодом, проиграть ноту, посчитать среднее | loop() читается как список дел |
| Вычисление нужно в двух местах | среднее по журналу для Serial и для шкалы | формула живёт в одном экземпляре |
| Кусок хочется временно отключить | отладочная печать на пять строк | глушится один вызов вместо пяти строк |
Возвращаемое значение: функция отвечает числом
Мигнуть — действие без ответа: мигнул, и всё. Совсем другая работа — посчитать и ответить. Возьмём аналоговый вход из урока про потенциометр: analogRead(A0) отдаёт 0..1023, а шкале и человеку нужны проценты. Заводим функцию int readPercent() { int raw = analogRead(A0); int pct = map(raw, 0, 1023, 0, 100); return pct; } — и по всему скетчу вместо трёх строк формулы остаётся одно слово readPercent(). Пересчёт теперь живёт в одном месте: когда шкале понадобится диапазон 0..50, правка случится в одной функции, а не в пяти местах скетча.
С массивами функции работают через параметры. В уроке про зуммер массив нот кормил tone() — идеальный кандидат вынести исполнение в функцию playNote(int freq, int ms): одно действие, одно имя. А среднее по журналу из прошлого урока становится функцией int averageOf(int values[], int count): параметр values[] — сам массив, count — сколько ячеек брать. Вызов averageOf(journal, 8) заменяет цикл суммирования из пяти строк, и та же функция честно посчитает журнал из шестнадцати ячеек — count подставите при вызове. Заметьте: массив при таком вызове не копируется, функция ходит по оригиналу; на Uno с его 2048 байтами RAM это не лирика, а экономия настоящих ячеек.
Ответ функции — полноценное значение, и его можно кормить другим функциям и условиям: if (readPercent() > 50) зажигает ночник — функция-вопрос работает прямо внутри условия. Функции срастаются и друг с другом: заведите blinkPattern(), которая трижды вызывает blinkLed(), — большие скетчи растут именно так, из мелких глаголов, а не из копипасты. Я перестал бояться дробить: функция из трёх строк с честным именем читается быстрее, чем те же три строки, встроенные в чужой поток.
- Научите blinkLed мигать N раз: добавьте параметр int times, оберните тело в цикл for и проверьте вызовом blinkLed(13, 3, 250). Подвох — в порядке аргументов: сверьте порядок в объявлении и вызове, перепутанные пин и длительность не дадут ни ошибки, ни мигания.
- Вынесите исполнение мелодии в функцию playNote(int freq, int ms) и перепишите на неё партитуру из урока про зуммер: массив нот не трогается, меняется только способ проиграть ноту. Если мелодия «съехала» по темпу — ищите лишний delay() за пределами функции.
- Сломайте averageOf нарочно: уберите параметр count, зашейте деление на 8 и скормите функции журнал из 16 ячеек. Функция промолчит и вернёт неверное среднее — запомните эту цену: параметр, который кажется лишним, обычно страхует от молчаливой лжи.
- Напишите функцию-переводчик int codeToMs(int code): по коду от 0 до 3 возвращайте 100, 250, 500 или 1000 мс через лестницу if/else из урока про условия. Проверьте «мусорный» вход: что вернёт код 7? Договоритесь с собой, чем отвечает функция на значения, для которых она не задумывалась.
- Функция — блок кода с именем: void blinkLed(int pin, int ms); вызов blinkLed(3, 150) исполняет тело с подставленными значениями. Тело существует в одном экземпляре — ошибка не размножается копипастой.
- void — «ответа нет»; вместо него тип (int, float) обязывает вернуть значение return-ом: int pct = readPercent();. Строки после return не исполняются.
- Параметры — заполнители, локальные переменные живут только внутри функции. Локальная int flashes затеняет глобальную тёзку: это две разные ячейки, глобальная остаётся нетронутой.
- Массив передаётся функции параметром: int averageOf(int values[], int count) — функция ходит по оригиналу, без копирования. Вернуть массив из функции нельзя — это тема будущего урока про указатели.
- Кандидаты в функции: повторяющийся блок, действие с именем словами, вычисление в двух местах, кусок для временного отключения. Больше четырёх параметров — сигнал делить функцию надвое.
- 1. analogRead(A0) вернул 512. Что вернёт функция int readPercent() со строкой return map(raw, 0, 1023, 0, 100);?
- 2. Глобально объявлен int flashes = 0;. Внутри функции написано int flashes = 0; и дальше flashes = flashes + 1;. Что произойдёт?
- 3. Почему в averageOf(int values[], int count) размер журнала передан параметром, а не зашит числом 8?
- 4. Функция только мигает светодиодом и ничего не отвечает. Как правильно объявить её?
Частые вопросы по теме урока
- analogRead(A0) вернул 512. Что вернёт функция int readPercent() со строкой return map(raw, 0, 1023, 0, 100);?
- map() пересчитывает линейно: 512 из 1023 — это половина диапазона, значит 50 из 100. Ответ кладут в переменную или печатают: функция-вопрос отвечает значением, а не действием. 50 — середина диапазона 0..100
- Глобально объявлен int flashes = 0;. Внутри функции написано int flashes = 0; и дальше flashes = flashes + 1;. Что произойдёт?
- Локальная переменная затеняет глобальную тёзку на всё время работы функции: функция прибавляет к своей ячейке, а глобальная стоит нетронутой. Именно так счётчики «замирают», хотя код выглядит правильным. Прибавится к локальной копии — глобальная останется 0
- Почему в averageOf(int values[], int count) размер журнала передан параметром, а не зашит числом 8?
- Параметр отделяет алгоритм от конкретных чисел: одна и та же функция считает среднее по любому окну. Зашитая восьмёрка промолчит и начнёт врать, как только журнал вырастет, — вы это проверяли в эксперименте с 16 ячейками. Функция становится универсальной: сработает и по журналу из 8, и из 16 ячеек
- Функция только мигает светодиодом и ничего не отвечает. Как правильно объявить её?
- void читается как «ответа нет»: функция делает работу и молчит. Тип обязателен и стоит перед именем; если функция отвечает числом — там будет int или float. void blinkLed(int pin, int ms) — с телом в фигурных скобках