エンタープライズギークス (Enterprise Geeks)

企業システムの企画・開発に携わる技術者集団のブログです。開発言語やフレームワークなどアプリケーション開発に関する各種情報を発信しています。ウルシステムズのエンジニア有志が運営しています。

Java8超入門 - for文の代わりにStreamを使おう(3)

前回の記事では、Stream APIfilterメソッドを使って、集合データから条件に合致する要素を抽出する方法を紹介した。 今回は集合データ内の要素を変換する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()メソッドを呼び出して、PersonListStreamに変換している。
続く2行目では、mapメソッドを呼び出している。ここで行っているのはPersonStream内の各要素から名前を取り出して、文字列のStreamに変換することだ。
続けて3行目では、取り出した名前の中から、条件に合うものだけを抽出するために、filterメソッドを呼び出している。
最後にcollectメソッドを呼び出して、StreamListに変換している。

以降、各処理を詳しく説明していこう。

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メソッドではTRという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オブジェクトは、TRの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インターフェースを宣言している。ここでは、TRという2つの型変数を利用する総称型インターフェースにしている。

3行目では、唯一の抽象メソッドのapplyを定義している。このメソッドは、T型のオブジェクトを引数に受け取り、R型のオブジェクトを戻り値にすることを宣言している。つまり、Functionインターフェースは、T型のオブジェクトを受け取り、R型のオブジェクトを返す関数ということになる(図1)。

f:id:enterprisegeeks:20151124112038p:plain
図1:Functionインターフェース


TおよびRには任意の型を指定可能で、両方を同じ型にすることもできる。

mapメソッドは集合データの各要素を別データに変換する

さて、mapメソッドに戻ろう。

mapメソッドの役割は、自身のStreamオブジェクトに格納されたすべての要素を変換することである。実際にどのような変換を行うかは、Functionインターフェースに記述するロジックによって決まる。

T型のオブジェクトを格納したStreamに対して、mapメソッドを呼び出すことで、R型のオブジェクトを格納したStreamが得られる(図2)。

f:id:enterprisegeeks:20151124123210p:plain
図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インターフェースを実装したオブジェクトになる。引数のpapplyメソッドの引数のT型に対応する。

実際に、このラムダ式を含むmapメソッドが呼ばれた時点でStreamに格納されているのはPersonである。このため型TPersonになり、式の右辺でPersonクラスのgetName()を呼び出すことができる。getName()の戻り値はStringのため、mapメソッドの戻り値はStream<String>になる(図3)。

f:id:enterprisegeeks:20151125143742p:plain
図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オブジェクトになる。引数のprefixPersonUtilクラスのcollectNamesStartsWith2メソッドに渡された引数である。

Streamfilterメソッドのシグニチャーも紹介しておこう。filterメソッドは、次のようにjava.util.functionパッケージのPredicateという関数オブジェクトを引数にしている。

public interface Stream<T> ...
  Stream<T> filter(Predicate<? super T> predicate);

条件判定のためのPredicateインターフェース

Predicateインターフェースのシグニチャーは次の通りである。唯一の抽象メソッドの名前はtestで、メソッドの戻り値はbooleanである。このため、ここにはT型のオブジェクトを引数に取り、何らかの判定を行って、その結果を返すロジックを記述する。(第1回記事で紹介したStreamallMatchメソッドの引数もPredicate型である。)

@FunctionalInterface
public interface Predicate<T> {
    boolean test(T t);

全体の流れ

サンプルコードでは、filterで条件に合致する名前を抽出した後で、最後にcollectメソッドを呼び出してStreamListに変換している。

全体の流れを図にすると次のようになる(図4)。

f:id:enterprisegeeks:20151125143044p:plain
図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());
  }

もしStreammapメソッドのシグニチャーが、次のようにTに対して境界ワイルドカードを指定していなかったとすると、上記のコードはコンパイルエラーになってしまう。

public interface Stream<T> ...
  <R> Stream<R> map(Function<T, ? extends R> mapper);

まとめ

今回はmapメソッドについて解説した。

Stream APIでは中間操作を連鎖して呼び出すことができる。途中にmapメソッドが入っている場合、Streamに含まれる要素の型がそこで変わる可能性があるが、型推論の仕組みが備わっているため、型が変わっていることをコード上に明示する必要がない。このため、Stream APIで書かれたコードを読む際には、mapメソッドの前後で型が変わっていないかどうかに留意するとよいだろう。

次回はflatMapを紹介する。