1. Указатель как параметр
Если вы раньше писали функции только с параметрами «по значению» (типа int x) или «по ссылке/const&», то указатель может показаться странной штукой: зачем нам отдельный тип, который ещё и может быть nullptr? Но как раз в этой «может быть nullptr» и живёт главная практическая ценность.
Указатель в параметре функции решает два жизненных сценария.
- Функция должна изменить объект, который находится у вызывающего кода, и вы хотите явно видеть, что изменение идёт «по адресу».
- Объект может отсутствовать, и вы хотите выразить это прямо в сигнатуре функции, а не через «магические значения» вроде -1 или пустой строки.
Представьте бытовую аналогию: вы даёте другу не «яблоко» (копию), а «адрес магазина, где лежит ваше яблоко». Друг может прийти и поменять ценник (изменить объект), а может не прийти, если адреса нет (nullptr). Аналогия немного пугающая, но запоминается.
Указатель передаётся по значению, но объект — тот же
Очень частая ошибка новичка — думать, что «если параметр передаётся в функцию, значит там всё копируется». Это верно для передачи по значению самого объекта, но не для указателя.
Когда вы передаёте T*, вы передаёте копию адреса, а не копию объекта. Адрес — маленькое значение, его копировать легко, но он всё ещё указывает на тот же объект.
Из этого следует важное правило мышления: внутри функции p — это отдельная переменная (копия адреса), но *p — это тот же объект, который живёт у вызывающего кода. Поэтому изменения через *p отражаются «снаружи».
Мини‑пример (очень маленький, но прям «в мозг»):
#include <iostream>
void set_99(int* p) {
if (p != nullptr) {
*p = 99; // меняем объект вызывающего кода
}
}
int main() {
int x = 10;
set_99(&x);
std::cout << x << '\n'; // 99
}
Обратите внимание на тонкость: если бы set_99 принимала int x, она поменяла бы только свою копию. А int* даёт доступ к исходному x.
2. Контракт параметра: nullable и обязательный указатель
Nullable‑указатель — это не «дырка в безопасности», а способ сказать правду о входных данных. Если параметр может отсутствовать, то лучше, чтобы это было видно прямо в сигнатуре: T* (и внутри — проверка). Это намного честнее, чем «передайте возраст -1, если пользователя нет» или «передайте пустую строку».
Важно не перепутать: nullable‑дизайн — это не про лень, а про явную ветку поведения. То есть у вас должно быть два логических пути: «объект есть» и «объекта нет». И оба пути должны быть корректными.
Nullable‑вход: nullptr как нормальная ветка
Давайте продолжим маленькое приложение: мини‑контактную книжку. Пока без файлов и сложной архитектуры — просто struct Contact и пара функций.
#include <iostream>
#include <string>
struct Contact {
std::string name;
int age{};
};
void print_contact(const Contact* c) {
if (c == nullptr) {
std::cout << "Contact: <null>\n";
return;
}
std::cout << "Contact: " << c->name << ", age=" << c->age << '\n';
}
int main() {
Contact ann{"Ann", 20};
print_contact(&ann); // Contact: Ann, age=20
print_contact(nullptr); // Contact: <null>
}
Заметьте, что параметр здесь const Contact*, потому что функция не должна менять контакт — она только печатает. const в сигнатуре — сильная подсказка читателю: «я буду смотреть, но не трогать».
Теперь пример, где nullable‑параметр означает «если объект есть — сделай, если нет — тихо пропусти». Это часто уместно для «косметических» действий: нормализовать строку, очистить флаг, подправить данные.
#include <string>
#include <cctype>
void normalize_name(std::string* s) {
if (s == nullptr) return;
if (!s->empty()) {
(*s)[0] = static_cast<char>(std::toupper((*s)[0]));
}
}
Обязательный указатель: nullptr как ошибка использования
Иногда T* используют не потому, что «может быть пусто», а потому что хотят «работать по адресу». Но если объект обязан быть, то nullable‑контракт тут вреден: читатель видит T* и думает «ага, наверное можно nullptr». Если нельзя — это нужно подчеркнуть.
Один из простых учебных способов — проверять предусловие через assert. Это не «обработка ошибки пользователя», а фиксация контракта: «в отладочной сборке мы хотим упасть сразу и громко, если нас используют неправильно».
#include <cassert>
#include <string>
struct Contact {
std::string name;
int age{};
};
void have_birthday(Contact* c) {
assert(c != nullptr); // контракт: контакт обязан быть
c->age += 1;
}
Почему это полезно? Потому что если кто-то вызовет have_birthday(nullptr), вы не получите тихий UB где-нибудь через месяц. Вы получите понятный сигнал: «контракт нарушен».
Но будьте честны: если в вашей программе nullptr — это ожидаемый сценарий, assert не должен быть основным инструментом. Тогда лучше вернуть статус (например, bool) и обработать «не получилось» как нормальную ветку логики.
3. Out‑parameter и try_*: статус + запись результата
Out‑parameter — это когда функция возвращает результат не через return result;, а через запись в объект, адрес которого ей передали. Это звучит немного старомодно, но на практике встречается часто: в задачах парсинга, в функциях try_*, в коде, где хочется вернуть и статус, и значение, не создавая отдельные структуры.
Ключевая идея: out‑параметр почти всегда идёт вместе со статусом. То есть функция возвращает bool (успех/неуспех), а значение записывает в *out только при успехе. Тогда вызывающий код читает это прозрачно: «если получилось — используй».
Классический учебный пример — безопасное деление:
#include <iostream>
bool try_divide(int a, int b, int* out) {
if (out == nullptr) return false; // out обязателен
if (b == 0) return false;
*out = a / b;
return true;
}
int main() {
int q = 0;
if (try_divide(10, 2, &q)) {
std::cout << q << '\n'; // 5
}
}
Обратите внимание: даже если out — указатель, он здесь не nullable по смыслу. Мы используем int*, потому что нам нужно «вернуть значение через запись», но контракт «out обязателен» фиксируем явной проверкой if (out == nullptr).
Как читать сигнатуру как контракт
В одной функции может быть и nullable‑вход, и обязательный out‑параметр, и ещё какие-нибудь числа. Поэтому полезно научиться прямо по сигнатуре отвечать на вопросы: «что может быть nullptr?», «кто что меняет?», «кто владеет данными?»
Сравним несколько форм:
| Сигнатура | Можно ли nullptr? | Меняет ли данные? | О чём говорит контракт |
|---|---|---|---|
|
да (обычно) | нет | «объект может отсутствовать, я только читаю» |
|
да (по типу) | да | «я могу менять объект; возможно, он может отсутствовать» |
|
out обычно нельзя | да (в *out) | «я пытаюсь посчитать/получить; если успех — запишу результат» |
Важная мораль: тип T* сам по себе не запрещает nullptr, поэтому запрет (если он есть) должен быть оформлен либо проверкой, либо assert, либо документацией/именованием. Например, имя try_* уже намекает на возможный отказ.
Блок‑схема “правильной” try‑функции с out‑параметром
Когда вы только начинаете писать такие функции, полезно держать в голове шаблон: «проверки → вычисление → запись результата → return true».
flowchart TD
A[Вход в try_*] --> B{out != nullptr?}
B -- нет --> X[return false]
B -- да --> C{выполнимы условия?}
C -- нет --> X
C -- да --> D[посчитать результат]
D --> E[*out = result]
E --> F[return true]
Если вы будете писать try‑функции по этому шаблону, у вас резко уменьшится число «странных падений» и «почему оно иногда работает».
4. Мини‑практика: контактная книжка
Соберём небольшой кусочек контактной книжки, где указатели в параметрах решают две задачи: «вход может отсутствовать» и «нужно вернуть результат через out».
Представим сценарий: мы хотим «попробовать повысить возраст контакта на 1», но контакт может быть не выбран (например, пользователь ещё не ввёл имя). Это кандидат на nullable‑вход.
#include <string>
struct Contact {
std::string name;
int age{};
};
bool try_have_birthday(Contact* c) {
if (c == nullptr) return false;
c->age += 1;
return true;
}
Теперь out‑параметр: мы хотим «найти индекс контакта по имени» в векторе. Возвращать -1 как «не найдено» можно, но это уже «магический результат». Вместо этого вернём bool, а индекс положим в outIndex.
#include <cstddef>
#include <string>
#include <vector>
struct Contact {
std::string name;
int age{};
};
bool try_find_index_by_name(const std::vector<Contact>& v,
const std::string& name,
std::size_t* outIndex) {
if (outIndex == nullptr) return false;
for (std::size_t i = 0; i < v.size(); ++i) {
if (v[i].name == name) {
*outIndex = i;
return true;
}
}
return false;
}
Заметьте несколько деталей, которые делают этот код «человечным».
- Во-первых, v передан как const std::vector<Contact>&, потому что функция не должна менять список контактов.
- Во-вторых, outIndex проверяется на nullptr, потому что out‑параметр — обязательная часть контракта.
- В-третьих, *outIndex заполняется только в случае успеха, то есть вызывающий код не прочитает «мусор».
Теперь соберём демонстрацию в main:
#include <iostream>
#include <string>
#include <vector>
struct Contact {
std::string name;
int age{};
};
bool try_find_index_by_name(const std::vector<Contact>& v,
const std::string& name,
std::size_t* outIndex);
int main() {
std::vector<Contact> contacts{{"Ann", 20}, {"Bob", 30}};
std::size_t idx = 0;
if (try_find_index_by_name(contacts, "Bob", &idx)) {
std::cout << "Found Bob at " << idx << '\n'; // Found Bob at 1
contacts[idx].age += 1;
std::cout << contacts[idx].age << '\n'; // 31
}
}
Это маленькая программа, но она демонстрирует очень взрослую идею: функция и её сигнатура задают контракт, а вызывающий код этот контракт уважает.
5. Типичные ошибки
Ошибка №1: забыли & при вызове функции, ожидающей T*.
Это выглядит безобидно, но по смыслу вы передали не адрес, а значение (или вообще не то, что нужно), и компилятор либо не соберёт код, либо вы начнёте «лечить симптом», меняя типы наугад. Если функция ждёт int*, почти всегда в месте вызова будет &x или nullptr.
Ошибка №2: сделали параметр nullable, но разыменовали без проверки.
Сигнатура T* p почти всегда читается как «может быть nullptr». Если вы внутри функции сразу делаете *p или p->field, вы фактически пишете «я надеюсь, что меня будут использовать правильно». Надежда — плохая стратегия. Либо проверяйте p != nullptr, либо делайте контракт жёстким (и оформляйте его явно).
Ошибка №3: out‑параметр заполняется не во всех ветках успеха, а вызывающий код всё равно читает.
Это один из самых коварных багов, потому что программа может «иногда работать». Правильная дисциплина простая: *out = ... выполняется только перед return true, а вызывающий код читает out только внутри if (try_...).
Ошибка №4: путаница между «изменить адрес» и «изменить данные по адресу».
p = &x; меняет куда указывает указатель. *p = x; меняет данные в объекте, на который указывает p. Если вы в голове не разделяете эти два действия, отладка превращается в гадание на кофейной гуще.
Ошибка №5: out‑параметр допускает nullptr «случайно», а не по дизайну.
Иногда пишут bool f(..., T* out) и забывают проверить out, потому что «ну кто же передаст nullptr». Передадут: случайно, при рефакторинге или потому что «временно так сделаю». Если out‑параметр обязателен — проверяйте в начале и возвращайте false (или используйте assert, если это реально нарушение контракта).
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ