作者 | 潘旻琦 在 Node.js 中,當我們把一個浮點數序列化,再反序列化,居然出錯了,這是為什麼呢?作者通過刨根問底的追查,發現 Node.js 根本沒有 float!
Node.js 中反序列化出錯?在 Node.js 中,當我們把一個浮點數序列化,再反序列化:
var f = 2.3; var buffer = new Buffer(1024); buffer.writeFloatBE(f); var g = buffer.readFloatBE; console.log(f == g);我們會發現,再也取不出之前的值了:
然而,如果再序列化回去,發現結果還是相同的:
這是為什麼?
排查出現問題的環節問題是不是出現在序列化的環節?為此,我們編寫了一個 C 語言程序對同一個浮點數進行序列化:
注意到 51、51、19、64 等價於 0x33、0x33、0x13、0x40,運行后除了存在小端序與大端序的差異外,我們發現序列化結果完全一致:
可見,序列化的環節 C、Node.js 兩者完全相同,沒有任何問題。接下來,讓我們試驗一下用 C 語言反序列化這個浮點數能得到怎樣的結果:
C 語言的將二進位浮點數轉為十進位可以得到正確的結果,我們判定問題很可能出在 Node.js 的二進位浮點數轉十進位的演算法上。
C 語言如何將二進位浮點數轉為十進位我們找出 glibc 的源碼,發現邏輯位於 printf_fp.c 的 ___printf_fp 函數內。該函數共一千多行,可見二進位浮點數轉為十進位不是個簡單的任務:
簡單閱讀源代碼發現,它取出了系統當前的 locale(考慮 LC_ALL 環境變數),按照 locale 的不同先找到正確的小數點表示(例如德語里的小數點是逗號,英文里是句號)。隨後取出浮點數的各部分,調用各種 mpn 高精度庫函數進行各種高精度運算,最後算出浮點數的十進位的表示。
這麼有名的標準庫,一個函數寫一千行,作者肯定是實現了一個著名演算法,否則這麼複雜的邏輯如何維護得起?果然,我們在 CHANGELOG 的 1992 年的一條記錄中發現了所用的演算法 —— Dragon4。
JS 語言如何將二進位浮點數轉為十進位那麼 JavaScript 又是什麼演算法呢?我們在 ECMA 的 9.8.1 章節中找到了解釋。原來,JS 多年來遵循的了一套複雜的對數字進行展示的演算法。標準該章節的末尾還提示 JS 的實現者參考 David M. Gay 在 1990 年發表的論文《Correctly Rounded Binary-Decimal and Decimal-Binary Conversions》。
首先,閱讀 V8 的源碼發現,它並沒有使用 David M. Gay 的樸素演算法。2010 年 Florian Loitsch 發表了論文《Printing floating-point numbers quickly and accurately with integers》,取得了該問題近 30 年來最大的進展,提出了新演算法 —— Grisu3,這使得 V8 不用高精度運算庫便可以求得二進位浮點數的十進位表示,幾乎完勝 dragon4 演算法。唯一的缺點是有 0.5% 的浮點數會轉換失敗,這時 V8 可以聰明地 fallback 到了高精度運算來搞定他們。
其次,輸出 2.3 並不是數學上正確的結果。運用一些數學知識進行分析就會發現,有些二進位的浮點數是永遠不會轉換成漂亮的有限位的十進位小數的。在十進位數系中,只要一個有理數的分母含有 10 的素因子之外的因子,那麼他就是一個無限小數。例如 1/15 等於 0.066666 循環,因為 15 含因子 3。這一結論可以使用費馬小定理,取 a=10,p=2 或 5 :
同理,在二進位數系中,只要一個有理數的分母含有 2 的素因子(只有 2)之外的因子,那麼他就是一個無限小數。所以,本例中的有理數 2.3 = 23/10 的分母的素因子有 2 和 5,含有 2 之外的因子,因此在二進位下的表示是無限的。
然而計算機是有限的,因此在浮點被序列化成 4 個位元組的一瞬間,精度就損失了。當反序列化回來的時候,那個輸出 2.3 的程序反而在數學上不正確,輸出 2.299999952316284 的程序在數學上才是更正確的。現實很殘酷。
2.3 是怎麼來的原來,C 語言的 printf 默認進行了一定有效數字位數的取整,如果我們不斷擴大有效數字的位數:
就可以發現,當小數后的有效數字位數設定為 1~7 時,剛好取整到我們想要的十進位結果,其他情況下並無卵:
Node.js 根本沒有 float但是我們回過頭來想一想,即便現實世界中的浮點數是殘酷的,但 Node.js 也有它做的不對的地方。為什麼有同等二進位表示的兩個數字,可以被 toString 成兩種結果?而且關於認為它們相等這件事,Node.js 也是拒絕的呢?
原因就是 Node.js 根本沒有頭髮!哦不對,是根本沒有 float!V8 引擎中 js 的 Number 對象的內部實現只有兩種,一是 smi(也就是小整數),二是 double。Grisu3 演算法的實現也只被封裝在 DoubleToCString 等函數中,從來沒有 FloatToCString 這種處理 float 的函數。
更為坑爹的是,在 readFloatBE 得到 float 值而傳入 V8 的一瞬,float 被 C++ 編譯器悄無聲息地 cast 成 double 了。
因此,他們的二進位其實是不一致的,因為我們先前只看了前四個位元組,沒看后四個位元組:
首先,如果實在想要像 C 那樣得到 2.3,那麼需要人工指定取整的位數,用 Number.prototype.toPrecision 函數四捨五入后重新實例化 Number 對象:
其次,應盡量避免使用 float,如今 double 已經得到伺服器的廣泛支持,沒有必要為了節省內存或帶寬棄 64 位不用而使用 32 位。尤其是在沒有 float 的 Node.js 的世界里,使用 float 作為數據存儲或數據交換格式必然會引入各種與直覺不符的問題。最後,在精度要求高的場合,應犧牲性能,放棄機器原生的浮點數,轉而使用 BigDecimal 等無限精度的運算庫。
作者簡介潘旻琦,供職於阿里巴巴集團。Ruby 與 C++ 重度用戶。國際技術會議講師。Node.js 的六位國內 Collaborator 之一。長期關注 Ruby、Node.js 與 V8 等開源項目的社區動態。
本文系作者投稿,已授權前端之巔公眾號發布。投稿請發郵件至 [email protected],註明「前端之巔」投稿。
Facebook 開源 JavaScript 代碼優化工具 Prepack
視野拓展InfoQ 主辦的移動和前端開發領域的精品大會【GMTC 2017】將於 6 月 9~10 日在北京舉行,作為首屆以「大前端」為主題的大會,GMTC 涉及移動、前端、跨平台、AI 應用等多個技術領域,幫助你方方面面提高技術水平。掃描下圖二維碼或戳閱讀原文,前往官網了解詳細信息!
前端之巔「前端之巔」是 InfoQ 旗下關注前端技術的垂直社群,加入前端之巔學習群請關注「前端之巔」公眾號后回復「加群」。推薦分享或投稿請發郵件到 [email protected],註明「前端之巔投稿」。