【クラスローダ】JVMが読み込むクラスを見つける仕組み【パッケージ】

NoClassDefFoundError や ClassNotFoundException に遭遇した時など,Java で書くならクラスローディングについてある程度知っておいたほうがよいケースというのはあるかなあと思い調べたときのメモ.なんとなくの概要.

クラスローダ

基本的な仕組み

Java プログラムは基本的に,複数のファイルに分かれた沢山のクラスによって成り立っています.プログラムを動かすためには,これらのクラスを JVM が読み込まなければなりません.Java では JVM のクラスローダと呼ばれる機構がこの処理を担います.クラスローダは,読み込むべきクラスを判断し,ストレージから当該クラスファイルを探し出し,ファイルを開いてクラスをロードします.

クラスロードの戦略としては,オンデマンドな感じです.プログラムの開始時に全てのクラスを読み込むのではなく,実行中に必要になったタイミングで必要になったクラスの検索→ロードをします.

クラスローダは役割ごとに複数存在していて,それらが階層構造(親子関係)をとっています.役割といってもやることは同じなのですが,クラスをロードする場所がそれぞれ異なる形です.あるクラスローダはどこどこのパスからクラスをロードするのを担当して,あるクラスローダは別のパスからロードするのを担当するという感じで.

で,基本的には,親の担当範囲で見つけられるクラスなら親にロードしてもらう,親がロードできなければ子がロードするという風に,上位のクラスローダから順に特定のクラスのロードを試みていきます.ロードの起点となるのは,実行されているクラスをロードしたクラスローダです.起点となったクラスローダは親がいれば親にロードを任せ,その親が更にその親に任せていきます.この仕組みは委譲(delegate)と呼ばれます.

また,クラスローダは基本的に子に委譲しません.これは結構重要なポイントです.例えば,クラスローダAによってロードされたクラスAを実行する際,クラスAが必要とする他のクラスのロードの起点となるのはクラスローダAです.すなわち,クラスA実行時には,その子クラスローダであるクラスローダBがロードを担当する範囲にあるクラスBをロード出来ないということを意味します.起点となったクラスローダより階層的に下位に存在するクラスローダは使われず,その下位クラスローダにしかロードできないクラスはロードできないのです(が,このスタイルが問題になるケースも存在するということで,委譲の順番をあえて変更しているサーバ等も一応存在).

このように,ロードに優先順位というか,方向性をもたせることで,複数のクラスローダが同じクラスを重複してロードしたりして競合が発生したりすることを防げます.このような仕組みになっているので,とあるクラスが,起点となるクラスローダの下位クラスローダ配下にしか存在しないクラスに依存する,という設計はよろしくないでしょう.

また,クラスローダは階層構造の横方向にも委譲しません.すなわち,兄弟関係にあるクラスローダ同士はお互いに影響し合わない仕組みになっています.このようになっていると,単一のサーバにデプロイされる複数のアプリケーションごとに兄弟関係になるクラスローダを作ることで,それぞれのアプリケーションでバージョンが違うクラスを読み込んだりできます.

基本となるクラスローダ

で,基本的なクラスローダは,ブートストラップクラスローダ,拡張クラスローダ,システムクラスローダの3つです.これらは挙げた順に親子関係になっています.

ブートストラップクラスローダは JVM そのものの実行に必要になるクラスをロードする基礎的なクラスローダです.JDK のコア API に関するクラスなどがこのクラスローダにロードされます.例えば,java.lang.* などがそれに当たります.<JAVA_HOME>/lib にあるような jar とかですね.また,このクラスローダは基本的には JVM 内にネイティブ実装されています.そのおかげで,そもそも最上位のクラスローダは誰がロードするのか問題(パラドックス)を解決できています.

拡張クラスローダは,拡張用ディレクトリ(一般的には <JAVA_HOME>/lib/ext)にあるクラスをロードします.クラスパスを変更することなく基礎的な機能を Java に追加したい場合には,このディレクトリに追加してあげれば簡単に実現できます.

システムクラスローダは,クラスパス(環境変数)で指定している場所のクラスのロードを担当します.普段プログラムを動かすためにクラスパスを追加すると思いますが,その場所のクラスはこいつが読み込んでくれています.また,このシステムクラスローダを親として独自のクラスローダを作成することも可能です(ユーザ定義クラスローダ).ユーザ定義クラスローダを作ることで,アプリケーション単位でクラスローダを割り当て,ライブラリのロード方法を変えたりできます.

ブートストラップクラスローダはネイティブ実装されているのですが,その他のクラスローダは Java オブジェクトとして存在するので,プログラム中でそのインスタンスを取得したりなんてことも簡単にできます.

クラスローディングの流れの大枠

JVM がプログラムを実行中にクラスのロードが必要だと判断すると,クラスローダに処理を依頼します.クラスローダはロードすべきクラスの完全修飾名を基に処理を始めます.完全修飾名とは,com.example.Hoge のようにクラス名とパッケージ名を繋げたものです.クラスローダは自分がどの場所からクラスを探せばよいかは知っていますが,その場所のどのクラスをロードすればよいかは知らないので,この完全修飾名を与えてロードすべきクラスを決定します.

例えば,/usr/local/lib を探すクラスローダがあったとして,JVM がそれに com.example.Hoge クラスをロードしてもらう指示を出した場合,完全修飾名がパスに変換され /usr/local/lib/com/example/Hoge.class をロードしようとします.この際,クラスが見つからない場合には ClassNotFound の例外が投げられることになります.

言い換えると,読み込まれるクラスは,パッケージ名とクラス名,そしてクラスローダの3つによって決定されるということです.完全修飾名が全く同じクラスが別の場所に複数存在していた場合,処理をするクラスローダによってどのクラスが読み込まれるかが変わってきます.また,同名の完全修飾名をもったクラスが複数のクラスローダからロードされ得る場合には,一番最初に発見されたクラスがロードされる事になっています.

クラスが無事発見されると,そのクラスが正しい構造になっているかどうかを検証したり,static フィールドを初期化したり,static イニシャライザを実行したりして,クラスを使える状態にします.

おわり

このように Java ではクラスローダという機構がクラスロードを担当しています.他の言語でのクラスロードの仕組みがどうなっているのか少し気になったので,また暇な時に調べて記事にしようかなーと思います.おわり.

参考

https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-5.html#jvms-5.3
https://www.ibm.com/developerworks/jp/websphere/library/java/j2ee_classloader/1.html

Javaでtail -fならcommons-ioが楽

Java で tail -f のようなファイルの監視・追跡をしたい時に0から実装するのもなかなか面倒くさいです.Apache commons-io というライブラリにそれを簡単に実現できる機能があったのでメモしておきます.

Commons IO

IO 系をよしなに扱うためのオープンソースライブラリです.今回はその中のファイル監視機能を使って tail -f を簡単に実現します.
ライブラリは以下からダウンロード可能です.

https://commons.apache.org/proper/commons-io/download_io.cgi

勿論 Maven Repo にもあります.

https://mvnrepository.com/artifact/commons-io/commons-io

Tailerを使う

tail -f を実現するために,commons-io の中の Tailer というクラスを使います.
以下が基本的な Tailer の使い方です.

public class Test {
    public static void main(String args[]){
        File targetFile = new File("log.txt");  // 監視対象のファイル
        TailerListener listener = new MyListener();  // 対象ファイルに追記があったときの動作を指定
        int checkDelay = 100;  // 追記の確認間隔
        boolean tailFromEOF = true;  // trueにするとファイルの最終行から読み取る. falseだと最初の行から.

        Tailer tailer = Tailer.create(targetFile, listener, checkDelay, tailFromEOF);

        // その他の処理
    }
}

public class MyListener extends TailerListenerAdapter {
    @Override
    public void handle(String line) {
        System.out.println(line);
    }
}

ファイルに追記があった場合の動作を規定したリスナークラスを自分で作成して,それを元に Tailer インスタンスを作ってあげる形です.追記が発生すると,追記された文字列を引数にリスナークラスの handle() が呼び出されるので,ここでは追記された行を単に標準出力することになります.

また,上では create() というファクトリメソッドで tailer インスタンスを作成しています.で,最終的にたどり着く create() の中を見ると以下のようになっています.

public static Tailer create(File file, TailerListener listener, long delayMillis, boolean end, boolean reOpen, int bufSize) {
    Tailer tailer = new Tailer(file, listener, delayMillis, end, reOpen, bufSize);
    Thread thread = new Thread(tailer);
    thread.setDaemon(true);
    thread.start();
    return tailer;
}

注意したいのは,create() インスタンスの中でスレッドをスタートさせているところです.したがって,create() を実行するとバックグラウンドで tail -f が走る形になります.なので,メインロジックが終了すると tail 処理も止まります.つまり,先のプログラムで「その他の処理」と書いた部分に何も書かなければ,tail 機能は動き始めた瞬間に止まることになります.

もしバックグラウンドではなくメイン処理としてファイルの監視ループをしたいのであれば,以下のように Tailer を使います.

public class Test {
    public static void main(String args[]){
        ...省略...
        Tailer tailer = new Tailer(targetFile, listener, checkDelay, tailFromEOF);
        tailer.run();  // ここでループが走りmain処理が止まる
    }
}

こちらでは,Tailer のコンストラクタを使ってインスタンスを生成し,その後でインスタンスの run() を呼び出しています.

run() ではスレッドを起動せず,run という private メンバを終了条件として while によるフラグループをします.run は private なので,外から状態を変えるには stop() を使います.stop() は run を false にするだけのメソッドです.なので,ループを止めたい状況では他のスレッドなどから tailer.stop() を呼んであげれば大丈夫です.

Tailerの動作の大枠

以下 run() の中身です.簡単のため,メイン処理部分だけ抽出して後は省いています.

public void run() {
    // ファイルオープンと初期化
    RandomAccessFile reader = null;
    try {
        long position = 0;

        while (run && reader == null) {
            reader = new RandomAccessFile(file, RAF_MODE);
            
            // ポイント位置の初期化
            position = end ? file.length() : 0;
            reader.seek(position);
        }
    }

    // 監視ループ
    while (run) {
        long length = file.length();

        if (length > position) {
            // 追記あり position の更新とリスナの呼び出し
            position = readLines(reader);
        }
        
        try {
            Thread.sleep(delayMillis);  // 意図的なディレイによる待機
        }
    }
}

やっていることは,ループして現在のファイルのポイント位置とファイルの長さを比較して,追記行の存在判定をしているだけです.ここでは,RandomAccessFile というクラスを使ってファイルのポインタ位置の細かい制御とシーク処理を実現しています.自分で tail を実装しようとする場合にも,お世話になりそうなクラスです.以下は概要記事.

http://www.javaroad.jp/java_io9.htm

で,追記行があれば readLines() を呼び出して追記行を読み込み,リスナに処理させています.以下 readLines() の中身です.

private long readLines(RandomAccessFile reader) throws IOException {
    StringBuilder sb = new StringBuilder();

    long pos = reader.getFilePointer();
    long rePos = pos; // position to re-read
    int num;

    // 追記行を読んでリスナーのhandleを呼び出す
    while (run && ((num = reader.read(inbuf)) != -1)) { 
        for (int i = 0; i < num; i++) {
            byte ch = inbuf[i];
            switch (ch) {
            case '\n':
                listener.handle(sb.toString());
                sb.setLength(0);
                rePos = pos + i + 1;
                break;
            default:
                sb.append((char) ch);
            }
        }

        pos = reader.getFilePointer();
    }

    reader.seek(rePos);
    return rePos;
}

やっていることは単純に行を読み込んでリスナーの handle() に渡しているだけです.が,改行文字の扱いなどがあるため,少しだけ複雑になります.で,最後 rePos を return する前に RandomAccessFile の reader をシークして,ファイルのポイント位置を更新しています.あとは run() に戻り,これの繰り返しです.

JavaでShell的に使えるコマンドラインツールを作る

Javaコマンドラインツールを作ったときのメモ.あえて Java でやる必要性は無いのですが,なんとなく Java で書きたいこともあるでしょう.

JCommanderを使ってコマンドライン引数の管理する

基本的にコマンドはオプションありきなので,引数をパースしてオプションを解釈しなければなりません.JCommander というオープンソースライブラリを使うと与えられた引数文字列→オブジェクトのマッピングをしてくれるので,かなり楽になります.例えば,「-f filename.txt」という文字列から File 型のオブジェクトに変換して保管してくれたりします.

このようなライブラリはコマンドラインパーサは JCommander 以外にもいくつかあります.が,個人的になんとなく良さそうだと感じたのが JCommander だったのでこれにしました.引数を特定の型のオブジェクトに変換するとか,文字列のバリデーションとかの機能の拡張も簡単です.まあこの辺は好みで選べばいいかと思います.

Javaコマンドラインパーサをまとめてくれている人がいました↓.

CLI Comparison · remkop/picocli Wiki · GitHub

ダウンロード

ソースは以下

GitHub - cbeust/jcommander: Command line parsing framework for Java

Maven や Gradle を使っている場合は以下から.ちなみに現在,公式Docには Maven Repository から ver1.71 をダウンロードするような記述がありますが,1.71 は今管理されていないので 1.72 を指定します.プルリク送ろうと思ったらもうだいぶ前に出している人がいました(ザワ).

Maven Repository: com.beust » jcommander

基本機能

public class Args {
  @Parameter(names = { "-log", "-verbose" }, description = "Level of verbosity")
  private Integer verbose = 1;

  @Parameter(names = "-debug", description = "Debug mode")
  private boolean debug = false;

  @Parameter(names = "-out",
      description = "File name",
      converter = FileConverter.class)
  private File file;

  // あとgetterとか
}

こんな感じで引数クラスを定義して,オプション用のフィールドはアノテーションで指定します.文字列として渡される引数を File 型のインスタンスに変換したい場合などは,Converter クラスを作成してアノテーションの converter オプションに指定してあげます.

public static void main(String args[]) {
    Args argsInstance = new Args();
    JCommander.newBuilder()
            .addObject(argsInstance)
            .build()
            .parse(args);
    argsInstance.getFile();
}

あとは上のように Args 用のインスタンスを用意してパースメソッドに渡してあげるだけです.

詳細は以下の公式Docから

JCommander

成果物のjarファイルをコマンド的に使えるようにする

Java だとプログラムを書き終わったら jar ファイルとして出力するのが一般的だと思います.ただ,このプログラムを使うのにイチイチ以下のように書くのは面倒です.

java -jar hogehoge.jar -debug -out filename.txt

ので,この jar をコマンド化します.

コマンドライン

コマンドラインからだと以下でOKです.

(echo '#!/usr/bin/env java -jar'; cat hogehoge.jar) > hogehoge
chmod +x hogehoge

1行目に java -jar を指定した shebang,その後に jar を続けたファイルを用意し,実行権を付与しています. で,hogehoge を /usr/local/bin とかパスの通った場所に置くと,以下のように実行できます.

hogehoge -debug -out filename.txt

Gradleタスクで

この動作を Gradle のタスクとして書いている人がいましたので以下に貼っておきます.

gist.github.com

PHPコードのコンパイルの流れメモ

久しぶりに PHP を触り処理系の動きがどんな感じだったのか忘れたのでおさらいメモ.ざっくりと大枠.

PHP では実行時にソースコードコンパイルされます.で,最終的にオペコードと呼ばれる中間コードに変換され,実行マシンによって逐次的に実行されます.コンパイルはいくつかのフェーズに分かれていて,今回はその各フェーズについて概要を書きます.なお,たまたま PHP を題材にしていますが,大まか流れはどの言語も似たような感じだと思います.

レキシング(字句解析)

ソースコードはまず,コンパイル時にレキサ(レキシカルアナライザ,字句解析器)によって字句解析されます.字句解析とは,ソースコード中の文字列を意味のある単位で分割し,それぞれを要素(トークン)として識別することです.

自然言語は名詞や動詞,接続詞などの様々な品詞に分けられますが,それと同様にソースコードも意味のある単位に分けられます.例えば,'<?php' は T_OPEN_TAG,$hoge という変数は T_VARIABLE,スペースは T_WHITESPACE というトークンになります.以下は PHPトークンの一覧で,眺めるとなんとなく雰囲気が掴めると思います.

PHP: パーサトークンの一覧 - Manual

で,コード上の文字列をそれぞれ何のトークンとして識別するのかを決めているのが,以下のような定義ファイルです.ここには,どういう状態の時に,どういう文字列が検出されたら,どういうトークンとして識別する,というような決めごとが書いてあったります.逆に言えば,この定義ファイルを変えれば言語の動作を変えることもできるということです.

php-src/zend_language_scanner.l at master · php/php-src · GitHub

こいつを re2c というレキサジェネレータ(レキサを作るソフト)に突っ込んでレキサを作り,それを使ってソースコードの字句解析をします.re2c 自体は PHP プロジェクト外で開発されたものです.このようなサードパーティ製のジェネレータを使うのは車輪の再発明をしないためでしょう.最適化などが熟考された汎用的なソフトが既にあるので,定義ファイルだけ作れば高性能なレキサが手に入ります.

このようにして生成されたトークン群は次のステップの主役であるパーサに出力されます.つまりこのフェーズでは,ソースコードをレキサへの入力とし,結果としてトークン列をパーサに出力します.

パース(構文解析

レキサがコードをトークン列にすると,それがパーサによって構文解析(パース)されます.構文解析では,書かれたソースコードの構造(文法)を解釈し,その正しさを確認します.

これも自然言語で考えることができます.自然言語には,例えば「動詞→名詞」はOKとか,「名詞→副詞」はNGとか,様々な文法の規則が存在します.プログラミング言語にも同じように文法があり,そのルールが守られているかどうかをパーサが検証するわけです.具体的には,トークンの順序やトークン間の関係性を検証します.定義されていない構文が出てきたら,エラーにします.

PHP の処理系は,ここでもレキサと同じようにジェネレータを使ってパーサを生成します.で,以下で文法が定義されています.これをパーサジェネレータへの入力とし,パーサを作るわけです.

https://github.com/php/php-src/blob/master/Zend/zend_language_parser.y

また,自然言語の文はこのように木構造で表すことができます.ソースコードも同様に文法的に木構造で表現でき,構文木と呼ばれます.また,構文木からオペコードの生成に不要な要素を取り除いて簡略化したものを抽象構文木(AST)と呼びます.以下はこちらで説明されている構文木と抽象構文木の違いです.両方とも表現しようとしているソースコードは同じです.

f:id:norikone:20180611002035p:plain:w260       f:id:norikone:20180611002043p:plain:w260

パーサが生成した AST は,次のステップの主役であるコンパイラへの入力となります.したがって,構文解析フェーズの役割は,トークン列を入力としてそれを基に AST を生成することです.

ちなみに PHP7 までのパーサは AST を生成せずに,トークン列を読みながら直接オペコードを生成していたらしいです.AST を生成するようになったのは,パーサとコンパイラを分離することで実装の保守性を高めたり,これまで実現が難しかった構文を導入したりできるようになると期待されたためです(詳細).

トークン列を読みながら直接オペコードを生成する方法だと,トークンを読んだ時点で出力するオペコードを決定しなければなりません.AST を構築する方法では一回全体を把握した後にオペコードを生成するため,読んだトークン以降のトークンを考慮して構文を解釈できるようになり,幅が広がるというイメージでしょうか.

コンパイル

ここまで来たら,あとはソースコードの中間表現である AST を辿りながら,実行マシンが理解できるオペコードに変換します.実際にそれを実行しているのは以下のファイルです.

https://github.com/php/php-src/blob/master/Zend/zend_compile.c

こいつが AST を読み,オペコードを出力していきます.AST が出来上がった段階でどのようなオペコードを出力すればいいかは決まっているので,この変換作業は割と単純なものであるはずです.逆に言えば,パースでは演算の優先度などを考慮して AST を構築するため,すこし変換規則が複雑です.

ちなみにコンパイル時には,様々な最適化やアクセラレータによるオペコードのキャッシュなども行われます.アクセラレータを使うと毎回コンパイルし直す必要がなくなるので,実行の高速化が期待できます.代表的なアクセラレータとして,APC や OPcache などが挙げられます.

コンパイルが完了したら実行マシンが逐次的にオペコードを実行していきます.

おわり

ということで PHPコンパイル(広義)は,レキシング→パース→コンパイル(狭義)という順で行われます.これだけでは詳細まで理解できませんが,なんとなく大枠の雰囲気は掴めたかなと思います.

Javaにおける検査例外の有効性と非検査例外との使い分けについて考えたこと

Java での検査例外と非検査例外の使い分けについて少し考えたときのメモ.どうやら検査例外を使う使わないについては賛否両論あるらしく,ネットで検索してみても,ブログ等でいくらか議論されていることがわかります.それらの記事を眺めながら,検査例外について考えてみます(開発経験の少ないキッズが自分が理解しやすいように勝手な解釈で書いているかもしれないので注意).

また,この記事では,ある状況を例外として表現するかどうか問題については書きません.例外を使うと選択した場合の検査例外と非検査例外のどちらを使うか問題についてだけ検討します.

まず前提として,Java コーディングにおいて,非検査例外は無くせないが検査例外は無くせるもの考えます.これは,非検査例外を無くすとコードがトンデモナク冗長に成り得るので現実的ではないことや,Java 以外の多くの言語が検査例外を採用していない状況に起因します.なので,例外処理の仕組みの基本は非検査例外であり,検査例外というのは非検査例外を使うよりも良いケースで使う発展的な仕組みであると考えます.

一方で,Java の検査例外はいくらか問題(後述)も孕んでいるので,滅多矢鱈に使うのはよろしくなさそうです.したがって,使う場合には「非検査例外を使うよりも良いケース」を見極めることが重要になると思います.ということで,そもそもそのようなケースが存在するのかどうか,存在するのであればどのようなケースか,ということに興味があるわけです.

このメモではまず検査例外の良いところと悪いところを確認してから,検査例外のいくつかの運用方法を紹介し,そのトレードオフについて見ていきます.

先に結論

を書くと,「プログラミングで回避できない」かつ「呼び出し元で例外を処理できる」ケースが,検査例外を使う適切な場面であるということです.勿論これは僕にとってはそうだというだけの話で,絶対的な正解ではありません.そんなものはそもそも存在しないという前提で,自分にとってベストな方法を見つけるための指針を示せればいいかなという感じです.

Java の検査例外と非検査例外の実用上の違い

まず,非検査例外と比較したときの Java の検査例外の特徴を挙げておきます.

  • 検査例外はコード上で文書化(仕様化)され,そのコンパイラにその正当性が保証される
  • 検査例外はメソッドの呼び出し元に例外の対処を強制する(捕捉or伝播)

これらの特徴が検査以外の良い面でもあり悪い面でもある,ということについて次に書きます.

検査例外のいいところ

検査例外を使うにあたっては,その利点難点を理解しておく必要があると思いますので,はじめにそれを書きます.

文書化(仕様化)能力

例外仕様は文書化されていた方が良い,と考えている方は多いのではないかと思います.何らかのメソッドを使う際にはその例外仕様について把握したいところですが,それが何も明文化されていない状態は利用側にとって厄介です.

[1]では,検査例外がコード上で例外の情報(メソッドの振る舞い)を文書化できることを主張しています.この文書化はメソッドの throws 節で表現されます.また,コンパイラがその正当性をチェックします.このため,「throws に書いている検査例外が実際には発生し得ない」もしくは「発生し得る検査例外が throws に書かれていない」という状況が発生しません.これを静的に検査し,保証できるのが Java の検査例外の強みの一つと言えます.

一方で非検査例外はコード上で明文化されないため,Javadoc などの補足資料だけを使って例外仕様を文書化することになります.例外の対処も強制されないので,あるメソッド内で発生し得る非検査例外をすべて把握し,明文化するのは困難です.このため,非検査例外の積極的な使用は,予期せぬ例外によってプログラムが暴走する事態を起こしやすくします.

また,非検査例外は Javadoc とコードの対応関係は検査されず,Javadoc に書き忘れがあると文書化に漏れが発生することになります(非検査例外での文書化も checkstyle 等である程度はサポート可能[2]らしいですがやはり完璧ではなさそう).併せて,投げる例外が変更された場合は Javadoc も書き換える必要がありますが,ここでも書き換え忘れによる仕様書バグを生む可能性があります.このように,非検査例外ではコードと文書にズレが生じてしまうことも考えられます.検査例外はコード上での文書化が強制され,正当性が検査されるので,その点は健全であると言えます.

ということで,検査例外は非検査例外に比べて文書化能力に長けています.

エラー処理の強制能力

Java の検査例外は,メソッドの呼び出し元にその対処を強制します(catch するか更に上に throw するか).[3]では,メソッドで例外が発生した際の後処理を書き忘れることができなくなることを検査例外の利点としています.ここで「後処理」とは,何らかの例外回復処理や,安全のためのクリーンアップ処理などのこととします.

例えば例外が発生した際に,リソースの close 処理や,バックグラウンドで密かに動いていたスレッドの停止処理などをしないと,リークが発生するかもしれません.デスクトップアプリではプログラムが緊急停止する前に窓を閉じる必要があるかもしれません.回復処理で言えば,何らかの一時的な理由により DB 接続に失敗した場合にはリトライをした方がいいかもしれません.

というように,一般的に例外が発生したら何かしらその対処が必要であると考えると,その処理を強制して忘れられないようにする仕組みは堅牢で良さそうに思えます.また,エラー対処を強制されることで,常に例外発生時の挙動について意識しながらコーディングできるようになります.

一方で,非検査例外では,メソッドが例外を投げる可能性をコード上で明示できないので,例外発生時のハンドリングを忘れやすくなるでしょう.例外仕様について頑張って Javadoc に示しても,読まれなければ効果はありませんし.安全性を考えた非正常時の考慮の強制という意味では,検査例外は Optional とかに近い考え方かもしれません.

ということで,検査例外を使うと基本的には安全性・堅牢性を高められると言えます.

検査例外のよくないところ

仕様変更時に面倒くさいことになる(文書化能力の副作用?)

これはよく「検査例外は開放/閉鎖原則に違反する」とか言われているやつです[4-7].検査例外で必須の throws 節はメソッドシグネチャの一部です.これは例外が伝播する経路上のすべてのメソッドが書くことになります(捕捉しない限り).

このような状況で例外を投げる大本のメソッドに仕様変更があり,投げられる例外が変わったり,新たな例外が投げられることになったりすると,その経路上のメソッドシグネチャすべてに変更を適用しなければなりません.勿論捕捉コードがあればそちらも変更することになるでしょう.

この状況が開放/閉鎖原則に違反していると言えるのかどうかはさておき,仕様変更による修正箇所の範囲がそれなりに広いことは事実だと思います.非検査例外でも捕捉コードがあればそちらに影響があることも事実なのですが(まあこれは一般的な戻り値でも同様),やはり throws を書かなければならない検査例外の方がこの問題が大きくなることは想像が付きます.

これは throws に具体的な型を記述する慣習に起因するかと思います.ぶっちゃけすべて Exception で宣言してしまえば throws 節の変更は必要無くなるわけですから.

変更に強くするためにできるだけ抽象化しようというのがオブジェクト思考プログラミングの基本思想ではありますが,例外処理ではこのような極端な抽象化は悪とされ,そのハンドリングのために具象化が好まれています.しかしながら,具象化しすぎると変更に弱くなりすぎるので,適度に抽象化しようというような話もあります[5,8].このあたりにも検査例外設計の難しさが潜んでいます.

ちなみに Swift にも throws 節があるらしいですが,型の明示は無いっぽいです(現在でもそうなのかは未調査)[9].処理の強制や throws 節はあるみたいです.この辺りの問題の対処(妥協点の一つ)かもしれません.

まとめると,検査例外を使うと変更耐性が落ちる可能性が高まるということです.

冗長化,複雑化する(エラー処理の強制能力の副作用)

エラー処理の強制という安全を意識した思想自体は避難されるようなものではありませんが,代償として副作用が生まれます.で,その問題を象徴するコードに,catch に何も例外の対処処理を書かないというものがあります[11].ここには2つの問題が潜んでいます.

1つは,何らかの対処が必要な例外の握りつぶしです.例えば,catch で例外を対処してプログラムの暴走を止めなければならない状況であるのにも関わらず,catch に何も書かないという場合です.クリーンアップやログ化など,やるべき処理があれば書かなければなりませんが,検査例外は特定の処理の実装までは強制できません.できるのは捕捉か伝播かの選択の強制だけです.

初心者のプログラマがこのような状況で例外を握りつぶしてしまうと,いざ問題が発生した時の原因特定が厄介になったりしそうです.これは結構色々なところで言われている気がします.非検査例外を使っていれば,そもそもその存在に気づかれず,catch されずに最低限のエラーの報告は達成されるはずです.意図的に catch するケースで握りつぶすなんてことはないはずですし.

もう1つの問題は,「必要のない処理の強制」です.上の状況では後処理を書かないプログラマが悪でしょう.しかし,そのような後処理が必要ないのにも関わらず try-catch を迫られるケースというのも少なからず存在するように思います.プログラマの気持ちとしては「別に回復処理なんて要らない状況だけど書かなきゃコンパイルが通らないからとりあえず対処する」という場合です.これは圧倒的無駄です.

必要な処理を迫ることは正しい強制と言えそうですが,この場合はそうではありません.この際,捕捉での握りつぶしか伝播かを選択すると思います.もしくは非検査例外でラップして投げ直すかもしれません.どちらにしても冗長な記述が生まれ,コードの見通しが悪くなります.

このように,検査例外を使うと無駄なエラー処理の強制によってコードが冗長化・複雑化する可能性が高まります.また,そのような処理を面倒臭がったプログラマが後処理が必要な局面で例外を握りつぶしてしまい,問題の原因の特定を難しくすることも想定できます.

検査例外と非検査例外のどちらを使うか

これは結局何を大事にしたいかによるかと思います.文書化を第一に考え,上に挙げた検査例外の難点が気にならないのであれば極力検査例外を使うことになりますし,仕様変更時の影響範囲が大きいことを気にするのであればすべて非検査例外を使えばいいと思います.

もしくは,エラー処理の強制による堅牢性と,その冗長性のトレードオフで考えることもできます.ほぼ検査例外だけを使うようにすれば堅牢なプログラムを作ることができそうですが,コーディングは冗長で面倒なものになりそうです.一方で,非検査例外だけを使えばスムーズなコーディングができそうですが,堅牢性は落ちるでしょう.

が,ここまで書いてきたことを見ると,このトレードオフについてはどちらか一方の極端な選択だけでなく,間を取るようなこともできそうな気がしてきます.検査例外の堅牢性を活かしつつも冗長化は抑えたいという欲求です.検査例外の利点を活かしつつも難点を殺すような使い方ができれば,理想的かもしれません.

以降では,その間を取るような検査例外の使い分け法として有名なものについて書きます.これには2つあって,「プログラミングで回避できるかどうか法」と「呼び出し元で処理できるかどうか法」です.そしてその次に,あまり推されていないこの両方を組み合わせる方法について書きます.ちなみにこれらは文書化や開放閉鎖原則云々よりも,エラー処理の強制の利点を活かしつつも try-catch の冗長性と抑える方法な印象です.

プログラミング(事前処理)で回避できるかどうか法

プログラミングで発生を回避できる例外であれば非検査例外,回避できない例外であれば検査例外とする方法です[12].エラー処理には事前処理(例外が発生する前に対処)と事後処理(例外が発生した後に対処)の2つがあると思いますが,プログラミングで回避できるかどうかということは,つまり事前処理で回避できるかどうかを意味します.

で,事前処理で回避できない問題は必ず発生し得えます.例えば FileNotFoundException は事前にファイルの存在チェックをやれば回避できるわけではありません.チェック後にファイルが消える可能性があるので,いくら入念にチェックしても発生する可能性があります.

すなわち,このような例外は事後処理でしか対処できません.よって,もし事後処理を書いていなければ,エラー処理をしていないと断定できます.「エラー処理は絶対しなければならないものである」という前提で考えると,これをプログラミングミス=コンパイルエラーとし try-catch による事後処理を強制するのは妥当だ(無駄ではない),という考え方の方法です.

一方で,事前処理で回避できる例外までも検査例外にするとどうなるでしょうか.事前処理で簡単に例外を回避できるケースや,例外が発生しないことが自明なケースでも,事後処理を強制されます.したがって,無駄な記述が増えたり分岐の複雑性が増したりしてしまいます.つまり,すべての例外を事後処理で対処するのは問題なので,事前処理によるハンドリングの道を残しつつ,その中で確実にエラー処理をしていないと断定できるケースでのみ事後処理を強制しようという話です.

事前処理による例外回避等のハンドリングはコンパイラがチェックできないので,強制されることはありません.なので,書き忘れが発生する可能性があります.このため,極力検査例外を使う方法に比べると堅牢性に欠けるかもしれませんが,冗長性は減らせるはずです.また,非検査例外しか使わない方法に比べると,検査例外の安全性を活かせる方法と言えます.

呼び出し元で処理できるかどうか法

呼び出し元が投げた例外を呼び出し元が処理できるなら検査例外,できないなら非検査例外とする方法です.try-catch による冗長性は「必要のない処理を迫ること」から生まれます.したがって,呼び出し元が処理を必要とするかどうかで決めれば,無駄がなくなるという考え方です.

「プログラミングで回避できるかどうか法」はエラー処理は絶対必要であるという前提で考えるのに対し,こちらの方法ではエラー処理は不要かもしれないという前提で考えます.つまり,この方法での関心事は「後処理の強制が無駄になるかどうか」だけです.なので,「プログラミングで回避できるかどうか法」よりも更に try-catch の冗長性を抑えたいという志向の方法と言えるかもしれません.

この基準で判断する場合には,呼び出し元が後処理を必要とするかどうかを想像しなければいけません.しかし,呼び出し元が後処理を必要とするかどうかの知識を例外を投げる側が持っているとは限りません.また,状況によっては後処理が必要だろうけど不要なケースもある,というような中途半端な場合もありそうです.

すなわち,「プログラミングで回避できるかどうか法」よりも選択基準が曖昧になりがちで,設計者によって判断が変わる可能性が高いです.どんな状況でも確実に後処理が必要だと思える例外だけ検査例外にする方法や,回復の可能性が少しでもあれば検査例外にする方法など,色々考えられます.

このようなことから,考え方は単純でも実際やろうとすると難しい方法と言えます.このため,判断ミスにより,検査例外を使っておけば後処理を書き忘れなかったであろう状況で非検査例外を選択してしまい,何らかの予期せぬプログラムの停止が発生したりする可能性は高まりそうです.ただ,この例外が出たら後処理をしても無駄だろう,という状況で検査例外を選択しなくなるので,堅牢性は落ちるが冗長性は抑えられる判断基準だと言えます.

しかし,この方法では先の方法と違い,事前処理で対処可能な例外も検査例外とする可能性があります.事前処理で対処可能なのに後処理を強制される状況というのは,前述のとおり冗長化に繋がります.したがって,この方法には冗長性を抑える部分と増加させる部分が存在するということです.

上記2つの組み合わせ法

上に書いた2つの方法は排他的ではなく,両方の篩で判断する方法もあります.まず事前処理で対処可能かどうかを見て,可能であればそれはプログラマの責務とする.可能でなければ後処理が必要かどうかを考え,必要なら検査例外にしてそれを強制する.必要なければ非検査例外とする.というようなフローです.

これにより,「プログラミングで回避できるかどうか法」の持つ問題である「無駄な処理を強制してしまう冗長性」と,「呼び出し元で処理できるかどうか法」の持つ問題である「事前処理可能な例外に対する後処理の強制による冗長性」が解消されます.

トレードオフとしては,「プログラミングで回避できるかどうか法」を①,「呼び出し元で処理できるかどうか法」を②とすると,
←堅牢 ①,②,①+② 非冗長→
という感じでしょうか.①+②の方法はここまで書いた理由から,かなり無駄のない選択と言えるでしょう.で,Effective Java には以下のようなことが書いてあります.

API の適切な使用では例外状態を防ぐことができなく、かつ、その API を使用しているプログラマが例外に直面した時になんらかの有用な処理をできる場合に、その負荷は正当化されます。この両方が満たされない限り、チェックされない例外の方が適切です。

ここで言う「負荷」とは,例外処理の強制,捕捉もしくは伝播させなければならない状況によるもののことです.また,「APIの適切な使用で例外状態を防ぐ」という部分は,この記事で言うところの①の話で,「その API を使用しているプログラマが例外に直面した時になんらかの有用な処理をできる場合」というとは,この記事で言うところの②の話です.

つまりこれは,①+②が検査例外を使う最も納得感のある方法であると言っています.無駄が無く,かつ検査例外の強みも活かしている方法です.大袈裟な言い方をすれば,その冗長性が正当化され,検査例外がプラスにしか働かない状況ということです.検査例外の難点を最大まで抑え,利点を活かせる状況なのです.

実際には,先に上げた開放閉鎖原則の話や,「API を使用しているプログラマが例外に直面した時になんらかの有用な処理をできる場合」を判断する難しさがあるので,絶対に上手くいくとは限らないのですが,少なくとも考え方としては一番妥当だと感じます.

結論

徒然なるままにだらだらと書いていたら何が言いたいのかよくわからない感じになってしましましたが,最後に書いたように,僕が思う検査例外を使ったほうがいいケースは,①+②の篩を通った場合だということです.[13]にも同じような基準が書いてあります.検査例外を使い分けようとしている時点で主な関心事は「冗長性や複雑性の排除」なはずなので,であれば一番それを実現できそうなケースで使うのが妥当かなという印象です.

ただ,トレードオフの問題でもありますし,変更耐性という少し別の観点での問題も存在するので,これが正しいと言える理由はありません.個人的に一番納得感のある方法だと思っただけです.ぶっちゃけ万人にとって最も優れている方法なんていうのは存在しないはずなので,自分が一番納得できる方法を使うのが一番かなあと思います.

契約による設計とかの話はよく理解していないので,そのあたりを理解すればもう少し別の視点で書けたのかもなあと思います.だいぶ長くなってしまって推敲する気も起きないので色々矛盾とかあったらサーセンという言い訳を書いておわりにします.

参考

[1] : https://www.ibm.com/developerworks/jp/java/library/j-jtp05254/
[2] : http://checkstyle.sourceforge.net/config_javadoc.html
[4] : https://qiita.com/irxground/items/614cac8e6c50d0a80652
[5] : http://bleis-tift.hatenablog.com/entry/20090809/1249825777
[6] : http://d.hatena.ne.jp/SiroKuro/20090809/1249838522
[7] : http://d.hatena.ne.jp/j5ik2o/20091017/1255799827
[8] : http://d.hatena.ne.jp/j5ik2o/20100116/1263658938
[9] : https://qiita.com/Kokudori/items/0fe9181d8eec8d933c98
[10] : https://qiita.com/koher/items/e4c1d88981291c35d571
[11] : http://www.mindview.net/Etc/Discussions/CheckedExceptions
[12] : https://qiita.com/yuba/items/d41290eca726559cd743
[13] : http://d.hatena.ne.jp/kmaebashi/20100114/p1

【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 は安全性を高められるイイ感じの仕組みだと思うので、積極的に使っていこうかなあと。おわり。

なぜJavaの文字列(String)はイミュータブルなのか

Java における String は不変(イミュータブル)であるというのはよく知られていることだと思います.が,なぜそうなっているのかということについては考えたことがありませんでした.今回はその理由についてメモ.

理由① String Pool のため

Java では String は少々特別扱いされていて,Java ヒープには String Pool と呼ばれる文字列を格納しておく領域があります.プログラム中で使われる文字列をここで重複無く管理しておくことで,いくつも同じ値の文字列オブジェクトが生成されることを無くすための仕組みです.所謂 Flyweight パターンです.

例えば以下のコードがあった時,String Pool には "Cat" と "Dog" が格納されます.

String str1 = "Cat";
String str2 = "Dog";
String str3 = "Cat";
str1 == str3; // true

まず,1行目で String Pool に "Cat" が格納されます.そして,3行目が実行されるタイミングでは既に String Pool に "Cat" が存在します.この場合,新たなオブジェクトは作成されず,String Pool にある既存のオブジェクトへの参照を str3 に代入します.すなわち,str1 と str3 が同じオブジェクトへの参照を持つことになります.これにより,プログラムを省リソース化できます.

さて,この時 str1 と str3 を == で比較すると当然 true になるわけです.ここで,String が可変だったとすると,str1 の値を変更すると str3 の値まで変わってしまいます(例えば破壊的な str1.toLowerCase() とか.String が可変の世界ではあり得る).一方で,String が不変だとこのような問題が起きないのは自明です.

可変であったとしても,str1 の値の変更を検出した時にその値が str3 からも参照されていることを判定できれば,str3 には新しく生成した "Cat" への参照を代入するというやり方も無くはないかもしれません.もしくは逆に,2箇所以上から参照されている文字列を変更する時だけは新たにオブジェクトを生成するとか(参照カウンタを使ったりして).ただ,こんなことをするよりかは文字列を不変オブジェクトにしてしまう方が容易でしょう.

ちなみに,String Pool が使われるのは上記のコードのようにダブルクォートで文字列を生成した場合です(所謂リテラル).で,new String() を使うと String Pool が考慮されずに,String Pool ではないヒープ上に文字列オブジェクトが新規生成されます.ということで,省リソースのためにも文字列はダブルクォートで作った方が良さそうです.

String str1 = "Cat";
String str2 = "Cat";
String str3 = new String("Cat");
str1 == str2; // true
str1 == str3; // false
str1 == str3.intern(); // true intern()はStringPoolの同一文字列を検索してヒットしたらその参照を返す

余談ですが最新の IntelliJ では new String(); したらちゃんと注意されるようになってました.

f:id:norikone:20180515165258p:plain

また,処理時間を測ってみてもだいぶ差があることがわかります.なお計測方法が正しいかどうかは不明.

String s1 = "Cat";
long startTime;
long endTime;

startTime = System.nanoTime();
String s2 = "Cat";
endTime = System.nanoTime();
System.out.println("処理時間1:" + (endTime - startTime) + " ns");

startTime = System.nanoTime();
String s3 = "Dog";
endTime = System.nanoTime();
System.out.println("処理時間2:" + (endTime - startTime) + " ns");

startTime = System.nanoTime();
String s4 = new String("Cat");
endTime = System.nanoTime();
 System.out.println("処理時間3:" + (endTime - startTime) + " ns");

結果

処理時間1:319 ns    // String Pool ヒット
処理時間2:3314 ns   // String Pool ノーヒット
処理時間3:10007 ns  // new で生成

処理時間1は endTime の計算が処理時間の90%以上を占めている感じなので,実際の処理時間はもっと小さそうです.

理由② キャッシングのため

不変であるということは,一度オブジェクトが生成されるとその状態が将来的に変更されないということです.これは,ハッシュコードが変更されないことを保証します.なぜなら,ハッシュコードはそのオブジェクトの状態を基に生成されるからです.なので,オブジェクトが不変なおかげで,一度ハッシュコードを計算したらそれをキャッシュして使い回しできるのです.

String クラスには以下のように hash という int 型の private フィールドがあり,hashCode() 実行時には hash に値があればそれを返します.なければハッシュコードの計算をして,hash に入れます.

...
private int hash; // Default to 0
...
public int hashCode() {
    int h = hash;
    if (h == 0 && value.length > 0) {
        char val[] = value;

        for (int i = 0; i < value.length; i++) {
            h = 31 * h + val[i];
        }
        hash = h;
    }
    return h;
}
...

このキャッシングにより,パフォーマンスの向上が期待できます.例えば,String は HashMap のキーなどでよく利用されますが,そのようなハッシュコードを頻繁に使うケースでより効率的に処理できるようになります.Hash コレクション系では値を追加する時や値を取得する時にハッシュコードが使われるので,それを高速化できる感じですね.

理由③ セキュリティのため

String は引数として使われることが非常に多いです.それも,データベース接続用のユーザ名であったり,ファイルを開くためのパスであったりと,外部リソースを扱う時によく使われたりします.このような状況下で String が可変であると,セキュリティ的によろしくありません.

例えば,引数として渡された文字列に対して何かしらのセキュリティ処理をした後に,文字列を変更されてしまうケースが想定できます.

public void secureOpen(String path){
    if(!isSecure(path)){ // 権限等のチェック
        throw new SecurityException();
    }

    // Stringが可変だと,ここで他の参照を使ってpathの値を書き換えることが可能
    open(path);   
}

isSecure() でのセキュリティチェックを通過した後で,他の参照から文字列が書き換えられてしまうと,予期せぬ問題が発生する可能性があります.他の参照というのは,path が指しているのと同じオブジェクトへの参照を持つ他の変数のことです.String を不変にしておくことで,呼び出し先に渡した文字列が途中で外部から書き換えられることを防げます.このように,String が不変な理由としてはセキュリティ的な観点もあります.

理由④ スレッドセーフティ

String が不変であるおかげで,マルチスレッドプログラミングで文字列を扱う際の面倒事を減らせます.String が可変である場合には,スレッド間で文字列を共有する際の同期制御について考える必要があります.

例えば String が可変であると,理由③に書いたコードのように文字列を参照するフェーズと使用するフェーズに分かれている場合,参照時と使用時で,スレッド間で共有している文字列の値が変わっている可能性があります.あるスレッドで isSecure() を実行した際にこのチェックをパスできる値になっていたとしても,次の open(path) が呼び出されるまでの間に他のスレッドが path の値を書き換えてしまうかもしれません.これを防ぐためにはロックなどの仕組みを使わなければならず,実装がやや複雑化します.

String が不変だとスレッド間での共有が簡単で,考え事やコードが少なくなります.つまり,正しく作成された不変オブジェクトは暗黙にスレッドセーフなので,文字列みたいな頻繁に使われるクラスは不変化したほうが捗るよね,ということでしょうか.String が可変だと,常に外部から変更される危険を孕むので,例えスレッド間で文字列を意図的に共有しない場合だったとしても面倒事が増えそうです.

おわり

これらは十分に String を不変とする理由になるかと思います.大きく分けると,「パフォーマンス」と「安全性」のため,になるでしょうか.最近のコンピューティング環境の発展に鑑みれば,パフォーマンスについてはあまり深刻に考える必要もないかもしれんが.といっても String は非常に使用頻度の高いオブジェクトなので,不変化による恩恵はそれなり受けられそうです.同じような理由で,Integer や Long などのクラスも不変クラスになっている模様です.
上記から分かる不変の強みは以下.

  • 不変オブジェクトには flyweight パターンが適用しやすい
  • 不変オブジェクトはキャッシュによるパフォーマンス向上がしやすい
  • 不変オブジェクトはセキュリティを向上できる
  • 不変オブジェクトはスレッドセーフ

参考

http://www.java67.com/2014/01/why-string-class-has-made-immutable-or-final-java.html