かとじゅんの技術日誌

技術の話をするところ

チェック例外が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を使いにくいものにすると言及があります。