10月12日から15日にかけて英国ロンドンのビジネスデザインセンタで開催されたJAX London 2015には,Javaやマイクロサービスなど,最近の開発プラクティスの各分野から,多くの専門家たちが集まった。テーマは多岐に渡るが,ここ数年間で普及したテクノロジの理解を深めるという目的は共通しているようだ。これが示唆するのは,これらのテクノロジが成熟の域に達していること,ユーザがその効果的な活用方法を積極的に学ぼうとしていること,この2つである。
Java関連では,重要な技術革新を数多く備えたバージョン8が2014年3月に一般公開されている。最も注目を集めているのはラムダ式だが,それだけではない。これら新機能が開発者コミュニティで使われるようになってしばらく経つが,JAX Londonの講演者たちは,それらを活用する方法に加えて,乱用を避ける方法についても理解を示していた。
ストリームは従来よりも関数的なコード記述を可能にする。これまでに多くのforループやwhileループがストリームに置き換えられてきた。ストリームを使うことで,次のforループは,
int total = 0;
for(int i = 0; i < 100; i++) {
total += i;
}
このように書き換えることができる。
int total = IntStream.range(0, 100).reduce(0, (a, b) -> a + b);
Angelika Langer氏はこのような置き換えによるパフォーマンスへの影響を,forループと同等のストリーム,あるいは逐次型の処理ストリームと並列型の処理ストリームを比較することで示してみせた。forループとそれに相当するシーケンシャルストリームを比較すると,ストリームの効率は最高でもforループと同程度で,それ以下の場合の多いことが分かる。おもな原因は,forループの反復処理のコストがシーケンシャルストリームのそれより低いことにある。繰り返し実行される処理が計算量的に重いものであれば,その処理コストが反復処理のコストを上回るため,forループとストリームのパフォーマンスは同程度になる。一方,実行する処理が軽量な場合には,全体に対して反復処理の占める割合が高くなるため,forループの方が著しく速い結果になるのだ。
並列ストリームとの比較では,いくつかの驚きがあった。先述の繰り返し処理を複数のスレッドで試すには,次のように書き換えることができる。
int total = IntStream.range(0, 100).parallel().reduce(0, (a, b) -> a + b);
しかしこれは,必ずしもパフォーマンス向上にはつながらない。まず最初に,複数のスレッドで処理するためには,ストリームを複数のサブストリームに分割する必要がある。その後,各スレッドが並列処理した結果をひとつにまとめているので,その過程のオーバーヘッドを考慮しなくてはならない。ストリームの規模や各イテレーションの処理負荷によっては,このオーバーヘッドが受け入れられない場合があるのだ(Java言語の並列処理ユーティリティで有名なDoug Lea氏は,最低でも10,000項目の負荷の少ない処理を推奨している)。
次に,そしておそらくはより興味深い事実として,使用しているコレクションが,ストリームを並列サブストリームに分割するコストに影響する場合がある。例として,500,000の項目を持つLinkedListから最大のものを探し出す処理を考えてみる。Angelikaによるとこの場合,シーケンシャルな処理よりも並列処理の方が65%遅くなるという。LinkedListには要素をランダムアクセスする効率的な方法がないため,サブストリームを構築するのに何度もリストを横断しなくてはならない,というのがその理由だ。
他の講演でも,Peter Lawrey氏がストリームを取り上げていた。ただしこちらはコードスタイルの視点からだ。氏は,ストリームによって関数的な方法でのコード記述が可能になることを,従来の命令的な手法との対比で説明した(この話題は,Richard Warburton,Raoul-Gabriel Urma両氏の講演“Pragmatical Functional Refactoring”でも取り上げられていた)。一方で氏は,関数型と命令型を混在させることのリスクについて警告している。例えば,文字列のリストから5文字より短い項目をすべて取り除きたい場合,次のような記述をしたくなるかも知れない。
list.stream().filter(s -> s.length() < 5).forEach(s -> list.remove(s));
この場合,繰り返しとフィルタリングには関数型のスタイルが,実際に項目を削除する操作には命令型のスタイルが使用されている。しかし,反復処理中のリストは変更できないため,これを実行するとCocurrentModificationExceptionが発生する。この場合は,要素を保持するためのリストを別に用意する必要がある。
list = list.stream().filter(s -> s.length() >= 5).collect(Collectors.toList());
あるいは削除する要素のリストを作成した上で,別ステップでそれを削除する。
List<String> toRemove = list.stream().filter(s -> s.length() < 5).collect(Collectors.toList());
list.removeAll(toRemove);
単純に,命令的な方法でリストを反復処理してもよい。
int i = 0;
while(i < list.size()) {
if(list.get(i).length() < 5)
list.remove(list.get(i));
else
i++;
}
Peter Lawrey氏はラムダの使用に関して,興味深い方法と紛らわしい方法の2つを紹介した。興味深い方法として氏が示したのは,クラスを生成する必要なく,そのファクトリクラスのオブジェクトを生成するためにラムダを使用することだ。例えば,次のようなStringFactoryインターフェースがあったとする。
public interface StringFactory {
public String build();
}
次のようにラムダあるいはメソッド参照を使用することで,さまざまなファクトリを簡単に実装することができる。
StringFactory helloWorldStringFactory = () -> "Hello World!";
StringFactory emptyStringFactory = String::new;
StringFactory inputStreamFactory = () -> {
try {
return (new BufferedReader(new InputStreamReader(System.in))).readLine();
} catch (IOException e) {
return null;
}
};
Stephen Colebourne氏も同じように,例外の発生を期待するユニットをコンパクトに記述するためにラムダを使用する方法や,さらには次のようなステートメントを記述可能なTestHelperクラスを紹介していた。
TestHelper.assertThrows(() -> object.methodToTest(), ExpectedException.class);
TestHelper.assertThrows(() -> object.anotherMethodToTest(), AnotherExpectedException.class);
紛らわしい方では,ラムダのコンパクトな表現を乱用した可読性の極めて低いコードの例として,次の式の結果がどのような型になるのかを参加者に考えさせていた。
a -> b -> b >- a;
この例は,パラメータを受け入れる関数が別の関数を返し,さらにパラメータを受け入れた上で,その2つを比較している式である。つまり次のような,もっと明確な表現の式と機能的に同じなのだ。
(a, b) -> b > -a;
続いて氏は,ラムダの型推論に例外が関係することにより,さらに紛らわしい状況の生じる可能性があることを,例を使って示した。次のブロックではFiles.lines()のIOExceptionが処理されてないため,コンパイルすることができない。
ExecutorService ex = Executors.newCachedThreadPool();
ex.submit(() -> {
Files.lines(Paths.get("data.txt")).forEach(System.out::println);
});
ところが次のように“return null;”という句を追加すると,この問題は解決する。
ExecutorService ex = Executors.newCachedThreadPool();
ex.submit(() -> {
Files.lines(Paths.get("data.txt")).forEach(System.out::println);
return null;
});
これはreturn句を追加することでコンパイラが違う型を推測するようになり,その違う型が違うシグネチャを持つためだと説明できる。最初の例のラムダは,次のようなシグネチャを持つRunnableであると推論される。
public abstract void run();
これに対して2つめの例のラムダは,Callable<V>として,次のシグネチャを持つものと推論される。
V call() throws Exception;
Runnableの場合,シグネチャに“throws”句が含まれていないため,Files.lines()による例外を処理する必要があるが,Callableではシグネチャに“throws Exception”句が含まれているので,IOExceptionは呼び出し元に対して透過的にエスカレーションされるのだ。
このような隠れた副作用を回避する方法としてStephenとPeterは,戻り値の型とシグネチャが明示的に書かれている場合には,ラムダを名前付きクラスとして明示的に書くことを勧めている。
Stephen Colebourne氏は,非常に重要な変更でありながらほとんど知られていない,Java 8のインターフェースの新しい特徴に注目すべきだと主張している。最新バージョンのインターフェースでは,staticメソッドや新たなメソッドのデフォルト実装の記述が可能になった。
氏の説明によれば,staticメソッドは,ファクトリクラスの必要性を回避するために利用することができる。これとパッケージスコープを巧妙に併用することで,実装クラスをより完全にカプセル化することが可能になるというのだ。クラス実装の詳細をインターフェースから抽象化する必要のある場合を考えてみよう。この場合,インターフェースとクラスを同じパッケージ内で,次のように記述すればよい。
public interface MyInterface {
public static MyInterface createInstance() {
return new MyInterfaceImpl();
}
/* メソッドの宣言 */
}
class MyInterfaceImpl implements MyInterface {
/* メソッドの定義 */
}
クラスはpublic宣言されていないので,パッケージ外部からアクセスできないことに注目してほしい。この状態でMyInterfaceImplのオブジェクトを生成したり,あるいは単純に参照したりすれば,スコープに関するコンパイルエラーが発生する。つまりクラスへのアクセスは,必ずインターフェース経由で行わなければならない。
さらにデフォルトメソッドは,これまでのバージョンで問題となっていた,既存の実装クラスに影響しないインターフェース拡張を可能にする。実際に,デフォルトメソッド(“デフェンダ(defender)メソッド”,“仮想拡張メソッド”などと呼ばれていたこともある)がJava 8に追加された理由のひとつは,コレクションインターフェースに対して,既存の実装に影響を与えることなくラムダ対応のメソッドを新たに追加することにあったと,OracleのJava言語アーキテクトであるBrian Goetz氏が“Interface evolution via virtual extension methods”の中で説明している。デフォルトメソッドは,開発者がインターフェースを改善する上で,より多くの柔軟性を提供する機構である。Stephenはこれを理由に,抽象クラスがいつの日にかなくなるかも知れないという,大胆な予測をしている。
開発者が現在のテクノロジを極めて分析的な目で見ていることを示す例は,他にも数多くあった。Chris Bailey氏はJavaとJavaScriptの比較を行った。ただし,これまでのような定性的アプローチ(機能比較)ではなく,数多くの基準値とベンチマークによる定量的なアプローチを採用した上で,その結果にはさまざまな解釈の可能性があることを強調していた。
方法論の面では,Daniel Bryant氏とSteve Poole氏が,DevOpsとクラウドが開発者にもたらす新たな課題をいくつか紹介していた。Chris Richardson氏は,オブジェクト的な視点からさまざまなマイクロサービス戦略の比較を試みるために,“パターン言語”を発表した。マイクロサービスの採用には数多くのメリットがあるが,Lyndsay Prewer氏はモノシリック手法にも成功を期待できることを実証した。セッションの全リストについては,JAX London 2015のプログラムがまだ公開されている。