Приведение типов данных

Итак, мы знаем, что в памяти компьютера каждый тип данных хранится по-разному.

Типы данных занимают разное количество памяти и могут распознаваться компьютером совершенно по-разному.

Но всё же зачастую нам нужно преобразовывать данные из одного типа в другой.

Такое преобразование называется приведением типов, или же 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 - плохая практика

Но использовать такое преобразование, кроме того, что это достаточно плохо читается в коде, не рекомендуется по нескольким причинам:

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

Статическое приведение типов - 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-битному целому числу, из-за чего ракета, стоившая сотни миллионов долларов, взорвалась при старте. Подробнее на эту тему можно почитать прекрасную статью с хабра, её я оставлю в источниках.

Мораль

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

Источники

  1. Статическая типизация и преобразования типов - Metanit
  2. Космическая ошибка: $370 000 000 за Integer overflow - Хабр
  3. Ariane 5 rocket launch explosion - Youtube