【Java6から】古いJavaのサンプルコードを用意してモダン化してみる【Java10まで】

勉強用にやってみた時のメモ。アップデートと共に書き方が変わるのは Java に限った話ではありませんが、Java は特に昔と今で書き方を大きく変えられる言語な印象があります。Java の勉強中にネット記事を眺めていても、バージョンの異なる色々な書き方が混在していたりして結構混乱しました。どれが昔の書き方でどれが今風の書き方なのかをざっくりと確認するために、古めかしい Java サンプルコードを用意してステップを踏んで今風に書き換えてみます。

サンプルコード

public static String method() {
    BufferedReader reader = null;
    try{
        reader = new BufferedReader(new InputStreamReader(new FileInputStream(
                new File("test.txt")), "UTF-8"));

        Set<String> set = new HashSet<String>();
        String line;
        while((line = reader.readLine()) != null){
            if(line.length() >= 7) {
                set.add(line.toLowerCase());
            }
        }
        List<String> list = new ArrayList<String>(set);
        Collections.sort(list);

        StringBuilder builder = new StringBuilder();
        for(String s : list){
            builder.append(s);
        }
        String str = validate(builder.toString());
        if(str != null){
            str = str.replace("a","b");
        }
        return str;
    }catch (IOException e){
        e.printStackTrace();
    }finally{
        if(reader != null){
            try{
                reader.close();
            }catch(IOException e){
                e.printStackTrace();
            }
        }
    }
    return null;
}

public static String validate(String str){
    return str.length() >= 30 ? "【" + str + "】" : null;
}

こいつを最終的に以下のようにします。

public static Optional<String> method() {
    try (Stream<String> lines = Files.lines(Paths.get("test.txt"))){
        var list = lines.distinct()
                .filter(s -> s.length() >= 7)
                .map(s -> s.toLowerCase())
                .sorted()
                .collect(Collectors.toList());

        var str = validate(String.join("", list));
        return str.map(s -> s.replace("a", "b"));
    } catch(IOException e){
        e.printStackTrace();
    }
    return Optional.empty();
}

public static Optional<String> validate(String str){
    if(str.length() < 30){
        str = null;
    }else{
        str = "【" + str + "】";
    }
    return Optional.ofNullable(str);
}

便宜上、サンプルコードではロジックとして冗長だったり無意味な記述がありますがあしからず。大した変更はしていないのですが、それでもコードの見通しが良くなっていると思います。が、今風に書くのが必ず正しいとは限らないという前提は置いておきます。

method() はファイルから文字列を読み込んで、それを加工して返しているだけです。validate() は引数にとった文字が30文字以上なら両端に【】を付けたものを返し、30文字以下なら null を返すメソッドです。

以下、本題。

try-with-resources , NIO2 を使う

上から順に書き換えていきます。まずは以下の部分。

BufferedReader reader = null;
    try{ 
        reader = new BufferedReader(new InputStreamReader(new FileInputStream(
                new File("test.txt")), "UTF-8"));
        ...
    }catch(IOException e){
        ...
    }finally{
        if(reader != null){
            try{
                reader.close();
            }catch(IOException e){
                e.printStackTrace();
            }
        }
    }

Java7 から使える try-with-resources を使えば、この冗長な記述をスッキリできます。try-with-resources は、IO等のリソースの変数を try の () 内に書くことで、コード側でリソースの close 処理を意識しなくてよくなる仕組みです。また、これがないと上記のように finally 部分で reader の close 処理をするためにまた try 文を書くというやたら面倒くさい記述になります。

ちなみに、Java9 までは try の () 内で変数を宣言しなければなりませんでしたが、9からは予め宣言しておいたリソース用の変数を try の () 内に書くだけでもOKになったみたいです。宣言時に右辺で例外が投げられる可能性がある場合には () 内で宣言したほうがスッキリそうですが。

try-with-resources を使うと以下のようになります。

try(BufferedReader reader = new BufferedReader(
    new InputStreamReader(new FileInputStream(new File("test.txt")), "UTF-8"))){
    ...
}catch (IOException e){
    ...
}

これで reader の閉め忘れの心配も減ります。以下は try-with-resources の解説記事。

The try-with-resources Statement

また、Java7 からは NIO2(New I/O 2) と呼ばれる新たなファイル操作系のライブラリが追加されました。これにより、上のコードは次のように書き換えられます。

try(BufferedReader reader = Files.newBufferedReader(Paths.get("test.txt"))){
    ...
}catch (IOException e){
    ...
}

従来のコードでは、File クラスを使ってファイルの指定や操作をしていました。NIO2 ではファイルの存在場所を表す Path , Paths と、ファイル操作をするための Files に役割が分割されています。上のコードでは、Files の newBufferedReader() というファクトリメソッドに Path オブジェクトを渡して BufferedReader を生成しています。

従来の File クラスは今のところは非推奨になっていませんが、今後は NIO2 が主流になり File がサポートされなくなることが無きにしも非ずと思うので、積極的に使っていきたいところです。ちなみに NIO2 では、File では難しいメタデータへのアクセスやファイルの監視といったことが容易にできるようになっています。以下は NIO2 の解説記事。

The Java NIO.2 File System in JDK 7

StreamAPI を使う

次に、以下の部分について、StreamAPI を使って書き換えてみます。ここでやっているのは、ファイルから行を読み出して、7文字以上であれば小文字化してリストに追加し、ソートするというものです。リストは要素の重複を許さないという架空の要件を設定しているので、一旦 Set に入れてから ArrayList に変換しています。

try (BufferedReader reader = Files.newBufferedReader(Paths.get("test.txt"))){
    String line;
    Set<String> set = new HashSet<String>();
    while((line = reader.readLine()) != null){
        if(line.length() >= 7) {
            set.add(line.toLowerCase());
        }
    }
    List<String> list = new ArrayList<String>(set);
    Collections.sort(list);
    ...
} 

StreamAPI は Java8 で追加された機能で、これを使うと要素集合に対する計算操作をイイ感じに書けるようになります。今さっき書き換えたばかりの try の () 内も、StreamAPI で書くためにまた書き換えます。書き換えると次のようになります。

try (Stream<String> lines = Files.lines(Paths.get("test.txt"))){
    List<String> list = lines.distinct()
         .filter(s -> s.length() >= 7)
         .map(s -> s.toLowerCase())
         .sorted()
         .collect(Collectors.toList());
    ...
}

もはや先のコードの跡形もありません。1行目では、Files.lines() でファイルの内容(行の集合)をストリームに格納しています。そして次の行以降で加工操作をしています。distinct() で重複を排除し、filter() で7文字未満の行を排除し、map() で小文字に変換し、sorted() でソートし、最後に collect() で List 化しています。これは、先のコードよりも直感的に理解できるようになっている印象です。filter() や map() の引数で使われているのはラムダ式と呼ばれるもので、メソッドに処理内容を渡しているイメージになります。コールバック処理に近い感じでしょうか。これも Java8 で導入されました。

このように、StreamAPI とラムダ式を組み合わせると、従来の Java らしからぬコードが書けるようになります。かといって、何でもかんでもこれらの書き方をするのが正義かというと、難しいところです。上のコードぐらいなら StreamAPI 等を使うことで可読性を上げられそうですが、複雑な処理を無理やりこの書き方で書くと結構しんどいことになりそうです。なので、特にチーム開発などでは用法用量を定めて(守って)適当に使うのが良さそうです。

ちなみに、単純な配列をストリーム化した場合などはストリームの close について意識する必要はないのですが、上記のように IO リソースを扱うようなストリームを生成した場合には close 処理が必要なので、try-with-resources で囲んでいます。

以下は Stream についての解説記事。

Processing Data with Java SE 8 Streams, Part 1

細かいアップデートも使う

ちょっと余談ですが、StreamAPI や try-with-resources などの構文レベルでの変更以外にも、色々細かいアップデートがありました。例えば Java9 では、これまで結構不便に感じていたコレクションの生成が楽に書けるようになったりしています(List.of() とか Map.of() とかで宣言・生成できる)。

Java8 で追加された String.join() を使うと、以下の部分を書き換えられます。

StringBuilder builder = new StringBuilder();
for(String s : list){
    builder.append(s);
}
String str = validate(builder.toString());

String str = validate(String.join("", list));

になります。文字列の結合方法が幾つか追加されていて、String.join() もその1つです。これでいちいち StringBuilder を使って連結しなくてよくなります。

Optionalを使う

次に、以下の部分を書き換えます。

public static String method(){
    ...
        String str = validate(String.join("", list));
        if(str != null){
            str = str.replace("a","b");
        }
        return str;
    ...
    return null;
}

public static String validate(String str){
    return str.length() >= 30 ? "【" + str + "】" : null;
}

method() と validate() の両方を書き換えます。ここでは、Java8 で追加された Optional という仕組みを使います。書き換え後は下のようになります。

public static Optional<String> method(){
    ...
        Optional<String> str = validate(String.join("", list));
        return str.map(s -> s.replace("a", "b"));
    ...
    return Optional.empty();
}

public static Optional<String> validate(String str){
    if(str.length() < 30){
        str = null;
    }else{
        str = "【" + str + "】";
    }
    return Optional.ofNullable(str);
}

Optional の基本的な使い方は、「null かもしれない値」を Optional クラスでラップすることで、その値が「nullかもしれない」ということを明示的に表現することです。上のコードでは validate() が null もしくは文字列を返します。すなわち、もしかしたら戻り値が null かもしれないわけで、これは結構怖いことです。なぜなら、呼び出し側で戻り値の null チェックをし忘れるとヌルポ(NullPointerException)が発生するからです。書き換え前のコードでは if 文でちゃんと null チェックをしていますが、これを書き忘れると(大体忘れる) str.replace() のタイミングでヌルポが発生する可能性があります。

書き換え後の validate() では Optional.ofNullable(str) で str を Optional でラップしたオブジェクトを返しています。したがって、method() 側では戻り値を String で受け取ることはできません。Optional で受け取ることになります。このため、method() 側に「戻り値がnullかもしれないよ」ということを明示し、null チェックを強制できます。

method() 側は Optional を受け取ると、Optioanl の map() を使って文字列を replace() した値を return します。map() 内では Optional がラップした String オブジェクトが null かどうかをチェックしていて、null なら空の Optional オブジェクトを、値が入っていれば map() に渡したラムダ式を適用した値を返します。すなわち、null なら str.replace() は実行されず、null じゃなければ str.replace() が実行されます。併せて method() 側の戻り値も Optional 化して、null を返さないようにします。

ここでは map() を使ってヌルポを回避していますが、最も基本的な使い方は ifPresent() を使うことだと思います。こいつを使うと、値があれば◯◯◯する、なければ何もしないという分岐ができます。

これで少し安全なコードになりました。以下は Optional についての解説記事。

Tired of Null Pointer Exceptions? Consider Using Java SE 8's Optional!

var を使う

最後に、Java10 で導入されたローカル変数の型推論を使ってみます。以下の変数宣言部分の型を var に置き換えるだけです。

...
List<String> list = lines.distinct()
                    .filter(s -> s.length() >= 7)
                    .map(s -> s.toLowerCase())
                    .sorted()
                    .collect(Collectors.toList());

Optional<String> str = validate(String.join("", list));
...

var list = lines.distinct()
                    .filter(s -> s.length() >= 7)
                    .map(s -> s.toLowerCase())
                    .sorted()
                    .collect(Collectors.toList());

var str = validate(String.join("", list));

こうなります。変数宣言時にいちいち型を書く、抽象型で受け取るというのが Java らしさだった印象ですが、こんな書き方ができるようになりました。代入時の右辺から型が推測できる場合には、var で宣言することで、記述を簡易化できます。これを読みやすいと感じるか否かは人それぞれだと思います。個人的には型が全て書かれていたほうが読みやすい気が。

上の例だと特に、validate() の戻り値を var で受け取るのは賛否両論ありそうです。というのも、右辺を見ただけでは何の型が返ってくるのか判断できないためです。これを判断するには validate() の戻り値が何型なのかを調べなければなりません。IDEが使える環境で読んでいるのであればあまり手間でもないですが、ブラウザ上でコードを読んでいたりするとちょっと面倒かも。StreamAPI やラムダ式と同様に、良くも悪くも書き方の幅が広がったので、複数人での開発時にはある程度規約を設けた方がよさそうな感じです。

以下はローカル変数型推論についての解説記事。

JEP 286: Local-Variable Type Inference

おわり

ということで、最終的に以下の形になりました。

public static Optional<String> method() {
    try (Stream<String> lines = Files.lines(Paths.get("test.txt"))){
        var list = lines.distinct()
                .filter(s -> s.length() >= 7)
                .map(s -> s.toLowerCase())
                .sorted()
                .collect(Collectors.toList());

        var str = validate(String.join("", list));
        return str.map(s -> s.replace("a", "b"));
    } catch(IOException e){
        e.printStackTrace();
    }
    return Optional.empty();
}

public static Optional<String> validate(String str){
    if(str.length() < 30){
        str = null;
    }else{
        str = "【" + str + "】";
    }
    return Optional.ofNullable(str);
}

サンプルコードではやや冗長なロジックが目立ちますが、なんとなーく(そしてざっくりと) Java コーディングの変遷が掴めた気がします。var や ラムダ式はともかく、try-with-resources や Optional は安全性を高められるイイ感じの仕組みだと思うので、積極的に使っていこうかなあと。おわり。