К содержимому
ИС30

Поиск по сайту

Конспекты, лабы, квизы, ЧаВо и страницы

Войти

Лекция 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);     // в большинстве реализаций вызов неоднозначен или выбирает int

nullptr однозначно выбирает версию для указателей.

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'.
Не пишите strlen без проверки

Если строка пришла извне (аргумент командной строки, файл, сеть), ничто не гарантирует терминатор. Сначала убедитесь, что строка корректна, либо передавайте длину отдельно.

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) в параметре функции и в вызывающем коде.
  • Сравнение указателей вместо сравнения содержимого строк.

Мини-тренажёр

Ответы
  1. int* p = &x; *p = 5; меняет значение x.
  2. int* p = nullptr; *p = 5; — неопределённое поведение.
  3. const int* p = &x; *p = 5; не скомпилируется, а p = &y; допустимо.
  4. int values[10]; sizeof(values) даёт 10⋅sizeof(int)10 \cdot \mathrm{sizeof}(\mathtt{int}), а в функции sizeof(array) для int* array даёт размер указателя.
  5. char s[] = "abc"; занимает 4 ячейки, strlen(s) даёт 3.
  6. void* v = &x; int* p = v; не скомпилируется в C++ без явного приведения.

Шпаргалка

ПонятиеСуть
УказательПеременная, хранящая адрес
&Взятие адреса
*Разыменование: доступ к объекту по адресу
const int*Указатель на константу
int* constКонстантный указатель
nullptrНулевой указатель, тип std::nullptr_t
Висячий указательАдрес объекта, который уже не существует
Распад массиваПреобразование массива в указатель на первый элемент
[begin, end)Полуинтервал диапазона, end не разыменовывается
C-строкаМассив char с терминатором '\0'
void*Адрес без типа, перед разыменованием нужно приведение
Указатель на функциюАдрес функции, тип включает сигнатуру