從「消失的 1 千粉絲」談及 toFixed 的坑

故事是這樣的

小鄭是某平臺一位 UP 🐷,通過兩年努力如今平臺粉絲也有 994500 個了,距離衝擊百萬 UP 僅剩一步之遙。javascript

99.45w

金主爸爸告訴他,只要百萬,就讓他接個大的廣告單。html

奈何最近沒有靈感,每天🕊,始終不漲粉。java

因而他便打起了歪主意,買粉!先到某寶上買了 1 千粉絲試試水,很快,對方說已到帳。git

小鄭興沖沖的上平臺上一看,並無,仍是顯示的 99.5 ,1 千個粉絲去哪了??程序員

正準備投訴商家時,鼠標下滑看到了這樣的一幕!web

99.55w

具體粉絲數確實是正確的,按照小學教的的四捨五入,應該顯示 99.6萬 纔對。瀏覽器

難道是平臺的 bug ?不解的小鄭打算向程序員朋友小蓋詢問,小蓋一看:bash

「 這應該是用了 toFixed 了吧,這方法有個坑。。容我細細道來 」ecmascript

以上故事純屬虛構(包括圖片)函數

toFixed 是什麼

如下引自 MDN 的定義

numObj.toFixed(digits)
複製代碼

返回指定 digits 位數的字符串,必要時會進行四捨五入。看如下例子

99.45.toFixed(1);   // "99.5"
99.99.toFixed(1);   // "100.0"
99.55.toFixed(1);   // "99.5" warning: 見下面解釋
複製代碼

是否是發現第三個例子不太對?

咱們知道,js 中的浮點數內部是用雙精度 64位(double)表示的,採用的是 IEEE 754 表示法

因此 99.55 實際是 99.549999999999997

網上有在線工具能夠直接算出來

那麼很明顯能夠看出來答案,四捨五入結果就是 99.5

so,獲得答案了,本文結束?

咱們再看下 ecmascript 規範是怎麼描述的

Number.prototype.toFixed ( fractionDigits )

toFixed

關鍵看紅色區域,咱們須要找個一個 n ,使其 n / 10 - 99.55 儘量接近零。 找到 n 以後後面的結果就都肯定了。

(額,看起來好像很複雜的樣子,不是直接對小數位四捨五入。。)

假設 n 爲 995,則 m 爲 "995", k = m.length = 3, a = "99", b = "5", 最終結果爲 "99.5"

那麼 n 是否是 995 呢?

知足 n / 10 - 99.55 儘量接近零這個條件的有兩個數: 995,996 而他們的計算結果分別爲:

995/10 - 99.55 // -0.04999999999999716
996/10 - 99.55 // 0.04999999999999716
複製代碼

和 0 間的差值是同樣的,選大的結果是 996 ,可咱們運行的結果倒是 99.5 ,這又是爲什麼?難道瀏覽器引擎的實現有誤?或者沒按規範實現?

對比了幾個引擎,結果都是 995,咱們先看一哈 JavaScriptCore (Webkit 的 js 引擎) 實現

JavaScriptCore toFixed 源碼

源碼在 webkit/Source/JavaScriptCore/runtime/NumberPrototype.cpp 中的 numberProtoFuncToFixed 方法

在 JavaScriptCore 中,原型方法很好找,就是 xxxProtoFuncXxx 的結構

調試環境

(本小結能夠選擇略過,直接看後面的分析和小結)

在 macOS 上編譯 webkit 比 v8 簡單多了,詳見 Setup and Debug JavaScriptCore / WebKit

經過如下命令進入 debug 環境

# 利用 lldb 調試 jsc
$ lldb ./WebKitBuild/Debug/bin/jsc
# 開始調試
(lldb) run
>>>
# control + c 再次進入 lldb
# 打斷點, 斷點方法輸幾個字符按 Tab 就能夠出提示了
(lldb): b JSC::numberProtoFuncToFixed(JSC::JSGlobalObject*, JSC::CallFrame*)
(lldb): b WTF::double_conversion::FastFixedDtoa(double, int, WTF::double_conversion::BufferReference<char>, int*, int*) 
# 結束調試,切到 jsc, 須要按 2次回車
(lldb): c
# 輸入 js 代碼,回車進入 lldb 調試環境
>>> 99.55.toFixed(1)
# EncodedJSValue JSC_HOST_CALL numberProtoFuncToFixed(JSGlobalObject* globalObject, CallFrame* callFrame)
複製代碼

jsc 經常使用指令

describe(x) #查看對象(js在 c 中均爲對象) 的內部描述,結構,內存地址
複製代碼

lldb 經常使用指令

加強插件 chisel ,更多使用方法後續寫一篇文章,

x/8gx address #查看內存地址 address

next(n) #單步執行
step(s) #進入函數
continue(c) #將程序運行到結束或者斷點處(進入下一斷點)
finish #將程序運行到當前函數返回(從函數跳出)
breakpoint(b) 行號/函數名 <條件語句> #設置斷點
fr v #查看局部變量信息
print(p) x #輸出變量 x 的值
複製代碼

源碼分析

入口,各類狀況的處理

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 取值 1
    int decimalPlaces = static_cast<int>(callFrame->argument(0).toInteger(globalObject));
    RETURN_IF_EXCEPTION(scope, { });

    // 特殊處理,略
    if (decimalPlaces < 0 || decimalPlaces > 100)
        return throwVMRangeError(globalObject, scope, "toFixed() argument must be between 0 and 100"_s);

    // x 的特殊處理,略
    if (!(fabs(x) < 1e+21))
        return JSValue::encode(jsString(vm, String::number(x)));

    // NaN or Infinity 的特殊處理
    ASSERT(std::isfinite(x));

    // 進入執行 number=99.549999999999997, decimalPlaces=1
    return JSValue::encode(jsString(vm, String::numberToStringFixedWidth(x, decimalPlaces)));
}
複製代碼

從 numberToStringFixedWidth 方法不斷進入,到達 FastFixedDtoa 處理方法

須要注意的是,原數值的整數和小數部分都分別採用了指數表示法,方便後面位運算處理

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;
  // 將 v 表示成 尾數(significand) × 底數(2) ^ 指數(exponent) 
  // 7005208482886451 x 2 ^ -46
  uint64_t significand = Double(v).Significand();
  int exponent = Double(v).Exponent();

  // 省略部分代碼

  if (exponent + kDoubleSignificandSize > 64) {
    // ...
  } else if (exponent >= 0) {
    // ...
  } else if (exponent > -kDoubleSignificandSize) {
    // exponent > -53 的狀況, 切割數字

    // 整數部分: integrals = 7005208482886451 >> 46 = 99 
    uint64_t integrals = significand >> -exponent;
    // 小數部分(指數表達法的尾數部分): fractionals = 7005208482886451 - 99 << 46 = 38702809297715
    // 指數不變 -46
    // 38702809297715 * (2 ** -46) = 0.5499999999999972
    uint64_t fractionals = significand - (integrals << -exponent);
    if (integrals > kMaxUInt32) {
      FillDigits64(integrals, buffer, length);
    } else {
      // buffer 中放入 "99"
      FillDigits32(static_cast<uint32_t>(integrals), buffer, length);
    }
    *decimal_point = *length;
    // 填充小數部分,buffer 爲 "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;
}

複製代碼

FillFractionals 用來填充小數部分,取幾位,是否進位都在該方法中處理

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


/* 小數部分的二進制表示法: fractionals * 2 ^ -exponent 38702809297715 * (2 ** -46) = 0.5499999999999972 前提: -128 <= exponent <=0。 0 <= fractionals * 2 ^ exponent < 1 buffer 能夠保存結果 此函數將舍入結果。在舍入過程當中,此函數未生成的數字可能會更新,且小數點變量可能會更新。若是此函數生成數字 99,而且緩衝區已經包含 「199」(所以產生的緩衝區爲「19999」),則向上舍入會將緩衝區的內容更改成 「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

    // 每次迭代,將小數乘以10,去除整數部分放入 buffer

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

      // fractionals 乘以 5 而不是乘以 10 ,並調整 point 的位置,這樣, fractionals 變量將不會溢出。而後總體至關於乘以 10
      // 不會溢出的驗證過程:
      // 循環初始: fractionals < 2 ^ point , point <= 64 且 fractionals < 2 ^ 56
      // 每次迭代後, point-- 。
      // 注意 5 ^ 3 = 125 < 128 = 2 ^ 7。
      // 所以,此循環的三個迭代不會溢出 fractionals (即便在循環體末尾沒有減法)。
      // 與此同時 point 將知足 point <= 61,所以 fractionals < 2 ^ point ,而且 fractionals 再乘以 5 將不會溢出(<int64)。


      // 該操做不會溢出,證實見上方
      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)++;
      // 去掉整數位
      fractionals -= static_cast<uint64_t>(digit) << point; // 193514046488575 - 5 * 2 ** 45 = 17592186044415 
      // 17592186044415 * 2 ** -45 = 0.4999999999999716 
    }
    // 看小數的下一位是否值得讓 buffer 中元素進位
    // 經過乘2看是否能 >=1 來判斷
    ASSERT(fractionals == 0 || point - 1 >= 0);
    // 本例中 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.
    // ...
  }
}


複製代碼

這樣就獲得了 995 ,即規範描述中的 n ,後面插入一個小數點即爲最終結果 99.5

小結

js 引擎並無按規範中說的,去尋找一個 n ,使其 n / (10 ^ f) 儘量等於 x ,感受這樣效率太慢了。而是直接將 x 分爲整數和小數部分,並採用指數表示法分別進行計算。

處理小數的時候,其實就是讓小數點右移。用指數表示法的時候,其中有個細節就是考慮了底數直接 *10 可能會致使溢出,而後採用了 底數 *5 ,指數遞減 的方式 ,註釋中給出了證實。 在 f 位計算後,最後再計算下一位,看是否須要進位。

固然,最終結果不符合咱們平常的計算,核心仍是在於 IEEE 754 表示法

99.55 在調試初期取值就是 99.549999999999997 了

所以,之後用 toFixed 方法的時候,要是擔憂沒有正常四捨五入,就先去 在線工具 上查看看

V8 toFixed 源碼

v8 toFixed

入口在這,就再也不分析了,和 JavaScriptCore 大同小異,感興趣的讀者能夠自行查看

// ES6 section 20.1.3.3 Number.prototype.toFixed ( fractionDigits )
BUILTIN(NumberPrototypeToFixed) {

  // ... 省略參數解析,拆包,類型判斷
  
  // value_number 和 fraction_digits_number 即爲咱們目標值
  // 假設 value_number = 99.55, fraction_digits_number = 1.0
  double const value_number = value->Number();
  double const fraction_digits_number = fraction_digits->Number();

  // ... 省略範圍檢查

  // ... 省略 value_number 特殊值處理: Infinity NaN

  // 實際處理方法 DoubleToFixedCString
  char* const str = DoubleToFixedCString(
      value_number, static_cast<int>(fraction_digits_number));
  Handle<String> result = isolate->factory()->NewStringFromAsciiChecked(str);
  DeleteArray(str);
  return *result;
}
複製代碼

正確的 「四捨五入」

那如何寫出一個符合常理的四捨五入方法呢?咱們能夠藉助 Math.round 方法實現

Math.round(x)

給定數字的值 x 四捨五入到最接近的整數。

  • 若是 x 的小數部分大於 0.5,則舍入到相鄰的絕對值更大的整數。
  • 若是 x 的小數部分小於 0.5,則舍入到相鄰的絕對值更小的整數。
  • 若是參數的小數部分剛好等於0.5,則舍入到相鄰的在正無窮(+∞)方向上的整數。

    ⚠️ 注意: 與不少其餘語言中的round()函數不一樣,Math.round()並不老是舍入到遠離0的方向(尤爲是在負數的小數部分剛好等於0.5的狀況下)。

舉例

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 
複製代碼

代碼

// 注意,要用除法。若用乘法的話,乘以小數,該小數是不精確的 (仍是上面的緣由,ieee 754 表示法)
// 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
複製代碼

對負數進行,好比 -99.5 四捨五入按其餘平臺處理,取值 -100

/** * * @param {*} number * @param {*} precision * @param {boolean} flag 負數四捨五入是否按遠離 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
複製代碼

以前想說還須要考慮溢出,由於發現咱們本身實現的 round 和 toFixed 都不符合預期

round(999999999955.2376236232, 6) // 999999999955.2378
999999999955.2376236232.toFixed(6) // "999999999955.237671
複製代碼

後來發現 999999999955.2376236232 這個數字在 64 位中就沒法表示了,只能表示爲 9.999999999552376708984375e11

因此,溢出的例子咱們就不考慮了。

PS: 處理時發現的一個方法 Math.trunc 能夠直接拿到整數部分,無論正負,不像 Math.floor 對於負數會向下取整

回到問題,平臺如何修復這個bug

平臺上的粉絲數顯示聽從這樣幾個原則:

  1. 小於 1 萬,直接顯示
  2. 小於 1 億,四捨五入保留一位小數,若小數部分爲 0 ,則不顯示
  3. 大於等於 1 億,四捨五入保留一位小數,若小數部分爲 0 ,則不顯示

利用剛剛寫的 round 函數操做一波

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
    }
}
/** * 測試用例 */

formatNumForAvatar(9999) // {num: 9999}
formatNumForAvatar(99999) // {num: 10, unit: "萬"}
formatNumForAvatar(995500) // {num: 99.6, unit: "萬"}
formatNumForAvatar(99999900) // {num: 10000, unit: "萬"}
formatNumForAvatar(109999900) // {num: 1.1, unit: "億"}
複製代碼

仍是有問題,沒有處理好 10000 萬 這種 case

目前想到的就是增長判斷條件,或者硬編碼

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 => {
    // 處理 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
    }
}
/** * 測試用例 */

formatNumForAvatar(9999) // {num: 9999}
formatNumForAvatar(99999) // {num: 10, unit: "萬"}
formatNumForAvatar(995500) // {num: 99.6, unit: "萬"}
formatNumForAvatar(99994999) // {num: 9999.5, unit: "萬"}
formatNumForAvatar(99999900) // {num: 1, unit: "億"}
formatNumForAvatar(109999900) // {num: 1.1, unit: "億"}
複製代碼

若是有其餘更好的方式歡迎評論~

最後

從發現問題,到寫下這篇文章拖了大概 2 周了,主要是以前對 JS 引擎調試徹底不瞭解

光搭建 v8 調試環境就花了好幾個晚上,包括 macOS 上 gdb 的坑,V8 構建的坑,斷點調試的坑,如何配合 vscode 等等... 後面會再出幾篇文章講這個,歡迎關注

若是是 macOS 上,建議仍是去看 JavaScriptCore 源碼吧,這些基礎方法實現,其實大部分和 V8 是同樣的

參考文檔

相關文章
相關標籤/搜索