Поливальный пульт держал два порога: утренняя грядка просыпалась по 400 единицам АЦП, вечерняя требовала 700. Захотелось кнопку «перевернуть режим» — пороги меняются местами одним нажатием, без перепрошивки. Я написал функцию pomenjat(int a, int b) с тремя строками через третью переменную, компилятор согласился, кнопка нажималась с приятным щелчком — а пороги стояли на месте. Два вечера я перезаливал скетч и подозревал дребезг, пока не напечатал значения прямо после вызова: функция честно меняла не пороги, а их копии.
Это не мой брак, а правило C++: по умолчанию аргументы копируются. Функция получает двойников, делает с ними всё, что вы написали, и двойники умирают на выходе — оригиналы в вызывающем коде не тронуты. Чтобы функция добралась до оригинала, ей отдают не значение, а доступ к ячейке: псевдоним через амперсанд или адрес через звёздочку. Обе записи пугают новичка, пока не сводятся к одной задаче — обмену двух переменных.
План урока: почему обмен молча не работает, обмен через ссылку построчно, указательная версия для сравнения, арифметический трюк без третьей переменной и его граница, — а в конце симулятор, где одна попытка падает так же, как на плате, а вторая работает. Параметры и return мы разбирали в уроке про свои функции, переполнение int — в уроке про типы: обе вещи здесь сработают в сюжет.
Почему обмен молча не работает
Заголовок void pomenjat(int a, int b) рождает две локальные ячейки. При вызове в них кладутся копии значений porogUtro и porogVecher — и вся дальнейшая жизнь параметров проходит внутри функции: обмен сработал, третья переменная честно потрудилась, на выходе копии уничтожены. Оригиналы ни на один такт не узнали о происходящем. Самое коварное здесь — тишина: компилятор не ругается ни словом, потому что синтаксически всё безупречно, а рантайм не жалуется, потому что копирование — законное поведение.
Точно так же ведёт себя и return-путь: вернуть одно изменённое значение функция может, а два — уже нет, и новичок нередко делает второй return, который просто не выполняется. Выход у C++ один: отдать функции не копию, а адрес или псевдоним оригинала. Для одиночных int это стоит ровно два символа в заголовке. Хорошая новость в том, что один раз разобравшись с обменом, вы бесплатно получаете и вторую типовую задачу — функцию, которая выдаёт два результата сразу: минимум и максимум, два показания, номер и значение.
Ссылочная версия выглядит так: void pomenjat(int& a, int& b). Амперсанд после типа превращает параметры в псевдонимы оригиналов: запись a внутри функции — это та же ячейка, что porogUtro снаружи. Тело не меняется ни на символ — те же три строки с третьей переменной. Вызов — как у обычной функции, без амперсандов: pomenjat(porogUtro, porogVecher);. Вся разница между «работает» и «не работает» — два амперсанда в заголовке.
Тело обмена стоит проговорить построчно, потому что оно повторится у вас ещё сто раз: int t = a; — запомнили первое значение; a = b; — в первую ячейку легло второе; b = t; — вторая получила то, что было в первой. Без промежуточной ячейки первый шаг затёр бы значение до того, как его успели скопировать: обмен — самая популярная демонстрация того, зачем в алгоритме нужна лишняя переменная.
const int KNOPKA = 2; int porogUtro = 400; // порог утренней грядкиint porogVecher = 700; // порог вечерней void pomenjat(int& a, int& b) { // ссылки: работаем с оригиналами int t = a; a = b; b = t;} void pomenjatAdres(int* a, int* b) { // то же самое адресами int t = *a; *a = *b; *b = t;} void pechat() { Serial.print("porogi: "); Serial.print(porogUtro); Serial.print(" / "); Serial.println(porogVecher);} void setup() { pinMode(KNOPKA, INPUT_PULLUP); Serial.begin(9600); pechat();} void loop() { if (digitalRead(KNOPKA) == LOW) { pomenjat(porogUtro, porogVecher); // псевдонимы: просто вызов pechat(); pomenjatAdres(&porogUtro, &porogVecher); // вернули как было адресами pechat(); delay(400); }}Звёздочка, третья переменная и арифметика
Указательная версия делает то же самое, но адресами. В заголовке int* a означает «а — не значение, а адрес ячейки»; при вызове отдаём адреса явно: pomenjatAdres(&porogUtro, &porogVecher) — тот самый амперсанд, только в аргументах. В теле звёздочка *a читается как «пойди по адресу и работай со значением там»: *a = *b; копирует содержимое одной ячейки в другую. Полная механика адресов — с массивами, стрелками и висячими указателями — разложена в взрослом уроке про указатели и ссылки; здесь я держу только обмен, потому что это самый частый первый контакт с символами & и *.
Моё правило короткое: одиночные значения обмениваю ссылками — тело читается как обычный код, вызов без амперсандов, промахнуться негде. Указатели оставляю там, где их требует чужая библиотека или где адрес сам по себе служит предметом обмена. Спорный вопрос «а что быстрее» на AVR не существует: ссылки компилируются в те же адреса, разница — ноль тактов, спор чисто о читаемости.
Арифметический трюк обещает обойтись без третьей переменной: a = a + b; b = a - b; a = a - b;. Прогоните на бумажке — обмен честный. Но граница у него жёсткая: на Uno int держит значения до 32767, и при a = 20000, b = 20000 первая же строка заворачивает сумму за краем — дальше обмен размножает мусор, и компилятор молчит, как мы видели при разборе переполнения. Второй вариант без ячейки — XOR через ^= — переполнения не боится, но три строки побитовых плясок я читаю только с распечаткой. Мнение с границей: на плате третья переменная стоит два байта, а читаемость — бесплатно; трюк оставляю для викторин.
Ссылка умеет и обещание «только читать»: параметр const int& x — тот же псевдоним, но запись в него компилятор запретит. Это выход для больших значений — структур и строк, — которые копировать накладно, а менять не хочется: тело функции работает с оригиналом без копии и без права его испортить. Для одиночных int выигрыш нулевой — копия четырёх байт ничего не стоит, — и я честно пишу по значению: читаемость дороже микросекунд, которых никто не мерил. Ссылки без const оставьте там, где функция действительно обязана менять оригинал, — тогда сам заголовок предупреждает о побочных эффектах.
| Способ | Запись | Что с оригиналами | Когда беру |
|---|---|---|---|
| По значению | void p(int a, int b) | не тронуты: функция трудится с копиями | никогда для обмена; полезно, когда оригиналы надо защитить |
| По ссылке | void p(int& a, int& b) | меняются, вызов без амперсандов | обмен двух переменных, выход двух значений из функции |
| По указателю | void p(int* a, int* b) | меняются, вызов с &x и &y | когда адреса требует чужая библиотека |
| Арифметика | a=a+b; b=a-b; a=a-b | меняются без лишней ячейки | только для int в узком диапазоне: сумма до 32767 |
Разбор макетки закрывает оба вопроса урока одной сценой. Попытка по значению провалилась и в симуляторе, и провалилась бы на плате: два int приехали копиями, обменялись и умерли — поведение движка здесь один в один как у настоящего компилятора. Обмен ячеек сработал в обеих вселенных по одной причине: имя массива — уже адрес первого элемента, поэтому arr[i] внутри функции пишет в общий буфер, о чём мы говорили в уроке про массивы-журнал. А для двух одиночных переменных остаётся ссылочная версия из code-блока — скопируйте её в IDE, нажмите кнопку и убедитесь, что печать после вызова перевернулась.
Пара «обмен + сравнение» — готовый кирпич для будущих скетчей: смена ролей каналов по кнопке, сортировка пары значений, перемешивание. Когда доберётесь до структур, тот же ход превратится в обмен целых карточек одним вызовом — но это уже сюжет продвинутого раздела. И если такие задачи повторяются из скетча в скетч, вынесите pomenjat в отдельную вкладку IDE: заголовок с int& понадобится написать ровно один раз, а пользоваться им будут все новые проекты без переписывания трёх строк обмена по памяти — именно на этой переписке амперсанд обычно и теряется.
- 1. Почему pomenjat(int a, int b) не меняет переменные вызывающего кода?
- 2. Какой заголовок честно обменивает два int?
- 3. Как выглядит вызов указательной версии void p(int* a, int* b) для переменной x?
- 4. a = a + b; b = a - b; a = a - b; при a = 20000 и b = 20000 на Uno — что получится?
- 5. Функция получила int arr[] и поменяла arr[0] с arr[1] местами. Что увидит вызывающий код?
- По умолчанию C++ копирует аргументы: обмен без амперсанда меняет копии, и компилятор молчит.
- int& в заголовке делает параметр псевдонимом оригинала: те же три строки с третьей переменной, вызов без амперсандов.
- Указательная версия живёт звёздочками в теле и амперсандами в вызове; для одиночных значений я беру ссылки.
- Арифметический обмен без третьей переменной держится до переполнения: 20000 + 20000 на Uno уже за краем int.
- Массивы приходят в функции адресом: обмен ячеек работает одинаково в симуляторе и на плате.
Частые вопросы по теме урока
- Почему pomenjat(int a, int b) не меняет переменные вызывающего кода?
- Передача по значению копирует аргументы. Функция работает безупречно — с копиями, которые умирают на выходе. Компилятор молчит, потому что это законное поведение. параметры — копии: функция меняет двойников, оригиналы нетронуты
- Какой заголовок честно обменивает два int?
- Амперсанд после типа делает параметры псевдонимами оригиналов: запись a внутри функции — та же ячейка, что снаружи. Тело обмена не меняется. void p(int& a, int& b)
- Как выглядит вызов указательной версии void p(int* a, int* b) для переменной x?
- Функция ждёт адреса — отдаём амперсандами в аргументах. Внутри звёздочки пойдут по адресам и поменяют оригиналы местами. p(&x, &y)
- a = a + b; b = a - b; a = a - b; при a = 20000 и b = 20000 на Uno — что получится?
- int на Uno держит до 32767. Первая строка переполняет сумму, дальше арифметика размножает мусор. Третья переменная или ссылки надёжнее. мусор: сумма 40000 заворачивается за краем int
- Функция получила int arr[] и поменяла arr[0] с arr[1] местами. Что увидит вызывающий код?
- Массивы в C++ не копируются при передаче — функция получает адрес и честно меняет ячейки. Движок симулятора ведёт себя так же. оригинал поменялся: имя массива — уже адрес первого элемента