かとじゅんの技術日誌

技術の話をするところ

チェック例外がJavaにあってC#にない理由

例外の再考 - じゅんいち☆かとうの技術日誌

に引き続き、チェック例外がJavaにあってC#*1になぜがないか考察してみようと思います。

また、いろいろググっているとよい記事がありました。
なぜ C# の言語仕様に検査例外がないのか?という記事。
"Why doesn't C# have exception specifications?"
http://msdn.microsoft.com/en-us/vcsharp/aa336812.aspx

基本原文読んでくださいね。私の英語力は当てにならないのでw

ここで言及しているのは以下の項目。順におっかけてみよう。

  1. Versioning
  2. Productivity and code quality
  3. Impracticality of having class author differentiate between "checked" and "unchecked" exceptions
  4. Difficulty of determining the correct exceptions for interfaces.

Versioning

先のエントリであげたように、throwsがメソッドのシグニチャに公開され、それがクライアントコードとの契約になります。仕様変更によって例外が追加された場合はthrowsは変更されることがあり、契約が変更されクライアントコードとの契約が破壊されます。

たとえば、以下のようなコードだろうか

public class Hoge{
    public void fuga() throws AException,BException,CException{
        // snip
    }
}

public class Main{
    public static void main(String[] args){
        Hoge hoge = new Hoge();
        try{
            hoge.fuga();
        }catch(AException a){
            // snip
        }catch(BException b){
            // snip
        }catch(CException c){
            // snip
        }
    }
}

こうなった場合

public class Hoge{
    // 要求仕様が変化しDをスローしなければならなくなった
    public void fuga() throws AException,BException,CException,DException{
        // snip
    }
}

ということで、クライアントのコードは維持できるのか?
当然、Dの例外をキャッチしていないのでコンパイルエラーになります。正しくは以下のようにDをキャッチしないといけません。

public class Main{
    public static void main(String[] args){
        Hoge hoge = new Hoge();
        try{
            hoge.fuga();
        }catch(AException a){
            // snip
        }catch(BException b){
            // snip
        }catch(CException c){
            // snip
        }catch(DException d){
            // snip
        }
    }
}

ここではこの例外条件がクラスライブラリの進化を制限してしまい、無駄に時間がかかる。メソッドのシグニチャ上のthrowsによって、そのクラスの互換性が壊れるのが必至ということを言ってますな。この項目が1番目にくるというのは影響度も大きいからでしょうね。なるほど。

Productivity and code quality

生産性とコード品質の話。小規模な開発においては拡張性に対する生産性やコード品質のためにチェック例外の仕様が必要とされてきたが、大規模プロジェクトでは異なる結果になるよと。それは生産性の低下と、低いもしくはコード品質があがらない問題。

コード上で例外仕様を増やすことは開発者の生産性を低下させることになる。

  • 例外がスローされてからチャッチされる間のメソッド(ここではメンバー)の数の問題。

近代的な例外管理では、例外を引き起こすコードとそれを管理するコード(つまりthrowするほうとcatchするほうということか)の仕事を分離できるらしい。
A Method => B Method => C Method => D Method と呼び出し階層で、Dで例外がthrowしAで管理する場合。これをチェック例外で行うとA,B,CはDでthrowされた例外の管理を行う必要がある。おそらく、BとCのメソッドのシグニチャにthrowsは最低限書くことになるでしょう。C#ではこのような場合でも例外の管理はAとDだけにできるよということみたいですね。

public class AClass{
    public void aMethod(){
        BClass b = new BClass();
        try{
            b.bMethod();
        }catch(HogeException ex){
            ex.printStacktrace();
        }
    }
}
public class BClass{
    public void bMethod() throws HogeException {
        CClass c = new CClass();
        c.cMethod();
    }
}
public class CClass{
    public void cMethod() throws HogeException {
        DClass d = new DClass();
        d.dMethod();
    }
}
public class DClass{
    public void dMethod() throws HogeException {
        if ( isFail() ){
            throw HogeException();
        }
    }
}

もし、DClass#dMethodでスローする例外にFugaExceptionが追加されたら変更箇所はどうなるだろうか。AとDのクラスは当事者同士なのでよいとして、その経路上にいるBとCにまで変更が影響されてしまうのはつらいねー。

public class AClass{
    public void aMethod(){
        BClass b = new BClass();
        try{
            b.bMethod();
        }catch(HogeException ex){
            ex.printStacktrace();
        }catch(FugaException ex){
            ex.printStacktrace();
        }
    }
}
public class BClass{
    // 自分の責務が変化していないのに変更の影響を受けた!
    public void bMethod() throws HogeException,FugaException {
        CClass c = new CClass();
        c.cMethod();
    }
}
public class CClass{
    // 自分の責務が変化していないのに変更の影響を受けた!
    public void cMethod() throws HogeException,FugaException {
        DClass d = new DClass();
        d.dMethod();
    }
}
public class DClass{
    public void dMethod() throws HogeException,FugaException {
        if ( isFailA() ){
            throw HogeException();
        }else if ( isFailB() ){
            throw FugaException();
        }
    }
}

コンパイルエラーで明確にわかるからいいよねという問題ではなく、変更箇所が多くなるのが面倒ですよね。IDEで支援されても確認が面倒。

  • 例外の数

例外の数はそもそも多い。至る所で例外発生しうるといいたいのだろう。
これらの例外仕様の増殖は開発者のレスポンスの低下と関係している。ひとつの新しい例外状況が、百もの例外仕様でプログラムの至るところを更新することもありうる。不十分なプログラマは決して安くなく、極めてコード品質を低下させることができるオプションを持っている。

  1. 全くフィットしない目的の既存例外を再利用すること。
  2. 例外をキャッチし無視すること。握りつぶしw これは危険。
  3. プラクティスの問題として、各メンバー(例外の通り道のメソッド)に汎用的な例外仕様を追加することで、完全なフィーチャーを覆す。
  4. なにがあってもコンパイラの要求によって例外仕様をうっかりと追加する。(よくも悪くもこのプロセスの自動化は必然)

これらのオプションは低いがゼロでない実装コストです。いずれも生産性を低下させそう。これはコストベネフィット像がかわいくないw よりよい戦略はクライアントのコードのためです。この後の下りの、汎用例外管理と特定例外管理がどういう意味をさすのかよく理解できなかったorz
まぁ、例外も多く、例外をやり取りするメソッドの呼び出し階層が深くなると、上記のようなリスクがあって生産性とコード品質は危うくなってくるよねという話。これは想像できる気がする。

Impracticality of having class author differentiate between "checked" and "unchecked" exceptions

これはそもそもチェック例外と非チェック例外を区別させることで、開発者にそのディシジョンを行わせるのは大きな負担になるという話。うーん、これは基準次第かなー。

Difficulty of determining the correct exceptions for interfaces.

最後に重いやつw
インターフェイスのための正しい例外を決定することの難しさ。
特定の実装がインターフェイス定義にない例外をスローするメソッド呼ぶなら、問題になる。
それにはいくつかのオプションが考えれる。

以下のようなインターフェイスと実装を想像したw

public interface Hoge {
    void doFuga() throws AException;
}

public class HogeImpl implements Hoge{

    private void doSomething() throws HogeException{
        // do something
        if ( isFail() ){
            throw new HogeException();
        }
    }
	
    @Override
    public void doFuga() throws AException {
        try {
            doSomething();
        } catch (HogeException e) {
	    // オプションによって変わる実装
        }
		
    }

}
  • 実装から非チェック例外をスローする。
    public void doFuga() throws AException {
        try {
            doSomething();
        } catch (HogeException e) {
            throw new HogeRuntimeException();
        }
		
    }
  • その例外をキャッチし、インターフェイス定義の例外でスローする。
    public void doFuga() throws AException {
        try {
            doSomething();
        } catch (HogeException e) {
            throw new AException();
        }	
    }
  • インターフェイス定義の例外のひとつにその例外をラップしてスロー。
    public void doFuga() throws AException {
        try {
            doSomething();
        } catch (HogeException ex) {
            throw new AException(ex);
        }	
    }
  • その例外を飲み込む。
    public void doFuga() throws AException {
        try {
            doSomething();
        } catch (HogeException ex) {
            ; // 握りつぶす
        }	
    }

問題のないワークアラウンドはひとつもない。非チェック例外やインターフェイス定義のひとつをスローすることは適切でないかもしれない。例外のラッピングや派生例外の作成はうまくいくかもしれないが、ユーザから本当に発生したなにかについての情報を隠す。例外を飲み込むのバッドアイデアw*2

もう少しわかりやすい事例として、DDLを暗号化して書き込むクラスで考えてみた。

// DDLを書き込むインターフェイス
public interface DdlWriter {
    // コンテキストを渡すと書き込む。失敗するとWriteExceptionが飛ぶ
    void writeOut(WriteContext context) throws WriteException;
}

// 暗号化に対応したDdlWriterの実装
public class EncryptDdlWriter  implements DdlWriter {

    private  Cipher chipher;

    // コンストラクタではkeyを与えて初期化します
    public EncryptDdlWriter(Key key) throws EncryptInitializeException{
        try {
            chipher = Cipher.getInstance("DES/CBC/PKCS5Padding");
            chipher.init(Cipher.ENCRYPT_MODE, key);
        } catch (NoSuchAlgorithmException e) {
            throw new EncryptInitializeException(e);
        } catch (NoSuchPaddingException e) {
            throw new EncryptInitializeException(e);
        } catch (InvalidKeyException e) {
            throw new EncryptInitializeException(e);
        }
    }

    // 与えた文字列を暗号化するメソッド。インターフェイスの定義にはない例外をスローします。	
    private byte[] encrypt(String src) throws EncryptException{
        try {
            byte[] encrypted = chipher.doFinal(src.getBytes());
            return encrypted;			
        } catch (IllegalBlockSizeException e) {
            throw new EncryptException(e);
        } catch (BadPaddingException e) {
            throw new EncryptException(e);
        }
    }


    // インターフェイスのメソッドに対応する実装
    @Override
    public void writeOut(WriteContext context) throws WriteException {
        FileOutputStream fos = null;
        try{
            fos = new FileOutputStream(context.getFile());
            fos.write(encrypt(context.getDdl()));
        } catch(EncryptException e){ // encryptメソッドはEncryptExceptionをスローしてしまう
            throw new WriteException(e); // 上記の例でいうインターフェイス定義の例外のひとつにその例外をラップしてスロー	
        } catch (FileNotFoundException e) { // encrypt以外にもラップするケースは至るところにある
            throw new WriteException(e);
        } catch (IOException e) { // encrypt以外にもラップするケースは至るところにある
            throw new WriteException(e);
        } finally{
            if ( fos != null){
                try {
                    fos.close();
                } catch (IOException e) {
                    throw new WriteException(e);
                }
            }
        }
    }

}

DdlWriterインターフェイスとEncryptDdlWriterクラスでは当然抽象度が違いますので、DdlWriterインターフェイスのthrowsに定義されている例外も抽象度が高く、具象クラスであるEncryptDdlWriterクラスで発生した具体的な例外はそのままスローできません。上記のワークアラウンドを取ることになると思いますが、(回復可能とするならば)ラップすることが一番妥当ではないかと思います。encryptメソッドがスローするEncrpytExceptionは一旦キャッチされてWriteExceptionにラップされてスローすることになるかと思います。こういう状況は意図しないほかのAPIでも起こりうる可能性があります。上記でもそれは理解できると思います。

インターフェイスにthrowsの仕様を追加するのは勇気のいる作業としかいいようがないなw厳格すぎる。
一度公開したinterfaceは実装コードの依存性を持って一人歩きしますので、最初にthrowsの設計ミスするとイタイことになるかもです。

というわけで、これもチェック例外に対する一つの見方、考え方ですが、

  • Impracticality of having class author differentiate between "checked" and "unchecked" exceptions

については判断基準があればなんとかなりそうですが、、、(そもそもチェック例外や非チェック例外の区別があることで、その取り扱いに関する議論や教育などで開発が非効率になると言語学者は考えたのかもしれませんが、ベストプラクティスならほかの言語に広がるはずなので)

  • Versioning
  • Productivity and code quality
  • Difficulty of determining the correct exceptions for interfaces.

の指摘はその通りではないかと思うんですよ。このリスクを回避するためにC#ではチェック例外がないわけですね。

個人的な考えのまとめ

非チェック例外は上記のリスクを回避できるというのはよいことだと思います。RTEのほうが変更に強く、生産性やコード品質の維持がしやすいのではないかと思います。フルRTE法でやっているS2ChronosS2Configの開発では上記のようなリスクに一度も遭遇したことがないんですよね。だから悪い選択ではないと思います。
とはいうものの、技術には100%というものがないので、パラドックスな部分はいなめませんw 上記のリスクを回避できても、やっぱりプログラマが自力でcatchするんですよねーというのは、つきまとうw。それは何を選ぶかの設計思想ですよね。
まぁ、それでも完全にチェック例外を使うなということではなく、リスクを把握した上で使うべき。RTEの場合はJUnitでしか信頼性は担保できません。実装コード上で担保したい場合、たとえば金銭とか命に関わる要件とかwではチェック例外を使うとか。要するに上記のようなリスクよりとにかく信頼性を重視する要件ですね。チェック例外をうまく使えば信頼性を担保できるということだと思います。これはほかの言語にない魅力だw ただし、使い方が重要。*3
まぁでも、それもなるべく局所的にする。*4メソッドの呼び出し階層が深くなるところやpublicに公開するインターフェイスではなるべく使わないようにすればいいのではないかと。

あわせて読みたい
http://d.hatena.ne.jp/asakichy/20091215/1260838490
http://d.hatena.ne.jp/asakichy/20091216/1260932997
http://d.hatena.ne.jp/kmaebashi/20100101/p1
http://pub.ne.jp/tb.php/1129006
http://d.hatena.ne.jp/SiroKuro/20090809/1249838522
http://fieldnotes.sytes.net/wiki/index.jsp?pid=Exception

蛇足

フルRTE法ではどんなRTEが飛んでくるかわからないので、リソースを確保しているところでは必ずtry{}finally{}が前提となるのですが,.NETではusingを使うだけでリソースの解放を自動で行ってくれます。

FileInputStream fis = null;
try{
    fis = new FileInputStream("hoge.txt");
    // 処理する
}finally{
    if ( fis != null ){
        try{
            fis.close();
        }catch(IOException ex){
            ex.printStacktrace();
        }
    }
}

C#だとusingで簡単にかけます。

using (FileStream fs = new FileStream("hoge.txt", FileMode.Read)) {
    // 処理する
}

上記はこれと等価です。

FileStream fs = new FileStream("test.txt", FileMode.Read);
try {
    // 処理する
}finally {
    if (fs != null) {
        fs.Dispose();
    }
}

こういうところは、後発だけでによく考えられていますね。
Javaでは結構メンドクサイけど、こんな感じならw

public class FileInputStreamUtil{
    public static interface Block{
        public void proccess(FileInputStream fis);
    }
    public static void open(String file, Block block){
        FileInputStream fis = null;
        try{
            fis = new FileInputStream(file);
            block.proccess(fis);
        }finally{
            try{
                fis.close();
            }catch(IOException ex){
                ex.printStacktrace();
            }
        }
    }
}

Rubyのブロックのパクリですw

FileInputStreamUtil.open("hoge.txt", new Block(){
    public void proccess(FileInputStream fis){
        // 処理する
    }
}

C#Javaから学んだように、Javaもほかの言語から学びつつもJavaであることが誇りに思える言語に成長することを祈りつつ合掌!クロージャ++

*1:C#というか.NET言語全般にいえると思いますw

*2:これは静的解析ツールで検知できると思います

*3:Effective Java 初版 39項, 第二版 58項に参考。

*4:Effective Java 初版 41項, 第二版 59項に記載があります。catchの強制で信頼性が向上するとはいっても、使いすぎるとAPIを使いにくいものにすると言及があります。

例外の再考

愛する部下の一人がよいエントリを書いてくれたので、私も重い腰をあげてみたw
Throwableについて本気出して考えてみた 2nd Season - 都元ダイスケ IT-PRESS
短時間で、ようまとめたなーw

チェック例外は、Java以外の言語では聞いたことがないのですが。Javaが10年やってきて、これがベストプラクティスなら他の言語にも存在してもよいはず。なぜ使わないんだろう。。。ということで、例外を再考してみました。

例外をめぐる議論

何気にいろいろ調べていたら、こういうのがでてきました。2004年以降はRTE使っているよというのがこれか!?
Javaの理論と実践: 例外をめぐる議論
これはもう過去にされていた議論ですね。知らなかったですw
Sunは、文書化されない例外は投げるべきでない。つまるところ、扱う例外はメソッドのthrowsにちゃんと明記する派。APIを使う側のユーザとしてはどのような例外が飛んでくるか想定するべきということですね。

しかし、Bruce EckelやRod Johnsonなどは、チェック例外には重大な問題があるとしています。特にBruce Eckelはこの実験は失敗だったと言及している。

これを読んで個人的にチェック例外が批判される大きい要因と思ったのは、

  • メソッドのシグニチャが不安定な場合には使えない。
  • チェック例外は例外のラッピングが多すぎる。

番外編で

  • RTEが飲み込まれるというのは、ウケたw

C++/C#の例外に対するスタンス

DelphiC#の生みの親Anders Hejlsbergのインタビューから。
The Trouble with Checked Exceptions
彼はまず、Versioning、互換性の問題を言及しています。メソッドfooがA,B,Cの例外をスローしていても、ほかの要件の関係でDの例外をスローしてしまうとクライアントのコードを破壊してしまうと。
もうひとつのScalabilityがよく理解できなかったのですが、下位から上位で例外の再スローで例外のラッピング。このような複雑な例外階層を意識した構造だと拡張性が乏しくなるってことかな。これはシステムの規模に関係するかも、小規模だとあまり問題にならない。

まとめると?

  • チェック例外は、メソッドの互換性と拡張性に問題が生じる可能性が高い。(ただし、小規模システムでは問題にならない)
  • 非チェック例外は、スローされる例外の仕様を文書化できない。つまりどんな例外が飛んでくるか不明になる。また、コードを解析してJavadocに記述する場合も、システムの規模に応じて非チェック例外の数も増える。文書化は絶対したほうがいいが現実的ではない。(irenkaでRTEの@throws自動生成とか考えたがとてつもない行数となる可能性が、かえって可読性が落ちるw)

結論はどの立場をとるのか、その”開発時の設計思想”によるところになるのではないかと思いますが、上記から学べることとしては、さまざま複雑な要件を対応しなければならないシーンではメソッドのシグニチャの互換性をなるべく維持できるほうがいい、システムが大きくなりクラスの責務を分割しメソッドの呼び出し階層が増えてきた場合はその抽象度に応じた例外クラスが必要となり、設計に与えるコストが大きくなる可能性が高いというのは言えるのではないかと思います。

業務アプリケーションで考えると上記の視点は無視できないんですよねー。業種、業態、顧客、仕向、バージョンごとにさまざまな要件がありますし、規模だってどうしたって大規模になりがち。

フルRTE法ならどうすればいいか?

仮にフルRTE法を採用するとして、どういう対策があるか、

  • スローされる例外の文書化の方針はid:daisuke-mのプランがよいと思います。

すべてを書ききるのは現実的でないから。で、スローされる例外はメソッドの階層を追えばわかるようにしていればいい。IDEが助けになってくれるので、RubyPythonの例外より管理しやすい状況ではないかとw*1
さらに、

  • RuntimeExceptionをそのままスローしない。(Exceptionもスローしない)

RuntimeExceptionから具体的な例外クラスを実装しスローする。なるべくどこで何がおこったかの情報を保持させて解析しやすいようにする。例外と判断した理由や背景までわからないといけない。

  • どのような処理でもtry{}finally{}で記述する。

developerworksの記事にもさらっと書いてあるのですが、フルRTE法でリソースを解放する必要がある場合は、try{}finally{}はどんなところでも使わないいけませんと。そのメソッドを呼ぶとどんなRTEが飛ぶかわかりませんからw Seasarでもそうです。T2でもそうだと思います。

  • mainメソッドなどの最上位階層では必ずtry{}catch(Exception ex){}finally{}する

ウェブアプリではFilterでキャッチすることが多いですが、それ以外のアプリでは最上位のmainメソッド内でRTEをキャッチして、"処理できないエラーが発生しました"などとログなり、UIなりにエラー用の表示する処理が必要。たとえば、XMLが壊れているとか、DBのパスワード間違いは、”使い分け法”では強制的にcatchだけど、”フルRTE法”の場合は、プログラマが意識してcatchしてエラーメッセージを表示してリカバリするなどしないと、エラー用の表示に遷移されてしまう。

例外のハンドリングがクライアント任せになるので、そのクライアントのテストケースが肝になる。ないとカオスw

これらはウェブアプリでもそれ以外でも同じ考え方になるのではないかと。Seasarってウェブだけでなくバッチ処理でも使っているので、気をつけたいところになるかな。

あわせて読みたい
http://d.hatena.ne.jp/asakichy/20091215/1260838490
http://d.hatena.ne.jp/asakichy/20091216/1260932997
http://d.hatena.ne.jp/kmaebashi/20100101/p1
http://pub.ne.jp/tb.php/1129006
http://d.hatena.ne.jp/SiroKuro/20090809/1249838522
http://fieldnotes.sytes.net/wiki/index.jsp?pid=Exception


追記:
>下位から上位で例外の再スローで例外のラッピング
これちょっとわかりにくい表現と思ったので追記。
Effictive Java(初版)なら43項で、第二版なら61項.

その抽象化に適切な例外を投げる。言い換えるとメソッドが投げる例外は、メソッドが何を行うのかと一貫した形で、抽象化レベルで定義すべきであり、必ずしもそのメソッドがどう実装されるかという低レベルの詳細で定義する必要はありません。例えばファイルやデータベース、あるいはJNDIからリソースをロードするメソッドは、リソースが見つからない場合には、何らかのResourceNotFound例外を投げるべき(一般的には下にある原因を保存するために例外チェーンを使います)であって、低レベルのIOExceptionやSQLException、NamingExceptionを投げるべきではありません。

というのがあります。
たとえば、ユーザ情報をストレージからユーザ名で取得するようなメソッドの場合。

public UserInfo getUserInfo(String userName) throws SQLException,IOException,NamingException{
    return dao.selectByUserName(userName);
}

この実装だとDaoを使っているのでDBから取得しますが、そのDBにつながらない場合はSQLExceptionが発生することが考えられます。その場合でもgetUserInfoはSQLExceptonではなくResourceNotFoundExceptionをスローすべきです。

public class UserService{
    public UserInfo getUserInfo(String userName) throws ResourceNotFoundException{
        try{
            return dao.selectByUserName(userName);
        }catch(SQLException ex){
            throw new ResourceNotFoundException(ex);
        }catch(IOException ex){
            throw new ResourceNotFoundException(ex);
        }catch(NamingException ex){
            throw new ResourceNotFoundException(ex);
        }
    }
}

なぜなら、メソッドの内部の仕様を公開してしまっているのでカプセル化が弱いというのと、getUserInfoがDBの場合はSQLExceptionでもよいですが、/etc/passwdなどのファイルベースになった場合はどうでしょうか?SQLExceptionはスローされないのにメソッドのシグニチャのthrowsは?これでは例外クラスの抽象度があっていないということになります。
抽象度を合わせるために例外をラップしてスローします。例外連鎖ともいいます。
勘のいい人はわかると思いますが、抽象度のレイヤー数に応じて例外連鎖が増えていき、チェック例外クラスも増えます。

追記:
ひがさんのコメントみっつけたw

検査例外と実行時例外のどちらを使うべきか、
いろいろな意見があると思いますが、
私は、catchしたければそうできるし、
catchしたくなければ何もしなくていい、
実行時例外のほうが、結局良いのではという
考えに変わり始めています。
検査例外をラップして例外を投げたりすると
それがいくつもネストしたりするんですよね。
悩んだんですが、Seasar V2では、すべて実行時例外で
行くことにしました。
ただし、実行時例外でもthrowsには記述します。

最後の実行時例外でもthrowsには記述するのは結局クライアントのコードを破壊するので自分はよくないと思っています。Seasar2のコードみたけどthrowsにRTE書かれてなかったと思います。

*1:Effective Javaの初版 44項, 第二版 62項ではすべての例外を文書化しろとしているが、非チェック例外の場合は現実的でない場合もありうる。