The story goes like this

Xiao Zheng is an UP 🐷 on a certain platform. After two years of efforts, there are 994500 fans on the platform now, which is only one step away from hitting one million UP.

Gold Lord father told him, as long as millions, let him take a big advertising sheet.

But recently no inspiration, every day 🕊, always do not rise powder.

So he hit up the wrong idea, buy powder! First to some treasure to buy 1000 fans to try the water, soon, the other party said that has been accounted.

Small zheng excitedly on the platform on a look, and no, or 99.5, 1 thousand fans to show where?

Is preparing to complain about the business, the mouse slide to see such a scene!

The specific number of fans is indeed correct, according to the rounddown taught in primary school, it should show 996,000.

Is it a platform bug? Puzzled small zheng plan to ask programmer friend small cover, small cover a look:

“This should be used toFixed, this method has a pit… let me elaborate.”

The above story is pure fiction (including pictures)

What is toFixed

The following definition is taken from MDN

numObj.toFixed(digits)
Copy the code

Returns a string specifying the digits, rounded if necessary. Look at the following example

99.45.toFixed(1);   / / "99.5"
99.99.toFixed(1);   / / "100.0"
99.55.toFixed(1);   // "99.5" warning: See explanation below
Copy the code

Do you find the third example not quite right?

We know that floating point numbers in JS are internally represented by double 64-bit, using IEEE 754 notation,

So 99.55 is actually 99.54999999999999997

There are online tools that you can use to figure it out

So the answer is pretty obvious. Round it up to 99.5

So, get the answer, end of article?

What does the ECMAScript specification say

Number.prototype.toFixed ( fractionDigits )

In the red area, we need to find an n that is as close to zero as possible to n / 10-99.55. Once you find n, everything else is fixed.

(Well, it looks like it’s complicated, not directly rounding decimal places.)

Suppose n is 995, then m is “995”, k = M.length = 3, a = “99”, b = “5”, and the final result is “99.5”.

So is n 995?

There are two numbers that satisfy the condition that n / 10-99.55 is as close to zero as possible: 995,996 and their calculation results are:

995/10 - 99.55 // -0.04999999999999716
996/10 - 99.55 //  0.04999999999999716
Copy the code

The difference between 0 and 0 is the same, so if I pick the larger value, I get 996, but I get 99.5. Why is that? Is the browser engine implementation wrong? Or is it not implemented according to specification?

The result is 995. Let’s take a look at the JavaScriptCore (Webkit’s javascript engine) implementation

JavaScriptCore toFixed source

In its Source code/Source/JavaScriptCore/runtime/NumberPrototype numberProtoFuncToFixed method of CPP

In JavaScriptCore, prototype methods are easy to find, which is the structure of xxxProtoFuncXxx

Debugging environment

(You can choose to skip this summary and directly see the following analysis and summary)

Compiling WebKit on macOS is much easier than v8, see Setup and Debug JavaScriptCore/WebKit

Run the following command to go to the debug environment

Debug JSC with LLDB
$ lldb ./WebKitBuild/Debug/bin/jsc
# start debugging
(lldb) run
>>>
# control + c enter LLDB again
# break point, breakpoint method type a few characters press Tab to prompt
(lldb): b JSC::numberProtoFuncToFixed(JSC::JSGlobalObject*, JSC::CallFrame*)
(lldb): b WTF::double_conversion::FastFixedDtoa(double, int, WTF::double_conversion::BufferReference<char>, int*, int*) 
To finish debugging, cut to JSC, press Enter twice
(lldb): c
Enter the js code and press Enter to enter the LLDB debugging environment> > > 99.55. ToFixed (1)# EncodedJSValue JSC_HOST_CALL numberProtoFuncToFixed(JSGlobalObject* globalObject, CallFrame* callFrame)
Copy the code

JSC common directives

describe(x) # view the internal description, structure, and memory address of the object
Copy the code

Common LLDB commands

Chisel enhancement plugin, more usage methods to write a follow-up article,

x/8gx address # check memory address

next(n) # step
step(s) # enter function
continue(c) # Run the program to the end or breakpoint (to the next breakpoint)
finish Return (exit from function)Breakpoint (b) < conditional statement ># set breakpoint
fr v View local variable information
print(p) x Print the value of variable x
Copy the code

Source code analysis

Entry, handling of various situations

EncodedJSValue JSC_HOST_CALL numberProtoFuncToFixed(JSGlobalObject* globalObject, CallFrame* callFrame)
{
    VM& vm = globalObject->vm();
    auto scope = DECLARE_THROW_SCOPE(vm);

    // x 取值 99.549999999999997
    double x;
    if(! toThisNumber(vm, callFrame->thisValue(), x))return throwVMToThisNumberError(globalObject, scope, callFrame->thisValue());

    // decimalPlaces The value is 1
    int decimalPlaces = static_cast<int>(callFrame->argument(0).toInteger(globalObject));
    RETURN_IF_EXCEPTION(scope, { });

    // Special processing, omitted
    if (decimalPlaces < 0 || decimalPlaces > 100)
        return throwVMRangeError(globalObject, scope, "toFixed() argument must be between 0 and 100"_s);

    // special processing of x, omitted
    if(! (fabs(x) < 1e+21))
        return JSValue::encode(jsString(vm, String::number(x)));

    // Special handling of NaN or Infinity
    ASSERT(std::isfinite(x));

    // Run number=99.549999999999997, decimalPlaces=1
    return JSValue::encode(jsString(vm, String::numberToStringFixedWidth(x, decimalPlaces)));
}
Copy the code

You go from the numberToStringFixedWidth method to the FastFixedDtoa method

It should be noted that the integer and fractional parts of the original value are expressed in exponential notation, which is convenient for the subsequent bit operation

99.549999999999997 = 7005208482886451 * 2 ** -46 = 99 + 38702809297715 * 2 ** -46

// FastFixedDtoa(v=99.549999999999997, fractional_count=1, buffer=(start_ = "", length_ = 122), length=0x00007ffeefbfd488, decimal_point=0x00007ffeefbfd494)

bool FastFixedDtoa(double v,
                   int fractional_count,
                   BufferReference<char> buffer,
                   int* length,
                   int* decimal_point) {
  const uint32_t kMaxUInt32 = 0xFFFFFFFF;
  // Write v as the mantisand times the base (2) ^ (exponent).
  // 7005208482886451 x 2 ^ -46
  uint64_t significand = Double(v).Significand();
  int exponent = Double(v).Exponent();

  // Omit some code

  if (exponent + kDoubleSignificandSize > 64) {
    // ...
  } else if (exponent >= 0) {
    // ...
  } else if (exponent > -kDoubleSignificandSize) {
    // If exponent > -53, cut the number

    // Integral parts: Integrals = 7005208482886451 >> 46 = 99
    uint64_t integrals = significand >> -exponent;
    // Fractionals = 7005208482886451-99 << 46 = 38702809297715
    // The index remains the same -46
    // 38702809297715 * (2 ** -46) = 0.5499999999999972
    uint64_t fractionals = significand - (integrals << -exponent);
    if (integrals > kMaxUInt32) {
      FillDigits64(integrals, buffer, length);
    } else {
      // Add "99" to buffer
      FillDigits32(static_cast<uint32_t>(integrals), buffer, length);
    }
    *decimal_point = *length;
    // Fill in the decimal part, buffer is "995"
    FillFractionals(fractionals, exponent, fractional_count,
                    buffer, length, decimal_point);
  } else if (exponent < - 128.) {
    // ...
  } else {
    // ...
  }
  TrimZeros(buffer, length, decimal_point);
  buffer[*length] = '\ 0';
  if ((*length) == 0) {
    // The string is empty and the decimal_point thus has no importance. Mimick
    // Gay's dtoa and and set it to -fractional_count.
    *decimal_point = -fractional_count;
  }
  return true;
}

Copy the code

FillFractionals is used to fill the decimal part, take the number of digits, whether or not carry is handled in this method

// FillFractionals(fractionals=38702809297715, exponent=-46, fractional_count=1, buffer=(start_ = "99���", length_ = 122), length=0x00007ffeefbfd488, decimal_point=0x00007ffeefbfd494)


/* Binary representation of the decimal part: Fractionals * 2 ^ - Exponent 38702809297715 * (2 ** -46) = 0.54999999999972 0 <= fractionals * 2 ^ exponent < 1 buffer can hold results. During rounding, numbers not generated by this function may be updated, and decimal variables may be updated. If this function generates the number 99, and the buffer already contains "199" (hence the resulting buffer "19999"), rounding up changes the contents of the buffer to "20000". * /
static void FillFractionals(uint64_t fractionals, int exponent,
                            int fractional_count, BufferReference<char> buffer,
                            int* length, int* decimal_point) {
  ASSERT(- 128. <= exponent && exponent <= 0);
  if (-exponent <= 64) { 
    ASSERT(fractionals >> 56= =0);
    int point = -exponent; / / 46

    // At each iteration, multiply the decimal number by 10 and put the integer portion into buffer

    for (int i = 0; i < fractional_count; ++i) { / / 0 - > 1
      if (fractionals == 0) break;

      // Multiply fractionals by 5 instead of 10, and adjust the position of point so that the fractionals variable will not overflow. And then all of that is the same thing as multiplying by 10
      // No overflow validation:
      // Start the loop: fractionals < 2 ^ point, point <= 64 and fractionals < 2 ^ 56
      // Point -- after each iteration.
      // Note that 5 ^ 3 = 125 < 128 = 2 ^ 7.
      // Therefore, the three iterations of this loop do not overflow fractionals (even if there is no subtraction at the end of the loop body).
      // Meanwhile point will satisfy point <= 61, so fractionals < 2 ^ point, and fractionals multiplied by 5 will not overflow (


      // This operation does not overflow, as shown above
      fractionals *= 5; / / 193514046488575
      point--; / / 45
      int digit = static_cast<int>(fractionals >> point); // 193514046488575 * 2 ** -45 = 5
      ASSERT(digit <= 9);
      buffer[*length] = static_cast<char> ('0' + digit); / / '995'
      (*length)++;
      // Remove integer bits
      fractionals -= static_cast<uint64_t>(digit) << point; // 193514046488575-5 * 2 ** 45 = 17592186044415
      // 17592186044415 * 2 ** -45 = 0.4999999999999716 
    }
    // See if the next bit of the decimal is worth carrying the elements in the buffer
    // Multiply by 2 to see if you can >=1
    ASSERT(fractionals == 0 || point - 1> =0);
    // In this example, 17592186044415 >> 44 = 17592186044415 * 2 ** -44 = 0.9999999999999432, & 1 = 0
    if((fractionals ! =0) && ((fractionals >> (point - 1)) & 1) = =1) { RoundUp(buffer, length, decimal_point); }}else {  // We need 128 bits.
    // ...}}Copy the code

This gives us 995, the n in the specification, followed by a decimal point which gives us 99.5

summary

The JS engine does not look for an n, as the specification says, so that n/(10 ^ f) is as close to x as possible, which feels too slow. Instead, x is divided directly into integer and fractional parts and calculated separately using exponential representation.

When you’re dealing with decimals, you’re just moving the decimal to the right. When we use exponential notation, one of the details is that we consider that base *10 directly may cause overflow, and then we use base *5, exponential decression, which is proved in the notes. After the f bit is computed, the next bit is computed to see if a carry is required.

Of course, the final result is not consistent with our daily calculation, the core is still in IEEE 754 representation

99.55 is 99.54999999999999997 at the beginning of debugging

Therefore, when using the toFixed method in the future, if you are worried about not rounding properly, check the online tool first

V8 toFixed source

v8 toFixed

Here is the entry, which is no longer analyzed. It is similar to JavaScriptCore, so interested readers can check it out for themselves

// ES6 section 20.1.3.3 number.prototype.tofixed (fractionDigits)
BUILTIN(NumberPrototypeToFixed) {

  / /... Omit parameter parsing, unpacking, type judgment
  
  // value_number and fraction_digits_number are our target values
  // Assume value_number = 99.55, fraction_digits_number = 1.0
  double const value_number = value->Number();
  double const fraction_digits_number = fraction_digits->Number();

  / /... Ellipsis range check

  / /... Omit value_number special value handling: Infinity NaN

  // The actual handler is DoubleToFixedCString
  char* const str = DoubleToFixedCString(
      value_number, static_cast<int>(fraction_digits_number));
  Handle<String> result = isolate->factory()->NewStringFromAsciiChecked(str);
  DeleteArray(str);
  return *result;
}
Copy the code

Correct “rounding”

So how do you write a logical rounding method? We can do this with the math.round method

Math.round(x)

The value x of a given number is rounded to the nearest integer.

  • If the fractional part of x is greater than 0.5, the adjacent integers with greater absolute values are rounded.
  • If the fractional part of x is less than 0.5, it is rounded to adjacent integers with smaller absolute values.
  • If the decimal part of the argument is exactly 0.5, it is rounded to adjacent integers in the direction of positive infinity (+∞).

    ⚠️ Note: Unlike the round() function in many other languages, math.round () is not always rounded away from zero (especially if the fractional part of a negative number is exactly 0.5).

For example,

Math.round(99.51) / / 100
Math.round(99.5) / / 100
Math.round(99.49) / / 99
Math.round(99.51) / / - 100
Math.round(99.5) / / - 99
Math.round(99.49) / / - 99
Copy the code

code

// Use division. If you multiply by a decimal, the decimal is not exact (again, ieee 754 notation).
// 996 * 0.1 = 99.60000000000001
function round(number, precision=0) {
    return Math.round(+number + 'e' + precision) / (10 ** precision)
    //same as:
    //return Number(Math.round(+number + 'e' + precision) + 'e-' + precision);
}

round(99.55.1) / / 99.6
round(99.5.0) / / - 99
Copy the code

Negative numbers, such as -99.5 rounded to the other platforms, are -100

/** ** @param {*} number * @param {*} precision * @param {Boolean} flag Whether negative numbers are rounded as far away from 0 */
function round(number, precision = 0, flag = false) {
    if (flag && number < 0) {
        return -round(Math.abs(number), precision)
    }
    return Math.round(+number + 'e' + precision) / (10 ** precision)
}
round(99.55.1) / / 99.6
round(99.55.1) // -99.5
round(99.55.1.true) // -99.6
Copy the code

Before, I wanted to say that we also need to consider overflow, because we found that round and toFixed realized by ourselves did not meet expectations

round(999999999955.2376236232.6) / / 999999999955.2378
999999999955.2376236232.toFixed(6) / / "999999999955.237671
Copy the code

Later found that the number 999999999955.2376236232 cannot be expressed in a 64 – bit, can only say 9.999999999552376708984375 e11

So, we’re not going to consider the overflow case.

PS: One of the methods found during processing is math. trunc, which takes integer parts directly, regardless of whether they are positive or negative, unlike Math.floor, which rounds down negative numbers

Back to the question, how does the platform fix this bug

The number of followers displayed on the platform follows the following principles:

  1. If the value is less than 10,000, it is displayed directly
  2. If the value is less than 100 million, keep one decimal part rounded. If the decimal part is 0, it is not displayed
  3. If the value is greater than or equal to 100 million, keep one decimal part rounded. If the decimal part is 0, the display is not displayed

Operate on a wave using the round function you just wrote

function round(number, precision = 0, flag = false) {
    if (flag && number < 0) {
        return -round(Math.abs(number), precision)
    }
    return Math.round(+number + 'e' + precision) / (10 ** precision)
}
const formatNumForAvatar = num= > {
    if (num >= 1e+8) {
        return {
            num: round(num / 1e+8.1),
            unit: '亿'}}if (num >= 1e+4) {
        return {
            num: round(num / 1e+4.1),
            unit: "万"}}return {
        num: num <= 0 ? 0 : num
    }
}
/** * Test case */

formatNumForAvatar(9999) // {num: 9999}
formatNumForAvatar(99999) // {num: 10, unit: "10 "}
formatNumForAvatar(995500) // {num: 99.6, unit: "000 "}
formatNumForAvatar(99999900) // {num: 10000, unit: "10000 "}
formatNumForAvatar(109999900) // {num: 1.1, unit: ""}
Copy the code

There is still a problem, not dealing with the case of 10 million

The idea is to add judgment criteria, or hard code

function round(number, precision = 0, flag = false) {
    if (flag && number < 0) {
        return -round(Math.abs(number), precision)
    }
    return Math.round(+number + 'e' + precision) / (10 ** precision)
}
const formatNumForAvatar = num= > {
    // handle the case of 99995000+
    if (num >= 1e+8 - 5000) {
        return {
            num: round(num / 1e+8.1),
            unit: '亿'}}if (num >= 1e+4) {
        return {
            num: round(num / 1e+4.1),
            unit: "万"}}return {
        num: num <= 0 ? 0 : num
    }
}
/** * Test case */

formatNumForAvatar(9999) // {num: 9999}
formatNumForAvatar(99999) // {num: 10, unit: "10 "}
formatNumForAvatar(995500) // {num: 99.6, unit: "000 "}
formatNumForAvatar(99994999) // {num: 9999.5, unit: "10000 "}
formatNumForAvatar(99999900) // {num: 1, unit: ""}
formatNumForAvatar(109999900) // {num: 1.1, unit: ""}
Copy the code

If there is a better way, please comment

The last

It has been about 2 weeks since I found the problem and wrote this article. The main reason is that I have no idea about JS engine debugging before

It took several nights just to set up the v8 debug environment, including GDB pit on macOS, v8 build pit, breakpoint debug pit, how to work with vscode, etc… There will be a few more articles on this, so stay tuned

If it is on macOS, it is recommended to look at JavaScriptCore source code. These basic methods are implemented mostly in the same way as V8

Reference documentation

  • JavaScriptCore compilation debugging
  • Debugging its