復(fù)制粘貼一時(shí)爽:傳播最廣的一段Java代碼曝出Bug
復(fù)制粘貼一時(shí)爽,頻出 bug 火葬場(chǎng)。對(duì)開發(fā)者而言,Stack Overflow 和 GitHub 是最為熟悉不過的兩大平臺(tái),這些平臺(tái)充斥著大量開源項(xiàng)目信息和解決各類問題的代碼片段。最近,一位叫做 Aioobe 的開發(fā)者在一項(xiàng)調(diào)查中發(fā)現(xiàn)了一段自己十年前寫的代碼,這段代碼成為了 Stack Overflow 上復(fù)制次數(shù)最多、傳播范圍最廣的答案,GitHub 的眾多項(xiàng)目中也存在這段代碼。然而,這位開發(fā)者表示這段代碼其實(shí)是有 bug 的,并于近日更新了答案并作出說明。
這段代碼是干啥的?
2010 年的時(shí)候,我整天泡在 Stack Overflow 上回答問題,希望可以提高自己的知名度。當(dāng)時(shí),有一個(gè)問題吸引了我的注意:如何以人類可讀的格式輸出字節(jié)數(shù)?舉個(gè)例子,將“123456789 字節(jié)”轉(zhuǎn)換為“123.5 MB”的格式輸出。
這是現(xiàn)在的截圖,但問題確實(shí)是這個(gè)
這里的隱含范式在于所得到的字符串值應(yīng)該在 1 到 999.9 之間,后面再跟上一個(gè)大小合適的單位。當(dāng)時(shí)已經(jīng)有人給了一條回應(yīng)。答案中的代碼以循環(huán)為基礎(chǔ),基本思路非常簡(jiǎn)單:嘗試所有單位,從最大(EB,即 1018 字節(jié))到最小(B,即 1 字節(jié)),而后使用一種顯示數(shù)量小于實(shí)際字節(jié)數(shù)量的單位。用偽代碼寫出來,基本是這么個(gè)意思:
- suffixes = [ "EB", "PB", "TB", "GB", "MB", "kB", "B" ]
- magnitudes = [ 1018, 1015, 1012, 109, 106, 103, 100 ]
- i = 0
- while (i < magnitudes.length && magnitudes[i] > byteCount)
- i++
- printf("%.1f %s", byteCount / magnitudes[i], suffixes[i])
一般來說,如果發(fā)布的正確答案已經(jīng)獲得了正分?jǐn)?shù),那后發(fā)者很難追上。在 Stack Overflow 上,這就叫“拔槍最快的贏”。不過,我認(rèn)為這個(gè)答案有缺陷,所以準(zhǔn)備重新改改。我意識(shí)到,無論是 KB、MB 還是 GB,所有單位的本質(zhì)實(shí)際都是 1000 的冪(當(dāng)然,按 IEC 標(biāo)準(zhǔn)來講是 1024),意味著應(yīng)該可以使用對(duì)數(shù)而非循環(huán)來計(jì)算正確的量級(jí)單位。
基于以上思路,我發(fā)布了下列內(nèi)容:
- public static String humanReadableByteCount(long bytes, boolean si) {
- int unit = si ? 1000 : 1024;
- if (bytes < unit) return bytes + " B";
- int exp = (int) (Math.log(bytes) / Math.log(unit));
- String pre = (si ? "kMGTPE" : "KMGTPE").charAt(exp-1) + (si ? "" : "i");
- return String.format("%.1f %sB", bytes / Math.pow(unit, exp), pre);
- }
當(dāng)然,這段代碼可讀性不高,而且 log/pow 也可能在一定程度上影響執(zhí)行效率,但至少這里沒有循環(huán),幾乎不涉及分支,我覺得還是比較整潔的。
這里面使用的數(shù)學(xué)方法非常簡(jiǎn)單。字節(jié)計(jì)數(shù)表示為 byeCount=1000s , 其中的 s 代表小數(shù)點(diǎn)后的位數(shù)(以二進(jìn)制表示,則使用 1024 為基數(shù)),求解 s,即可得出 s=log1000(byteCount)。
API 里沒有現(xiàn)成的 log1000 可以直接使用,但我們不妨用自然對(duì)數(shù)來表示,即 s = log(byteCount) / log(1000)。接下來,我們?nèi)?s 的底(即取整數(shù)),因?yàn)榧偃缥覀兊贸龅慕Y(jié)果超過 1 MB(但不足 1 GB),則希望繼續(xù)使用 MB 作為表示單位。
此時(shí),如果 s=1,則單位為 KB;如果 s=2,則單位為 MB;依此類推,我們將 byteCount 值除以 1000s ,然后取對(duì)應(yīng)的單位。
接下來,我能做的就是等待,看看社區(qū)是否喜歡這個(gè)答案。那時(shí)候的我,絕對(duì)想不到它會(huì)成為 Stack Overflow 上復(fù)制最多的代碼片段。
BUG 在哪?
估計(jì)不少人看到這兒肯定在想,這段代碼里到底有什么 bug?再來看一遍代碼:
- public static String humanReadableByteCount(long bytes, boolean si) {
- int unit = si ? 1000 : 1024;
- if (bytes < unit) return bytes + " B";
- int exp = (int) (Math.log(bytes) / Math.log(unit));
- String pre = (si ? "kMGTPE" : "KMGTPE").charAt(exp-1) + (si ? "" : "i");
- return String.format("%.1f %sB", bytes / Math.pow(unit, exp), pre);
- }
在 EB,即 1018 之后,接下來的單位應(yīng)該是 ZB,即 1021。難道是輸入量過大導(dǎo)致“kMGTPE”字符串的索引超出范圍?不是的,long 的最大值是 263 - 1 ≈ 9.2 × 1018,因此任何 long 值都不會(huì)超出 EB 范圍。
那么,是 SI 與二進(jìn)制之間存在混雜嗎?也不是。答案的早期版本中確實(shí)有這個(gè)問題,但很快就得到了修復(fù)。
那么,是不是 exp 可以為 0 會(huì)導(dǎo)致 charAt(exp-1) 發(fā)生錯(cuò)誤?不是的。第一個(gè) if 語句也涵蓋了這種情況,因此 exp 值將始終至少為 1。
那就只剩最后一種情況了,輸出結(jié)果中是否存在某些奇怪的舍入錯(cuò)誤?這正是我們接下來要討論的部分……
太多 9
這套解決方案一直運(yùn)作良好,直到字節(jié)數(shù)量達(dá)到 1 MB。假定輸入為 999999 字節(jié),那么結(jié)果(在 SI 模式下)將為“1000.0 kB”。盡管 999999 比 999.9 x 10001 更接近于 1000 x 10001,但根據(jù)規(guī)范,1000 的“有效位數(shù)”超出了范圍。正確的結(jié)果應(yīng)該是“1.0 MB”。
無論如何,在這個(gè)帖子的所有 22 個(gè)答案中(包括使用 Apache Commons 以及 Android 庫的答案)截至本文撰稿之時(shí)都存在這個(gè)錯(cuò)誤(或者其變體)。那么,我們?cè)撊绾谓鉀Q?
首先,我們會(huì)注意到,一旦字節(jié)數(shù)比 999.9 x 10001(999.9 k)更接近于 1 x 10002(1 MB),則指數(shù)(exp)就應(yīng)由“k”變更為“M”。例如,9999950 就符合這種情況。同樣的,當(dāng)我們輸入 999950000 時(shí),我們應(yīng)該從“M”切換為“G”,依此類推。為了達(dá)成這一目標(biāo),我們會(huì)計(jì)算該閾值,一旦字節(jié)數(shù)超過閾值,則增加 exp:
- if (bytes >= Math.pow(unit, exp) * (unit - 0.05))
- exp++;
調(diào)整之后,代碼即可正常工作,直到字節(jié)數(shù)接近 1 EB。以輸入為 999,949,999,999,999,999 為例,其目前的結(jié)果為 1000.0 PB,但正確結(jié)果應(yīng)該是 999.9 PB。但從數(shù)學(xué)上講,代碼結(jié)果又是準(zhǔn)確的,這又是怎么回事?這里,我們就遇到了 double(雙)精度機(jī)制的局限性。
浮點(diǎn)運(yùn)算基礎(chǔ)知識(shí)
由于采用 IEEE 754 表示方式,因此近零浮點(diǎn)值會(huì)非常密集,但大值則非常稀疏。實(shí)際上,所有浮點(diǎn)值中的一半都位于 -1 與 1 之間;而在談到大雙精度浮點(diǎn)數(shù)時(shí),像 Long.MAX_VALUE 那么大的值已經(jīng)沒有任何意義了。
- double l1 = Double.MAX_VALUE;
- double l2 = l1 - Long.MAX_VALUE;
- System.err.println(l1 == l2); // prints true
下面來看兩項(xiàng)有問題的計(jì)算:
- String.format 參數(shù)中的除法;
- exp 進(jìn)位閾值
我們當(dāng)然可以切換為 BigDecimal,但這么干就沒意思了。另外,由于標(biāo)準(zhǔn) API 中沒有 BigDecimal log 函數(shù),所以問題其實(shí)仍然存在。
縮小中間值
對(duì)于第一個(gè)問題,我們可以將字節(jié)值縮小至更合理的精度范圍,同時(shí)相應(yīng)調(diào)整 exp。無論如何,最終結(jié)果都會(huì)四舍五入,因此我們要做的就是不要舍棄最低有效數(shù)字。
- if (exp > 4) {
- bytes /= unit;
- exp--;
- }
調(diào)整最低有效位
對(duì)于第二個(gè)問題,我們當(dāng)然關(guān)心最低有效位(999、949、99…9 與 999,950,00…0 應(yīng)該以不同的單位結(jié)尾),因此必須得想個(gè)不同的解決方案。
首先,我們注意到閾值存在 12 種不同的可能值(每種模式 6 種),而且其中只有一種最終會(huì)發(fā)生故障。通過以 D0016 結(jié)尾這一跡象,可以準(zhǔn)確識(shí)別出錯(cuò)誤結(jié)果。一旦發(fā)生這種情況,我們將其調(diào)整為正確值即可。
- long th = (long) (Math.pow(unit, exp) * (unit - 0.05));
- if (exp < 6 && bytes >= th - ((th & 0xFFF) == 0xD00 ? 52 : 0))
- exp++;
由于我們?cè)诟↑c(diǎn)結(jié)果中需要使用特定數(shù)位模式,因此下手的對(duì)象自然就是 strictfp,旨在保證其不受硬件運(yùn)行代碼的影響。
負(fù)輸入
目前我還沒想到什么情況下有可能需要使用負(fù)字節(jié)數(shù)量,但考慮到 Java 不支持無符號(hào) long,我們最好還是把這個(gè)問題考慮進(jìn)來?,F(xiàn)在,如果輸入為 -10000,那么結(jié)果為 -10000 B。這里我們引入 absBytes:
- long absBytes = bytes == Long.MIN_VALUE ? Long.MAX_VALUE : Math.abs(bytes);
這里的表達(dá)之所以如此復(fù)雜,是基于 -Long.MIN_VALUE == Long.MIN_VALUE 這一事實(shí)?,F(xiàn)在,我們利用 absBytes 替代 bytes 執(zhí)行所有與 exp 相關(guān)的計(jì)算。
最終版本
以下是代碼片段的最終版本,其中已經(jīng)對(duì)最初版本做了精心調(diào)整與改進(jìn):
- // 來自: https://programming.guide/the-worlds-most-copied-so-snippet.html
- public static strictfp String humanReadableByteCount(long bytes, boolean si) {
- int unit = si ? 1000 : 1024;
- long absBytes = bytes == Long.MIN_VALUE ? Long.MAX_VALUE : Math.abs(bytes);
- if (absBytes < unit) return bytes + " B";
- int exp = (int) (Math.log(absBytes) / Math.log(unit));
- long th = (long) (Math.pow(unit, exp) * (unit - 0.05));
- if (exp < 6 && absBytes >= th - ((th & 0xfff) == 0xd00 ? 52 : 0)) exp++;
- String pre = (si ? "kMGTPE" : "KMGTPE").charAt(exp - 1) + (si ? "" : "i");
- if (exp > 4) {
- bytes /= unit;
- exp -= 1;
- }
- return String.format("%.1f %sB", bytes / Math.pow(unit, exp), pre);
- }
請(qǐng)注意,這段代碼最初的目標(biāo)是避免由循環(huán)以及大量分支帶來的復(fù)雜性。在解決了所有極端情況之后,新代碼的可讀性要比原始版本更差。我個(gè)人是肯定不會(huì)把這段代碼復(fù)制到生產(chǎn)代碼中的。
這段代碼被復(fù)制到了哪里?
此前,一位名叫 Sebastian Baltes 的博士生在《Empirical Software Engineering》上發(fā)表了一篇論文,標(biāo)題為《GitHub 項(xiàng)目中 Stack Overflow 代碼片段的用法與歸因》,文章探討的核心議題只有一個(gè):用戶對(duì)代碼片段的引用是否遵循 Stack Overflow 的 CC BY-SA 3.0 許可,即從 Stack Overflow 上復(fù)制代碼時(shí),用戶應(yīng)保證何等程度的歸因水平?
在分析當(dāng)中,作者從 Stack Overflow 數(shù)據(jù)轉(zhuǎn)儲(chǔ)中提取出代碼片段,并將其與公共 GitHub 存儲(chǔ)庫中的代碼進(jìn)行匹配。下面來看論文的基本發(fā)現(xiàn):
我們進(jìn)行了一項(xiàng)大規(guī)模實(shí)證研究,分析了來自各公共 GitHub 項(xiàng)目中的非常規(guī) Java 代碼片段,對(duì)其中實(shí)際上源自 Stack Overflow 的代碼片段進(jìn)行了用法與歸因調(diào)查。
這篇文章給出了一份表格,而其中 ID 為 3758880 的答案正是我?guī)啄昵鞍l(fā)布的那一條。截至目前,這條答案獲得了幾十萬次查看外加一千多個(gè)好評(píng)。只要在 GitHub 上隨便搜搜,就能找到成千上萬條 humanReadableByteCount。
這也就意味著,這段有問題的代碼被無數(shù)的項(xiàng)目和開發(fā)者引用,要驗(yàn)證這段代碼是否也在自己的本地存儲(chǔ)庫內(nèi),請(qǐng)執(zhí)行以下操作:
- $ git grep humanReadableByteCount
心得摘要
最后,我希望告訴廣大開發(fā)者 Stack Overflow 上的代碼片段可能存在 bug,即使得到無數(shù)好評(píng)也改變不了這一事實(shí);一定要對(duì)所有極端情況做出測(cè)試,特別是測(cè)試那些復(fù)制自 Stack Overflow 的代碼;浮點(diǎn)運(yùn)算很復(fù)雜,也很困難,在復(fù)制代碼時(shí),請(qǐng)確保了解代碼背后的邏輯和使用規(guī)范。