天天看点

Guava 是个风火轮之基础工具(1)前言Joiner

guava 是 java 开发者的好朋友。虽然我在开发中使用 guava 很长时间了,guava api 的身影遍及我写的生产代码的每个角落,但是我用到的功能只是 guava 的功能集中一个少的可怜的真子集,更别说我一直没有时间认真的去挖掘 guava 的功能,没有时间去学习 guava 的实现。直到最近,我开始阅读 getting started with google guava,感觉有必要将我学习和使用 guava 的一些东西记录下来。

我们经常需要将几个字符串,或者字符串数组、列表之类的东西,拼接成一个以指定符号分隔各个元素的字符串,比如把 [1, 2, 3] 拼接成 "1 2 3"。

在 python 中我只需要简单的调用 str.join 函数,就可以了,就像这样。

到了 java 中,如果你不知道 guava 的存在,基本上就得手写循环去实现这个功能,代码瞬间变得丑陋起来。

guava 为我们提供了一套优雅的 api,让我们能够轻而易举的完成字符串拼接这一简单任务。还是上面的例子,借助 guava 的 joiner 类,代码瞬间变得优雅起来。

被拼接的对象集,可以是硬编码的少数几个对象,可以是实现了 iterable 接口的集合,也可以是迭代器对象。

除了返回一个拼接过的字符串,joiner 还可以在实现了 appendable 接口的对象所维护的内容的末尾,追加字符串拼接的结果。

guava 对空指针有着严格的限制,如果传入的对象中包含空指针,joiner 会直接抛出 npe。与此同时,joiner 提供了两个方法,让我们能够优雅的处理待拼接集合中的空指针。

如果我们希望忽略空指针,那么可以调用 skipnulls 方法,得到一个会跳过空指针的 joiner 实例。如果希望将空指针变为某个指定的值,那么可以调用 usefornull 方法,指定用来替换空指针的字符串。

需要注意的是,joiner 实例是不可变的,skipnulls 和 usefornull 都不是在原实例上修改某个成员变量,而是生成一个新的 joiner 实例。

mapjoiner 是 joiner 的内部静态类,用于帮助将 map 对象拼接成字符串。

withkeyvalueseparator 方法指定了键与值的分隔符,同时返回一个 mapjoiner 实例。有些家伙会往 map 里插入键或值为空指针的键值对,如果我们要拼接这种 map,千万记得要用 usefornull 对 mapjoiner 做保护,不然 npe 妥妥的。

源码来自 guava 18.0。joiner 类的源码约 450 行,其中大部分是注释、函数重载,常用手法是先实现一个包含完整功能的函数,然后通过各种封装,把不常用的功能隐藏起来,提供优雅简介的接口。这样子的好处显而易见,用户可以使用简单接口解决 80% 的问题,那些罕见而复杂的需求,交给全功能函数去支持。

由于构造函数被设置成了私有,joiner 只能通过 joiner#on 函数来初始化。最基础的 joiner#on 接受一个字符串入参作为分隔符,而接受字符入参的 joiner#on 方法是前者的重载,内部使用 string#valueof 函数将字符变成字符串后调用前者完成初始化。或许这是一个利于字符串内存回收的优化。

整个 joiner 类最核心的函数莫过于 <code>&lt;a extends appendable&gt; joiner#appendto(a, iterator&lt;?&gt;)</code>,一切的字符串拼接操作,最后都会调用到这个函数。这就是所谓的全功能函数,其他的一切 appendto 只不过是它的重载,一切的 join 不过是它和它的重载的封装。

这段代码的第一个技巧是使用 if 和 while 来实现了比较优雅的分隔符拼接,避免了在末尾插入分隔符的尴尬;第二个技巧是使用了自定义的 tostring 方法而不是 object#tostring 来将对象序列化成字符串,为后续的各种空指针保护开了方便之门。

注意到一个比较有意思的 appendto 重载。

在 appendable 接口中,append 方法是会抛出 ioexception 的。然而 stringbuilder 虽然实现了 appendable,但是它覆盖实现的 append 方法却是不抛出 ioexception 的。于是就出现了明知不可能抛异常,却又不得不去捕获异常的尴尬。

这里的异常处理手法十分机智,异常变量命名为 impossible,我们一看就明白这里是不会抛出 ioexception 的。但是如果 catch 块里面什么都不做又好像不合适,于是抛出一个 assertionerror,表示对于这里不抛异常的断言失败了。

另一个比较有意思的 appendto 重载是关于可变长参数。

注意到这里的 iterable 方法,它把两个变量和一个数组变成了一个实现了 iterable 接口的集合,手法精妙!

如果是我来实现,可能是简单粗暴的创建一个 arraylist 的实例,然后把这两个变量一个数组的全部元素放到 arraylist 里面然后返回。这样子代码虽然短了,但是代价却不小:为了一个小小的重载调用而产生了 o(n) 的时空复杂度。

看看人家 g 社的做法。要想写出这样的代码,需要熟悉顺序表迭代器的实现。迭代器内部维护着一个游标,cursor。迭代器的两大关键操作,hasnext 判断是否还有没遍历的元素,next 获取下一个元素,它们的实现是这样的。

hasnext 中关键的函数调用是 size,获取集合的大小。next 方法中关键的函数调用是 get,获取第 i 个元素。guava 的实现返回了一个被覆盖了 size 和 get 方法的 abstractlist,巧妙的复用了由编译器生成的数组,避免了新建列表和增加元素的开销。

当待拼接列表中可能包含空指针时,我们用 usefornull 将空指针替换为我们指定的字符串。它是通过返回一个覆盖了方法的 joiner 实例来实现的。

首先是使用复制构造函数保留先前初始化时候设置的分隔符,然后覆盖了之前提到的 tostring 方法。为了防止重复调用 usefornull 和 skipnulls,还特意覆盖了这两个方法,一旦调用就抛出运行时异常。为什么不能重复调用 usefornull ?因为覆盖了 tostring 方法,而覆盖实现中需要调用覆盖前的 tostring。

在不支持的操作中抛出 unsupportedoperationexception 是 guava 的常见做法,可以在第一时间纠正不科学的调用方式。

skipnulls 的实现就相对要复杂一些,覆盖了原先全功能 appendto 中使用 if 和 while 的优雅实现,变成了 2 个 while 先后执行。第一个 while 找到 第一个不为空指针的元素,起到之前的 if 的功能,第二个 while 功能和之前的一致。

mapjoiner 实现为 joiner 的一个静态内部类,它的构造函数和 joiner 一样也是私有,只能通过 joiner#withkeyvalueseparator 来生成实例。类似地,mapjoiner 也实现了 appendto 方法和一系列的重载,还用 join 方法对 appendto 做了封装。mapjoiner 整个实现和 joiner 大同小异,在实现中大量使用 joiner 的 tostring 方法来保证空指针保护行为和初始化时的语义一致。

mapjoiner 也实现了一个 usefornull 方法,这样的好处是,在获取 mapjoiner 之后再去设置空指针保护,和获取 mapjoiner 之前就设置空指针保护,是等价的,用户无需去关心顺序问题。