Совсем немного теории

Я буду использовать в статье стандартное 32-х битное представление числа IEEE 754 для примера. Другие форматы, в основном отличаются только размером, структура та же. Биты считаются справа налево.

  • 31-й бит - это знак числа, 0 - плюс, 1 - минус

  • с 23-го по 30-й идут биты степени двойки

  • с 0-го по 22-й - дробная часть или мантисса

Знак

Это самое простое, тут не надо ничего придумывать:

int sign = (int) Math.signum(floatNumber);

Экспонента

Для работы с битами числа, float надо конвертировать в двоичное представление:

int bits = Float.floatToIntBits(floatNumber);

Чтобы вытащить экспоненту, воспользуемся операцией сдвига. Сначала нужно обнулить 31-й знаковый бит, для этого сдвинем биты влево на 1, затем без учета знака сдвинем на 24 бита вправо, получим 24 нуля слева и само значение:

int exponent = bits << 1 >>> 24;

Экспонента - это 1 байт кода со сдвигом, минимальное значение -126 представляется нулём, максимальное 127 как 255. Т.е. при кодировании к числу надо прибавить 127, для раскодирования вычесть:

exponent -= 127;

Дробная часть

Мантисса - это двоичная дробь от 0 до 1 к которой еще прибавляется 1. Для ее раскодирования пройдём от 22-го бита до 0-го, переводя их в десятичный формат. Перевод целого числа осуществляется при помощи умножения разрядов на степень двойки, дробного при помощи деления. Получить значение i-го бита можно так:

  • Сдвинуть 1 на i бит влево, получим степень двойки

  • Выполнить логическое И, тогда на i-м бите будет 0 или 1

  • Сдвинем обратно на i бит вправо, получим десятичные 0 или 1 - bitValue

  • Полученное значение разделать на 2 в степени

Код будет следующий:

float fraction = 1, div = 2;
for (int i = 22; i >= 0; i--) {
  int bitValue = ((1 << i) & bits) >>> i;
  fraction += bitValue / div;
  div *= 2;
}

Ограничения точности как раз и происходят из невозможности представить некоторые десятичные дроби в двоичном формате.

Но есть и другой способ. Дробная часть у нас уже есть в двоичном представлении числа, всё что нужно - это вместо экспоненты подставить 0, чтобы степень двойки стала единицей. Сделать это можно следующим образом:

  • Обнулить биты с 23-го по 31-й, сдвинув влево на 9 бит и затем вправо на 9 без учёта знака:

    (bits << 9 >>> 9) = 00000000011100101110010111001100

  • В экспоненту подставить 0 закодированный со сдвигом, для этого 127 сдвинем влево на 23:

    (127 << 23) = 00111111100000000000000000000000

  • Выполнив логическое ИЛИ с этими числами, получим дробную часть с нулевой экспонентой:

float fractionFormula = Float.intBitsToFloat((bits << 9 >>> 9) | (127 << 23));

Проверка

Подставим знак, экспоненту и дробь в формулу:

float check = (float)  (sign * Math.pow(2, exponent) * fraction);
assert check == floatNumber;
assert fractionFormula == fraction;

Всё вместе

private static void parseFloat(float floatNumber) {
  int sign = (int) Math.signum(floatNumber);
  
  int bits = Float.floatToIntBits(floatNumber);
  int exponent = bits << 1 >>> 24;
  exponent -= 127;
  
  float fraction = 1, div = 2;
  for (int i = 22; i >= 0; i--) {
    int bitValue = ((1 << i) & bits) >>> i;
    fraction += bitValue / div;
    div *= 2;
  }
  float fractionFormula = Float.intBitsToFloat((bits << 9 >>> 9) | (127 << 23));
  
  float check = (float)  (sign * Math.pow(2, exponent) * fraction);
  assert check == floatNumber;
  assert fractionFormula == fraction;
  System.out.println("Binary: " + Integer.toBinaryString(bits));
  System.out.printf("parseFloat(%.10f) = %d * 2^%d * %.10f\n", floatNumber, sign, exponent, fraction);    
}

Пример:

parseFloat(2.3164524E-4F);
parseFloat(3.6F);
Binary: 111001011100101110010111001100
parseFloat(0,0002316452) = 1 * 2^-13 * 1,8976378441
Binary: 1000000011001100110011001100110
parseFloat(3,5999999046) = 1 * 2^1 * 1,7999999523

Другие форматы

Формат IEEE 754 является самым распространённым, но есть и другие, например:

  • Microsoft Binary Format (MBF) - содержит знаковый бит между экспонентой и мантиссой

  • bfloat16 - позволяет увеличить скорость вычислений и сократить место для хранения без значительных потерь в точности

Что ещё почитать

Если вам понравилось двигать биты влево и вправо, рекомендую вот этот фундаментальный труд. То что там описано применяется в жизни. Например, метод вычисления десятичного логарифма числа используется в Java BigDecimal.precision() для получения точности.

Комментарии (15)


  1. Irval
    02.04.2023 08:12
    +6

    Рассказали бы еще про особенности вещественной арифметики (например A + B = A, A * B = 0), представление INF и NaN. Конкретно эта информация реально очень полезна и зачастую бывает неизвестна даже опытным программистам.


    1. findoff
      02.04.2023 08:12
      +1

      https://codepen.io/findoff/pen/WNgqWxY
      Написанный на коленке JS калькулятор этого, с примерами что как выглядит


  1. Olegun
    02.04.2023 08:12
    -2

    Почему статья написана слева на право, а биты на первой картинке считаются справа на лево? К чему изменение порядка?


    1. thealfest
      02.04.2023 08:12
      +2

      потому что бинарное представление выглядит именно так, а не наоборот.


  1. quaer
    02.04.2023 08:12
    +4

    Более интересен вопрос, является ли математика на java полностью переносимой. Будет ли бит-в-бит совпадение если код выполнить на разных ОС с разной разрядностью и разных версиях jre.

    Если один и тот же код написать на double, StrictMath и Math, будет ли одинаковый результат?


    1. Xobotun
      02.04.2023 08:12
      +1

      Емнип, strictfp как раз отвечает за переносимость вычислений, в стандарте прописано. Правда в 17 джаве он уже устарел.

      Но насчёт Math и StrictMath — не могу так сразу сказать.


    1. tmaxx
      02.04.2023 08:12
      +1

      До версии 1.2 - да, все double вычислялись по IEEE754
      После версии 17 - тоже да (SSE2 сейчас есть почти везде, а там аппаратная поддержка).
      https://openjdk.org/jeps/306

      Между этими версиями для переносимости существовало ключевое слово strictfp, но это было медленно и использовалось редко.


      1. quaer
        02.04.2023 08:12

        float это 32 бита, double это 64 бита. Вы уверены что округления осуществляются одинаково на всех платформах, где работает java?

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

        И эта разница приводила к тому, что численное решение системы нелинейных уравнений на эмуляторе давало решение, а на мобильнике - нет.


        1. tmaxx
          02.04.2023 08:12
          +1

          Я к сожалению не работал Андроидом, но насколько я знаю там не совсем стандартная JVM. Плюс гугл говорит, что последний Android 13 использует Java 11, где переносимость все еще не гарантировалась.

          Java 17 - относительно новая версия, ей меньше 2 лет, много где на нее еще не перешли.


          1. quaer
            02.04.2023 08:12
            -2

            Написав код на java вы же не будете выяснять особенности JVM? На ПК тоже jre разные существуют. Кроме x86 есть ARM и Apple со своим процессором. Так что вопрос переносимости вычислений с плавающей точкой интересен.


            1. tmaxx
              02.04.2023 08:12

              В ARM тоже есть поддержка IEEE754. Скорее всего во всех популярных процессорах она есть.

              Если где-то нет, то придется эмулировать, как в древней Java 1.2.

              Но функционально вычисления будут одинаковы на любой JVM 17


              1. quaer
                02.04.2023 08:12

                функционально вычисления будут одинаковы

                бит-в-бит или примерно?

                Вот пример операции:

                final double result = Double.longBitsToDouble( 0x40d5bd276215b682L ) * Double.longBitsToDouble( 0x411b2dadadf85664L );
                System.out.println( "result = " + Double.doubleToRawLongBits( result ) );


  1. sci_nov
    02.04.2023 08:12

    Ещё есть IBM формат float-ов, также 32-х битный.


  1. Myclass
    02.04.2023 08:12
    +2

    1. Без упоминания 'проблем' с этим форматом не хватает многого. Что иногда a+b может не равняться b+a. Или как тут с 0.1 + 0.2 не всегда 0.3 выдаёт:

    https://0.30000000000000004.com/

    2.не указано, чем отличаются нормальная от двойной точности. И чтo у двойной точности bias не 127, а 1023 итд.

    3. Не хватает описания причин для этого формата. Немного истории и вообще причин для вообще - зачем всё это? Как для этого формата, так и для например представления негативных чисел в памяти - причина для этого, как-бы это не банально не звучал - что основа памяти - байт, и в своей основе байт не что иное как числа т 0 до 255. А нам нужны и другие представления.


  1. BugM
    02.04.2023 08:12
    +1

    У вас ой прямо в первом пункте. Все корнер кейсы будут работать неверно. А их у float прям много.

    Посмотрите как правильно:

    public static double signum(double d) {
        return (d == 0.0 || Double.isNaN(d))?d:copySign(1.0, d);
    }
    
    public static double copySign(double magnitude, double sign) {
        return Double.longBitsToDouble((Double.doubleToRawLongBits(sign) &
                                        (DoubleConsts.SIGN_BIT_MASK)) |
                                       (Double.doubleToRawLongBits(magnitude) &
                                        (DoubleConsts.EXP_BIT_MASK |
                                         DoubleConsts.SIGNIF_BIT_MASK)));
    }