torutkのブログ

ソフトウェア・エンジニアのブログ

JJUGナイトセミナー「Project Lambdaハンズオン」参加

今日の夜、日本Javaユーザーグループ(JJUG: Japan Java Users Group)主催のナイトセミナー「Project Lambdaハンズオン」が開催されました。

前半がJavaにおけるラムダ導入までの紆余曲折の経緯、ラムダの概説、後半が例題によるハンズオンです。

今回のセミナーの個人的にとってもよかったところは

  • Scala、Groovy、Rubyなどの話題がほとんど出なかった点
  • ラムダ計算の話題がでたけど小難しくなかった点
  • 実際に見て聞いて手で動かして結果を目で見るのは、新しい概念を学ぶのに有効

ですね。

残念なところは、

  • 夜の勉強会なので時間が短く、十分なハンズオン時間がなかった点

です。

プログラミングは、自分の手で書いて動かして悩んでやっと身に付くので、ハンズオン大歓迎です。独りでも身に付く分野もありますが、ラムダ(関数型)はパラダイムが違うので、なかなか独りでは難しく、よい機会でした。

懇親会も参加したかったのですが(ちょっと悩みましたが)、ラムダ式のプログラミングを早めにいろいろ咀嚼したかったので帰宅しました。

理解したこと

あくまで静的型付け言語中心、関数型プログラミング挫折プログラマーの感想です。

JavaのProject Lambdaは、

  • 実装しなければならないメソッドが1つだけのインタフェース型について、このインタフェース型を実装する匿名クラスの定義とインスタンス生成を簡単に記述するシンタックスシュガーである。
  • 互換性最重要のJava言語*1ゆえに必要にせまられて導入したデフォルトメソッドが、Mix-inのように便利な機能になるかも。
  • 外部イテレータに慣れた身には内部イテレータに移行するのは苦痛かも。ただし慣れの問題とは思う。ともあれ、forEach、filter、map、reduce、sortedなどの使い方をマスターするのは大事そう。
  • 実質的finalは、従来匿名クラスのメソッド内からローカル変数を参照するにはfinal宣言が必要だったところを、再代入していないことをコンパイラが検出すればfinal宣言がなくてもfinal宣言があるとみなして参照できる。
  • 今までProject Lambdaの資料などを見てきて、ラムダ式の実際の型はコード中に現れないので、java.util.functionsパッケージとそのinterface型の存在を知った。

ハンズオンのポイント

課題1

従来の匿名クラス宣言をラムダ式に書き換えるものです。書き換えは2か所あります。

  • ActionListenerインタフェースの匿名クラス宣言
ActionListener listener = new ActionListener() {
    public void actionPerformed(ActionEvent event) {
        label.setText(field.getText());
    }
};

を、ラムダ式を使って

ActionListener listener = event -> { label.setText(field.getText()); };

と書き換えました。
ラムダ式の処理が1行だけでも、return式でない場合は波括弧が省略できないようで、次の書き方ではコンパイルエラーとなりました。

ActionListener listener = event -> label.setText(field.getText());
  • SwingUtilities.invokeLaterの引数Runnableの匿名クラス宣言
SwingUtilities.invokeLater(new Runnable() {
    public void run() {
        new SwingDemo();
    }
});

を、ラムダ式を使って

SwingUtilities.invokeLater( () -> { new SwingDemo(); } );

と書き換えました。

なお、この課題1では、ラムダ式内でJLabel/JTextFieldにアクセスするためこの2つがフィールドになっていました。
これらを、ローカル変数にしてみましたが、実質的finalによってエラーにならずコンパイルできました。

	JTextField field = new JTextField(20);
	panel.add(field);
          : (中略)
	JLabel label = new JLabel();
	label.setHorizontalAlignment(JLabel.CENTER);
	frame.add(label, BorderLayout.CENTER);

	ActionListener listener = event -> { label.setText(field.getText()); };
課題2

拡張for文を内部イテレータ(IterableのforEachメソッド)に書き換えるものです。

  for (Integer num : numbers) {
      : (略)
  }

を、ラムダ式を使って

  numbers.forEach(num -> {
      :(略)
  });


と書き換えます。

課題3

コレクションに格納されているStudent要素から特定の条件を抽出し、その要素の集計をします。

int highScore = students.filter(s -> s.getGradYear() == 2011)
                        .map(s -> s.getScore())
                        .reduce(0, Math::max);

書かれたコードを説明されれば分からないでもないですが、自分からこのような書き方をするのは難しいです。

理解を深めるために、メソッド呼び出しをそれぞれ式にして書くと

Iterable<Student> students2011 = students.filter(s -> s.getGradYear() == 2011);
Iterable<Integer> scores = students2011.map(s -> s.getScore());
Integer highScore = scores.reduce(0, Math:max);

という感じです。最後のreduceがちょっとまだ理解しきれてませんが・・・

jdk8 lambda preview版は、APIjavadocが公開されていないので、preview版に含まれるsrc.zipを展開し、javadocコマンドでAPI javadocを生成すると便利です。

C:\work\src にsrc.zipを展開してから、

javadoc -d jdk8_lambda_doc -sourcepath src java.util java.util.functions java.lang
  :

とすれば、主要なラムダ関係のjavadocが生成されます。パッケージは足りなければjavadocコマンドラインに追加します。

本課題のfilter, map, reduceの各メソッドの詳細については、java.lang.IterableインタフェースのJavadocを調べます。

Iterable filter(Predicate predicate)
引数に指定されたpredicateに従ってフィルターされた要素のIteravleビューを返却します。

Predicateは、java.util.functions.Predicate(ファンクショナルインタフェース)です。実装すべき1つのメソッドはtestです。

boolean test(T t)
引数に指定されたオブジェクトがある条件にマッチしたらtrueを返します。

ラムダ式を使わずに書くと

  Predicate<Student> predicate = new Predicate<Student>() {
      public boolean test(Student s) {
          return s.getGradYear() == 2011;
      }
  };
  Iterable<Student> students2011 = students.filter(predicate);

となります。

ラムダ式に慣れていない現時点では、こちらの方が可読性が高いと感じてしまいます。書くのは楽かもしれないけどラムダ略しすぎて読むのが(今はまだ)つらい。

さて、次はmapです。java.lang.Iterableのjavadocでfilterメソッドを確認すると

<U> Iterable<U> map(Mapper<? super T, ? extends U> mappper)
引数mapperで要素を写像し、写像された要素のIterableビューを返します。

となっています。Mapperは、java.util.functions.Mapperを見ます。実装すべき1つのメソッドはmapです。

U map(T t)
指定された引数オブジェクトから出力オブジェクトへ写像します。

ラムダ式を使わずに書くと

  Mapper<Student, Integer> mapper = new Mapper<Student, Integer>() {
      public Integer map(Student s) {
          return s.getScore();
      }
  };
  Iterable<Integer> scores = students2011.map(mapper);

となります。

最後のreduceについて、IterableのJavadocを見ます。

T reduce<T base, BinaryOperator<T> reducer)
要素(複数)を単一の値に約し(reduce)ます。

BinaryOperatorは、java.util.functions.BinaryOperatorを見ます。実装すべき1つのメソッドはevalです。

T eval(T left, T right)

ラムダ式を使わずに書くと

  BinaryOperator<Integer> reducer = new BinaryOperator<Integer>() {
      public Integer eval(Integer left, Integer right) {
          return Math.max(left, right);
      }
  };
  Integer highScore = scores.reduce(0, reducer);

reduceは動作がイメージしにくいですが、socresが、{70, 80, 50, 90, 30}だとすると、baseが0の場合

eval(0, 70) => 70  baseの0とscoresの先頭70が引数に入る
eval(70, 80) => 80  直前のevalの出力70とscoresの2番目の要素80が引数に入る
eval(80, 50) => 80  直前のevalの出力80とscoresの3番目の要素50が引数に入る
eval(80, 90) => 90  直前のevalの出力80とscoresの4番目の要素90が引数に入る
eval(90, 30) => 90  直前のevalの出力90とscoresの5番目の要素30が引数に入る

という呼び出しが発生します。

慣れると、20行が1行で記述できるラムダ式と内部イテレータで、相当簡潔になります。ただ、読むのにも慣れが必要です。

課題4

Mapperで集計するという例題です。
(reduceじゃないんですね)

    List<Integer> numbers = initNumbers();
    Mapper<List<Integer>, Integer> mapper =
        n -> n.isEmpty() ? 0 : n.remove(0) + mapper.map(n);
    Integer sum = mapper.map(numbers);

※ 抜粋のためフィールドをローカル変数に移動したりしてます。

ListをIntegerに写像しています。

今見て気付いたのですが、最初の課題ではIterableを実装するコレクションに対してmapメソッドを呼び、引数にmapperを渡していました。この課題では、Mapperインスタンスのmapメソッドを読んでいたのですね。ハンズオンのときはまったくそこまで頭が回ってませんでした。

reduceを使って合計を出すようにコードを書いてみます。

import java.util.ArrayList;
import java.util.List;

public class NumberAdder3 {

    public NumberAdder3() {
	List<Integer> numbers = initNumbers();
	Integer sum = numbers.reduce(0, (left, right) -> left + right);
	System.out.println(sum);
    }

    private List<Integer> initNumbers() {
	final int num = 100_000;
	List<Integer> numbers = new ArrayList<>(num);
	for (int i = 1; i <= num; ++i) {
	    numbers.add(i);
	}
	return numbers;
    }

    public static void main(String... args) {
	new NumberAdder3();
    }
}

だんだんラムダに慣れてきたような気がします。

課題5

Parallel版得点集計プログラムです。

import java.util.Arrays;
import java.util.Random;
import java.util.concurrent.TimeUnit;

public class ParallelScoreFinder {
    private Random random = new Random();

    public ParallelScoreFinder() {
	Student[] students = initStudents();
	
	int highScore = Arrays.parallel(students)
	    .filter(s -> s.getGradYear() == 2011 ? true : false)
	    .map(s -> s.getScore())
	    .reduce(0, Math::max);
	System.out.println("High score is " + highScore);
    }

    private Student[] initStudents() {
	final int numStudents = 5_000_000;
	Student[] students = new Student[numStudents];

	for (int i = 0; i < numStudents; ++i) {
	    students[i] = new Student(
		"" + i, random.nextInt(12) + 2000, random.nextInt(101)
	    );
	}
	return students;
    }

    public static void main(String... args) {
	long start = System.nanoTime();
	new ParallelScoreFinder();
	long stop = System.nanoTime();
	System.out.println(
	    "It takes " + TimeUnit.NANOSECONDS.toMillis(stop - start) + " [ms]"
	);
    }
}

性能計測の方は、このサンプルでは何とも言えない結果に・・・

生徒数を、20,000,000人に増やし、GCが発生しないようヒープサイズを12GBにして、-XX:+PrintGCで確認し、CPU数をaffinity指定で制御して比べました。

OpenJDK Runtime Environment (build 1.8.0-ea-lambda-nightly-h209-20120712-b48-b00)
OpenJDK 64-Bit Server VM (build 24.0-b15, mixed mode)

まずは、ふつうに実行

C:\work> start /B java -Xms12g -Xmx12g -XX:+PrintGC ParallelScoreFinder
High score is 100
It takes 2653 [ms]

CPU(コア)を1つに限定

C:\work>start /affinity 1 /B java -Xms12g -Xmx12g -XX:+PrintGC ParallelScoreFinder
High score is 100
It takes 2893 [ms]

と、差が10%未満です。もっとCPUを使う計算を入れると比較できそうです。

なお、ヒープサイズを小さいままにしていると、すべてのコアのCPU使用率が一気に上昇しますが、これはパラレルGCが一気に活動するため(FullGC発生等)で、ラムダ式が並行実行されたわけではありません。

ラムダ式について

Javaラムダ式が入ることになれば、いままでクラス単位のモジュール化では解決できなかった細粒度のコードの再利用が可能になるのではないかと期待しています。

外部イテレータによる処理の記述は、イテレートは同じでも処理がちょっと違う、というケースが多く、コピー&ペーストでループ内部の処理を修正する、という再利用コーディングがはびこります。

ただ、今回感じた慣れが必要、という点をうまく乗り越える必要がありそうです。
Javaの場合、幸いにも関数型とかなんとかの小難しい概念を理解しなくても*2ラムダ式による恩恵を受けることができるのが嬉しいです。

*1:実にすばらしいポリシー。互換性軽視のマイクロソフトに蹂躙されたプログラマーにとって福音ともいえるポリシーなのに、昨今レガシーだCOBOLだなどと陰口をたたかれる

*2:関数型プログラミングから見れば、なんちゃってレベルかもしれませんが