Фундаментом большинства языков программирования является их система типов. Тип переменной определяет ее размер и набор поддерживаемых операций. C++ является языком со статической типизацией, это означат, что тип переменной определяется на стадии компиляции и не может меняться в процессе жизни переменной. Но при написании кода достаточно часто возникает ситуация, когда в некотором контексте тип переменной оказывается неподходящим. Одним из способов решения этой проблемы является выполнение операции, называемой приведением типа (type cast). Так как тип переменной изменять нельзя, то при приведении типа создается новая переменная, тип которой является подходящим в данном контексте (целевой тип приведения), и эта переменная инициализируется с помощью исходной переменной. То есть приведение типа является фактически функцией с одним параметром, тип которого совпадает с типом исходной переменной, а возвращаемое значение имеет тип целевой переменной (conversion function). Вызовы такой функции могут присутствовать в коде явно, а могут быть вставлены в код компилятором в определенном контексте. В последнем случае приведение типа называется неявным (implicit type cast). Также следует обратить внимание на то, что переменные, которые участвуют в приведении типа, не обязательно объявлены в коде явно, они могут создаваться компилятором как временные и не иметь имени.
1. Неявные приведения типа
Один из критериев при классификации языков программирования проходит по оси строго типизированные — нестрого типизированные (также используют термины сильно/слабо типизированные). Разделение по этим признакам не очень четкое, обычно к строго типизированным относят языки, имеющие небольшое количество неявных приведений типа, а к нестрого типизированным языки, имеющие большое количество таких приведений. Понятно, что эти критерии довольно субъективны, поэтому в этой статье мы не будем навешивать ярлыки, а просто попробуем подробно рассказать о типах, к которым может быть применено неявное приведение типа, контекстах, в которых выполняется такое приведение, и о потенциальные проблемах, возникающих из-за этого.
Далее, для иллюстрации наличия или отсутствия неявного приведения типа мы будем в основном использовать инструкцию объявления и инициализации переменной типа T в следующей форме:
T x = expression;
Эта инструкция будет компилироваться без ошибки, если тип expression совпадает с T или если есть неявное приведение от типа выражения expression к типу T.
Еще один контекст, где часто используется неявное приведение типа — это вызов функции.
Пусть объявлена функция
int Foo(T x);
Вызов этой функции Foo(argument) будет компилироваться без ошибки, если тип argument совпадает с T или если есть неявное приведение от типа выражения argument к типу T.
При выполнении неявных приведений компилятор может выдавать предупреждения. Но надо иметь в виду, что этой зависит от компилятора и его настроек, поэтому, когда мы будем говорить о выдаче предупреждений, будем считать, что компилятор настроен на уровень предупреждений по умолчания.
2. Явные приведения типа
Если отсутствует необходимое неявное приведение типа, то можно попробовать выполнить явное. Для этого в C++ имеется набор специальных операторов. Но важно подчеркнуть, что такие приведения имеют свои ограничения, которые зависят от исходного и целевого типа и, в ряде случаев, от используемого оператора приведения. Таким образом, не любое приведение типа можно выполнить явно явно с помощью имеющихся операторов.
Иногда явное приведение типа выполняется даже тогда, когда есть неявное (для любого неявного приведение типа можно подобрать эквивалентное явное). Такой прием может улучшить читаемость кода и убрать предупреждения компилятора.
Во всех случаях ответственность за корректность явного приведения типа ложится на программиста, подсказок от компилятора уже не будет.
2.1. Операторы приведения типа
В C++ есть несколько операторов приведения типа, часть из них унаследована из C, другие появились в C++. Часто одно и то же приведение можно выполнить с помощью разных операторов.
Обозначим через T тип, к которому выполняется приведение (целевой тип) и через x выражение, для которого выполняется приведение типа.
2.1.1. Операторы C++
Здесь есть четыре оператора:
static_cast<T>(x)
dynamic_cast<T>(x)
const_cast<T>(x)
reinterpret_cast<T>(x)
Область их применения, рекомендации и ограничения по использованию будут подробнее описаны далее.
2.1.2. Операторы, унаследованные из C
Здесь есть два оператора:
T(x)
(T)x
Примеры их использования будут приведены далее.
2.1.3. Выбор оператора
Операторы приведения не зря были добавлены в C++. Они имеют специализацию, в частности, они позволяют выполнять приведения между указателями и ссылками на классы, связанные наследованием (в C нет наследования и ссылок). При неправильном использовании этих оп��раторов компилятор может выдать ошибку. На первый взгляд эти операторы выглядят более громоздко, но это можно рассматривать как дополнительное достоинство, при просмотре кода глаз легко «цепляется» за них, при необходимости все места их использования могут быть легко обнаружены с помощью поиска в любом редакторе кода.
Любое использование операторов, унаследованных из C , может быть заменено на эквивалентное использование одного из операторов C++. Конечно, использование операторов, унаследованных из C, выглядит, короче, но детали их работы могут оказаться несколько запутанными и привести к ошибке. Они подходят для простых случаев, когда результат не вызывает сомнений.
3. Пользовательские типы
При определении пользовательского типа с помощью ключевого слова class, struct или union имеется возможность задать некоторые приведения типа. Для этого есть два способа.
Определить конструктор с одним параметром. Он задает приведение от типа параметра к определяемому пользовательского типу.
Определить специальный оператор преобразования типа (user-defined conversion function). Он задает приведение от определяемого пользовательского типа к любому другому типу, указанному в этом операторе.
Вот пример:
class MyInt { int m_Value; public: MyInt(int v) : m_Value(v) {} // конструктор operator int() const { return m_Value; } // оператор // преобразования типа };
В этом примере в классе MyInt заданы неявное приведение от int к MyInt с помощью конструктора и неявное приведение от MyInt к int с помощью оператора преобразования типа. Вот пример использования этих приведений:
MyInt i0 = 70; // неявное приведение от int к MyInt int k0 = i0; // неявное приведение от MyInt к int
Для заданных таким образом приведений имеется возможность запретить неявные приведения, оставив только возможность явного приведения. Для этого соответствующие функции-члены надо объявить с ключевым словом explicit.
explicit MyInt(int v) : m_Value(v) {} explicit operator int() const { return m_Value; }
В этом случае приведенные выше примеры уже не будут компилироваться. Вместо них можно использовать инструкции, использующие явные приведения. Рекомендуется использовать оператор static_cast<>(), также можно использовать операторы, унаследованные из C.
MyInt i1 = static_cast<MyInt>(71); MyInt i2 = (MyInt)72; MyInt i3 = MyInt(73); int k1 = static_cast<int>(i1); int k2 = (int)i2; int k3 = int(i3);
Заданные таким образом приведения типа поддерживают некоторые классы стандартной библиотеки. Например, в классе std::string определен не-explicit конструктор, который имеет один параметр типа const char*, и, следовательно, он задает неявное приведение типа от const char* к std::string. Но вот неявного приведения типа от std::string к const char* нет, надо явно использовать функцию-член c_str().
Некоторые стандартные классы имеют explicit оператор преобразования типа к типу bool. Подробнее этот вариант будет рассмотрен в разделе 5.3.
4. Числовые типы
С++ имеет достаточно традиционный набор числовых типов, которые подразделяются на целочисленные типы (integer types) и типы с плавающей точкой (floating-point types). Целочисленные типы дополнительно подразделяются на знаковые (signed) и беззнаковые (unsigned). Будем для краткости числовые типы с плавающей точкой называть вещественными типами. На наиболее популярных платформах целочисленные типы могут быть 8-, 16-, 32- и 64-битными, вещественные типы 32- и 64-битными.
Некоторые детали работы приведений между целочисленными типами зависят от того, каким образом представляются знаковые отрицательные числа. На большинстве платформ используется так называемый дополнительный код (two’s complement).
Приведения между числовыми типами подразделяются на сужающие, когда может возникнуть потеря точности, то есть когда младшие биты исходного числа могут быть безвозвратно утеряны, и расширяющие, когда гарантируется отсутствие таких потерь. Можно еще выделить случай приведений, не меняющих битовый образ исходного числа. Также может оказаться так, что значение исходного числа выходит за диапазон значений целевого типа, в этом случае результат приведения не определен.
В C++ имеются неявные приведения между любыми числовыми типами. В случае сужающих приведений и возможного неопределенного результата выдается предупреждение, но операция не запрещается. Также любое приведение типа может быть выполнено явно, см. раздел 4.4.
4.1. Варианты приведения числовых типов
Рассмотрим варианты приведения между числовыми типами и потенциальные проблемы, возникающие при этом.
4.1.1. Приведение целочисленного типа к целочисленному типу
Размер целевого типа больше размера исходного. В этом случае при использовании дополнительного кода знаковое отрицательное исходное число будет дополнено единичными старшими битами, а беззнаковое или знаковое неотрицательное нулевыми. Потери точности не происходит, предупреждение не выдается.
Размер целевого типа совпадает с размером исходного. В этом случае приведение имеет смысл только при изменении знаковости числа. При использовании дополнительного кода для отрицательных знаковых чисел приведение не меняет битовый образ исходного числа, просто после приведения эти биты интерпретироваться в ряде случаев по-другому. Подробнее см. раздел 4.3. Предупреждение не выдается.
Размер целевого типа меньше размера исходного. Если значение исходного числа попадает в диапазон целевого типа, то исходное число не меняется, иначе результат не определен. Выдается предупреждение.
4.1.2. Приведение вещественного типа к вещественному типу
Размер целевого типа больше размера исходного. В этом случае потери точности не происходит, предупреждение не выдается.
Размер целевого типа меньше размера исходного. В этом случае размер мантиссы исходного типа будет больше размера мантиссы целевого типа и лишние биты будут отброшены, соответственно, возможна потеря точности. Если порядок исходного числа выходит за диапазон порядка целевого типа, то результат не определен. Выдается предупреждение.
4.1.3. Приведение целочисленного типа к вещественному типу
Размер вещественного типа больше размера целочисленного. В этом случае размер мантиссы вещественного типа будет больше, чем размер целочисленного типа, потери точности не будет. Предупреждение не выдается.
Размер вещественного типа равен или меньше размера целочисленного. В этом случае размер мантиссы вещественного числа будет меньше, чем размер целочисленного типа и, соответственно, возможна потеря младших битов исходного числа. Выдается предупреждение.
4.1.4. Приведение вещественного типа к целочисленному типу
В этом случае сначала отбрасывается дробная часть исходного числа и, соответственно, возможна потеря точности, которая будет зависеть от величины отброшенной дробной части и величины оставшейся целой части. После этого целая часть приводится к целочисленному типу. Если величина целой части выходит за диапазон значений целочисленного типа, то результат будет не определен. Выдается предупреждение.
4.2. Неявные приведения в операторах
Использование числовых типов не имеет смысла без поддержки традиционных операций над числами — арифметических, побитовых, сравнения. Для этого в C++ имеется набор из 23 операторов, которые подразделяются на унарные и бинарные. Эти операторы требуют операндов определенного типа, кроме того, многие бинарные операторы требуют операндов одинакового типа. Если эти условия не выполняются, то компилятор выполняет необходимые приведения операндов.
4.2.1. Целочисленное продвижение
Если операнд имеет тип short, unsigned short или один из вариантов char, то происходит так называемое целочисленное продвижение (integral promotion), операнд приводится к int или unsigned int. Выбор целевого типа опирается на правило: если максимальное значение операнда меньше либо равно максимальному значению int, то выбирается int, иначе unsigned int. Таким образом, на платформах с 32-битным int 16- и 8-битные операнды любой знаковости будут продвинуты до int.
При целочисленном продвижении потери точности не происходит, но беззнаковый операнд может быть продвинут до знакового.
4.2.2. Бинарные операторы
Рассмотрим правила приведения для бинарных операторов, требующих операндов одинакового типа.
Оба операнда вещественного типа. Операнд меньшего размера приводится к типу операнда большего размера. Потери точности не происходит.
Один операнд вещественного типа, второй целочисленного. Сначала, если необходимо, для целочисленного операнда выполняется целочисленное продвижение и после этого выполняется приведение к типу вещественного операнда. Возможны потери точности, если размер целочисленного операнда больше либо равен размеру вещественного операнда.
Оба операнда целочисленного типа. В этом случае правила приведения сложнее, но гарантируется, что приведение будет без потери точности. Рассмотрим эти правила более подробно.
Сначала, если необходимо, выполняется целочисленное продвижение для каждого операнда.
Далее, если оба операнда имеют одинаковую знаковость, то операнд меньшего размера приводится к типу большего.
В случае разной знаковости правило такое: если максимальное значение беззнакового операнда меньше или равно максимальному значению знакового, то беззнаковый операнд приводится к типу знакового, иначе знаковый операнд приводитс�� к типу беззнакового. В частности, если размеры операндов равны, то знаковый операнд приводится к типу беззнакового операнда.
Приведем примеры для платформ с 32-битным int.
uint16_t x = 10, y = 30; auto dif = x - y;
В этом примере при целочисленном продвижении x и y будут продвинуты до int, тип dif будет int, значение dif будет -20.
int64_t x = 10; uint32_t y = 30; auto dif = x - y;
В этом примере y будет приведена к int64_t, тип dif будет int64_t, значение dif будет -20.
int x = 10; unsigned int y = 30; auto dif = x – y;
В этом примере x будет приведена к unsigned int, тип dif будет unsigned int, значение dif будет 4294967276. Откуда взялось такое значение будет объяснено в разделе 4.3.2.
4.2.3. Другие операторы
Целочисленное продвижение может выполняться также в унарных операторах и бинарных операторах, не требующих операндов одинакового типа. Вот пример:
unsigned char x = 1; auto inv = ~x; auto sft = x << 8; auto neg = -x; std::cout << std::hex << std::showbase << inv << ' ' << sft << ' ' << std::dec << neg << '\n';
Вывод:
0xfffffffe 0x100 -1
Во всех примерах x будет продвинут до int, тип inv, sft, neg будет int. По их значению хорошо видно, что сначала x будет продвинута, затем будет выполнена операция.
Особый случай представляют собой операторы инкремента и декремента. В них продвижения не происходит, тип результата всегда совпадает с типом операнда.
Есть еще тернарный (условный) оператор. Этот оператор не выполняет операций над операндами, он реализует выбор из двух значений. У него три операнда, первый операнд задает условие, если оно выполняется, то выбирается второй операнд, иначе третий. (На самом деле второй и третий операнд не обязательно имеют числовой тип, но мы этот вариант обсуждать не будем.) Тип результата должен быть определен на стадии компиляции. Если второй и третий операнд имеют одинаковый тип, то результата будет иметь этот общий тип. Если типы второго и третьего операндов не совпадают, то эти операнды будут приведены к общему типу по правилам бинарных операторов с одинаковым типом операндов, описанных в предыдущем разделе, и результата будет иметь этот общий тип. Вот пример:
unsigned char x = 1, y = 2; short s = 10; bool c = true; auto r1 = c ? x : y; auto r2 = c ? x : s;
В первом примере использования тернарного оператора приведения операндов не будет и, соответственно, r1 будет иметь тип unsigned char. Во втором будет выполнено продвижение операндов до int и, соответственно, r2 будет иметь тип int.
4.3. Совместное использование знаковых и беззнаковых целочисленных типов
Рассмотрим некоторые проблемы, возникающие при совместном использовании знаковых и беззнаковых целочисленных типов. Будем считать, они имеют одинаковый размер.
4.3.1. Приведения типа
При приведении знакового числа к беззнаковому такого же размера битовые образы исходного и целевого значения совпадают. Для представления целых чисел используется код, который дает одинаковое битовое представление для неотрицательных знаковых чисел и беззнаковых с таким же значением, соответственно, никаких изменений в этом случае не требуется (в этом случае старший бит чисел будет нулевым) и значения не меняются. Если для представления знаковых отрицательных чисел используется дополнительный код, то при приведении знакового отрицательного числа к беззнаковому биты дополнительного кода просто будут интерпретироваться как беззнаковое число (в этом случае старший бит чисел будет единичным). Изменений битового образа не будет, но значение изменится.
При обратном приведении изменений битового образа также не происходит, если старший бит беззнакового числа равен 0, то его биты будут интерпретироваться как значение знакового неотрицательного числа, а если старший бит равен 1, то его биты будут интерпретироваться как дополнительный код отрицательного знакового.
При таких приведениях отрицательные знаковые числа будут приведены к беззнаковым, значение которых большие максимального знакового. В частности, значению -1 будет приведено к максимальному беззнаковому, а минимальное знаковое к максимальному знаковому плюс 1.
4.3.2. Арифметические операции
При выполнении арифметических операций над целыми числами (операторы +, - и т. д.) процессор не различает знаковые и беззнаковые операнды. Процессор просто берет наборы битов операндов и генерирует набор битов результата. Благодаря использованию дополнительного кода и обработки переполнения получается правильный результат.
Но вот компилятор должен различать эти два типа, так как он должен определить тип результата. Если операнды имеют одинаковую знаковость, то такую же знаковость будет иметь и результат, иначе знаковый аргумент приведется к беззнаковому и результат операции будет беззнаковым (правило описано в разделе 4.2.2). Приведем пример:
unsigned int x = 10; int y = -30; auto sum = x + y; std::cout << x << ' ' << y << ' ' << sum << ' ' << int(sum) << '\n';
Вывод:
10 -30 4294967276 -20
При выполнении сложения компилятор приведет операнд y к unsigned int и результат будет иметь этот же тип. При форматировании мы получаем несколько неожиданный результат в виде большого значения. Приведение результата к int все ставит на свои места, мы получаем результат в соответствии с правилами математики.
4.3.3. Перегрузка функций
Функция может иметь перегруженные варианты для знаковых и беззнаковых целочисленных параметров. Например, оператор вывода в поток << перегружен для всех целочисленных типов и может по-разному форматировать знаковые и беззнаковые числа, даже если побитово они совпадают. Это продемонстрировано в приведенном выше примере.
4.3.4. Операции сравнения
Рассмотрим теперь сравнение целых чисел (операторы >, < и т. д.). Особенности реализации этих операторов могут привести к тому, что результат не будет соответствовать правилам математики. Если операнды имеют одинаковую знаковость, то результат всегда будет соответствовать правилам математики, но если один операнд знаковый, а другой беззнаковый, то это уже не всегда так. Компилятор приводит знаковый операнд к беззнаковому и, если его первоначальное значение было отрицательным, то в ��езультате приведения он будет иметь большое положительное значение и результат сравнения может оказаться неверным с точки зрения правил математики. Вот пример:
unsigned int x = 10; int y = -20; bool grt = x > y;
По правилам математики grt должна иметь значение true, но в данном случае компилятор приведет операнд y к unsigned int со значением 4294967276 и результат сравнения окажется false, то есть неверным с точки зрения правил математики. По этой причине при сравнении знаковых чисел и беззнаковых компилятор выдает предупреждение.
Для решения этой проблемы в стандартной библиотеке С++20 добавлен набор функций сравнения, которые работают корректно с любыми вариантами аргументов. Эти функции находятся в заголовочном файле <utility>. Вот пример использования одной из таких функций с данными из предыдущего примера:
bool grt20 = std::cmp_greater(x, y);
Теперь значение grt20 будет true, в соответствии с правилами математики.
4.4. Явные приведения
Для явного приведения между числовыми типами рекомендуется использовать оператор static_cast<>(), также можно использовать операторы, унаследованные из C. Но в этом случае за корректность такого приведения полностью отвечает программист, компилятор уже не выдает предупреждения в случае проблем, описанных в разделе 4.1. Вот пример:
double x = 3.14; int k1 = static_cast<int>(x); int k2 = (int)x; int k3 = int(x);
5. Логический тип
5.1. Приведение к bool
По историческим причинам (совместимость с C) в C++ много типов, имеющих неявное приведение к логическому типу bool. К этим типам относятся числовые типы, типы указателей, перечисления без области видимости, пользовательские типы, определившие не-explicit приведение к bool, а также любые типы, имеющие неявное приведение к числовому типу или типу указателей. Эти приведения выполняются без предупреждений. При приведении числовых типов и перечислений нулевое значение интерпретируются как false, остальные значения как true. При приведении указателей значение nullptr интерпретируется как false, остальные значения как true.
Наличие таких неявных приведений может привести к фактическим ошибкам, которые компилятор не будет относить к ошибкам, в лучшем случае выдаст предупреждение. Наиболее известной из них является следующая: для числовых переменных x и y по ошибке используется в качестве логического выражения x=y, вместо правильного x==y.
5.2. Приведения от bool
Имеется неявное приведение от bool к числовым типам. Значение false интерпретируется как нулевое значение, значение true как единичное. В бинарных и унарных числовых операторах (кроме операторов инкремента и декремента) можно использовать операнды типа bool, они будут продвинуты до int, со значением 1 (true) или 0 (false). Для некоторых операторов компилятор выдает предупреждение.
5.3. explicit приведение к bool
В пользовательском типе, объявляемом с помощью ключевого слова class, struct или union можно определить специальный оператор преобразования типа, который задает приведение от определяемого пользовательского типа к типу bool. Этот оператор может быть объявлен как explicit, в этом случае запрещаются неявные приведения типа и можно выполнять только явные (см. раздел 3). Рассмотрим пример:
class MyInt { int m_Value; public: MyInt(int v) : m_Value(v) {} explicit operator bool() const { return m_Value != 0; } }; void Foo(bool b);
Для явных приведений рекомендуется использовать оператор static_cast<>(), также можно использовать операторы, унаследованные из C. Вот примеры:
MyInt i = 42; bool b1 = static_cast<bool>(i); // OK, явное приведения от MyInt к bool bool b2 = (bool)i; // OK, явное приведение от MyInt к bool bool b3 = bool(i); // OK, явное приведение от MyInt к bool Foo((bool)i); // OK, явное приведения от MyInt к bool
Вот примеры, когда возникает ошибка из-за отсутствия неявного приведения:
bool b5 = i; // ошибка, нет неявного приведения от MyInt к bool Foo(i); // ошибка, нет неявного приведения от MyInt к bool
А вот теперь немного неожиданная особенность: экземпляры такого класса можно использовать в качестве условия в инструкциях и операторах, использующих условие, без явного приведения к bool. (К ним относятся инструкции if, while, do-while, for и операторы &&, ||, !, тернарный оператор.) Вот пример для класса MyInt:
MyInt i0 = 70, i1 = 42, i2 = 0; if (i0){ /* ... */ } // OK, инструкция if bool bb = i1 && i2; // OK, оператор &&
Такой оператор определяют некоторые стандартные классы, например, потоковые классы, умные указатели, std::optional<>. Вот пример:
class R { public: void Foo(); // ... }; // ... std::unique_ptr<R> pr; // ... if (pr){ pr->Foo(); }
Таким образом, мы имеем возможность использовать умные указатели почти так же, как и обычные, но при этом избегать некоторых ошибок, связанных с неявном приведением к bool.
6. Перечисления
В С++ можно использовать два типа перечислений.
Старые, без области видимости (unscoped). Эти перечисления объявляются с помощью ключевого слова
enum, они унаследованы из С.Новые, с областью видимости (scoped). Эти перечисления объявляются с помощью ключевого слова
enum class, они появились в С++11.
Перечисления реализуются компилятором с помощью одного из целочисленных типов, который называется базовым типом (underlying type). Из неявных приведений между типами перечислений и числовыми типами имеется только одно — неявное приведение от типа старого перечисления к числовому или логическому типу. Вот пример:
enum G{ Zero, One, Two }; G g1 = One; int k1 = g1; // OK, k1 получит значение 1 G g2 = 2; // ошибка, нет неявного приведения от int к G
Новые перечисления не поддерживают и такое приведение, в этом случае все инструкции будут ошибочными.
Для перечислений разрешены явные приведения типа от целочисленного типа к типу перечисления. Для этого рекомендуется использовать оператор static_cast<>(), также можно использовать операторы, унаследованные из C. Вот пример:
enum class S {Zero, One, Two }; S s1 = static_cast<S>(71); S s2 = (S)72; S s3 = S(73);
Основная проблема такого приведения заключается в том, что нет никакой гарантии в том, что значение такого экземпляра перечисления будет совпадать с одним из объявленных значений перечисления.
Для инициализации экземпляра перечисления можно использовать пустой инициализатор, в этом случае экземпляр получит нулевое значение. Объявление
S s4{};
будет эквивалентно
S s4 = S(0);
Такая инициализация может произойти неявно, в конструкторе по умолчанию, сгенерированном компилятором, поэтому при проектировании перечисления рекомендуется обязательно включать элемент с нулевым значением.
Приведение от типа нового перечисления к базовому типу также можно выполнить только явно.
int k1 = static_cast<int>(s1); int k2 = (int)s2; int k3 = int(s3);
7. Указатели и ссылки
7.1. Нулевые и нетипизированные указатели
Имеется неявное приведение от std::nullptr_t (тип с единственным значением nullptr) к типу указатель на известный тип (типизированный указатель), а также к void* или const void* (нетипизированный указатель).
Имеется неявное приведение от типа указатель на неконстанту известного типа к void* или const void* и неявное приведение от типа указатель на константу известного типа к const void*. Вот пример:
int* p = nullptr; void* pv = p; const void* cpv = p;
Разрешено явное приведение от void* к типу указатель на неконстанту известного типа и от void* или const void* к типу указатель на константу известного типа. Для этого рекомендуется использовать static_cast<>(), также можно использовать операторы, унаследованные из C. Вот пример:
int* p1 = static_cast<int*>(pv); int* p2 = (int*)(pv); const int* cp1 = static_cast<const int*>(cpv); const int* cp2 = (const int*)pv;
Подобные приведения могут встретиться в следующей цепочке приведений: типизированный указатель->нетипизированный указатель->типизированный указатель. В этих приведениях битовый образ указателей не меняется. Эта цепочка будет работать корректно, если начальный тип и конечный будут совпадать, но ответственность за это лежит на программисте.
7.2. Приведение типа и наследование
Рассмотрим приведения между типами указатель или ссылка на классы, связанные наследованием.
7.2.1. Особенности приведений
Приведение от производного класса к базовому называется повышающим (upcasting), а от базового класса к производному понижающим (downcasting). В русских текстах еще встречаются термины восходящее/нисходящее приведение. Эти термины возникли из-за того, что на диаграммах классов, отображающих наследование, базовые классы принято изображать сверху.
В C++ базовая часть может располагаться с ненулевым смещением. Причиной может быть множественное наследование или указатель на таблицу виртуальных функций. В этом случае приведенный указатель может отличаться от исходного на величину смещения. (Это называется дельта-арифметикой.)
Из-за множественного наследия класс может иметь насколько базовых классов одного типа. Это может привести к тому, что приведение становится неоднозначным, в этом случае возникает ошибка компиляции.
Повышающее приведение выполняется неявно, а понижающее только явно. Использование явных приведений в первом случае не запрещено, но это уже вопрос вкуса. На неявном повышающем приведении основан полиморфизм, реализованный в C++. Есть еще одна деталь: наследование должно быть объявлено общедоступным (public), иначе любые приведения между базовым классом и производным будут недоступны.
Для понижающих и повышающих приведений предлагаются два оператора: static_cast<>() и dynamic_cast<>(). Иногда могут корректно работать операторы, унаследованные из C, но это как раз тот случай, когда их лучше не использовать, так как в C нет наследования и результат их применения является труднопредсказуемым.
7.2.2. Оператор static_cast<>()
Этот оператор работает достаточно формально: он просто вычисляет необходимое смещение, основываясь на объявлениях базового и производного класса. При понижающем приведении этот оператор не проверяет, что указатель на базовый класс указывает на базовую часть какого-то объекта производного типа и поэтому результирующий указатель может указывать неизвестно на что. Если использовать этот оператор для приведения между типами указатель или ссылка на классы, не связанные наследованием, то возникает ошибка компиляции (в этом случае надо использовать reinterpret_cast<>(), см. раздел 7.4).
7.2.3. Оператор dynamic_cast<>()
Этот оператор предназначен для работы с полиморфными классами. (По-определению полиморфные классы — это классы, определяющие или наследующие виртуальные функции.) При понижающем приведении этот оператор с помощью Runtime Type Information (RTTI) проверяет корректность указателя или ссылки на производный класс. (Компиляторы имеют возможность отключить поддержку RTTI.) Если проверка не прошла, то возвращается нулевой указатель или выбрасывается исключение в случае приведения ссылок. Если использовать этот оператор с неполиморфными типами, то возникает ошибка компиляции.
7.2.4. Сравнение указателей
Указатели можно сравнивать с помощью бинарных операторов ==, < и т.д. Эти операторы требуют, чтобы операнды имели одинаковый тип, либо тип одного операнда имел неявное приведение к типу другого операнда. В первом случае выполняется побитовое сравнение, во втором сначала выполняется приведение, затем происходит побитовое сравнение. Таким образом, можно сравнивать указатели на классы, связанные наследованием, в этом случае для указателя на производный класс будет выполнено повышающее приведение. Но при повышающем приведении может измениться битовый образ указателя (дельта-арифметика) и мы можем получить ситуацию, когда два изначально побитово различных указателя при сравнении с помощь оператора == дадут true. Это будет означать, что оба указателя связаны с одним и тем же экземпляром класса.
7.2.5. Примеры использования операторов
Рассмотрим примеры использования описанных операторов приведения.
class B { int m_X; public: virtual ~B() = default; }; class D : public B { int m_Y; }; intptr_t I(const void* p){ return reinterpret_cast<intptr_t>(p); }
Мы объявили полиморфный класс B, производный от него класс D и вспомогательную функцию I, которая приводит адрес к целочисленному значению.
D d; D* pd = &d; B* pb1 = pd; D* pd1 = dynamic_cast<D*>(pb1); D* pd2 = static_cast<D*>(pb1); std::cout << std::hex << I(pd1) << ' ' << I(pd2) << ' ' << I(pd) << '\n '
Вывод:
3c2daff918 3c2daff918 3c2daff918
Мы объявили экземпляр D и с помощью неявного повышающего приведения получили указатель на его базовую часть pb1. После этого выполнили явные понижающие приведения pb1 к указателю на D. Результаты для dynamic_cast<>() и static_cast<>() ожидаемо совпадают, они оба будут указывать на объявленный экземпляр D.
B b; B* pb2 = &b; D* pd3 = dynamic_cast<D*>(pb2); D* pd4 = static_cast<D*>(pb2); std::cout << I(pd3) << ' ' << I(pd4) << '\n';
Вывод:
0 3c2daff9c8
Мы объявили отдельно стоящий экземпляр B и получили указатель на него pb2. После этого выполнили явные понижающие приведения pb2 к указателю на D. Оператор dynamic_cast<>() обмануть не удалось, результат, как и положено, нулевой. Оператор static_cast<>() дал неверный результат, указатель ненулевой и он не указывает на какой-то экземпляр D.
7.3. Снятие константности
Имеется неявное приведение от типа указатель или ссылка на неконстанту известного типа к типу указатель или ссылка на константу того же типа. А вот обратное приведение можно выполнить только явно, для этого предлагается оператор const_cast<>(). Также корректно будут работать операторы, унаследованные из C, но вот использовать static_cast<>(), dynamic_cast<>() и reinterpret_cast<>() нельзя. Вот пример:
class X { /* ... */ }; const X* GetPtrToConst(); const X* cpx = GetPtrToConst(); X* px = const_cast<X*>(cpx);
Такое приведение является потенциально опасным, но в некоторых случаях оно оправданно и безопасно. Рассмотрим один из таких примеров. В контейнерах 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& item) const { return m_Id < item.m_Id; } 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()) // есть элемент с заданным id { // Item& itm = *itr; // ошибка const Item& citm = *itr; Item& itm = const_cast<Item&>(citm); itm.SetData(data); ret = true; } return ret; }
Класс Item использует для сравнения экземпляров член m_Id, то есть этот член является фактическим ключом. Оператор * для итератора возвращает ссылку на константу, несмотря на то, что итератор неконстантный, это и есть обсуждаемая особенность таких контейнеров. Мы снимаем константность с этой ссылки и после этого можем использовать неконстантную SetData() для изменения m_Data. Этот член не участвуют в сравнении экземпляров класса Item, поэтому такая операция безопасна. Обратим внимание на следующую особенность данного примера: класс Item спроектирован так, что ключ m_Id является константным и его нельзя изменить даже через ссылку на неконстанту. Такой прием можно рекомендовать при работе с этими контейнерами.
7.4. Использование грубой силы
Оператор reinterpret_cast<>() предназначен для приведения между указателями на типы, не связанные наследованием, или между указателями и целочисленными типами. Этот оператор заставляет компилятор рассматривать битовый образ переменной одного типа как битовый образ переменной другого типа, не изменяя сам битовый образ. Целочисленные типы std::intptr_t и std::uintptr_t позволяют хранить битовый образ указателя без потерь.
Оператор можно использовать для приведения ссылочных типов, но он не может снимать константность. Иногда оператор может быть заменен оператором, унаследованным из C, но надо четко понимать, когда это возможно (см. раздел 7.5).
Оператор является потенциально опасным и непереносимым, его использование должно быть тщательно продумано.
7.5. Указатели на функцию
Имеется неявное приведение от типа указатель на функцию к void*. Разрешено явное приведение от void* к типу указатель на функцию, в этом случае рекомендуется использовать оператор static_cast<>(), также можно использовать операторы, унаследованные из C. Вот пример:
int (*p1)() = nullptr; void* pv = p1; int (*p2)() = static_cast<int (*)()>(pv);
Разрешено явное приведение между указателями на функции с разными сигнатурами. Для этого можно использовать reinterpret_cast<>() или операторы, унаследованные из C, а вот использовать static_cast<>() нельзя. Вот пример:
void (*p3)(int) = reinterpret_cast<void (*)(int)>(p1); void (*p4)(int) = (void (*)(int))p1;
В качестве целевого типа не обязательно должен быть указатель на функцию.
int* p5 = reinterpret_cast<int*>(p1); int* p6 = (int*)p1;
Конечно, такие приведения небезопасны, но есть сценарии, когда без них не обойтись. Один из них возникает при динамической загрузке разделяемой библиотеки и последующем получением адреса экспортируемого символа. (Экспортируемыми символами могут быть функции или переменные.) В Windows для этого можно использовать LoadLibrary() и GetProcAdress(). Последняя функция предназначена для получения адреса экспортируемого символа по имени. Она возвращает указатель на функцию, тип этого указателя int (__stdcall *)() и чаще всего его приходится приводить к указателю на функцию с другой сигнатурой или к указателю на переменную, в соответствии с фактическим типом экспортируемого символа. В Unix-подобных ОС для загрузки библиотеки используется функция dlopen(), а для получения адреса экспортируемого символа по имени dlsym(). Последняя функция возвращает указатель void*, который надо приводить к указателю на функцию с нужной сигнатурой или к указателю на переменную. Ответственность за правильный выбор целевого типа таких приведений полностью лежит на программисте.
8. Разное
8.1. Неполные типы
В ряде случаев компилятору для компиляции правильного кода достаточно знать, что то или иное имя является именем какого-то пользовательского типа (класса, структуры, объединения, перечисления), а полное объявление типа не нужно. В этом случае можно использовать неполное объявление (incomplete declaration), называемое еще упреждающим или предваряющим (forward declaration). Типы с неполным объявлением называются неполными.
Для переменных типа указатель или ссылка на неполный тип возможны некоторые приведения типа.
class X; // неполное объявление class Y; // неполное объявление void* p; Y* y; const X* cx; // ... X* x1 = static_cast<X*>(p); X* x2 = reinterpret_cast<X*>(y); X* x3 = const_cast<X*>(cx);
Приведения неполных типов имеют ограничения. Неполные типы не могут быть связаны наследованием, поэтому static_cast<>() можно применять только в варианте приведения void* или const void* к указателю на неполный тип. Неполные типы не могут быть полиморфными, поэтому нельзя использовать dynamic_cast<>().
8.2. Массивы
Массивы входят в систему типов C++, но имеют ряд ограничений. Например, функция не может использовать массив в качестве возвращаемого значения. Использование массивов подчинено правилу, которое называется низведением массивов (decay, array-to-pointer decay). В переводах на русский еще можно встретить термин сведение и разложение. Согласно этому правилу идентификатор массива почти в любом контексте неявно приводится к указателю на первый элемент и информация о размере теряется. Исключениями являются оператор sizeof, оператор & (взятие адреса), инициализация ссылки на массив и использование decltype. Низведение очень похоже на неявное приведение типа от типа массива к типу указателя на элемент.
Кроме потери информации о размере массива низведение в сочетании с неявным приведением типа может привести к другим проблемам. Пусть у нас есть два класса, связанные наследованием и два массива из элементов этих типов.
class B { /* ... */}; class D : public B{ /* ... */ }; void Foo(B* pb, std::size_t s); B b[4]; D d[4];
Вызов Foo(d, std::size(d)) будет откомпилирован без ошибок и предупреждений (сначала будет выполнено низведение, затем неявное приведение к указателю на базовый класс), но это потенциально опасно, так как если sizeof(D) > sizeof(B), то смещение элементов массива d будет не совпадать со смещением элементов массива b.
8.3. Контрвариантность
Если у нас есть классы, связанные наследованием, то имеется неявное приведение указателя или ссылки на производный класс к указателю или ссылки на базовый класс. Если у нас есть какие либо другие типы, построенные на основе классов, связанных наследованием, то между такими типами также возможны неявные приведения типа. Если есть неявное приведение от типа, основанного на производном классе, к типу, основанному на базовом классе, то говорят, что эти типы поддерживают ковариантность. Если есть обратное неявное приведение, то есть неявное приведение от типа, основанного на базовом классе, к типу, основанному на производном классе, то говорят, что эти типы поддерживает контрвариантность.
В C++ контрвариантность поддерживают указатели на члены и функции-члены класса. Указатели на члены или функции-члены базового класса имеют неявное приведение к указателям на члены или функции-члены производного класса. Естественно, что для этих указателей типы члена, в случае указателей на члены, и сигнатуры функций, в случае указателей на функции-члены, должны совпадать у базового и производного класса. Вот пример для указателей на функцию-член:
class B { public: void Foo(int x); // ... }; class D : public B { /* ... */ }; void (B::* pmb)(int) = &B::Foo; void (D::* pmd)(int) = pmb;
В последней инструкции мы видим неявное приведения указателя на функцию-член класса B к указателю на функцию-член класса D с такой же сигнатурой.
8.4. Инициализация
Рассмотрим другой синтаксис инструкции объявления и инициализации переменной типа T:
T x{expression};
Это эта инструкция будет компилироваться без ошибки, если тип expression совпадает с T или есть неявное приведения от типа выражения expression к типу T. Но это еще не все, инструкция также будет компилироваться без ошибки, если есть определенное пользователем explicit приведение от типа выражения expression к типу T (см. раздел 3). Вот пример:
class MyInt { int m_Value; public: explicit MyInt(int v) : m_Value(v) {} explicit operator int() const { return m_Value; } }; MyInt i0{ 70 }; int k0{ i0 };
Вот еще один вариант применимости этого синтаксиса: тип T имеет конструктор с одним параметром и существует неявное приведение от типа выражения expression к типу параметра этого конструктора. Пример будет в следующем разделе.
Рассмотрим несколько примеров, когда этот синтаксис инициализации дает ошибку.
enum class S { Zero, One, Two }; S s{2}; // ошибка, требуется явное приведение от int к S int k{ S::One }; // ошибка, требуется явное приведение от S к int void* pv { nullptr }; // ОК, есть неявное приведение int* p{ pv }; // ошибка, требуется явное приведение от void* к int*
8.5. Транзитивность
Пусть у нас есть три типа T1, T2, T3 и неявные приведения типа от T1 к T2 и от T2 к T3. Возникает вопрос: будет ли неявное приведение от T1 к T3? Если такое приведение есть, то это можно назвать транзитивностью неявных приведений. Будут ли приведения транзитивны, зависит от того являются ли эти приведения встроенными (предопределенными) или определены пользователем (см. раздел 3). Если эти оба приведения типа определены пользователем, то транзитивности нет, но если хотя бы одно из приведений в этой цепочке является встроенным, то транзитивность будет выполняться.
Вот пример.
class StrHolder { std::string m_Str; public: StrHolder(const char* s) : m_Str(s) {} operator const char* () const { return m_Str.c_str(); } }; StrHolder sh = "meow"; std::string s1 = sh; // ошибка, нет неявного приведения типа // от StrHolder к std::string
Вот правильные варианты:
const char* t = sh; std::string s2 = t; std::string s3 = (const char*)sh; std::string s4{ sh };
В этом примере есть два неявных приведения, определенных пользователем: приведение типа от StrHolder к const char* (оператор преобразования типа в StrHolder) и приведение типа от const char* к std::string, (один из конструкторов std::string), но вот транзитивного неявного приведения типа от StrHolder к std::string нет, поэтому возникла ошибка. Последняя инструкция компилируется без ошибки, потому что есть неявное приведение от StrHolder к const char*, то есть от типа аргумента к типу параметра конструктора std::string, см. предыдущий раздел.
8.6. Ссылки
Приведение к ссылочному типу имеет некоторые особенности.
Рассмотрим инструкцию объявления и инициализации ссылки на константу типа T:
const T& r = expression;
Это инструкция будет компилироваться без ошибки, если тип expression является const T&, T&, const T, T или есть неявное приведения от типа выражения expression к типу const T или T. Таким образом, имеется неявное приведение к ссылке на константу типа T от ссылки на неконстанту типа T и от типа const T или T.
Иная ситуация, в случае инициализации ссылки на неконстанту.
T& r = expression;
В этом случае инструкция будет компилироваться без ошибки, если тип expression является T& или T, причем в последнем случае дополнительно требуется, что бы expression было lvalue, то есть именованной переменной. Таким образом, имеется неявное приведение к ссылке на неконстанту типа T от lvalue типа T.
Приведем пример.
double x = 3.14; const int& r1 = x; // OK int& r2 = x; // ошибка int y = x; int& r3 = y // OK
Во второй инструкции этого примера произойдет неявное приведение от double к int и r1 будет ссылаться на результате этого приведения — временную безымянную переменную типа intсо значением 3. Следующая инструкция ошибочна, так как в ней требуется такое же приведение, но ссылка на неконстанту не может ссылаться на временную переменную, так как это rvalue. В последней инструкции все в порядке, r3 будет ссылаться на переменную y, которая является lvalue и имеет значение 3.
Использование инструкции инициализации без знака = (см. раздел 8.4) ничего не меняет. И при вызове функции с параметрами ссылочного типа к аргументу применяются те же требования.
9. Список статей серии «C++, копаем вглубь»
10. Итоги
Слишком либеральные правила неявных приведений между числовыми типами могут создать немало потенциальных проблем. В этом признавался даже сам Страуструп. На этапе компиляции могут быть выявлены только потенциальные проблемы, а произойдут ли они в процессе выполнения, зависит от конкретных данных, используемых в процессе выполнения программы. Программа может пройти тестирование, и даже эксплуатироваться некоторое время без сбоев, но в самый неподходящий момент произойдет сбой. В этой ситуации программисту можно только посоветовать относиться внимательно к предупреждениям и хорошо понимать о чем предупреждает компилятор.
Большое количество неявных приведений к типу bool перешли в C++ из C. Они были сохранены из-за необходимости обеспечить совместимость языков, при создании C++ старались сделать так, чтобы почти любой код на C без ошибок компилировался в C++. Из-за этого возможны многочисленные ошибки, не обнаруживаемые компилятором. Но кардинально изменить ситуацию невозможно — надо знать о потенциальных проблемах и уметь с ними жить.
Потенциальную опасность представляют явные приведения целочисленных типов к типу перечисления, так как в этом случае нет никакой гарантии, что значение экземпляра перечисления будет совпадать с одним из объявленных значений перечисления. Инициализация экземпляра перечисления нулевым значением может произойти неявно, в конструкторе по умолчанию, сгенерированном компилятором, поэтому при проектировании перечисления рекомендуется обязательно включать элемент с нулевым значением.
Неявные повышающие приведения указателей и ссылок безопасны. Из явных понижающих приведений указателей и ссылок безопасен оператор dynamic_cast<>(), а вот другие операторы могут давать неверный результат. При приведении указателей на классы, связанные наследованием, результат может побитово отличатся от исходного указателя (дельта-арифметика). Самым небезопасным является оператор reinterpret_cast<>(), он просто интерпретирует набор битов одного типа как набор битов другого типа.
Список литературы
[Dewhurst]
Дьюхэрст, Стефан К. Скользкие места C++. Как избежать проблем при проектировании и компиляции ваших программ.: Пер. с англ. — М.: ДМК Пресс, 2012.
