使用 Stream 進行管線操作


先來看一個程式片段:

String fileName = args[0];
String prefix = args[1];
String firstMatchdLine = "no matched line";
for (String line : Files.readAllLines(Paths.get(fileName))) {
    if(line.startsWith(prefix)) {
        firstMatchdLine = line;
        break;
    }
}
out.println(firstMatchdLine);

這個程式片段會讀取指定的檔案,找到第一個符合條件的行,然後顯示小寫後離開迴圈。在JDK8中,這類的需求,可以用以下的程式片段來完成:

String fileName = args[0];
String prefix = args[1];
Optional<String> firstMatchdLine =
                Files.lines(Paths.get(fileName))
                     .filter(line -> line.startsWith(prefix))
                     .findFirst();
out.println(firstMatchdLine.orElse("no matched line"));

一眼可見到最大的差別是沒有使用到for迴圈與if判斷式,以及使用了管線化(Pipeline)操作風格,而效能上也有所差異,如果讀取的檔案很大,第二個程式片段會比第一個程式片段來得有效率。

java.nio.file.Fileslines()方法,會傳回java.util.stream.Stream實例,就這個例子來說就是Stream<String>,使用Streamfilter方法會過濾留下符合條件的元素,findFirst()方法會嘗試看看留下的元素有沒有首元素,因為也可能完全沒有元素,因此傳回Optional<String>實例。

效能上的差異性在於,第一個程式片段的Files.readAllLines()方法傳回的是List<String>實例,當中包括了檔案中所有行,如果第一行就符合指定的條件了,那後續的行讀取就是多餘的;第二個程式片段的lines()方法實際上沒有進行任何一行的讀取,filter()也沒有作任何一行的過濾,直到呼叫findFirst()時,filter()指定的條件才會真正去執行,而此時才會要求lines()傳回的Stream進行第一行讀取,如果第一行就符合,那後續的行就不會再讀取,效率的差異性就在於此。

之所以能夠達到這類惰性求值(Lazy evaluation)的效果,也就是需要時findFirst()要求filter(),而filter()再要求讀取檔案下一行這種你需要我再給的行為,功臣就是Stream實例。第一個程式片段要取得List傳回的Iterator,以搭配for迴圈進行外部迭代(External iteration),第二個程式片段則將迭代行為隱藏在lines()filter()findFirst()方法之中,稱之為內部迭代(Internal iteration),因為內部迭代的行為是被隱藏的,因此多了很多可以實現效率的可能性。

Stream的API文件 中談到,Stream繼承了AutoClosable,而BaseStream.close()實作了close()方法,然而基本上,絕大多數的Stream並不需要呼叫close()方法,除了一些IO操作之外,例如Files.lines()Files.list()Files.walk()方法,建議這類操作可以搭配try-with-resource語法。

JDK8引入了Stream API,也引入了管線操作風格,一個管線基本上包括了幾個部份:

  • 來源(Source):可能是檔案、陣列、群集(Collection)、產生器(Generator)等,在這個例子就是指定的檔案。
  • 零或多個中介操作(Intermediate operation):又稱為聚合操作(Aggregate operation),這些操作呼叫時,並不會立即進行手邊的資料處理,它們很懶惰(Lazy),只會在後續中介操作要求資料時才會動手處理下一筆資料,像是第二個程式片段中的filter()方法。
  • 一個最終操作(Terminal operation):最後真正需要結果的操作,這個操作會要求之前懶惰的中介操作開始動手。

這就是Stream API之所以命名為Stream的原因,Stream實例銜接了來源,提到中介操作方法,每個中介操作方法都會傳回Stream實例,但不會實際進行資料處理,每個中介操作後的Stream實例會串連在一起,Stream亦提供最終操作方法,不是傳回Stream而是傳回真正需要的結果,最終操作方法會引發之前串連在一起的Stream實例進行資料處理。

實際上從來源進行一些運算,以求得最終結果,正是程式設計時最常進行的動作,因此JDK8在不少具有來源概念的API上,都增加了可傳回Stream的方法,除了這邊看到的BufferedReader之外,你還可以使用Stream上的靜態方法來建立Stream實例,像是of()方法,對於陣列,也可以使用Arraysstream()方法來建立Stream實例。

Collection也是個例子,其定義了stream()方法會傳回Stream實例,只要是Collection都可以進行中介操作。例如,原本有個程式片段:

List<Person> persons = ...;
List<String> names = new ArrayList<>();
for(Person person : persons) {
    if(person.getAge() > 15) {
        names.add(person.getName().toUpperCase());
    }
}

在JDK8中可以改為以下的風格:

List<Person> persons = ...;
List<String> names = persons.stream()
                            .filter(person -> person.getAge() > 15)
                            .map(person -> person.getName().toUpperCase())
                            .collect(toList()); // 使用了 Collectors.toList() 方法

每個中介操作隱藏了細節,除了增加更多效率改進的空間之外,也鼓勵開發者多利用這類風格,來避免撰寫一些重複流程,或思考目前的複雜演算中,實際上會是由哪些小任務完成。

例如,如果你的程式在for迴圈中使用了if

for(Person person : persons) {
    if(person.getAge() > 15) {
        // 這是下一個小任務
    }
}

也許就有改用filter()方法的可能性:

persons.stream()
   .filter(person -> person.getAge() > 15);

如果你的程式在for迴圈中從一個型態對應至另一個型態:

for(Person person : persons) {
    ...
        ...person.getName().toUpperCase()...
    ...
}

也許就有改用map()方法的可能性:

persons.stream()
       .map(person -> person.getName().toUpperCase());

許多時候,for迴圈中就是滲雜了許多小任務,從而使for迴圈中的程式碼艱澀難懂,辨識出這些小任務,運用中介操作,形成管線化操作風格,就能增加程式碼閱讀時的流暢性。

Stream實際上繼承自java.util.stream.BaseStream,而BaseStream還有DoubleStreamIntStreamLongStream這三個用於基本型態操作的子介面。

Stream只能迭代一次,重複對Stream進行迭代,會引發IllegalStateException