Java8 Gold ProgrammerがJava8を復習する

こんにちは。CTO室の神沼(@t_kanuma)です。
私はJava(Spring、Java EE)でのWebシステム開発がキャリアの中で一番長く、以下のOracleの資格を有しているほどにはJavaが好きです。(Javaが持つ隙のない一貫性が好きです。)

  • Java SE6 Programmer

  • Java SE7 Gold Programmer

  • Java SE8 Gold Programmer

  • Java EE5 Web Component Developer

  • Java EE6 Enterprise Java Beans Expert

  • Java EE6 Java Persistence API Expert

しかしここ数年は、クラウドインフラの設計構築や、プログラミングにおいてもPythonやJSを使うことが多くJavaとは少し疎遠になっていました。そうこうしているうちに、Spring Bootは2に、Springは5にバージョンアップしてマイクロサービスアーキテクチャに対応しているし、Java EEはOracleを離れてJakarta EEになっているし、SEはライセンス体系に大きな変更があり、いつの間にかLTS対象は11に、最新は16までバージョンアップしていました。

ちょっとキャッチアップしなきゃあかーんと思い、まずはSE Gold Programmerの資格を11にアップデートすることを目標に定めました。しかしそこで思いました。
いや待て、まずは謙虚に8の復習から始めた方が良さそうだと・・・

この記事は、自分のGitHubの学習用リポジトリを漁り、あーそうそうこうだった、あーだったと、かなりの今更感を伴いながらJava 8をゆるゆるふわふわっと復習して、Tips的にまとめた記事です。箸休め程度にお読みください。

なお、復習の対象はJava7からのアップデート事項のみに絞ります。
Lambda関数とStream APIの話が中心です。

始めに

それまでのJavaの進化の流れとは毛色が異なるLambda関数の導入は衝撃が大きかった記憶があります。Java 8のリリースは2014年ですが、導入の大きな理由を推測すると、クラウドネイティブ/マイクロサービスアーキテクチャ時代の到来を告げるころであり、イベント駆動プログラミングの重要性が高まってきていたこと、そしてIoT/AI時代の到来を告げるころであり、Hadoop/Sparkの台頭と共にビッグデータ処理/分析の重要性が高まってきていたことがあげられます。

後者においてデータ処理における基本は、以下の処理の流れと思っていますが、
1. 生データを構造化する。
2. 構造化したデータをグルーピングして集約する。
3. 集約したデータを可視化する。

この流れにおいて、Lambda式/Stream APIを使うことで上記1と2のコードを素早く、そして柔軟に書けるようになりました。

Lambda式の表記法は無名関数のそれとして昔から関数型言語ではあるものだという認識です。Java8のリリースが2014年ですが、JSでもES6での大幅アップデートの中の1つとして、Arrow関数記法が導入されたのが2015年。これも時代の流れだったのかなと思うと興味深いところです。

私は以前、HDFS/Sparkを使ったIoTデータ分析系のR&D/PoCをやっていました。(その時はPythonを選択しました。)その際に思ったのは、アプリをJavaで書くとなると実際はHadoop/SparkのAPIを利用する訳で、Java 8のAPIは使わないのでは?ということです。では、Lamda関数/Stream APIをガッツリ使うところははどこになるのかと考えたところ・・・個人的な意見ですがそれはFaaSです。FaaSはマイクロサービスを構成する小さなREST APIや様々なイベントソースに対するハンドラー、スモールデータに対する分析処理に向いている思います。そしてFaaSといえばAWS Lambda…もしかしたらAWS Lambdaのサービス名の由来は、これまで話してきた無名関数の表記であるLambda式から来ているのかもしれないと思うと興味深いです。

最後に、一貫性を強く持つガチガチのオブジェクト指向言語であるJavaにどう関数を導入したのか?について述べて本編に入りたいと思います。
Java設計者は関数を独立させて導入せず、馴染みのあるインターフェイスと関連付けることで既存の枠組みとのスムースさを保った印象です。具体的にはメソッドを1つだけ持つインターフェイスを実装する無名クラスのインスタンスを関数としました。そしてこのインターフェイスを関数型インターフェイス(Functional Interface)と名付けました。

1. インターフェイス

1.1 defaultメソッド

defaultメソッドが導入されたことで、インターフェイスが実装を持つことができるようなりました。しかしインターフェイスはこれまで通りインスタンスフィールドを持てるわけではありませんし、抽象クラス無しにprotectedメソッドを要するTemplate Methodパターンは実装できません。defaultメソッドで可能な実装が限られていることを考えれば抽象クラスはまだまだ健在だと思います。

1.2 defaultメソッドの継承

普通のクラス間におけるインスタンスメソッドの継承と同様に、インターフェイス間でdefaultメソッドをオーバーライドすることが可能です。
またJava8は多重継承を許可してはいません。例えば以下のようなクラスAがあるとして、同じメソッドシグネチャを持つdefaultメソッドがCとDに、インスタンスメソッドがBにあるとするとクラスA自身でオーバーライドしない限り、コンパイルエラーになります。

public class A extends B implements C,D {
  ...
}

1.3 staticメソッド

defaultメソッドはインスタンスメソッドですが、それだけでなくstaticメソッドもインターフェイスに持てるようになりました。これによりCollectionインターフェイスに対するCollectionsクラスのような、Util系のクラスを別に作成しなくてもよくなりました。(合体させた結果、あまりに多くのメソッドを持つ場合はクラス全体の見通しが悪くなるため、別にした方が良いと思いますが。)

1.4 @FunctionalInterface

あるインターフェイスを関数型インターフェイスとしてプログラマに扱ってもらうためにこのアノテーションの付与は任意ですが、以下の理由でつけた方がいいと考えます。

  • そのインターフェイスが関数型の要件を満たしていない場合にコンパイルエラーになる。

  • プログラマが、そのインターフェイスが関数型、つまりラムダ式で実装する用のものだと認識できる。

また、複数のメソッドを持つ既存のインターフェース内の1つのメソッドをラムダ式で実装したい場合、つまり既存のインターフェイスを関数型にしたい場合、ラムダ式用のメソッド以外をdefaultメソッドとして実装することで、それが可能になります。(ただし、defaultメソッドに意味のある実装ができる場合に限った方がいいと思いますが。nullだけなど意味のないdefaultでは実装するクラスでオーバーライドし忘れた時が怖いです。)

2. Lambda式の変数

2.1 パラメータ変数のスコープ

ラムダ式のパラメータ変数のスコープはそのラムダ式が存在するスコープになります。変数名がかぶるとコンパイルエラーになるので気をつけましょうという単純な話です。

2.2 エンクロージングスコープへのアクセス

Java8以前より、無名クラスからエンクロージングスコープのローカル変数(メソッド引数含む)にアクセスすることが可能でこれによりクロージャを生成できました。その際に、アクセスするローカル変数にはfinalをつけなくてはなりませんでした。Java8ではfinalをつけなくても、”実質的にfinal”であればアクセスすることができます。Lambda関数も無名クラスのインスタンスであるわけですから、同じようにエンクロージングのローカル変数にアクセスが可能です。ちなみにfinalにする理由は、外側内側双方からの変更できてしまうと複雑度が増すから。またRunnable、Callableを実装したマルチスレッド処理の場合、スレッドセーフではなく競合状態が起きてしまうリスクがあるからだと個人的に思っています。

3. Stream APIでのCollection操作

3.1 Iterable#forEach(..)、Stream#forEach(..)での注意点

Integerはイミュータブルオブジェクトであるため、forEachでの処理が、それを呼び出したオブジェクトに反映されません。(ミュータブルであれば反映される。)

List<Integer> nums = Arrays.asList(1,2,3); 
nums.forEach(x -> x++);
// 1,2,3
nums.forEach(System.out::println);      

3.2 中間メソッドと終端メソッド

中間メソッド(filter, peek, map, distinct, sorted, skipなど)は終端メソッド(forEach, reduce, collect, count, min, max, allMatch, findAnyなど)が呼び出されるまで処理を遅延します(遅延実行、非同期実行)。以下のコードにおいてforEachをコメントアウトした場合、filterもpeekもmapも実行されません。

List<Character> grades = Arrays.asList('A', 'B', 'C');
grades.stream().
// これらは全て、中間メソッド
filter(x -> x == 'A')
.peek(System.out::println)
.map(x -> x == 'A' ? 'S' : x)
// 終端メソッド
.forEach(x -> System.out.println("new grade : " + x));

3.3 フィルタリング

条件に合致する要素のみ取得するのであればStream#filter(..)がシンプルですが、合致しない要素も残したい場合はCollectors.partitioningBy(..)が使えます。

Stream<Integer> nums = Stream.of(2, 3, 5, 7, 11, 13, 17, 19);

// collectで返される型は、Map<Boolean, List<Integer>>
// このMapから、get(true)で合致するList、get(false)で合致しないListを取得できる。
Stream<Integer> filteredNums = nums.collect(Collectors.partitioningBy(num -> {
    return num > 5 && num < 15;
})).get(true).stream();

//7,11,13
filteredNums.forEach(System.out::println);

3.4 マッピング

シンプルなマッピングにはStream#map(..)が使えますが、

List<String> list = Arrays.asList("a", "ab", "abc");
Function<String, Integer> f = x -> x.length();
list.stream().map(f).forEach(y -> System.out.println("Length: " + y);

例えばあるインスタンスからインスタンスフィールドをKey-ValueにしたMapを作りたいような場合は、Collectors.toMap(..)が使えます。

List<Book> books = Arrays.asList(
    new Book("Foo", 2000), 
    new Book("Bar", 3000),
    new Book("Baz", 4000)
);

Map<String, Double> bookMap = books.stream().
    collect(Collectors.toMap(book -> book.getTitle(), book -> book.getPrice()));

BiConsumer<String, Double> consumer = (title, price) -> {
    if (title.startsWith("F")) {
        System.out.println(price);
    }
};

// 2000
bookMap.forEach(consumer);

3.5 groupingと集約

GroupingにはCollectors.groupingBy(..)を利用します。
以下は要素の文字数をキーにグルーピングし、Collectors.countingでグループの個数をカウントしています。

List<String> languages = Arrays.asList("java", "python", "javascript", "c", "dart");
Map<Integer, Long> data = languages.stream().collect(Collectors.groupingBy(
        String::length,
        Collectors.counting()));

// 1=1, 4=2, 6=1, 10=1
data.entrySet().forEach(System.out::println);

以下はカテゴリをキーにグルーピングをして、Collectors.averagingDouble(..)でカテゴリごとの値段の平均を算出しています。

// Itemオブジェクトはフィールドにアイテム名、カテゴリ、値段を持つ。
List<Item> items = Arrays.asList(new Item("Pen", "Stationery", 300), new Item("Pencil", "Stationery", 200),
        new Item("Eraser", "Stationery", 100), new Item("Milk", "Food", 200), new Item("Eggs", "Food", 300));

Map<String, List<Item>> categoryMap = items.stream().collect(Collectors.groupingBy(Item::getCategory));

ToDoubleFunction<Item> func = item -> item.getPrice();
categoryMap.forEach((key, value) -> {
    double avgPrice = value.stream().collect(Collectors.averagingDouble(func));
    // Stationary : 200
    // Food : 250
    System.out.println(key + " : " + avgPrice);
});

3.6 集約系をもっと

Streamインタフェースではなく、IntStreamやDoubleStreamインターフェイスに変換すると引数なしの集約メソッドを使えます。

以下はIntStream、Streamインターフェイスそれぞれを使って最大値を求めています。

// IntStreamに変換
List<Integer> ls = Arrays.asList(10, 47, 33, 23);
OptionalInt max1 = ls.stream().mapToInt(x -> x).max();
// 47
System.out.println(max1.getAsInt()); 

//Streamのまま
Optional<Integer> max2 = ls.stream().max(Comparator.comparing(y -> y));
System.out.println(max2.orElse(0)); 

// 最大値の算出はreduceメソッドでも可能
Optional<Integer> max3 = ls.stream().reduce((a, b) -> a > b ? a : b);
System.out.println(max3.orElse(0));

以下は、合計値を算出しています。

// IntStreamに変換
double sum = ls.stream().mapToInt(x -> x).sum();
System.out.println(sum);

// Stream#reduce(..)でも算出可能
System.out.println(ls.stream().reduce(0, (a, b) -> a + b));

3.7 match系

match系のメソッドはbooleanを返します。

List<Integer> list = Arrays.asList(11, 22, 33, 44, 55, 66);
Stream<Integer> stream = list.Stream();

// 全ての要素が条件にマッチしなければ真
// True
System.out.println(stream.noneMatch(x -> x % 11 > 0));

// 全ての要素が条件にマッチすれば真
// True
System.out.println(stream.allMatch(x -> x % 11 == 0));

// 要素のうちどれかが条件にマッチすれば真
// True
System.out.println(stream.anyMatch(x -> x == 11));

3.8 Parallel Stream

ストリーム処理はマルチスレッドで並列化することができます。

List<Integer> list = Arrays.asList(1,2,3,4);
list.parallelStream().
//ストリームを並列化しているので、mapは複数スレッドで処理される。
map(x -> x*x).
//forEachOrderedのため、ここでの処理は直列化される。
//コードの記述順に処理されるため、結果は必ず1,4,9,16になる。
forEachOrdered(System.out::println);

4. その他

Concurrency UtilitiesにおけるFutureの進化

それまでのFutureパターンでは、Callableインターフェイスを実装したタスクをExecutorServiceから別スレッドで実行し、任意のタイミングでFuture#get()をコールすることで実行結果を取得するものでした。ただFuture#get()の際に起動したスレッドでタスクが完了していないと、元スレッドはブロックしていました。Java8からCompletableFutureが導入され、別スレッドでのタスク完了時のコールバック処理を登録できようになりました。これでより効率的なマルチスレッド処理ができるようになりました。これはJSでのIO(ノンブロッキング)発生時のPromiseオブジェクトの非同期処理と同じ形だと思います。

CompletableFuture.supplyAsync(() -> { /** 別スレッドでの処理 **/})
    .whenComplete((result, exception) -> { /** 完了後の処理 **/ });

終わりに

記憶が蘇り感覚が戻ってきました!
さらに復習を深めつつ、SE 11 Gold Programmerを目指し学習を始めたいと思います!そして学習したことをまた記事にできたらと思います。
最後までお読みいただきありがとうございました。