Лекция 3. Указатели, массивы и строки
Адреса и указатели, операторы взятия адреса и разыменования, const с указателями, nullptr, висячие указатели, передача по указателю, массивы и арифметика указателей, C-строки, void*, указатели на функции.
Указатель — самое важное и самое опасное понятие первой половины курса. Он позволяет работать с памятью напрямую и менять объекты, которые находятся в другом месте программы. Цена — ответственность: ошибка с указателем может привести не к сообщению об ошибке, а к неопределённому поведению, которое проявится позже и в другом месте.
Для каждого нового понятия сначала представляйте ячейки памяти с адресами, а уже потом код. Указатель — это переменная, в которой хранится адрес другой переменной, и больше ничего.
1. Указатели и адреса
Каждый байт памяти имеет адрес. Объект занимает несколько подряд идущих байтов, и его адрес — адрес первого из них. Указатель — переменная, значения которой являются адресами, плюс особое значение: нулевой адрес (nullptr), который означает «не указывает ни на что».
Тип указателя задаёт, на что он указывает. Указатель типа int* ожидает адрес int:
int value = 10;
int* pointer = &value; // pointer хранит адрес valueЧтобы изобразить это в голове, представьте, что value — дом, а pointer — записка с адресом этого дома. Записка сама по себе занимает место в памяти и имеет свой адрес.
2. Операторы & и *
&— взятие адреса:&objectдаёт адрес объекта;*— разыменование:*pointerдаёт доступ к объекту, на который указываетpointer.
int x = 1;
int y = 2;
int* pointer = &x;
y = *pointer; // читаем x через указатель, y становится 1
*pointer = 0; // пишем в x через указатель
// Теперь x == 0, y == 1Обратите внимание: y = *pointer копирует значение, а не адрес. После этого y и x независимы: изменение x не трогает y.
Числовые значения адресов сами по себе не являются частью логики программы. Они меняются между запусками, сборками и платформами, поэтому не пишите код, который на них завязан.
3. Размер и безопасность указателя
Тип указателя определяет две вещи: как интерпретируется объект по адресу и на какой шаг сдвигается адрес при арифметике. Размер самого указателя задаёт реализация и ABI. На распространённых 64-битных платформах указатель на объект занимает 8 байт, но стандарт этого не гарантирует. Указатели на функции не обязаны совпадать по размеру с указателями на объекты.
Разыменование нулевого, неинициализированного или недействительного указателя — неопределённое поведение.
Указатель сам является объектом и имеет собственный адрес. Выражения pointer и &pointer дают разные значения: первое — адрес объекта, на который указывает pointer, второе — адрес самой переменной-указателя.
4. Константность с указателями
Три варианта, которые легко перепутать:
int x = 10;
const int* p1 = &x; // указатель на константу
int* const p2 = &x; // константный указатель
const int* const p3 = &x; // константный указатель на константуconst int*— адрес можно менять, а объект через указатель менять нельзя;int* const— адрес задан при инициализации и не меняется, а объект через указатель можно;const int* const— нельзя менять ни адрес, ни объект через указатель.
Важно: const у указателя запрещает запись через этот указатель. Сам объект x при этом остаётся изменяемым через обычное имя.
Читайте объявление справа налево: const int* p — «p — указатель на константный int», а int* const p — «p — константный указатель на int».
5. Указатели на указатели
Указатель может указывать на другой указатель. Каждый уровень добавляет * в тип:
int value = 0;
int* pointer = &value;
int** pointer_to_pointer = &pointer;
int*** third_level = &pointer_to_pointer;
**pointer_to_pointer = 5; // то же, что value = 5На практике многоуровневые указатели встречаются редко. Если их больше двух, стоит подумать, не стоит ли упростить структуру.
6. nullptr
nullptr— литерал нулевого указателя, его типstd::nullptr_t;- обозначает отсутствие объекта;
- при перегрузке функций не смешивается с целыми числами;
- в новом коде используем
nullptr, а не0илиNULL.
Почему это важно, видно на перегрузке. Пусть есть две функции, одна принимает int*, другая int:
void Func(int* pointer);
void Func(int value);
Func(nullptr); // вызывается Func(int*)
Func(0); // вызывается Func(int)
Func(NULL); // в большинстве реализаций вызов неоднозначен или выбирает intnullptr однозначно выбирает версию для указателей.
7. Висячие указатели
Указатель, который ссылается на объект, переставший существовать, называется висячим (dangling pointer). Классический пример — возврат адреса локальной переменной:
int* Make() {
int local = 42;
return &local; // ошибка: local умирает при выходе из функции
}После возврата local уже не существует. Разыменование полученного указателя — неопределённое поведение. Современные компиляторы предупреждают об этом, а в учебной среде такой код можно скомпилировать с флагом -Werror=return-stack-address, чтобы сборка падала.
Проверка if (pointer != nullptr) говорит только о том, что адрес не нулевой. Она не гарантирует, что по адресу ещё живёт объект. Для висячего указателя проверка пройдёт успешно, а чтение всё равно даст неопределённое поведение.
Правильное решение в примере выше — вернуть значение по значению: int Make().
8. Семантика указателей
- Копирование указателя копирует адрес, а не объект. После
q = pоба указывают на один объект. - Изменение через указатель меняет общий объект:
*q = 42видно и через*p. - Смена адреса меняет только сам указатель: после
q = &otherуказательpпо-прежнему указывает на старый объект. - Указатель может не указывать на объект: тогда он
nullptr, и разыменовывать его нельзя. - Указатель не управляет временем жизни объекта: он не продлевает жизнь и не уничтожает объект. Этим занимается код, создавший объект.
9. Передача по указателю
Если передать аргумент по значению, функция меняет свою копию:
void SwapValues(int left, int right) {
int temporary = left;
left = right;
right = temporary;
}
// В main после вызова SwapValues(first, second) значения first и second не меняются.Чтобы поменять исходные объекты, передаём их адреса:
void SwapThroughPointers(int* left, int* right) {
int temporary = *left;
*left = *right;
*right = temporary;
}
// SwapThroughPointers(&first, &second);Для такой задачи в C++ обычно удобнее ссылка, и с ними мы разберёмся на следующих лекциях. Сейчас важно понять механизм: через указатель функция работает с объектом вызывающего кода.
10. Массивы
Массив — последовательность элементов одного типа, которые лежат в памяти подряд. Размер встроенного массива задаётся при объявлении и не меняется. Индексация с нуля: для массива из N элементов допустимы индексы от 0 до N - 1.
int uninitialized[10]; // значения не определены
int inferred[] = {1, 2, 3, 4, 5}; // размер выводится из списка
int fixed[3] = {1, 2, 3};
int matrix[2][3] = {
{1, 2, 3},
{4, 5, 6},
};Пять элементов int по 4 байта занимают 20 байт. Элементы идут один за другим, и адрес следующего отличается от предыдущего на sizeof(int).
11. Преобразование массива в указатель
Во многих выражениях массив T[N] неявно превращается в указатель T* на первый элемент. Это называют распадом массива (array-to-pointer decay). Происходит это:
- при инициализации или присваивании указателя;
- при передаче в функцию с параметром-указателем;
- в арифметике, например
values + 1.
void PrintFirst(const int* pointer) {
std::cout << *pointer << '\n';
}
int values[] = {10, 20, 30};
int* first = values; // то же, что &values[0]
PrintFirst(values); // передан адрес первого элементаРаспад не копирует элементы и не передаёт размер массива. Для константного массива указатель получает тип const T*.
Массив остаётся массивом в нескольких случаях: в sizeof(values), при взятии адреса &values, в decltype(values) и при связывании со ссылкой на массив. Поэтому &values — указатель на весь массив, а не на первый элемент.
11.1. Ловушка sizeof в функции
void PrintSize(int* array) {
std::cout << sizeof(array) << '\n'; // размер указателя, не массива
}
int values[10] = {};
std::cout << sizeof(values) << '\n'; // размер всего массива: 40 байт
PrintSize(values); // внутри: размер указателя, обычно 8В параметрах функции запись int array[] означает int* array. Число элементов из указателя не получить, его нужно передавать отдельно: void PrintAll(const int* values, std::size_t count).
12. Арифметика указателей
Если pointer указывает на элемент массива, то pointer + 1 — адрес следующего элемента. Смещение измеряется в элементах, а не в байтах:
int values[] = {10, 20, 30};
int* pointer = values;
int first = *pointer; // 10
int second = *(pointer + 1); // 20
int third = pointer[2]; // 30Для допустимого индекса i выражения values[i], *(values + i), pointer[i] и *(pointer + i) эквивалентны.
12.1. Границы
Элементы массива образуют полуинтервал [begin, end): позиция end указывает сразу за последним элементом и сама элементом не является.
int* begin = values;
int* end = values + 3;
for (int* current = begin; current != end; ++current) {
std::cout << *current << '\n';
}Позицию end можно сформировать и сравнить, но разыменовывать её нельзя. Так же нельзя выходить за неё: end + 1 уже неопределённое поведение, даже без разыменования.
13. C-строки
C-строка — последовательность символов char, которая заканчивается нулевым символом '\0'. Сам терминатор занимает отдельную ячейку, но в длину строки не входит.
Строковый литерал в C++ — массив константных символов, поэтому указатель на него должен быть const char*:
const char* first = "Hello world"; // неизменяемый литерал
char second[] = "Hello world"; // отдельный изменяемый массив
const char* third = second; // читаем массив через указатель
second[0] = 'h'; // допустимо: массив изменяемыйСтрока "Hello world" занимает 12 ячеек: 11 символов и терминатор.
13.1. Длина строки
Длину находим, двигая указатель до '\0':
std::size_t StringLength(const char* string) {
std::size_t length = 0;
while (*string != '\0') {
++string;
++length;
}
return length;
}Предусловие: передан ненулевой указатель на корректную строку с терминатором. Стандартная функция std::strlen работает так же.
13.2. Сравнение строк
Строки сравниваются посимвольно. Оператор == для двух указателей сравнивает адреса, а не содержимое. Вариант с индексами:
int StringCompare(const char* first, const char* second) {
std::size_t index = 0;
while (first[index] != '\0' && first[index] == second[index]) {
++index;
}
return static_cast<unsigned char>(first[index])
- static_cast<unsigned char>(second[index]);
}Результат: 0, если строки равны; меньше нуля, если первая раньше; больше нуля, если позже. Символы сравниваем как unsigned char, чтобы не зависеть от знаковости char.
13.3. Почему C-строки опасны
const char raw[3] = {'C', '+', '+'}; // нет терминатора
const char text[4] = "C++"; // терминатор есть
std::size_t a = sizeof(raw); // 3
std::size_t b = sizeof(text); // 4
std::size_t c = std::strlen(text); // 3
// std::strlen(raw) — неопределённое поведение: терминатора нет- массив без
'\0'не является C-строкой; strlenищет терминатор и не знает границ буфера;- при записи в буфер учитывайте вместимость: для
"C++"нужно четыре ячейкиchar, включая'\0'.
Если строка пришла извне (аргумент командной строки, файл, сеть), ничто не гарантирует терминатор. Сначала убедитесь, что строка корректна, либо передавайте длину отдельно.
14. Аргументы командной строки
В традиционной форме main получает количество аргументов и массив указателей на C-строки:
int main(int argc, char* argv[]) {
for (int index = 0; index < argc; ++index) {
std::cout << argv[index] << '\n';
}
return 0;
}argc — число аргументов, argv[index] — C-строка с соответствующим аргументом.
15. Универсальный указатель void*
void* может хранить адрес объекта любого типа, но разыменовать его напрямую нельзя: компилятор не знает, как интерпретировать байты. Чтобы обратиться к объекту, нужно вернуть правильный тип:
int value = 239;
int* typed_pointer = &value;
void* untyped_pointer = typed_pointer;
int* restored_pointer = static_cast<int*>(untyped_pointer);
std::cout << *restored_pointer << '\n'; // 239Чтение объекта через указатель несовместимого типа ведёт к неопределённому поведению. Исключение — доступ к байтам через unsigned char: правила языка разрешают просмотреть представление любого объекта побайтово.
void PrintBytes(const void* object, std::size_t size) {
const auto* bytes = static_cast<const unsigned char*>(object);
for (std::size_t index = 0; index < size; ++index) {
std::cout << std::format("{:08b} ", bytes[index]);
}
std::cout << '\n';
}std::format появилась в C++20. Квалификатор const сохраняется: функция только читает данные.
15.1. Порядок байтов
Порядок байтов многобайтового числа зависит от платформы. На little-endian системах младший байт лежит по младшему адресу, на big-endian — наоборот. Поэтому при обмене двоичными данными между системами порядок байтов нужно указывать явно.
16. Указатели на функции
Функции тоже имеют адрес. Тип указателя на функцию содержит тип результата и типы параметров:
int Same(int value) {
return value;
}
int main() {
int (*function)(int) = Same;
int (*same_pointer)(int) = &Same; // & необязателен
return function(2) + same_pointer(2) == 4 ? 0 : 1;
}Указатели на функции удобно передавать в алгоритмы. Например, функция поиска может принимать правило сравнения:
bool Less(int left, int right) {
return left < right;
}
int* FindByOrder(int* array, std::size_t size, bool (*comes_before)(int, int)) {
if (size == 0) {
return nullptr;
}
int* result = array;
for (std::size_t index = 1; index < size; ++index) {
if (comes_before(*result, array[index])) {
result = &array[index];
}
}
return result;
}Эта функция возвращает указатель на «самый последний» элемент по заданному порядку. Передав Less, получаем максимум, передав Greater, — минимум.
В современном C++ вместо обычного указателя на функцию часто используют лямбда-выражения и std::function. Указатели на функции полезно знать, потому что старый код и библиотеки C работают именно с ними.
Объявление void (*signal(int number, void (*handler)(int)))(int); читается изнутри наружу: signal — функция, принимающая int и указатель на функцию void(int), а возвращает указатель на функцию void(int). Для читаемости используйте using Handler = void (*)(int);.
Частые ошибки
- Разыменование неинициализированного или
nullptrуказателя. - Возврат адреса локальной переменной.
- Выход за границы массива, включая разыменование
end. - Вызов
strlenили подобных функций без терминатора. - Путаница
sizeof(array)в параметре функции и в вызывающем коде. - Сравнение указателей вместо сравнения содержимого строк.
Мини-тренажёр
Ответы
int* p = &x; *p = 5;меняет значениеx.int* p = nullptr; *p = 5;— неопределённое поведение.const int* p = &x; *p = 5;не скомпилируется, аp = &y;допустимо.int values[10]; sizeof(values)даёт , а в функцииsizeof(array)дляint* arrayдаёт размер указателя.char s[] = "abc";занимает 4 ячейки,strlen(s)даёт 3.void* v = &x; int* p = v;не скомпилируется в C++ без явного приведения.
Шпаргалка
| Понятие | Суть |
|---|---|
| Указатель | Переменная, хранящая адрес |
& | Взятие адреса |
* | Разыменование: доступ к объекту по адресу |
const int* | Указатель на константу |
int* const | Константный указатель |
nullptr | Нулевой указатель, тип std::nullptr_t |
| Висячий указатель | Адрес объекта, который уже не существует |
| Распад массива | Преобразование массива в указатель на первый элемент |
[begin, end) | Полуинтервал диапазона, end не разыменовывается |
| C-строка | Массив char с терминатором '\0' |
void* | Адрес без типа, перед разыменованием нужно приведение |
| Указатель на функцию | Адрес функции, тип включает сигнатуру |
Актуальная версия: https://m3105.ru/notes/osnovy-programmirovaniya/lektsiya-3-ukazateli-massivy-i-stroki

Комментарии0
Пока никто ничего не написал.
Войдите, чтобы оставить комментарий