Как стать автором
Обновить

Константность в C++

Уровень сложностиСредний
Время на прочтение31 мин
Количество просмотров14K


Продолжаем серию «C++, копаем вглубь». Цель этой серии — рассказать максимально подробно о разных особенностях языка, возможно довольно специальных. Это седьмая статья из серии, список предыдущих статей приведен в конце в разделе 10. Серия ориентирована на программистов, имеющих определенный опыт работы на C++. Данная статья посвящена концепции константности в C++.


Переменные являются основой любого языка программирования. Если значение переменной нельзя изменить после инициализации, то такие переменные называются неизменяемыми (immutable) переменными или константными переменными или просто константами. Константные переменные в том или ином виде поддерживаются во всех языках программирования и играют в них важную роль. Такие переменные помогают компилятору оптимизировать код, улучшают читаемость и надежность кода, позволяют выявлять бОльшее количество ошибок на стадии компиляции. Константы помогают решать проблемы, связанные с многопоточным доступом к переменным. Использование константных переменных влияет на проектные решения в объектно-ориентированном программировании, а функциональное программирование изначально исходит из неизменяемости используемых переменных.


В C++ концепции константности уделяется значительное место. Для переменных используется два вида константности, имеются ссылки и указатели на константу, константные функции-члены, константные итераторы. Попробуем разобраться во всех этих понятиях.


Оглавление

Оглавление


1. Константы — объявление, инициализация, правила использования
  1.1. Объявление констант
    1.1.1. Основы
    1.1.2. Массивы, указатели, ссылки
    1.1.3. Динамические объекты
  1.2. Инициализация констант
  1.3. Константы времени компиляции
  1.4. Автоопределение типа и константность
  1.5. Основные правила использования констант
    1.5.1. Смысл константности
    1.5.2. Ссылки и указатели на константу
    1.5.3. Приведение ссылок и указателей
2. Константные типы
  2.1. Псевдонимы константных типов
  2.2. const void
  2.3. Типы, которые не могут быть константными
  2.4. Использование константных типов в шаблонах
3. Константные нестатические функции-члены
  3.1. Объявление и основные требования
  3.2. Константная и неконстантная версия функции-члена
  3.3. Деструктор
  3.4. Физическая и логическая константность
  3.5. Константность и многопоточность
4. Итераторы
  4.1. Константные и неконстантные итераторы
  4.2. Особенности некоторых контейнеров
5. Константные параметры функции, константное возвращаемое значение функции
  5.1. Константные параметры функции
  5.2. Константное возвращаемое значение функции
6. Ссылки и указатели на константу — дополнительные детали
  6.1. Инициализация и временная материализация
  6.2. Параметры функций
  6.3. Возвращаемое значение функции
  6.4. Члены класса
  6.5. Указатели на константу
7. Разное
  7.1. Локальное и глобальное связывание
  7.2. Классы с константными нестатическими членами
8. Ключевое слово constexpr
  8.1. Переменные
  8.2. Шаблоны переменных
  8.3. Функции
  8.4. Нестатические функции-члены класса
9. Итоги
10. Список статей серии «C++, копаем вглубь»
Список литературы



1. Константы — объявление, инициализация, правила использования



1.1. Объявление констант



1.1.1. Основы


Пусть T некоторый встроенный тип или пользовательский тип (класс/структура/объединение/перечисление) или псевдоним, объявленный с помощью typedef или using (для псевдонимов есть ограничения, см. раздел 2.3). В C++11 в качестве T также можно использовать ключевое слово auto и конструкцию decltype(expression).


Следующая инструкция объявляет константную переменную или просто константу типа T:


const T x; // константа типа T

Ключевое слово const относится к так называемым cv-квалификаторам, куда еще входит ключевое слово volatile.


Константы обычно явно инициализируются при объявлении, подробнее см. раздел 1.2.


Квалификатор const может стоять как до имени типа, так и после. Объявление


const T x;

будет эквивалентно объявлению


T const x;

Первый вариант более привычный, но ряд специалистов (см., например [VJG]) считают второй вариант более предпочтительным и у них есть серьезные аргументы. В сообщениях компиляторов также часто используется эта форма. В этой статье мы будем использовать традиционную форму, когда квалификатор const стоит до имени типа.


В одной инструкции можно объявить несколько переменных, эти переменные разделяются запятыми. В этом случае константность будет распространяться на все переменные. Объявление


const T x, y;

будет эквивалентно объявлениям


const T x;
const T y;


1.1.2. Массивы, указатели, ссылки


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


Если N целочисленное выражение, вычисляемое во время компиляции и его значение больше нуля, то можно объявить массив констант:


const T ax[N]; // массив из N констант типа T

Спецификатор указателя можно использовать двумя способами:


const T *pcx;  // указатель на константу типа T
T *const cpx;  // константный указатель на переменную типа T

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


const int x = 42;
const int *pcx = &x;
*pcx = 0; // ошибка
pcx = nullptr; // OK

Константный указатель — это указатель, который сам является константой, но он может указывать на неконстанту.


int x = 42;
int *const cpx = &x;
*cpx = 0; // OK
cpx = nullptr; // ошибка

Эти два варианта константности можно объединить.


const T *const cpcx; // константный указатель на константу типа T

Ограничения в этом случае также объединяются.


Константные указатели можно объявлять с помощью псевдонимов указателей:


using PT = T*; // псевдоним указателя на переменную типа T
const PT cpx;  // константный указатель на переменную типа T

Спецификатор ссылки можно использовать только одним способом:


const T &rcx;  // ссылка на константу типа T

Нельзя изменять значение переменной, на которую ссылается ссылка на константу.


const int x = 42;
const int &rcx = x;
rcx = 0; // ошибка

Сами ссылки являются «константоподобными» объектами, они должны быть всегда явно инициализированы и не могут поменять объект, на который ссылаются, но формально это не константы. К сожалению, очень часто можно наблюдать терминологическую неточность — ссылки на константу называют константными ссылками. Формально ссылка не может быть константной, а вот ссылаться она может как на константу, так и на неконстанту. В случае указателей мы вынуждены четко различать эти варианты константности, а в случае ссылок, увы, нет.



1.1.3. Динамические объекты


Можно также создать динамический константный объект:


const T *pcx = new const T; // константа типа T

Оператор new в этом случае возвращает указатель на константу.


Динамические константные объекты удаляются как обычно, операторы delete и delete[] можно применять к указателю на константу.


Правда, динамические константные объекты можно отнести к большой экзотике, какой-нибудь полезный пример привести трудно.



1.2. Инициализация констант


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


Для инициализации переменной можно применять четыре синтаксических конструкции:


  1. Символ =;
  2. Круглые скобки;
  3. Фигурные скобки;
  4. Комбинацию символа = и фигурных скобок.

Вот примеры:


const int x1 = 5;
const int x2(5);
const int x3{ 5 };
const int x4 = { 5 };

В зависимости от типа переменной и контекста объявления, какие-то из этих вариантов могут оказаться недопустимыми.


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


const int x = 42; // объявление и инициализация

class X
{
    static const double Factor; // объявление
    const int m_Id;             // объявление
public:
    X(int id);
// ...
};

X::X(int id): m_Id(id) {}      // инициализация
const double X::Factor = 0.04; // инициализация

Отсутствие явной инициализации при объявлении переменной не всегда означает, что она окажется неинициализированной. Если тип объявляемой переменной является нетривиальным, то при наличии доступного конструктора по умолчанию (такой конструктор может генерироваться компилятором), он вызывается автоматически и переменная становится инициализированной, если же такого конструктора нет, то возникает ошибка. Вот пример:


const std::string emptyStr; // OK, пустая строка

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


Наконец отметим, что инициализация — это не присваивание, хотя в ряде случаев используется символ =. Присвоить значение можно только ранее созданной переменной, а инициализация происходит во время создания. И самое главное — для констант присваивание запрещено.



1.3. Константы времени компиляции


Инициализирующие выражения для констант можно отнести к двум классам: вычисляемые во время компиляции и вычисляемые во время выполнения. Вот пример инициализации во время выполнения:


int CalcMaxLength();
// ...
const int MaxLength = CalcMaxLength();

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


const int N = 32;
int a[N];
std::array<int, N> aa;
enum Flags { Yoo = N };
alignas(N) char buffer[1024];

Концепция константности времени компиляции получила дальнейшее развитие в C++11, в этом стандарте появилось новое ключевое слово constexpr. Теперь константы времени компиляции правильнее объявлять как constexpr, а не как const. В C++11 это ключевое слово используется не только для объявления переменных, подробнее см. раздел 8.



1.4. Автоопределение типа и константность


В C++11 появилась возможность не объявлять явно тип переменной, а определять ее тип исходя из типа инициализирующего выражения. Для этого используется ключевое слово auto. (Это ключевое слово появилось в первых версиях C, но его использование оказалось излишним, и в C++11 его решили использовать по-новому.)


С auto можно использовать квалификатор const, спецификаторы ссылки и указателя. Нельзя объявлять массивы, нестатические члены класса, статические члены класса без инициализации. Если объявляется несколько переменных в одной инструкции, то для них выводимый основной тип должен быть один и тот же.


Использование auto имеет некоторые не вполне очевидные особенности. При выводе типа переменой снимается ссылочность и константность инициализирующего выражения. То есть в объявлении


auto x = expression;

тип переменной x никогда не будет иметь тип константы или ссылки. Если выражение expression имеет ссылочный тип, то будет создана копия экземпляра, на который эта ссылка ссылается. Квалификатор const и спецификатор ссылки надо указывать явно.


const auto c = expression;
const auto &rc = expression;

Эту особенность важно учитывать при работе с контейнерами, так как для итераторов перегруженный оператор * будет возвращать ссылку на элемент контейнера. Также возвращают ссылку на элемент такие функции-члены стандартных контейнеров как индексаторы, at(), front(), back().


В C++11 появилась конструкция decltype(expression). Это по существу псевдоним для типа выражения expression, константность при этом сохраняется. Вот пример:


const int cx = 42;
decltype(cx) cx2 = 125; // const int

Эта конструкция в основном используется в шаблонах.


Подробнее о выводе типа можно почитать у Скотта Мейерса [Meyers2].



1.5. Основные правила использования констант



1.5.1. Смысл константности


Ключевое свойство констант — это то, что после инициализации значение константы изменить нельзя. Для константы запрещены операции, которые потенциально могут изменить ее битовый образ. Так же запрещено получить ссылку или указатель на константу, через которые ее можно изменить. Вот что это означает более конкретно.


  1. Для констант запрещены модифицирующие операции. Для встроенных типов и перечислений это означает, что запрещено присваивание и, если поддерживаются, инкремент и декремент. Для пользовательских типов (класс/структура/объединение) это означает запрет вызова неконстантных фунций-членов (см. раздел 3).
  2. Cсылку на неконстанту нельзя инициализировать константой или ссылкой на константу. Указатель на неконстанту нельзя инициализировать указателем на константу. Указателю на неконстанту нельзя присваивать значение указателя на константу.
  3. Константы запрещено использовать в качестве аргументов функции, когда соответствующий параметр является ссылкой или указателем на неконстанту.
  4. Если константа пользовательского типа имеет доступные члены, то они рассматриваются как константы, соответственно, правила 1-4 применяются и к ним.

Рассмотрим примеры:


void Foo(int &rx);
void Foo(int *px);
// ...
const int x = 42;
x = 0;  // ошибка, №1
x += 1; // ошибка, №1
++x;    // ошибка, №1
int &rx = x;  // ошибка, №2
int *px = &x; // ошибка, №2
Foo(x);  // ошибка, №3
Foo(&x); // ошибка, №3
const int &rсx = x;  // OK
const int *pсx = &x; // OK


1.5.2. Ссылки и указатели на константу


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


int x = 42;
const int &rcx = x;  // OK
const int *pcx = &x; // OK

Все ограничения, связанные с константами, распространяются на такие переменные, если доступ осуществляется через эти ссылки или указатели. Таким образом, мы можем предоставить интерфейс константны для неконстантных переменных. Другими словами, мы можем временно, в некотором контексте «превратить» любую переменную в константу.



1.5.3. Приведение ссылок и указателей


То, что описано в предыдущем разделе означает, что существует неявное приведение ссылок и указателей на неконстанты к ссылкам и указателям на константы. Таким образом, мы можем использовать ссылки и указатели на неконстанты везде, где требуются ссылки и указатели на константы. А вот обратное приведение, снимающее константность, может быть выполнено только явно, для этого существует оператор const_cast<>. Вот пример:


const int *pcx;
int *px = const_cast<int*>(pcx);

Потенциально с помощью такого приведения можно делать очень нехорошие вещи. Вот пример:


const int cx = 2;
const int &crx = cx;
int &rx = const_cast<int&>(crx);
rx = 3;

Это конечно ужасный стиль (как такое могло придти в голову ?!), кроме того, может оказаться, что константы размещены в памяти доступной только для чтения и в этом случае мы получим аварийное завершение приложения.


Но все-таки иногда приходится снимать константность, например, при работе с внешним API. При этом надо всегда тщательно продумать возможные последствия. Пример корректного снятия константности приведен в разделе 4.2.



2. Константные типы


Тип константной переменной называется константным типом.



2.1. Псевдонимы константных типов


Можно объявить псевдоним константного типа:


using CINT = const int;

Также можно использовать более старый вариант с использованием typedef:


typedef const int CINT;

После чего объявление


CINT cх;

будет эквивалентно объявлению


const int cx;

Объявление


CINT *pcх;

будет эквивалентно объявлению


const int *pcx;

Отметим, что ссылки и указатели на константу сами по себе не являются константными типами.



2.2. const void


Константным может быть тип void.


using CV = const void; // OK
const void* pcv = nullptr; // OK

Особого смысла в таком типе нет, но это допущение может оказаться полезным при написании шаблонов.



2.3. Типы, которые не могут быть константными


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


Двойная константность не поддерживается, если имеется два квалификатора const, то второй игнорируется. Объявление


const T const x;

будет эквивалентно объявлению


const T x;

Компилятор в этом случае может выдать предупреждение.


Можно попробовать объявить как-нибудь так:


using CINT = const int;
const CINT ccх = 8;

В этом случае const будет проигнорировано, компилятор может выдать предупреждение.


Ссылки по сути являются «константоподобными» объектами, они должны быть всегда явно инициализированы и не могут поменять объект, на который ссылаются. (Распространенная точка зрения состоит в том, что «под капотом» у ссылки находится константный указатель.) Но сами ссылки нельзя объявить константными, так как в этом случае получим по существу двойную константность. Можно попробовать объявить как-нибудь так:


int &const crx;

или так


using RINT = int&;
const RINT crx;

В этом случае const будет проигнорировано, компилятор может выдать предупреждение или ошибку.


Массивы не могут быть константными, константными могут быть только элементы массива. Инструкция


const int x[4];

объявляет массив констант.


Можно попробовать объявить как-нибудь так:


using I4 = int[4];
const I4 x;

В этом случае это будет то же самое, что и


const int x[4]; // массив констант

При низведении (decay, array-to-pointer decay) массив констант преобразуется к указателю на константу, который сам не является константным.


Функции также не могут быть константами, константным может быть только возвращаемое значение. Инструкция


const int Foo();

объявляет функцию, возвращающую константу типа int.


Можно попробовать объявить как-нибудь так:


using F = void(int);
const F cf;

В этом случае const будет проигнорировано, компилятор может выдать предупреждение или ошибку.


Можно попробовать объявить указатель на константную функцию как-нибудь так:


void(const *pcf)(int);

В этом случае получим ошибку.


Но можно объявить константный указатель на функцию:


void(*const cpf)(int);


2.4. Использование константных типов в шаблонах


Константные типы можно использовать в качестве шаблонных аргументов и для специализации шаблонов классов, правда, могут быть ограничения, связанные с конкретными шаблонами. Например, константный тип можно использовать для конкретизации шаблона класса std::array<>, но нельзя использовать для конкретизации остальных стандартных контейнеров. Вот, например, сообщение MSVS:


The C++ Standard forbids containers of const elements because allocator<const T> is ill-formed.


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


// первичный шаблон
template <typename T>
struct U
{
    const char* Tag() const { return "primary"; }
};

// частичная специализация для константных типов
template <typename T>
struct U<const T>
{
    const char* Tag() const { return "const"; }
};

U<int> u1;
U<const int> u2;

std::cout << u1.Tag() << ' ' << u2.Tag() << '\n';

Вывод: primary const


Для программирования шаблонов, которые могут использовать константные типы в качестве шаблонных аргументов, в стандартной библиотеке (заголовочный файл <type_traits>) имеется несколько свойств типов, позволяющих определить и изменить константность используемых в шаблоне типов: std::is_const<>, std::add_const<>, std::remove_const<>.



3. Константные нестатические функции-члены



3.1. Объявление и основные требования


Нестатические функции-члены можно объявить константными следующим образом:


class X
{
// ...
    int GetWeight() const;
};

В константной функции-члене экземпляр, для которого вызывается эта функция (на него указывает this) будет считаться константным. Соответственно, константные функции-члены не должны изменять битовый образ экземпляра, для которого вызываются, а также возвращать ссылки или указатели, через которые можно изменить этот битовый образ.


Соответственно при определении константной функции-члена должны выполняться следующие требования:


  1. Не должны модифицироваться члены своего класса. Если имеется член класса пользовательского типа, который агрегирован по значению, то его члены также нельзя модифицировать (подробнее о смысле модификации см. в разделе 1.5).
  2. Не должны вызываться неконстантные функции-члены своего класса. Если имеется член класса пользовательского типа, который агрегирован по значению, то для него также нельзя вызывать неконстантные члены.
  3. Значение, которое можно получить из функции-члена, не может быть указателем или ссылкой, через которую можно модифицировать члены своего класса. Если имеется член класса пользовательского типа, который агрегирован по значению, то значение, которое можно получить из функции-члена, не может быть указателем или ссылкой, через которую можно модифицировать члены такого класса.

Рассмотрим пример:


class X
{
    int m_Weight;
// ...
    const int & GetWeight() const { return m_Weight; } // OK
    void DoubleWeight() const { m_Weight *= 2; }    // ошибка, №1
    int & GetWeightRef() const { return m_Weight; } // ошибка, №3
    void GetWeightPtr(int*& pw) const { pw = &m_Weight; } // ошибка, №3
};

Если нестатический член класса объявлен как mutable, то его можно модифицировать в константных функциях.


Для константных экземпляров класса можно вызывать только константные функции-члены, тогда как для неконстантных экземпляров класса можно вызывать любые функции-члены. Таким образом, объявив функцию константной, мы расширяем контекст ее использования.



3.2. Константная и неконстантная версия функции-члена


В классе могут быть две функции-члена с одним именем и одинаковыми параметрами, которые отличаются константностью и, обычно, возвращаемым значением. Таким образом, они оказываются перегруженными. Вот как примерно объявлены два варианта индексатора в std::vector<>:


value_type &operator[](size_type ind);
const value_type & operator[](size_type ind) const;

При разрешении перегрузки для константных экземпляров класса будет выбрана константная версия функции-члена, для неконстантных — неконстантная. Так же константная версия функции-члена выбирается, если вызов происходит внутри другой константной функции-члена, и неконстантная, когда вызов внутри неконстантной функции-члена.


Такое решение широко применяется в стандартной библиотеке. Подробнее эту тему обсуждает Скотт Мейерс [Meyers1].



3.3. Деструктор


Рассмотрим теперь вопрос о константности деструктора. Если мы попробуем объявить деструктор константным, то компилятор выдаст ошибку. Несложно понять, почему это так. Деструктор должен вызываться в конце жизненного цикла любого объекта нетривиального типа, независимо от того, является этот объект константным или нет и поэтому деструктор должен вести себя одинаково как для константных объектов, так и неконстантных. После вызова деструктора объект уже нельзя использовать, при попытке использования возникнет неопределенное поведение, и поэтому неважно, изменял ли деструктор битовый образ объекта или нет, все равно эти биты использовать нельзя. Таким образом рассматривать константность деструктора просто не имеет смысла.


Отметим также, что вполне законно удалять динамический объект с помощью оператора delete через указатель на константу, а также удалять объект из контейнера с помощью константного итератора, принципы константности не будут нарушены (см. раздел 4.1).



3.4. Физическая и логическая константность


Под физической константностью будем понимать формальные правила, изложенные в разделе 3.1. Под логической константностью будем понимать менее формальные критерии, восходящие скорее к здравому смыслу.


Рассмотрим два примера, когда при нарушении физической константности целесообразно сделать функцию-член константным.


Рассмотрим функцию-член для доступа к некоторому члену класса. Если этот член класса инициализируется в соответствии с идиомой lazy evaluation, то есть при первом обращении, то такую функцию-член формально нельзя считать константной, но можно считать, что нарушения логической константности здесь нет. Такие функции-члены можно объявлять константными, а соответствующий член класса как mutable.


Другой пример — это использования мьютекса. Операции с мьютексом (блокировки, разблокировки) являются неконстантными, и, следовательно, если мьютекс является членом класса, то функции-члены, которые его используют, формально не могут быть константными. Но операция с мютексом носит чисто технический характер и при этом основные члены класса могут не меняться. В этом случае также целесообразно считать, что нарушения логической константности здесь нет, объявлять функции-члены константными, а мьютекс как mutable.


Рассмотрим теперь примеры, когда при соблюдении физической константности функцию-член целесообразно объявлять неконстантной.


Пусть у нас имеется класс, имеющий в качестве члена указатель или ссылку на другой объект. Вызов неконстантной функции-члена через такой указатель или ссылку не изменяет битовый образ самого указателя и поэтому он может быть сделан в константной функции-члене. Точно также константная функция-член может возвращать указатель или ссылку на неконстанту на данные, доступные через указатель или ссылку. В этом случае говорят о наличии физической константности, и нарушении логической константности и такую функцию-член можно объявлять неконстантной. Но если граф подобъектов глубокий, то отслеживать неконстантность в дальних ветках может оказаться непростой задачей.


Один из вариантов логической константности может звучать так: можем ли мы изменять элементы контейнера, если экземпляр контейнера является константным? Несложно написать контейнер, в котором это разрешено. Но вот контейнеры стандартной библиотеки такие операции запрещают, если экземпляр контейнера является константой, то модификация элементов запрещена. Функции-члены для доступа к элементам контейнера (а это at(), operator[](), front(), back(), data()) имеют две перегруженные версии — константную и неконстантную (см. раздел 3.2). Константные версии возвращают ссылку или указатель на константу. (Исключением является operator[]() для отображений (std::*map<>), но это довольно специфический случай.) Аналогично работают функции-члены, возвращающие итераторы (begin(), end(), etc), их константные версии возвращают константные итераторы (см. раздел 4.1). Таким образом, если экземпляр контейнера является константой, то функции-члены доступа к элементам возвращают ссылку на константу или константный итератор и «законная» модификация элемента невозможна.


Рассмотрим теперь интеллектуальные указатели. Перегруженные операторы -> и * являются константными функциями-членами, но возвращают обычный указатель и ссылку соответственно. То есть мы можем через константный интеллектуальный указатель модифицировать объект, который контролирует интеллектуальный указатель. Для запрета модификации этого объекта интеллектуальные указатели надо конкретизировать константным типом. Такое поведение полностью совпадает с поведением обычных указателей.



3.5. Константность и многопоточность


Константные функции-члены хорошие кандидаты для безопасной работы в многопоточных сценариях. Если функции-члены не изменяют никаких данных, то их безопасно можно вызывать из разных потоков.



4. Итераторы


Итераторы являются важнейшей частью STL, они служат «клеем», который соединяет две другие части STL —контейнеры и алгоритмы. Итераторы являются «указателеподобными» объектами, они реализуют интерфейс встроенных указателей для того, что бы предоставить доступ к элементам контейнеров и при этом поддерживают концепцию константности.



4.1. Константные и неконстантные итераторы


Стандартные контейнеры предоставляют два типа итераторов: обычные, с помощью которых можно модифицировать объекты, находящиеся в контейнере, и константные, с помощью которых объекты, находящиеся в контейнере, являются доступными только для чтения. Для константных итераторов оператор * возвращает ссылку на константу, а оператор -> указатель на константу. Итераторы являются вложенными типами, их имена — iterator для обычного итератора и const_iterator для константного. Полное имя получается довольно громоздкими, поэтому для их объявления обычно используется ключевое слово auto.


В C++98 работа с константными итераторами была довольно неудобна — константный итератор можно было получить только через константный экземпляр контейнера. В C++11 контейнеры стали поддерживать функции-члены cbegin(), cend() (а также их реверсивные аналоги), которые возвращают константный итератор для неконстантных экземпляров контейнера. Появилась возможность более четко отделять константность самого контейнера и константность элементов контейнера, что дает возможность писать более ясный и надежный код. Кроме того, операции, которые не изменяют элементы контейнера, но изменяют сам контейнер (вставка элемента, удаление), стали принимать константный итератор, что лучше соответствует принципам константности. Такие операции реализуют функции-члены insert(), erase() и некоторые другие. Обратим внимание на то, что erase() в процессе удаления элемента из контейнера вызывает его деструктор, но эта операция применима к константному объекту, см. раздел 3.3. Вот пример шаблона функции, которая удаляет все элементы контейнера, отвечающие заданному условию:


template<class C, class P>
int EraseIf(C &cont, P pred)
{
    int ret = 0;
    for (auto itr = cont.cbegin(); itr != cont.cend();)
    {
        if (pred(*itr))
        {
            itr = cont.erase(itr);
            ++ret;
        }
        else
        {
            ++itr;
        }
    }
    return ret; // число удаленных элементов
}

Стандартная библиотека также содержит свободные (не-члены) шаблоны функций cbegin(), cend(), etc, которые можно использовать не только со стандартными контейнерами, но и с «контейнероподобными» типами, например массивами.


Существует неявное приведение неконстантного итератора к соответствующему константному, то есть мы можем использовать неконстантный итератор везде, где требуются константный. Это полностью аналогично соответствующему поведению указателей (см. раздел 1.5.3) и было бы очень неудобно, если бы это было не так. Но вот в отличие от указателей, простого и переносимого обратного преобразования — от константного итератора к соответствующему неконстантному — не существует.


Рассмотрим теперь термин «константный итератор». Здесь похожая ситуация, что и с термином «константная ссылка» (см. раздел 1.1.2). Сам итератор практически никогда не бывает константным, но может давать доступ как константным объектам, так и неконстантным. Эту проблему обсуждает Стефан Дьюхэрст [Dewhurst], правда он не приводит правильного с его точки зрения варианта. Так что будем считать термин «константный итератор» компромиссом между точностью и удобством использования.



4.2. Особенности некоторых контейнеров


Рассмотрим контейнеры std::set<>, std::unodered_set<> и их multi аналоги. В этих контейнерах хранимые объекты являются одновременно ключом, то есть используются для сравнения в ассоциативных контейнерах или для вычисления хеш-функции в неупорядоченных, и значением, то есть содержат какие-то данные, возможно отличные от ключа. Для объектов, находящихся в контейнере, нельзя менять ключи, но контейнер не может различить изменения хранимого объекта, затрагивающие ключ и другие изменения (инкапсуляция!) и поэтому вынужден «на всякий случай» запретить любые изменения. Таким образом, контейнер считает любые итераторы по существу константными, то есть не позволяющими изменять объект, хранимый в контейнере. Но в таких объектах часто можно выделить ключевую часть, которую действительно нельзя изменять, и данные, которые не используются в ключевых операциях и которые можно безопасно модифицировать непосредственно в контейнере. Контейнер про такое разделение не знает, но программист знает и может организовать модификацию неключевых данных. Вот пример:


class Item
{
    const int m_Id;   // ключ
    int       m_Data; // данные
public:
    explicit Item(int id, int data = 0)
        : m_Id(id), m_Data(data) {}
    bool operator<(const Item& itm) const
        { return m_Id < itm.m_Id; }
    int Id() const { return m_Id; }
    int Data() const { return m_Data; }
    void SetData(int data) { m_Data = data; }
};

using ItemSet = std::set<Item>;

bool SetData(ItemSet& items, int id, int data)
{
    bool ret = false;
    ItemSet::iterator itr = items.find(Item(id));
    if (itr != items.end())
    {
        const Item& citm = *itr;
        Item& itm = const_cast<Item&>(citm);
        itm.SetData(data);
        ret = true;
    }
    return ret;
}

Оператор * для итератора возвращает ссылку на константу, несмотря на то, что итератор неконстантный, это и есть обсуждаемая особенность таких контейнеров. Мы снимаем константность с этой ссылки и, соответственно, можем использовать неконстантную SetData() для изменения данных. (Про снятие константности подробнее в разделе 1.5.3.) Член m_Data не участвуют в сравнении экземпляров класса Item, поэтому такая операция «законна». Обратим внимание на следующую особенность данного примера: класс Item спроектирован так, что ключ нельзя изменить даже через ссылку на неконстанту. Такой прием можно рекомендовать при работе с этими контейнерами.



5. Константные параметры функции, константное возвращаемое значение функции



5.1. Константные параметры функции


При объявлении функции константность параметра игнорируется.


void Foo(const int x);
void Foo(int x);

Это не перегрузка, это одно и то же.


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



5.2. Константное возвращаемое значение функции


Далее под rvalue будем понимать выражение, имеющее значение, но не представленное именованной переменной. Под lvalue будем понимать именованную переменную.


Тип возвращаемого значения функции может быть константным. Если это тип встроенный, то особого смысла в этом нет, но в случае пользовательского типа (кроме перечисления) это может оказаться полезным. Дело в том, что rvalue встроенных типов запрещено модифицировать, а вот rvalue пользовательского типа (кроме перечисления) можно модифицировать, точнее для них можно вызывать неконстантные функции члены и, соответственно, мы можем модифицировать rvalue результат вызова функции. Вот примеры:


class X
{
// ...
    X& operator=(int x);
};

int G();
X F();

G() = 42; // ошибка
F() = 42; // ОК

Такое поведение может оказаться нежелательным. Например, при перегрузке операторов могут оказаться синтаксически корректными некие «странные» выражения, невозможные для встроенных версий операторов. В этом случае надо объявить возвращаемый тип константным, и его модификация в rvalue будет запрещена. Приведем пример.


class Int
{
    int m_Value;
public:
    Int(int val) : m_Value(val) {}
// ...
    Int operator++(int) // post-increment
    {
        auto t = m_Value;
        ++m_Value;
        return Int(t);
    }
};

Рассмотрим код:


Int r = 0;
r++++;

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


Для предотвращения такой «патологии» надо объявить возвращаемое значение оператора константным:


const Int operator++(int);

После этого подобный код уже не будет компилироваться.


В C++11 это решение можно получить другим способом — надо запретить применять этот оператор для rvalue. Для этого надо использовать ссылочные квалификаторы:


Int operator++(int) &;
Int operator++(int) && = delete;

Второе объявление является необязательным, но повышает наглядность кода.


Если функция возвращает объект по значению и тип этого объекта перемещаемый, то делать его константным нельзя, так как это разрушает всю семантику перемещения.



6. Ссылки и указатели на константу — дополнительные детали


Ссылки и указатели на константу обладают рядом интересных и не вполне очевидных свойств. В этом разделе мы в основном будем говорить о ссылках, указатели кратко рассмотрим в конце в разделе 6.5.



6.1. Инициализация и временная материализация


Напомним, что ссылки должны быть всегда инициализированы. Понятно, что ссылку на константу можно инициализировать константой или такой же ссылкой на константу. Кроме этого ссылку на константу можно инициализировать неконстантной переменной или ссылкой на неконстанту.


Более интересное свойство заключается в том, что ссылку на константу можно инициализировать с помощью rvalue, то есть выражением, имеющим значение, но не представленное переменной. Более того, ссылку на константу можно инициализировать любым выражением, тип которого имеет неявное приведение к типу, на который эта ссылка ссылается. В этих случаях будет создана временная скрытая переменная соответствующего типа, на которую ссылка и будет ссылаться. Гарантируется, что время жизни такой переменной будет не меньше, чем время жизни самой ссылки. Это называется временной материализацией (temporary materialization).


class X
{
    int m_A, m_B;
public:
    X() : m_A(0), m_B(0) {}
    X(int a) : m_A(a), m_B(0) {}
    X(int a, int b) : m_A(a), m_B(b) {}
};

const X &r1 = 1; // до C++11
const X &r2{};
const X &r3{ 3 };
const X &r4{ 4, 5 };

В этом примере для всех ссылок, кроме r1, используется синтаксис универсальной инициализации (uniform initialization), появившийся в C++11. Во всех случаев создается скрытая переменная типа X, инициализированная соответствующими аргументами. Но если конструкторы класса X объявить как explicit, то эти объявления окажутся недопустимыми, так как будут запрещены неявные приведения списка аргументов к типу X. В этом случае можно объявить так:


const X &s2 = X();
const X &s3 = X(3);
const X &s4 = X(4, 5);

Временная материализация здесь также выполняется, так как инициализирующие выражения являются rvalue.


Проблемы с временной материализацией могут возникнуть, когда ссылка, ссылающаяся на соответствующую временную переменную, копируется. Компилятор не отслеживает время жизни копии и оно может оказаться больше, чем время жизни временной переменной. В этом случае копия окажется висячей ссылкой (dangling reference), то есть ссылкой, ссылающейся на удаленную переменную. Примеры будут приведены в разделах 6.3, 6.4.



6.2. Параметры функций


Ссылка на константу достаточно часто используется в качестве типа параметра функции.


int Foo(const T &x); // передача параметра по ссылке на константу

Этот вариант не позволяет модифицировать аргумент, то есть в теле функции переменная, на которую ссылается параметр x, считается константой. Никаких специальных требований к типу T нет. Накладные расходы при передаче параметра эквивалентны передачи указателя на T, то есть они постоянны. В качестве аргумента можно использовать любое выражение, допустимое для инициализации ссылки на константу типа T. Если при вызове функции в процессе вычисления аргумента происходит временная материализация, то время жизни соответствующей временной переменной будет не меньше, чем время жизни выражения (максимально большого), внутри которого происходит вызов функции.


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



6.3. Возвращаемое значение функции


Ссылка на константу может быть использована в качестве возвращаемого значения функции. Но здесь надо четко понимать, на что эта ссылка ссылается и не окажется ли так, что ее время жизни больше, чем объект, на который она ссылается, то есть не окажется ли она висячей.


Рассмотрим пример, в котором функция имеет параметры типа ссылка на константу и использует в качестве возвращаемого значения один из этих параметров.


template<typename T>
const T & Min(const T &x, const T &y)
{
    return x < y ? x : y;
}

Если аргументом для параметра, который будет возвращать функция, является lvalue, то функция вернет ссылку на это значение. Если аргумент является rvalue, то произойдет временная материализация и время жизни соответствующей временной переменной будет не больше, чем время жизни выражения (максимально большого), внутри которого происходит вызов функции. Если в этом случае возвращаемое значение функции будет использовано для инициализации ссылки, то деструктор этой временной переменной будет вызван сразу после инициализации. Дальнейшее использование такой ссылки будет некорректным и может закончиться неопределенным поведением. Проблема не возникает, если возвращаемое значение используется внутри выражения, в котором выполняется вызов функции.


Приведенный пример не является какой-то «патологией», в стандартной библиотеке таким образом реализованы некоторые шаблоны функций, например: std::min(), std::max(), std::clamp. О возможности возникновения висячих ссылок есть даже предупреждение в документации (см. https://en.cppreference.com/w/cpp/algorithm/max).



6.4. Члены класса


Нестатические члены класса типа ссылка на константу также могут порождать висячие ссылки. Такие члены должны быть инициализированы в конструкторе и рассматриваемая проблема гарантированно возникает, когда для инициализации используется rvalue аргумент. Вот пример:


class R;

class X
{
    const R &m_R;
public:
    X(const R &r) : m_R(r) { /* ... */ }
// ...
};

Если экземпляр класса X будет инициализирован с помощью rvalue, то произойдет временная материализация, ссылка на соответствующую временную переменную будет доступна в течении жизни этого экземпляра, но сама эта временная переменная будет удалена сразу после вызова конструктора. Для предотвращения подобной инициализации можно запретить использование rvalue аргументов в конструкторе, для этого надо объявить соответствующий конструктор удаленным.


X(R &&) = delete;

При инициализации такого экземпляра с помощью lvalue переменной также надо следить, чтобы время жизни этой переменной было не меньше, чем время жизни экземпляра.



6.5. Указатели на константу


В отличие от ссылок на константу, указатели на константу не требуют обязательной инициализации. Указатель может быть нулевым, также поддерживается присваивание, то есть можно менять переменную, на которую этот указатель указывает. Указатель на константу может указывать на неконстантную переменную.


const int x = 1;
int y = 2;
const int *p = nullptr;
p = &x;
p = &y;

Указатели на константу не поддерживают временную материализацию, так как оператор взятия адреса & не может применяться к rvalue.


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


int Foo(const T *a, int n);


7. Разное



7.1. Локальное и глобальное связывание


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


При необходимости с помощью ключевого слова extern для констант можно реализовать глобальное связывание.


// определение, ровно в одном *.cpp файле модуля
extern const int ExtConst = 42;
// объявления в других файлах, может быть несколько
extern const int ExtConst;


7.2. Классы с константными нестатическими членами


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


Можно спроектировать класс, где все члены будут константными. Экземпляры такого класса по существу будут константами всегда. Подобные решения используются в других языках программирования, но в C++ не получили распространения.


Если константные нестатические члены класса инициализированы при объявлении выражением, вычисляемым при компиляции, то такие члены будут иметь одно и то же значение для всех экземпляров класса и, соответственно, их надо объявлять как static const, а еще лучше как static constexpr (см. раздел 8.1).


Константными могут быть также члены объединения. Это позволит проектировать объединения, для которых одни члены используются для установки значения, а другие только для чтения.



8. Ключевое слово constexpr


Концепция константности времени компиляции (ее можно считать частью метапрограммирования) была признана C++ сообществом весьма перспективной и получила дальнейшее развитие. В результате в C++11 появилось новое ключевое слово constexpr. Оно может быть использовано в следующих ситуациях:


  1. Объявление переменной, массива и шаблона переменой.
  2. Объявление свободной функции, статической функции-члена класса, шаблона функции и лямбда-выражения.
  3. Объявление нестатической функции-члена класса.

Про использование constexpr можно почитать у Скотта Мейерса [Meyers2].



8.1. Переменные


Переменные и массивы, объявленные глобально, в области видимости пространства имен или локально, а также статические члены класса могут быть объявлены как constexpr. Такие переменные должны быть инициализированы при объявлении выражением, вычисляемым во время компиляции. Вот примеры:


constexpr int MagicNumber = 54;

class X
{
    static constexpr double PI = 3.14;
// ...
};

Статические члены класса можно дополнительно определить, но это не обязательно и нужно только, если требуется ссылка или указатель на такую переменную. При определении нельзя повторно использовать инициализацию.


constexpr double X::PI;

Нельзя объявить как constexpr нестатические члены класса, причины рассмотрены в разделе 7.2.


При объявлении можно использовать auto. Типы, для которых можно использовать constexpr относятся к литеральным типам. Литеральными являются встроенные типы, агрегатные типы, а также некоторые нетривиальные типы при выполнении дополнительных условий. Подробнее этот вопрос будет рассмотрен в разделе 8.4.


Переменные, объявленные как constexpr, можно использовать в любом контексте, где требуется выражение, вычисляемое во время компиляции. Целочисленные переменные могут быть использованы для определения размера массива, в качестве нетипового аргумента шаблона, для определения значения элемента перечисления, для определения выравнивания. Переменные, объявленные как constexpr, являются более современной формой констант времени компиляции, рассмотренных в разделе 1.3.



8.2. Шаблоны переменных


В C++14 появились шаблоны переменных, которые удобно использовать для объявления семейств constexpr переменных. Шаблоны переменных допускают специализацию (полную и частичную), что позволяет гибко настраивать значение переменой в зависимости от типа (см. [VJG]). Вот примеры:


template<typename T> // первичный шаблон
constexpr T MaxVal;

template<> // полная специализация для char
constexpr char MaxVal<char> = 127;

template<> // полная специализация для short
constexpr short MaxVal<short> = 32767;

template<> // полная специализация для int
constexpr int MaxVal<int> = 2147483647;

template<typename T> // первичный шаблон
constexpr bool IsPtr = false;

template<typename T> // частичная специализация для указателей
constexpr bool IsPtr<T*> = true;

std::cout
<< (int)MaxVal<char> <<''
<< MaxVal<short> <<''
<< MaxVal<int> <<''
<< std::boolalpha
<< IsPtr<int*> <<''
<< IsPtr<int> << '\n';

Вывод: 127 32767 2147483647 true false



8.3. Функции


Можно объявить как constexpr свободные функции, а также статические функции-члены класса. Для этих функций возвращаемое значение должны вычисляться во время компиляции, если аргументы вычисляются во время компиляции. Возвращаемое значение и параметры должны быть литерального типа. Как правило, constexpr функции не выбрасывают исключений и, соответственно, их следует объявлять как noexcept. Вызов constexpr функции можно использовать в любом контексте, где требуется выражение, вычисляемое во время компиляции. Понятно, что в этом случае определение функции должно быть видимым в точке вызова.


constexpr int Square(int n) noexcept { return n * n; }
int a[Square(2)];

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


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


auto square = [](auto n) constexpr { return n * n; };

int a[square(2)];

Шаблоны функций также могут быть объявлены как constexpr. Вот примеры из стандартной библиотеки C++17:


// namespace std

// header <numeric>

// наибольший общий делитель (greatest common divisor)
template<typename M, typename N>
constexpr common_type_t<M, N> gcd(M m, N n) noexcept;

// наименьшее общее кратное (least common multiple)
template<typename M, typename N>
constexpr common_type_t<M, N> lcm(M m, N n) noexcept;

// header <algorithm>

// зажим
template<class T>
constexpr const T& clamp(const T &v, const T &lo, const T &hi);

// минимум
template<class T>
constexpr const T& min(const T &a, const T &b);

В C++11 на реализацию constexpr функций накладывалось много ограничений, которые в значительной степени были сняты в C++14, C++17 и C++20. Идеологически constexpr функции близки к чистым функциям, то есть функциям, у которых возвращаемое значение зависит только от значений аргументов.


В C++20 к функциям можно применить спецификатор consteval, такие функции являются более сильной формой constexpr функций, их можно вызывать только с аргументами, вычисляемыми на стадии компиляции.



8.4. Нестатические функции-члены класса


Нестатические функции-члены класса также могут быть объявлены как constexpr. Вот простой пример:


class Int
{
    int m_Value;
public:
    constexpr Int(int val) noexcept : m_Value(val) {}
    constexpr int Value() noexcept { return m_Value; }
};

constexpr Int ii(4);
int aa[ii.Value()];

Конструктор, объявленный как constexpr, должен инициализировать все члены класса, а также отвечать требованием constexpr функций. Другие constexpr функции-члены должны отвечать требованием constexpr функций. Кроме того, такие функции-члены по умолчанию являются константными и, следовательно, должны отвечать соответствующим ограничениям, но квалификатор const можно не использовать.


Данный пример является примером литерального класса. В первом приближении можно сказать, что литеральный класс — это тривиальный класс, к которому добавлен constexpr конструктор и другие constexpr функции-члены. У литеральных классов есть еще другие «послабления» по сравнению с тривиальными, но это уже очень специальная тема.


В C++11 и C++14 было свойство типа, которое позволяет определить, является ли тип литеральным. Выражение std::is_literal_type<Т>::value имеет значение true, если T литеральный тип и false в противном случае. В C++17 это свойство типа объявлено устаревшим, а в C++20 удалено.



9. Итоги


Рекомендуется максимально широко использовать const и constexpr переменные, ссылки и указатели на константу, константные функции-члены, константные итераторы. Это повысит надежность кода, улучшит его читаемость, расширит контекст использования.



10. Список статей серии «C++, копаем вглубь»


  1. Перегрузка в C++. Часть I. Перегрузка функций и шаблонов.
  2. Перегрузка в C++. Часть II. Перегрузка операторов.
  3. Перегрузка в C++. Часть III. Перегрузка операторов new/delete.
  4. Массивы в C++.
  5. Ссылки и ссылочные типы в C++.
  6. Объявление и инициализация переменных в C++.


Список литературы


[VJG]
Вандевурд, Дэвид, Джосаттис, Николаи М., Грегор, Дуглас. Шаблоны C++. Справочник разработчика, 2-е изд.: Пер. с англ. — СПб.: ООО «Диалектика», 2020.


[Dewhurst]
Дьюхэрст, Стефан К. Скользкие места C++. Как избежать проблем при проектировании и компиляции ваших программ.: Пер. с англ. — М.: ДМК Пресс, 2012.


[Meyers1]
Мэйерс, Скотт. Эффективное использование C++. 55 верных способов улучшить структуру и код ваших программ.: Пер. с англ. — М.: ДМК Пресс, 2014.


[Meyers2]
Мейерс, Скотт. Эффективный и современный C++: 42 рекомендации по использованию C++11 и C ++14.: Пер. с англ. — М.: ООО «И.Д. Вильямс», 2016.

Теги:
Хабы:
Всего голосов 16: ↑16 и ↓0+16
Комментарии24

Публикации

Истории

Работа

QT разработчик
7 вакансий
Программист C++
123 вакансии

Ближайшие события

27 августа – 7 октября
Премия digital-кейсов «Проксима»
МоскваОнлайн
11 сентября
Митап по BigData от Честного ЗНАКа
Санкт-ПетербургОнлайн
14 сентября
Конференция Practical ML Conf
МоскваОнлайн
19 сентября
CDI Conf 2024
Москва
20 – 22 сентября
BCI Hack Moscow
Москва
24 сентября
Конференция Fin.Bot 2024
МоскваОнлайн
25 сентября
Конференция Yandex Scale 2024
МоскваОнлайн
28 – 29 сентября
Конференция E-CODE
МоскваОнлайн
28 сентября – 5 октября
О! Хакатон
Онлайн
30 сентября – 1 октября
Конференция фронтенд-разработчиков FrontendConf 2024
МоскваОнлайн