Почему (2.55).toFixed(1) равно 2,5?

JavaScript
Почему (2.55).toFixed(1) равно 2,5?

В прошлый раз я столкнулся со странной проблемой: вывод JS (2.55).toFixed(1) был 2,5, а не округленный 2,6, почему?

Дальнейшие наблюдения:


Выясняется, что не все ненормально.Округление 1,55 все-таки правильное.Почему 2,55 и 3,45 неверны?

Это требует от нас найти ответ в исходном коде.

В V8 существует два типа хранения чисел.Один — Smi для небольших целых чисел, а другой — для всех чисел, кроме небольших целых чисел.При использовании HeapNumber Smi помещается непосредственно в стек, тогда как HeapNumber требует, чтобы новый применялся к памяти Да, положить в кучу. Мы можем просто нарисовать расположение кучи и стека в памяти:


Следующий код:

let obj = {};

Здесь определяется переменная obj obj — это указатель, который является локальной переменной и помещается в стек. Фигурные скобки {} создают экземпляр объекта, пространство, которое объект должен занимать, представляет собой память, выделенную в куче, а obj указывает на расположение памяти.

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

Для следующего кода:

let smi = 1;

smi — это число типа Number. Если такое простое число также помещается в кучу, а затем создается указатель, указывающий на него, оно того не стоит ни с точки зрения места для хранения, ни с точки зрения эффективности чтения. Итак, V8 создал класс с именем Smi. Этот класс не будет создан. Его адрес указателя — это значение числа, которое он хранит, а не место в куче. Поскольку сам указатель является целым числом, его можно использовать как целое число.И наоборот, это целое число можно преобразовать в указатель экземпляра Smi, а функцию, определенную классом Smi, можно настроить, например, получить фактическое целочисленное значение.

Комментарии для следующего исходного кода:

// Smi represents integer Numbers that can be stored in 31 bits.
// Smis are immediate which means they are NOT allocated in the heap.
// The this pointer has the following format: [31 bit signed int] 0
// For long smis it has the following format:
//     [32 bit signed int] [31 bits zero padding] 0
// Smi stands for small integer.

В общей системе int составляет 32 бита, и первые 31 бит используются для представления значения целого числа (включая знак), а если это 64 бита, первые 32 бита используются для представления значения целого числа. . Таким образом, когда имеется 32 бита, для представления данных остается 31 бит минус знаковый бит, и остается 30 бит, поэтому максимальное целое число Smi равно:

2^30 - 1 = 1073741823 = 1 миллиард

Около 1 млрд.

В этот момент у вас может возникнуть вопрос, а зачем вам столько хлопот, вместо того, чтобы напрямую использовать базовые типы типа int для его хранения, нужно создать класс Smi? Это может быть связано с тем, что представление данных JS в V8 унаследовано от объекта корневого класса (обратите внимание, что объект здесь не является объектом JS, объект JS соответствует объекту JSObject из V8), так что некоторая общая обработка может быть Готово. Таким образом, небольшие целые числа также должны создавать класс, но они не могут быть созданы, поэтому используется этот метод — использование указателей для хранения значений.

С помощью HeapNumber хранится более 2,1 миллиарда десятичных знаков. Как и JSObject, данные хранятся в куче. Содержимое, хранящееся в HeapNumber, представляет собой число с плавающей запятой двойной точности, то есть 8 байтов = 2 слова = 64 бита. Что касается структуры хранения чисел с плавающей запятой двойной точности, у меня естьПочему 0,1 + 0,2 не равно 0,3?» дал подробное представление. Здесь можно кратко упомянуть, например, определение исходного кода:

  static const int kMantissaBits = 52;
  static const int kExponentBits = 11;

В 64-битном формате мантисса занимает 52 бита, экспонента использует 11 бит, а один бит является знаковым. Когда это пространство двойной точности используется для представления целых чисел, это пространство 52-битных мантисс, потому что целые числа могут быть точно представлены в двоичном виде, поэтому 52-битная мантисса плюс 1 из скрытых целочисленных битов (что это за 1 ? прийти для справкиПредыдущий) может представлять максимальное значение 2^53 - 1:

// ES6 section 20.1.2.6 Number.MAX_SAFE_INTEGER
const double kMaxSafeInteger = 9007199254740991.0;  // 2^53-1

Это 16-разрядное целое число, поэтому мы знаем, что точное количество цифр в числе двойной точности с плавающей запятой равно 15, и вероятность того, что 16-я цифра является точной, составляет 90%.


Таким образом, мы знаем, как число хранится в V8. Для версии 2.55 используются числа с плавающей запятой двойной точности, а 64-битное хранилище версии 2.55 выводится следующим образом:

Для (2.55).toFixed(1) исходный код такой: сначала вынимается целочисленный бит 2, преобразуется в строку, затем вынимается десятичный разряд и округляется в соответствии с указанным количеством цифр по параметру, а затем в середине Укажите десятичную точку, и вы получите округленный результат строки.

Как получить целую часть? Мантисса числа 2,55 (плюс скрытая 1) — это число a:

1.01000110011...

Его бит экспоненты равен 1, поэтому сдвиг этого числа на один влево дает число b:

10.1000110011...

a изначально составляет 52 бита, и сдвиг на 1 бит влево становится 53-битным числом, а затем сдвиг b вправо на 52 - 1 = 51 бит, целая часть равна 10 в двоичном формате или 2 в десятичном. Затем вычтите 10 из b и сдвиньте значение влево на 51 бит, чтобы получить дробную часть. Фактический процесс расчета выглядит следующим образом:

// 尾数右移51位得到整数部分
uint64_t integrals = significand >> -exponent; // exponent = 1 - 52
// 尾数减掉整数部分得到小数部分
uint64_t fractionals = significand - (integrals << -exponent);

Следующий вопрос — как преобразовать целые числа в строки? Исходный код выглядит так:

static void FillDigits32(uint32_t number, Vector<char> buffer, int* length) {
  int number_length = 0;
  // We fill the digits in reverse order and exchange them afterwards.
  while (number != 0) {
    char digit = number % 10;
    number /= 10;
    buffer[(*length) + number_length] = '0' + digit;
    number_length++;
  }
  // Exchange the digits.
  int i = *length;
  int j = *length + number_length - 1;
  while (i < j) {
    char tmp = buffer[i];
    buffer[i] = buffer[j];
    buffer[j] = tmp;
    i++;
    j--;
  }
  *length += number_length;
}

Он состоит в том, чтобы непрерывно модулировать это число на 10, чтобы получить однозначную цифру, и добавить код ascii цифры 0, чтобы получить код ascii, состоящий из одной цифры, который является типом char. В C/C++/Java/Mysql char представляет собой переменную, представленную одинарными кавычками, а байт используется для представления символа ascii. Фактическое сохраняемое значение представляет собой его код ascii, поэтому его можно преобразовать в целые числа и из них, например как «0» + 1, чтобы получить «1». Каждый раз, когда вы получаете одну цифру, разделите ее на 10, что эквивалентно сдвигу на одну цифру вправо в десятичной дроби, а затем продолжите обработку следующей одиночной цифры и продолжайте помещать ее в массив символов (обратите внимание, что целочисленное деление в С++ десятичные числа округляются, а не как в JS).

Наконец, снова переверните массив, потому что после описанной выше обработки одна цифра ушла вперед.


Как поворачивается дробная часть? Как показано в следующем коде:

int point = -exponent; // exponent = -51
// fractional_count表示需要保留的小数位,toFixed(1)的话就为1
for (int i = 0; i < fractional_count; ++i) {
  if (fractionals == 0)
    break;
  fractionals *= 5; // fractionals = fractionals * 10 / 2;
  point--;
  char digit = static_cast<char>(fractionals >> point);
  buffer[*length] = '0' + digit;
  (*length)++;
  fractionals -= static_cast<uint64_t>(digit) << point;
}
// If the first bit after the point is set we have to round up.
if (((fractionals >> (point - 1)) & 1) == 1) {
  RoundUp(buffer, length, decimal_point);
}

Если это toFixed(n), то первые n знаков после запятой будут преобразованы в строку, а затем необходимо увеличить значение n + 1 цифр.

При преобразовании первых n знаков после запятой в строку сначала умножьте знаки после запятой на 10, а затем сдвиньте вправо на 50 + 1 = 51 знак, чтобы получить первый знак после запятой (код умножается на 5, в основном с целью избежать переполнение). После умножения десятичного знака на 10 первый десятичный знак переходит в целое число, а затем 51 цифра исходной мантиссы сдвигается вправо, чтобы потерять десятичный знак, потому что оставшиеся 51 цифра должны быть десятичной частью, так что только что получил первое десятичное число. Затем вычтите целую часть, чтобы получить дробную часть, оставшуюся после удаления десятичного знака 1. Поскольку он зацикливается только один раз, он выпрыгивает из цикла.

Затем решается, нужно ли округление.Условие его решения - равен ли 1-й бит оставшейся мантиссы 1, если да, то она войдет в 1, иначе не будет обработана. После вычитания 1-го десятичного знака выше остается 0,05:

На самом деле сохраняется значение не 0,05, а чуть меньше 0,05:

Поскольку 2,55 не является точно представимым, а 2,5 точно представимым, 2,55 - 2,5 даст вам значение, хранящееся в 0,05. Видно, что он действительно меньше 0,05.

Согласно оценке исходного кода, если первая цифра оставшейся мантиссы не равна 1, перенос выполняться не будет.Поскольку первая цифра оставшейся мантиссы равна 0, она не будет переноситься, поэтому результат ввода ( 2.55).toFixed(1) равно 2.5 .

Основная причина заключается в том, что хранилище 2,55 немного меньше, чем фактическое хранилище, в результате чего 1-я мантисса 0,05 не равна 1, поэтому она отбрасывается.


тогда что нам делать? Не можете использовать toFixed?

Зная причину, можем внести поправку:

if (!Number.prototype._toFixed) {
    Number.prototype._toFixed = Number.prototype.toFixed;
}
Number.prototype.toFixed = function(n) {
    return (this + 1e-14)._toFixed(n);
};

Просто добавьте в Fixed очень маленькую десятичную дробь, эта десятичная дробь проверена, подойдет 1e-14. Какое влияние это может иметь, и вызовет ли это перенос, который не должен был переноситься? Добавление 14-битного десятичного числа может привести к 13-битному раунду. Но если разница между двумя числами составляет 1e-14, два числа можно считать почти равными, поэтому эффект от их сложения практически незначителен, если только вам не требуется очень высокая точность. Это число немного хуже, чем Number.EPSILON:

После этого toFixed становится нормальным:


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