Professor Hans Noodles
41 уровень

Устройство вещественных чисел

Статья из группы Java Developer
Привет! В сегодняшней лекции расскажем о числах в Java, а конкретно — о вещественных числах. Устройство вещественных чисел - 1Без паники! :) Никаких математических сложностей в лекции не будет. Будем говорить о вещественных числах исключительно с нашей, «программистской» точки зрения. Итак, что же такое «вещественные числа»? Вещественные числа — это числа, у которых есть дробная часть (она может быть нулевой). Они могут быть положительными или отрицательными. Вот несколько примеров: 15 56.22 0.0 1242342343445246 -232336.11 Как же устроено вещественное число? Достаточно просто: оно состоит из целой части, дробной части и знака. У положительных чисел знак обычно не указывают явно, а у отрицательных указывают. Ранее мы подробно разобрали, какие операции над числами можно совершать в Java. Среди них было много стандартных математических операций — сложение, вычитание и т. д. Было и кое-что новое для тебя: например, остаток от деления. Но как именно устроена работа с числами внутри компьютера? В каком виде они хранятся в памяти?

Хранение вещественных чисел в памяти

Думаю, для тебя не станет открытием, что числа бывают большими и маленькими :) Их можно сравнивать друг с другом. Например, число 100 меньше числа 423324. Влияет ли это на работу компьютера и нашей программы? На самом деле — да. Каждое число представлено в Java определенным диапазоном значений:
ТипРазмер в памяти (бит)Диапазон значений
byte8 битот -128 до 127
short16 битот -32768 до 32767
char16 битбеззнаковое целое число, которое преставляет собой символ UTF-16 (буквы и цифры)
int32 битаот -2147483648 до 2147483647
long64 битаот -9223372036854775808 до 9223372036854775807
float32 битаот 2-149 до (2-2-23)*2127
double64 битаот 2-1074 до (2-2-52)*21023
Сегодня поговорим именно о последних двух типах — float и double. Оба выполняют одну и ту же задачу — представляют дробные числа. Их еще очень часто называют «числа с плавающей точкой». Запомни этот термин на будущее :) Например, число 2.3333 или 134.1212121212. Довольно странно. Ведь получается, нет никакой разницы между этими двумя типами, раз они выполняют одну и ту же задачу? Но разница есть. Обрати внимание на столбец «размер в памяти» в таблице выше. Все числа (да и не только числа — вообще вся информация) хранится в памяти компьютера в виде битов. Бит — это самая маленькая единица измерения информации. Она довольно проста. Любой бит равен или 0, или 1. Да и само слово «bit» происходит от английского «binary digit» — двоичное число. Думаю, ты наверняка слышал о существовании двоичной системы счисления в математике. Любое привычное нам десятичное число можно представить в виде набора единиц и нулей. Например, число 584.32 в двоичной системе будет выглядеть так: 100100100001010001111. Каждые единица и ноль в этом числе являются отдельным битом. Теперь тебе должна быть более понятна разница между типами данных. Например, если мы создаем число типа float, в нашем распоряжении есть всего 32 бита. При создании числа float именно столько места будет выделено для него в памяти компьютера. Если же мы хотим создать число 123456789.65656565656565, в двоичном виде оно будет выглядеть так: 11101011011110011010001010110101000000. Оно состоит из 38 единиц и нулей, то есть для его хранения в памяти нужно 38 бит. В тип float это число просто не «влезет»! Поэтому число 123456789 можно представить в виде типа double. Для его хранения выделяется целых 64 бита: это нам подходит! Разумеется, и диапазон значений тоже будет подходящим. Для удобства ты можешь представлять число как маленький ящик с ячейками. Если ячеек хватает для хранения каждого бита, значит, тип данных выбран правильно :) Устройство вещественных чисел - 2Разумеется, разное количество выделяемой памяти влияет и на само число. Обрати внимание, что у типов float и double отличается диапазон значений. Что это означает на практике? Число double может выразить большую точность, чем число float. У 32-битных чисел с плавающей точкой (в Java это как раз тип float) точность составляет примерно 24 бита, то есть около 7 знаков после запятой. А у 64-битных чисел (в Java это тип double) — точность примерно 53 бита, то есть примерно 16 знаков после запятой. Вот пример, который хорошо демонстрирует эту разницу:

public class Main {

   public static void main(String[] args)  {

       float f = 0.0f;
       for (int i=1; i <= 7; i++) {
           f += 0.1111111111111111;
       }

       System.out.println(f);
   }
}
Что мы должны получить здесь в качестве результата? Казалось бы, все довольно просто. У нас есть число 0.0, и мы 7 раз подряд прибавляем к нему 0.1111111111111111. В итоге должно получиться 0.7777777777777777. Но мы создали число float. Его размер ограничен 32 битами и, как мы сказали ранее, он способен отобразить число примерно до 7 знака после запятой. Поэтому в итоге результат, который мы получим в консоли, будет отличаться от того, что мы ожидали:

0.7777778
Число как будто было «обрезано». Ты уже знаешь как хранятся данные в памяти — в виде битов, поэтому тебя не должно это удивлять. Понятно, почему это произошло: результат 0.7777777777777777 просто не влез в выделенные нам 32 бита, поэтому и был обрезан так, чтобы поместиться в переменную типа float :) Мы можем изменить тип переменной на double в нашем примере, и тогда итоговый результат не будет обрезан:

public class Main {

   public static void main(String[] args)  {

       double f = 0.0;
       for (int i=1; i <= 7; i++) {
           f += 0.1111111111111111;
       }

       System.out.println(f);
   }
}

0.7777777777777779
Здесь уже 16 знаков после запятой, результат «уместился» в 64 бита. Кстати, возможно ты заметил, что в обоих случаях результаты получились не совсем корректными? Подсчет был произведен с небольшими ошибками. О причинах этого мы поговорим ниже :) Теперь скажем пару слов о том, как можно сравнить числа между собой.

Сравнение вещественных чисел

Мы частично уже затрагивали этот вопрос в прошлой лекции, когда говорили об операциях сравнения. Такие операции как >, <, >=, <= повторно разбирать мы не будем. Вместо этого рассмотрим более интересный пример:

public class Main {

   public static void main(String[] args)  {

       double f = 0.0;
       for (int i=1; i <= 10; i++) {
           f += 0.1;
       }

       System.out.println(f);
   }
}
Как ты думаешь, какое число будет выведено на экран? Логичным ответом был бы ответ: число 1. Мы начинаем отсчет с числа 0.0 и последовательно прибавляем к нему 0.1 десять раз подряд. Вроде все правильно, должна получиться единица. Попробуй запустить этот код, и ответ сильно тебя удивит :) Вывод в консоль:

0.9999999999999999
Но почему в таком простом примере возникла ошибка? О_о Тут бы даже пятиклассник с легкостью верно ответил, но программа на Java выдала неточный результат. «Неточный» тут более подходящее слово, чем «неправильный». Мы все-таки получили очень близкое к единице число, а не просто какое-то рандомное значение :) Оно отличается от правильного буквально на миллиметр. Но почему? Возможно, это просто разовая ошибка. Может, комп заглючил? Попробуем написать другой пример.

public class Main {

   public static void main(String[] args)  {

       //прибавляем к нулю 0.1 одиннадцать раз подряд
       double f1 = 0.0;
       for (int i = 1; i <= 11; i++) {
           f1 += .1;
       }

       //Умножаем 0.1 на 11
       double f2 = 0.1 * 11;

       //должно получиться одно и то же - 1.1 в обоих случаях
       System.out.println("f1 = " + f1);
       System.out.println("f2 = " + f2);

       // Проверим!
       if (f1 == f2)
           System.out.println("f1 и f2 равны!");
       else
           System.out.println("f1 и f2 не равны!");
   }
}
Вывод в консоль:

f1 = 1.0999999999999999
f2 = 1.1
f1 и f2 не равны!
Так, дело явно не в глюках компа :) Что происходит? Подобные ошибки связаны с тем, как числа представлены в двоичном виде в памяти компьютера. Дело в том, что в двоичной системе невозможно точно представить число 0,1. В десятичной системе, кстати, тоже есть подобная проблема: в ней нельзя правильно представить дроби (и вместо ⅓ мы получим 0.33333333333333…, что тоже не совсем правильный результат). Казалось бы, мелочь: при таких подсчетах разница может быть в одну стотысячную часть (0,00001) или даже меньше. Но что, если от этого сравнения будет зависеть весь результат работы твоей Очень Серьезной Программы?

if (f1 == f2)
   System.out.println("Ракета летит в космос");
else
   System.out.println("Запуск отменяется, все расходятся по домам");
Мы явно ожидали, что два числа будут равны, но из-за особенностей внутреннего устройства памяти мы отменили запуск ракеты. Устройство вещественных чисел - 3Раз так, нам нужно определиться, как же все-таки сравнить два числа с плавающей точкой, чтобы результат сравнения был более...эммм...предсказуемым. Итак, правило №1 при сравнении вещественных чисел мы уже усвоили: никогда не используй == при сравнении чисел с плавающей точкой. Ок, плохих примеров, думаю, достаточно :) Давай рассмотрим хороший пример!

public class Main {

   public static void main(String[] args)  {

       final double threshold = 0.0001;

       //прибавляем к нулю 0.1 одиннадцать раз подряд
       double f1 = .0;
       for (int i = 1; i <= 11; i++) {
           f1 += .1;
       }

       //Умножаем 0.1 на 11
       double f2 = .1 * 11;

       System.out.println("f1 = " + f1);
       System.out.println("f2 = " + f2);

       if (Math.abs(f1 - f2) < threshold)
           System.out.println("f1 и f2 равны");
       else
           System.out.println("f1 и f2 не равны");
   }
}
Здесь мы по сути делаем то же самое, но меняем способ сравнения чисел. У нас есть специальное «пороговое» число — 0.0001, одна десятитысячная. Оно может быть и другим. Это зависит от того, насколько точное сравнение тебе нужно в конкретном случае. Можно сделать его и больше, и меньше. С помощью метода Math.abs() мы получаем модуль числа. Модуль — это значение числа независимо от знака. Например, у чисел -5 и 5 модуль будет одинаковым и равен 5. Мы вычитаем второе число из первого, и если полученный результат, независимо от знака, будет меньше того порога, который мы установили, значит наши числа равны. Во всяком случае, они равны до той степени точности, которую мы установили с помощью нашего «порогового числа», то есть как минимум они равны вплоть до одной десятитысячной. Такой способ сравнения избавит тебя от неожиданного поведения, которое мы увидели в случае с ==. Еще один хороший способ сравнения вещественных чисел — использовать специальный класс BigDecimal. Этот класс специально был создан для хранения очень больших чисел с дробной частью. В отличие от double и float, при использовании BigDecimal сложение, вычитание и прочие математические операции выполняются не с помощью операторов (+- и т.д.), а с помощью методов. Вот как это будет выглядеть в нашем случае:

import java.math.BigDecimal;

public class Main {

   public static void main(String[] args)  {

       /*Создаем два объекта BigDecimal - ноль и 0.1.
       Делаем то же самое что и раньше - прибавляем 0.1 к нулю 11 раз подряд
       В классе BigDecimal сложение осуществляется с помощью метода add()*/
       BigDecimal f1 = new BigDecimal(0.0);
       BigDecimal pointOne = new BigDecimal(0.1);
       for (int i = 1; i <= 11; i++) {
           f1 = f1.add(pointOne);
       }

       /*Здесь тоже ничего не изменилось: создаем два объекта BigDecimal
       и умножаем 0.1 на 11
       В классе BigDecimal умножение осуществляется с помощью метода multiply()*/
       BigDecimal f2 = new BigDecimal(0.1);
       BigDecimal eleven = new BigDecimal(11);
       f2 = f2.multiply(eleven);

       System.out.println("f1 = " + f1);
       System.out.println("f2 = " + f2);

       /*Еще одна особенность BigDecimal - объекты чисел нужно сравнивать между
       собой с помощью специального метода compareTo()*/
       if (f1.compareTo(f2) == 0)
           System.out.println("f1 и f2 равны");
       else
           System.out.println("f1 и f2 не равны");
   }
}
Какой же вывод в консоль мы получим?

f1 = 1.1000000000000000610622663543836097232997417449951171875
f2 = 1.1000000000000000610622663543836097232997417449951171875
f1 и f2 равны
Мы получили ровно тот результат, на который рассчитывали. И обрати внимание, насколько точными получились наши числа, и сколько знаков после запятой в них уместилось! Гораздо больше, чем во float и даже в double! Запомни класс BigDecimal на будущее, он тебе обязательно пригодится :) Устройство вещественных чисел - 4Фух! Лекция получилась немаленькая, но ты справился: молодец! :) Увидимся на следующем занятии, будущий программист!
Комментарии (168)
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ
Hackron Уровень 6, Москва, Russian Federation
14 сентября 2022
зачем нужно 0.0f почему не 0.0? в записи float f = 0.0f;
Boris Уровень 8, Israel
9 сентября 2022
Тот редкий и приятный случай, когда тема не сложная, но очень интересная и явно очень полезная на будущее.
pureTruth Уровень 15, Russian Federation
3 сентября 2022
Спасибо. Познавательно, да ещё и интересно. Значит должно лучше запомниться.
alegakom Уровень 11, Москва, Russian Federation
24 августа 2022
Самый полезный разбор. Спасибо!
AlekseiSieger Уровень 9, Stara-Zagora, Bulgaria
18 августа 2022
Полезная статья, спасибо
Solorev Oleg #3113589 Уровень 6, Russian Federation
17 августа 2022
Зашквар, но познавательно! Все сразу на подкорке не запишется, но, со временем и при помощи практики думаю, въеду.
matvejsh17 Уровень 10, Минск, Беларусь
16 августа 2022
я не очень понял этот момент, что значит точность у float 24 бита и 7 цифр, а у double 53 бита и 16 цифр почему у float именно 7 цифр, хотя в 24 бита вмещается 8 цифр, а в double именно 53 бита, хотя 16 цифр вмещается в 51 бит
Anonymous #2756426 Уровень 6, Ростов-на-Дону, Russian Federation
21 июня 2022
Мдааа, оказывается кудахтер считает быстро, но не точно. Причём умножает точнее, чем складывает, уже при 0.7+0.1 начинается расхождение (если верить онлайн компилятору). А есть похожие примеры с другими операциями?
Pavel Martynov Уровень 6, Санкт-Петербург, Россия
20 июня 2022
Быстренько пролистал все комментарии, я так понимаю меня одного смущает в статье float 32 бита от 2^-149 до (2-(2^-23))*2^127 , где 2^-149 = 1 / (2^149) , а это значение близиться к нулю, с дабл ситуация идентичная. Короче загуглите минимальные и максимальные значения float = +/- 3.4028235E+38 (7знаков); от -2^127 до 2^127 (всего значений 2^128) double = +/- 1,7E+308 (15 знаков); от -2^1023 до 2^1023 (всего значений 2^1024) Но на сайте майкрософт пишут что в С,С+,С++: float : Минимальное значение 1,175494351 E – 38; Максимальное значение 3,402823466 E + 38; double: 2,2250738585072014 E – 308 и 1,7976931348623158 E + 308 Более подробно про мантиссу и куда она не записывается, знак числа куда записывается и тд на просторах интернета, кому интересно. (один из наглядных источников https://habr.com/ru/post/112953/) - очень очень ! крутая статья для понимания.
Ole Уровень 5, Киев, Украина
9 июня 2022
Прикольно, у меня есть вопрос, как все это запомнить, поделитесь секретом плз.