JavaRush /Java блог /Java Developer /Устройство вещественных чисел
Автор
Александр Выпирайленко
Java-разработчик в Toshiba Global Commerce Solutions

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

Статья из группы 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 на будущее, он тебе обязательно пригодится :) Фух! Лекция получилась немаленькая, но ты справился: молодец! :) Увидимся на следующем занятии, будущий программист!
Комментарии (214)
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ
Олег Галабурда Уровень 10
12 марта 2024
Большое спасибо за доходчивое изложение тонких нюансов. Респект! Мне кажется, что в статье после слов: " А у 64-битных чисел (в Java это тип double) — точность примерно 53 бита, то есть примерно 16 знаков после запятой. Вот пример, который хорошо демонстрирует эту разницу: " В примере есть лишний напечатанный символ "f" перед точкой с запятой: float f = 0.0f;
Тимур Уровень 3
4 марта 2024
В западной матиматической практике для дробей принято использовать точку (point), а в наших регионах запятую. Поэтому, не следует вводить в заблуждение народ, числа не с плавающей точкой, а с плавающей запятой. Даже в вашем предложении есть эта не состыковка: "У 32-битных чисел с плавающей точкой (в Java это как раз тип float) точность составляет примерно 24 бита, то есть около 7 знаков после запятой." А за стрью большое спасибо! :)
Grigoryvvv Уровень 6 Expert
21 января 2024
21.01.24. 4 уровень
Павел Уровень 11
17 декабря 2023
Спасибо! Всё очень наглядно и доходчиво. Кое что даже прояснилось.
Anton Уровень 9
3 декабря 2023
Отличная статья, а можете подсказать как высчитывается зависимость почему 24 бита это примерно 7 знаков, а 53 это 16 знаков после запятой, или это фиксированные значения?
Aleksey63 Уровень 35
15 ноября 2023
Автор на примере 1/3 в десятичной записи показывает, где и как может потеряться точность. Именно этого примера мне не хватало, чтобы понять, почему не все дробные числа можно точно записать в двоичной (и другой) системе. Спасибо.
Kirill Cheremnykh Уровень 22
15 ноября 2023
Интересная статья, спасибо автору! Только не понял, как в двоичном коде записать дробные десятичные числа?
Иван #3374913 Уровень 15
11 ноября 2023
Какое максимальное число можно записать в объект BigDecimal?
degreez Уровень 10
26 октября 2023
Написано интересно и понятным языком, спасибо!
Владимир Уровень 6
11 июля 2023
Интересно, а BigDecimal это число? Т.е. получим ли мы результат обычного умножения BigDecimal на целое/вещественное число или вылетит ошибка?