Адресна арифметика
Ми вже знаємо, що вказівник — це число (адреса в пам'яті). Відповідно, з цими "числами" можна виконувати математичні операції. У С++ дозволено додавати та віднімати цілі числа від вказівників.
Це явище називається адресною арифметикою (або арифметикою вказівників). І працює вона не зовсім так, як звичайна шкільна математика.
Як працює адресна арифметика?
Припустімо, у нас є вказівник ptr, що зберігає адресу 0x0010. Здавалося б, математичний вираз ptr + 1 має дати результат 0x0011.
Але це не так!
Вказівник знає тип даних, на який він вказує. Коли ми додаємо 1, компілятор розуміє це не як "перейди на 1 байт вперед", а як "перейди на 1 такий об'єкт вперед".
Тому формула адресної арифметики виглядає так: нова_адреса = поточна_адреса + (число * розмір_типу_в_байтах).
Розглянемо на прикладі:
#include <iostream>
using namespace std;
int main()
{
int value = 8;
int *iPtr = &value;
// Припустимо, iPtr має адресу 0x2CF9A4
cout << iPtr << "\n"; // 0x2CF9A4
cout << iPtr + 1 << "\n"; // 0x2CF9A8 (+ 4 байти)
cout << iPtr + 2 << "\n"; // 0x2CF9AC (+ 8 байт)
cout << iPtr + 3 << "\n"; // 0x2CF9B0 (+ 12 байт)
return 0;
}
Оскільки int зазвичай займає 4 байти, кожен крок вперед на +1 зміщує адресу рівно на 4 байти.
Але якщо ми використаємо тип short (що займає 2 байти), крок зміниться:
short val = 8;
short *sPtr = &val;
// Припустимо, sPtr має адресу 0x2BFA20
cout << sPtr << "\n"; // 0x2BFA20
cout << sPtr + 1 << "\n"; // 0x2BFA22 (+ 2 байти)
cout << sPtr + 2 << "\n"; // 0x2BFA24 (+ 4 байти)
Отже, компілятор всюди автоматично враховує sizeof(тип), щоб ми могли стрибати від об'єкта до об'єкта, не думаючи про їхній розмір у байтах.
Розташування елементів масиву в пам'яті
Ця математика здається дуже небезпечною, коли вказівник дивиться на одну змінну (value). Адже iPtr + 1 вкаже на якесь "сміття" після нашої змінної! Навіщо ж тоді це потрібно?
Головна (і фактично єдина) сфера застосування адресної арифметики — робота з масивами.
Масиви у С++ гарантовано розміщуються в оперативній пам'яті суцільним блоком "стик у стик". Переконаємось у цьому:
#include <iostream>
using namespace std;
int main()
{
int arr[4] = { 7, 8, 2, 4 };
cout << "arr[0] address: " << &arr[0] << "\n"; // Наприклад: 0x002CF6F4
cout << "arr[1] address: " << &arr[1] << "\n"; // Тоді тут: 0x002CF6F8
cout << "arr[2] address: " << &arr[2] << "\n"; // Тоді тут: 0x002CF6FC
cout << "arr[3] address: " << &arr[3] << "\n"; // Тоді тут: 0x002CF700
return 0;
}
Як бачимо, кожен елемент лежить чітко через 4 байти (розмір int) від попереднього.
Індексація масивів "розкрита"
Пам'ятаєте з минулого уроку, як назва масиву "розпадається" у вказівник на його перший елемент? Тобто arr дорівнює &arr[0].
Зведемо до купи три факти:
arr— це вказівник наarr[0].- Елементи масиву лежать послідовно.
- Додавання
+ nдо вказівника бере адресу елемента на відстаніn.
Тоді вираз arr + 1 дасть адресу arr[1].
А вираз arr + 2 дасть адресу arr[2].
А щоб отримати саме значення (а не просто адресу), нам достатньо розіменувати цей вказівник оператором *!
int arr[5] = { 7, 8, 2, 4, 5 };
cout << arr[1] << "\n"; // Виведе 8 (через індекс)
cout << *(arr + 1) << "\n"; // Виведе 8 (через арифметику вказівників!)
Дужки *(arr + 1) обов'язкові, оскільки оператор * має вищий пріоритет, ніж +. Без дужок *arr + 1 обчислиться як (значення arr[0]) + 1, тобто 7 + 1 = 8. Це збіглося з відповіддю, але якби ми хотіли другий елемент: *arr + 2 дало б 9 (7+2), а не потрібне 2.
!CAUTION Синтаксичний цукор! Секрет С++: оператор квадратних дужок
[]— це не якась магічна інструкція процесора. Це звичайнісінький синтаксичний цукор! Коли ви пишетеarr[n], компілятор перед збиранням програми автоматично перетворює цей рядок на*(arr + n).Саме тому індексація масивів у C++ починається з нуля. Бо перший елемент має зміщення (офсет) нуль:
*(arr + 0)— це те ж саме, що просто*arr.
Ітерація по масиву через вказівник
Знаючи, що [i] це просто замаскований + i, ми можемо переписувати цикли for для проходження по масиву, оперуючи безпосередньо вказівниками.
Це був єдиний можливий спосіб ітерації у ранніх версіях мови C. Сьогодні це радше історичний факт і спосіб для оптимізації в окремих "вузьких" місцях високопродуктивних бібліотек.
#include <iostream>
using namespace std;
int main()
{
const int SIZE = 5;
int arr[SIZE] = { 10, 20, 30, 40, 50 };
// Звичайна ітерація через індекс:
// for (int i = 0; i < SIZE; ++i) { cout << arr[i] << " "; }
// Ітерація через адресну арифметику (вказівник):
for (int *ptr = arr; ptr < arr + SIZE; ++ptr)
{
cout << *ptr << " ";
}
// Виведе: 10 20 30 40 50
return 0;
}
Як працює цей цикл?
int *ptr = arr— наш вказівникptrстає на старт (показує наarr[0]).ptr < arr + SIZE— умова зупинки.arr + SIZE— це адреса, яка знаходиться відразу ПОЗА межами масиву. Поки ми її не досягли, цикл продовжується.++ptr— операція інкремента на вказівнику рівнозначнаptr = ptr + 1. Вона пересуває стрілку вказівника на наступний елемент (на 4 байти вперед).*ptr— розіменування, щоб вивести число на екран.
for(int x : arr)) або звичайну індексацію arr[i], оскільки вони читабельніші та менш вразливі до помилок виходу за межі пам'яті (SegFault). Але розуміти, як працює адресна ітерація під капотом, — дуже важливо для глибокого володіння C++.Підсумок
🧮 Кроки арифметики
ptr + n зміщує адресу на відстань n * sizeof(Type) байтів. Це забезпечує безпечний перехід між елементами незалежно від їхнього розміру.📦 Пам'ять масивів
🍬 Синтаксичний цукор
arr[n] — це лише зручна для читання обгортка над чистою адресною математикою *(arr + n).Вказівники, const і масиви
Взаємодія вказівників із постійними значеннями (const). Вказівник на константу vs константний вказівник. Як масиви зводяться до вказівників. Символьні рядки.
Динамічна пам'ять
Стек і Купа (Stack vs Heap). Динамічне виділення пам'яті в C++. Оператори new та delete. Висячі вказівники та витоки пам'яті. Динамічні масиви.