Найти в Дзене
Road to the programming

Урок №33. Типы данных с плавающей точкой: float, double и long double

На этом уроке мы рассмотрим типы данных с плавающей точкой в языке С++, их точность и диапазон. Выясним, что такое экспоненциальная запись и как она используется, а также рассмотрим ошибки округления и дадим определения для nan и inf .
Типы данных с плавающей точкой
Целочисленные типы данных отлично подходят для работы с целыми числами, но есть ведь еще и дробные числа. И тут нам на помощь
Оглавление

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

Типы данных с плавающей точкой

Целочисленные типы данных отлично подходят для работы с целыми числами, но есть ведь еще и дробные числа. И тут нам на помощь приходит тип данных с плавающей точкой (или «тип данных с плавающей запятой» , англ. «floating point» ). Переменная такого типа может хранить любые действительные дробные значения, например: 4320.0, -3.33 или 0.01226. Почему точка «плавающая»? Дело в том, точка/запятая перемещается («плавает» ) между цифрами, разделяя целую и дробную части значения.

Есть три типа данных с плавающей точкой: floatdouble и long double . Язык C++ определяет только их минимальный размер (как и с целочисленными типами). Типы данных с плавающей точкой всегда являются signed (т.е. могут хранить как положительные, так и отрицательные числа).

-2

Объявление переменных разных типов данных с плавающей точкой:

float fValue ;
double dValue ;
long double dValue2 ;

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

int n ( 5 ) ; // 5 - это целочисленный тип
double d ( 5.0 ) ; // 5.0 - это тип данных с плавающей точкой (по умолчанию double)
float f ( 5.0f ) ; // 5.0 - это тип данных с плавающей точкой ("f" от "float")

Обратите внимание, литералы типа с плавающей точкой по умолчанию относятся к типу double. f в конце числа означает тип float.

Экспоненциальная запись

Экспоненциальная запись очень полезна для написания длинных чисел в краткой форме. Числа в экспоненциальной записи имеют следующий вид: мантисса × 10экспонент . Например, рассмотрим выражение 1.2 × 104 . Значение 1.2 — это мантисса (или «значащая часть числа» ), а 4 — это экспонент (или «порядок числа» ). Результатом этого выражения является значение 12000.

Обычно, в экспоненциальной записи, в целой части находится только одна цифра, все остальные пишутся после разделительной точки (в дробной части).

Рассмотрим массу Земли. В десятичной системе счисления она представлена как 5973600000000000000000000 кг . Согласитесь, очень большое число (даже слишком большое, чтобы поместиться в целочисленную переменную размером 8 байт). Это число даже трудно читать (там 19 или 20 нулей?). Но используя экспоненциальную запись, массу Земли можно представить, как 5.9736 × 1024 кг (что гораздо легче воспринимается, согласитесь). Еще одним преимуществом экспоненциальной записи является сравнение двух очень больших или очень маленьких чисел — для этого достаточно просто сравнить их экспоненты.

В языке C++ буква е /Е означает, что число 10 нужно возвести в степень, которая следует за этой буквой. Например, 1.2 × 104 эквивалентно 1.2e4 , значение 5.9736 × 1024 еще можно записать как 5.9736e24 .

Для чисел меньше единицы экспонент может быть отрицательным. Например, 5e-2 эквивалентно 5 * 10-2 , что, в свою очередь, означает 5 / 102 или 0.05 . Масса электрона равна 9.1093822e-31 кг .

На практике экспоненциальная запись может использоваться в операциях присваивания следующим образом:

double d1 ( 5000.0 ) ;

double d2 ( 5e3 ) ; // другой способ присвоить значение 5000

double d3 ( 0.05 ) ;

double d4 ( 5e - 2 ) ; // другой способ присвоить значение 0.05

Конвертация чисел в экспоненциальную запись

Для конвертации чисел в экспоненциальную запись необходимо следовать процедуре, указанной ниже:

  • Ваш экспонент начинается с нуля.
  • Переместите разделительную точку (которая разделяет целую и дробную части) влево, чтобы слева от нее осталась только одна ненулевая цифра:
  • каждое перемещение точки влево увеличивает экспонент на 1 ;
  • каждое перемещение точки вправо уменьшает экспонент на 1 .
  • Откиньте все нули перед первой ненулевой цифрой в целой части.
  • Откиньте все конечные нули в правой (дробной) части, только если исходное число является целым (без разделительной точки ) .

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

Исходное число: 42030
Перемещаем разделительную точку на 4 цифры влево: 4.2030e4
Слева (в целой части) нет нулей: 4.2030e4
Отбрасываем конечный нуль в дробной части: 4.203e4 (4 значащие цифры)

Исходное число: 0.0078900
Перемещаем разделительную точку на 3 цифры вправо: 0007.8900e-3
Отбрасываем нули слева: 7.8900e-3
Не отбрасываем нули справа (исходное число является дробным): 7.8900e-3 (5 значащих цифр)

Исходное число: 600.410
Перемещаем разделительную точку на 2 цифры влево: 6.00410e2
Слева нет нулей: 6.00410e2
Нули справа оставляем: 6.00410e2 (6 значащих цифр)

Самое главное, что нужно запомнить — это то, что цифры в мантиссе (часть перед e ) называются значащими цифрами . Количество значащих цифр определяет точность самого значения. Чем больше цифр в мантиссе, тем точнее значение.

Точность и диапазон типов с плавающей точкой

Рассмотрим дробь 1/3. Десятичное представление этого числа — 0.33333333333333… (с тройками до бесконечности). Бесконечное число требует бесконечной памяти для хранения, а у нас в запасе, как правило, 4 или 8 байт. Переменные типа с плавающей запятой могут хранить только определенное количество значащих цифр, остальные — отбрасываются. Точность определяется количеством значащих цифр , которые представляют число без потери данных.

Когда мы выводим переменные типа с плавающей точкой, то точность объекта cout , по умолчанию, составляет 6. Т.е. на экране мы увидим только 6 значащих цифр, остальные — потеряются. Например:

#include <iostream>
int main ( )
{
float f ;
f = 9.87654321f ;
std :: cout << f << std :: endl ;
f = 987.654321f ;
std :: cout << f << std :: endl ;
f = 987654.321f ;
std :: cout << f << std :: endl ;
f = 9876543.21f ;
std :: cout << f << std :: endl ;
f = 0.0000987654321f ;
std :: cout << f << std :: endl ;
return 0 ;
}

Результат выполнения программы:

9.87654
987.654
987654
9.87654e+06
9.87654e-05

Обратите внимание, каждое из вышеприведенных значений имеет только 6 значащих цифр (цифры перед e , а не перед точкой).

Также, в некоторых случаях, cout сам может выводить числа в экспоненциальной записи. В зависимости от компилятора, экспонент может быть дополнен нулями. Например, 9.87654e+06 — это то же самое, что и 9.87654e6 (просто с добавленным нулем и знаком + ). Минимальное количество цифр экспонента определяется компилятором (Visual Studio использует 2, другие компиляторы могут использовать 3).

Также мы можем переопределить точность cout, используя функцию std::setprecision() , которая находится в заголовочном файле iomanip:

#include <iostream>
#include <iomanip> // для std::setprecision()
int main ( )
{
std :: cout << std :: setprecision ( 16 ) ; // задаем точность в 16 цифр
float f = 3.33333333333333333333333333333333333333f ;
std :: cout << f << std :: endl ;
double d = 3.3333333333333333333333333333333333333 ;
std :: cout << d << std :: endl ;
return 0 ;
}

Результат выполнения программы:

3.333333253860474
3.333333333333333

Так как мы увеличили точность до 16, то каждая переменная выводится 16-ю цифрами. Но, как вы можете видеть, исходные числа имеют больше цифр!

Точность зависит от размера типа данных (в типе float точность меньше, чем в типе double) и от присваиваемого значения:

точность float : от 6 до 9 цифр (в основном 7);

точность double : от 15 до 18 цифр (в основном 16);

точность long double : 15, 18 или 33 цифры (в зависимости от того, сколько байт занимает тип данных на компьютере).

Этот принцип относится не только к дробным числам, но и ко всем значениям, которые имеют слишком большое количество значащих цифр. Например:

#include <iostream>
#include <iomanip> // для std::setprecision()
int main ( )
{
float f ( 123456789.0f ) ; // переменная f имеет 10 значащих цифр
std :: cout << std :: setprecision ( 9 ) ; // задаем точность в 9 цифр
std :: cout << f << std :: endl ;
return 0 ;
}

Результат:

123456792

Но ведь 123456792 больше чем 123456789 , не так ли? Значение 123456789.0 имеет 10 значащих цифр, но точность float равна 7. Поэтому мы и получили другое число, произошла потеря данных!

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

Диапазон и точность типов данных с плавающей точкой, согласно стандарту IEEE 754 :

-3

Может показаться немного странным, что 12-байтовая переменная типа с плавающей точкой имеет тот же диапазон, что и 16-байтовая переменная. Это потому, что они имеют одинаковое количество бит, выделенных для экспонента (только в 16-байтовой переменной точность будет выше).

Правило: Используйте по умолчанию тип double вместо типа float, так как его точность выше.

Ошибки округления

Рассмотрим дробь 1/10. В десятичной системе счисления эту дробь можно представить, как 0.1. В двоичной системе счисления эта дробь представлена в виде бесконечной последовательности — 0.00011001100110011… Именно из-за подобных разногласий в представлении чисел в разных системах счисления, у нас могут возникать проблемы с точностью. Например:

#include <iostream>
#include <iomanip> // для std::setprecision()
int main ( )
{
double d ( 0.1 ) ;
std :: cout << d << std :: endl ; // используем точность cout по умолчанию (6 цифр)
std :: cout << std :: setprecision ( 17 ) ;
std :: cout << d << std :: endl ;
return 0 ;
}

Результат выполнения программы:

0.1
0.10000000000000001

Первый cout выводит 0.1 (что и ожидаемо). После того, как мы изменили для объекта cout точность вывода до 17 цифр, мы увидели, что значением переменной d является не совсем 0.1 ! Подобное происходит из-за ограничений в количестве выделяемой памяти для переменных типа double, а также из-за необходимости «округлять» числа. По факту мы получили типичную ошибку округления .

Подобные ошибки могут иметь неожиданные последствия:

#include <iostream>
#include <iomanip> // для std::setprecision()
int main ( )
{
std :: cout << std :: setprecision ( 17 ) ;
double d1 ( 1.0 ) ;
std :: cout << d1 << std :: endl ;
double d2 ( 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 ) ; // должно получиться 1.0
std :: cout << d2 << std :: endl ;
}

Результат выполнения программы:

1
0.99999999999999989

Хотя мы ожидали, что d1 и d2 окажутся равными, но это не так. А что, если бы нам довелось сравнивать эти переменные и, исходя из результата, выполнять определенный сценарий? В таком случае ошибок нам не миновать.

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

nan и inf

Есть две специальные категории чисел типа с плавающей запятой:

  • inf (или «бесконечность» , от англ «inf inity»), которая может быть либо положительной, либо отрицательной.
  • nan (или «не число» , от англ «n ot a n umber»). Их есть несколько видов (обсуждать все виды здесь мы не будем).

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

#include <iostream>
int main ( )
{
double zero = 0.0 ;
double posinf = 5.0 / zero ; // положительная бесконечность
std :: cout << posinf << "\n" ;
double neginf = - 5.0 / zero ; // отрицательная бесконечность
std :: cout << neginf << "\n" ;
double nan = zero / zero ; // не число (математически некорректно)
std :: cout << nan << "\n" ;
return 0 ;
}

Результат выполнения программы:

inf
-inf
-nan(ind)

inf означает «бесконечность», а ind означает «неопределенный» (от англ. «ind eterminate»). Обратите внимание, результаты вывода inf и nan зависят от компилятора/архитектуры компьютера, поэтому ваш результат выполнения вышеприведенной программы может отличаться от моего результата.

Заключение

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

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

Тест

Запишите следующие числа в экспоненциальной записи в стиле языка C++ (используя букву е , как символ экспонента) и определите, сколько значащих цифр имеет каждое из следующих чисел:

  • 34.50
  • 0.004000
  • 123.005
  • 146000
  • 146000.001
  • 0.0000000008
  • 34500.0