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. Тип 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 << "немає значення\n"; // немає значення
        return;
    }
    std::cout << *p << '\n';
}

int main() {
    int x = 5;
    print_if_present(&x);     // 5
    print_if_present(nullptr);// немає значення
}

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 << "немає масиву\n";      // немає масиву
        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);          // немає масиву
}

Це чудовий місток між темою масивів і темою вказівників: вказівник — це справді адреса, а для масиву адреса першого елемента часто стає доступною автоматично.

Міні‑схема: як читати 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 («де» і «що»).
Новачок часто пише p = 10 замість *p = 10 і потім не розуміє, чому все зламалося. p — це адреса (координата), *p — значення за адресою. Корисна мантра: «без зірочки — де, із зірочкою — що».

Помилка № 4: використовувати 0 або NULL замість nullptr.
У старому C++ (і в застарілому коді) можна зустріти NULL і просто 0. Це гірше читається й інколи створює неоднозначності під час перевантаження. У сучасному C++ пишуть nullptr, бо це окреме значення «нульовий вказівник», а не просто «якесь число». Навіть у матеріалах стандартної бібліотеки часто радять писати саме nullptr.

Помилка № 5: вважати, що вказівник «сам створює обʼєкт».
Вказівник — це лише адреса. Якщо у вас int* p = nullptr, це не означає «десь є int, просто не ініціалізований». Це означає «обʼєкта немає, адреса порожня». У межах цієї лекції ми не створюємо обʼєкти через вказівники й не керуємо памʼяттю вручну — ми використовуємо вказівники лише як спосіб передати адресу наявного обʼєкта або «порожнечу» як сигнал.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ