JavaRush /Курсы /C++ SELF /Указатели как параметр: nullable‑дизайн

Указатели как параметр: nullable‑дизайн

C++ SELF
39 уровень , 2 лекция
Открыта

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? Меняет ли данные? О чём говорит контракт
void f(const T* p)
да (обычно) нет «объект может отсутствовать, я только читаю»
void f(T* p)
да (по типу) да «я могу менять объект; возможно, он может отсутствовать»
bool try_f(..., T* out)
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, если это реально нарушение контракта).

1
Задача
C++ SELF, 39 уровень, 2 лекция
Недоступна
Датчик или пусто
Датчик или пусто
1
Задача
C++ SELF, 39 уровень, 2 лекция
Недоступна
Косметика строки
Косметика строки
1
Задача
C++ SELF, 39 уровень, 2 лекция
Недоступна
Безопасное деление
Безопасное деление
1
Задача
C++ SELF, 39 уровень, 2 лекция
Недоступна
Индекс контакта
Индекс контакта
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ