Неявные преобразования типов запрещены во многих современных языках программирования, особенно новых.
Так в Rust, Haskell, Kotlin нельзя просто так использовать float
и int
в одном арифметическом выражении, без явного указания преобразовать одно в другое. Python не так строг, но все же не дает смешивать строки, символы и числа.
В С++ запрета неявного преобразования нет, что порождает массу ошибочного кода. Причем в таком коде может быть как определенное, но неожиданное, так неопределенное поведение.
Пример:
#include <vector>
#include <numeric>
#include <iostream>
int average(const std::vector<int>& v) {
if (v.empty()) {
return 0;
}
return std::accumulate(v.begin(), v.end(), 0) / v.size();
}
int main() {
std::cout << average({-1,-1,-1});
}
Любой, кто мельком бросит взгляд на этот код, будет ожидать, что результатом работы окажется -1
.
Но, увы, результат будет совершенно другим.
В этом коде нет неопределенного поведение (по крайней мере на используемых входных данных). Но есть неявное приведение типов, делающее результат неожиданным.
- Тип возвращаемого значения
std::accumulate
определяется третьим аргументом. В данном случае это целочисленный знаковый ноль — тип по умолчанию для всех числовых литералов. - Тип возвращаемого значения операции деления определяется наибольшим из участвующих типов аргументов, а также правилами integer promotion. В примере тип левого аргумента —
int
, а правого —size_t
— достаточно широкое беззнаковое целое, Более широкое чемint
. Потому, по правилам integer promotion, результатом будетsize_t
-3
неявно преобразуется к типуsize_t
— такое преобразование вполне определено. Результатом будет беззнаковое число2^N - 3
.- Далее будет произведено деление беззнаковых чисел.
(2^N - 3) / 3
. Старший бит результата окажется нулевым. - Возвращаемым типом функции
average
объявленint
. Так что нужно выполнить еще одно неявное преобразование. - В общем случае преобразование unsigned -> signed определяется реализацией (implementation defined).
- Если размеры типов
int
иsize_t
одинаковые, то, поскольку старший бит нулевой, положительное число укладывается в допустимый диапазон значений для типаint
— стандарт гарантирует, что никаких проблем нет - Если размеры не совпадают, то произойдет сужающее преобразование (narrowing conversion), которое как раз таки отдано на откуп деталям реализации. Так, вместо ожидаемой обрезки старших, не поместившихся, битов, на некоторых платформах может произойти замена на
std::numeric_limits<int>::max
- Для примера сборки под 64-битную платформу с помощью
gcc
сужающее преобразование определено, как и ожидается, через обрезку старших битов. Поэтому итоговым результатом оказывается ((2^64 -3) / 3 % 2^32
)
- Если размеры типов
Неявные приведения типов касаются не только встроенных примитивов, но и более сложных типов. И самое неприятное — они вмешиваются в выбор подходящей перегрузки функции, приводя к различным, часто неприятным, казусам.
Пример с abs
#include <cmath>
#include <iostream>
int main() {
std::cout << abs(3.5) << "\n"; // функция библиотеки С,
// принимает на вход тип long
// результат — 3
std::cout << std::abs(3.5); // функция библиотеки С++
// перегружена для double
// результат — 3.5
}
Еще более неприятный пример наблюдается со стандартным типом std::string
#include <string>
int main() {
std::string s;
s += 48; // неявное приведение к char.
s += 1000; // а тут еще и с переполнением, очень неприятным
// на платформе с signed char.
s += 49.5; // опять-таки неявное приведение к char
}
Этот ужас компилируется!
Казалось бы, этот пример совершенно ужасного использования никогда не может встретиться в нормальном коде. Увы, но может.
Вы можете написать обобщенный код своего std::accumulate
, с различными проверками шаблонных аргументов, и случайно, по ошибке, передать в него string
в качестве аккумулятора и контейнер, например, float
. И никакой ошибки компиляции не будет. Только странный баг в программе.
Цепочки неявных преобразований могут быть очень неочевидными
void f(float&& x) { std::cout << "float " << x << "\n"; }
void f(int&& x) { std::cout << "int " << x << "\n"; }
void g(auto&& v) { f(v); } // C++20
// template <class T> void g(T v) { f(v); }
int main() {
g(2);
g(1.f);
}
Самым удивительным образом этот пример выводит
float 2
int 1
Хотя мы подставляли типы констант совсем наоборот и почти наверняка ожидали
int 2
float 1
Это не баг компилятора и не неопределенное поведение! Всему виной хитрая цепочка неявных преобразований.
Рассмотрим ее на примере первого вызова g(2)
, подставив параметр шаблона
void g(int&& v) {
// Несмотря на то что тип v — int&&
// Дальнейшее использование v в выражениях дает int& !
// decltype(v) == int&&
// decltype((v)) == int&
// Функции f принимают только rvalue ссылки
// Неявное преобразование int& к int&& запрещено
// int&& x = 5;
// int&& y = x; // не компилируется!
// Таким образом перегрузка f(int&&) не может быть использована
// Остается f(float&&)
// int умеет неявно приводиться к float
// int& умеет неявно выступать в роли просто int
// неявный static_cast<float>(v) возвращает временное значение float
// временные значения типа T неявно биндятся к T&&
// Имеем цепочку преобразований:
// int& -> int -> float -> float&&
f(v); // будет вызван f(float&&) !
// явно: f(static_cast<float>(v));
}
Конечно, никто никогда (по крайней мере явно) не принимает примитивы по rvalue
-ссылкам. Потому что это бессмысленно. Но даже без rvalue
-ссылки для примитивов, мы можем сотворить нечто ужасное
struct MyMovableStruct {
operator bool () {
return !data.empty();
}
std::string data;
};
void consume(MyMovableStruct&& x) {
std::cout << "MyStruct: " << x.data << "\n";
}
void consume(bool x) { std::cout << "bool " << x << "\n"; }
void g(auto&& v) { consume(v); }
int main() {
g(MyMovableStruct{"hello"});
}
Той же самой цепочкой преобразований получим в выводе bool 1
.
Разве что последний шаг не нужен.
Обязательно включайте предупреждения компилятора обо всех неявных преобразованиях. Очень желательно трактовать их как ошибки.
Не привносите неявные преобразования для своих типов — всегда помечайте однопараметрические конструкторы как explicit
.
Если перегружаете операторы приведения (operator T()
) для своих типов — также делайте их explicit
.
Если ваши функции/методы рассчитаны на работу только с определенным примитивным типом, навешивайте на них ограничения с помощью шаблонов, SFINAE, концептов, или, что очень просто, механизма явного удаления перегрузок (= delete
):
int only_ints(int x) { return x;}
template <class T>
auto only_ints(T x) = delete;
int main() {
const int& x = 2;
only_ints(2);
only_ints(x);
char c = '1';
only_ints(c); // Compilation Error.
only_ints(2.5); // Explicitly deleted
}