Покажчики, посилання і масиви в C і C++: крапки над i

У цьому пості я постараюся остаточно розібрати такі тонкі поняття в C і C++, як покажчики, посилання і масиви. Зокрема, я відповім на питання, так є масиви C покажчиками чи ні.

Позначення і припущення
  • Я буду припускати, що читач розуміє, що, наприклад, в C++ є посилання, а у C — немає, тому я не буду постійно нагадувати, про який саме мовою (C/C++ чи саме C++) я зараз кажу, читач зрозуміє це з контексту;
  • Також, я вважаю, що читач вже знає C і C++ на базовому рівні і знає, наприклад, синтаксис оголошення посилання. У цьому пості я буду займатися саме допитливим розбором дрібниць;
  • Буду позначати типи так, як виглядало б оголошення змінної TYPE відповідного типу. Наприклад, типу «масив довжини 2 int'ов» я буду позначати як
    int TYPE[2]
  • Я буду припускати, що ми в основному маємо справу зі звичайними типами даних, такими як
    int TYPE
    ,
    int *TYPE
    і т. д., для яких операції =, &, * та інші не перевизначені і позначають звичайні речі;
  • «Об'єкт» завжди буде означати «все, що не посилання», а не «екземпляр класу»;
  • Скрізь, за винятком спеціально обумовлених випадків, маються на увазі C89 і C++98.


Вказівники та посилання
Покажчики. Що таке покажчики, я розповідати не буду. :) Будемо вважати, що ви це знаєте. Нагадаю лише такі речі (всі приклади коду передбачаються знаходяться всередині якої-небудь функції, наприклад, main):

int x;
int *y = &x; // Від будь-якої змінної можна взяти адресу за допомогою операції взяття адреси "&". Ця операція повертає покажчик
int z = *y; // Вказівник можна разыменовать за допомогою операції разыменовывания ".*". Це операція повертає той об'єкт, на який вказує покажчик


Також нагадаю наступне: char — це завжди рівно один байт і у всіх стандартах C і C++
sizeof (char) == 1
(але при цьому стандарти не гарантують, що міститься в байті саме 8 біт :)). Далі, якщо додати до покажчика на який-небудь тип T число, то реальне чисельне значення цього покажчика збільшиться на це число, помножене на
sizeof (T)
. Тобто якщо p має тип
T *TYPE
,
p + 3
еквівалентно
(T *)((char *)p + 3 * sizeof (T))
. Аналогічні міркування стосуються і віднімання.

Посилання. Тепер з приводу посилань. Посилання — це те ж саме, що і покажчики, але з іншим синтаксисом і деякими іншими важливими відмінностями, про які мова піде далі. Наступний код нічим не відрізняється від попереднього, за винятком того, що в ньому фігурують посилання замість покажчиків:
int x;
int &y = x;
int z = y;


Якщо зліва від знака присвоювання стоїть посилання, то немає ніякого способу зрозуміти, хочемо ми присвоїти самої посиланням або об'єкту, на який вона посилається. Тому таке присвоювання завжди присвоює об'єкту, а не посиланням. Але це не відноситься до ініціалізації посилання: ініціалізується, зрозуміло, сама посилання. Тому після ініціалізації посилання немає ніякого способу змінити її саму, тобто посилання завжди постійна (але не її об'єкт).

Lvalue. Ті вирази, яким можна привласнювати, називаються lvalue в C, C++ і багатьох інших мовах (це скорочення від «left value», тобто ліворуч від знака рівності). Інші вирази називаються rvalue. Імена змінних очевидним чином є lvalue, але не тільки вони. Вираження
a[i + 2]
,
some_struct.some_field
,
*ptr
,
*(ptr + 3)
— теж lvalue.

Дивовижний факт полягає в тому, що посилання і lvalue — це в якомусь сенсі одне і те ж. Давайте поміркуємо. Що таке lvalue? Це щось, чому можна привласнити. Тобто це якесь фіксоване місце в пам'яті, куди можна щось покласти. Тобто адресу. Тобто покажчик або посилання (як ми вже знаємо, вказівники та посилання — це два різних синтаксично способу в C++ висловити поняття адреси). Причому швидше за посилання, ніж покажчик, т. к. посилання можна помістити ліворуч від знака рівності і це буде означати призначення об'єкта, на який вказує посилання. Значить, lvalue — це посилання.

А що таке посилання? Це один з синтаксисів для адреси, тобто, знову-таки, чогось, куди можна класти. І посилання можна ставити ліворуч від знака рівності. Отже, посилання — це lvalue.

Окей, але ж (майже будь-яка) змінна теж може бути ліворуч від знака рівності. Значить, (така) змінна — посилання? Майже. Вираз, що представляє собою змінну — посилання.

Іншими словами, припустимо, ми оголосили
int x
. Тепер x — це змінна типу
int TYPE
і ніякого іншого. Це int і все тут. Але якщо я тепер пишу
x + 2
або
x = 3
, то в цих виразах підвираз
x
має тип
int &TYPE
. Тому що інакше цей x нічим не відрізнявся б від, скажімо, 10, і йому (як і десятці) не можна було б нічого привласнити.

Цей принцип («вираз, що є змінною — посилання») — моя вигадка. Тобто ні в якому підручнику, стандарті і т. д. я цей принцип не бачив. Тим не менш, він багато чого спрощує і його зручно вважати вірним. Якщо б я реалізовував компілятор, я б просто вважав там змінні у виразах посиланнями, і, цілком можливо, саме так і передбачається в реальних компіляторах.

Більш того, зручно вважати, що особливий тип даних для lvalue (тобто посилання) існує навіть і в C. Саме так ми і далі припускати. Просто поняття посилання не можна висловити синтаксично C, посилання можна оголосити.

Принцип «будь lvalue — посилання» — теж моя вигадка. А ось принцип «будь-яке посилання — lvalue» — цілком законний, загальновизнаний принцип (зрозуміло, що посилання має бути посиланням на змінюваний об'єкт, і цей об'єкт повинен допускати присвоювання).

Тепер, з урахуванням наших угод, сформулюємо суворо правила роботи з посиланнями: якщо оголошено, скажімо,
int x
, то тепер вираз x має тип
int &TYPE
. Якщо тепер це вираження (або будь-яке інше вираження типу посилання) стоїть ліворуч від знака рівності, то воно використовується як посилання, практично у всіх інших випадках (наприклад, в ситуації
x + 2
) x автоматично конвертується в тип
int TYPE
(ще однією операцією, поруч з якою посилання не конвертується в свій об'єкт, є &, як ми побачимо далі). Ліворуч від знака рівності може стояти тільки посилання. Ініціалізувати посилання може тільки посилання.

Операції * і &. Наші угоди дозволяють по-новому поглянути на операції * і &. Тепер стає зрозуміло наступне: операція * може застосовуватися тільки до покажчика (конкретно це було завжди відомо) і вона повертає посилання на той же тип. & застосовується завжди до посиланню і повертає покажчик того ж типу. Таким чином, * і & перетворюють вказівники та посилання один на одного. Тобто, по суті, вони взагалі нічого не роблять і лише замінюють сутності одного синтаксису на сутності іншого! Таким чином, & взагалі-то не зовсім правильно називати операцією взяття адреси: вона може бути застосована лише до вже існуючого адресою, просто вона змінює синтаксичне втілення цієї адреси.

Зауважу, що вказівники та посилання оголошуються
int *x
та
int &x
. Таким чином, принцип «оголошення підказує використання» зайвий раз підтверджується: оголошення покажчика нагадує, як перетворити його на заслання, а посилання оголошення — навпаки.

Також зауважу, що
&*EXPR
(тут EXPR — це довільне вираження, не обов'язково один ідентифікатор) еквівалентно EXPR завжди, коли має сенс (тобто завжди, коли EXPR — покажчик), а
*&EXPR
теж еквівалентно EXPR завжди, коли має сенс (тобто коли EXPR — посилання).

Масиви
Отже, є такий тип даних — масив. Оголошуються масиви, наприклад, так:
int x[5];

Вираз у квадратних дужках має бути неодмінно константою часу компіляції в C89 і C++98. При цьому в квадратних дужках повинно стояти число, порожні квадратні дужки не допускаються.

Подібно до того, як всі локальні змінні (нагадаю, ми припускаємо, що всі приклади коду знаходяться всередині функцій) знаходяться на стеку, масиви теж знаходяться на стеку. Тобто наведений код привів до виділення прямо на стеку величезного блока пам'яті розміром
5 * sizeof (int)
, в якому цілком розміщується наш масив. Не потрібно думати, що цей код оголосив якийсь покажчик, який вказує на пам'ять, розміщену десь там далеко, в купі. Ні, ми оголосили масив, самий справжній. Тут, на стеку.

Чому буде дорівнювати
sizeof (x)
? Зрозуміло, воно буде дорівнює розміру нашого масиву, тобто
5 * sizeof (int)
. Якщо ми пишемо
struct foo
{
int a[5];
int b;
};

то, знову-таки, місце для масиву буде цілком виділятися прямо всередині структури, і sizeof від цієї структури буде це підтверджувати.

Від масиву можна взяти адреса (
&x
), і це буде самий справжній курсор на те місце, де цей масив розташований. Тип у вирази
&x
, як легко зрозуміти,
int (*TYPE)[5]
. На початку масиву розміщений його нульовий елемент, тому адресу самого масиву та адресу його нульового елемента чисельно збігаються. Тобто
&x
та
&(x[0])
чисельно рівні (тут я хвацько написав вираз
&(x[0])
, насправді в ньому не все так просто, до цього ми ще повернемося). Але ці вирази мають різний тип —
int (*TYPE)[5]
та
int *TYPE
, тому порівняти їх за допомогою == не вийде. Але можна застосувати трюк з
void *
: наступне вираз буде істинним:
(void *)&x == (void *)&(x[0])
.

Добре, будемо вважати, я вас переконав, що масив — це масив, а не що-небудь ще. Звідки тоді береться вся ця плутанина між вказівниками і масивами? Справа в тому, що ім'я масиву майже при будь-яких операціях перетвориться в покажчик на його нульовий елемент.

Отже, ми оголосили
int x[5]
. Якщо ми тепер пишемо
x + 0
, то це перетворює наш x (який мав тип
int TYPE[5]
, або, більш точно,
int (&TYPE)[5]
),
&(x[0])
, тобто вказівник на нульовий елемент масиву x. Тепер наш x має тип
int *TYPE
.

Конвертування імені масиву
void *
або застосування до нього == теж призводить до попереднього перетворення цього імені в вказівник на перший елемент, тому:
&x == x // помилка компіляції, різні типи: int (*TYPE)[5] і int *TYPE
(void *)&x == (void *)x // істина
x == x + 0 // істина
x == &(x[0]) // істина


Операція []. Запис
a[b]
завжди еквівалентна
*(a + b)
(нагадаю, що ми не розглядаємо перевизначення
operator[]
та інших операцій). Таким чином, запис
x[2]
означає наступне:
  • x[2]
    еквівалентно
    *(x + 2)
  • x + 2
    відноситься до тих операцій, при яких ім'я масиву перетвориться в покажчик на його перший елемент, тому це відбувається
  • Далі, у відповідності з моїми поясненнями вище,
    x + 2
    еквівалентно
    (int *)((char *)x + 2 * sizeof (int))
    , тобто
    x + 2
    означає «зрушити вказівник x на два int'а»
  • Нарешті, від результату береться операція розіменування і ми витягуємо той об'єкт, який розміщений по цьому зрушаться вказівником


Типи брали участь у виразів наступні:
x // int (&TYPE)[5], після перетворення типу: int *TYPE
x + 2 // int *TYPE
*(x + 2) // int &TYPE
x[2] // int &TYPE


Також зауважу, що зліва від квадратних дужок необов'язково повинен стояти саме масив, там може бути будь-покажчик. Наприклад, можна написати
(x + 2)[3]
, і це буде еквівалентно
x[5]
. Ще зауважу, що
*a
та
a[0]
завжди еквівалентні, як у випадку, коли a — масив, так і a — покажчик.

Тепер, як я і обіцяв, я повертаюся до
&(x[0])
. Тепер ясно, що в цьому виразі спершу x перетвориться в покажчик, потім до цього покажчика згідно з вищенаведеним алгоритмом застосовується
[0]
і в результаті виходить значення типу
int &TYPE
, і нарешті, за допомогою & воно перетвориться до типу
int *TYPE
. Тому, пояснювати за допомогою цього складного виразу (всередині якого вже виконується перетворення масиву до покажчика) трохи більш просте поняття перетворення масиву до покажчика — це був трохи мухлеж.

А тепер питання на засипку: що таке
&x + 1
? Що ж,
&x
— це покажчик на весь масив цілком,
+ 1
призводить до кроку на весь цей масив. Тобто
&x + 1
— це
(int (*)[5])((char *)&x + sizeof (int [5]))
, тобто
(int (*)[5])((char *)&x + 5 * sizeof (int))
(
int (*)[5]
— це
int (*TYPE)[5]
). Отже,
&x + 1
чисельно дорівнює
x + 5
, а не
x + 1
, як можна було б подумати. Так, в результаті ми вказуємо на пам'ять, яка знаходиться за межами масиву (відразу після останнього елемента), але кого це хвилює? Адже в C все одно не перевіряється вихід за межі масиву. Також, зауважимо, що вираз
*(&x + 1) == x + 5
істинно. Ще його можна записати так:
(&x)[1] == x + 5
. Також буде істинним
*((&x)[1]) == x[5]
, або, що теж саме,
(&x)[1][0] == x[5]
(якщо ми, звичайно, не схопимо segmentation fault за спробу звернення за межі нашої пам'яті :)).

Масив не можна передати як аргумент у функцію. Якщо ви напишіть
int x[2]
або
int x[]
в заголовку функції, то це буде еквівалентно
int *x
та функцію завжди буде передаватися покажчик (sizeof від переданої змінної буде таким, як у вказівника). При цьому розмір масиву, зазначений у заголовку буде ігноруватися. Ви запросто можете вказати в заголовку
int x[2]
і передати туди масив довжини 3.

Однак, в C++ існує спосіб передати у функцію посилання на масив:
void f (int (&x)[5])
{
// sizeof (x) тут дорівнює 5 * sizeof (int)
}

int main (void)
{
int x[5];
f (x); // OK
f (x + 0); // Не
int y[7];
f (y); // не Можна, не той розмір
}

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

І що найцікавіше, цю передачу можна використовувати так:
// Обчислює довжину масиву
template < typename t, size_t n> size_t len (t (&a)[n])
{
return len;
}

Схожим чином реалізована функція std::end в C++11 для масивів.

«Вказівник на масив». Строго кажучи, «покажчик на масив» — це саме вказівник на масив і ніщо інше. Іншими словами:
int (*a)[2]; // покажчик на масив. Самий справжній. Він має тип int (*TYPE)[2]
int b[2];
int *c = b; // Це не вказівник на масив. Це просто покажчик. Покажчик на перший елемент якогось масиву
int *d = new int[4]; // І це не вказівник на масив. Це покажчик

Однак, іноді під фразою «вказівник на масив» неформально розуміють покажчик на область пам'яті, в якій розміщений масив, навіть якщо тип у цього покажчика невідповідний. У відповідності з таким неформальним розумінням c і d (і
b + 0
) — це покажчики на масиви.

Багатовимірні масиви. Якщо оголошено
int x[5][7]
, то x — це не масив довжини 5 певних ознак, що вказують кудись далеко. Ні, x тепер — це єдиний монолітний блок розміром 5 x 7, розміщений на стеку.
sizeof (x)
дорівнює
5 * 7 * sizeof (int)
. Елементи розташовуються в пам'яті так:
x[0][0]
,
x[0][1]
,
x[0][2]
,
x[0][3]
,
x[0][4]
,
x[0][5]
,
x[0][6]
,
x[1][0]
і так далі. Коли ми пишемо
x[0][0]
, події розвиваються так:
x // int (&TYPE)[5][7], після перетворення: int (*TYPE)[7]
x[0] // int (&TYPE)[7], після перетворення: int *TYPE
x[0][0] // int &TYPE

Те ж саме відноситься до
**x
. Зауважу, що у виразах, скажімо,
x[0][0] + 3
та
**x + 3
в реальності витяг з пам'яті відбувається тільки один раз (незважаючи на наявність двох зірочок), в момент перетворення остаточної посилання типу
int &TYPE
просто
int TYPE
. Тобто якщо б ми поглянули на асемблерний код, який генерується з виразу
**x + 3
, ми б в ньому побачили, що операція витягання даних з пам'яті виконується там тільки один раз.
**x + 3
можна ще по-іншому записати як
*(int *)x + 3
.

А тепер подивимося на таку ситуацію:
int **y = new int *[5];

for (int i = 0; i != 5; i++)
{
y[i] = new int[7];
}


Що тепер є y? y — це покажчик на масив (у неформальному сенсі!) покажчики на масиви (знову-таки, в неформальному значенні). Ніде тут не з'являється єдиний блок розміру 5 x 7, є 5 блоків розміру
7 * sizeof (int)
, які можуть знаходитися далеко один від одного. Що є
y[0][0]
?
y // int **&TYPE
y[0] // int *&TYPE
y[0][0] // int &TYPE

Тепер, коли ми пишемо
y[0][0] + 3
, витяг з пам'яті відбувається два рази: витяг з масиву y і подальше вилучення з масиву
y[0]
, який може знаходитись далеко від масиву y. Причина цього в тому, що тут не відбувається перетворення імені масиву в вказівник на його перший елемент, на відміну від прикладу з багатовимірним масивом x. Тому
**y + 3
тут не еквівалентний
*(int *)y + 3
.

Поясню ще разок.
x[2][3]
еквівалентно
*(*(x + 2) + 3)
. І
y[2][3]
еквівалентно
*(*(y + 2) + 3)
. Але в першому випадку наше завдання знайти «третій елемент у другому ряду» в єдиному блоці розміру 5 x 7 (зрозуміло, елементи нумеруються з нуля, тому цей третій елемент буде в деякому сенсі четвертим :)). Компілятор обчислює, що насправді потрібний елемент знаходиться на
2 * 7 + 3
-му місці в цьому блоці і витягує його. Тобто
x[2][3]
тут еквівалентно
((int *)x)[2 * 7 + 3]
, або, що те ж саме,
*((int *)x + 2 * 7 + 3)
. У другому випадку спочатку витягує 2-й елемент у масиві y, а потім 3-й елемент в отриманому масиві.

У першому випадку, коли ми робимо
x + 2
, ми сдвигаемся відразу на
2 * sizeof (int [7])
, тобто
2 * 7 * sizeof (int)
. У другому випадку,
y + 2
— це зсув на
2 * sizeof (int *)
.

У першому випадку
(void *)x
та
(void *)*x
(
(void *)&x
!) — це один і той же покажчик, у другому — це не так.

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.