资讯专栏INFORMATION COLUMN

[轮子系列]Google Guava之CharMatcher源码分析

pekonchan / 2400人阅读

摘要:子类通过实现方法或重写其他父类的方法,从而提供了各种不同的具体操作,如判断是否为某一个字符,判断是否为数字字符,判断是否为字符等。

本文源地址:http://www.fullstackyang.com/...,转发请注明该地址或segmentfault地址,谢谢!

最近遇到了一些字符匹配的需求,进而仔细地看了CharMatcher的源码,发现还是有点东西值得回味,例如它为我们提供了如何在多种字符类型场景下提高灵活性从而满足不同匹配需求的优秀示范。下面就对CharMatcher类的结构,设计模式,以及几个算法做一些粗浅的分析。

一、关于源码中的彩蛋

CharMatcher类中,开头部分有一张宠物小精灵“小火龙”的字符画,就像本文的封面图一样,一开始不解为何要放一只“小火龙”在这里,后来看到其英文名Charmander才明白过来。好吧,谐音梗……略冷。

二、类的结构和关系

下图是CharMatcher的类关系图,图中蓝色的是abstract类,红色的是final类

首先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)。

三、设计模式

上一节说到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表达式创建一些实例了,例如:

CharMatcher.inRange("0", "9").or(CharMatcher.is("d"))
                .or(CharMatcher.forPredicate(c -> c <= "b" || c > "e")).retainFrom("abc12d34ef");
// ab12d34f
四、算法分析

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。

Whitespace.matches 即判断该字符是否为空白字符,包括空格,换行等

static final class Whitespace extends NamedFastMatcher {

    static final String TABLE =
        "u2002u3000
u0085u200Au2005u2000u3000"
            + "u2029u000Bu3000u2008u2003u205Fu3000u1680"
            + "u0009u0020u2006u2001u202Fu00A0u000Cu2009"
            + "u3000u2004u3000u3000u2028
u2007u3000";
    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
u0085u200Au2005u2000"
                + "u2029u000Bu2008u2003u205Fu1680"
                + "u0009u0020u2006u2001u202Fu00A0u000Cu2009"
                + "u2004u2028
u2007u3000";

        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),这样就比较费时了。

五、其他

在CharMatcher罗列多种字符的不同Unicode码,如果你在其他的工作场景下需要用的这些unicode,可以参考一下CharMatcher。

数字字符

private static final String ZEROES =
        "0u0660u06f0u07c0u0966u09e6u0a66u0ae6u0b66u0be6u0c66u0ce6u0d66u0de6"
            + "u0e50u0ed0u0f20u1040u1090u17e0u1810u1946u19d0u1a80u1a90u1b50u1bb0"
            + "u1c40u1c50ua620ua8d0ua900ua9d0ua9f0uaa50uabf0uff10";
如果要获得其他数字的unicode,就直接对应加上对应的数值

空白字符

static final String TABLE =
        "u2002u3000
u0085u200Au2005u2000u3000"
            + "u2029u000Bu3000u2008u2003u205Fu3000u1680"
            + "u0009u0020u2006u2001u202Fu00A0u000Cu2009"
            + "u3000u2004u3000u3000u2028
u2007u3000";

不可见字符

private static final String RANGE_STARTS =
        "u0000u007fu00adu0600u061cu06ddu070fu08e2u1680u180eu2000u2028u205fu2066"
            + "u3000ud800ufeffufff9";
private static final String RANGE_ENDS = // inclusive ends
        "u0020u00a0u00adu0605u061cu06ddu070fu08e2u1680u180eu200fu202fu2064u206f"
            + "u3000uf8ffufeffufffb";

单字节长度字符

"u0000u05beu05d0u05f3u0600u0750u0e00u1e00u2100ufb50ufe70uff61"
"u04f9u05beu05eau05f4u06ffu077fu0e7fu20afu213aufdffufeffuffdc"
中文字符就是双字节长度

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/76668.html

相关文章

  • [轮子系列]Google GuavaBloomFilter源码分析及基于Redis的重构

    摘要:方法,即表示自动扩容,这个函数名是从中搬过来的。自动扩容最后,也是最重要的,其中用了布隆过滤器中计算型数组长度的方法,得到之后使用命令初始化一个全部为的位数组。 本文源地址:http://www.fullstackyang.com/...,转发请注明该地址或segmentfault地址,谢谢! 一、背景知识 在网上已经有很多关于布隆过滤器的介绍了,这里就不再赘述,下面简单地提炼几个要点...

    xiangchaobin 评论0 收藏0
  • Split in Java

    摘要:比如的结果是,长度为,因为首先匹配任意字符,所以原字符串中每一个都是分割符,这就产生了个空字符串,然后默认为,从后往前删除空字符串,结果就为空。 在 Java 中处理字符串时,split 是一个很常用的操作,但是这一简单的操作,却经常有意想不到的结果,就拿Guava库官方教程中的一个例子来说,,a,,b,.split(,) 的结果是? 1. , a, , b, 2. null, a,...

    iOS122 评论0 收藏0
  • 后端经验

    摘要:在结构上引入了头结点和尾节点,他们分别指向队列的头和尾,尝试获取锁入队服务教程在它提出十多年后的今天,已经成为最重要的应用技术之一。随着编程经验的日积月累,越来越感觉到了解虚拟机相关要领的重要性。 JVM 源码分析之 Jstat 工具原理完全解读 http://click.aliyun.com/m/8315/ JVM 源码分析之 Jstat 工具原理完全解读 http:...

    i_garfileo 评论0 收藏0
  • Guava 源码分析(Cache 原理)

    摘要:缓存本次主要讨论缓存。清除数据时的回调通知。具体不在本次的讨论范围。应该是以下原因新起线程需要资源消耗。维护过期数据还要获取额外的锁,增加了消耗。 showImg(https://segmentfault.com/img/remote/1460000015272232); 前言 Google 出的 Guava 是 Java 核心增强的库,应用非常广泛。 我平时用的也挺频繁,这次就借助日...

    wangxinarhat 评论0 收藏0
  • Guava 源码分析(Cache 原理)

    摘要:缓存本次主要讨论缓存。清除数据时的回调通知。具体不在本次的讨论范围。应该是以下原因新起线程需要资源消耗。维护过期数据还要获取额外的锁,增加了消耗。 showImg(https://segmentfault.com/img/remote/1460000015272232); 前言 Google 出的 Guava 是 Java 核心增强的库,应用非常广泛。 我平时用的也挺频繁,这次就借助日...

    Thanatos 评论0 收藏0

发表评论

0条评论

pekonchan

|高级讲师

TA的文章

阅读更多
最新活动
阅读需要支付1元查看
<