1. Знайомство з array-to-pointer conversion (decay)
У масивів у C++ є одна історична особливість: мова зберегла підхід із часів, коли багато API хотіли приймати просто адресу даних. Тому у виразах масив часто поводиться не як «контейнер із N елементів», а як «адреса першого елемента». Це перетворення називають array-to-pointer conversion або decay (у розмовній мові — «масив протух до вказівника»).
Навіщо це зроблено? Переважно заради зручності та сумісності з мовою C: функціям і старішим інтерфейсам було простіше працювати з «початком даних». Але за цю зручність доводиться платити: адреса не зберігає розмір, а для масиву розмір — одна з головних характеристик.
Головна думка цієї лекції така: «масив як об’єкт» і «імʼя масиву у виразі» — не одне й те саме. Якщо їх плутати, логіка програми дуже швидко починає ламатися.
Масив як об’єкт: памʼять, елементи та розмір
Важливо відразу впорядкувати картину: масив — це об’єкт. Він займає памʼять одразу під усі елементи та має фіксовану довжину, відому на етапі компіляції.
Продовжимо розвивати наш проєкт: таблиця результатів — наприклад, очки за 5 раундів.
#include <cstddef>
#include <iostream>
int main() {
constexpr std::size_t N = 5;
int scores[N] = {10, 12, 8, 15, 9};
std::cout << scores[0] << ' ' << scores[N - 1] << '\n'; // 10 9
}
Поки ми звертаємося до scores[i] і використовуємо N, усе зрозуміло: є довжина, є елементи, є коректні межі 0..N-1.
Поки ви маєте справу саме з масивом, можна безпечно порахувати кількість елементів, зробити sizeof(scores), використати std::size(scores) і загалом почуватися впевнено.
Мінімальна модель вказівника: «вказівник = адреса»
Слово «вказівник» звучить так, ніби зараз почнуться страшні історії про витоки памʼяті, new/delete і нічні кошмари відладчика. Тут цього не буде: сьогодні вказівник — це просто змінна, що зберігає адресу, тобто «місце в памʼяті», де лежить певний об’єкт.
Проста аналогія: масив — це «полиця з книжками», а вказівник — «наліпка з координатою, де стоїть перша книжка». Наліпка корисна, але якщо на ній не написано, скільки книжок на полиці, ви можете спробувати взяти «шосту книжку» там, де полиця вже закінчилася.
Мінімальна практична модель, яка нам тут потрібна, така: якщо p — вказівник на int, то вираз p[0] означає «перший int за цією адресою». Ми не обговорюємо, чому це так влаштовано, і не використовуємо арифметику вказівників. Просто приймаємо, що в C++ так можна звертатися до даних за адресою.
2. Decay на практиці: масив перетворюється на адресу першого елемента
Тепер — головний момент лекції. Для масиву int scores[N] є важлива властивість:
- scores як об’єкт — це масив із N елементів;
- але scores у більшості виразів автоматично перетворюється на int*, тобто на адресу першого елемента.
На практиці це виглядає так:
#include <cstddef>
#include <iostream>
int main() {
constexpr std::size_t N = 5;
int scores[N] = {10, 12, 8, 15, 9};
int* p = scores; // decay: p "дивиться" на scores[0]
std::cout << scores[0] << ' ' << p[0] << '\n'; // 10 10
}
Тут і відбулося перетворення «масив → адреса першого елемента». Вказівник p вказує на те саме місце, де лежить scores[0].
Якщо хочеться побачити це зовсім явно, можна безпосередньо взяти адресу першого елемента:
#include <cstddef>
#include <iostream>
int main() {
constexpr std::size_t N = 5;
int scores[N] = {10, 12, 8, 15, 9};
int* p1 = scores; // неявно (decay)
int* p2 = &scores[0]; // явно
std::cout << p1 << '\n'; // наприклад: 0x7ff... (адреса у вас буде іншою)
std::cout << p2 << '\n'; // та сама адреса
}
std::cout для int* друкує адресу, зазвичай у шістнадцятковому вигляді. Точний формат залежить від реалізації, але сенс той самий: це «координата в памʼяті».
5. Ціна decay: розмір втрачено
Чому розмір не можна «отримати» з T*
Коли у вас був масив scores[N], ви знали N. Коли у вас залишився тільки int* p, ви знаєте лише, де лежить перший елемент. Але ви не знаєте, скільки елементів далі належать цьому масиву, а скільки — уже ні.
Вказівник — це не контейнер і не «розумна структура». Це буквально адреса. Тому запит «дай мені розмір масиву, на який вказує p» концептуально неможливий: мова не зберігає «розмір масиву» всередині int*.
Щоб наочно відчути проблему, зробімо демонстрацію «поганої ідеї»: спробуймо обчислити кількість елементів через sizeof від вказівника.
#include <cstddef>
#include <iostream>
int main() {
constexpr std::size_t N = 5;
int scores[N] = {10, 12, 8, 15, 9};
int* p = scores;
std::size_t wrongCount = sizeof(p) / sizeof(p[0]);
std::cout << wrongCount << '\n'; // часто 2 на 64-bit (8 / 4), але це НЕ розмір масиву
}
Цей результат не повʼязаний із кількістю елементів. Він повʼязаний лише з тим, що «розмір адреси» (наприклад, 8 байтів) ділиться на «розмір int» (наприклад, 4 байти). Це випадкова математика, а не логіка програми.
sizeof(масив) і sizeof(вказівник) — різні сутності
sizeof вимірює розмір типу або об’єкта в байтах. І тут масив та вказівник принципово різняться:
- sizeof(scores) — розмір усього масиву (усіх елементів разом);
- sizeof(p) — розмір змінної-адреси (зазвичай 4 або 8 байтів), але не самих даних.
#include <cstddef>
#include <iostream>
int main() {
constexpr std::size_t N = 5;
int scores[N] = {10, 12, 8, 15, 9};
int* p = scores;
std::cout << "sizeof(scores) = " << sizeof(scores) << '\n';
std::cout << "sizeof(p) = " << sizeof(p) << '\n';
}
Типова картина на 64‑бітній системі така: sizeof(scores) буде 20 (5 * 4), а sizeof(p) — 8. Але не привʼязуйтеся до конкретних чисел: навіть якщо int матиме інший розмір, логіка не зміниться — масив «важкий», а вказівник «легкий».
Як правильно отримувати довжину масиву
Початківці часто запамʼятовують формулу:
sizeof(a) / sizeof(a[0])
Вона справді працює… доки a — масив. Щойно a перетворюється на вказівник, ви вже ділите «розмір адреси» на «розмір елемента» й отримуєте нісенітницю.
#include <cstddef>
#include <iostream>
int main() {
int a[5] = {1, 2, 3, 4, 5};
int* p = a;
std::size_t ok = sizeof(a) / sizeof(a[0]);
std::size_t bad = sizeof(p) / sizeof(p[0]);
std::cout << ok << ' ' << bad << '\n'; // 5 2 (bad — сміттєва логіка)
}
Зручніший і читабельніший спосіб отримати кількість елементів масиву — std::size(a). Він «розуміє», що йому передали саме масив, і повертає правильну кількість.
#include <cstddef>
#include <iostream>
#include <iterator> // std::size
int main() {
int a[5] = {1, 2, 3, 4, 5};
int* p = a;
std::cout << std::size(a) << '\n'; // 5
// std::cout << std::size(p) << '\n'; // так не можна: p не масив, у нього немає довжини
(void)p;
}
6. Практика: виведення та обхід через вказівник
Чому std::cout << a; не друкує елементи
Є ще один кумедний момент, на який натрапляє майже кожен: ви пишете std::cout << a; і очікуєте побачити 1 2 3 4 5, а натомість отримуєте щось на кшталт 0x7ff....
Причина та сама: у виразі a перетворюється на int*, а operator<< для вказівника друкує адресу.
#include <iostream>
int main() {
int a[3] = {10, 20, 30};
std::cout << a << '\n'; // 0x7ff... (адреса, НЕ "10 20 30")
std::cout << a[0] << '\n'; // 10
}
Якщо ви хочете вивести елементи, робіть це в циклі (поки що без алгоритмів і без range-for, щоб не забігати наперед):
#include <cstddef>
#include <iostream>
int main() {
constexpr std::size_t N = 3;
int a[N] = {10, 20, 30};
for (std::size_t i = 0; i < N; ++i) {
std::cout << a[i] << ' ';
}
std::cout << '\n'; // 10 20 30
}
Якщо у вас залишився лише T*, довжину зберігайте окремо
Практичне правило, яке рятує нерви: щойно ви переходите до вказівника, поруч із ним обовʼязково має бути довжина.
У межах цієї лекції робімо це максимально прямолінійно: є N, є p, і ми обходимо p[i], але межу циклу беремо з N, а не «намагаємося вгадати».
#include <cstddef>
#include <iostream>
int main() {
constexpr std::size_t N = 5;
int scores[N] = {10, 12, 8, 15, 9};
int* p = scores;
int sum = 0;
for (std::size_t i = 0; i < N; ++i) {
sum += p[i];
}
std::cout << "sum=" << sum << '\n'; // sum=54
}
Якщо хочеться посилити дисципліну (і зменшити ризик «випадково змінити N»), можна взяти довжину з масиву через std::size(scores), але лише до того моменту, поки масив ще не «втратив масивність» і не перетворився на вказівник:
#include <cstddef>
#include <iostream>
#include <iterator>
int main() {
int scores[5] = {10, 12, 8, 15, 9};
const std::size_t n = std::size(scores);
int* p = scores;
for (std::size_t i = 0; i < n; ++i) {
std::cout << p[i] << ' ';
}
std::cout << '\n'; // 10 12 8 15 9
}
Схема: що саме відбувається під час decay
Щоб мозок не перетворював усе це на «магічний ритуал компілятора», корисно тримати в голові просту схему: є об’єкт-масив, а є вираз, який часто перетворюється на адресу.
flowchart TD
A["int scores[N] (масив як обʼєкт)
має N елементів"] --> B["scores (у виразі)"]
B --> C["int* p = scores;
p зберігає адресу scores[0]"]
C --> D["p[i] (доступ до даних за адресою)
але i потрібно обмежувати окремо"]
Сенс цієї схеми простий: масив і вказівник повʼязані, але це не одне й те саме.
7. Типові помилки
Помилка № 1: думати, що T* «знає довжину масиву».
Після int* p = a; багато хто інтуїтивно очікує, що p зберігає не лише адресу, а й розмір. Але розмір масиву не є частиною вказівника. Якщо ви обходите p циклом, межу потрібно зберігати окремо: константою N, значенням std::size(a) до decay тощо. Інакше дуже легко вийти за межі масиву й отримати невизначену поведінку.
Помилка № 2: обчислювати кількість елементів через sizeof(p) / sizeof(p[0]).
Це виглядає логічно, доки ви не усвідомите, що sizeof(p) — це розмір адреси, а не даних. Отримане число часто здається «правдоподібним» (наприклад, 2), тому така помилка може довго лишатися непоміченою. Правильна формула sizeof(a) / sizeof(a[0]) працює лише для справжнього масиву, де a — об’єкт-масив, а не вказівник.
Помилка № 3: писати std::cout << a; і очікувати друк елементів.
Потокове виведення бачить не «масив», а «адресу першого елемента» — результат decay. Саме тому воно друкує адресу. Щоб вивести елементи, потрібен цикл і явне виведення a[i]. Якщо ви бачите у виводі 0x..., це не «зламався cout», а просто команда вивести адресу.
Помилка № 4: змішувати «останній індекс» і «розмір».
Після переходу до вказівника особливо легко переплутати межі: розмір — це N, останній індекс — N - 1. Помилка на кшталт i <= N у циклі перетворюється на спробу прочитати p[N], тобто «шостий елемент» у масиві з пʼяти. Навіть якщо програма «не падає», це не означає, що все добре: вона може тихо читати чужу памʼять.
Помилка № 5: намагатися пояснювати собі p[i] через арифметику вказівників і відразу починати експериментувати.
Так, у вказівників є арифметика, але це окрема тема зі своїми граблями. У межах цієї лекції тримайтеся мінімальної моделі: «вказівник — адреса», «p[i] дає доступ до i‑го елемента», «межі задаємо розміром окремо». Це дасть стійку базу й не перетворить навчання на сафарі у світі невизначеної поведінки.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ