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. Воно означає: «адреси немає, обʼєкт не задано, звертатися нікуди».
Мінітаблиця, щоб швидко зорієнтуватися:
| Що це | Приклад | Зміст |
|---|---|---|
| Обʼєкт | |
«є значення 10» |
| Адреса обʼєкта | |
«де лежить x» |
| Вказівник | |
«p зберігає адресу x» |
| Порожній вказівник | |
«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. Це зручно, коли відсутність аргументу — нормальна ситуація, а не помилка.
Невелика таблиця-порівняння — без філософії, суто для практики:
| Контракт | Як виглядає | Може бути «порожнім»? | Що зобовʼязана зробити функція |
|---|---|---|---|
| «Обʼєкт точно є» | |
Ні | Можна сміливо змінювати |
| «Обʼєкт може бути відсутнім» | |
Так () |
Перевірити перед |
Тут важливо звикнути читати сигнатури як дорожні знаки. Коли досвідчений розробник бачить 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, просто не ініціалізований». Це означає «обʼєкта немає, адреса порожня». У межах цієї лекції ми не створюємо обʼєкти через вказівники й не керуємо памʼяттю вручну — ми використовуємо вказівники лише як спосіб передати адресу наявного обʼєкта або «порожнечу» як сигнал.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ