Синтаксис си/с++

Указатели: основы, арифметика указателей, указатели на указатели

Введение

Уже неоднократно использовали указатели — настало время разобрать их системно, как одну из самых фундаментальных и одновременно потенциально опасных концепций C/C++: что представляет собой указатель, как работает арифметика указателей, и для чего нужны указатели на указатели.

Концепция

Указатель — переменная, хранящая адрес в памяти другой переменной (или, в более общем смысле, произвольного участка памяти) — оператор & («взятие адреса») получает адрес существующей переменной, а оператор * («разыменование») используется в двух разных контекстах: при объявлении указателя (часть синтаксиса типа) и при доступе к значению, на которое указатель указывает. Арифметика указателей — сложение/вычитание целого числа с указателем, автоматически масштабируемое компилятором согласно размеру типа, на который указывает указатель (pointer + 1 сдвигает адрес ровно на sizeof(тип) байт, а не на 1 байт), что и обеспечивает эквивалентность индексации массива (array[i]) и арифметики указателей (*(array + i)). Указатель на указатель (int <strong>pp) хранит адрес переменной, которая сама является указателем — это нужно для функций, изменяющих сам указатель (а не только значение, на которое он указывает) через параметр, и для некоторых структур данных, требующих динамических массивов указателей (например, динамический двумерный массив).

Пример кода

#include <iostream>

int main()
{
    int value = 42;
    int *ptr = &value; // ptr хранит АДРЕС переменной value, а не само значение 42

    std::cout << "Значение value: " << value << std::endl;
    std::cout << "Адрес value: " << &value << std::endl;
    std::cout << "Значение ptr (тот же адрес): " << ptr << std::endl;
    std::cout << "Разыменование ptr: " << *ptr << std::endl; // 42 — значение ПО адресу, хранимому в ptr

    *ptr = 100; // изменение значения ЧЕРЕЗ указатель
    std::cout << "value после изменения через ptr: " << value << std::endl; // 100

    return 0;
}
// Арифметика указателей — автоматическое масштабирование согласно размеру типа
#include <iostream>

int main()
{
    int numbers[5] = {10, 20, 30, 40, 50};
    int *ptr = numbers;

    std::cout << ptr << std::endl;     // некий адрес, например 0x1000
    std::cout << ptr + 1 << std::endl; // адрес СДВИНУТ на sizeof(int) байт (обычно 4), а не на 1 байт!

    std::cout << *(ptr + 1) << std::endl; // 20 — второй элемент массива, эквивалент numbers[1]

    // Разность двух указателей даёт количество элементов МЕЖДУ ними, а не разность байтовых адресов
    int *endPtr = numbers + 5;
    std::cout << "Количество элементов: " << (endPtr - ptr) << std::endl; // 5

    return 0;
}
// Указатель на указатель — нужен для изменения самого указателя через функцию
#include <iostream>

void allocateAndSet(int **ptrToPtr) // указатель на указатель — позволяет изменить ЧТО указывает внешний ptr
{
    *ptrToPtr = new int(999); // изменяем значение самого внешнего указателя, а не только то, на что он указывал
}

int main()
{
    int *myPointer = nullptr;
    allocateAndSet(&myPointer); // передаём АДРЕС самого указателя

    std::cout << *myPointer << std::endl; // 999 — myPointer теперь указывает на НОВУЮ выделенную память

    delete myPointer; // статья 524 — освобождение динамически выделенной памяти
    return 0;
}

Пояснения к коду

Базовый пример с int *ptr = &value показывает фундаментальное различие между значением переменной и её адресом — ptr хранит адрес, и *ptr даёт доступ к значению по этому адресу, что демонстрируется явным изменением value через *ptr = 100, отражающимся на оригинальной переменной именно потому, что ptr указывает непосредственно на её адрес в памяти. Демонстрация ptr + 1, дающего сдвиг на sizeof(int) байт, а не на единицу, показывает ключевую особенность арифметики указателей, объясняющую эквивалентность индексации массивов и арифметики указателей — компилятор автоматически учитывает размер типа, на который указывает указатель, при выполнении арифметических операций с ним. allocateAndSet(int </strong>ptrToPtr) показывает практический случай, требующий указателя на указатель — функция должна изменить САМ указатель myPointer в вызывающем коде (заставить его указывать на новую область памяти), а не просто значение, на которое он указывал ранее, что требует передачи адреса самого указателя (&myPointer), то есть указателя на указатель.

Подводные камни

  • Разыменование неинициализированного или нулевого указателя — указатель, объявленный без явной инициализации, содержит неопределённое, «мусорное» значение (неинициализированной памяти), и попытка его разыменования (равно как и разыменования явно нулевого указателя, nullptr) является неопределённым поведением, на практике чаще всего проявляющимся как немедленный крах программы (segmentation fault), хотя формально может проявиться и менее очевидным образом.
  • Путаница между * как частью объявления типа указателя и * как оператором разыменования в одной и той же строке кода — синтаксис int *ptr = &value; использует * в смысле «объявление переменной-указателя на int», тогда как в выражении *ptr = 100; тот же символ используется в смысле «разыменовать ptr и присвоить значение по этому адресу», и эта двойная роль одного и того же символа — частый источник путаницы для начинающих изучать C/C++.
  • Выход за границы выделенного блока памяти при арифметике указателей, аналогично подводному камню про индексацию массива за его границами — ptr + n для слишком большого n указывает на память, не принадлежащую исходному массиву/блоку, и последующее разыменование такого указателя является неопределённым поведением, причём сама арифметика указателей (без последующего разыменования) для указателя, вышедшего за пределы массива более чем на один элемент после его конца, формально тоже является неопределённым поведением согласно строгой трактовке стандарта, хотя на практике это менее вероятно проявится заметной проблемой по сравнению с реальным разыменованием.
  • Многоуровневое использование указателей на указатели (int </strong>*, и глубже) без действительно веской необходимости**, существенно усложняющее понимание кода — хотя такие конструкции синтаксически допустимы и иногда необходимы для специфичных случаев (динамические многомерные структуры), чрезмерная глубина уровней индирекции указателей значительно усложняет рассуждение о корректности кода, и для большинства практических случаев современный C++ предоставляет более безопасные и понятные альтернативы (ссылки, умные указатели, стандартные контейнеры), избегающие необходимости в столь глубокой, явной индирекции указателей.