Вказівники, const і масиви
Коли ми поєднуємо вказівники з модифікатором const або з масивами, синтаксис C++ може здатися дещо заплутаним. На цьому уроці ми розберемо всі можливі комбінації const та вказівників, а також дізнаємося, що масиви "під капотом" приховують ті самі вказівники.
Вказівники та const
До цього моменту всі наші вказівники були звичайними (неконстантними) і вказували на звичайні змінні. Ми могли:
- Змінити адресу, яку зберігає вказівник.
- Змінити значення за цією адресою.
Але що відбудеться, якщо ми спробуємо вказати на const змінну?
const int value = 7;
// int *ptr = &value; // ❌ ПОМИЛКА: Неможливо конвертувати const int* в int*
Якби C++ дозволив це, ми б змогли написати *ptr = 8; і тим самим змінити константу, зруйнувавши сам сенс слова "константа"! Тому компілятор це забороняє.
Існує три різні способи поєднати const із вказівниками. Синтаксис може здаватися складним, але є просте правило: читайте оголошення справа наліво, звертаючи увагу на те, де стоїть const відносно зірочки *.
1. Вказівник на константу (const int*)
Якщо const стоїть ДО зірочки, це означає: "можна змінювати сам вказівник (куди він вказує), але НЕ МОЖНА змінювати значення, на яке він вказує".
int a = 5;
const int b = 7;
// Читаємо справа наліво: ptr1 - це вказівник (*) на ціле число (int), яке є постійним (const)
const int *ptr1 = &b; // ✅ Можна вказувати на константу
const int *ptr2 = &a; // ✅ Можна вказувати і на звичайну змінну (але для нас вона тимчасово стане "read-only")
ptr1 = &a; // ✅ ДОЗВОЛЕНО: Ми можемо перепризначити сам вказівник на іншу адресу
// *ptr1 = 10; // ❌ ЗАБОРОНЕНО: Ми не можемо змінювати значення через цей вказівник
const int&).2. Константний вказівник (int* const)
Якщо const стоїть ПІСЛЯ зірочки, це означає: "вказівник назавжди прив'язаний до однієї адреси, але значення за цією адресою змінювати МОЖНА".
Такий вказівник обов'язково треба ініціалізувати при створенні:
int a = 5;
int b = 10;
// Читаємо справа наліво: ptr - це постійний (const) вказівник (*) на ціле число (int)
int * const ptr = &a;
*ptr = 8; // ✅ ДОЗВОЛЕНО: a тепер дорівнює 8. Змінювати значення можна!
// ptr = &b; // ❌ ЗАБОРОНЕНО: ptr назавжди прив'язаний до адреси змінної a!
Це дуже схоже на поведінку звичайних посилань (int&).
3. Константний вказівник на константу (const int* const)
Ми можемо поставити const і до, і після зірочки. Це заблокує обидві можливості: "вказівник назавжди прив'язаний до адреси, і значення за нею змінювати не можна".
int a = 5;
// Постійний (const) вказівник (*) на ціле число (int), яке є постійним (const)
const int * const ptr = &a;
// ptr = &b; // ❌ ЗАБОРОНЕНО
// *ptr = 10; // ❌ ЗАБОРОНЕНО
Підсумок по const
Щоб не плутатися, запам'ятайте цю табличку або просто подумки діліть оголошення по зірочці (*):
- Все, що зліва від
*— стосується значення в пам'яті. - Все, що справа від
*— стосується самого вказівника (адреси).
| Оголошення | Чи можна змінити ptr = &b? | Чи можна змінити *ptr = 5? |
|---|---|---|
int *ptr | ✅ | ✅ |
const int *ptr | ✅ | ❌ |
int * const ptr | ❌ | ✅ |
const int * const ptr | ❌ | ❌ |
Вказівники і масиви (Decay)
У C++ масиви фіксованого розміру (C-style arrays) та вказівники пов'язані дуже тісно. Подивіться на цей код:
int arr[4] = { 5, 8, 6, 4 };
cout << arr << "\n";
Рядок cout << arr замість того, щоб вивести вміст масиву, виведе шістнадцяткову адресу: 0x004BF968.
Більше того, ця адреса абсолютно збігається з адресою першого елемента: cout << &arr[0].
У більшості випадків, коли ми використовуємо ім'я масиву, воно автоматично ("неявно") перетворюється на вказівник на свій перший елемент. Це явище називається Array Decay ("розпад масиву").
int arr[4] = { 5, 8, 6, 4 };
int *ptr = arr; // arr автоматично розпався на int* (вказівник на arr[0])
cout << *ptr << "\n"; // Вгадайте результат? Виведе 5!
Відмінності між масивом і вказівником
Попри "розпад", масив arr і вказівник ptr — це не одне й те саме. Головна різниця випливає при використанні оператора sizeof.
sizeof(масив)повертає загальний об'єм масиву в байтах (кількість елементів * розмір елемента).sizeof(вказівник)повертає лише розмір самої адреси (зазвичай 4 або 8 байт).
#include <iostream>
using namespace std;
int main()
{
int arr[4] = { 5, 8, 6, 4 };
int *ptr = arr;
cout << "sizeof(arr): " << sizeof(arr) << "\n"; // 4 * 4 байти = 16
cout << "sizeof(ptr): " << sizeof(ptr) << "\n"; // Розмір вказівника = 8 (на 64-бітних ОС)
return 0;
}
Передача масивів у функції
Чому масиви не можна передавати у функції за значенням? Бо копіювати масив на 1 000 000 елементів — це дуже повільно! Тому C++ оптимізує цей процес: коли ви передаєте масив у функцію, він завжди передається як вказівник (Array Decay).
// Ці дві сигнатури абсолютно ідентичні для компілятора!
void printArray(int arr[]);
void printArray(int *arr);
Масив скопіював лише свою 8-байтну адресу в параметр arr. Це швидко, але має два наслідки:
- Функція, змінюючи
arr[i], змінює оригінальний масив (адже вона працює з оригіналом через вказівник). - Оператор
sizeof(arr)всередині функції поверне8, а не розмір масиву. Звідси славнозвісна проблема: вказівник не знає, скільки в масиві елементів. Саме тому в С-стилі разом із масивом завжди передають параметрsize.
Символьні константи рядків (String Literals)
Чи замислювалися ви коли-небудь, чим є текст у подвійних лапках?
cout << "Hello, World!";
Коли програма компілюється, рядок "Hello, World!" розміщується у спеціальному захищеному розділі оперативної пам'яті "Тільки для читання" (Read-Only Memory).
А сам літерал "Hello, World!" неявно стає вказівником на константний символ (const char*), який вказує на літеру 'H' з цього захищеного розділу.
const char *name = "John"; // ✅ name - це вказівник на Read-Only рядок "John\0"
cout << name; // Виведе John
char.char arrName[] = "John"; // Створює локальний масив і КОПІЮЄ туди літери.
arrName[0] = 'D'; // Можна змінювати! Стане "Dohn".
const char *ptrName = "John"; // Вказівник на Read-Only оригінал в пам'яті.
// ptrName[0] = 'D'; // ❌ ПОМИЛКА під час виконання! (Access Violation).
Чому cout обробляє char* інакше?
Ви могли помітити дивину: якщо вивести в cout вказівник int*, ми побачимо адресу (наприклад 0x00A1). Чому ж cout << name виводить слово "John", а не адресу символу 'J'?
Справа в тому, що творці бібліотеки iostream зробили для типу char* (і const char*) спеціальний виняток: замість виведення адреси, cout інтерпретує його як рядок (C-style). Він виводить символ за адресою, зміщує адресу на 1, виводить наступний символ, і так продовжує, доки не зустріне нуль-термінатор '\0'.
Іноді це призводить до кумедних багів. Якщо ви маєте масив символів без \0 і спробуєте вивести його адресу, або якщо ви спробуєте вивести адресу звичайного char:
char ch = 'R';
cout << &ch; // &ch має тип char*, тому cout подумає, що це рядок!
Базуючись на правилі вище, cout виведе літеру R, а потім продовжить читати пам'ять далі (виводячи «сміття»: значки, ієрогліфи), аж поки випадково не натрапить на нульовий байт.
Підсумок
const і вказівники
*. const int* — не можна змінювати значення. int* const — не можна змінювати, куди дивиться вказівник.Array Decay
arr) за замовчуванням "розпадається" (decay) і перетворюється на вказівник на найперший елемент &arr[0].Передача масивів
int arr[]. Розмір масиву (через sizeof) при цьому втрачається.Символьні рядки
"Рядок" зберігаються у Read-Only пам'яті та представляють собою const char*. А от char[] — це локальна копія, яку можна мутувати.Посилання (References)
Посилання в мові C++. Різниця між посиланнями та вказівниками. l-value та r-value. Передача аргументів за посиланням. Константні посилання.
Адресна арифметика
Додавання та віднімання з вказівниками (pointer arithmetic). Як масиви зберігаються в пам'яті. Синтаксичний цукор індексації масивів (array[i]). Ітерація за допомогою вказівників.