我们在码砖的过程中,经常会遇到List转字符串、字符串转List这类需求,当然这不仅仅是单纯的转字符串,而是加入了一个连接符。比如:将一个list转换成以","分隔的字符传,这个时候仅仅使用list.toString()是做不到的。初级的猩猩会想到循环list,然后用StringBuilder来拼装字符串,这样最后一般会多一个字符,再切分。大概代码如下:
List list = new ArrayList();
list.add("a");
list.add("b");
list.add("c");
String separator = ",";
StringBuilder stringBuilder = new StringBuilder();
list.forEach(str -> {
if (str != null)
stringBuilder.append(str).append(separator);
});
stringBuilder.setLength(stringBuilder.length() - delimiter.length());
System.out.println(stringBuilder.toString());
说句题外话,面试当中也问过很多人,就上面这段代码来说,如果你还在用substring或者干脆用String+String。那只能说你写代码写的真是太随意(Low)了!
上面代码实现了list转string的功能,并且按照逗号分隔,但是大家也看到了,实现起来代码量还是很多的,这对于工程来说并不友好,所以中级的猩猩一般会使用Guava工具中的Joiner来帮助我们实现这一功能。具体代码如下:
List list = new ArrayList();
list.add("a");
list.add("b");
list.add("c");
String separator = ",";
String result = Joiner.on(separator).join(list);
System.out.println(result);
使用Joiner,我们可以实现输出a,b,c。功能已经实现,现在进入本篇文章的核心,这段代码其实没有任何问题,但是这么写是有一个隐藏的bug,而且你怎么测试一般都测不出来。高级的猩猩,经验丰富,写代码的时候会很自然的绕过去,但是初级和中级的可能就会留下坑。
上述代码在实际应用中肯定不会是这样写,大多数情况是传进来一个list变量或者调用方法返回一个list,数据来源可能是数据库或其他,list的内容、长度其实对我们来说并不那么直观。假如,数据库中存在一个null值,这个null值最终被传入list中,那么再执行这段代码会怎样呢?
list.add(null);
再次执行上面的代码会发现,程序报错了:
Exception in thread "main" java.lang.NullPointerException
at com.google.common.base.Preconditions.checkNotNull(Preconditions.java:770)
at com.google.common.base.Joiner.toString(Joiner.java:454)
at com.google.common.base.Joiner.appendTo(Joiner.java:109)
at com.google.common.base.Joiner.appendTo(Joiner.java:154)
at com.google.common.base.Joiner.join(Joiner.java:197)
at com.google.common.base.Joiner.join(Joiner.java:187)
at com.wf.test.main(test.java:62)
这个报错是checkNotNull判断空指针异常,为什么会null指针呢,就是因为我插入了一个null值,是这个null导致的嘛?答案是的,请注意我文章开头的那段代码,循环list的中是有一个非null判断的,所以,我们是要屏蔽掉null值得,但是我一个初级猩猩都能想到得问题Google那帮大神难道会忽略掉这个情况?那你可能是想多了。但是为什么没有屏蔽掉null值,反而报错了呢?
下面我们开始分析Joiner源码
首先我们调用Joiner.on(delimiter).join(list);on方法是返回一个初始化得Joiner
public static Joiner on(String separator) {
return new Joiner(separator);
}
并且初始化Joiner得时候将分隔符传参进去,注意这里得checkNotNull判断,判断的是传入得分隔符,而不是list
private Joiner(String separator) {
this.separator = checkNotNull(separator);
}
回到第一步,join方法,传入list,并使用迭代器
public final String join(Iterable> parts) {
return join(parts.iterator());
}
进入join方法,初始化一个StringBuilder
public final String join(Iterator> parts) {
return appendTo(new StringBuilder(), parts).toString();
}
进入appendTo方法,开始进行转换。好吧,还没开始转换
@CanIgnoreReturnValue
public final StringBuilder appendTo(StringBuilder builder, Iterator> parts) {
try {
appendTo((Appendable) builder, parts);
} catch (IOException impossible) {
throw new AssertionError(impossible);
}
return builder;
}
再次进入appendTo方法,这次开始转换了
@CanIgnoreReturnValue
public A appendTo(A appendable, Iterator> parts) throws IOException {
checkNotNull(appendable);
if (parts.hasNext()) {
appendable.append(toString(parts.next()));
while (parts.hasNext()) {
appendable.append(separator);
appendable.append(toString(parts.next()));
}
}
return appendable;
}
看上去任然没有任何毛病,根据上面得报错,我们可以发现是appendable.append(toString(parts.next()));这行报错,并且是toString这个方法
CharSequence toString(Object part) {
checkNotNull(part); // checkNotNull for GWT (do not optimize).
return (part instanceof CharSequence) ? (CharSequence) part : part.toString();
}
好的,终于发现你了,就是checkNotNull(part); 这里报出了null指针,从上面代码我们可以看出,这里判断得part就是list中的一个元素,因为我们插入了一个null值,所以这里报错了。但是有的猩猩就说了,我明明判断while (parts.hasNext()),既然是null值为什么还是会进入循环,关于这个问题这里就不详细说了,百度一堆一堆的。
从上面的源码可以看出,对于list中的null元素问题,Joiner是没有进行过判断的,就是说如果我们的list中有null值,使用Joiner就会出现这个异常。但是我也说了,Google的大神未必多牛逼但肯定不会比你菜,他们肯定考虑到这个问题,所以如果我们享用Joiner并且避免这种情况,我们应该这样使用:
Joiner.on(separator).skipNulls().join(list);
加一个skipNulls()方法来跳过null值,我们来看skipNulls方法:
public Joiner skipNulls() {
return new Joiner(this) {
@Override
public A appendTo(A appendable, Iterator> parts) throws IOException {
checkNotNull(appendable, "appendable");
checkNotNull(parts, "parts");
while (parts.hasNext()) {
Object part = parts.next();
if (part != null) {
appendable.append(Joiner.this.toString(part));
break;
}
}
while (parts.hasNext()) {
Object part = parts.next();
if (part != null) {
appendable.append(separator);
appendable.append(Joiner.this.toString(part));
}
}
return appendable;
}
@Override
public Joiner useForNull(String nullText) {
throw new UnsupportedOperationException("already specified skipNulls");
}
@Override
public MapJoiner withKeyValueSeparator(String kvs) {
throw new UnsupportedOperationException("can't use .skipNulls() with maps");
}
};
}
这里我们可以看到又初始化了一个Joiner,并且重写了appendTo方法,在这个appendTo方法中,加入了part != null的判断,这样就完美的解决null值问题了。
总结:为什么说这个问题是一个bug呢。因为我们不使用skipNulls,一样可以实现我们的功能,甚至测试过程中也不会出现问题,因为我们的数据中有没有null值谁也不清楚,如果碰巧测试数据没有空值,那这个问题就不会被发现,并且,这个bug一旦拿到线上,查找起来也是要消耗一定功夫的。我相信大部分的人都知道使用Joiner,但是知道使用skipNulls的应该不多,非常繁忙之中写下这篇博客希望能够帮到大家。