Приведение типов данных
Итак, мы знаем, что в памяти компьютера каждый тип данных хранится по-разному.
Типы данных занимают разное количество памяти и могут распознаваться компьютером совершенно по-разному.
Но всё же зачастую нам нужно преобразовывать данные из одного типа в другой.
Такое преобразование называется приведением типов, или же type casting
.
Явное приведенеи типов
Мы можем напрямую попросить компилятор преобразовать значение из одного типа в другой.
C-style casting
Так как C++
является продолжением языка C
, в целях совместимости этих двух языков в C++
остаётся много инструментов из C
.
Одним из них является C-style casting
, или же преобразование типов в стиле C.
Записывается оно следующим образом:
int a = 4;
std::cout << (double)a << std::endl;
внимание!
Использовать C-style casting
- плохая практика
Но использовать такое преобразование, кроме того, что это достаточно плохо читается в коде, не рекомендуется по нескольким причинам:
- При таком преобразовании компилятор не будет проверять, насколько такое преобразование безопасно. Поэтому мы можем случайно спровоцировать потерю данных, неопределённое поведение нашей программы или даже аварийное завершение.
- Преобразование в стиле С может даже снимать константность с переменных
Статическое приведение типов - static_cast
Такое приведение уже избавлено от недостатков C-style casting
. Его мы и будем использовать для явного приведеня типов.
Записывается оно при помощи static_cast
:
int a = 4;
std::cout << static_cast<double>(a) << std::endl;
В угловых скобках указывается тип, к которому мы хотим преобразовать данные, а в круглых - сами преобразуемые данные.
Другие C++-преобразования
В следующих уроках мы рассмотрим подробнее и другие преобразования:
dynamic_cast
const_cast
reinterpret_cast
На данный момент мы разобрали ещё недостаточно материала, так что пока отставим эти темы на потом.
Неявное приведение типов
Также в C++, как и в C, часто работает неявное приведение типов. В таких случаях значение одного типа данных без наших инструкций, по решению компилятора преобразуется в значение другого типа.
Нежелаемое неявное приведение типов в программе может привести к очень опасным последствиям, вплоть до взрыва космических ракет, управляемых программой на C/C++.
Поэтому нужно очень чётко понимать, когда срабатывает неявное приведение типов.
Общий случай
Вообще, все ситуации с неявным приведением можно свести к общему правилу: когда компилятор видит, что один тип данных используется в контексте другого типа данных, он будет пытаться привести второй тип данных к первому (если мы ему это, конечно же, не запретили).
Если же компилятору нужно выбрать, к какому типу нужно привести значение, он всегда будет стараться подобрать тип с наименьшей потерей информации. Конкретный пример посмотрим далее для оператора сложения +
.
Операторы
Когда мы используем стандартные операторы, так же стоит понимать, что конкретные операции в C++ определены только для конкретных типов.
Например, сложение в C++
через +
определено только для:
- двух целых чисел
- двух вещественных чисел
Соответственно, при вызове следующей операции:
double a = 5.2 + 4;
...оператор +
без преобразований не сможет вычислить эту сумму.
Ему придётся либо 5.2
привести к инту, либо 4
привести к float
. Что же он сделает?
Конечно же, компилятор видит, что если он приведёт 5.2
к инту, произойдёт потеря информации - мы потеряем дробную часть числа. А если он приведёт 4
к float
, ничего не потеряется - поэтому в данному случае произойдёт именно преобразование 4
к float
, после чего будет вычислен результат сложения двух вещественных чисел.
Ещё один очень наглядный и базовый пример - это деление.
Как вы думаете, что будет выведено на экран при исполнении этого кода?
std::cout << 5/2 << std::endl;
И 2.5
- это неправильный ответ! На самом деле, на экран выведется число 2. Давайте разберёмся, почему.
У нас есть оператор /
, который принимает два аргумента.
В данном случае и левый, и правый операторы - это целые числа. На основе входных данных (два целых числа), оператор считает, что он также должен вернуть целое число. Поэтому, 5 делится на два с учётом того, что результат должен быть целым числом. Из-за этого дробная часть просто выкидывается, и на выходе мы получаем число 2.
Как исправить этот код? Чётко сказать компилятору, какой тип мы хотим получить. Например, задать один из аргументов как число с плавающей точкой:
std::cout << 5.0 / 2 << std::endl;
...или так:
std::cout << static_cast<float>(5)/2 << std::endl;
В данном случае оператор получает одно число с плавающей точкой и одно целое, и на основе этой информации принимает решение возвращать тоже число с плавающей точкой, чтобы избежать потери данных.
Если быть точнее, оператор /
определён только для работы либо с двумя интами, либо с двумя вещественными числами. Когда мы задаём одно число как вещественное, компилятор преобразует второе число в вещественное и правильно вычисляет результат (по аналогии с только что рассмотренным примером сложения).
замечание
Если применить static_cast
ко всему выражению:
std::cout << static_cast<float>(5/2) << std::endl;
...у нас всё равно выведется 2.
Потому что в данном случае оператор /
всё равно примет на вход 2 целых числа
и точно также вернёт значение int
, и уже это готовое целое число будет формально
преобразовано к типу float
.
Вызов функций
Предположим, у нас есть абстрактная функция для сложения двух чисел с плавающей точкой:
double double_sum(double a, double b)
{
return a + b;
}
...и позже, где-то в коде, нам захотелось вызвать эту функцию для двух целых чисел:
int height_table, height_vase;
// ...
std::cout << double_sum(height_table, height_vase);
В этом случае тоже будет произведено неявное преобразование целых чисел в вещественные.
Инициализация
Когда мы инициализируем переменную через оператор =
, тоже может возникнуть неявное преобразование.
Например, следующий код:
int a = 4.0;
В этом случае, при инициализации целого числа вещественное число справа неявно преобразуется в int
.
Чтобы избежать неявного преобразования типов при инициализации, можно использовать универсальную инициализацию:
int a {4.0};
Такой код компилятор уже откажется компилировать и резонно укажет нам на различие типа переменной и присваеваемого значения.
Условие if
Когда мы пишем условный оператор if
, в круглых скобках мы указываем условие, при котором должно быть выполнено тело if
(команды в фигурных скобках).
Это условие, как нетрудно догадаться - это на самом деле какое-то значение типа bool
. Если оно равняется true
, то тело выполняется, если false
, то нет.
И если передать в круглые скобки if
какое-то не булевое значение, произойдёт неявное преобразование типа в bool
:
int a; std::cin >> a;
if (a)
{
std::cout << "a is null!\n" << std::endl;
}
Здесь a
- это целое число. if
принимает только булевые значения, поэтому компилятор выполняет неявное преобразование из int
в bool
.
Вообще, преобразования каких-либо типов в bool
работают очень просто: если значение не ноль - результирующий bool
будет true
, иначе - false
.
Так и в нашем примере - если a == 0
, то тело выполнется, иначе нет.
Опасное и безопасное приведение типов
Как мы видим, типы в C++ можно преобразовывать и изменять совершенно по всякому. Но стоит понимать, что приведение типов допустимо далеко не во всех ситуациях.
Приведение типов считается безопасным, если оно не может потенциально привести к потере данных.
Например, преобразование int
в long int
- безопасное, потому что число, хранящееся в инте, со стопроцентной гарантией поместится в long int
.
В целом, это правило можно немного расширить: всякое преобразование из типа данных, который занимает меньшие объём памяти, к типу данных, который занимает больший объём памяти, является безопасным. Некоторые примеры:
char
вshort int
,int
,long int
,double
, ...int
вdouble
,long long int
, ...double
вlong long int
Если же преобразование типов может привести к потере данных, то такое преобразование называется опасным.
Например, приведение типа int
к типу char
. Если в int
-е хранится число, большее 255, оно просто не поместится в char
, и в итоговой переменной мы получим "оборванное значение".
Легко можно это визуализировать, представив себе коробки с песком. Если насыпать в большую коробку чуть-чуть песка - ничего плохого не случится, несмотря на то, что часть коробки будет не занята - это безопасное приведение типов.
Но если мы попытаемся в маленькую коробку засыпать камаз песка - часть песка высыпется из коробки и будет утеряна - это будет небезопасное приведение типов.
Как это выглядит на уровне памяти?
Пусть у нас есть переменная int a
, в которой лежит значение 300. В памяти компьютера это будет выглядеть так:
00000000000000000000000100101100
(32 знака, потому что int
, обычно, занимает 4 байта).
Дальше мы хотим это значение привести к типу char
. Что сделает компьютер? Правильно, он просто обрежет наше число:
000000000000000000000001[00101100]
char
занимает 1 байт, поэтому компьютер "обрезает" от числа последние 8 бит.
Но в итоговый char
не попала одна единичка из исходного числа, потому что она просто не влезла в границы char
-а!
В итоге, мы получим в char
значение 44 вместо 300.
Самым наглядным примером небезопасного преобразования типов, которое привело к необратимым последствиям, является крушение ракеты Airane 5.
В этом случае в программе 64-битное вещественное число преобразовывалось к 16-битному целому числу, из-за чего ракета, стоившая сотни миллионов долларов, взорвалась при старте. Подробнее на эту тему можно почитать прекрасную статью с хабра, её я оставлю в источниках.
Мораль
Всегда следите за преобразованием типов в своей программе, используйте статическое приведение типов и не забывайте отслеживать в своей программе неявные (и явные тоже) небезопасные преобразования.