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? | Чи змінює дані? | Про що говорить контракт |
|---|---|---|---|
|
так (зазвичай) | ні | «обʼєкт може бути відсутнім, я лише читаю» |
|
так (за типом) | так | «я можу змінювати обʼєкт; можливо, він може бути відсутнім» |
|
зазвичай ні | так (у *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, якщо це справді порушення контракту).
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ