教你如何寫Bug:Google Guava源碼分析之——Joiner

我們在碼磚的過程中,經常會遇到List轉字符串、字符串轉List這類需求,當然這不僅僅是單純的轉字符串,而是加入了一個連接符。比如:將一個list轉換成以","分隔的字符傳,這個時候僅僅使用list.toString()是做不到的。初級的猩猩會想到循環list,然後用StringBuilder來拼裝字符串,這樣最後一般會多一個字符,再切分。大概代碼如下:

		List<String> list = new ArrayList<String>();
		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<String> list = new ArrayList<String>();
		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 extends Appendable> 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 extends Appendable> 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的應該不多,非常繁忙之中寫下這篇博客希望能夠幫到大家。

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