Программа курса← Программирование на языке C++
1Введение в язык C++
2Как выполняются программы на C++
3Структуры и классы
4Объектно-ориентированное программирование
5Дополнительные возможности C++
6Шаблоны
7Методические материалы

Указатели, массивы и C-строки

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

Что такое указатель

int value = 42;
int* p = &value;      // & – взять адрес

std::cout << p;       // напечатает адрес, что-то вроде 0x7ffd4c8a1b2c
std::cout << *p;      // 42 – разыменование: «то, что лежит по адресу»

*p = 100;             // теперь value == 100

Тип указателя не украшение: он говорит компилятору, сколько байт читать при разыменовании и с каким шагом двигаться при арифметике. int* и double* – разные типы, между собой неявно не преобразуются.

Читать объявления удобно справа налево: int* p – «p есть указатель на int», const char* s – «s есть указатель на константный char». Позиция звёздочки (int* p или int *p) – вопрос стиля, компилятору всё равно. Но int* a, b; объявляет указатель и обычный int: звёздочка относится к переменной, а не к типу.

Нулевой указатель

Указатель может не указывать никуда. В современном C++ это записывается nullptr:

int* p = nullptr;

if (p != nullptr) {
    *p = 1;           // разыменовывать можно только после проверки
}

Старый код пишет NULL или просто 0. Оба варианта – это буквально ноль, из-за чего вызов Foo(NULL) мог уйти в перегрузку Foo(int). nullptr имеет собственный тип и такой неоднозначности не создаёт.

Разыменование нулевого указателя – неопределённое поведение; на практике почти всегда мгновенное падение, и это лучший из возможных исходов: ошибка видна сразу.

Массивы

Массив в C++ – это непрерывный участок памяти под N элементов одного типа. Размер – часть типа и должен быть известен на компиляции.

int a[5] = {1, 2, 3, 4, 5};
int b[] = {1, 2, 3};        // размер выведет компилятор: 3
int c[10] = {};             // все элементы – нули

std::cout << sizeof(a);     // 20 – именно байты массива, а не указателя

Проверок границ нет. a[7] скомпилируется и прочитает то, что лежит за массивом, – в лучшем случае мусор, в худшем испортит соседние данные.

Массив и указатель – не одно и то же

Имя массива в большинстве контекстов распадается (decay) в указатель на первый элемент:

int a[5] = {1, 2, 3, 4, 5};
int* p = a;                 // распад: p == &a[0]

std::cout << *(a + 2);      // 3
std::cout << a[2];          // 3 – ровно то же самое, a[i] означает *(a + i)
std::cout << 2[a];          // тоже 3: сложение коммутативно. Так писать не надо

Но массив и указатель – разные типы, и разница видна там, где распада не происходит:

int a[5];
int* p = a;

sizeof(a);   // 20 – размер массива
sizeof(p);   // 8  – размер указателя

Особенно важно это при передаче в функцию:

void Foo(int a[3]) { /* ... */ }   // размерность 3 компилятор игнорирует

int x[1] = {};
Foo(x);                            // компилируется, хотя элементов не три

Параметр-массив всегда превращается в указатель, поэтому размер внутрь функции не попадает. Отсюда стандартный приём: передавать длину отдельным параметром.

void Print(const int* data, size_t count);

Арифметика указателей

int a[5] = {10, 20, 30, 40, 50};
int* p = a;

++p;              // сдвиг на sizeof(int) байт, теперь p смотрит на a[1]
p += 2;           // на a[3]
--p;              // на a[2]

int* end = a + 5; // указатель «за последним» – так делать законно
std::ptrdiff_t n = end - a;   // 5: разность указателей даёт число элементов

Правила, которые стоит помнить:

  • шаг арифметики – размер типа, поэтому у void* арифметики нет;
  • законно получить адрес «на один за последним элементом», но разыменовывать его нельзя;
  • вычитать друг из друга можно только указатели внутри одного массива;
  • сравнение <, > тоже определено только внутри одного массива.

Перебор массива через указатели – идиома, на которой построена вся стандартная библиотека:

for (int* it = a; it != a + 5; ++it) {
    std::cout << *it << ' ';
}

Именно так устроены итераторы: begin() и end() – обобщение указателя на начало и «за конец».

C-style строки

Отдельного строкового типа в C не было. Строка – это массив char, заканчивающийся символом '\0' (символ с кодом ноль). C++ совместимость сохранил.

const char* s = "C-style string";   // тип литерала – const char[15]: 14 символов и ноль
char copy[] = "C-style string";     // копия в собственном массиве, её менять можно

Литерал константен не для красоты: он лежит в области памяти, доступной только для чтения, и запись в него роняет программу.

Длина строки нигде не хранится – её каждый раз вычисляют проходом до нуля:

size_t Length(const char* s) {
    size_t n = 0;
    while (s[n] != '\0') {
        ++n;
    }
    return n;
}

Отсюда два классических источника ошибок. Первый: забыли завершающий ноль – и любая функция работы со строкой уходит читать за границу массива. Второй: strcat и strcpy не знают, сколько места в приёмнике, и радостно пишут за его пределы. Это буквально самая эксплуатируемая уязвимость в истории. Предпочитайте варианты с ограничением длины (strncat) или, ещё лучше, std::string.

Что уже есть в стандартной библиотеке

Функции работы с C-строками объявлены в <cstring>:

#include <cstring>

char str[100] = "left part";
std::strcat(str, " right part");
size_t length = std::strlen(str);
const char* found = std::strstr(str, "right");   // nullptr, если не найдено

А для массивов вообще – <algorithm>:

#include <algorithm>

int a[100] = { /* ... */ };
std::sort(a, a + 100);                     // по возрастанию
int* minIt = std::min_element(a, a + 100); // указатель на элемент, а не сам элемент

Обратите внимание на пару «начало, за концом» – так выглядит почти каждая функция стандартной библиотеки. Писать свою сортировку не нужно; писать свой strlen – тоже, хотя один раз это полезно сделать, чтобы понять, откуда берётся его цена.