Подпишитесь на мой телеграм-канал, там я пишу о дотнете и веб-разработке.

Друзья:
//devdigest platform - новости и полезные статьи о дотнете.

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

September 01, 2019

Поехали

В этой статье я разбираю, как Double хранится в памяти, но это не самостоятельная статья, в том плане, что вряд ли вы сможете разобраться с этой темой, прочитав одну её, поэтому я порекомендую ссылки на другие статьи (хотя и их тоже не назовёшь идеальными).

У Джона Скита есть две коротенькие статьи на эту тему, первая: Binary floating point and .NET - про double и float, вторая: Decimal floating point in .NET - про decimal (в этой статье я не касаюсь Decimal).

Сам Скит рекомендует к прочтению эту статью: Floating Point in .NET part 1: Concepts and Formats - она более подробная и позволит более основательно разобраться в вопросе.

Также в процессе подготовки этого материала я нашёл статью на русском: Взгляд со стороны: Стандарт IEEE754 - она в целом о стандарте хранения чисел IEEE754, статья основательная, но довольно абстрактная и её не назовёшь лёгким, доступным материалом.

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

Кратко о том как Double хранится в памяти

Double занимает в памяти 8 байт или 64 разряда в двоичном представлении. Старший разряд хранит знак числа - 0 обозначает положительное число, а 1 отрицательное. 11 следующих разрядов занимает часть, которую называют экспонента. Оставшиеся 52 - часть называемая мантисса. Комбинация этих трёх компонент в виде: знак * мантисса * 2 ^ экспонента, с небольшой предварительной манипуляцией над этими компонентами и будет представлять хранимое число. Есть разные классы хранимых чисел: нормализованные, субнормальные, бесконечность и Nan - они отличаются тем, как именно из хранимых компонент получается итоговое число.

Как можно посмотреть байтовое представление Double

Класс BitConverter позволяет получить байтовое представление базовых типов или наоборот преобразовать байтовое представление в базовый тип.

var bytesArray = BitConverter.GetBytes(1.0d); //Получить представление 1 типа double в виде массива байт

Console.WriteLine($"1.0d представленное как массив байтов: {string.Join("-", bytesArray)}"); 
// 1.0d представленное как массив байтов: 0-0-0-0-0-0-240-63

Console.WriteLine($"1.0d представленное как массив байтов в виде 16-ричных чисел: {BitConverter.ToString(bytesArray)}"); 
//1.0d представленное как массив байтов в виде 16-ричных чисел: 00-00-00-00-00-00-F0-3F

Double занимает 8 байтов в памяти и метод GetBytes возвращает нам массив из 8 элементов - всё сходится.

Но для разбора удобнее числа представлять в двоичной системе исчисления. Можно для этого каждый элемент массива представить в двоичном виде.

//Reverse используется для того, чтобы изменить обратный порядок байтов, который используется процессором, на прямой
var bytesArray = BitConverter.GetBytes(1.0d).Reverse();
//Convert.ToString не возвращает старшие разряды байта, равные 0, поэтому используем PadLeft
Console.WriteLine($"1.0d в двоичной системе исчисления: {string.Join("", bytesArray.Select(ba => Convert.ToString(ba, 2).PadLeft(8, '0')))}"); 
//1.0d в двоичной системе исчисления: 0011111111110000000000000000000000000000000000000000000000000000

А можно воспользоваться методом DoubleToInt64Bits - он возвращает 64 битное целое число, которое в двоичном виде соответствует байтовому представлению числа типа double.

var numberinlong = BitConverter.DoubleToInt64Bits(1.0d);
//Convert.ToString не возвращает старшие разряды 64 разрядного целого числа, равные 0, поэтому используем PadLeft
var numberbinary = Convert.ToString(numberinlong, 2).PadLeft(64, '0');
Console.WriteLine($"1.0d в двоичной системе исчисления: {numberbinary}");
//1.0d в двоичной системе исчисления: 0011111111110000000000000000000000000000000000000000000000000000

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

var sign = numberbinary[0];
var storedexponent = string.Concat(numberbinary.Skip(1).Take(11));
var storedmantissa = string.Concat(numberbinary.Skip(12).Take(52));
Console.WriteLine("sign   " + "stored exponent   " + "stored mantissa");
Console.WriteLine(sign.ToString().PadRight(7) + storedexponent.PadRight(18) + storedmantissa);
//sign   stored exponent   stored mantissa
//0      01111111111       0000000000000000000000000000000000000000000000000000

Как получаются нормализованные числа

Нормализованное число - это, по сути, способ кодирования в мантиссе и экспоненте чисел представленных типом Double и лежащих в определённом числовом диапазоне (всех кроме специальных и очень маленьких). Формула для получения итогового числа: М * 2 ^ е, где M - мантисса, а e - экспонента, но чтобы получить Мантиссу и Экспоненту из данных непосредственно хранимых в Double, нужно проделать некоторые манипуляции над ними.

В Double для мантиссы отводятся 52 разряда, ещё один старший разряд в нормализованных числах подразумевается и он всегда равен 1. Чтобы из мантиссы хранимой в Double получить настоящую мантиссу, которую нужно умножать на экспоненту, нужно добавить к ней ещё один разряд заполненный 1 и разделить на 2 в степени 52.

Например для числа 1.0d хранимая мантисса (мы её получили в примере выше): 0000000000000000000000000000000000000000000000000000 - это 52 разряда заполненные нулями, при добавлении ещё одного подразумеваемого разряда заполненного единицей получается: 10000000000000000000000000000000000000000000000000000 - это уже 53 разряда с единицей в старшем разряде, при делении этого числа на 2^52 результат будет 1.0000000000000000000000000000000000000000000000000000 в двоичной системе исчисления (и в данном случае, точно такой же в десятичной - 1.0) - при делении на 2^52 мы двигаем двоичное число вправо на 52 разряда за точку целого числа в итоге мантисса это всегда число вида: 1.xxx... - в двоичной системе исчисления, где “xxx…” часть хранится в мантиссе Double. При переводе в десятичную систему исчисления получается, что мантисса всегда лежит в диапазоне: 1 <= M < 2

Теперь поговорим о том, как из хранимой экспоненты, получить настоящую экспоненту.

Для хранения экспоненты в Double отводится 11 разрядов - 11 разрядов дают нам 2^11 = 2048 числа с учётом 0 (то есть наибольшее хранимое число это 2047), которые мы можем использовать в качестве экспоненты. Но так как мантисса у нас всегда лежит в диапазоне от 1 до 2, а с помощью нормализованных чисел мы представляем как числа по модулю и большие 2 и меньшие 1, то нам необходимо в этих 11 разрядах хранить также отрицательные экспоненты, которые позволят получить числа меньше 1. Для этого подразумевается, что хранимая экспонента - это сдвиг относительно числа 1023. Примеры:

  1. Если в экспоненте Double хранится число 1023, то значит реальная экспонента: 1023 - 1023 = 0.
  2. Если в экспоненте Double хранится число 1024, то значит реальная экспонента: 1024 - 1023 = 1.
  3. Если в экспоненте Double хранится число 1022, то значит реальная экспонента: 1022 - 1023 = -1.
  4. Если в экспоненте Double хранится число 0, то значит реальная экспонента… А вот так нельзя, 0 зарезервированное число и в нормализованных числах хранимая экспонента никогда не будет 0 - наименьшая хранимая экспонента - это 1, а значит наименьшая реальная экспонента 1 - 1023= -1022. Но это только в нормализованных числах, если экспонента всё-таки равна 0, то значит кодируемое число либо 0, либо субнормальное число.
  5. Если в экспоненте Double хранится число 2047 (максимальное возможное число для 11 разрядов), то значит реальная экспонента… А вот так тоже нельзя, 2047 тоже зарезервированное число - наибольшая хранимая экспонента нормализованного числа - это 2046, а значит наибольшая реальная экспонента 2046 - 1023 = 1023. Если вы всё-же видите в экспоненте 2047, то значит кодируемое число либо Nan, либо Infinity.

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

Самое большое число:

  • Самая большая реальная мантисса: 1.11111111 11111111 11111111 11111111 11111111 11111111 1111 или в десятичном виде 9 007 199 254 740 991 (53 разряда заполненные единицами) / 2^52 = 1.9999999999999997779553950749687
  • Самая большая реальная экспонента: 1022
  • Самое большое хранимое нормализованное число по нашим расчётам: 1.9999999999999997779553950749687 * 2^1022 = 1,797693134862315708145274237317e+308‬
  • И по официальным данным: 1.7976931348623157e+308

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

Самое маленькое положительное число:

  • Самая маленькая реальная мантисса: 1.0000000000000000000000000000000000000000000000000000
  • Самая маленькая реальная экспонента: -1022
  • Самое маленькое позитивное нормализованное число по нашим расчётам: 1.0 * 2^-1022 = 2,2250738585072013830902327173324e-308
  • И по официальным данным: 2.2250738585072010e-308

Если нужно будет хранить число меньше, то это будет сделано используя другой способ хранения числа, которое называется “субнормальное число”.

Любое нормализованное число из выше определённого диапазона нормализованных чисел может быть сделано отрицательным с помощью изменения старшего разряда в единицу в 64 разрядах типа данных Double.

Разберём некоторые нормализованные числа

Я написал сниппет, с помощью которого можно посмотреть разбор любого нормализованного числа:

var numberinlong = BitConverter.DoubleToInt64Bits(1.0d);
//Convert.ToString не возвращает старшие разряды 64 разрядного целого числа, равные 0, поэтому используем PadLeft
var numberbinary = Convert.ToString(numberinlong, 2).PadLeft(64, '0');

var sign = string.Concat(numberbinary.Take(1));
var storedexponent = string.Concat(numberbinary.Skip(1).Take(11));
var storedmantissa = string.Concat(numberbinary.Skip(12).Take(52));

var storedexponentinint = Convert.ToInt64(storedexponent, 2);
var realexponentinint = storedexponentinint - 1023;
var realmantissa = $"1{storedmantissa}";
var realmantissainint = Convert.ToInt64(realmantissa, 2);
var signmultiplyer = Convert.ToInt64(sign, 2) == 0 ? 1 : -1;

var resultnumber = signmultiplyer * realmantissainint / Math.Pow(2,52) * Math.Pow(2, realexponentinint);

Console.WriteLine("sign   " + "stored exponent   " + "stored mantissa");
Console.WriteLine(sign.ToString().PadRight(7) + storedexponent.PadRight(18) + storedmantissa);
Console.WriteLine();
Console.WriteLine($"{nameof(storedexponentinint)} = {storedexponentinint}");
Console.WriteLine($"{nameof(realexponentinint)} = {nameof(storedexponent)} - 1023 = {realexponentinint}");
Console.WriteLine($"{nameof(realmantissa)}: {realmantissa}");
Console.WriteLine($"{nameof(realmantissainint)}: {realmantissainint}");
Console.WriteLine($"{nameof(signmultiplyer)}: {signmultiplyer}");
Console.WriteLine($"{nameof(resultnumber)} = {nameof(signmultiplyer)} * {nameof(realmantissainint)} / 2^52 * 2^{nameof(realexponentinint)} = {signmultiplyer} * {realmantissainint} / 4503599627370496 * {Math.Pow(2, realexponentinint)} = {resultnumber}");

Запустить его в браузере вы можете с помощью try.dot.net, перейдя по этой ссылке и самостоятельно сделать разбор любого нормализованного числа.

1.0d:


//sign   stored exponent   stored mantissa
//0      01111111111       0000000000000000000000000000000000000000000000000000

//storedexponentinint = 1023
//realexponentinint = storedexponent - 1023 = 0
//realmantissa: 10000000000000000000000000000000000000000000000000000
//realmantissainint: 4503599627370496
//signmultiplyer: 1
//resultnumber = signmultiplyer * realmantissainint / 2^52 * 2^realexponentinint = 1 * 4503599627370496 / 4503599627370496 * 1 = 1

Реальная экспонента - 0, так как само число уже лежит в диапазоне от 1 до 2, поэтому можно сразу же сохранить мантиссу, откинув целую 1.

-1.0d:

//sign   stored exponent   stored mantissa
//1      01111111111       0000000000000000000000000000000000000000000000000000

//storedexponentinint = 1023
//realexponentinint = storedexponent - 1023 = 0
//realmantissa: 10000000000000000000000000000000000000000000000000000
//realmantissainint: 4503599627370496
//signmultiplyer: -1
//resultnumber = signmultiplyer * realmantissainint / 2^52 * 2^realexponentinint = -1 * 4503599627370496 / 4503599627370496 * 1 = -1

Отличается от 1.0d только знаком в старшем разряде Double.

1.1d:

//sign   stored exponent   stored mantissa
//0      01111111111       0001100110011001100110011001100110011001100110011010

//storedexponentinint = 1023
//realexponentinint = storedexponent - 1023 = 0
//realmantissa: 10001100110011001100110011001100110011001100110011010
//realmantissainint: 4953959590107546
//signmultiplyer: 1
//resultnumber = signmultiplyer * realmantissainint / 2^52 * 2^realexponentinint = 1 * 4953959590107546 / 4503599627370496 * 1 = 1.1

Число тоже лежит в диапазоне между 1 и 2, поэтому реальная экспонента будет 0.

Попробуем сами получить хранимую мантиссу:

1.1 * 2^52 = 4 953 959 590 107 545.6‬ округляем до 4 953 959 590 107 546 в двоичном виде: 1 0001 10011001 10011001 10011001 10011001 10011001 10011010 отбрасываем старший разряд и получаем искомое 0001100110011001100110011001100110011001100110011010

1000000d:

//sign   stored exponent   stored mantissa
//0      10000010010       1110100001001000000000000000000000000000000000000000

//storedexponentinint = 1042
//realexponentinint = storedexponent - 1023 = 19
//realmantissa: 11110100001001000000000000000000000000000000000000000
//realmantissainint: 8589934592000000
//signmultiplyer: 1
//resultnumber = signmultiplyer * realmantissainint / 2^52 * 2^realexponentinint = 1 * 8589934592000000 / 4503599627370496 * 524288 = 1000000

Попробуем сами получить хранимую мантиссу и экспоненту:

Сначала нам нужно привести число к виду: М * 2^e - так чтобы M лежала между 1 и 2. Это будет: 1,9073486328125 * 2^19.

Теперь нам известна реальная экспонента - 19, для того, чтобы получить хранимую нужно к 1023 прибавить 19 итого получается: 1042 - это хранимая экспонента.

Нам также известна реальная мантисса - это 1,9073486328125. Для того, чтобы получить хранимую мантиссу умножим её на 2^52: 1,9073486328125 * 2^52 = 8 589 934 592 000 000. В двоичном виде: 1 1110 1000 0100 1000 0000 0000 0000 0000 0000 0000 0000 0000 0000. Отбросим старший разряд и получим хранимую мантиссу: 1110 1000 0100 1000 0000 0000 0000 0000 0000 0000 0000 0000 0000.

0.1d:

//sign   stored exponent   stored mantissa
//0      01111111011       1001100110011001100110011001100110011001100110011010

//storedexponentinint = 1019
//realexponentinint = storedexponent - 1023 = -4
//realmantissa: 11001100110011001100110011001100110011001100110011010
//realmantissainint: 7205759403792794
//signmultiplyer: 1
//resultnumber = signmultiplyer * realmantissainint / 2^52 * 2^realexponentinint = 1 * 7205759403792794 / 4503599627370496 * 0.0625 = 0.1

Попробуем сами получить хранимую мантиссу и экспоненту:

Сначала нам нужно привести число к виду: М * 2^e - так чтобы M лежала между 1 и 2. Это будет: 1,6 * 2^-4.

Теперь нам известна реальная экспонента - это -4, для того, чтобы получить хранимую нужно к 1023 прибавить -4 итого получается: 1019 - это хранимая экспонента.

Нам также известна реальная мантисса - это 1,6. Для того, чтобы получить хранимую мантиссу умножим её на 2^52: 1,6 * 2^52 = 7 205 759 403 792 793.6‬ после округления 7 205 759 403 792 794. В двоичном виде: 1 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1010‬. Отбросим старший разряд и получим хранимую мантиссу: 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1010‬.

Подведём итоги

Надеюсь вам стало понятнее, как хранится Double в памяти. Я не разобрал субнормальные числа и специальные числа (0, Infinity, Nan), как хранятся они вы можете прочитать в статьях по ссылкам, которые я дал в начале этой статьи. В статье Floating Point in .NET part 1: Concepts and Formats разбирается более подробно тип данных Float, который в этой статье я не касаюсь. Также теперь вы знаете как посмотреть, что в реальности хранит Double в памяти и знаете как из этих данных получить хранимое число и наоборот как из желаемого числа получить вид в котором оно должно хранится в памяти. Ну и пользуйтесь сниппетом для того, чтобы разобрать как хранится любое нормализованное Double число в памяти.

Дополнительные ресурсы для чтения