В этой статье я расскажу о математике, стоящей за типом 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
все установлены на 1
s. Если 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
!