Java 字符串 split 踩坑記

1.1 split 的坑

前幾天在公司對通過 FTP 方式上傳的數據文件按照事先規定的格式進行解析後入庫,代碼的大概實現思路是這樣的:先使用流進行文件讀取,對文件的每一行數據解析封裝成一個個對象,然後進行入庫操作。本以爲很簡單的一個操作,然後寫完代碼後自己測試發現對文件的每一行進行字符串分割的時候存在問題,在這裏做個簡單的記錄總結。在 Java 中使用 split 方法對字符串進行分割是經常使用的方法,經常在一些文本處理、字符串分割的邏輯中,需要按照一定的分隔符進行分割拆解。這樣的功能,大多數情況下我們都會使用 String 中的 split 方法。關於這個方法,稍不注意很容易踩坑。

(1)split 的參數是正則表達式
首先一個常見的問題,就是忘記了 String 的 split 方法的參數不是普通的字符串,而是正則表達式,例如下面的這兩種使用方式都達不到我們的預期:

   /**
    * @author mghio
    * @date: 2019-10-13
    * @version: 1.0
    * @description: Java 字符串 split 踩坑記
    * @since JDK 1.8
    */
    public class JavaStringSplitTests {

        @Test
        public void testStringSplitRegexArg() {
            System.out.println(Arrays.toString("m.g.h.i.o".split(".")));
            System.out.println(Arrays.toString("m|g|h|i|o".split("|")));
        }

    }

<!-- more -->

以上代碼的結果輸出爲:

[]
[m, |, g, |, h, |, i, |, o]

上面出錯的原因是因爲 .| 都是正則表達式,應該用轉義字符進行處理:

"m.g.h.i.o".split("\\.")
"m|g|h|i|o".split("\\|")

在 String 類中還有其它的和這個相似的方法,例如:replaceAll。

(2)split 會忽略分割後的空字符串
大多數情況下我們都只會使用帶一個參數的 split 方法,但是隻帶一個參數的 split 方法有個坑:就是此方法只會匹配到最後一個有值的地方,後面的會忽略掉,例如:

   /**
    * @author mghio
    * @date: 2019-10-13
    * @version: 1.0
    * @description: Java 字符串 split 踩坑記
    * @since JDK 1.8
    */
    public class JavaStringSplitTests {
            
        @Test
        public void testStringSplitSingleArg() {
            System.out.println(Arrays.toString("m_g_h_i_o".split("_")));
            System.out.println(Arrays.toString("m_g_h_i_o__".split("_")));
            System.out.println(Arrays.toString("m__g_h_i_o_".split("_")));
        }

    }

以上代碼輸出結果爲:

[m, g, h, i, o]
[m, g, h, i, o]
[m, , g, h, i, o]

像第二、三個輸出結果其實和我們的預期是不符的,因爲像一些文件上傳其實有的字段通常是可以爲空的,如果使用單個參數的 split 方法進行處理就會有問題。通過查看 API 文檔 後,發現其實 String 中的 split 方法還有一個帶兩個參數的方法。第二個參數是一個整型類型變量,代表最多匹配上多少個,0 表示只匹配到最後一個有值的地方,單個參數的 split 方法的第二個參數其實就是 0,要想強制匹配可以選擇使用負數(通常傳入 -1 ),換成以下的寫法,輸出結果就和我們的預期一致了。

    "m_g_h_i_o".split("_", -1)      // [m, g, h, i, o]
    "m_g_h_i_o__".split("_", -1)    // [m, g, h, i, o, , ]
    "m__g_h_i_o_".split("_", -1)    // [m, , g, h, i, o, ]

(3)JDK 中字符串切割的其它 API
在 JDK 中還有一個叫做 StringTokenizer 的類也可以對字符串進行切割,用法如下所示:

   /**
    * @author mghio
    * @date: 2019-10-13
    * @version: 1.0
    * @description: Java 字符串 split 踩坑記
    * @since JDK 1.8
    */
    public class JavaStringSplitTests {

    @Test
    public void testStringTokenizer() {
        StringTokenizer st = new StringTokenizer("This|is|a|mghio's|blog", "|");
        while (st.hasMoreElements()) {
        System.out.println(st.nextElement());
        }
    }

    }

不過,我們從源碼的 javadoc 上得知,這是從 JDK 1.0 開始就已經存在了,屬於歷史遺留的類,並且推薦使用 String 的 split 方法。

1.2 JDK 源碼探究

通過查看 JDK 中 String 類的源碼,我們得知在 String 類中單個參數的 split 方法(split(String regex))裏面調用了兩個參數的 split 方法(split(String regex, int limit)),兩個參數的 split 方法,先根據傳入第一個參數 regex 正則表達式分割字符串,第二個參數 limit 限定了分割後的字符串個數,超過數量限制的情況下前limit-1個子字符串正常分割,最後一個子字符串包含剩下所有字符。單個參數的重載方法將 limit 設置爲 0。源碼如下:

    public String[] split(String regex, int limit) {
        char ch = 0;
        if (((regex.value.length == 1 &&
             ".$|()[{^?*+\\".indexOf(ch = regex.charAt(0)) == -1) ||
             (regex.length() == 2 &&
              regex.charAt(0) == '\\' &&
              (((ch = regex.charAt(1))-'0')|('9'-ch)) < 0 &&
              ((ch-'a')|('z'-ch)) < 0 &&
              ((ch-'A')|('Z'-ch)) < 0)) &&
            (ch < Character.MIN_HIGH_SURROGATE ||
             ch > Character.MAX_LOW_SURROGATE))
        {
            int off = 0;
            int next = 0;
            boolean limited = limit > 0;
            ArrayList<String> list = new ArrayList<>();
            while ((next = indexOf(ch, off)) != -1) {
                if (!limited || list.size() < limit - 1) {
                    list.add(substring(off, next));
                    off = next + 1;
                } else {    // last one
                    //assert (list.size() == limit - 1);
                    list.add(substring(off, value.length));
                    off = value.length;
                    break;
                }
            }
            // If no match was found, return this
            if (off == 0)
                return new String[]{this};

            // Add remaining segment
            if (!limited || list.size() < limit)
                list.add(substring(off, value.length));

            // Construct result
            int resultSize = list.size();
            if (limit == 0) {
                while (resultSize > 0 && list.get(resultSize - 1).length() == 0) {
                    resultSize--;
                }
            }
            String[] result = new String[resultSize];
            return list.subList(0, resultSize).toArray(result);
        }
        return Pattern.compile(regex).split(this, limit);
    }

接下來讓我們一起看看 String 的 split 方法是如何實現的。

(1)特殊情況判斷

    (((regex.value.length == 1 &&
             ".$|()[{^?*+\\".indexOf(ch = regex.charAt(0)) == -1) ||
             (regex.length() == 2 &&
              regex.charAt(0) == '\\' &&
              (((ch = regex.charAt(1))-'0')|('9'-ch)) < 0 &&
              ((ch-'a')|('z'-ch)) < 0 &&
              ((ch-'A')|('Z'-ch)) < 0)) &&
            (ch < Character.MIN_HIGH_SURROGATE ||
             ch > Character.MAX_LOW_SURROGATE))
  • 第一個參數 regex 爲單個字符時,將其賦值給 ch,並判斷是否在元字符:「.$|()[{^?*+\」中
  • 第一個參數 regex 爲兩個字符時,第一個字符爲 \\(要表示一個需要用兩個\轉義得到),第二個字符不在數字、大小寫字母和 Unicode 編碼 Character.MIN_HIGH_SURROGATE('uD800')和 Character.MAX_LOW_SURROGATE('uDBFF')之間。

(2)字符串分割
第一次分割時,使用 off 和 next,off 指向每次分割的起始位置,next 指向分隔符的下標,完成一次分割後更新 off 的值,當 list 的大小等於 limit - 1 時,直接添加剩下的子字符串。

  • 如果字符串不含有分隔符,則直接返回原字符串
  • 如果字符串進行完第一次分割後,數量沒有達到 limit - 1 的話,則剩餘的字符串在第二次添加
  • 如果傳入的第二個參數 limit 等於 0 ,則從最後的字符串往前移動,將所有的空字符串(”“)全部清除

(3)正則匹配
String 的 split 方法在不是上面的特殊情況下,會使用兩個類 PatternMatcher 進行分割匹配處理,而且 Strig 中涉及正則的操作都是調用這兩個類進行處理的。

  • Pattern 類我們可以將其理解爲模式類,它主要是用來創建一個匹配模式,它的構造方法是私有的,不能直接創建該對象,可以通過 Pattern.complie(String regex) 簡單的工廠方法創建一個正則表達式。
  • Matcher 類我們可以將其理解爲匹配器類,它是用來解釋 Pattern 類對字符串執行匹配操作的引擎,它的構造方法也是私有的,不能直接創建該對象,可以通過 Pattern.matcher(CharSequence input) 方法得到該類的實例。String 類的雙參數 split 方法最後使用 Pattern 類的 compile 和 split 方法,如下:
    return Pattern.compile(regex).split(this, limit);

首先調用 Pattern 類的靜態方法 compile 獲取 Pattern 模式類對象

    public static Pattern compile(String regex) {
        return new Pattern(regex, 0);
    }

接着調用 Pattern 的 split(CharSequence input, int limit) 方法,在這個方法中調 matcher(CharSequence input) 方法返回一個 Matcher 匹配器類的實例 m,與 String 類中 split 方法的特殊情況有些類似。

  • 使用 m.find()、m.start()、m.end() 方法
  • 每找到一個分割符,則更新 start 和 end 的位置
  • 然後處理沒找到分隔符、子字符串數量小於 limit 以及 limit = 0 的情況

1.3 其它的字符串分割方式

  • 方式一:使用 org.apache.commons.lang3.StringUtils#split,此方法使用完整的字符串作爲參數,而不是正則表達式。底層調用 splitWorker 方法(<font color="#dd0000">注意:</font>此方法會忽略分割後的空字符串)
   /**
    * @author mghio
    * @date: 2019-10-13
    * @version: 1.0
    * @description: Java 字符串 split 踩坑記
    * @since JDK 1.8
    */
    public class JavaStringSplitTests {

        @Test
        public void testApacheCommonsLangStringUtils() {
            System.out.println(Arrays.toString(StringUtils.split("m.g.h.i.o", ".")));
            System.out.println(Arrays.toString(StringUtils.split("m__g_h_i_o_", "_")));
        }

    }

輸出結果:

[m, g, h, i, o]
[m, g, h, i, o]
  • 方式二:使用 com.google.common.base.Splitter,使用 Google Guava 包中提供的分割器 splitter,它提供了更加豐富的分割結果處理的方法,比如對結果前後去除空格,去除空字符串等
   /**
    * @author mghio
    * @date: 2019-10-13
    * @version: 1.0
    * @description: Java 字符串 split 踩坑記
    * @since JDK 1.8
    */
    public class JavaStringSplitTests {

        @Test
        public void testApacheCommonsLangStringUtils() {
            Iterable<String> result = Splitter.on("_").split("m__g_h_i_o_");
            List<String> resultList = Lists.newArrayList();
            result.forEach(resultList::add);
            System.out.println("stringList's size: " + resultList.size());
            result.forEach(System.out::println);
        }

    }

輸出結果:

stringList's size: 7
m

g
h
i
o

1.4 總結

String 類中除了 split 方法外,有正則表達式接口的方法都是調用 Pattern(模式類)和 Matcher(匹配器類)進行實現的。JDK 源碼的每一個如 finalprivate 的關鍵字都設計的十分嚴謹,多讀類和方法中的javadoc,多注意這些細節對於閱讀代碼和自己寫代碼都有很大的幫助。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章