В C/C++ возможна работа с памятью и адресами напрямую из программы, что позволяет писать очень эффективные и быстрые штуки. В языках более высокого уровня такой возможности нет - там всё устроено более абстрактно и безопасно. На Си можно очень просто сломать программу, неаккуратно работая с памятью, либо допустить уязвимость, которой может воспользоваться хакер.
Адрес #
Если проводить аналогию, то память - это многоквартирный дом, одна квартира - это один байт, каждая квартира имеет свой номер (адрес) по порядку. В зависимости от своего размера, переменная может занимать как одну квартиру, так и несколько соседних. При создании переменной выделяется необходимая память - переменная "заселяется" в квартиры и получает адрес в памяти - адрес первой квартиры, то есть первого байта из занимаемых переменной. По этому адресу можно прочитать данные, зная их размер, то есть тип. В программе мы об этом не задумываемся и просто используем имя переменной - компилятор сделает всю работу сам.
Максимальный адрес определяется количеством квартир объёмом оперативной памяти в байтах, так что если мы захотим адрес где-то сохранить - нужен соответствующий тип данных. Размер адреса зависит от платформы, например на МК с небольшим количеством памяти (AVR) это 2 байта, на тех же ESP8266/ESP32 - 4 байта.
Указатель #
Указатель (pointer) - специальная переменная, которая хранит адрес в памяти. Синтаксис создания указателя: тип* имя
, также возможны комбинации с отступами тип *имя
, тип * имя
, тип*имя
- это всё одно и то же. Компилятор должен знать, какие данные расположены по этому адресу, чтобы корректно к ним обращаться. Например int*
- это указатель на адрес, по которому лежат данные типа int
.
Вес самого указателя не зависит от типа данных, на который он указывает, а зависит от платформы, как было написано выше. Таким образом например sizeof(char*)
равен sizeof(long*)
. Размер целочисленного типа данных size_t
совпадает с размером указателя на текущей платформе
В отличие от ссылки, указатель может быть не инициализирован, а также может менять своё значение (адрес) в процессе работы программы. Указатели могут приравниваться к другим указателям такого же типа (как переменные), значение указателя - адрес, он и передаётся в этом случае:
int* ptr; // указатель на int
int* ptr2;
ptr = ptr2; // ptr теперь указывает туда же, куда ptr2
Несколько указателей #
Чтобы создать несколько указателей в одну строку, нужно указывать символ *
перед каждым указателем:
int *a, *b, *c;
Адрес переменной #
Чтобы получить адрес переменной, используется оператор взятия адреса &
, который пишется перед её именем. Оператор вернёт адрес в формате указателя на тип этой переменной, например:
int a;
&a; // имеет тип int* - указатель на int
//(int)&a - численно равно адресу в памяти, байт
&имя - это адрес в памяти переменной с именем "имя"
Соответственно теперь можно создать указатель и записать в него этот адрес:
int a;
int* ptr = &a; // ptr указывает на a
int b;
ptr = &b; // ptr теперь указывает на b
int val = (int)ptr;
// здесь val численно равен адресу в памяти, например 2143
Указатель можно получить и из ссылки на переменную:
int a;
int& ref = a; // ссылка на a
int* ptr = &ref; // указатель на a
Доступ по указателю #
Чтобы обратиться к данным, на которые указывает указатель, нужно использовать оператор косвенного доступа - *
. Он пишется перед именем указателя и по сути возвращает ссылку на переменную этого типа, по которой можно писать и читать сами данные, как если бы это была обычная переменная. Этот процесс называется разыменованием указателя, т.е. превращением его в ссылку.
*указатель - это значение, которое находится в памяти по адресу, записанному в "указатель"
int a = 0;
int* ptr = &a; // ptr указывает на a
*ptr = 123; // меняем a по указателю ptr
// здесь a равно 123
int b = *ptr; // читаем a в переменную b
// здесь b тоже равно 123
int& ref = *ptr; // ссылка на a, получена из указателя
ref = 456; // меняем a по ссылке
// здесь a равно 456
int* ptr2;
ptr2 = &ref; // новый указатель на a, получен из ссылки
int* ptr3 = ptr; // новый указатель на a, получен из указателя
Указатели и ссылки связаны друг с другом и могут "конвертироваться" друг в друга
Указатель на адрес #
Синтаксис языка не запрещает присвоение указателю любого адреса напрямую числом, для этого число нужно вручную привести его к типу указателя:
int* ptr = (int*)12345;
*ptr = 123;
// это сломает программу =)
Делать так не нужно, но нужно понимать, как оно работает.
Нулевой указатель #
Какое значение имеет глобально созданный указатель? Нулевое, как и глобальная переменная, по сути - нулевой адрес в памяти. Иногда бывает удобно присваивать нулевой адрес для дальнейшей проверки в программе - корректный это указатель или нет: нулевой указатель будет false
при проверке в условии, а ненулевой - true
, как и обычное число:
// проверяем, корректный ли указатель
if (ptr) {
*ptr = 123;
}
Разыменование нулевого указателя приведёт к неопределённому поведению - скорее всего сломает программу
Указателю можно присваивать только адрес такого же типа, как и он сам, поэтому нужно указать нулевой адрес и вручную привети тип:
//int* ptr = 0; // ошибка, 0 это int, а не int*
int* ptr = (int*)0;
Чтобы удобно присваивать указателям нулевой адрес, есть специальное ключевое слово - nullptr
(нулевой указатель). Это нулевой указатель универсального типа, его можно присвоить любому указателю без преобразования:
int* ptr1 = nullptr;
float* ptr2 = nullptr;
//*ptr1 = 123; // катастрофа
В языке Си для этого используется константа NULL
, в C++ же есть nullptr
- константа особого типа данных. Не используйте NULL в cpp проекте!
nullptr
имеет свой тип данных - nullptr_t
, что позволяет более универсально перегружать функции:
void foo(int* ptr); // #1
int a;
foo(&a); // вызов #1
foo(nullptr); // вызов #1
void foo(int* ptr); // #1
void foo(nullptr_t ptr); // #2
int a;
foo(&a); // вызов #1
foo(nullptr); // вызов #2
"Висячий" указатель #
Может возникнуть ситуация, в которой переменная, на которую указывал указатель, уже удалена, а сам указатель - нет:
int* ptr;
{
int a;
ptr = &a;
}
// ...
*ptr = 123; // катастрофа
Доступ к такому указателю может привести к неопределённому поведению, например в памяти по этому адресу появилась уже другая переменная, а мы туда запишем значение и сломаем программу. Если указатель "живёт" дольше, чем переменная, и может принимать разные адреса для работы, то обычно принято "сбрасывать" его в nullptr
при удалении самой переменной - для дальнейшей проверки "доступности" указателя. Исправим предыдущий абстрактный пример:
int* ptr = nullptr;
{
int a;
ptr = &a;
// ...
ptr = nullptr;
}
// ...
if (ptr) *ptr = 123; // запись только по валидному указателю
Константы #
Указатель на константу #
Можно создать указатель, по которому нельзя изменить сами данные:
int a;
const int* ptr = &a; // ptr указывает на a
//*ptr = 123; // ошибка компиляции
int b;
ptr = &b; // ptr указывает на b
Const указатель #
Можно создать константный указатель, в который нельзя записать другой адрес. Как и const
переменная, такой указатель должен быть инициализирован. Синтаксис следующий:
int a;
//int* const ptr; // ошибка компиляции, не инициализирован
int* const ptr = &a; // ptr указывает на a
*ptr = 123; // меняем a
int b;
//ptr = &b; // ошибка компиляции
Const const указатель #
Также можно соединить эти варианты и сделать указатель, у которого нельзя менять ни данные, ни сам указатель:
int a;
const int* const ptr = &a;
//*ptr = 123; // ошибка компиляции
int b;
//ptr = &b; // ошибка компиляции
const T*
- указатель на константу, а T* const
- константный указатель, константа типа "указатель на T"
Отбрасывание const #
C/C++ очень гибкий язык и позволяет делать страшные вещи - например проигнорировать const
, но это придётся сделать вручную:
const int a = 123;
const int* ptr = &a;
//*ptr = 123; // ошибка, менять константу нельзя
int* ptr2 = &a; // ошибка, неконстантный указатель
// а так ошибки не будет
int* ptr3 = (int*)&a; // привели вручную к неконстантному типу
*ptr3 = 456;
// но программа скорее всего сломается
Такая возможность есть, но использовать её не нужно. Но можно встретить в чужом коде и понимать, что происходит.
Другие особенности #
Инкремент и декремент #
В алгоритмах часто используется инкремент и декремент указателя для последовательного побайтного доступа к значению. Здесь стоит помнить о приоритете операций: у инкремента и декремента он выше, чем у разыменования:
uint8_t arr[] = {1, 2, 3, 4};
uint8_t* p = arr;
// инкрементировать ДАННЫЕ по указателю
(*p)++; // arr == {2, 2, 3, 4}
++(*p); // arr == {3, 2, 3, 4}
// инкрементировать УКАЗАТЕЛЬ (адрес) и получить доступ к данным
*p++; // == *(p++) - доступ к *p, прошлому адресу
*++p; // == *(++p) - доступ к *(p + 1), по следующему адресу
Инициализация #
Как и локальная переменная, локально созданный указатель имеет по сути случайное значение, случайный адрес. Запись по такому указателю сломает программу или приведёт к её непредсказуемому поведению:
{
int* ptr;
*ptr = 123; // катастрофа
}
Поэтому при работе с указателями нужно гарантировать, что он указывает в нужное место - программист сам несёт за это ответственность.
Указатель на указатель #
Указатель может указывать на другой указатель, синтаксис будет такой:
int a;
int* ptr = &a; // указывает на переменную a
int** pptr = &ptr; // указывает на указатель ptr
**pptr = 123; // запись в переменную a по указателю на указатель
// здесь a равно 123
int b;
*pptr = &b; // ptr теперь указывает на переменную b
*ptr = 456; // запись в переменную b по указателю
// здесь b равно 456
**pptr = 789; // запись в переменную b по указателю на указатель
// здесь b равно 789
Другой тип #
Указатель не может принять адрес данных другого типа - будет ошибка компиляции:
int a;
//long* ptr = &a; // ошибка компиляции
В то же время тип можно привести вручную, что позволяет обращаться к памяти очень гибко, полностью в ручном режиме:
char a;
long* ptr = (long*)&a;
*ptr = 123; // это сломает программу =)
// так как мы запишем тип long, который не поместится в 1 байт
// компилятор не сможет это предотвратить и лишние байты также запишутся в память
Указатель на void #
Как говорилось выше, указатель принимает только адрес своего типа. Здесь особую роль играет тип void
- указатель этого типа может принять адрес любого типа без дополнительного преобразования. А вот разыменовать указатель на void
нельзя - его сначала нужно привести к конкретному типу:
int a;
void* ptr = &a;
//*ptr = 123; // ошибка компиляции
int* iptr = (int*)ptr;
*iptr = 123;
// здесь a равна 123
Это очень удобно при разработке библиотек и каких то универсальных инструментов, подробнее рассмотрим в следующих уроках.
Трюки #
Доступ к байтам #
При помощи доступа по указателю можно прочитать отдельно байты другой переменной - это будет быстрее математических операций. Указатель хранит в себе число и ведёт себя как обычная переменная - если прибавить к нему число - адрес сместится. Причём сместится на размер типа данных, на который он указывает:
uint32_t var = 0xabcdef12;
uint8_t* bytes = (uint8_t*)&var;
*(bytes + 0); // == 0x12
*(bytes + 1); // == 0xEF
*(bytes + 2); // == 0xCD
*(bytes + 3); // == 0xAB
uint16_t* bytes16 = (uint16_t*)&var;
*(bytes16 + 0); // == 0xEF12
*(bytes16 + 1); // == 0xABCD
Вот здесь как раз играет роль endianness - порядок байтов зависит от конкретной платформы.