JavaRush /Курсы /C++ SELF /T* и nullptr: указатель как адрес

T* и nullptr: указатель как адрес

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

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. Оно означает: «адреса нет, объект не задан, смотреть некуда».

Мини-таблица, чтобы быстро ориентироваться:

Что это Пример Смысл
Объект
int x = 10;
«есть значение 10»
Адрес объекта
&x
«где лежит x»
Указатель
int* p = &x;
«p хранит адрес x»
Пустой указатель
int* p = nullptr;
«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. Это удобно, когда отсутствие аргумента — нормальная ситуация, а не ошибка.

Небольшая таблица-сравнение (без философии, чисто для практики):

Контракт Как выглядит Может быть «пустым»? Что обязана сделать функция
«Объект точно есть»
void f(int& x)
Нет Можно смело менять
x
«Объект может отсутствовать»
void f(int* p)
Да (
nullptr
)
Проверить
p
перед
*p

Здесь важно привыкнуть читать сигнатуры как дорожные знаки. Если преподаватель видит 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, просто не инициализирован». Это означает «объекта нет, адрес пустой». В рамках этой лекции мы не создаём объекты через указатели и не управляем памятью вручную — мы используем указатели только как способ передать адрес существующего объекта или передать «пустоту» как сигнал.

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