JavaのStream/Optionalに対する理解を深める

はじめに

こんにちは、アソビューでバックエンドエンジニアをしている小原です。

アソビューのバックエンドはJava + Spring Bootで構築されており、プロダクトによってJava8かJava11が利用されています。

Java 8は、2014年3月に正式リリースされ、ラムダ式やStream、Optionalなどの機能が導入されました。皆さんもこれらの機能を活用されている事かと思われます。しかしながら、特にStreamは機能が多いためか、適切に使われてないケースをちらほら見てきました。今回は、StreamとOptionalをおさらいし、理解を深めていただければと思い、記事を書きました。

Stream

概要

JavaのStreamは、Java 8から導入されたAPIであり、コレクションなどの要素に対して高水準のストリーム操作を提供するための仕組みです。 Streamを利用することで、一連のデータ処理を簡潔かつ効率的に記述できるようになります。

  • フィルタリング
    • Stream内の要素から、条件に合致するものだけを取り出す。
  • マッピング
    • Stream内の要素を別の形式に変換する。
  • ソート
    • Stream内の要素をソートする。
  • 畳み込み
    • Stream内の要素を集約して、単一の値にする。

これらの操作を組み合わせることで、複雑なデータ処理を行うことができます。 また、ラムダ式やメソッド参照など、Java 8から追加された関数型プログラミングの手法を使用して記述することが可能です。

特徴

JavaのStreamには、以下のような特徴があります。

  • 一度しか使えない
    • Streamは使い回しができません。Stream内の要素を複数回利用する場合は、Streamを再作成する必要があります。
  • 遅延評価される
    • Streamは、終端操作が呼び出されるまで、中間操作による変換が遅延されます。
  • 並列実行ができる
    • 複数のスレッドで同時に処理を実行することができるため、並列実行をサポートしています。

Streamの生成

JavaのStreamは、以下のような方法で生成できます。

  • 配列からStreamを生成する
String[] array = {"a", "b", "c"};
Stream<String> stream = Arrays.stream(array);
  • コレクションからStreamを生成する
List<String> list = Arrays.asList("a", "b", "c");
Stream<String> stream = list.stream();
  • Stream.of()メソッドを使用する
Stream<String> stream = Stream.of("a", "b", "c");
  • Stream.generate()メソッドを使用する
Stream<Integer> stream = Stream.generate(() -> new Random().nextInt(100));
  • Stream.iterate()メソッドを使用する
Stream<Integer> stream = Stream.iterate(0, n -> n + 2).limit(10);

中間操作と終端操作

Streamにおいて、中間操作とは元のStreamを変換するための操作であり、新しいStreamを返すメソッドを指します。中間操作を繰り返すことで、複数の操作を組み合わせることができます。

一方、終端操作とはStreamの最終的な処理を行って結果を生成するためのメソッドです。終端操作は1回しか実行できず、実行するとStreamはクローズされ利用できなくなります。

中間操作

filter()
  • 要素をフィルタリングする
Stream<Integer> stream = Stream.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
Stream<Integer> filterStream = stream.filter(n -> n % 3 == 0);
// 3, 6, 9
map()
  • 要素を別の要素に変換する
Stream<String> stream = Stream.of("abcd", "a", "ab");
Stream<Integer> lengthStream = stream.map(string -> string.length());
// 4, 1, 2 (StringからIntegerのStreamに変換されている点に注目)
flatMap()
  • 要素を別のStreamに展開して結合する
Stream<List<String>> stream = Stream.of(
        Arrays.asList("A", "B", "C"),
        Arrays.asList("D", "E")
);
Stream<String> flatMapStream = stream.flatMap(strings -> strings.stream());
// "A", "B", "C", "D", "E" (2つのListの要素が一つのStreamの要素に統合されている)
distinct()
  • 重複した要素を除去する
Stream<Integer> stream = Stream.of(1, 2, 2, 3, 3, 3, 4, 5, 5);
Stream<Integer> distinctStream = stream.distinct();
// 1, 2, 3, 4, 5
sorted()
  • 要素をソートする
Stream<Integer> stream = Stream.of(3, 2, 4, 5, 1);
Stream<Integer> sortedStream = stream.sorted();
// 1, 2, 3, 4, 5

Stream<Integer> stream = Stream.of(3, 2, 4, 5, 1);
Stream<Integer> sortedStream = stream.sorted(Comparator.reverseOrder()); // 逆順にソート
// 5, 4, 3, 2, 1
peek()
  • 要素を処理する
Stream<String> stream = Stream.of("a", "b", "c");
stream.peek(s -> System.out.println("This is " + s));
// 後述する終端処理のforEachと異なり、peekの後にfilterなどの中間操作を追加できる
limit()
  • Streamの要素数を指定された数に制限する
Stream<Integer> stream = Stream.of(1, 2, 3, 4, 5);
Stream<Integer> limitStream = stream.limit(3);
// 1, 2, 3
skip()
  • 先頭から指定された数の要素を無視する
Stream<Integer> stream = Stream.of(1, 2, 3, 4, 5);
Stream<Integer> skipStream = stream.skip(2);
// 3, 4, 5
takeWhile()
  • 先頭要素から指定された条件がfalseになるまで取得する
Stream<Integer> stream = Stream.of(1, 2, 3, 4, 5);
Stream<Integer> takeWhileStream = stream.takeWhile(n -> n < 4);
// 1, 2, 3
dropWhile()
  • 先頭要素から指定された条件がfalseになるまで無視する
Stream<Integer> stream = Stream.of(1, 2, 3, 4, 5);
Stream<Integer> dropWhileStream = stream.dropWhile(n -> n < 4);
// 4, 5
parallel()
  • 並列処理を有効にする
    • 要素が複数のスレッドで並列処理されるようになります
Stream<Integer> stream = Stream.of(1, 2, 3, 4, 5);
Stream<Integer> parallelStream = stream.parallel();
// 上記のparallelStreamは複数のスレッドで並列に実行される
並列スレッド数について

parallel()で並列に処理を行う場合、mainスレッドと共通ForkJoinPoolのスレッドが利用されます。デフォルトでは使用可能なプロセッサの数となります。

試しに手元のM1プロセッサを搭載したMacBookでスレッド数を取得してみました。

List<String> threadNames = IntStream.range(1, 100)
        .parallel()
        .boxed()
        .map(integer -> Thread.currentThread().getName())
        .distinct()
        .sorted()
        .collect(Collectors.toList());
System.out.println(threadNames);
// [ForkJoinPool.commonPool-worker-1, ForkJoinPool.commonPool-worker-2, ForkJoinPool.commonPool-worker-3, ForkJoinPool.commonPool-worker-4, ForkJoinPool.commonPool-worker-6, ForkJoinPool.commonPool-worker-7, main]

M1プロセッサのコア数は8であり、期待通りのスレッド数で並列実行されていることが確認できました。

この並列スレッド数を制御するにはForkJoinPoolのsubmit内でStreamを実行する事です。 以下は、スレッド数を2に制限した場合の例です。

List<String> threadNames = new ArrayList<>();
ForkJoinPool forkJoinPool = new ForkJoinPool(2);
forkJoinPool.submit(() -> {
    threadNames.addAll(
            IntStream.range(1, 100)
                    .parallel()
                    .boxed()
                    .map(integer -> Thread.currentThread().getName())
                    .distinct()
                    .sorted()
                    .toList()
    );
}).get();
System.out.println(threadNames);
// [ForkJoinPool-1-worker-1, ForkJoinPool-1-worker-2]

期待通りに、スレッド数が2に制限されていることが確認できました。

バッチで重いデータ処理を複数のスレッドで同時に処理してパフォーマンスを上げるには良いかもしれませんが、Webアプリケーションではスレッド数増加により全体のパフォーマンスが低下する可能性があるため、使用は慎重に行ってください。

sequential()
  • 並列処理を無効にする
    • parallel()で並行処理が有効になっている状態を無効にします
Stream<Integer> parallelStream = Stream.of(1, 2, 3, 4, 5).parallel(); // 並列実行を有効にした
Stream<Integer> sequentialStream = parallelStream.sequential(); // 並列実行を無効にした

終端操作

collect()
  • 要素を収集して別の形式に変換する
    • Collectorsのメソッドを利用して、さまざまな形式変換が可能です。
Collectors.toList(), toSet(), toMap(), toCollection()
  • 要素をコレクションに変換する
Stream<String> stream = Stream.of("a", "ab", "abc");
List<String> list = stream.collect(Collectors.toList());
// [a, ab, abc] <- ArrayList

Stream<String> stream = Stream.of("a", "ab", "abc");
Set<String> set = stream.collect(Collectors.toSet());
// [a, ab, abc] <- HashSet

Stream<String> stream = Stream.of("a", "ab", "abc");
Map<String, Integer> map = stream.collect(Collectors.toMap(s -> s, s -> s.length()));
// {a=1, ab=2, abc=3} <- HashMap

Stream<String> stream = Stream.of("a", "ab", "abc");
Set<String> set = stream.collect(Collectors.toCollection(() -> new LinkedHashSet<>()));
// [a, ab, abc] <- LinkedHashSet (任意のコレクションを指定できる)
Collectors.joining()
  • 要素を指定された区切り文字で結合する
String joined = Stream.of("a", "b", "c").collect(Collectors.joining(", "));
// abc
Collectors.groupingBy()
  • 要素をグループ化する
Map<Integer, List<String>> map = Stream.of("aaaa", "b", "cc", "d")
        .collect(Collectors.groupingBy(s -> s.length()));
// {1=[b, d], 2=[cc], 4=[aaaa]}
Collectors.partitioningBy()
  • 要素を条件で分割する
Map<Boolean, List<Integer>> map = Stream.of(1, 2, 3, 4, 5)
        .collect(Collectors.partitioningBy(n -> n % 2 == 0));
// {false=[1, 3, 5], true=[2, 4]}
Collectors. summingInt(), summingLong(), summingDouble()
  • 要素の合計値を計算する
int sum = Stream.of(1, 2, 3, 4, 5).collect(Collectors.summingInt(Integer::intValue));
// 15
Collectors.averagingInt(), averagingLong(), averagingDouble()
  • 要素の平均値を計算する
double average = Stream.of(1, 2, 3, 4, 5).collect(Collectors.averagingInt(integer -> integer.intValue()));
// 3.0
Collectors.mapping(), flatMapping()
  • 要素を別の型にマッピングし、そのマッピング結果に対して別のコレクターを適用するためのメソッド
    • 第1引数には、マッピングを行うためのFunctionオブジェクト、第2引数には、適用するコレクターを指定して使います
List<Integer> list = Stream.of("a", "bb", "ccc")
        .collect(Collectors.mapping(s -> s.length(), Collectors.toList()));
// [1, 2, 3]

List<String> list = Stream.of("a,b", "c", "d,e")
        .collect(Collectors.flatMapping(str -> Arrays.stream(str.split(",")), Collectors.toList()));
// [a, b, c, d, e]
forEach()
  • 各要素に対してアクションを実行する
Stream<String> stream = Stream.of("a", "b", "c");
stream.forEach(x -> System.out.println(x));
// a, b, c
reduce()
  • 要素を累積して単一の値にまとめる
Stream<Integer> stream = Stream.of(1, 2, 3, 4, 5);
Optional<Integer> result = stream.reduce((a, b) -> a * b);
// Optional[120]

Stream<Integer> stream = Stream.of(1, 2, 3, 4, 5);
Integer result = stream.reduce(1, (a, b) -> a * b);
// 120 (reduceの第1引数で初期値を与えると、戻り値がOptionalではなくなる点に注目)
min(), max()
  • 最小・最大要素を返す
Stream<Integer> stream = Stream.of(3, 2, 4, 5, 1);
Optional<Integer> min = stream.min((x, y) -> Integer.compare(x, y));
// 1

Stream<Integer> stream = Stream.of(3, 2, 4, 5, 1);
Optional<Integer> max = stream.max((x, y) -> Integer.compare(x, y));
// 5
count()
  • 要素数を返す
Stream<Integer> stream = Stream.of(1, 2, 3, 4, 5);
long count = stream.count();
// 5
toArray()
  • 要素を配列に変換する
Stream<Integer> stream = Stream.of(1, 2, 3, 4, 5);
Integer[] array = stream.toArray(value -> new Integer[value]);
// [1, 2, 3, 4, 5]
anyMatch()
  • 指定された条件に一致するものがあるかどうかを返す
Stream<Integer> stream = Stream.of(1, 2, 3, 4, 5);
boolean isAnyMatch = stream.anyMatch(n -> n % 2 == 0);
// true
allMatch()
  • 指定された条件にすべて一致するかどうかを返す
Stream<Integer> stream = Stream.of(1, 2, 3, 4, 5);
boolean isAllMatch = stream.allMatch(n -> n % 2 == 0);
// false
noneMatch()
  • 指定された条件に一致しないかどうかを返す
Stream<Integer> stream = Stream.of(1, 2, 3, 4, 5);
boolean isNoneMatch = stream.noneMatch(n -> n % 2 == 0);
// false
findFirst()
  • 最初の要素を返す
Stream<Integer> stream = Stream.of(1, 2, 3, 4, 5);
Optional<Integer> first = stream.findFirst();
// 1
findAny()
  • いずれかの要素を返す
Stream<Integer> stream = Stream.of(1, 2, 3, 4, 5);
Optional<Integer> any = stream.findAny();
// 1, 2, 3, 4, 5 のどれかが返る

Streamとnull

Streamはnullを要素として扱うことができますが、逆に言えば要素にnullが含まれてることを考慮しなくてはなりません。例えばnullの要素が含まれたStreamで、中間操作や終端操作で要素のメソッドを実行した場合、NullPointerExceptionが発生してしまいます。

要素にnullが含まれており、かつnull要素を無視したい場合は、先にnull値を除外してから中間操作と終端操作を行うようにしましょう。下記のようにfilter()メソッドを使えば簡単に取り除くことができます。

// NullPointerExceptionが発生する例
List<String> list = Arrays.asList("a", "b", null, "d");
List<String> result = list.stream()
                          .filter(s -> s.length() > 0) // 3つ目の要素でNullPointerException 
                          .collect(Collectors.toList()); 

// filter()を利用してnull値を除外した例
List<String> result = list.stream()
        .filter(value -> value != null) // null値を除外
        .filter(s -> s.length() > 0)
        .collect(Collectors.toList());

プリミティブ型のStream

プリミティブ型には、対応した専用のStreamが用意されています。 プリミティブ型の値を直接扱えるため、オートボクシングやアンボクシングによるオーバーヘッドを避けることができます。

以下のStreamが提供されています。

  • IntStream
    • int型の値を扱うStream
  • LongStream
    • long型の値を扱うStream
  • DoubleStream
    • double型の値を扱うStream

次のように範囲を簡単に生成できます。

IntStream stream = IntStream.range(1, 6); // 終端値を含まない -> 1, 2, 3, 4, 5
IntStream stream = IntStream.rangeClosed(1, 5); // 終端値を含む -> 1, 2, 3, 4, 5

プリミティブ型のStreamでは boxed() を利用してラッパークラスのStreamに変換できます。boxed() により、通常のStreamになることで、他の型への変換などが行えます。

List<String> list = IntStream.range(1, 4)
        .boxed()
        .map(integer -> "Value is " + integer)
        .collect(Collectors.toList());
// [Value is 1, Value is 2, Value is 3]

Optional

概要

JavaのOptionalは、Java 8から導入されたクラスです。メソッドの戻り値として利用することを想定して設計されています。

nullを返すかもしれないメソッドの戻り値にOptionalを導入することで、呼び出し元に対して返す値がないかもしれないことを明示的に伝えることができます。 これにより、nullチェックを行わずに値が存在しない場合のデフォルト動作の指定や適切なエラーハンドリングを促すことが可能になります。

特徴

JavaのOptionalには、以下のような特徴があります。

  • 1つの値があるかどうかを表現する
    • Streamと異なり、単一の値が存在するかどうかを表します。
  • 遅延評価される
    • Streamと同様に、終端操作が呼び出されるまで、中間操作による変換が遅延されます。

Optionalの生成

JavaのOptionalは、以下のような方法で生成できます。

  • of()メソッド
    • 引数に渡された値を持つOptionalを生成します。引数がnullの場合はNullPointerExceptionを発生させます。
Optional<String> name = Optional.of("Value");
  • ofNullable()メソッド
    • 引数に渡された値がnullでない場合は、その値を持つOptionalを生成し、nullの場合は空のOptionalを生成します。
String string = null;
Optional<String> name = Optional.ofNullable(string);
  • empty()メソッド
    • 空のOptionalを生成します

中間操作と終端操作

OptionalにもStreamと同様に中間操作と終端操作があります。

中間操作

filter()
  • 引数として渡された条件を満たす場合に、現在のOptionalを返すが、条件を満たさない場合には、空のOptionalを返す
Optional<String> optional = Optional.of("abc");
Optional<String> filteredOptional = optional.filter(n -> n.startsWith("a"));
// Optional[abc]

Optional<String> optional = Optional.of("abc");
Optional<String> filteredOptional = optional.filter(n -> n.startsWith("x"));
// Optional.empty    
map()
  • 値を別の値に変換する
Optional<String> optional = Optional.of("abc");
Optional<Integer> mapOptional = optional.map(String::length);
// Optional[3]
flatMap()
  • Optionalの値を別のOptionalに変換する
Optional<String> optional = Optional.of("abc");
Optional<String> flatMapOptional = optional.flatMap(n -> Optional.of(n.toUpperCase()));
// Optional[ABC]
or()
  • 2つのOptionalのうち、1つでも値を持っている方を返す
Optional<String> optional1 = Optional.of("value1");
Optional<String> optional2 = Optional.of("value2");
Optional<String> optional3 = Optional.empty();

Optional<String> orOptional = optional3.or(() -> optional1).or(() -> optional2);
// Optional[value1]

終端操作

isPresent()
  • 値を持っている場合にはtrueを返し、空の場合にはfalseを返す
Optional<String> optional = Optional.of("abc");
boolean isPresent = optional.isPresent();
// true

Optional<String> optional = Optional.empty();
boolean isPresent = optional.isPresent();
// false
isEmpty()
  • 値を持っている場合にはfalseを返し、空の場合にはtrueを返す
Optional<String> optional = Optional.of("abc");
boolean isPresent = optional.isEmpty();
// false

Optional<String> optional = Optional.empty();
boolean isPresent = optional.isEmpty();
// true
get()
  • 保持する値を取得する
    • 空の場合はNoSuchElementExceptionがスローされます。
Optional<String> optional = Optional.of("abc");
String get = optional.get();
// "abc"

Optional<String> optional = Optional.empty();
String get = optional.get();
// NoSuchElementExceptionをスロー
orElse()
  • 値を持っていない場合に、引数として渡されたデフォルト値を返す
Optional<String> optional = Optional.empty();
String orElse = optional.orElse("abc");
// abc
orElseGet()
  • 値を持っていない場合に、引数として渡されたSupplierから値を取得して返す
    • orElse()と異なり、遅延評価されます。デフォルト値として固定の値を持つ場合はorElse()を利用し、インスタンスの生成や何らかの処理を伴うデフォルト値はorElseGet()を使用しましょう。
Optional<String> optional = Optional.empty();
String orElseGet = optional.orElseGet(() -> UUID.randomUUID().toString()); // orElseの場合は、値があっても利用されないUUIDの計算が行われてしまう
// UUIDの文字列
orElseThrow()
  • 値を持っていない場合に、指定された例外をスローする
Optional<String> optional = Optional.empty();
String value = optional.orElseThrow(() -> new IllegalStateException("It's empty"));
// IllegalStateExceptionをスロー
ifPresent()
  • 値を持っている場合に、持っている値をCosumerの引数に渡して処理を行う
Optional<String> optional = Optional.of("abc");
optional.ifPresent(value -> System.out.println("Value is " + value));
// Value is abc が標準出力に出力される
ifPresentOrElse()
  • 値を持っている場合はifPresent()と同様に、持ってない場合は任意の処理を行う
Optional<String> optional = Optional.of("value");
optional.ifPresentOrElse(
        value -> System.out.println("Value is " + value),
        () -> System.out.println("No value provided")
);
// Value is abcが標準出力に出力される

OptionalからStreamへの変換

Optionalはstream()メソッドが用意されており、このメソッドを呼び出すことで要素が1つ、もしくは空のStreamに変換できます。

List<Integer> value = Arrays.asList(1, 2, 3);
Optional<List<Integer>> optional = Optional.ofNullable(value);
Stream<Integer> stream = optional.stream()
        .flatMap(integers -> integers.stream());

map()でnullを返した場合の挙動

map()に適用した関数がnullを返した場合、Optional.emptyの状態になります。isPresent()はfalseになり、orElse()の値が適用される点に注意してください。

Optional<String> optional = Optional.of("abc");
String result = optional.map(s -> {
            if (s.equals("abc")) {
                return null;
            } else {
                return s.toUpperCase();
            }
        })
        .orElse("def");
// def

最後に

ざっとStreamとOptionalの使い方を紹介させていただきました。

Streamの最終的な要素数を取得する際に collect(Collectors.toList()) でListに変換して、size() を呼ぶといった事をしていませんか?それ、count() でできます。こちらの記事でStreamとOptionalの理解が深まっていただければ幸いです。

アソビューでは一緒に挑戦していくエンジニアを募集しています。カジュアル面談もやっていますので、気になった方はエントリーのほどお願いいたします。 www.asoview.com