[輪子系列]Google Guava之CharMatcher源碼分析

004.00.png

本文源地址: http://www.fullstackyang.com/...,轉發請註明該地址或segmentfault地址,謝謝!

最近遇到了一些字符匹配的需求,進而仔細地看了CharMatcher的源碼,發現仍是有點東西值得回味,例如它爲咱們提供瞭如何在多種字符類型場景下提升靈活性從而知足不一樣匹配需求的優秀示範。下面就對CharMatcher類的結構,設計模式,以及幾個算法作一些粗淺的分析。java

1、關於源碼中的彩蛋

CharMatcher類中,開頭部分有一張寵物小精靈「小火龍」的字符畫,就像本文的封面圖同樣,一開始不解爲什麼要放一隻「小火龍」在這裏,後來看到其英文名Charmander才明白過來。好吧,諧音梗……略冷。git

2、類的結構和關係

下圖是CharMatcher的類關係圖,圖中藍色的是abstract類,紅色的是final類
WX20180804-143922.png
首先CharMatcher修飾爲abstract,其中只有一個abstract方法matches,即判斷給定字符是否匹配,以及一些其餘的經常使用操做(它們的功能從方法名能夠得知,這裏就不一一介紹了,下文會選其中的一些作分析),此外還有三個用於組合的方法:negate、or、and。算法

public abstract boolean matches(char c);

public int indexIn(CharSequence sequence) // 查找匹配字符首次出現的索引位置

public int countIn(CharSequence sequence) // 對匹配的字符計數

public String retainFrom(CharSequence sequence) // 抽取匹配的字符

public CharMatcher negate() // 取反

public CharMatcher and(CharMatcher other) // 與

public CharMatcher or(CharMatcher other) // 或

...

如上圖所示,CharMatcher類有不少的子類,一部分是直接繼承於父類,一部分是繼承於FastMatcher,另外還有繼承於Negated和RangeMatcher。子類經過實現matches方法或重寫其餘父類的方法,從而提供了各類不一樣的具體操做,如Is(判斷是否爲某一個字符),Digit(判斷是否爲數字字符),Ascii(判斷是否爲ASCII字符)等。
再來講說其中一個比較重要的子類——FastMatcher,它和CharMatcher的主要區別在於,FastMatcher取消了父類中相對複雜的precomputed方法,其註釋寫道,這個方法能夠獲得一個處理速度更快的實例,可是執行該方法自己須要花費必定時間,因此只有在須要頻繁調用的狀況下,這樣作才比較划算。
至於這個方法的奧祕在於,它使用BitSet做爲存儲字符的數據結構,而後遍歷全部的字符(Character.MIN_VALUE~Character.MAX_VALUE),根據matches方法放入這個BitSet中,最後根據這個BitSet中1的數量生成其餘類型的CharMatcher實例,包括None,Is,IsEither,SmallCharMatcher(一個單獨的子類)以及BitSetMatcher,這樣就避免了頻繁調用過程當中,(特別是複雜組合的狀況)執行沒必要要實例化操做,而是直接歸約到某一個類的實例上。
而上述那5個類正是繼承於FasterMatcher(或NamedFasterMatcher)。segmentfault

3、設計模式

上一節說到CharMatcher提供不少子類,爲了較好地管理和使用這些類,CharMatcher對外提供了基於內部類的靜態工廠方法或者單例模式來得到某個實例,舉例來講:設計模式

  • 靜態工廠方法
public static CharMatcher is(final char match) {
    return new Is(match);
}

private static final class Is extends FastMatcher {
    
    private final char match;

    Is(char match) {
      this.match = match;
    }
    ...
  }
使用靜態工廠方法的好處,這點在《Effective Java》一書中有詳細的介紹
  • 單例模式
public static CharMatcher ascii() {
    return Ascii.INSTANCE;
}

private static final class Ascii extends NamedFastMatcher {

    static final Ascii INSTANCE = new Ascii();

    Ascii() {
      super("CharMatcher.ascii()");
    }
    ...
}

這樣咱們就能夠很方便地得到一個實例,並對相應的字符類型作處理,好比抽取字符串中全部的數字數組

CharMatcher.inRange('0', '9').retainFrom("abc12d34ef");
// 固然也能夠用Digit類,不過最近的版本已經被標記爲Deprecated
// 區別在於Digit類處理了字符0到9的各類unicode碼,不過大多數狀況仍是處理ASCII數字,因此建議使用inRange
CharMatcher.digit().retainFrom("abc12d34ef");
// 1234

固然也能夠經過negate/or/and產生一些複雜的組合:數據結構

CharMatcher.inRange('0','9').or(CharMatcher.is('d')).retainFrom("abc12d34ef");
// 12d34

另外還有一個ForPredicate的子類,它接收Predicate對象做爲參數,而後用Predicate的apply方法來實現matches方法,這樣就用lamda表達式建立一些實例了,例如:app

CharMatcher.inRange('0', '9').or(CharMatcher.is('d'))
                .or(CharMatcher.forPredicate(c -> c <= 'b' || c > 'e')).retainFrom("abc12d34ef");
// ab12d34f

4、算法分析

  • collapseFrom方法,如代碼註釋所示,把一個字符串中匹配到的(連續)部分替換爲給定的字符,
//CharMatcher.anyOf("eko").collapseFrom("bookkeeper", '-') returns "b-p-r"
public String collapseFrom(CharSequence sequence, char replacement) {
    // This implementation avoids unnecessary allocation.
    int len = sequence.length();
    for (int i = 0; i < len; i++) {
      char c = sequence.charAt(i);
      if (matches(c)) {
        if (c == replacement && (i == len - 1 || !matches(sequence.charAt(i + 1)))) {
          // a no-op replacement
          i++;
        } else {
          StringBuilder builder = new StringBuilder(len).append(sequence, 0, i).append(replacement);
          return finishCollapseFrom(sequence, i + 1, len, replacement, builder, true);
        }
      }
    }
    // no replacement needed
    return sequence.toString();
}
  
private String finishCollapseFrom(
      CharSequence sequence,
      int start,
      int end,
      char replacement,
      StringBuilder builder,
      boolean inMatchingGroup) {
    for (int i = start; i < end; i++) {
      char c = sequence.charAt(i);
      if (matches(c)) {
        if (!inMatchingGroup) {
          builder.append(replacement);
          inMatchingGroup = true;
        }
      } else {
        builder.append(c);
        inMatchingGroup = false;
      }
    }
    return builder.toString();
}
事實上,CharMatcher裏面的算法基本上都和這個差很少程度。

正如註釋部分所述,這個算法沒有分配沒必要要的空間。遍歷過程當中當發現當前字符知足匹配條件,這時再作一次判斷,若是當前字符自己就是所須要替換的字符replacement,那麼這種狀況是不須要進行替換操做(感受能夠直接用一個if(c != replacement)換掉else,並不須要i++的操做),不然將i以前的字符拼上replacement造成一個「半成品」傳入finishCollapseFrom,在該方法中利用了一個布爾值inMatchingGroup來控制是否須要拼接replacement,當發現知足匹配條件時,再檢查inMatchingGroup是否爲false,它表示上一輪拼接的不是replacement,以保證返回的結果中不會出現兩個以上連續的replacement。ide

  • Whitespace.matches 即判斷該字符是否爲空白字符,包括空格,換行等
static final class Whitespace extends NamedFastMatcher {

    static final String TABLE =
        "\u2002\u3000\r\u0085\u200A\u2005\u2000\u3000"
            + "\u2029\u000B\u3000\u2008\u2003\u205F\u3000\u1680"
            + "\u0009\u0020\u2006\u2001\u202F\u00A0\u000C\u2009"
            + "\u3000\u2004\u3000\u3000\u2028\n\u2007\u3000";
    static final int MULTIPLIER = 1682554634;
    static final int SHIFT = Integer.numberOfLeadingZeros(TABLE.length() - 1);

    static final Whitespace INSTANCE = new Whitespace();

    @Override
    public boolean matches(char c) {
      return TABLE.charAt((MULTIPLIER * c) >>> SHIFT) == c;
    }
}

這個算法自己很簡單,即TABLE字符串中是否存在一樣的字符c,巧妙的是它的定位方式。
先說明Integer.numberOfLeadingZeros這個方法返回的是該int變量二進制開頭部分連續的零的個數。TABLE的長度爲32,故SHIFT的值爲27,也就是說,經過字符c和某一個乘子的乘積(超出int範圍以後取低32位)向右移動27位獲得的數值,即爲TABLE的下標索引,例如字符'u2002'其值爲8194,它和1682554634的乘積再右移27位獲得0,而TABLE第0個字符就是'u2002',則斷定相等,字符'u3000'的值爲12288,應用相同算法獲得26,TABLE第26個字符也是'u3000',一樣斷定相等。由此能夠看出,1682554634這個魔數和TABLE是刻意設計成這樣的。可是源碼中沒有解釋如何生成,在GitHub上卻是也有人這麼問過,Guava owner回覆說道:他們確實有一個生成器,可是因爲一些依賴的緣由,並無開源出來。其實若是不考慮性能,咱們能夠用最簡單的暴力法生成乘子和TABLE,代碼以下:函數

@Test
    public void test() {
        // 去掉table中重複的字符
        String WHITE = "\u2002\r\u0085\u200A\u2005\u2000"
                + "\u2029\u000B\u2008\u2003\u205F\u1680"
                + "\u0009\u0020\u2006\u2001\u202F\u00A0\u000C\u2009"
                + "\u2004\u2028\n\u2007\u3000";

        char[] chars = WHITE.toCharArray();
        char filler = chars[chars.length - 1];
        char[] table = new char[32];

        int shift = Integer.numberOfLeadingZeros(WHITE.length());

        for (int i = 0; i <= Integer.MAX_VALUE; i++) {
            Arrays.fill(table, filler);//先用最後一個字符填充整個table
            boolean conflict = false;
            for (char c : chars) {
                int index = (i * c) >>> shift;
                //若是當前字符爲填充字符,則覆蓋填充字符,不然跳過
                if (table[index] != filler) { 
                    conflict = true;
                    continue;
                }
                table[index] = c;
            }
            if (conflict)
                continue;
            System.out.println("MULTIPLIER: " + i);
            System.out.println("TABLE:" + new String(table));
        }
    }

上面能夠獲得多種MULTIPLIER和TABLE的結果。固然,反推過程比較簡單粗暴,必定有更優雅更高效的實現方式。不過這裏想要表達的是,它自己是一個簡單的查找算法,一般的複雜度爲O(logn),這裏巧妙經過映射函數,將字符映射爲字符串下標索引,使得時間複雜度爲O(1),不得不佩服Guava開發者們追求極致的精神。

  • removeFrom方法,即在給定字符串中,刪除其匹配的部分
// CharMatcher.is('a').removeFrom("bazaar") returns "bzr"
public String removeFrom(CharSequence sequence) {
    String string = sequence.toString();
    int pos = indexIn(string);
    if (pos == -1) {
      return string;
    }

    char[] chars = string.toCharArray();
    int spread = 1;

    // This unusual loop comes from extensive benchmarking
    OUT:
    while (true) {
      pos++;
      while (true) {
        if (pos == chars.length) {
          break OUT;
        }
        if (matches(chars[pos])) {
          break;
        }
        chars[pos - spread] = chars[pos];
        pos++;
      }
      spread++;
    }
    return new String(chars, 0, pos - spread);
  }

比較詭異的是,它使用了兩層while循環,以及break [lable]的語法(這種用法並很少見,能夠理解爲goto語句的改良形式,能夠方便地跳出多層循環),不過在內層循環時一樣也作了pos++的操做,本質上仍是O(n)的時間複雜度,算法思想是char數組的位移操做,每次匹配到一個字符時,spread就自增,其餘狀況則每一個數組元素向前移動,具體來講,spread的做用至關於對匹配到的字符進行計數,匹配到1個元素,pos指向的元素及其以後的元素向前移動1步以覆蓋掉上一輪命中的字符,匹配到2個元素,pos執行的元素及其以後的元素向前移動2步,以覆蓋上一次移動留下的空位和上一輪命中的字符,依次類推。最終利用String的構造函數(第二個參數是offset,即初始的偏移位置,第三個參數count,即所需長度)返回正確的字符串。
作個對比,咱們以Apache commons lang3中的StringUtils做爲比較對象,其對應的實現基於Matcher(java.util.regex)的replaceAll方法,亦即將匹配的字符替換爲空字符串,整個遍歷的過程當中重複調用了find()方法,該方法查找當前字符串中匹配的字符,它每次都須要從頭進行搜索,所以時間複雜度爲O(n^2),這樣就比較費時了。

5、其餘

在CharMatcher羅列多種字符的不一樣Unicode碼,若是你在其餘的工做場景下須要用的這些unicode,能夠參考一下CharMatcher。

  • 數字字符
private static final String ZEROES =
        "0\u0660\u06f0\u07c0\u0966\u09e6\u0a66\u0ae6\u0b66\u0be6\u0c66\u0ce6\u0d66\u0de6"
            + "\u0e50\u0ed0\u0f20\u1040\u1090\u17e0\u1810\u1946\u19d0\u1a80\u1a90\u1b50\u1bb0"
            + "\u1c40\u1c50\ua620\ua8d0\ua900\ua9d0\ua9f0\uaa50\uabf0\uff10";
若是要得到其餘數字的unicode,就直接對應加上對應的數值
  • 空白字符
static final String TABLE =
        "\u2002\u3000\r\u0085\u200A\u2005\u2000\u3000"
            + "\u2029\u000B\u3000\u2008\u2003\u205F\u3000\u1680"
            + "\u0009\u0020\u2006\u2001\u202F\u00A0\u000C\u2009"
            + "\u3000\u2004\u3000\u3000\u2028\n\u2007\u3000";
  • 不可見字符
private static final String RANGE_STARTS =
        "\u0000\u007f\u00ad\u0600\u061c\u06dd\u070f\u08e2\u1680\u180e\u2000\u2028\u205f\u2066"
            + "\u3000\ud800\ufeff\ufff9";
private static final String RANGE_ENDS = // inclusive ends
        "\u0020\u00a0\u00ad\u0605\u061c\u06dd\u070f\u08e2\u1680\u180e\u200f\u202f\u2064\u206f"
            + "\u3000\uf8ff\ufeff\ufffb";
  • 單字節長度字符
"\u0000\u05be\u05d0\u05f3\u0600\u0750\u0e00\u1e00\u2100\ufb50\ufe70\uff61"
"\u04f9\u05be\u05ea\u05f4\u06ff\u077f\u0e7f\u20af\u213a\ufdff\ufeff\uffdc"
中文字符就是雙字節長度
相關文章
相關標籤/搜索