1. Адреса и указатели
Когда вы только начинаете писать функции, хочется, чтобы всё было просто: передал число — получил число. Но довольно быстро появляются ситуации, где «обычный» параметр не выражает смысл. Иногда аргумента может не быть (например, «если хочешь — передай, если не хочешь — пропусти»). Иногда функция должна вернуть результат не через return, а «положить» его в переменную, которую вы ей дали. И вот тут указатель в параметре — это способ честно сказать в интерфейсе: «я ожидаю адрес, и этот адрес может быть пустым».
В современном C++ nullptr — это стандартный и предпочтительный способ обозначить «нулевой указатель» (то есть «никуда не указывает»). Даже в материалах по стандартной библиотеке вы встретите правки формата «use nullptr instead of 0».
Оператор &: вспоминаем как он работает
Представьте, что переменная — это «ящик» в памяти, а значение — то, что лежит в ящике. Тогда адрес — это «координаты ящика»: по этому адресу компьютер может найти место, где хранится значение. В ранних темах про массивы мы видели, что имя массива во многих выражениях ведёт себя как «адрес первого элемента». Это была первая встреча с идеей адреса — тогда мы не делали из этого трагедию, а сегодня сделаем из этого инструмент.
Оператор & в C++ означает «взять адрес». Если у нас есть переменная x, то &x — это адрес этой переменной.
#include <iostream>
int main() {
int x = 42;
std::cout << x << '\n'; // 42
std::cout << &x << '\n'; // (какой-то адрес, например 0x7ffd...)
}
Вторую строку с адресом не стоит пытаться запомнить или «угадать»: адрес зависит от запуска, ОС, компилятора и фазы Луны. Важна идея: &x — это не число «42», это «где лежит x».
Чтобы закрепить, полезно представить это схемой:
Память (очень условно)
Адрес: 0x1000 ┌───────────┐
│ 42 │ <- здесь лежит int x
└───────────┘
^
|
&x
У ссылок есть 2 шикарных преимущества:
- можно передать объект не копируя его: очень полезно
- можно изменить объект, если на него есть ссылка
Ссылки стали настолько популярными, что для их хранения даже завели специальный тип — указатель.
2. Тип T* и значение nullptr
Если int — это «целое число», то int* — это «штука, которая хранит адрес int». Поэтому запись T* читается как «указатель на T». Важно: указатель — это не сам объект T, а «отсылка» на него.
Вторая ключевая идея — указатель может быть пустым. Для этого есть специальное значение nullptr. Оно означает: «адреса нет, объект не задан, смотреть некуда».
Мини-таблица, чтобы быстро ориентироваться:
| Что это | Пример | Смысл |
|---|---|---|
| Объект | |
«есть значение 10» |
| Адрес объекта | |
«где лежит x» |
| Указатель | |
«p хранит адрес x» |
| Пустой указатель | |
«p никуда не указывает» |
Посмотрим на короткий пример: указатель хранит адрес, но сам по себе «ничего не меняет». Менять начинает разыменование.
#include <iostream>
int main() {
int x = 7;
int* p = &x; // p "указывает" на x
std::cout << x << '\n'; // 7
std::cout << p << '\n'; // (адрес x)
}
Разыменование *p и проверка на nullptr
До этого момента указатель выглядел как «странное число-адрес». Но смысл указателя — в доступе к объекту по адресу. Для этого используется оператор * (звёздочка) — разыменование. Он означает: «возьми объект, который лежит по этому адресу».
На человеческом языке это звучит так: p — «где», *p — «что».
#include <iostream>
int main() {
int x = 10;
int* p = &x;
*p = 99; // записали 99 в x через указатель
std::cout << x << '\n'; // 99
std::cout << *p << '\n'; // 99
}
Теперь самое важное правило, которое спасает от боли и страданий: разыменовывать можно только непустой указатель. Если p == nullptr, то *p — это попытка прочитать/записать «в никуда». Это приводит к ошибке или к очень странному поведению, и новичкам это обычно кажется «магией багов».
Поэтому безопасный паттерн такой: сначала проверка, потом разыменование.
#include <iostream>
void print_if_present(const int* p) {
if (p == nullptr) {
std::cout << "no value\n"; // no value
return;
}
std::cout << *p << '\n';
}
int main() {
int x = 5;
print_if_present(&x); // 5
print_if_present(nullptr);// no value
}
3. Указатели в параметрах: nullable‑контракт и выходные параметры
Когда вы видите в сигнатуре функции int&, вы можете считать, что функция получила «второе имя» существующего объекта и объект точно существует. Ссылки не бывают «пустыми»: это часть их смысла.
А вот int* в параметре — другой контракт: функция получила адрес, но адрес может быть nullptr. Это удобно, когда отсутствие аргумента — нормальная ситуация, а не ошибка.
Небольшая таблица-сравнение (без философии, чисто для практики):
| Контракт | Как выглядит | Может быть «пустым»? | Что обязана сделать функция |
|---|---|---|---|
| «Объект точно есть» | |
Нет | Можно смело менять |
| «Объект может отсутствовать» | |
Да () |
Проверить перед |
Здесь важно привыкнуть читать сигнатуры как дорожные знаки. Если преподаватель видит T*, он сразу думает: «ага, тут возможен nullptr, значит, где-то должна быть проверка».
Представим, что мы продолжаем наше консольное приложение “ToDo‑список”. Оно хранит задачи в std::vector<std::string> и умеет печатать список, добавлять задачу и удалять по номеру. Номер пользователь вводит строкой (через getline), и нам нужно превратить её в int.
Мы пока не используем продвинутые механизмы ошибок (типа optional) и не хотим полагаться на исключения. Поэтому сделаем простой и очень распространённый интерфейс: функция возвращает bool (успех/неуспех), а результат кладёт в выходной параметр int* out.
Почему int*, а не int&? Потому что так мы можем выразить дополнительный смысл: «результат можно не забирать» (например, для проверки валидности ввода без сохранения).
Парсер числа: bool parse_int(const std::string&, int* out)
Перед тем как писать код, договоримся о контракте. Если out == nullptr, мы не будем падать и обижаться — мы просто будем считать, что вызывающий код попросил «только проверить строку». Это реальный приём в проектах: «валидация без извлечения значения».
#include <string>
bool parse_int(const std::string& s, int* out) {
if (s.empty()) return false;
int sign = 1;
std::size_t i = 0;
if (s[0] == '-') { sign = -1; i = 1; }
if (i == s.size()) return false;
int value = 0;
for (; i < s.size(); ++i) {
char c = s[i];
if (c < '0' || c > '9') return false;
value = value * 10 + (c - '0');
}
if (out != nullptr) {
*out = sign * value;
}
return true;
}
Да, тут есть ограничения (например, переполнение int мы не ловим). Но для учебной стадии и маленьких входов это отличный тренажёр: мы явно видим проверку nullptr перед *out.
Как использовать out и как передать адрес
Теперь покажем, как это выглядит в main. Главное — помнить: чтобы передать адрес переменной x в параметр int*, мы пишем &x.
#include <iostream>
#include <string>
bool parse_int(const std::string& s, int* out); // прототип
int main() {
std::string line = "123";
int number = 0;
if (parse_int(line, &number)) {
std::cout << number << '\n'; // 123
}
}
А вот пример «проверить, но не сохранять»:
#include <iostream>
#include <string>
bool parse_int(const std::string& s, int* out); // прототип
int main() {
std::string line = "12x";
bool ok = parse_int(line, nullptr);
std::cout << ok << '\n'; // 0 (false)
}
Обратите внимание на психологический эффект: сам вызов parse_int(line, nullptr) читается как «мне не нужен результат». Это и есть nullable‑контракт в действии.
ToDo‑пример: удаляем задачу по номеру
Теперь соберём из этого кусочек логики ToDo‑приложения. Пусть у нас есть tasks и пользователь ввёл строку line, которая должна быть номером. Мы хотим: если номер валиден и в диапазоне, удалить задачу. И вот тут удобно сделать функцию, которая возвращает bool и принимает выходной параметр для индекса или напрямую делает действие.
Поскольку мы пока не углубляемся в архитектуру, сделаем простой вариант: функция пытается удалить, а успех сообщает bool.
#include <string>
#include <vector>
bool parse_int(const std::string& s, int* out); // уже есть
bool remove_task_by_line(std::vector<std::string>& tasks, const std::string& line) {
int index = -1;
if (!parse_int(line, &index)) return false;
if (index < 1) return false;
int pos = index - 1; // пользователь вводит 1..N, а вектор 0..N-1
if (pos >= static_cast<int>(tasks.size())) return false;
tasks.erase(tasks.begin() + pos);
return true;
}
Тут важна не столько erase (мы его могли выучить раньше), сколько идея: мы получили число через int* out, а значит, у нас есть место, куда функция «положила» результат.
4. Массивы и указатели: «начало последовательности»
Раньше, когда мы обсуждали массивы, вы могли заметить странность: имя массива во многих местах превращается в адрес первого элемента. Это и есть та самая «array-to-pointer conversion (decay)», только мы не обязаны знать страшное имя, чтобы пользоваться идеей. Сегодня важно понять практический вывод: если у вас есть массив int a[5], то его можно передать туда, где ждут int* или const int*.
Покажем функцию, которая печатает первые n чисел. Мы не делаем арифметику указателей, просто используем индексирование p[i], которое в C++ работает и для указателей (потому что исторически указатели и массивы тесно связаны).
#include <iostream>
void print_numbers(const int* p, int n) {
if (p == nullptr) {
std::cout << "no array\n"; // no array
return;
}
for (int i = 0; i < n; ++i) {
std::cout << p[i] << ' ';
}
std::cout << '\n';
}
int main() {
int a[4] = {10, 20, 30, 40};
print_numbers(a, 4); // 10 20 30 40
print_numbers(nullptr, 3); // no array
}
Это отличный мостик между темой массивов и темой указателей: указатель — это действительно адрес, а для массива адрес первого элемента часто «торчит наружу» автоматически.
Мини‑схема: как читать T* в сигнатуре
Когда вы начнёте видеть указатели в коде чаще, полезно иметь в голове маленький алгоритм чтения. Он не «официальный», но очень рабочий: сначала вы понимаете контракт, потом проверяете, что код этому контракту соответствует.
flowchart TD
A["Вижу параметр T* p"] --> B["Возможен nullptr? (обычно да)"]
B --> C["Есть ли проверка p == nullptr перед *p?"]
C -->|да| D["Ок: разыменование безопаснее"]
C -->|нет| E["Риск: *p может упасть (или хуже)"]
В хороших учебных примерах (и в реальном коде) вы почти всегда увидите проверку либо в начале функции, либо прямо перед разыменованием.
10. Типичные ошибки при работе с T* и nullptr
Ошибка №1: разыменование nullptr “потому что ну я же передал нормально”.
Самый частый сценарий: функция принимает int* out, внутри сразу пишет *out = ..., а потом кто-то вызывает её как f(nullptr). Итог — падение в рантайме. Психологически это особенно неприятно, потому что компилятор не ругается. Лечится дисциплиной: если указатель может быть пустым, в функции должна быть проверка if (out == nullptr) ... до первого *out.
Ошибка №2: забыть & при передаче адреса.
Если функция ждёт int*, а вы пишете parse_int(line, number), компилятор справедливо удивляется: «я ждал адрес, а мне дали число». Правильный вызов — parse_int(line, &number). Это не «пунктуация ради пунктуации», это явное действие: вы просите у компилятора адрес переменной.
Ошибка №3: путать p и *p (где vs что).
Новичок часто пишет p = 10 вместо *p = 10 и потом не понимает, почему всё сломалось. p — это адрес (координата), *p — значение по адресу. Полезная мантра: «без звёздочки — где, со звёздочкой — что».
Ошибка №4: использовать 0 или NULL вместо nullptr.
В старом C++ (и в старом коде) можно встретить NULL и просто 0. Это хуже читается и иногда создаёт неоднозначности с перегрузками. В современном C++ пишут nullptr, потому что это отдельное значение «нулевой указатель», а не просто «какое-то число». Даже внутри стандартной библиотеки много где приводили формулировки к nullptr.
Ошибка №5: считать, что указатель “сам создаёт объект”.
Указатель — это только адрес. Если у вас int* p = nullptr, это не означает «где-то есть int, просто не инициализирован». Это означает «объекта нет, адрес пустой». В рамках этой лекции мы не создаём объекты через указатели и не управляем памятью вручную — мы используем указатели только как способ передать адрес существующего объекта или передать «пустоту» как сигнал.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ