На уроці №13 ми дізналися, що змінна — це назва шматочка пам’яті, який містить значення.
Оператор адреси &
При виконанні ініціалізації змінної, їй автоматично присвоюється вільна адреса в пам’яті, і, будь-яке значення, яке ми присвоюємо змінній, зберігається за цією адресою в пам’яті. Наприклад:
|
1 |
int b; |
При виконанні цього стейтменту процесором, виділяється частина оперативної пам’яті. В якості прикладу припустимо, що змінній b присвоюється комірка пам’яті під номером 150. Всякий раз, коли програма зустрічає змінну b в виразі чи в стейтменті, вона розуміє, що для того, щоб отримати значення — їй потрібно зазирнути в комірку пам’яті під номером 150.
Хороша новина — нам не потрібно турбуватися про те, які конкретно адреси в пам’яті виділені для певних змінних. Ми просто посилаємося на змінну через присвоєний їй ідентифікатор, а компілятор конвертує цей ідентифікатор у відповідну адресу в пам’яті. Однак цей підхід має деякі обмеження, які ми обговоримо на цьому і на наступних уроках.
Оператор адреси & дозволяє дізнатися, яку адресу в пам’яті присвоєно певній змінній. Все дуже просто:
|
1 2 3 4 5 6 7 8 9 10 |
#include <iostream> int main() { int a = 7; std::cout << a << '\n'; // виводимо значення змінної a std::cout << &a << '\n'; // виводимо адресу в пам'яті змінної a return 0; } |
Результат на моєму комп’ютері:
7
0046FCF0
Примітка: Хоча оператор адреси виглядає так само, як оператор побітового І, відрізнити їх можна по тому, що оператор адреси є унарним оператором, а оператор побітового І — бінарним оператором.
Оператор розіменування *
Оператор розіменування * дозволяє отримати значення по вказаній адресі:
|
1 2 3 4 5 6 7 8 9 10 11 |
#include <iostream> int main() { int a = 7; std::cout << a << '\n'; // виводимо значення змінної a std::cout << &a << '\n'; // виводимо адресу змінної a std::cout << *&a << '\n'; /// виводимо значення комірки в пам'яті змінної a return 0; } |
Результат на моєму комп’ютері:
7
0046FCF0
7
Примітка: Хоча оператор розіменування виглядає так само, як і оператор множення, відрізнити їх можна по тому, що оператор розіменування — унарний, а оператор множення — бінарний.
Вказівники
Тепер, коли ми вже знаємо про операторів адреси і розіменування, ми можемо поговорити про вказівники.
Вказівник (або “покажчик”) — це змінна, значенням якої є адреса комірки в пам’яті. Вказівники оголошуються так само, як і звичайні змінні, тільки із зірочкою між типом даних і ідентифікатором:
|
1 2 3 4 5 6 7 |
int *iPtr; // вказівник на значення типу int double *dPtr; // вказівник на значення типу double int* iPtr3; // коректний синтаксис (дозволено, але не бажано) int * iPtr4; // коректний синтаксис (не робіть так) int *iPtr5, *iPtr6; // оголошуємо два вказівники для змінних типу int |
Синтаксично мова C++ приймає оголошення вказівника, коли зірочка знаходиться поруч з типом даних, з ідентифікатором або навіть посередині. Зверніть увагу, ця зірочка НЕ є оператором розіменування. Це всього лише частина синтаксису оголошення вказівника.
Однак, при оголошенні кількох вказівників, зірочка повинна знаходитися біля кожного ідентифікатора. Це легко забути, якщо ви звикли вказувати зірочку біля типу даних, а не біля імені змінної. Наприклад:
|
1 |
int* iPtr3, iPtr4; // iPtr3 - це вказівник на значення типу int, а iPtr4 - це звичайна змінна типу int! |
З цієї причини, при оголошенні вказівника, рекомендується вказувати зірочку біля імені змінної. Як і звичайні змінні, вказівники не ініціалізуються при оголошенні. Вмістом неініціалізованого вказівника є звичайне сміття.
Присвоювання значень вказівнику
Оскільки вказівники містять тільки адреси, то при присвоюванні значення вказівнику — це значення повинно бути адресою. Для отримання адреси змінної використовується оператор адреси:
|
1 2 |
int value = 5; int *ptr = &value; // ініціалізуємо ptr адресою значення змінної |
Ось чому вказівники мають таку назву: ptr містить адресу значення змінної value, і, можна сказати, ptr вказує на це значення.
Ще дуже часто можна побачити наступне:
|
1 2 3 4 5 6 7 8 9 10 11 12 |
#include <iostream> int main() { int value = 5; int *ptr = &value; // ініціалізуємо ptr адресою значення змінної std::cout << &value << '\n'; // виводимо адресу значення змінної value std::cout << ptr << '\n'; // виводимо адресу, яку містить ptr return 0; } |
Результат на моєму комп’ютері:
003AFCD4
003AFCD4
Тип вказівника повинен відповідати типу змінної, на яку він вказує:
|
1 2 3 4 5 6 7 |
int iValue = 7; double dValue = 9.0; int *iPtr = &iValue; // ок double *dPtr = &dValue; // ок iPtr = &dValue; // неправильно: вказівник типу int не може вказувати на адресу змінної типу double dPtr = &iValue; // неправильно: вказівник типу double не може вказувати на адресу змінної типу int |
Наступне не є допустимим:
|
1 |
int *ptr = 7; |
Це пов’язано з тим, що вказівники можуть містити тільки адреси, а цілочисельний літерал 7 не має адреси в пам’яті. Якщо ви все ж зробите це, то компілятор повідомить вам, що він не може перетворити цілочисельне значення в цілочисельний вказівник.
Мова C++ також не дозволить вам напряму присвоювати адреси в пам’яті вказівнику:
|
1 |
double *dPtr = 0x0012FF7C; // не ок: розглядається як присвоювання цілочисельного літералу |
Оператор адреси повертає вказівник
Варто зазначити, що оператор адреси & не повертає адресу свого операнда в якості літералу. Замість цього він повертає вказівник, що містить адресу операнда, тип якого отримано з аргументу (наприклад, адреса змінної типу int передається як адреса вказівника на значення типу int):
|
1 2 3 4 5 6 7 8 9 10 |
#include <iostream> #include <typeinfo> int main() { int x(4); std::cout << typeid(&x).name(); return 0; } |
Результат виконання програми:
Розіменування вказівників
Як тільки у нас є вказівник, який вказує на що-небудь, ми можемо його розіменувати, щоб отримати значення, на яке він вказує. Розіменований вказівник — це вміст комірки в пам’яті, на яку він вказує:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
#include <iostream> int main() { int value = 5; std::cout << &value << std::endl; // виводимо адресу value std::cout << value << std::endl; // виводимо вміст value int *ptr = &value; // ptr вказує на value std::cout << ptr << std::endl; // виводимо адресу, яка зберігається в ptr (тобто &value) std::cout << *ptr << std::endl; // розіменовуємо ptr (отримуємо значення, на яке вказує ptr) return 0; } |
Результат:
0034FD90
5
0034FD90
5
Ось чому вказівники повинні мати тип даних. Без типу вказівник не знав би, як інтерпретувати вміст, на який він вказує (при розіменуванні). Також, тому і повинні збігатися тип вказівника з типом змінної. Якщо вони не збігатимуться, то вказівник при розіменуванні може неправильно інтерпретувати біти (наприклад, замість типу double використати тип int).
Одному вказівнику можна присвоювати різні значення:
|
1 2 3 4 5 6 7 8 9 10 |
int value1 = 5; int value2 = 7; int *ptr; ptr = &value1; // ptr вказує на value1 std::cout << *ptr; // виведеться 5 ptr = &value2; // ptr тепер вказує на value2 std::cout << *ptr; // виведеться 7 |
Коли адреса значення змінної присвоєна вказівнику, то виконується наступне:
ptr — це те ж саме, що і &value;
*ptr обробляється так само, як і value.
Оскільки *ptr обробляється так само, як і value, то ми можемо присвоювати йому значення так, наче це звичайна змінна. Наприклад:
|
1 2 3 4 5 |
int value = 5; int *ptr = &value; // ptr вказує на value *ptr = 7; // *ptr - це те ж саме, що і value, якому ми присвоїли значення 7 std::cout << value; // виведеться 7 |
Розіменування некоректних вказівників
Вказівники в мові С++ по своїй суті є небезпечними, а їх неправильне використання — один з найкращих способів отримати збій в програмі.
При розіменуванні вказівника, програма намагається перейти в комірку в пам’яті, яка зберігається в вказівнику і “витягнути” вміст цієї комірки. З міркувань безпеки сучасні операційні системи (скор. “ОС”) запускають програми в пісочниці для запобігання їх неправильної взаємодії з іншими програмами і для захисту стабільності самої операційної системи. Якщо програма спробує отримати доступ до комірки в пам’яті, не виділеної для неї операційною системою, то ОС відразу завершить виконання цієї програми.
Наступна програма добре ілюструє вищесказане. При запуску ви отримаєте збій (спробуйте, нічого страшного з вашим комп’ютером не відбудеться):
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
#include <iostream> void foo(int *&p) { } int main() { int *p; // створюємо неініціалізований вказівник (вмістом якого є сміття) foo(p); // вводимо компілятор в оману, ніби ми збираємося присвоїти вказівнику коректне значення std::cout << *p; // розіменовуємо вказівник зі сміттям return 0; } |
Розмір вказівників
Розмір вказівника залежить від архітектури, на якій скомпільовано виконуваний файл: 32-бітний виконуваний файл використовує 32-бітні адреси в пам’яті. Відповідно, вказівник на 32-бітному пристрої займає 32 біти (4 байти). З 64-бітним виконуваним файлом вказівник займатиме 64 біти (8 байтів). І це незалежно від того, на що вказує вказівник:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
char *chPtr; // тип char займає 1 байт int *iPtr; // тип int займає 4 байти struct Something { int nX, nY, nZ; }; Something *somethingPtr; std::cout << sizeof(chPtr) << '\n'; // виведеться 4 std::cout << sizeof(iPtr) << '\n'; // виведеться 4 std::cout << sizeof(somethingPtr) << '\n'; // виведеться 4 |
Як ви можете бачити, розмір вказівника завжди один і той же. Це пов’язано з тим, що вказівник — це всього лише адреса в пам’яті, а кількість біт, необхідна для доступу до адреси в пам’яті на певному пристрої, — завжди постійна.
Чим корисні вказівники?
Зараз ви можете подумати, що вказівники є непрактичними і взагалі непотрібними. Навіщо використовувати вказівник, якщо ми можемо використати вихідну змінну?
Вказівники корисні в наступних випадках:
Випадок №1: Масиви реалізовані за допомогою вказівників. Вказівники можуть використовуватися для ітерації по масиву.
Випадок №2: Вони є єдиним способом динамічного виділення пам’яті в С++. Це, безумовно, найбільш поширений варіант використання вказівників.
Випадок №3: Вони можуть використовуватися для передачі великої кількості даних в функцію без копіювання цих даних.
Випадок №4: Вони можуть використовуватися для передачі однієї функції в якості параметру іншій функції.
Випадок №5: Вони використовуються для досягнення поліморфізму при роботі зі спадкуванням.
Випадок №6: Вони можуть використовуватися для представлення однієї структури/класу в іншій структурі/класі, формуючи, таким чином, “ланцюжки”.
Вказівники застосовуються в багатьох випадках. Не хвилюйтеся, якщо ви багато чого не розумієте з вищесказаного. Тепер, коли ми розібралися зі вказівниками на базовому рівні, ми можемо почати заглиблюватися в окремі випадки, в яких вони корисні, що ми і зробимо на наступних уроках.
Висновки
Вказівники — це змінні, які містять адреси в пам’яті. Їх можна розіменувати за допомогою оператора розіменування * для вилучення значень, які містяться за адресою в пам’яті. Розіменування вказівника, значенням якого є сміття, призведе до збою в вашій програмі.
Порада: При оголошенні вказівника вказуйте зірочку біля імені змінної.
Тест
Завдання №1
Які значення ми отримаємо в результаті виконання наступної програми (припустимо, що це 32-бітний пристрій, і тип short займає 2 байти):
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
short value = 7; // &value = 0012FF60 short otherValue = 3; // &otherValue = 0012FF54 short *ptr = &value; std::cout << &value << '\n'; std::cout << value << '\n'; std::cout << ptr << '\n'; std::cout << *ptr << '\n'; std::cout << '\n'; *ptr = 9; std::cout << &value << '\n'; std::cout << value << '\n'; std::cout << ptr << '\n'; std::cout << *ptr << '\n'; std::cout << '\n'; ptr = &otherValue; std::cout << &otherValue << '\n'; std::cout << otherValue << '\n'; std::cout << ptr << '\n'; std::cout << *ptr << '\n'; std::cout << '\n'; std::cout << sizeof(ptr) << '\n'; std::cout << sizeof(*ptr) << '\n'; |
Відповідь №1
Значення:
0012FF60
7
0012FF60
7
0012FF60
9
0012FF60
9
0012FF54
3
0012FF54
3
4
2
Коротке пояснення щодо останньої пари: 4 і 2. 32-бітний пристрій означає, що розмір вказівника складає 32 біти, але оператор sizeof завжди виводить розмір в байтах: 32 біти = 4 байти. Таким чином, sizeof(ptr) дорівнює 4. Оскільки ptr є вказівником на значення типу short, то *ptr є типу short. Розмір short в цьому прикладі становить 2 байти. Таким чином, sizeof(*ptr) дорівнює 2.
Завдання №2
Що не так з наступним фрагментом коду?
|
1 2 3 |
int value = 45; int *ptr = &value; // оголошуємо вказівник і ініціалізуємо його адресою змінної value *ptr = &value; // присвоюємо адресу value для ptr |
Відповідь №2
Останній рядок не скомпілюється. Розглянемо цю програму детально.
У першому рядку знаходиться стандартне визначення змінної разом з ініціалізованим значенням. Тут нічого особливого.
У другому рядку ми визначаємо новий вказівник з ім’ям ptr і присвоюємо йому адресу змінної value. Пам’ятаймо, що в цьому контексті зірочка є частиною синтаксису оголошення вказівника, а не оператором розіменування. Так що і в цьому рядку все нормально.
У третьому рядку зірочка вже є оператором розіменування, і використовується для витягування значення, на яке вказує вказівник. Таким чином, цей рядок говорить: «Витягуємо значення, на яке вказує ptr (цілочисельне значення), і перезаписуємо його на адресу цього ж значення”. А це вже якась нісенітниця — ви не можете присвоїти адресу цілочисельному значенню!
Третій рядок повинен бути:
|
1 |
ptr = &value; |
У вищенаведеному рядку ми коректно присвоюємо вказівнику адресу значення змінної.

(102 оцінок, середня: 4,89 з 5)
Додаткове уточнення до фрагменту коду:
Ліва частина *ptr → тип int — це звичайний int, не pointer, оскільки розіменувавши вказівник ми по суті отримуємо значення змінної value.
Права частина &value → тип int* – це вказівник на int (наприклад,
0x0012FF60).Типи правої і лівої частин не співпадають
int≠int*Тому компілятор справедливо каже:
Ви отримуєте кракозябри, тому що ви виводите значення адреси змінної та вказівника у вигляді символьного рядка. Адреса змінної та вказівника – це числові значення, які не можна вивести у вигляді символьного рядка безпосередньо. Для того, щоб вивести значення адреси у вигляді символьного рядка, вам потрібно використовувати функцію
std::to_string().Ось виправлений код:
C++
Цей код виведе наступне:
Address1: 0x7ffeee02e020
Address2: 0x7ffeee02e020
Також, ви можете вивести адреси у вигляді 16-ти значного числа, використовуючи функцію
std::hex().C++
Цей код виведе наступне:
Address1: 0x7ffeee02e020Address2: 0x7ffeee02e020
Нарешті, ви можете вивести адреси у вигляді 8-ми значного числа, використовуючи функцію
std::oct().C++
Цей код виведе наступне:
Address1: 01777777020Address2: 01777777020
Дуже дякую за відповідь, тепер зрозуміло.
Маю питання по вказівниках:
Роблю змінну типу char і вказівник на адресу цієї змінної. Пробую отримати адресу, і замість 16-го значення отримую якісь кракозябри. Причому адреса змінної, і вказівник явно мають якісь різні значення.
char value = '5';
std::cout << "Address1: " << &value << '\n';
char *ptr = &value;
std::cout << "Address2: " << &value << '\n';
Ось що отримую на виході:
Address1: 5)f��UAddress2: 5P76�