В этой статье я расскажу о математике, стоящей за типом Single в .NET (представленном ключевым словом float C#), и о том, почему вы можете получить странные результаты при работе с этим типом.

Плавающая точка против фиксированной точки

Числа с плавающей запятой имеют такое название, потому что десятичную точку можно переместить в любое место. Сравните это с числами «с фиксированной точкой», где десятичная точка всегда находится в одном и том же месте.

Научная нотация

Десятичные числа записываются в технике/науке в форме, называемой научной записью:

0.049x10¹³ = 0.49x10¹² = 4.9x10¹¹ = 49x10¹⁰ = 490x10⁹

4.9x10¹¹ предпочтительнее (одна цифра до запятой), это «нормализованная» форма экспоненциального представления.

В экспоненциальном представлении есть 4 компонента: знак (+/-, если знак не отображается, по умолчанию используется +), мантисса (также известная как мантисса) (4.9 в этом примере), основание (10 в этом примере) и показатель степени (11 в этом примере).

Если основание равно основанию системы счисления, которую вы используете, т.е. если вы используете десятичную систему счисления и ваше основание равно 10, то показатель степени говорит вам, на сколько знаков нужно переместить десятичную точку, чтобы получить фактическое число.

4.9*10¹¹ = 490000000000.0

4.9*10^(–11) = 0.000000000049

Мантисанда (в данном примере 4.9) всегда >= 1 и меньше основания, т.е. 10.

Давайте посмотрим на пример в двоичном формате:

101.1101 равно:

1*2² + 0*2¹ + 1*2⁰ + 1*2^–1 + 1*2^–2 + 0*2^–3 + 1*2^–4 = 5.8125 , или в нормализованном экспоненциальном представлении, 5.8125*10⁰.

Правило

Правило состоит в том, что значение должно быть >=1 и меньше основания (в предыдущем примере основание равно двум). Итак, поскольку часть перед десятичной дробью равна 101 = 5 в десятичной системе, нам нужно сделать часть перед двоичной точкой равной 1.

На самом деле, все двоичные числа, записанные в нормализованном экспоненциальном представлении, будут начинаться с 1, потому что это единственное число в двоичном формате >= 1 и меньше основания (два).

So 101.1101 = 1.011101*2².

Одинарная и двойная точность

Стандарт IEEE для двоичной арифметики с плавающей запятой определяет одинарную точность, для которой требуется 4 байт, и двойную точность, для которой требуется 8.

В C# эквивалентами являются структура System.Single, представленная ключевым словом float, и System.Double, представленная ключевым словом double.

Эти типы эквивалентны по размеру System.Int32 (4 bytes = 4 * 8bits = 32 bits), представленному ключевым словом int C#, и System.Int64 (8 bytes = 8 * 8bits = 64 bits), представленному ключевым словом long.

В одинарной точности числа хранятся в экспоненциальном представлении.

База всегда 2, поэтому есть 3 частей, которые могут меняться:

  • 1-битный знаковый бит (0 для +ve и 1 для -ve)
  • 8-битная экспонента (также известная как «мощность»)
  • 23-битная мантивная дробь (часть после двоичной точки)

Это означает, что их 1 + 8 + 23 = 32bits или 4 bytes всего.

s = sign, e = exponent, f = significand

Помните, как я сказал ранее, что 1 ВСЕГДА число слева от двоичной точки? Из-за этого это число не нужно сохранять, а сохраняется только 23-битная дробная часть мантиссы.

Однако, несмотря на то, что для мантиссы хранится только 23 битов, считается, что «точность» составляет 24 бит.

Смещенный показатель

8-битный показатель степени может находиться в диапазоне от 0 до 255. Вам нужно вычесть число (известное как «смещение») из показателя степени, чтобы получить фактическую используемую показатель степени со знаком.

Смещение равно 127 для одинарной точности.

Формулу одинарной точности можно записать так:

((-1)^s) * (1.f) * (2^(e-127))

Знаковый бит

В приведенной выше формуле, если s равно 0, число равно +ve, потому что все в степени 0 равно +1. Если s равно 1, то число отрицательное, потому что (-1)¹ = -1.

Нуль

+0:

Если s = 0, e = 0 и f = 0, число равно +0.

Это означает, что все 32 бита установлены на 0.

-0:

Если s = 1, e = 0 и f = 0, число равно -0.

Если знаковый бит установлен на 1, а все остальные биты равны 0, это указывает на отрицательное значение 0, что указывает на очень маленькое число, которое не может быть представлено только 32 битами, но все же меньше 0.

бесконечность

+ve бесконечность:

Если s = 0, e = 255 и f = 0, число равно +infinity.

-ve бесконечность:

Если s = 1, e = 255 и f = 0, число равно -infinity.

Не число:

Если e = 255 и f != 0, то значение NaN (не число), т.е. неизвестный номер или результат недопустимой операции.

Не нормализовано

Если e = 0 и f != 0, число допустимо, но не нормализовано:

((-1)s) * (0.f) * (2^–127)

Наименьшее число

Наименьшее число по величине, +ve или -ve, может быть представлено с помощью f = 0 и e = 1:

1.00000000000000000000000 * 2^–126 = 1.175494351 * 10^–38, который имеет малую величину, но высокую точность.

Наибольшее число

Вот еще раз формула для справки:((-1)^s) * (1.f) * (2^(e-127))

Наибольшее число — это когда s = 0 (положительное число), e = 254 (не может быть 255, потому что это бесконечность) и f все установлены на 1s. Если e = 254, то мощность 254–127=127.

11111111111111111111111 * 2¹²⁷ = 3.402823466 * 10³⁸, который имеет высокую величину, но низкую точность.

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

Точность

Поскольку точность составляет 24 бит, это означает, что числа с плавающей запятой одинарной точности имеют точность до одной части в 16'777'216 (или 2²⁴).

Чтобы понять, что это значит, давайте посмотрим на это число:

Если s = 0, f = 00000000000000000000000, e = 151 = 10010111 в двоичном формате

((-1)s) * (1.f) * (2e^-127) = 1 * 1.00000000000000000000000 * 2²⁴ = 1000000000000000000000000.0 = 16'777'216.

В памяти это будет сохранено как бит знака s, за которым следует показатель степени e, за которым следует мантиссан f:

0 10010111 00000000000000000000000

Что произойдет, если мы добавим 1 к мантиссе?

Если s = 0, f = 00000000000000000000001, e = 151 = 10010111 в двоичном формате

((-1)s) * (1.f) * (2(e^-127)) = 1.00000000000000000000001 * 2²⁴ = 1000000000000000000000010.0= 16'777'218.

Что случилось с 16'777'217?

Посмотрите на следующую диаграмму, чтобы понять, почему при добавлении единицы к мантиссу пропускается 16'777'217:

Как видно из диаграммы, любые биты, которые не помещаются в 23 битов дробной мантиссы, не могут быть сохранены.

Если бы мы хотели представить 16'777'217, мы бы установили для последнего 0 значение 1, но мы не можем получить доступ к этому биту, так как это будет 25-й бит, которого у нас просто нет в одинарной точности.

Как видите, из-за ограниченного количества битов одинарной точности мы не можем точно выразить числа между 16'777'216 и 16'777'218.

Фактически, любые числа, для которых требуются биты в позициях справа от последнего бита в мантиссе, не могут быть выражены.

Вот почему 16'777'216.5 и 16'777'217 и т. д. хранятся в памяти так же, как 16'777'216.

Если вы хотите представить 16'777'217, вам понадобится дополнительный бит в мантиссе для повышения точности:

Если s = 0, f = 000000000000000000000001 (24 бита вместо 23 бит), e = 151 = 10010111 в двоичном формате:

((-1)^s) * (1.f) * (2e^-127) = 1 * 1.000000000000000000000001 * 2²⁴ = 1000000000000000000000001.0 = 16'777'217

Чтобы представить еще более точные числа, такие как 16'777'216.5, вам нужно добавить еще один бит к мантиссе:

Если s = 0, f = 0000000000000000000000001 (25 бит вместо 24 бит), e = 151 = 10010111 в двоичном формате

((-1)^s) * (1.f) * (2e^-127) = 1 * 1.0000000000000000000000001 * 2²⁴ = 1000000000000000000000000.1 = 16'777'216.5

Пример в Visual Studio

В следующем примере показаны виды ошибок, которые могут произойти из-за отсутствия точности с типом System.Single в .NET.

В этом примере также показано, как число хранится в памяти.

Сложение этих двух чисел дает на единицу меньше, чем вы ожидали, потому что в памяти 16'777'216 и 16'777'217 хранятся как 16'777'216!