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), ви не отримаєте тиху невизначену поведінку десь згодом. Натомість одразу побачите зрозумілий сигнал: «контракт порушено».

Але будьте чесні: якщо у вашій програмі nullptr — це очікуваний сценарій, assert не має бути основним інструментом. У такому разі краще повернути статус, наприклад bool, і обробити «не вдалося» як звичайну гілку логіки.

3. Out‑параметр і try_*: статус + запис результату

Out‑параметр — це коли функція повертає результат не через 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) «я намагаюся обчислити або отримати значення; якщо успіх — запишу результат»

Важлива думка: тип 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]
    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, якщо це справді порушення контракту).

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