ちょっと話題の記事

[Java8] はじめて触るStreamの世界

2014.12.17

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

札幌は大荒れ、の予想だったのですが、今のところ大変穏やかな天候です。こんにちは。こむろです。 この記事はJava Advent Calendar 2014の17日目になります。 昨日16日は@zephiransasさんの「Lambda-behaveでテストを書こう」でした。

Java8の素晴らしき世界

Java8を使ってプログラミングしていると、新しい機能を色々と使うことで簡潔で読みやすいコードを記述することが出来ます。これが思いの外楽しくるんるん気分でコードを書くことが出来ます。今までのように型をいちいち書かなきゃとかネスト地獄みたいなところを、少しだけ、でも大胆に改善することが出来、久々にJavaでコードを書いていて楽しいと思った時間でした。AndroidはJavaではn(ry

特にここ数ヶ月、Play for JavaでJava8を利用した開発をしていたのですが、その中でStreamとOptionalを使ってコードを記述するのが、個人的にとても気に入ったので、このエントリーではStreamの機能の一端をこんな風に使っていました、という紹介とラムダ式の省略記法の変更の仕方を紹介したいと思います。

Streamとは

Java8では、Streamパッケージが追加されています。 これにより、map関数などに代表されるScalaのコレクションメソッドのようなもの(map, filter, foldl, foldr, sumなどなど)を実現することが出来るようです。ラムダ式が加わったJava8で新たに追加された強力機能の一つです(Functional Javaなどを使えば今までも使えましたけど)。 ラムダ式前提の機能なので見た目見慣れない点も多くありますが、慣れるとネストが深くなりがちな処理もスッキリと見通しが良いコードになります。また関数型のエッセンスを取り入れることで副作用のない操作になるため、より安全で不具合の少ないコードを書くことが期待できます(一部、Consumerなどは副作用を伴うインターフェースのようなので、全てではないようです)

病気になる

map, filter, sortedなどを使いはじめると、無闇やたらとfor文を憎むようになり、for文を出来るだけ駆逐したくなる病気にかかるようです。かくいう私も病気に羅漢しました。for文を見ると、こうお腹の底からムズムズする何かが這い上がってくる感じがします。そんな素晴らしきStreamの機能の一部をコードと共にご紹介します

Streamを使うとこんなに楽

まずは余計な情報の記述を行う必要がないので、必然的にコードのタイプ量は削減されます。さらに複雑な条件の処理を行う際に、非常にコードが理解しやすい構造になります。通常条件といえば、ifやらswitchを使って条件分岐を行うかと思いますが、気をつけてないと後から条件をたそうとした時に、カオスな状態になって白目を剥くことになります。そういった状態を改善することが出来ます。

サンプルコード

こんなクラスを定義してみます。PersonDtoのファクトリメソッドにはPersonクラスのインスタンスを受け取って適当に処理してPersonDtoクラスのインスタンスを返すようなものを定義します。

Person.java

/**
 * DBのモデルの代わり
 */
public class Person {

    /** 名前 */
    public String name;

    /** 年齢 */
    public int age;

    /** 性別 */
    public int gender;
}

PersonDto.java

import java.util.function.Function;

/**
 * アプリ側で利用するデータの形
 */
public class PersonDto {

    /** 名前 */
    public String name;

    /** 性別 */
    public Gender gender;

    /** 世代カテゴリ */
    public AgeRange ageRange;
    
    /** ファクトリメソッド */
    public static PersonDto create(Person person) {
        PersonDto personDto = new PersonDto();
        personDto.name = person.name;
        personDto.gender = toGender.apply(person.gender);
        personDto.ageRange = toAgeRange.apply(person.age);
        return personDto;
    }

    /**
     * 性別定数からenumへ変換
     */
    public static Function<Integer, Gender> toGender = (x) -> {
        switch (x) {
            case 1: return Gender.MALE;
            case 2: return Gender.FEMALE;
            default: return Gender.OTHER;
        }
    };

    /**
     * 年齢をenumへ変換
     */
    public static Function<Integer, AgeRange> toAgeRange = (x) -> {
        if(x < 20) {
            return AgeRange.YOUNG;
        } else if(x < 50) {
            return AgeRange.MIDDLE;
        } else {
            return AgeRange.OLD;
        }
    };

    enum Gender {
        MALE,
        FEMALE,
        OTHER,
    }

    enum AgeRange {
        YOUNG,
        MIDDLE,
        OLD,
    }
}

Streamを使わずに変換してみる

旧来の書き方だとPersonのListからPersonDtoへのリストの変換はこんな感じでしょうか。名称や年齢、性別に他意はありません

Main.java

class Main {
    private static List<Person> personList = new ArrayList<Person>() {{
        add(new Person(){{
            this.name = "りゅうじょう";
            this.age = 20;
            this.gender = 1;
        }});
        add(new Person(){{
            this.name = "こんごう";
            this.age = 10;
            this.gender = 2;
        }});
        add(new Person(){{
            this.name = "やましろ";
            this.age = 120;
            this.gender = 3;
        }});
    }};

    public static void main(String[] args) {
	List<PersonDto> personDtoList = new ArrayList<>();
        for(Person person : personList) {
        	PersonDto personDto = PersonDto.create(person);
        	personDtoList.add(personDto);
        }
        
        for(PersonDto dto : personDtoList) {
        	System.out.println("PersonDto : " + dto.name + ", " + dto.ageRange + ", " + dto.gender);
        }
    }
}

Streamを使って変換

Main.java

class Main {
    // (snip)
    public static void main(String[] args) {
	List<PersonDto> personDtoList = personList.stream()
                // 全ての要素にファクトリメソッドを適用
                .map(PersonDto::create)
                .collect(Collectors.toList());
                
        personDtoList.stream().forEach( dto -> {
            System.out.println("PersonDto : " + dto.name + ", " + dto.ageRange + ", " + dto.gender);
        });
    }
}

いずれも実行結果は以下のようになります。

PersonDto : りゅうじょう, MIDDLE, MALE
PersonDto : こんごう, YOUNG, FEMALE
PersonDto : やましろ, OLD, OTHER

Streamを使った方を見ると、ちょっとだけ削減された気がします。でも正直

「あんまり記述する量変わってなくね?」

「あとなんか意味の分からん記述入ってね?」

ちょっと待って。ここで判断するのは早急です。僕のサンプルコードもアレですが、以下の理由を読んでからでも遅くはないですよ!

記述する量

これはすでにわかっている余計な情報を大きく削減してます。ラムダ式の引数は型推論によって型が自動的に決まるので、型をいちいち書く必要がありません。IDEのサポートがあるとは言え、やはりすでにわかっている型を明示的に書くよりも書かない方が格段に早いし無駄がないのではないでしょうか。

for(Person person : personList) { // このpersonは、明示的にPersonクラスで宣言してあげる必要あり
personList.stream().map(person -> { // このpersonは勝手にPersonクラスに推論されてるので型を指定する必要なし

この例を見ると、 streammapしてからcollectしています。 それぞれ、型は次のように遷移しています。 Stream<Person> -> Stream<PersonDto> -> List<PersonDto> このように次々と形を変えています。しかし明示的に型を記述する必要はありません。各処理の返却値の型から自動的に型を判別してくれるので、こういった宣言が不要になっています。

見覚えのない記述

.map(PersonDto::create)、これが初見で出てくると訳が分からんのですが、省略した記法になっているだけなので基本的なラムダ式から段階的に変化させていくことができます。こちらを一度理解しておくと分かりやすいです。

ラムダ式の記述をちょっとずつ変化させていきます。

これが基本的な形です。

// 変形第1段階
personList.stream()
    .map(person -> { 
       return PersonDto.create(person);
    })

複数の処理を行わないのであれば1ラインでreturnが不要になります。

// 変形第2段階
personList.stream()
    .map(person -> PersonDto.create(person))
// 変形第3段階
personList.stream()
    .map(PersonDto::create)

ラムダ式の引数をそのまま何もせずにメソッドの入力する場合は、このようなメソッド参照の記法が使えるみたいです。 Swiftのクロージャの簡易記法もそうですが、慣れるまでは一見さんお断り的な雰囲気を醸し出しています。

実はこのあたりは、Intelli J IDEAを利用しているとこの辺りはIDEが色々と注意してくれるので、指示通りにするだけで第2段階まではすぐ出来ます。素晴らしいですね。見慣れると第1,2段階を飛ばして3段階目までパッと変換できます(多分)

処理の途中に変換やフィルタ処理をInjectionするのが簡単

Streamで実装していて一番感じたのは、今成立しているプログラムの構造を壊さずに条件や処理をInjectionすることができるということでした。

例えばこれに成人のみのデータのみ変換して欲しい場合は以下のように記述します。

List<PersonDto> personDtoList = personList.stream()
          // 18歳以上の人のみ抽出
          .filter(person -> person.age > 18)
          // 全てのListの子要素にファクトリメソッドを適用
          .map(PersonDto::create) 
          // Listで集計
          .collect(Collectors.toList());

プログラムの前後関係には一切手を入れず、filterを通過した時点ですでに対象データの抽出は完了しているので、あとは先ほどと同じように変換してあげるだけです。非常に分かりやすい構造です。その処理が通過した時点で結果が出ているものなので、for文と違い最後まで実行しないとどのような状態になっているかがわかりづらいということがありません。

集計した結果を自由に変えることが出来る

filtermapなどを通じて色々と処理した結果ですが、これもまた様々な形に自在に変えることができます。

試しに年齢をKeyにしたMapにしてみましょう。for文でも書けなくはないですが、変更となると結構面倒な記述をする必要がありそうです。

Map<Integer, List<PersonDto>> personDtoMap = personList.stream()
          // 18歳以上の人のみ抽出
          .filter(person -> person.age > 18)
          // 全てのListの子要素にファクトリメソッドを適用
          .map(PersonDto::create) 
          // 名前の文字数をKeyにMapにする
          .collect(Collectors.groupingBy(dto -> dto.name.length()));

これも途中までの処理には一切手をつけていません。最後のcollectのみ変更を加えています。これだけで結果をListから任意のKeyを持ったMapへ変更できます。さらに重複したKeyがある場合はListとしてまとめてくれます。 ちなみに結果は以下のようになります。

{
6: [{name:りゅうじょう, ageRange:MIDDLE, gender:MALE}],
4: [{name:こんごう, ageRange:YOUNG, gender:FEMALE}, {name:やましろ, ageRange:OLD, gender:OTHER}]
}

試しに旧来のコードで書いてみましょう。

Map<Integer, List<PersonDto>> personDtoMap = new HashMap<>();
for(Person person : personList) {
    PersonDto personDto = PersonDto.create(person);
    // Keyを作成
    int length = personDto.name.length();
    // Valueを取得
    List<PersonDto> list = personDtoMap.get(length);
    // 存在しなければListを作成
    if(list == null) {
        list = new ArrayList<>();
    }
    // 重複してなければ追加
    if(!list.contains(personDto)) {
        list.add(personDto);
    }
    personDtoMap.put(length, list);
}

少し過剰な感じがしますが、こんな感じじゃないでしょうか。この辺りはStreamを使った記述の方が圧倒的に分かりやすいですね。

各要素が条件を満たすか検証したい

これは値のValidationの時とかに使ったりするのではないでしょうか。Listの中のデータをチェックして全てValidからInvalidなデータを含むかの試験を考えた時、旧来の方法だとfor文のその側にbooleanの値を定義して、条件をPassしたかどうかによってそれを書き換え、返すという方法でしょうか。

コードにするとこのような感じ

List<String> params = Arrays.asList("URL", "%address", "%gender", "%method%");
boolean match = true;
for(String param : params) {
	// 一つでも条件からはずれればfalseに設定
	if(!param.contains("%")) {
		match = false;
		break;
	}
}
return match;

うーん、何となくバグを埋め込みそうな気配がします。怪しげなパラメータmatchを削減するにしても、if内で、一つでも条件に引っかかったら即座にreturn falsel;と書くやり方でしょうか。いずれにしてもあまり綺麗な書き方には見えません。

Streamで書きなおしてみます。

List<String> params = Arrays.asList("URL", "%address", "%gender", "%method%");
boolean match = params.stream().allMatch( x -> x.contains("%"));	// false

allMatchは、各要素が全て条件式に対してtrueでなければtrueを返しません。それ以外はfalseです。また、どれか一つでもtrueならばPassするのは、anyMatchです。

List<String> params = Arrays.asList("URL", "%address", "%gender", "%method%");
boolean match = params.stream().anyMatch( x -> x.contains("%"));	// true

これは明らかにStreamを利用したコードの方が綺麗で分かりやすく、記述量も少ないですね。

まとめ

今回は当初flatMapなどを使ったサンプルもたくさんモリモリで出して行きたかったのですが、他には出てなさそうな良さげなサンプルコードが思いつかなかったため、大変中途半端な感じでぐぬぬ感があります。 案件でOptionalと共に使いまくっていて色々思いつくかなと思っていたのですが、いざサンプルコードを考えるとなると非常に難しかったです。

Streamを利用した処理の良い所は、中間処理の途中でも、何の処理を行って今現在どういうデータになっているかが一目瞭然で分かることではないでしょうか。for文の場合は、全体を最後まで見渡さないと、どのような結果になっているかがなかなか予測しづらいものです。この辺りをうまく使うと、より不具合の少ない簡潔で分かりやすいコードが記述できると思います。

Java8はラムダ式を初め、StreamやOptionalなど今後のコードの書き方や考え方がガラッと変わるような機能が満載です。 Streamにしてもまだまだたくさんの機能があり、まだまだほんの一部しか触れていません。今回Java8の機能の一つを少し紹介してみて、これをきっかけに最新のJavaに興味を持ってくれる人がいれば幸いです。

参照