2.6. Ссылки, указатели, константность

Ссылки — это псевдонимы для переменных. Указатели хранят адреса других переменных в памяти. Ключевое слово const подчеркивает, что переменная используется только для чтения. Часто оно используется совместно с объявлением ссылок и указателей.

Ссылки позволяют вводить псевдонимы для переменных. Указатели — это самостоятельные типы данных, которые могут хранить адреса других переменных в памяти. Ключевое слово const позволяет подчеркнуть, что переменная используется только для чтения. Часто оно используется совместно с объявлением ссылок и указателей.

Копии переменных

Для начала давайте рассмотрим такой фрагмент кода:

#include <iostream>
#include <string>

int main() {
    std::string s1 = "Elementary, my dear Watson!";
    std::string s2 = s1;

    s1.clear();  // s2 никак не изменится

    std::cout << s1 << "\n";  // пустая строка
    std::cout << s2 << "\n";  // Elementary, my dear Watson!
}

Важно понимать, что здесь s2 будет совершенно новой строкой, которая проинициализирована значением s1, но более никак с s1 не связана. Это отличает С++ от некоторых других языков программирования — например, языка Python. В них после аналогичного присваивания строка осталась бы той же самой.

Создание новой строки s2 требует ресурсов: нужно выделить новый блок памяти и скопировать туда старую строку.

Ссылки

Впрочем, в C++ есть возможность обращаться к уже существующему в памяти объекту под другим именем. Рассмотрим это на примере целых чисел:

#include <iostream>

int main() {
    int x = 42;
    int& ref = x;  // ссылка на x

    ++x;
    std::cout << ref << "\n";  // 43
}

Здесь ref — псевдоним для x. Это не самостоятельная переменная, а просто ссылка на объект, уже живущий в памяти. Формально типом ref является int& — ссылка на int.

Аналогично для строк:

#include <iostream>
#include <string>

int main() {
    std::string s1 = "Elementary, my dear Watson!";
    std::string& s2 = s1;  // тут ссылка!

    s1.clear();

    std::cout << s2.size() << "\n";  // напечатает 0
}

Ссылка должна быть проинициализирована сразу в момент объявления. Например, так написать нельзя:

int main() {
    int my_variable = 42;
    int& ref;  // ошибка!
    // ...
    ref = my_variable;
}

Ссылка привязана к одному и тому же объекту со своего рождения. Переназначить её нельзя:

int main() {
    int x = 42, y = 13;
    int& ref = x;  // OK
    ref = y;  // ссылка останется привязанной к x, значение x поменяется
}

Ссылки удобны там, где исходное имя слишком громоздко (например, является вложенным полем какой-либо структуры).

Указатели

Другой (более базовый) способ сослаться на что-то уже существующее в памяти — указатели. Это специальные типы данных, которые могут хранить адрес какой-либо другой переменной в памяти. Здесь мы можем представлять себе память как длинную ленту с пронумерованными ячейками (байтами). Сам адрес переменной можно получить с помощью унарного оператора &:

int main() {
    int x = 42;
    int* ptr = &x;  // сохраняем адрес в памяти переменной x в указатель ptr

    ++x;  // увеличим x на единицу
    std::cout << *ptr << "\n";  // 43
}

C

Формально указатель — это не номер ячейки памяти, а отдельный тип. Но обычно он может быть преобразован к целому числу. Вот такой код напечатает адреса переменных в шестнадцатеричном виде:

#include <iostream>

int main() {
    int x = 1, y = 2, z = 3;
    std::cout << &x << "\n";
    std::cout << &y << "\n";
    std::cout << &z << "\n";
}

Пример вывода:

0x7ffdfee3188c
0x7ffdfee31888
0x7ffdfee31884

Можно заметить, что адреса будут идти «рядом» с шагом sizeof(int) по возрастанию или убыванию — это зависит от платформы и компилятора. Но при повторном запуске программы они могут отличаться, так как программе может быть назначен совсем другой сегмент памяти.

Кроме адреса ячейки памяти переменная-указатель обладает ещё и типом данных, значение которого в этой ячейке лежит. Это позволяет компилятору правильно интерпретировать обращение к памяти по этому адресу. Поэтому мы используем не какой-либо абстрактный тип «указатель», а именно «указатель на int».

Оператор разыменования (унарная звёздочка) противоположен оператору взятия адреса (унарному амперсанду). Сравните: &x — это адрес x в памяти, а *ptr — это значение, живущее по адресу, записанному в ptr.

Указатели, в отличие от ссылок, можно переназначать. Кроме того, есть выделенное значение никуда не ссылающегося указателя — nullptr («нулевой» указатель):

#include <iostream>

int main() {
    int x = 42, y = 13;
    int* ptr;  // по умолчанию не инициализируется, тут лежит «случайный» адрес
    ptr = nullptr;  // «нулевой» указатель
    ptr = &x;  // теперь в ptr лежит адрес переменной x
    std::cout << *ptr << "\n";  // 42
    ptr = &y;  // можно поменять адрес, записанный в ptr
    std::cout << *ptr << "\n";  // 13
}

Указатель nullptr нельзя разыменовывать: это приведёт к неопределённому поведению.

Часто указатели используются вместе с динамическим выделением памяти (malloc/new). Мы познакомимся с динамической памятью в параграфе «Жизненный цикл объекта». А сейчас лишь стоит заметить, что указатель сам по себе совершенно не означает, что память, на которую он ссылается, была выделена динамически. Например, во всех примерах выше указатель ссылался на обычную переменную на стеке.

Отдельно рассмотрим указатели на структуру. Для обращения к полям структуры через указатель есть отдельный оператор ->:

#include <iostream>

struct Point {
    double x, y, z;
};

int main() {
    Point p = {3.0, 4.0, 5.0};

    Point* ptr = &p;

    std::cout << (*ptr).x << "\n";  // обращение через * и . требует скобок
    std::cout << ptr->x << "\n";  // то же самое, но чуть короче
}

Константность

Константа — это переменная, предназначенная только для чтения. Её значение должно быть зафиксировано в момент присваивания. При этом оно не обязательно должно быть известно в момент компиляции:

#include <iostream>

int main() {
    const int c1 = 42;  // эта константа известна в compile time

    int x;
    std::cin >> x;
    const int c2 = 2 * x;  // значение становится известным только в runtime

    с2 = 0;  // ошибка компиляции: константе нельзя присвоить новое значение
}

У константного вектора или строки нельзя будет вызвать функции, которые их будут изменять:

#include <iostream>
#include <vector>

int main() {
    const std::vector<int> v = {1, 3, 5};
    std::cout << v.size() << "\n";  // OK, напечатает 3
    v.clear();  // ошибка компиляции: константный вектор нельзя изменять
    v[0] = 0;  // тоже ошибка компиляции
}

Ссылки и указатели можно комбинировать с константностью:

int main() {
    int x = 42;

    int& ref = x;  // обычная ссылка
    const int& cref = x;  // константная ссылка
    ++x;  // OK
    ++ref;  // OK
    ++cref;  // ошибка компиляции: псевдоним cref предназначен только для чтения

    int* ptr = &x;  // обычный указатель
    const int* cptr = &x;  // указатель на константу
    ++*ptr;  // OK
    ++*cptr;  // ошибка компиляции: разыменованный cptr — константа!
}

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

int main() {
    const int cx = 42;

    int& ref = cx;  // ошибка компиляции: константность нельзя убрать
    const int& cref = cx;  // OK

    int* ptr = &cx;  // тоже ошибка компиляции
    const int* cptr = &cx;  // OK
}

Базовый тип и слово const можно менять местами. Так что const T и T const — это одно и то же. Но следует различать указатель на константу (const T*) и константу типа «указатель» (T* const):

int main() {
    int x = 42;
    const int cx = 13;

    int* ptr = &x;  // обычный указатель
    ptr = &cx;  // ошибка компиляции

    const int* cptr = &x;  // OK: через *cptr нельзя будет изменить x
    cptr = &cx;  // OK

    int* const ptrc = &x;  // OK: *ptrc можно менять, но сам ptrc менять нельзя
    ptrc = nullptr;  // ошибка компиляции

    const int* const cptrc = &x;  // OK, для &cx тоже бы сработало
}

Пример в последней строке похож на константную ссылку: указатель cptrc не позволяет менять содержимое ячейки &x (первый const) и в него нельзя записать адрес другой переменной (второй const).

Ссылки в цикле range-for

Рассмотрим итерацию по элементам вектора строк. Намеренно положим в вектор много длинных строк и в цикле попробуем подсчитать их длину (которую, конечно, можно было бы сразу вычислить):

#include <iostream>
#include <vector>

int main() {
    // создаём вектор из m строк длины n
    // и искусственно заполняем его:
    const size_t m = 1000000;
    const size_t n = 10000;
    std::vector<std::string> v(m);
    for (size_t i = 0; i != m; ++i) {
        v[i].resize(n, '@');  // кладём в вектор строку из n символов @
    }

    // нам интересен этот цикл:
    size_t sum = 0;
    for (auto row : v) {
        sum += row.size();
    }
    std::cout << sum << "\n";
}

Скомпилируем программу с умеренным уровнем оптимизаций (ключ -O2) и измерим время её работы с помощью консольной утилиты time:

$ clang++ -O2 -o runnable test.cpp
$ time ./runnable

real   0m4,255s
user   0m1,948s
sys    0m2,307s

Программа работала 4,255 секунды. Давайте её ускорим. Заметим, что в цикле мы пишем

for (auto row : v) {
    // ...
}

На самом деле это эквивалентно такому:

for (size_t i = 0; i != v.size(); ++i) {
    std::string row = v[i];  // здесь создаётся копия!
    // ...
}

Понятно, что вместо копирования очередной строки можно воспользоваться константной ссылкой:

for (const auto& row : v) {
    // ...
}

Время работы такой программы уже будет меньше:

$ time ./runnable

real   0m3,462s
user   0m1,157s
sys    0m2,305s

Давайте запомним: чтобы избегать лишнего копирования, в range-for используйте константную ссылку при итерации по набору «тяжёлых» объектов — строк, векторов, структур. Если вы хотите в цикле менять элементы контейнера — используйте обычную ссылку. Нашу программу можно было бы переписать так:

#include <iostream>
#include <vector>

int main() {
    const size_t m = 1000000;
    const size_t n = 10000;
    std::vector<std::string> v(m);
    for (auto& row : v) {  // обычная ссылка
        row.resize(n, '@');
    }

    size_t sum = 0;
    for (const auto& row : v) {  // константная ссылка
        sum += row.size();
    }
    std::cout << sum << "\n";
}

«Висячие» ссылки и указатели

Может так оказаться, что переменная, на адрес которой ссылается указатель, уже вышла из своей области видимости. Похожая ситуация может произойти и со ссылками. В таком случае обращаться к памяти через ссылку или указатель нельзя — это приведёт к неопределённому поведению.

#include <iostream>

int main() {
    int* ptr = nullptr;

    {
        int x = 42;
        ptr = &x;
    }

    // обращаться к памяти, в которой жила переменная x, уже нельзя:
    std::cout << *ptr << "\n";  // неопределённое поведение!
}

Аналогичная ситуация произойдёт при обращении к уже несуществующему элементу вектора:

#include <iostream>
#include <vector>

int main() {
    std::vector<std::string> words = {"one", "two", "three"};

    std::string& ref = words[0];  // псевдоним для начального элемента вектора

    words.clear();

    // обращаться к ссылке ref уже нельзя!
    std::cout << ref << "\n";  // неопределённое поведение!
}

Важно не допускать в программах таких ситуаций.

Отмечайте параграфы как прочитанные чтобы видеть свой прогресс обучения

Вступайте в сообщество хендбука

Здесь можно найти единомышленников, экспертов и просто интересных собеседников. А ещё — получить помощь или поделиться знаниями.
Вступить
Сообщить об ошибке
Предыдущий параграф2.5. Составные типы данных

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

Следующий параграф2.7. Функции

Функции позволяют отделить часто используемый код и переиспользовать его с разными значениями аргументов.