阿拉伯人用阿拉伯數字嗎?——記一次用String#format格式化字串趟到的雷

ZxLee發表於2019-02-01

要生成一個字串,其中夾雜著一些動態變化的整數,我們一般是用String.format方法來完成,但是,如果用的不恰當,你可能是得不到正確的整數字符串的。

事情從一個線上崩潰說起,從崩潰堆疊來看,我的一句SQL語句有語法錯誤,執行的時候出錯導致了崩潰。
SQL語句大致的生成如下:

int i = 0;
String querySql = String.format("select * from table1 where id = %d", i);複製程式碼

完全沒有語法問題的可能,本地執行也是麻溜的通過了。
再細看日誌,原來是format後的SQL語句,%d本該替換為i的值對應的字串,結果卻變成了亂碼,也是導致語法錯誤的原因。看看其他地方的字串格式化,發現只有%d的轉換出了問題,字串的轉換也是%s的轉換是正常的。
所以,String.format在轉換數字的時候,出現了不可靠的一些事情。

JDK裡這麼常用的方法如果不可靠,那肯定是前人踩坑多次,且很有可能還提交過issue了,所以直接上StackOverflow找了一圈,未想到竟沒有結果。
那我只好大膽猜測,莫非是線上某些使用者裝置的字符集是不相容ASCII碼的,所以把數字轉換成了別的字元。這個想法很快被組內一些同事否定了,這世上應該沒有哪個字符集標準傻到不相容ASCII吧。

好吧,不亂猜了,大不了"read the fuck source code" .

String#format的原始碼如想象的那般簡單,把模式字串分解成一個陣列,每個陣列元素要麼是一個純字串,要麼是一個'%'符號開頭的格式串,然後遍歷陣列,把格式串一個個的替換成target值,再把陣列拼接回字串。
由於只有整數的轉換出錯,所以重點關注整數的轉換過程,其中一段程式碼略顯詭異:

    char c = value[j];
    sb.append((char) ((c - '0') + zero));複製程式碼

value是整數對應的ASCII碼陣列,比如,整數21對應的value陣列就是[50,49]。按理說,把這個陣列一股腦插入StringBuilder這個例項就萬事大吉了,但是偏偏插入前有一個(char) ((c - '0') + zero)的轉換過程,把目標字元c減去字元'0'再加上字元zero,看來這一步就是導致轉換出亂子的罪魁禍首了,來看zero的值。

char zero = getZero(l); //由於我們呼叫format方法沒有指定locale,所以l=Locale.getDefault();複製程式碼

再看getZero方法

private char getZero(Locale l) {
    if ((l != null) &&  !l.equals(locale())) {
        DecimalFormatSymbols dfs = DecimalFormatSymbols.getInstance(l);
        return dfs.getZeroDigit();
    }
    return zero;
}複製程式碼

由於locale()方法返回的就是我們傳入的locale,所以這裡不走if,直接返回類屬性zero的值,再看類屬性zero的初始化,是在構造方法裡面通過呼叫靜態方法來賦值的

private static char getZero(Locale l) {
    if ((l != null) && !l.equals(Locale.US)) {
        DecimalFormatSymbols dfs = DecimalFormatSymbols.getInstance(l);
        return dfs.getZeroDigit();
    } else {
        return '0';
    }
}複製程式碼

如果locale不是US,我們終究是躲不過上次if語句塊裡的DecimalFormatSymbols的,這個類的例項化很簡單,根據傳入的locale初始化一些固定的值,如小數點符號,分組符號,百分符號,還有我們最關注的zeroDigit

    /**
     * Gets the character used for zero. Different for Arabic, etc.
     */
    public char getZeroDigit() {
        return zeroDigit;
    }

    /**
     * Sets the character used for zero. Different for Arabic, etc.
     */
    public void setZeroDigit(char zeroDigit) {
        this.zeroDigit = zeroDigit;
        cachedIcuDFS = null;
    }複製程式碼

這兩個方法的方法體不重要,重要的線索在註釋裡:阿拉伯國家的‘0’是不一樣的。至於不一樣在哪裡,把手機語言切換成阿拉伯語,斷點除錯一下,果然有驚喜:String.format("%d",0).toCharArray()輸出的字元陣列中,第一個元素值並不是48(對應'0'),而是1632,直接通過String.valueOf((char)1632)轉換為字元,得到一個很粗的‘·’字元,這個應該就是阿拉伯人數字(不是阿拉伯數字)裡面的0了。Google一下,果然如此:


再實驗1633,1634等字元,完全是對應的。同時在我的崩潰日誌裡面出現的亂碼,也正好就是這些東西。

所以,回頭來看(char) ((c - '0') + zero)這個轉換,就很簡單了。可以看出,String.format對數字的轉換,並不是我們固有的認為是“0變成'0',1變成'1'”這麼簡單,而是要把“0變成零,1變成一”(打個比方而已,^__^ 嘻嘻……還好我們中國是習慣用123的,所以中文下format並不會出現一二三)。

事情原因就是這麼簡單,解決的辦法自然有了,要麼,呼叫format的時候傳入Locale.US,要麼,別用%d配整數,改用%s配字串。

PS:孟加拉語環境下也有同樣的問題,孟加拉語的0對應Unicode裡面的2534。

相關文章