Як насправді працюють вказівники в C: Пояснення адрес пам’яті

1

Більшість розробників сприймають пам’ять як «чорну скриньку». Вони вважають, що змінні – це просто імена, прикріплені до значень. Це працює доти, доки вам не знадобиться писати код, що маніпулює даними безпосередньо на машинному рівні. Тоді ви стикаєтеся з вказівниками.

Звичайна змінна проста. Це фрагмент пам’яті з ім’ям, яке ви використовуєте у коді. Оголосіть цілісну змінну i, і компілятор зарезервує чотири байти. Ви бачите i. Машина бачить адресу. Значення перебуває у цих чотирьох байтах.

Покажчики ламають цю просту модель.

Покажчик не зберігає значення. Він зберігає адресу. Він свідчить про іншу змінну. Конкретніше, він зберігає розташування цієї змінної у пам’яті. Ця відмінність є критично важливою. Якщо ви їх переплутаєте, ваша програма аварійно завершить роботу. Жорстко.

Структура має дворівневий характер. По-перше, існує сама змінна-покажчик. Вона містить числову адресу. По-друге, існує змінна мета. Адреса в покажчику призводить до реальних даних. Як тільки ви зрозумієте цей поділ, ви відкриєте собі значні можливості в системному програмуванні.

Розглянемо цей стандартний приклад на C:

`

Перший рядок оголошує дві звичайні цілочисельні змінні, i та j. Наступний рядок, int *p, повідомляє покажчик. Зірочка є ключовою. Вона повідомляє компілятору, що p — це ціле число. Це вказівник на ціле число. Ви можете зробити те ж саме з числами з плаваючою комою, структурами, символами або ще. Синтаксис залишається тим самим.

Потім йде частина, яка часто збиває з пантелику новачків: p = &i;.

C амперсанд & є оператором адреси. &i не повертає значення i. Він повертає адресу пам’яті, де i. Привласнення p = &i копіює цю адресу в покажчик p. Тепер p вказує на i.

Якщо ви пропустите цей крок, p міститиме сміття. Він міститиме випадкову, невизначену адресу. Наступне розіменування, швидше за все, спричинить помилку сегментації. Програма аварійно завершить роботу, тому що ви наказали отримати доступ до пам’яті, якої вона не володіє.

Візуалізація допомагає. Уявіть пам’ять як сітку.

До ініціалізації i, j та p існують, але містять невідомі значення. Цілі числа – це коробки зі знаком питання. Вони можуть утримати будь що. Вказівник p – це коло. Він має стрілки, що вказують у випадкових напрямах. Він ще не знає, куди йти.

Після виконання p = & i стрілки змінюються. Усі вони вказують прямо на i. Тепер p точно знає, де знаходиться i.

Зв’язок очевидний. p зберігає місцезнаходження. i зберігає дані. Ви використовуєте p, щоб дістатися i.

Цей механізм дозволяє ефективно управляти пам’яттю. Він дозволяє функціям змінювати змінні, передані за посиланням. Він забезпечує динамічне виділення пам’яті. Він дозволяє створювати складні структури даних, такі як зв’язкові списки та дерева.

Але він також ризикує. Одна неправильна адресація, і ви читаєте чи записуєте дані у порожнечу.

Зірочка в p = 5 робить щось інше, ніж та, що в int * p. Друга зірочка була частиною визначення типу. Вона оголошувала змінну як покажчик. Перша зірочка – це оператор розіменування. Він означає «перейти на адресу, що зберігається в p, і змінити значення там».

Таким чином, *p = 5 поміщає число 5 у комірку пам’яті, на яку вказує p. Оскільки p вказує на i, i стає рівним 5. Змінною j потім надається значення i. Виведення на екран покаже 5, 5 і 5. Всі три значення відображають одну і ту ж фундаментальну зміну.

Розуміння того, як працюють покажчики C, вимагає зміни мислення. Перестаньте думати про змінні як контейнери. Почніть думати про них як про місцезнаходження. Вказівник – це карта. Змінна – це пункт призначення. Якщо карта неправильна, ви губитеся. А в низькорівневому програмуванні втрата орієнтації коштує дорого.

Пояснення оператора непрямого доступу (indirection operator)

Коли ви присвоюєте адресу змінної i покажчику p, ви фактично даєте одному й тому блоку пам’яті два різних імені. Змінна i, як і раніше, звертається до нього безпосередньо, але p тепер служить ручкою (handle), точкою відліку для доступу до тих же даних.

У C ця двоїстість визначає роботу вказівних змінних. Сам покажчик p – це просто контейнер, що зберігає адресу. Він не містить значення, яке вас цікавить. Натомість він зберігає місце, де це значення знаходиться. Тут входить у гру оператор непрямого доступу *.

Використання *p дозволяє отримати доступ до даних за адресою, що зберігається в p. Це можна назвати розйменуванням (dereferencing). Якщо ви пишете *p = 5, ви не змінюєте адресу, що зберігається всередині p. Ви переходите за адресою, на яку вказує p, і перезаписуєте значення числом 5, що знаходиться там.

Ця відмінність є критично важливою для розуміння управління пам’яттю. p вказує, де шукати. *p вказує, що саме потрібно подивитися. Плутанина між ними призводить до помилок, які важко відстежити, оскільки ви можете випадково змінити адресу замість даних або спробувати записати дані у випадкову область пам’яті через некоректну ініціалізацію p.

Як тільки ви опануєте цю ментальну модель, покажчики перестають бути абстрактним синтаксисом і стають прямим каналом доступу до пам’яті. Ви можете змінювати змінні в різних областях видимості, передавати великі структури за посиланням без їх копіювання та створювати динамічні структури даних, які зростають та стискаються за необхідності. Це потужний інструмент, якщо ви перестанете боротися з синтаксисом.

Наведений нижче фрагмент коду ілюструє саме таку поведінку, показуючи, як значення i змінюється тільки тому, що для доступу до нього використовується p.

`

Значення i оновлюється миттєво. Адреса не змінилася. Змінився лише вміст.

Як вказівники зберігають значення та адреси

Здається, що все просто, але механіка роботи може поставити вас у глухий кут. Оскільки змінної i присвоєно значення 5, а покажчик p зберігає адресу i, розіменування p (*p ) повертає 5. Коли ви виконуєте j = i;, ви копіюєте це значення. Таким чином, j стає рівним 5.

Виконайте оператор printf зараз. Ви отримаєте 5 5 5.

Це відбувається через подвійну природу покажчиків. Уявіть, що вони мають дві різні ролі. По-перше, сама змінна-покажчик зберігає адресу в пам’яті. По-друге, вона вказує на конкретний тип даних, розташований на цій адресі.

У нашому прикладі:
p – це покажчик. Він зберігає розташування у пам’яті.
*p – це значення. Це фактичні дані, що зберігаються на цій адресі.

Змішування цих понять призводить до помилок. Якщо ви сприймаєте адресу як дані, ви отримуєте сміття. Якщо ви сприймаєте дані як адресу, програма аварійно завершує роботу. Розуміння того, з чим ви маєте справу, змінює все.

Чому ця відмінність важлива для повсякденного програмування? Тому що розуміння різниці між тим, де зберігаються дані, і тим, що являють собою дані, дозволяє вам безпосередньо маніпулювати пам’яттю. Це корисно для динамічних структур, але небезпечно, якщо ви припускаєтеся помилки. Один неправильний рух, і система спробує прочитати дані з неіснуючого розташування.

Ви починаєте розуміти, чому C залишається таким самим потужним, як і небезпечним.