Java8超入門 - for文の代わりにStreamを使おう(3)
前回の記事では、Stream APIのfilter
メソッドを使って、集合データから条件に合致する要素を抽出する方法を紹介した。
今回は集合データ内の要素を変換するmap
メソッドを紹介する。
これまでと同様に、シンプルなPerson
クラスを利用する。
public class Person { private String name; private boolean maleFlag; private int age; public String getName() { return this.name; } public boolean isMale() { return this.maleFlag; } public int getAge() { return this.age; } public Person(String name, boolean maleFlag, int age) { this.name = name; this.maleFlag = maleFlag; this.age = age; } }
for
ループによるコード
このPerson
オブジェクトを格納したコレクションから、指定した文字列で始まる名前だけを取り出すコードを書いてみよう。従来のfor
文を使ったコードは次のようになるだろう。
public class PersonUtil { public List<String> collectNamesStartsWith(List<Person> persons, String prefix) { List<String> names = new ArrayList<>(); for (Person person : persons) { String name = person.getName(); if (name.startsWith(prefix)) { names.add(name); } } return names; }
簡単なコードである。まず該当する名前を格納するためのコレクションを用意する。そしてPerson
コレクションの要素をすべて走査し、条件に該当する名前があった場合には、用意したコレクションに格納して、最後にリターンする。
このメソッドを使って、B
から始まる名前を集めてみよう。
List<Person> presidents = Arrays.asList( new Person("Richard Nixon", true, 56), new Person("Gerald Ford", true, 61), new Person("Jimmy Carter", true, 52), new Person("Ronald Reagan", true, 69), new Person("George Bush", true, 64), new Person("Bill Clinton", true, 46), new Person("George Bush", true, 54), new Person("Barack Obama", true, 47) ); List<String> names = new PersonUtil().collectNamesStartsWith(presidents, "B"); for (String name : names) { System.out.println(name); }
結果は次のようになる。
Bill Clinton Barack Obama
Stream APIによる書き換え
では、いつものように、このコードをStream APIを使って書き換えてみよう。
public List<String> collectNamesStartsWith2(List<Person> persons, String prefix) { return persons.stream() .map(p -> p.getName()) .filter(s -> s.startsWith(prefix)) .collect(Collectors.toList()); }
全体の流れを簡単に説明しよう。
return
文の1行目ではstream()
メソッドを呼び出して、Person
のList
をStream
に変換している。
続く2行目では、map
メソッドを呼び出している。ここで行っているのはPerson
のStream
内の各要素から名前を取り出して、文字列のStream
に変換することだ。
続けて3行目では、取り出した名前の中から、条件に合うものだけを抽出するために、filter
メソッドを呼び出している。
最後にcollect
メソッドを呼び出して、Stream
をList
に変換している。
以降、各処理を詳しく説明していこう。
map
メソッドは2つの型変数を利用する
Stream
インターフェースのmap
は、集合データ内の各要素を変換するメソッドだ。map
メソッドのシグニチャーは次のようになっている。
public interface Stream<T> ... <R> Stream<R> map(Function<? super T, ? extends R> mapper);
Javaのジェネリクス(総称型)の仕組みに詳しくない人は、このシグニチャーを見て少々面食らうかもしれない。
順に説明していこう。
1行目はStream
インターフェースの宣言である。
ここでは、型変数のT
を指定して、総称型のインターフェースにしている。これにより、List<E>
やSet<E>
と同様に、任意の型を扱うことができる。
2行目ではmap
メソッドのシグニチャーを定義している。
最初の<R>
は型変数の宣言である。Stream
インターフェースのT
に加えて、map
メソッドでも型変数のR
を宣言することで、map
メソッドではT
とR
という2つの型変数を利用できるようにしている。
次のStream<R>
は、map
メソッドの戻り値の宣言だ。これはmap
メソッドの戻り値がStream<R>
型、すなわちR
型を格納したStream
オブジェクトであることを示している。map
メソッドはStream<T>
インターフェースに定義されているので、このメソッドはStream<T>
をStream<R>
に変換する役割を持つことになる。
続くmap(Function<? super T, ? extends R> mapper)
はちょっとわかりづらいかもしれない。ここで定義しているのは、メソッド名と仮引数である。すなわち、map
がメソッド名で、続く括弧内は、Function<? super T, ? extends R>
までが引数の型で、mapper
が仮引数名である。
? super
および? extends
は境界ワイルドカードと呼ぶ記法で、これを指定することで、map
メソッドに渡せる関数の範囲を広くしている。境界ワイルドカードについてはこの記事の最後で説明するが、ひとまず次の定義とほぼ同じものと考えていただきたい。
public interface Stream<T> ... <R> Stream<R> map(Function<T, R> mapper);
ここまでの話をまとめると次のようになる。
Stream
インターフェースでは型変数T
を宣言して、任意の型を格納できるようにしている。map
メソッドでは、さらに型変数R
を宣言して、戻り値をStream<R>
にしている。map
メソッドの引数に渡すFunction
オブジェクトは、T
とR
の2つの型変数を利用する。
ある型を別の型に変換するFunction
インターフェース
ここでFunction
インターフェースについて説明しておこう。
Function
は、Java8から導入された関数型インターフェースの1つだ。関数型インターフェースは、抽象メソッドを1つだけ持つインターフェースで、オブジェクト指向言語のJavaで関数を扱えるようにするための仕組みである。
Function
インターフェースの定義を見てみよう。Function
インターフェースは、java.util.function
パッケージにあり、唯一の抽象メソッドのapply
を次のように定義している。(その他にもdefault
メソッドが定義されているが、ここでは省略した。)
@FunctionalInterface public interface Function<T, R> { R apply(T t); ... }
1行目の@FunctionalInterface
は、このインターフェースが関数型であることを明示するアノテーションである。ちなみにJava8の仕様では、このアノテーションを指定しなくても、抽象メソッドを1つだけ持つインターフェースであれば関数型インターフェースとして扱うことができる。(このアノテーションを指定したインターフェースが関数型の条件を満たさない場合には、コンパイルエラーになる。)
2行目では、Function
インターフェースを宣言している。ここでは、T
とR
という2つの型変数を利用する総称型インターフェースにしている。
3行目では、唯一の抽象メソッドのapply
を定義している。このメソッドは、T
型のオブジェクトを引数に受け取り、R
型のオブジェクトを戻り値にすることを宣言している。つまり、Function
インターフェースは、T
型のオブジェクトを受け取り、R
型のオブジェクトを返す関数ということになる(図1)。
図1:Functionインターフェース
T
およびR
には任意の型を指定可能で、両方を同じ型にすることもできる。
map
メソッドは集合データの各要素を別データに変換する
さて、map
メソッドに戻ろう。
map
メソッドの役割は、自身のStream
オブジェクトに格納されたすべての要素を変換することである。実際にどのような変換を行うかは、Function
インターフェースに記述するロジックによって決まる。
T
型のオブジェクトを格納したStream
に対して、map
メソッドを呼び出すことで、R
型のオブジェクトを格納したStream
が得られる(図2)。
図2:mapメソッドの役割
ラムダ式でFunction
のロジックを記述する
さて、最初に示したPerson
オブジェクトのサンプルコードに戻ろう。
長くなったので、先ほどのコードを再掲する。
public List<String> collectNamesStartsWith2(List<Person> persons, String prefix) { return persons.stream() .map(p -> p.getName()) .filter(s -> s.startsWith(prefix)) .collect(Collectors.toList()); }
このコードでは、map
の引数に次のラムダ式を指定している。
p -> p.getName()
map
メソッドはFunction
型の関数オブジェクトを引数にとるため、上記のラムダ式はFunction
インターフェースを実装したオブジェクトになる。引数のp
はapply
メソッドの引数のT
型に対応する。
実際に、このラムダ式を含むmap
メソッドが呼ばれた時点でStream
に格納されているのはPerson
である。このため型T
はPerson
になり、式の右辺でPerson
クラスのgetName()
を呼び出すことができる。getName()
の戻り値はString
のため、map
メソッドの戻り値はStream<String>
になる(図3)。
図3:mapメソッドによる変換処理
ちなみにこのラムダ式は、p
の型を明示して次のように書くこともできる。
(Person p) -> p.getName()
上記のようにPerson
型を明示する必要がない理由は、コンパイラーの型推論の仕組みにより、このラムダ式の処理対象がStream<Person>
であることを特定できるからである。
また、上記のラムダ式はJava7以前の文法を使って次のように書くこともできる。
public class NameGetterFunction implements Function<Person, String> { @Override public String apply(Person p) { return p.getName(); } }
このように書いた場合、map
の引数にはFunction
を実装したクラスのオブジェクトを渡せばよい。
public List<String> collectNamesStartsWith3(List<Person> persons, String prefix) { return persons.stream() .map(new NameGetterFunction()) .filter(s -> s.startsWith(prefix)) .collect(Collectors.toList()); }
条件に合う要素を抽出するfilter
メソッド
さて、Person
のサンプルコードに戻ろう。
map
メソッドの次ではfilter
メソッドを呼び出して要素を抽出している。filter
メソッドは、前回の記事で解説したので、今回はラムダ式についてのみ簡単に説明する。今回のコードでは、filter
メソッドに指定したラムダ式は次のようになっている。
s -> s.startsWith(prefix)
このラムダ式の中で呼び出しているstartsWith
メソッドは、Java標準のString
クラスのものである。
直前のmap
メソッドにより、Stream<Person>
がStream<String>
に変換されたため、filter
メソッドの引数に指定する抽出ロジックの対象は、String
オブジェクトになる。引数のprefix
はPersonUtil
クラスのcollectNamesStartsWith2
メソッドに渡された引数である。
Stream
のfilter
メソッドのシグニチャーも紹介しておこう。filter
メソッドは、次のようにjava.util.function
パッケージのPredicate
という関数オブジェクトを引数にしている。
public interface Stream<T> ... Stream<T> filter(Predicate<? super T> predicate);
条件判定のためのPredicate
インターフェース
Predicate
インターフェースのシグニチャーは次の通りである。唯一の抽象メソッドの名前はtest
で、メソッドの戻り値はboolean
である。このため、ここにはT
型のオブジェクトを引数に取り、何らかの判定を行って、その結果を返すロジックを記述する。(第1回記事で紹介したStream
のallMatch
メソッドの引数もPredicate
型である。)
@FunctionalInterface public interface Predicate<T> { boolean test(T t);
全体の流れ
サンプルコードでは、filter
で条件に合致する名前を抽出した後で、最後にcollect
メソッドを呼び出してStream
をList
に変換している。
全体の流れを図にすると次のようになる(図4)。
図4:全体の流れ
境界ワイルドカード
最後に境界ワイルドカードについて、簡単に説明しておこう。
型変数に指定する?
は境界ワイルドカードと呼ぶ記法で、特定の型に加えて、その型のサブクラスやスーパークラスも許容することを意味する。
先ほどのmap
メソッドのシグニチャーは次のようになっていた。
public interface Stream<T> ... <R> Stream<R> map(Function<? super T, ? extends R> mapper);
? super T
は、T
およびT
の上位クラス(スーパークラスおよびスーパーインターフェース)の型を指し、? exends R
は、R
およびR
の下位クラス(サブクラス、実装クラス、サブインターフェース)の型を指す。
このため、map
メソッドの引数に指定するFunction
インターフェースは、1つめの型変数にはT
の上位クラスを指定してもよく、2つめの型変数にはR
の下位クラスを指定してもよいことになる。
たとえば次に示すように、map
メソッドの引数のFunction
を、Function<Person, String>
型の代わりにFunction<Object, String>
型としてもコンパイルエラーにならない。
public List<String> collectPersonStrings(List<Person> persons) { return persons.stream() .map((Object o) -> o.toString()) .collect(Collectors.toList()); }
もしStream
のmap
メソッドのシグニチャーが、次のようにT
に対して境界ワイルドカードを指定していなかったとすると、上記のコードはコンパイルエラーになってしまう。
public interface Stream<T> ... <R> Stream<R> map(Function<T, ? extends R> mapper);
まとめ
今回はmap
メソッドについて解説した。
Stream APIでは中間操作を連鎖して呼び出すことができる。途中にmap
メソッドが入っている場合、Stream
に含まれる要素の型がそこで変わる可能性があるが、型推論の仕組みが備わっているため、型が変わっていることをコード上に明示する必要がない。このため、Stream APIで書かれたコードを読む際には、map
メソッドの前後で型が変わっていないかどうかに留意するとよいだろう。
次回はflatMap
を紹介する。