小鄭是某平臺一位 UP 🐷,通過兩年努力如今平臺粉絲也有 994500 個了,距離衝擊百萬 UP 僅剩一步之遙。javascript
金主爸爸告訴他,只要百萬,就讓他接個大的廣告單。html
奈何最近沒有靈感,每天🕊,始終不漲粉。java
因而他便打起了歪主意,買粉!先到某寶上買了 1 千粉絲試試水,很快,對方說已到帳。git
小鄭興沖沖的上平臺上一看,並無,仍是顯示的 99.5
,1 千個粉絲去哪了??程序員
正準備投訴商家時,鼠標下滑看到了這樣的一幕!web
具體粉絲數確實是正確的,按照小學教的的四捨五入,應該顯示 99.6萬
纔對。瀏覽器
難道是平臺的 bug ?不解的小鄭打算向程序員朋友小蓋詢問,小蓋一看:bash
「 這應該是用了 toFixed 了吧,這方法有個坑。。容我細細道來 」ecmascript
以上故事純屬虛構(包括圖片)函數
如下引自 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 )
關鍵看紅色區域,咱們須要找個一個 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 引擎) 實現
源碼在 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 方法的時候,要是擔憂沒有正常四捨五入,就先去 在線工具 上查看看
入口在這,就再也不分析了,和 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 方法實現
給定數字的值 x 四捨五入到最接近的整數。
⚠️ 注意: 與不少其餘語言中的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
對於負數會向下取整
平臺上的粉絲數顯示聽從這樣幾個原則:
利用剛剛寫的 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 是同樣的