1. 程式人生 > >Effective Java 第三版——47. 優先使用Collection而不是Stream來作為方法的返回型別

Effective Java 第三版——47. 優先使用Collection而不是Stream來作為方法的返回型別

Tips
《Effective Java, Third Edition》一書英文版已經出版,這本書的第二版想必很多人都讀過,號稱Java四大名著之一,不過第二版2009年出版,到現在已經將近8年的時間,但隨著Java 6,7,8,甚至9的釋出,Java語言發生了深刻的變化。
在這裡第一時間翻譯成中文版。供大家學習分享之用。
書中的原始碼地址:https://github.com/jbloch/effective-java-3e-source-code
注意,書中的有些程式碼裡方法是基於Java 9 API中的,所以JDK 最好下載 JDK 9以上的版本。但是Java 9 只是一個過渡版本,所以建議安裝JDK 10。

Effective Java, Third Edition

47. 優先使用Collection而不是Stream來作為方法的返回型別

許多方法返回元素序列(sequence)。在Java 8之前,通常方法的返回型別是CollectionSetList這些介面;還包括Iterable和陣列型別。通常,很容易決定返回哪一種型別。規範(norm)是集合介面。如果該方法僅用於啟用for-each迴圈,或者返回的序列不能實現某些Collection方法(通常是contains(Object)),則使用迭代(Iterable)介面。如果返回的元素是基本型別或有嚴格的效能要求,則使用陣列。在Java 8中,將流(Stream)新增到平臺中,這使得為序列返回方法選擇適當的返回型別的任務變得非常複雜。

你可能聽說過,流現在是返回元素序列的明顯的選擇,但是正如條目 45所討論的,流不會使迭代過時:編寫好的程式碼需要明智地結合流和迭代。如果一個API只返回一個流,並且一些使用者想用for-each迴圈遍歷返回的序列,那麼這些使用者肯定會感到不安。這尤其令人沮喪,因為Stream介面在Iterable介面中包含唯一的抽象方法,Stream的方法規範與Iterable相容。阻止程式設計師使用for-each迴圈在流上迭代的唯一原因是Stream無法繼承Iterable。

遺憾的是,這個問題沒有好的解決方法。 乍一看,似乎可以將方法引用傳遞給Stream的iterator方法。 結果程式碼可能有點嘈雜和不透明,但並非不合理:

// Won't compile, due to limitations on Java's type inference

for (ProcessHandle ph : ProcessHandle.allProcesses()::iterator) {

    // Process the process

}

不幸的是,如果你試圖編譯這段程式碼,會得到一個錯誤資訊:

Test.java:6: error: method reference not expected here

for (ProcessHandle ph : ProcessHandle.allProcesses()::iterator) {

為了使程式碼編譯,必須將方法引用強制轉換為適當引數化的Iterable型別:

// Hideous workaround to iterate over a stream

for  (ProcessHandle ph : (Iterable<ProcessHandle>)ProcessHandle.allProcesses()::iterator)

此程式碼有效,但在實踐中使用它太嘈雜和不透明。 更好的解決方法是使用介面卡方法。 JDK沒有提供這樣的方法,但是使用上面的程式碼片段中使用的相同技術,很容易編寫一個方法。 請注意,在介面卡方法中不需要強制轉換,因為Java的型別推斷在此上下文中能夠正常工作:

// Adapter from  Stream<E> to Iterable<E>

public static <E> Iterable<E> iterableOf(Stream<E> stream) {

    return stream::iterator;

}

使用此介面卡,可以使用for-each語句迭代任何流:

for (ProcessHandle p : iterableOf(ProcessHandle.allProcesses())) {

    // Process the process

}

注意,條目 34中的Anagrams程式的流版本使用Files.lines方法讀取字典,而迭代版本使用了scannerFiles.lines方法優於scannerscanner在讀取檔案時無聲地吞噬所有異常。理想情況下,我們也會在迭代版本中使用Files.lines。如果API只提供對序列的流訪問,而程式設計師希望使用for-each語句遍歷序列,那麼他們就要做出這種妥協。

相反,如果一個程式設計師想要使用流管道來處理一個序列,那麼一個只提供Iterable的API會讓他感到不安。JDK同樣沒有提供介面卡,但是編寫這個介面卡非常簡單:

// Adapter from Iterable<E> to Stream<E>

public static <E> Stream<E> streamOf(Iterable<E> iterable) {

    return StreamSupport.stream(iterable.spliterator(), false);

}

如果你正在編寫一個返回物件序列的方法,並且它只會在流管道中使用,那麼當然可以自由地返回流。類似地,返回僅用於迭代的序列的方法應該返回一個Iterable。但是如果你寫一個公共API,它返回一個序列,你應該為使用者提供哪些想寫流管道,哪些想寫for-each語句,除非你有充分的理由相信大多數使用者想要使用相同的機制。

Collection介面是Iterable的子型別,並且具有stream方法,因此它提供迭代和流訪問。 因此,Collection或適當的子型別通常是公共序列返回方法的最佳返回型別。 陣列還使用Arrays.asListStream.of方法提供簡單的迭代和流訪問。 如果返回的序列小到足以容易地放入記憶體中,那麼最好返回一個標準集合實現,例如ArrayListHashSet。 但是不要在記憶體中儲存大的序列,只是為了將它作為集合返回

如果返回的序列很大但可以簡潔地表示,請考慮實現一個專用集合。 例如,假設返回給定集合的冪集(power set:就是原集合中所有的子集(包括全集和空集)構成的集族),該集包含其所有子集。 {a,b,c}的冪集為{{},{a},{b},{c},{a,b},{a,c},{b,c},{a,b , C}}。 如果一個集合具有n個元素,則冪集具有2n個。 因此,你甚至不應考慮將冪集儲存在標準集合實現中。 但是,在AbstractList的幫助下,很容易為此實現自定義集合。

訣竅是使用冪集中每個元素的索引作為位向量(bit vector),其中索引中的第n位指示源集合中是否存在第n個元素。 本質上,從0到2n-1的二進位制數和n個元素集和的冪集之間存在自然對映。 這是程式碼:

// Returns the power set of an input set as custom collection

public class PowerSet {

   public static final <E> Collection<Set<E>> of(Set<E> s) {

      List<E> src = new ArrayList<>(s);

      if (src.size() > 30)

         throw new IllegalArgumentException("Set too big " + s);

      return new AbstractList<Set<E>>() {

         @Override public int size() {

            return 1 << src.size(); // 2 to the power srcSize

         }



         @Override public boolean contains(Object o) {

            return o instanceof Set && src.containsAll((Set)o);

         }

         @Override public Set<E> get(int index) {

            Set<E> result = new HashSet<>();

            for (int i = 0; index != 0; i++, index >>= 1)

               if ((index & 1) == 1)

                  result.add(src.get(i));

            return result;

         }

      };

   }

}

請注意,如果輸入集合超過30個元素,則PowerSet.of方法會引發異常。 這突出了使用Collection作為返回型別而不是StreamIterable的缺點:Collection有int返回型別的size的方法,該方法將返回序列的長度限制為Integer.MAX_VALUE或231-1。Collection規範允許size方法返回231 - 1,如果集合更大,甚至無限,但這不是一個完全令人滿意的解決方案。

為了在AbstractCollection上編寫Collection實現,除了Iterable所需的方法之外,只需要實現兩種方法:containssize。 通常,編寫這些方法的有效實現很容易。 如果不可行,可能是因為在迭代發生之前未預先確定序列的內容,返回Stream還是Iterable的,無論哪種感覺更自然。 如果選擇,可以使用兩種不同的方法分別返回。

有時,你會僅根據實現的易用性選擇返回型別。例如,假設希望編寫一個方法,該方法返回輸入列表的所有(連續的)子列表。生成這些子列表並將它們放到標準集合中只需要三行程式碼,但是儲存這個集合所需的記憶體是源列表大小的二次方。雖然這沒有指數冪集那麼糟糕,但顯然是不可接受的。實現自定義集合(就像我們對冪集所做的那樣)會很乏味,因為JDK缺少一個框架Iterator實現來幫助我們。

然而,實現輸入列表的所有子列表的流是直截了當的,儘管它確實需要一點的洞察力(insight)。 讓我們呼叫一個子列表,該子列表包含列表的第一個元素和列表的字首。 例如,(a,b,c)的字首是(a),(a,b)和(a,b,c)。 類似地,讓我們呼叫包含字尾的最後一個元素的子列表,因此(a,b,c)的字尾是(a,b,c),(b,c)和(c)。 洞察力是列表的子列表只是字首的字尾(或相同的字尾的字首)和空列表。 這一觀察直接展現了一個清晰,合理簡潔的實現:

// Returns a stream of all the sublists of its input list

public class SubLists {

   public static <E> Stream<List<E>> of(List<E> list) {

      return Stream.concat(Stream.of(Collections.emptyList()),

         prefixes(list).flatMap(SubLists::suffixes));

   }



   private static <E> Stream<List<E>> prefixes(List<E> list) {

      return IntStream.rangeClosed(1, list.size())

         .mapToObj(end -> list.subList(0, end));

   }



   private static <E> Stream<List<E>> suffixes(List<E> list) {

      return IntStream.range(0, list.size())

         .mapToObj(start -> list.subList(start, list.size()));

   }

}

請注意,Stream.concat方法用於將空列表新增到返回的流中。 還有,flatMap方法(條目 45)用於生成由所有字首的所有後綴組成的單個流。 最後,通過對映IntStream.rangeIntStream.rangeClosed返回的連續int值流來生成字首和字尾。這個習慣用法,粗略地說,流等價於整數索引上的標準for迴圈。因此,我們的子列表實現似於明顯的巢狀for迴圈:

for (int start = 0; start < src.size(); start++)

    for (int end = start + 1; end <= src.size(); end++)

        System.out.println(src.subList(start, end));

可以將這個for迴圈直接轉換為流。結果比我們以前的實現更簡潔,但可能可讀性稍差。它類似於條目 45中的笛卡爾積的使用流的程式碼:

// Returns a stream of all the sublists of its input list

public static <E> Stream<List<E>> of(List<E> list) {

   return IntStream.range(0, list.size())

      .mapToObj(start ->

         IntStream.rangeClosed(start + 1, list.size())

            .mapToObj(end -> list.subList(start, end)))

      .flatMap(x -> x);

}

與之前的for迴圈一樣,此程式碼不會包換空列表。 為了解決這個問題,可以使用concat方法,就像我們在之前版本中所做的那樣,或者在rangeClosed呼叫中用(int) Math.signum(start)替換1。

這兩種子列表的流實現都可以,但都需要一些使用者使用流-迭代介面卡( Stream-to-Iterable adapte),或者在更自然的地方使用流。流-迭代介面卡不僅打亂了客戶端程式碼,而且在我的機器上使迴圈速度降低了2.3倍。一個專門構建的Collection實現(此處未顯示)要冗長,但執行速度大約是我的機器上基於流的實現的1.4倍。

總之,在編寫返回元素序列的方法時,請記住,某些使用者可能希望將它們作為流處理,而其他使用者可能希望迭代方式來處理它們。 儘量適應兩個群體。 如果返回集合是可行的,請執行此操作。 如果已經擁有集合中的元素,或者序列中的元素數量足夠小,可以建立一個新的元素,那麼返回一個標準集合,比如ArrayList。 否則,請考慮實現自定義集合,就像我們為冪集程式裡所做的那樣。 如果返回集合是不可行的,則返回流或可迭代的,無論哪個看起來更自然。 如果在將來的Java版本中,Stream介面宣告被修改為繼承Iterable,那麼應該隨意返回流,因為它們將允許流和迭代處理。