コンピュータ科学者は問題解決において,表現の簡潔さの持つ価値を強調します。Unixの先駆者であるKen Thompson氏がかつて,“最もプログラムをたくさん書いていた頃には,1,000行のコードを捨てたこともある”と語ったことは有名です。継続的なサポートとメンテナンスを必要とする,すべてのソフトウェアプロジェクトにとって,これは価値のある目標です。しかしながら,コード行数などソフトウェア開発指標を重視することのより,この目標が見失われる場合もあります。初期のLispコントリビュータであるPaul Graham氏は,プログラム言語の簡潔性は言語の能力そのものである,とまで言っています。言語の能力に対するこの概念によって,多くの現代的なソフトウェアプロジェクトでは,コンパクトでシンプルなコードを記述可能なことを,言語選択の最も重要な基準としてきました。
リファクタリングで不要なコードや,あるいは空白など意味のない部分を削除すれば,どのようなプログラムでも短くなります。しかしプログラム言語の中には,本質的に表現力が高く,簡潔なプログラムの記述に特に適しているものがあります。この品質を念頭において,Perlプログラマたちが普及させたのがコードゴルフコンテストです。これは,可能な限り短いコードによって,ある問題を解決したり,あるアルゴリズムを実装することを目標とするものです。例えばAPL言語は,特殊なグラフィック記号を導入することで,少ないコードで強力なプログラムを記述できるように設計されています。適切に実装されたAPLプログラムは,標準的な数学表現をうまくマッピングしています。小さなスクリプトを手早く記述する場合,特に,簡潔さが目的を不明瞭にする不安のない,明確に描出された問題領域では,言語が簡潔(terse)であることが非常に有効です。
Javaは他のプログラム言語に対して,比較的冗長な言語であると評価されています。その理由の一部は,多くの場合において,タスク実行時の記述性やコントロールが豊富なことを良しとする,プログラミングコミュニティの確立した慣習にあります。例えば,長い変数名は,長期的には大規模なコードベースの可読性やメンテナンス性の向上に寄与しますし,一般的にファイル名に対応して付けられる記述的なクラス名は,新機能を既存システムのどこに追加すべきかを一目瞭然なものにしてくれます。記述的な名称を一貫して使用すれば,アプリケーション内の特定の機能を示すためのテキスト検索は,極めて簡単なものになります。このようなプラクティスは,巨大で複雑なコードベースを持った大規模実装における,Javaの素晴らしい成功に貢献しました。
簡潔さは,小規模なプロジェクトにおいて好まれる性質です。プログラム言語の中には,短いスクリプトの記述や,プロンプト上で対話的,探査的に行うプログラミングに非常に適したものがあります。クロスプラットフォームなユーティリティを記述する多目的言語として,Javaは極めて有用な選択肢なのですが,このような状況で“冗長なJava”を使用することは,必ずしも付加価値のあるものではありません。変数の名称などの領域は,コードスタイルで変更できるかも知れません。しかしJava言語の基本部分には,ひとつのタスクを達成するために必要な文字の数が,他のプログラム言語に比較して多いという歴史的な側面があります。このような制限に対して,Java言語のアップデートが長く続けられてきました。その中には特に,いわゆる“シンタックスシュガー”に分類されるものも含まれています。これらのイディオムによって,同じ機能を,より短い文字数で表現することが可能になりました。このようなイディオムは冗長な表現に比べれば望ましいものであって,一般的にプログラミングコミュニティにも早く浸透する傾向があります。
この記事では,JDK 8で利用可能となった新機能を中心に,簡潔なJavaコードを記述するためのプラクティスに注目していきたいと思います。ラムダ式が採用されたことによってより短く,よりエレガントなコードが可能になります。新しいJava Streaming APIを使ってコレクションを処理する場合には,これが特に顕著です。
Javaの冗長性
Javaが冗長だという評価の一部は,オブジェクト指向の実装スタイルに起因しています。古典的な例題である“Hello World”プログラムは,多くの言語であれば,20文字にも満たない1行のコードで実装することが可能なのですが,Javaではクラス内にmainメソッドを定義した上で,System.out.println()による文字列出力メソッドコールが必要になります。空白文字をすべて取り除いて,メソッド修飾子や括弧,セミコロンを最小限にした“Hello World”プログラムでも,最低86文字が必要です。可読性のための空白や多少のインデントを加えれば,Javaの“Hello World”プログラムが冗長な印象を与えることには,議論の余地はありません。
簡潔性よりも記述性を重視するというコミュニティの基準も,Javaの冗長性の理由のひとつです。これに関して,コード形式の標準に別の美意識を選択するというのは,よい方法とは言えません。また定型的なメソッドやセクションの類であれば,APIの中に組み込む形でラップすることも可能です。簡潔性を念頭に置いてプログラムのリファクタリングを行えば,精度や透明性を損なうことなく,コードを大幅に簡素化することができるでしょう。
Javaの冗長性に関する評判には,古いコード例が多過ぎることで歪曲されている部分もあります。Javaに関する書籍の多くは,何年も前に執筆されたものです。Javaはワールドワイドウェブの初期から存在しているので,初期バージョンの頃から,たくさんのオンラインリソースでスニペットが提供されていました。しかしながら,何年間にも及ぶ欠陥発覚と対応によって,Java言語は成熟しています。そのため,正確で適切に実装されたプログラム例でも,新しい言語イディオムやAPIを活用できていない場合があります。
Javaの設計目標は,オブジェクト指向で(当時としてはC++スタイルの構文を使ったという意味で)分かりやすく,堅牢でセキュア,ポータブル,マルチスレッドで高パフォーマンスといったものでした。簡潔性は目標とされていませんでした。オブジェクト指向の構文を使った実装と同等のタスクを手軽に提供するのは,関数型言語です。Java 8でラムダ式が導入されたことにより,関数プログラミングイディオムへの扉が開かれました。これによってJavaの外観は大きく変わり,一般的な処理の多くで必要なコード量はこれまでより少なくなります。
関数型プログラミング
関数型プログラミングでは,プログラマは関数を中心にプログラムを記述することになります。ここでは関数を引数として渡すなど,非常に柔軟な方法での関数使用が可能になってです。この機能に基づいたJavaのラムダ式では,関数をメソッド引数として扱ったり,コードをデータとして扱ったりすることができます。ラムダ式は,特定のクラスに関連付けられない無名メソッドとも考えられ,リッチで興味深い数学的基礎を備えたアイデアであると言えます。
関数型プログラミングとラムダ式は,抽象的で難解な概念だと考えられています。主として業務的な開発に従事しているプログラマは,コンピューティングの最新動向のキャッチアップに関心がない場合があります。Javaにラムダを導入するには,開発者がこれらの新機能に関して,少なくとも他の開発者の書いたプログラムの内容が分かる程度は理解していなくてはなりません。ラムダ式の導入はコンカレントシステムの設計に影響を及ぼして,結果的にパフォーマンスが向上する,という実用的なメリットがあります。ですが,本記事での関心事は,コンパクトかつ明瞭なコードを記述する上で,これらのメカニズムがどのような効果があるか,という点にあります。
ラムダ式で簡潔なコードが記述できるのには,いくつかの理由があります。ローカル変数を減らすことで,変数の宣言や設定といった雑多な処理を省くことができます。また,ループをメソッドコールに置き換えられるため,3行以上のコードを1行にまとめることが可能です。従来ならばネストしたループと条件式で記述されていたコードを,1行で表現することができるのです。流暢なインターフェース(fluent interface)として実装されたメソッドであれば,Unixのパイプのような方法でチェーンさせることも可能です。関数型でコードを記述することのメリットは,可読性に限ったものではありません。状態の保持を回避し,副作用をなくすことが可能です。このようなコードには,並列化による処理効率の向上が容易であるという,さらなるメリットもあります。
ラムダ式
ラムダ式に関連する構文は単純ですが,従来のバージョンで見られるJavaとはかなり違います。ラムダ式は引数リスト,矢印,本体という3つの部分で構成されます。引数リストには括弧を含んでも,含まなくても構いません。関連するオペレータとしてダブルコロン("::")も追加され,ラムダ式でのコード量の削減に一役買っています。この演算子はメソッド参照として知られているものです。
スレッド生成
この例では,スレッドの作成と起動を行います。空の引数リストが指定されたラムダ式が代入演算子の右側にあって,スレッドを実行すると,簡単なメッセージが標準出力に書き込まれます。
Runnable r1 = () -> System.out.print("Hi!");
r1.run()
引数リスト |
矢印 |
本体 |
|
|
|
コレクション処理
開発者がラムダ式の存在に気付くであろう,主な用途のひとつがコレクションAPIです。例えば文字列のリストを,長さでソートする場合を考えてみましょう。
java.util.List l;
l= java.util.Arrays.asList(new String[]{"aaa", "b", "cccc", "DD"});
この機能の実装にラムダ式が使えます。
java.util.Collections.sort(l, (s1, s2) ->
new Integer(s1.length()).
compareTo(s2.length())
この例では,ラムダ式の本体に2つの引数を指定して,その長さを比較できるようにしています。
引数リスト |
矢印 |
本体 |
|
|
|
標準的な"for"や"while"ループに頼ることなくリスト内の各要素を操作する,いくつかの方法が用意されています。コレクションの"forEach"メソッドにラムダ式を渡すことで,同じようなセマンティクスを実現することができます。この場合は引数がひとつだけなので,括弧は必要ありません。
|
引数リスト |
矢印 |
本体 |
|
|
|
この例では,メソッド参照を使って静的メソッドとそれを含むクラスを分離することで,コードをさらに短くすることができます。次の例では,各要素がprintlnメソッドに順に引き渡されます。
l.forEach(System.out::println)
java.util.streamはJava 8の新しいパッケージです。関数型プログラマに馴染み深い構文でコレクションを処理します。その内容について,概要では次のように説明しています。
簡潔なJava
“コレクション上の map-reduce変換など,要素のストリームに対する関数型操作をサポートするクラス群。”
下記のクラス図は,以降の例で取り上げている機能を中心とした,パッケージの概要を示しています。パッケージの構造には多数のBuilerクラスが含まれていますが,これらは流暢なインターフェースでは一般的なもので,メソッドを連結して,パイプライン化した操作セットにすることを可能にします。
文字列解析とコレクション操作は単純ですが,現実のアプリケーションでは非常に多く利用されています。NLP(自然言語処理)を行う場合,文章を個々の単語に分割することが必要ですし,バイオインフォマティクスでは,DNAやRNAなどの高分子をC, G, A, T, Uなどの文字で構成された核酸塩基で表記します。いずれの問題領域でも,Stringを分解した上で,構成部品のフィルタリング,計数,ソートなどの操作を行います。ですから,プログラム例は非常に単純なユースケースのものですが,その概念はさまざまなタスクに対して一般化されています。
プログラム例では,文章を含んだStringを解析して,単語数と特定の文字の数をカウントします。リスト全体で,空白文字を含んで70行弱のコードです。
1. import java.util.*;
2.
3. import static java.util.Arrays.asList;
4. import static java.util.function.Function.identity;
5. import static java.util.stream.Collectors.*;
6.
7. public class Main {
8.
9. public static void p(String s) {
10. System.out.println(s.replaceAll("[\\]\\[]", ""));
11. }
12.
13. private static List uniq(List letters) {
14. return new ArrayList(new HashSet(letters));
15. }
16.
17. private static List sort(List letters) {
18. return letters.stream().sorted().collect(toList());
19. }
20.
21. private static Map uniqueCount(List letters) {
22. return letters.stream().
23. collect(groupingBy(identity(), counting()));
24. }
25.
26. private static String getWordsLongerThan(int length, List words) {
27. return String.join(" | ", words
28. .stream().filter(w -> w.length() > length)
29. .collect(toList())
30. );
31. }
32.
33. private static String getWordLengthsLongerThan(int length, List words)
34. {
35. return String.join(" | ", words
36. .stream().filter(w -> w.length() > length)
37. .mapToInt(String::length)
38. .mapToObj(n -> String.format("%" + n + "s", n))
39. .collect(toList()));
40. }
41.
42. public static void main(String[] args) {
43.
44. String s = "The quick brown fox jumped over the lazy dog.";
45. String sentence = s.toLowerCase().replaceAll("[^a-z ]", "");
46.
47. List words = asList(sentence.split(" "));
48. List letters = asList(sentence.split(""));
49.
50. p("Sentence : " + sentence);
51. p("Words : " + words.size());
52. p("Letters : " + letters.size());
53.
54. p("\nLetters : " + letters);
55. p("Sorted : " + sort(letters));
56. p("Unique : " + uniq(letters));
57.
58. Map m = uniqueCount(letters);
59. p("\nCounts");
60.
61. p("letters");
62. p(m.keySet().toString().replace(",", ""));
63. p(m.values().toString().replace(",", ""));
64.
65. p("\nwords");
66. p(getWordsLongerThan(3, words));
67. p(getWordLengthsLongerThan(3, words));
68. }
69. }
プログラム実行後の出力例:
Sentence : the quick brown fox jumped over the lazy dog
Words : 9
Letters : 44
Letters : t, h, e, , q, u, i, c, k, , b, r, o, w, n, , f, o, x, , j, u, m, p, e, d, , o, v, e, r, , t, h, e, , l, a, z, y, , d, o, g
Sorted : , , , , , , , , a, b, c, d, d, e, e, e, e, f, g, h, h, i, j, k, l, m, n, o, o, o, o, p, q, r, r, t, t, u, u, v, w, x, y, z
Unique : , a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q, r, t, u, v, w, x, y, z
Counts
letters
a b c d e f g h i j k l m n o p q r t u v w x y z
8 1 1 1 2 4 1 1 2 1 1 1 1 1 1 4 1 1 2 2 2 1 1 1 1 1
words
quick | brown | jumped | over | lazy
5 | 5 | 6 | 4 | 4
このコードでは,短縮のため,いくつかの異なった方法が使用されています。すべてのバージョンのJavaで使用可能なものばかりではありませんが,一般的に受け入れられているスタイルガイドには一致しています。以前のバージョンのJavaで,これと同じ出力を得る方法を考えてみましょう。一時的なデータの格納やインデックスとして使用するために,ローカル変数をいくつか用意する必要があります。データをどうやって処理するかを定義するために,さまざまな条件文やループも必要なはずです。これに対して,新たな関数型アプローチでは,どのようなデータが必要かを重視します。一時的な変数やループのネスト,インデックス管理や条件文の処理に注意を払う必要はありません。
プログラムの中には,以前の言語バージョンが使用されていたという理由から,明瞭性を犠牲にして,標準的なJava構文でコードを短縮している部分もあります。例えば,1行目にある標準的なimport文では,Javaパッケージとして個々のクラス名称を指定する代わりに,java.utlの全クラスを参照しています。System.out.printlnコールはpという名称のメソッドコールに置き換えて,各メソッド呼び出しの名称を短縮しています(9~11行)。これらの変更は,いくつかのJavaコード標準に反するものとして議論の余地はありますが,異なるバックグラウンドから来たプログラマにとっては,必ずしも懸念を持って見るようなものではないでしょう。
別のケースでは,以前のバージョンの言語では使えず,JDK8プレバージョンから可能になった機能を利用しています。例えばstaticインポート(3~5行)を使用して,インラインで必要なクラス参照の数を削減しています。関数プログラミングとは関係ありませんが,正規表現(10行,45行)を使って,ループや条件文を効果的に隠しています。これらのイディオム,特に正規表現については,可読性や理解性を損なうという問題があることも少なくありませんが,注意して用いれば,ノイズを大幅に削減することで,開発者が読んで理解する必要のあるコード量の減少に寄与します。
最後に,このコードでは新しいJDK 8のストリーミングAPIを利用しています。リストのフィルタやグループ,処理などを行うために,ストリーミングAPIで利用可能なメソッドがいくつも使われています(17~40行)。定義されているクラスとの関連は,IDE内では明確ですが,APIに十分に慣れていなければ容易には分かりません。次のリストでは,コードに見られるそれぞれのメソッドコールが,どこを起源とするものなのかを示しています。
メソッド |
メソッドの完全修飾名 |
stream() | java.util.Collection.stream() |
sorted() | java.util.stream.Stream.sorted() |
collect() | java.util.stream.Stream.collect() |
toList() | java.util.stream.Collectors.toList() |
groupingBy() | java.util.stream.Collectors.groupingBy() |
identity() | java.util.function.Function.identity() |
counting() | java.util.stream.Collectors.counting() |
filter() | java.util.stream.Stream.filter() |
mapToInt() | java.util.stream.Stream.mapToInt() |
mapToObject() | java.util.stream.Stream.mapToObject() |
uniq()メソッド(13行)とsort()メソッド(17行)は,同名のUnixユーティリティの機能を反映したものです。sort()は最初のstream()の呼び出し結果にsorted()を,次いでcollect()を適用して,結果をListインスタンスに出力します。uniqueCount(21行)は "uniq -c" と同じように,各文字をキー,その文字が現れる回数のカウントを値とするマップを返します。2つの"getWords"メソッド(26行と33行)は,指定された長さよりも短い単語を除外します。getWordLengthsLongerThan()では,結果を最終的なStringにフォーマットし,キャストするためのメソッド呼び出しも使用されています。
このコードでは,ラムダ式に関する新たな概念は使用していません。前述した構文を,JavaのストリームAPIに対して,特定の用途で単純に適用したのみです。
結論
与えられたタスクを短いコードで記述するという考え方は,Einstein博士の,“知識の適切な表現を一片のデータたりとも放棄することなく,可能な限り単純で少数の,分解不可能な基本的要素を作り上げる”という考えに沿ったものです。これは一般的に,“より簡単にではなく,可能な限り単純化する”と引用されます。ラムダ式と新しいストリームAPIは,スケール性のよいシンプルなコードを記述可能な新機能として注目されることが多いのですが,コードを適切に簡素化し,最高の表現を実現可能にするという面でプログラマに寄与する機能でもあるのです。
関数型プログラミングのイディオムは意図的に短くなっているので,ほとんどそのままで,Javaコードをより簡潔にすることが可能な場合がほとんどです。新しい構文は馴染みの薄いものですが,過度に複雑という訳でもありません。これらの新機能は,Javaが,言語としての本来の目的を遥かに越えた位置にあることを明確に示しています。現在では,他のプログラム言語の持つ優れた機能を包含し,独自の機能としてそれらを統合しているのです。
著者について
Casimir Saternos氏はソフトウェア開発者,データベース管理者,ソフトウェアアーキテクトの職に15年以上従事しています。氏は先日,Rプログラム言語に関する著作とスクリーンキャストを公開しました。JavaおよびOracleのテクノロジに関する氏の記事は,Java MagazineやOracle Technology Networkで見ることができます。氏はまた,O'Reilly Mediaが発行している“Client-Server Web Apps with JavaScript and Java”の著者でもあります。