ラムダ式とストリーム

Effective Java 第 3 版の個人的メモ

  1. 項目 42 匿名クラスよりもラムダ式を選ぶ
  2. 項目 43 ラムダよりもメソッド参照を選ぶ
  3. 項目 44 標準の関数型インタフェースを使う
  4. 項目 45 ストリームを注意して使う
  5. 項目 46 ストリームで副作用のない関数を選ぶ
  6. 項目 47 戻り値型として Stream よりも Collection を選ぶ
  7. 項目 48 ストリームを並列化するときは注意を払う
  8. 参考 URL

Effective Java 3rd 勉強会(11/29)

日時: 2019/11/29 14:00-14:45 参加者(敬称略): 則兼、岩崎、倉元
以下、内容。

1. 項目 42 匿名クラスよりもラムダ式を選ぶ

1.1. 結論

以下の場合は、ラムダ式を使いましょう。

  • 数行のコードで済む
  • 自分への参照が必要
  • 関数型インターフェースの型しか使用しない

それ以外の場合は、匿名クラスを使いましょう。

1.2. 匿名クラスでの実装

Anonymous.java

1
2
3
4
5
6
7
List<String> words = Arrays.asList("apple", "pen", "pineapple");
// 匿名クラスで書く。
Collections.sort(words, new Comparator<String>() {
    public int compare(String s1, String s2) {
        return Integer.compare(s1.length(), s2.length());
    }
});

1 行で済んでおり、自分への参照は不要なので、ラムダ式の方が良いですね。

そもそも匿名クラスとは、、
以下のように記述される処理のことですね。

1
2
3
変数の型 変数 = new クラス名/インターフェース名( ) {
メソッドの処理を記述
};

1.3. ラムダ式での実装

Anonymous.java

1
2
3
List<String> words = Arrays.asList("apple", "pen", "pineapple");
// ラムダ式で書く。
Collections.sort(words, (s1, s2) -> Integer.compare(s1.length(), s2.length()));

Collection.sortの第二引数がComparatorであると認識するのはなぜなのか?

  • Collection.sortの仕様は、引数「List list, Comparator<? super T> c」となっていて、Comparatorが関数型インタフェースだからラムダ式で表現できる。

数行で済んでおり、自分への参照はしておらず、Collections.sortの第 2 引数が関数型インターフェース(Comparator)なので、ラムダ式が向いていますね。

関数型インタフェースとは、、
関数型インターフェースの条件は、大雑把に言って、定義されている抽象メソッドが 1 つだけあるインターフェース。(static メソッドや Object クラスにあるデフォルトメソッドはカウントしない。)

Java8 での関数型インタフェース一覧はOracle Java SE 8 (Uses of Class java.lang.FunctionalInterface)にあります。 3rd party 製のライブラリで@FunctionalInterfaceが付いているインタフェースも、関数型インタフェースですね。

@FunctionalInterfaceを付けることで以下のメリットがある。

  • コンパイルエラーにしてくれる。
  • IDEがもっとよい書き方を提案してくれる。

2. 項目 43 ラムダよりもメソッド参照を選ぶ

2.1. 結論

  • メソッド参照の利点:ラムダ式よりも簡潔に書ける。
  • ただし、同クラスにあるメソッドを用いる場合は、ラムダ式の方が簡潔に書ける場合がある。

2.2. メソッド参照

メソッド参照とは、、
関数型インターフェース(抽象メソッドが 1 つだけ定義されているインターフェース)の変数にメソッドそのものを代入することが出来る。これを「メソッド参照」と呼ぶ。

MethodRefAndLambdaExpression.java

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
var list = new ArrayList<Integer>();

for(var i = 0 ; i < 10 ; i++) {
    list.add(i);
}

// メソッド参照
list.forEach(System.out::print);

// ラムダ式
list.forEach(i -> System.out.print(i));

メソッド参照の方が、簡潔に記載できていますね。

2.3. メソッド参照について深掘り

メソッド参照には、下記 5 種類がある。

上記の例は、Systemクラスのstaticフィールドoutprintlnメソッドなので、静的メソッド参照だったんですね。

メソッド参照形式 コード 等価ラムダ
静的メソッド TypeName::method (args) -> TypeName.method(args)
非静的メソッド(インスタンス#1 の場合) instance::method (args) -> instance.method(args)
非静的メソッド(インスタンスなし) TypeName::method (instance, args) -> instance.method(args)
コンストラクタ#2 TypeName::new (args) -> new TypeName(args)
配列コンストラクタ TypeName[]::new (int size) -> new TypeName[size]

#1 : instanceは、インスタンスへの参照を評価する任意の式です。例えば、getInstance()::methodthis::methodのように書けます。

#2 : TypeNameが非静的な内部クラスである場合、コンストラクタ参照は外部クラスインスタンスのスコープ内でのみ有効です。(つまり、外からコンストラクタが見れる場合使えますよ。という内容です。)

3. 項目 44 標準の関数型インタフェースを使う

基本的には、標準の関数型インタフェースで事足りる。 しかし、特定の場合に限っては独自の関数インタフェースを定義する必要がある。

3.1. 標準の関数型インタフェース

3.2. 独自の関数型インタフェース

以下の場合、独自の関数型インタフェースを作成しましょう。

  • 広く使われ、説明的な名前によってメリットがある場合
  • 強く関係する契約がある場合
    • 引数に制約がある場合
      • 型の制約
      • 2つではなく3つの引数が必要な場合
  • 独自のデフォルトメソッドによるメリットがある場合

作成するときは、@FunctionalInterfaceを使いましょう。書き方に誤りがある場合、コンパイルエラーにしてくれます。

同じ名前のメソッドで、異なる関数型インタフェースを引数に持つのは、ユーザーにとって曖昧となるのでやめましょう。
例:ExecutorService.submit()は、異なる関数インタフェース「Runnable」と「Callable<T>」を引数に持っているのでよくありません。

3.3. 標準の関数型インタフェースについての余談

各標準の関数型インタフェースについて以下の観点でまとめておきます。

  • 従来の書き方
  • 匿名クラスでの書き方
  • lambda 式を使った書き方
  • 呼び出し方

3.3.1. Function

  • 従来の書き方
1
2
3
R functionName(T arg) {
    return new R();
}
  • 匿名クラスでの書き方
1
2
3
4
5
6
Function<T, R> functionalInstance = new Function<T, R>() {
    @Override
    public R apply(T arg) {
        return new R();
    }
};
  • lambda 式を使った書き方
1
Function<T, R> functionalInstance = arg -> new R();
  • 呼び出し方
1
R returnValue = functionalInstance.apply((T) argument);

3.3.2. Consumer

  • 従来の書き方
1
2
3
void consumer(T arg) {
    something(arg);
}
  • 匿名クラスでの書き方
1
2
3
4
5
6
Consumer<T> consumer = new Consumer<T>() {
    @Override
    public void accept(T arg) {
        something(arg);
    }
};
  • lambda 式を使った書き方
1
Consumer<T> consumer = arg -> something(arg);
  • 呼び出し方
1
consumer.accept((T) argument)

3.3.3. Supplier

  • 従来の書き方
1
2
3
T supplier() {
    return new T();
}
  • 匿名クラスでの書き方
1
2
3
4
5
6
Supplier<T> supplier = new Supplier<T>() {
    @Override
    public T get() {
        return new T();
    }
};
  • lambda 式を使った書き方
1
Supplier<T> supplier = () -> new T();
  • 呼び出し方
1
T value = supplier.get();

3.3.4. Predicate

偶奇判定を例に記載します。

  • 従来の書き方
1
2
3
boolean predicate(Integer i) {
  return i % 2 == 0 ? true : false;
}
  • 匿名クラスでの書き方
1
2
3
4
5
6
boolean predicate = new Predicate<T>(){
    @Override
    public boolean test(Integer i) {
        return i % 2 == 0;
    }
};
  • lambda 式を使った書き方
1
Predicate<Integer> checker = (i)-> { return i % 2 == 0; };
  • 呼び出し方
1
boolean result = checker.test(i);

3.3.5. UnaryOperator

String を強調させるメソッドを例に記載します。

  • 従来の書き方
1
2
3
public String apply(String t) {
    return "** " + t + " **";
}
  • 匿名クラスでの書き方
1
2
3
4
5
6
String str = new UnaryOperator<String>(){
	@Override
	public String apply(String t) {
	    return "** " + t + " **";
	}
}
  • lambda 式を使った書き方
1
UnaryOperator<String> operator = (String t) -> { return "**" + t + "**"; }
  • 呼び出し方
1
 String newText = operator.apply(text);

3.3.6. BinaryOperator

足し算メソッドを例に記載します。

  • 従来の書き方
1
2
3
public Integer binaryOperator(Integer i1, Integer i2) {
    return i1 + i2;
}
  • 匿名クラスでの書き方
1
2
3
4
5
6
BinaryOperator<Integer> binaryOperator = new BinaryOperator<Integer>() {
    @Override
    public Integer apply(Integer i1, Integer i2) {
        return i1 + i2;
    }
};
  • lambda 式を使った書き方
1
2
3
BinaryOperator<Integer> binaryOperator = (Integer i1, Integer i2) -> {
    return i1 + i2;
};
  • 呼び出し方
1
int actual = binaryOperator.apply(1, 2);

4. 項目 45 ストリームを注意して使う

4.1. 結論

Stream 処理により簡単になる場合は、主に以下である。
いつStreamを使うべきかという、かっちりとしたルールはない。

  • 単純な要素の変換
  • 要素のフィルタリング処理
  • 単純なオペレーションで要素を結びつける処理
  • 要素をCollectionに集約する処理
  • 特定の基準を満たす要素を探す処理

次のようなことになるのであれば、使わないでおきましょう。

  • Stream 処理ばかりで、複雑になる場合
  • char 型の値の Stream 処理
  • コードブロック内({...})で出来ることを捨ててまで、Stream 処理にしない。

4.2. Stream 処理ばかりで、複雑になる場合

4.2.1. 改善前

ファイル内の単語(行単位)を、アナグラムごとにまとめて、指定数以上含まれている場合は出力するプログラム。

アナグラム(anagram)とは、言葉遊びの一つで、単語または文の中の文字をいくつか入れ替えることによって、全く別の意味にさせる遊びである。Wikipedia

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
var dictionary = new File("hoge.txt");
var minGroupSize = 2;
var groups = new HashMap<String, Set<String>>();

try (var words = Files.lines(dictionary)) {
    words.collect(Collectors.groupingBy(word -> word.chars().sorted()
            .collect(StringBuilder::new, (sb, c) -> sb.append((char) c), StringBuilder::append).toString()))
            .values().stream().filter(group -> group.size() >= minGroupSize)
            .map(group -> group.size() + ":" + group).forEach(System.out::println);
}

解説が必要なほど、複雑だと思うので、以下に記載します。。。(なんでもかんでもStream処理が良いという訳ではないということですね。)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 行ごとの単語をソートする。(アナグラムとして捉えるため。)
word.chars().sorted()

// StringBuilderでソートした文字列を組み立てる。
.collect(StringBuilder::new, (sb, c) -> sb.append((char) c), StringBuilder::append).toString()))

// 指定数以上かどうかでフィルターをかける。
.values().stream().filter(group -> group.size() >= minGroupSize)

// 表示形式を指定して、出力する。
.map(group -> group.size() + ":" + group).forEach(System.out::println);

4.2.2. 改善後

ファイル内の単語(行単位)を、アナグラムごとにまとめて、指定数以上含まれている場合は出力するプログラム。

悪い例でややこしかった、文字列ソートの部分をalphabetizeメソッドに切り出しているので、わかりやすくなっていますね。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
var dictionary = new File("hoge.txt");
var minGroupSize = 2;
var groups = new HashMap<String, Set<String>>();

try (var words = Files.lines(dictionary)) {
    words.collect(Collectors.groupingBy(word -> alphabetize(word)))
    .values().stream()
            .filter(group -> group.size() >= minGroupSize)
            .forEach(g -> System.out.println(g.size() + ":" + g));
}

private static String alphabetize(String s) {
    var a = s.toCharArray();
    Arrays.sort(a);
    return new String(a);
}

4.3. char 型の値の Stream 処理

char型のStream処理は直感的でない動きをしうるので、原則としてchar型の値をStream処理で扱うべきでない。

1
2
3
4
5
6
7
8
 "Hello, world!".chars().forEach(System.out::print);
 // 実行結果
 // 72101108108111443211911111410810033

// (char)にキャストしてあげる。
 "Hello, world!".chars().forEach(x -> System.out.print((char) x));
 // 実行結果
 // "Hello, world!"が表示

4.4. コードブロック内({...})で出来ることを捨ててまで、Stream 処理にしない。

関数型オブジェクトを使用したStream処理で出来ず、コードブロック内({...})で出来ること。

  • ローカル変数の使用(ラムダ式ではfinalでないと使用できない。)
  • 内部メソッドのreturn, ループ内でのbreak/continue, 例外スロー(ラムダ式では出来ない。)

5. 項目 46 ストリームで副作用のない関数を選ぶ

5.1. 前提

Streamの最も重要なところは、純粋関数のみによる演算 であること。

純粋関数とは、副作用を持たない関数のことである。副作用とは、変数の再代入や入出力などによってプログラムの状態が変化することを指します。

副作用があることによるデメリット
・全体の流れが分かりづらくなる可能性がある。
・速度が落ち、並列処理時の問題が出る可能性がある。

5.2. 結論

副作用を持たないようにするため、ストリーム処理は以下のようにする。

  • forEachは、ストリームの演算の結果を示すためだけに利用する。
  • Collectorsを利用する。

5.3. forEachは、ストリームの演算の結果を示すためだけに利用する。

ファイルに含まれる単語の頻度を求めるプログラム。

以下は、ストリーム内で、外部の状態(freq変数)を変更しているため(副作用を持つため)、良くない。

1
2
3
4
5
6
7
// Uses the streams API but not the paradigm--Don't do this!
var freq = new HashMap<String, Long>();
try (Stream<String> words = new Scanner(file).tokens()) {
    words.forEach(word -> {
        freq.merge(word.toLowerCase(), 1L, Long::sum);
    });
}

以下が改善例。 ストリーム内で、外部の状態(freq変数)を変更しておらず、戻り値を代入している。

1
2
3
4
5
6
// Proper use of streams to initialize a frequency table
Map<String, Long> freq;
try (Stream<String> words = new Scanner(file).tokens()) {
    freq = words
        .collect(groupingBy(String::toLowerCase, counting()));
}

5.4. Collectorsを利用する。

ストリームパイプラインの可読性のために、Collectors のメンバーは static インポートしておくべき。

5.4.1. toSet(), toCollection(), toList()

toSet(), toCollection(), toList()を利用した例。

1
2
3
4
List<String> topTen = freq.keySet().stream()
    .sorted(comparing(freq::get).reversed())
    .limit(10)
    .collect(toList());

5.4.2. toMap()

toSet(), toCollection(), toList()以外は、ストリームを Map にするためのものがほとんどである。

toMap()を使ったサンプルを載せる。

1
2
3
4
5
6
7
8
// e.ordinal()は、列挙定数の序数を返す。
Map<ENUM, Integer> map = Stream.of(ENUM.values())
    .collect(Collectors.toMap(
        // key
        e -> e,
        // value
        e -> e.ordinal()
    ));
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
HashMap<ENUM, Integer> map= Stream.of(ENUM.values())
    .collect(Collectors.toMap(
        // key
        e -> e
        // value
        ,e -> e.ordinal()
        // 同一キーの値が複数あった場合、最初の要素を優先する
        // 最後の要素を優先する場合は`(e1, e2) -> e2`とする
        ,(e1, e2) -> e1
        // HashMap型を指定する。
        ,HashMap::new
    ));

5.4.3. groupingBy()

groupingBy()は、classifierをもとに要素をカテゴリー分けした Map を生成する。

classifierには、以下のサンプルのt -> t.length() や 「項目 45 ストリームを注意して使う」で紹介したalphabetize(word)が該当する。

1
2
3
4
5
Stream<String> s = Stream.of("a", "bar", "c", "foo", "zzz");
Map<Integer, List<String>> m = s.collect(Collectors.groupingBy(t -> t.length()));
System.out.println(m);
// 実行結果:
// {1=[a, c], 3=[bar, foo, zzz]}

groupingBy()の 2 つ引数を取るシンプルな例としては以下。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import static java.util.stream.Collectors.*;
.
.
.
Stream<String> words = Stream.of("A", "Bar", "C", "fOO", "zZz", "A");
Map<String, Long> freq = words
        .collect(groupingBy(String::toLowerCase, counting()));
System.out.println(freq);
// 実行結果
// {a=2, bar=1, c=1, foo=1, zzz=1}

5.4.4. その他のメソッド

downstream collectorとしての利用に特化した以下のメソッドは、collect(counting())のように利用してはいけない。 Streamに同様の機能があるため。

  • counting()
    • 要素数を取得する。
  • summing*()
    • 入力値を型変換して、合算する。
  • averaging*()
    • 入力値を型変換して、平均値を算出する。 空ストリームの場合は 0 を返す。
  • summarizing*()
    • 入力値を型変換して、各種集計値を算出する。
  • reducing()
    • 値を集約する。
  • filtering()
    • 条件を満たす値だけ他のコレクターに渡す。
  • mapping()
    • 値を変換して他のコレクターを呼び出す。
  • flatMapping()
    • 値を Stream に変換して他のコレクターを呼び出す。
  • collectingAndThen()
    • 他のコレクターを呼び出した結果を変換する。
  • minBy()
    • 最小値を取得する。
  • maxBy()
    • 最大値を取得する。
  • joining()
    • 結合して String にする。

CollectorsStreamの利用方法については、こちらのブログ(Java Collector, Java Stream)が入門として(大まかに把握するのに)良さそうである。

6. 項目 47 戻り値型として Stream よりも Collection を選ぶ

6.1. 結論

連続した要素をどの型で返すべきか?については、Collection一択

6.2. 選択肢にはなにがあるか

以下4択。

  • ストリーム
  • Iterable
  • collectionインターフェース
  • 配列

6.3. ストリームで返す

StreamはIterableを継承していない。 ストリームを処理する場合には、さらに加工が必要。

for-eachの場合は、Streamのiteratorメソッドを使うしかない。

以下のようにするとコンパイルできないので…

1
2
3
4
var list = List.of("1","2","3").stream();
for (Object o : list.iterator()) {  // cannot compile

}

以下のような対処が必要。

1
2
3
4
5
var list = List.of("1","2","3").stream();
for (Iterator<String> it = list.iterator(); it.hasNext(); ) { // just workaround 
    Object o = it.next();

}

これを簡素にするクラスをつくってもいいが、このようにユーザコード側が煩雑になることを理解する。

6.4. Iterable で返す

ストリームと同じなので割愛

6.5. collection で返す

CollectionインターフェースはIterableのサブタイプであり、ストリームのメソッドも持っているので、イテレーション処理でもストリーム処理でも対応できる。 そのため、連続した要素を返すメソッドの最適な戻り値型は、たいていの場合、Collectionとなる。

もし返却する連続要素が十分小さければ、ArrayListやHashSetなどのCollectionを実装したものを返せばいいのだが、Collectionとして返却するために、大きな連続要素をメモリに蓄えることはすべきでない(このときは、StreamやIteratorも可。JDBCのResultSetと同じといっても良い。)。

返却する連続要素が、大きいけれど簡潔に表現できるものであれば、特別にcollectionを実装してやることを考える。

6.6. 先輩からいただいたありがたいお言葉

そもそも、複数の値を返却するAPIが、Streamを返却するということに違和感があります。 たいていのAPIは、「XXXという条件のものを全部くれ」という言い方になるので、集合であることが普通です。
Streamだと、その名の通り「流れ」になるので、継続的に流れてくる(なので、前の要素には戻らない)、という意味がでてきます。 集合とは違う概念が混ざってくる認識です。
https://docs.oracle.com/javase/jp/8/docs/api/java/util/stream/package-summary.html を読んでみると違いが書いています。

7. 項目 48 ストリームを並列化するときは注意を払う

7.1. 結論

気軽に並列化できる点は素晴らしいのですが、その裏の仕組みが変わるわけでは有りません。 並列処理独特の考慮点がなくなるわけではないので、慎重に使いましょう。

7.2. 得意/不得意

7.2.1. 得意

要素1つで独立した操作ができるとき

  • Collectionに対して処理する
    • ArrayList、HashMap、HashSet、ConcurrentHashMap
    • intやlongのrangeも同等
  • リダクション操作
    • min、max、count、sum
  • 短絡評価
    • anyMatch、allMatch、noneMatch
    • filterも同等

7.2.2. 不得意

他の要素と関係を踏まえた操作のとき

  • limit
  • collectメソッドによって行われる可変リダクション操作(と書いてあるが、ケースバイケース)

7.2.3. 一例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
var stopWatch = new StopWatch();
stopWatch.start();
IntStream.range(0, Integer.MAX_VALUE)
//      .parallel()
        .filter(value -> value % 10000 == 0)
        .boxed()
        .collect(Collectors.toSet());
stopWatch.stop();

System.out.println(stopWatch.prettyPrint());`

paralellの有りなしで、↑のような話だと恩恵を受けやすい。

paralellなしだと3秒、paralellありだと1秒くらい(環境によって差がある)

7.2.4. 並列化すると起きる問題

ストリームだから起きるというわけではなくが、ストリームだから手軽にかけてしまい、ついつい起こしてしまいやすい、ことになる。

一言で言えば、副作用がない処理をする、なのだが、StreamのJavadocには重要なことが書いてある。

8. 参考 URL