Глиф Учебники

Урок 8. Указатели и память

Курс «C++» · урок 8 из 10 · 4 минуты чтения

Указатели пугают новичков сильнее всего остального в C++. На деле идея простая, а сложности начинаются только при небрежном обращении. В этом уроке — что такое указатель, как им пользоваться и как не выстрелить себе в ногу.

Адрес переменной

Память компьютера — длинный ряд пронумерованных байтов. Номер байта называется адресом. Каждая переменная лежит где-то в памяти, и у неё есть адрес — номер её первого байта. Узнать его можно оператором &:

C++
#include <iostream>
using namespace std;

int main() {
    int x = 10;
    cout << &x << "\n";
    return 0;
}

Программа выведет что-то вроде 0x7ffd5e8c3a4c — адрес в шестнадцатеричной записи. Он разный на разных компьютерах и даже при каждом запуске, и само число обычно не важно. Важно, что по адресу можно найти переменную.

Указатель

Указатель — переменная, в которой хранится адрес. Тип указателя — тип того, на что он указывает, со звёздочкой: int* — указатель на int.

C++
#include <iostream>
using namespace std;

int main() {
    int x = 10;
    int* p = &x;            // p хранит адрес x
    cout << *p << "\n";     // значение по адресу
    *p = 25;                // меняем x через указатель
    cout << x << "\n";
    cout << (p == &x) << "\n";
    return 0;
}
Вывод
10
25
1
int x int* p память 0x7ffd5e8c3a4c 10 … хранит адрес 0x7ffd5e8c3a4c … p указывает на x &x адрес переменной x *p значение по адресу: 10 *p = 25; в x теперь 25
Переменная x лежит в памяти по адресу вроде 0x7ffd5e8c3a4c, а указатель p хранит этот адрес: &x даёт адрес, *p — значение по нему, а *p = 25 меняет саму x.

Звёздочка * здесь встречается в двух ролях, и их легко спутать:

& тоже двуликий: в выражении &x — взять адрес, а в объявлении int& r = x — ссылка из прошлого урока.

nullptr — указатель в никуда

Указатель, который пока ни на что не указывает, приравнивают к nullptr. Разыменовывать такой указатель нельзя, поэтому перед использованием его проверяют:

C++
#include <iostream>
using namespace std;

void reset(int* p) {
    if (p != nullptr) {
        *p = 0;
    }
}

int main() {
    int a = 7;
    reset(&a);
    reset(nullptr);         // ничего не случится: функция проверяет
    cout << a << "\n";
    return 0;
}
Вывод
0

В старом коде вместо nullptr пишут NULL или 0 — в новом коде берите nullptr.

Указатель без начального значения (int* p; внутри функции) содержит случайный адрес. Запись через него (*p = 5) портит случайную память: программа падает с segmentation fault или, хуже, тихо работает неправильно. Всегда задавайте указателю значение — адрес или nullptr.

Указатели и массивы

Имя массива в выражениях превращается в указатель на его первый элемент. К указателю можно прибавить число — он сдвинется на столько элементов (а не байтов):

C++
#include <iostream>
using namespace std;

int main() {
    int a[4] = {5, 6, 7, 8};
    int* p = a;             // то же, что &a[0]
    cout << *p << " " << *(p + 2) << " " << p[3] << "\n";
    p++;
    cout << *p << "\n";
    return 0;
}
Вывод
5 7 8
6

a[i] — это сокращение для *(a + i). Отсюда же причина, по которой выход за границы массива не ловится: a[10] — просто адрес на 10 элементов дальше начала, и C++ не знает, что массив кончился раньше.

int a[4] = {5, 6, 7, 8}; int* p = a; a[i] — это *(a + i) a[0] 5 a[1] 6 a[2] 7 a[3] 8 p *p → 5 p после p++ p + 2 *(p + 2) → 7 p + 3 p[3] → 8 *p → 6
Указатель p = a смотрит на первый элемент: *(p + 2) даёт 7, p с индексом 3 — 8, а после p++ указатель сдвигается на один элемент и смотрит на 6.

Из-за этого же массив, переданный в функцию, не знает своей длины: функция получает только указатель на первый элемент. Длину приходится передавать отдельным параметром — ещё одна причина брать vector.

Динамическая память: new и delete

Обычные локальные переменные живут в стеке — небольшой области памяти (обычно несколько мегабайт), которая освобождается сама при выходе из функции. Большие данные и данные, которые должны жить дольше функции, берут из кучи — общей памяти программы. Её выдаёт new и возвращает delete:

C++
#include <iostream>
using namespace std;

int main() {
    int n;
    cin >> n;
    int* arr = new int[n];      // массив из n чисел в куче
    for (int i = 0; i < n; i++) {
        arr[i] = i * i;
    }
    long long sum = 0;
    for (int i = 0; i < n; i++) {
        sum += arr[i];
    }
    cout << sum << "\n";
    delete[] arr;               // вернуть память
    arr = nullptr;
    return 0;
}
Ввод
5
Вывод
30
Стек локальные переменные main int n 5 int* arr long long sum 30 освобождается сам при выходе из функции Куча память, которую выдаёт new new int[n] 0 1 4 9 16 delete[] arr; вернуть память забыли delete[] — утечка памяти
Переменные n, arr и sum лежат в стеке, а массив, выданный new, — в куче; arr хранит его адрес, пока память не вернут через delete, иначе будет утечка.

Правила:

Ошибки здесь самые коварные:

Как обойтись без new и delete

В современном C++ new и delete вручную пишут редко:

C++
#include <iostream>
#include <memory>
using namespace std;

int main() {
    unique_ptr<int> p = make_unique<int>(42);
    cout << *p << "\n";
    *p += 1;
    cout << *p << "\n";
    return 0;
}                               // здесь память освободится сама
Вывод
42
43

Указатели при этом никуда не деваются: на них построены vector, string и вся стандартная библиотека, ими пользуются для связных списков и деревьев, без них не обойтись в языке C и при работе с железом. Понимать их нужно, но писать каждый день — не обязательно.

Указатель или ссылка

Указатель int*Ссылка int&
Может быть пустымда, nullptrнет
Можно перенаправить на другую переменнуюданет, навсегда связана с первой
Доступ к значению*pпросто r
Арифметика (p + 1)естьнет

Коротко

Проверьте себя

1. Что выведет программа?
C++
#include <iostream>
using namespace std;

int main() {
    int a = 3;
    int* p = &a;
    *p = *p * 4;
    cout << a << "\n";
    return 0;
}
2. Чем освобождают память, выделенную new int[n]?
3. Что такое nullptr?
4. Что лучше взять для массива, размер которого известен только при работе программы?