Скетч новогодней гирлянды разросся до трёх одинаковых кусков: каждому окну — свой блок мигания со своими digitalWrite и delay. Поменял период со 120 мс на 80 в одном месте — два других окна продолжали жить своей жизнью, и дом светился вразнобой. Вылечилось всё одной строкой: blink(2, 80) — а весь дублированный кусок уехал в единственную функцию, где период теперь правится ровно один раз.
Само устройство Arduino построено на функциях: void setup() и void loop() — уже функции, только их вызывает не ваш код, а каркас. Компилятор разворачивает ваш файл в main(), где setup запускается один раз, а loop крутится в бесконечном цикле. digitalWrite, millis, map — тоже функции, чужие и готовые. Свои — команды того же уровня: вы учите плату новым словам, а потом говорите ими в loop короткими фразами.
Каркас своей функции мы завели ещё в уроке о своих функциях — там было имя, скобки и вызов. Здесь спускаемся этажом ниже: параметры как переменные вызова, возврат значения через return, срок жизни локальных переменных и прототипы, из-за которых один и тот же скетч собирается в IDE, но не собирается в PlatformIO.
Выгода от выноса — не только порядок. Скопированный кусок кода компилируется каждый раз заново: три копии мигания — три блока команд во флеше. Функция — один блок, а вызовы стоят пару байтов. Плюс невидимая выгода отладки: печать значения в одном месте легко найти и проверить; три одинаковых печати в трёх углах loop сначала ещё нужно отыскать — и не перепутать между собой.
Параметры: функция как команда с настройкой
Параметр — переменная, которая рождается в момент вызова и умирает на закрывающей скобке функции. В объявлении void blink(byte n, int ms) имена n и ms — формальные параметры: заглушки, у которых значения появятся только при вызове. Вызов blink(3, 120) подставляет в них 3 и 120 — это уже аргументы. Функция с параметром — команда с настройкой: одна blink обслуживает любое число вспышек и любой темп, код её тела написан один раз.
Порядок и типы параметров — часть имени команды. blink(3, 120) и blink(120, 3) — не одно и то же: во втором случае плате достанется 3 миллисекунды на вспышку и 120 повторений, глазу покажется, что диод просто слегка мигнул. Компилятор проверяет типы и число аргументов, но не смысл — перепутанные местами числа одного типа он пропустит молча.
По умолчанию аргумент передаётся по значению: функция получает копию, и её правки внутри тела до вызывателя не доезжают. Поменять n внутри blink — снаружи ничего не изменится. Чтобы функция меняла переменную на месте, нужна передача по ссылке — весь механизм с амперсандом разобран в уроке об обмене значений, там же про то, когда ссылка уместна, а когда честнее вернуть значение через return.
Сколько параметров заводить — вопрос дисциплины. Мой потолок — три: blink(n, ms) читается мгновенно, а вызов с пятью аргументами уже требует заглядывать в объявление и гадать, что значит третий int. Группу связанных настроек честнее собрать в структуру или выбрать понятные имена; параметры-флаги bool в количестве больше двух — почти всегда сигнал, что пора делить функцию на две.
Локальные переменные живут только при вызове. int sum, объявленная внутри readAverage, рождается на входе и умирает на закрывающей скобке: между двумя вызовами её не существует, а память может уйти под чужую переменную. Считать, что sum «запомнит» прошлый вызов, — классический промах новичка; хранить состояние между вызовами умеют глобальная переменная и static, и о static подробнее сказано в уроке о прототипах и static.
const int PIN_LED = 12;const int PIN_KN = 2; void blink(byte n, int ms) { // n вспышек по ms миллисекунд for (byte i = 0; i < n; i++) { digitalWrite(PIN_LED, HIGH); delay(ms); digitalWrite(PIN_LED, LOW); delay(ms); }} int readAverage(byte pin) { // среднее из 8 замеров int sum = 0; for (byte i = 0; i < 8; i++) { sum = sum + analogRead(pin); delay(5); } return sum / 8; // отдаём значение наружу} void setup() { pinMode(PIN_LED, OUTPUT); pinMode(PIN_KN, INPUT_PULLUP); Serial.begin(9600); Serial.println("knopka D2 = vspyshki, A0 = sglazhennyi uroven");} void loop() { if (digitalRead(PIN_KN) == LOW) { byte n = map(readAverage(A0), 0, 1023, 1, 5); blink(n, 150); Serial.print("blink n="); Serial.println(n); delay(500); }}Возврат — половина силы функций. Строка int readAverage(byte pin) обещает: функция вернёт целое. return sum / 8 выполняет обещание и заодно завершает функцию — до закрывающей скобки дело даже не доходит, если return стоит посередине. У void-функций return тоже работает, но без значения: ранний выход, когда продолжать уже бессмысленно — например, выйти из blink, если аргумент нулевой.
readAverage — не учебная абстракция, а мой самый частый инструмент: одиночный analogRead на макетке пляшет на десяток единиц, и усреднение из восьми замеров делает показ пригодным для порогов. Дальше по теме шума идут выбросы и медиана — усреднение там дополняется фильтром одиночных скачков. Возврат без параметров вы уже видели тысячи раз: millis() — та же схема, функция, которая отдаёт unsigned long и ничего не спрашивает.
Ещё return умеет обрывать виток loop: сами setup() и loop() объявлены void, и их return ничего не передаёт каркасу — он просто завершает текущий проход. В начале loop такой return — законный способ начать виток заново, если условие ещё не созрело: всё, что ниже, в этом проходе не выполняется. Экономит вложенность в скетчах, где виток начинается с серии проверок.
Тип возвращаемого значения — часть контракта. Присвоите результат readAverage переменной byte при сыром значении выше 255 — компилятор ужмёт без предупреждения, и на верхнем ходе ручки числа начнут сворачиваться; про потолки типов и сворачивание подробно разобрано в уроке о типах и переполнении. Правило у меня одно: тип функции выбирается не «какой был под рукой», а по диапазону честных значений, которые она может вернуть.
Прототипы: почему порядок функций в файле имеет цену
Прототип — строка-обещание вида void blink(byte n, int ms); с точкой с запятой вместо тела. Она говорит компилятору: такая функция будет, параметры такие, вернёт вот это, — и он проверяет ваши вызовы, не дожидаясь тела ниже. Без прототипа компилятор читает файл сверху вниз и к строке вызова ещё не знает, что blink существует.
В Arduino IDE определение ниже loop обычно работает: среда сама прогоняет файл через препроцессор и дописывает прототипы в начало, до вашего кода. Это удобство давно стало привычкой — и тихой ловушкой при переносе. Авто-генератор не всевидящ: на функциях с типами, объявленными внутри скетча, и в паре других краевых случаев он спотыкается, а сборка вне IDE — PlatformIO, чистый avr-gcc, CI — прощений не знает и отвечает «not declared in this scope».
Мой порядок фиксирован годами: сверху файла — прототипы всех своих функций, ниже setup, потом определения. Строка прототипа стоит ноль байтов прошивки, зато скетч собирается в любой среде и файл читается как оглавление: верх страницы перечисляет все команды проекта с их настройками, ещё до того, как глаза дошли до тел.
Есть тут и вторая польза — читаемость loop. Когда все команды вынесены с внятными именами и параметрами, loop превращается в сценарий из трёх-пяти строк вызовов, и через полгода понять замысел можно без раскопок. Плотность деталей прячется по этажам: сценарий в loop, расшифровка в телах функций, константы вверху файла.
| Подход | Что даёт | Чем платите |
|---|---|---|
| Копипаст куска кода | ноль минут на объявление | правка в N местах, расползание версий |
| Функция без параметров | код в одном месте | жёсткие числа внутри тела, вызов всё одинаков |
| Функция с параметрами | одна команда на все случаи | нужен прототип для переносимости |
| Функция с возвратом | результат пригоден для порогов и map | тип возврата надо выбирать осознанно |
| Параметр по умолчанию | вызов короче: blink(3) без второго аргумента | новичкам путает порядок и смысл аргументов |
Первый рефакторинг я сделал на ночнике из четырёх режимов: loop ужался с девяноста строк до двадцати пяти, а правка поведения свелась к телу одной функции. Цена — час переразборки и один невидимый в тот вечер промах: локальный счётчик я объявил глобальным, и режимы начали влиять друг на друга. Возврат к локальности починил всё за минуту — с тех пор глобальными у меня живут только то, что реально переживает вызовы: состояние режима, таймеры планировщика, пины.
На чужих функциях те же правила читаются мгновенно: Serial.parseInt возвращает int и потому влезает прямо в присваивание — разбор чисел из монитора порта целиком стоит на этом возврате, а планировщик на millis держится на функции без параметров, отдающей unsigned long. Каждый раз, когда готовая библиотека приятно вписывается в строку кода, за кадром работает тот же контракт: имя, параметры, возврат.
Имена функций выбираю глаголами: blink, readAverage, primeniPorog — команда читается как действие. Существительное в имени функции (fonar, datchik) почти всегда намекает, что функция только вычисляет и должна вернуть значение, а ничего не зажигать. Мелочь, но через полгода именно такие мелочи отличают файл, который читается, от файла, который разгадывается.
- 1. Сколько параметров у функции void blink(byte n, int ms) и какие их типы?
- 2. Где живёт переменная int sum, объявленная внутри readAverage()?
- 3. Почему определение функции ниже loop() чаще всего компилируется в Arduino IDE?
- 4. Что делает return sum / 8 внутри функции с типом int?
- 5. readAverage(A0) вернула 900, а вы присвоили результат в byte. Что произойдёт?
- void setup() и void loop() — уже функции: их вызывает каркас, а свои команды вы заводите тем же способом.
- Параметр — переменная, живущая от вызова до закрывающей скобки; blink(byte n, int ms) — одна команда на любое число вспышек и темп.
- Аргументы передаются копией по значению; локальные переменные функции умирают на выходе и ничего не помнят.
- Тип возврата — контракт: int readAverage(byte pin) обещает целое, и тип надо выбирать по честному диапазону значений.
- Прототипы выше setup стоят ноль байтов, но спасают в PlatformIO и чистом avr-gcc, где авто-прототипов IDE нет.
Частые вопросы по теме урока
- Сколько параметров у функции void blink(byte n, int ms) и какие их типы?
- Параметра два: byte n и int ms, порядок важен — первый аргумент уедет в n, второй в ms. void относится к типу возврата, а не к списку параметров. два: byte и int
- Где живёт переменная int sum, объявленная внутри readAverage()?
- Локальные переменные рождаются при вызове и умирают на закрывающей скобке. Хранить значение между вызовами умеют глобальные переменные и static. только пока выполняется вызов функции
- Почему определение функции ниже loop() чаще всего компилируется в Arduino IDE?
- Arduino IDE прогоняет скетч через препроцессор и генерирует прототипы. Вне IDE — PlatformIO, avr-gcc — этого удобства нет, и порядок объявлений становится обязательным. IDE сама дописывает прототипы в начало файла
- Что делает return sum / 8 внутри функции с типом int?
- return завершает функцию немедленно и отдаёт значение. В функции с объявленным типом возврата каждая ветка обязана отдать значение этого типа. завершает функцию и отдаёт значение вызывателю
- readAverage(A0) вернула 900, а вы присвоили результат в byte. Что произойдёт?
- byte вмещает 0–255: 900 сворачивается по модулю 256 в 132 (900 − 768). Компилятор сузит тип без предупреждения — выбирайте тип возврата по диапазону. значение ужмётся по модулю 256 — получится 132