Пульт полива держал настройки: целевая влажность, длительность полива, часы тишины — двенадцать байт, упакованные в структуру. Кнопки меняли значения, после каждой правки вызывалась функция sohrani(Nastroyki n), которая писала структуру в EEPROM — тем же приёмом, каким урок про первые настройки хранит порог полива. Всё выглядело рабочим ровно до первой перезагрузки: плата включалась со старыми настройками, потому что функция честно сохранила копию, которую ей передали, — а кнопки меняли оригинал. Три вечера я подозревал EEPROM, потом библиотеку, потом питание. Виноват оказался заголовок моей функции.
Передача по значению — поведение C++ по умолчанию: аргумент копируется целиком, и функция трудится с двойником. Для одиночного int из урока про свои функции это незаметно и безопасно. Для структуры — незаметно и катастрофично: функция сделала всё, что вы написали, только с чужой копией, а копия умерла на выходе. Двухчасовая охота за «не сохраняется» — типичная цена незнания, кто кому принадлежит.
Минимум про адреса, который нужен ардуинщику, умещается в один урок: амперсанд берёт адрес, звёздочка читает по адресу, ссылка — тот же адрес без звёздочек. В конце — симулятор: движок не эмулирует указатели, но массивы он передаёт как по ссылке, честно меняя оригинал, — и макетка это докажет вам руками. Про память как ресурс и три её вида мы говорили в уроке про память, здесь речь о том, как не копировать лишний раз то, что уже лежит в ОЗУ.
Адрес: амперсанд и звёздочка
ОЗУ — это нумерованные ячейки по одному байту. Когда вы пишете int porog = 500, компилятор отводит под porog две ячейки, скажем с номером 512. Амперсанд &porog — это и есть номер: адрес переменной, обычное число вроде 512. Указатель — переменная, в которой хранится адрес: int* p = &porog. Звёздочка *p — обратная операция, разыменование: «пойди по адресу, что записан в p, и работай со значением там». Чтение и запись симметричны: *p + 1 читает 500, а *p = 501 меняет сам porog — никаких копий, работа по адресу.
Отсюда передача по указателю. Функция void uvelichi(int* p) { *p = *p + 1; } зовётся как uvelichi(&porog): вы отдали адрес, функция пришла по нему и изменила оригинал. После вызова porog равен 501, и это не магия, а почтальон, которому дали точный адрес вместо фотокопии письма. Ту же механику использует всё, что возвращает по адресу: EEPROM.put, getBytes, Wire.requestFrom — мы ходили по этим адресам в уроке про put и get структур.
Структура по указателю выглядит так: void pokazhi(Nastroyki* n) — поля достаются через стрелку, n->porog, потому что сначала надо дойти по адресу, а уже потом брать поле. Звёздочка с точкой (*n).porog — то же самое, но глаза слезятся, поэтому везде пишут стрелку. Минус указателя для новичка один, но серьёзный: указатель может показывать куда угодно, в том числе в никуда, и компилятор это не проверяет.
struct Nastroyki { int porog; // порог срабатывания, единицы АЦП int dlitelnost; // длительность полива, с}; void uvelichi(int* p) { // по указателю: адрес одиночной переменной *p = *p + 10;} void razognat(Nastroyki* n) { // по указателю: поля через стрелку n->porog = n->porog + 10; n->dlitelnost = n->dlitelnost + 1;} void pomenyat(Nastroyki& n) { // по ссылке: поля через точку n.porog = n.porog * 2;} void setup() { Serial.begin(9600); Nastroyki n = {500, 30}; uvelichi(&n.porog); // отдаём адрес поля Serial.print("porog="); Serial.println(n.porog); // 510 razognat(&n); Serial.print("porog="); Serial.println(n.porog); // 520 pomenyat(n); // ссылка: без амперсанда Serial.print("porog="); Serial.println(n.porog); // 1040} void loop() {}Ссылка: тот же адрес без звёздочек
Ссылка — это указатель, с которого сняли звёздочки: Nastroyki& n внутри функции — псевдоним оригинала, а не копия. Внутри тела вы пишете n.porog как с обычной переменной, а вызываете функцию без всяких амперсандов: pomenyat(n). Под капотом — тот же адрес, та же работа с оригиналом, но синтаксис не даёт разыменоваться не туда, и нулевой ссылки не бывает: ссылку при рождении обязаны на что-то привязать.
Половину пользы даёт const. void pokazhi(const Nastroyki& n) — функция читает оригинал без копирования и без права писать в него; попытка изменить поле ловится компилятором на месте. И тип проверяется бесплатно: передать Nastroyki в функцию, ждущую ссылку на int, не выйдет — несоответствие типов сборка укажет до прошивки. Моё правило простое и экономит мне байты каждый день: структуры — всегда по ссылке, const-ссылка, если функция их не меняет; одиночные значения — по значению, копирование двух байт не стоит разговора; указатели — там, где их требует чужой API или нужен «может быть пусто».
Цена копирования не абстрактна. Структура настроек на 24 байта, которую вы передаёте по значению в функцию, живущую в loop(), — это 24 байта копирования каждый оборот: сотни раз в секунду на 16 МГц тратятся такты, а главное — каждое изменение внутри копии тихо умирает. Моё первое «не сохраняется» из начала урока стоило ровно одного амперсанда в заголовке функции, и это самые дешёвые исправленные три вечера в моей практике.
Осталось увидеть указатели в чужом коде — они уже живут в каждой библиотеке, которую вы подключаете. EEPROM.get(0, n) принимает структуру по ссылке — внутри шаблона прячется всё тот же адрес. Wire.requestFrom посылает по шине адрес, а Serial.readBytes(buf, n) ждёт имя массива и длину отдельно — и это не прихоть, а след простого правила: имя массива в C++ разваливается в адрес первого элемента. Поэтому в функции void obrabotka(int arr[]) вы не знаете размер — sizeof(arr) вернёт 2, размер указателя на AVR, а не длину массива. Я наступал на это один раз: в функции буфер «внезапно» имел длину 2 и цикл for по нему молча съедал чужую память. Правило одно: размер массива всегда передают вторым аргументом, как мы делали в симуляторе ниже — zapishi(arr, 4, znachenie).
И последнее различие, которое стоит запомнить: массив скопировать по значению нельзя — любой заголовок с int arr[] принимает адрес. Со структурой выбор остаётся за вами: Nastroyki n в аргументе — 24 байта двойника, Nastroyki& n — ноль копий. Это единственное место в уроке, где массив и структура ведут себя по-разному, и именно поэтому в таблице ниже у них разные строки.
| Способ | В заголовке функции | Что происходит | Когда беру |
|---|---|---|---|
| По значению | void f(Nastroyki n) | копируются все байты, оригинал в безопасности | одиночные int, byte, float |
| По указателю | void f(Nastroyki* n) | передаётся адрес, поля через стрелку, может быть пустым | чужие API, обмен адресами, массивы в C-стиле |
| По ссылке | void f(const Nastroyki& n) | псевдоним оригинала, ноль копий, поля через точку | структуры всегда, const& — если функция не меняет |
Движок симулятора указателей и ссылок на одиночные переменные не умеет: амперсанд в аргументах он съедает, разыменования не исполняет. Зато массивы движок передаёт как по ссылке, честно: функция получает оригинал, и изменения живут после выхода. Макетка ниже — кольцевой буфер показаний потенциометра, та самая конструкция из урока про массивы-журнал: zapishi() пишет в общий массив и двигает голову, srednee() считает среднее, razgruzi() обнуляет весь массив изнутри функции. Кнопки докажут, что меняется оригинал, а не копия: после разрузки среднее снова станет нулём. Среднее здесь не декорация: одиночный замер с A0 прыгает на пару делений от шума, а среднее по четырём точкам буфера держит показание ровнее — тот же приём, что в гладких графиках датчиков.
На плате те же функции живут с теми же заголовками int arr[] — массив и в C++ передаётся как адрес первого элемента, поэтому в симуляторе модель точная, а не приблизительная. А вот int и структуры на плате передавайте по правилам из code-блока выше: амперсанд для указателя, амперсанд-ссылка для псевдонима.
- По значению C++ копирует аргумент целиком: структура в функции — двойник, изменения умирают на выходе
- Амперсанд &x — адрес переменной, звёздочка *p — работа по адресу: указатель приходит в функцию и меняет оригинал
- Поля структуры по указателю — через стрелку n->porog; по ссылке — через точку, вызов без амперсанда
- const Nastroyki& — чтение оригинала без копий; моё правило: структуры по ссылке, одиночные значения по значению
- В симуляторе модель передачи по ссылке — массивы: функции меняют оригинал, что видно по буферу и среднему
- 1. Что делает амперсанд в выражении int* p = &x?
- 2. Структуру на 24 байта передали в функцию по значению. Что произошло?
- 3. Функция объявлена как void uvelichi(int* p). Как её правильно вызвать для переменной x?
- 4. Чем ссылка Nastroyki& удобнее указателя Nastroyki* для обычного скетча?
- 5. Функция zapishi(int arr[], int n, ...) изменила элемент массива. Что это доказывает в симуляторе?
Частые вопросы по теме урока
- Что делает амперсанд в выражении int* p = &x?
- Амперсанд — адресная операция: p получает номер ячейки, где живёт x. Разыменование — это звёздочка, и она тут ни при чём. берёт адрес переменной x
- Структуру на 24 байта передали в функцию по значению. Что произошло?
- По значению копируется весь объект: 24 байта в стек, и изменения внутри функции умирают вместе с копией. Это и есть тупик из начала урока. скопированы все 24 байта — функция трудится с двойником
- Функция объявлена как void uvelichi(int* p). Как её правильно вызвать для переменной x?
- Функция ждёт адрес, значит передаём &x. Внутри она разыменует его звёздочкой и изменит оригинал. uvelichi(x) даже не соберётся: int вместо адреса. uvelichi(&x)
- Чем ссылка Nastroyki& удобнее указателя Nastroyki* для обычного скетча?
- Ссылка — синтаксический сахар над адресом: читается как обычная переменная и обязана быть привязана. Скорость и память те же, безопасность выше. нет звёздочек и стрелок: поля через точку, пустой ссылки не бывает
- Функция zapishi(int arr[], int n, ...) изменила элемент массива. Что это доказывает в симуляторе?
- Имя массива в C++ — адрес первого элемента: функция работает с оригиналом. Симулятор передаёт массивы так же честно, как реальный компилятор. меняется оригинал: массивы передаются как по ссылке