Skip to content

Latest commit

 

History

History
470 lines (354 loc) · 12.7 KB

026-exception.md

File metadata and controls

470 lines (354 loc) · 12.7 KB

傲慢なエラー処理: 例外

例外を投げる

std::arrayの実装方法はほとんど解説した。読者はstd::arrayの実装方法を知り、確固たる自信の元にstd::arrayを使えるようになった。ただし、1つだけ問題がある。

"std::array"のユーザーはあらかじめ設定した要素数を超える範囲の要素にアクセスすることができてしまう。

int main()
{
    // 妥当な要素はa[0]のみ
    std::array<int, 1> a = {1} ;

    // エラー、範囲外
    a[1000] = 0 ;
}

arrayを自力で実装できる傲慢な読者としては、ユーザーごときが間違った使い方をできるのが許せない。間違いを起こした時点でエラーを発生させ、問題を知らしめ、対処できるようにしたい。

operator []に範囲外チェックを入れるのは簡単だ。問題は、エラーをユーザーに通知する方法がない。

reference array::operator [] ( std::size_t i )
{
    // 範囲外チェック
    if ( i >= size() )
    {
        // エラー検出
        // しかし何をreturnすればいいのだろう
    }

    return storage[i] ;
}

operator []は伝統的にエラーチェックをしない要素アクセスをするものだ。

vectorで一番最初に説明した要素アクセスの方法であるメンバー関数atを覚えているだろうか。実はメンバー関数atはエラーチェックをする。試してみよう。

int main()
{
    std::array<int, 1> a = {1} ;

    std::cout << a.at(1000) = 0 ;
}

以下が実行結果だ。

terminate called after throwing an instance of 'std::out_of_range'
  what():  array::at: __n (which is 1000) >= _Nm (which is 1)

何やらよくわからないがエラーのようだ。以下のような意味であることがわかる。

`std::out_of_range`がthrowされたあとにterminateが呼ばれた
  what(): array_at: __n(値は1000) >= _Nm (値は1)

どうやらエラーメッセージのようだ。わかりづらいメッセージだが、なんとなく言わんとすることはわかる。_Nmarrayの要素数で、__nがメンバー関数atに渡した実引数だ。要素数_Nmよりも__nが大きい。

このエラー処理は、「例外」を使って行われる。

例外は通常の処理をすっ飛ばして特別なエラー処理をする機能だ。何もエラー処理をしない場合、プログラムは終了する。例外を発生させることを、「例外を投げる」という。

例外は文字どおり投げるという意味のthrowキーワードを使い、何らかの値を投げる(throw)。

// int型の値123を投げる
throw 123 ;

// double型の値3.14を投げる
throw 3.14 ;

std::array<int, 5> value = {1,2,3,4,5} ;

// std::array<int,5>型の変数valueの値を投げる
throw value ;

この例では、int型、double型、std::array<int,5>型の値を投げている。

一度例外が投げられると、通常の実行はすっ飛ばされる。

以下は0を入力すると例外を投げるプログラムだ。

int main()
{
    // 0を入力するなよ、絶対するなよ
    std::cout << "Don't type 0. >"s ;

    int input {} ;
    std::cin >> input ;

    /// 入力が0なら例外を投げる
    if ( input == 0 )
        throw 0 ;

    // 通常の処理
    std::cout << "Success!\n"s ;
}

このプログラムを実行すると、非0を入力した場合、"Success!\n"が出力される。0を入力した場合、例外が投げられる。例外が投げられると、通常の実行はすっ飛ばされる。エラー処理はしていないので、プログラムは終了する。

std::arraystd::vectorのメンバー関数at(n)nが要素数を超える場合、例外を投げている。

array::reference array::at( std::size_t n )
{
    if ( n >= size() )
        throw 何らかの値

    return storage[n] ;
}

投げる例外は、std::out_of_rangeというクラスの値だ。このクラスを完全に説明するのは現時点では難しいが、以下のように振る舞うと考えておこう。

namespace std {

struct out_of_range
{
    // エラー内容の文字列を受け取るコンストラクター
    out_of_range( std::string const & ) ;
    // エラー内容の文字列を返すメンバー関数
    auto what() ;
} ;

}

とりあえず使ってみよう。

int main()
{
    std::out_of_range err("I am error.") ;

    // I am error.
    std::cout << err.what() ;
}

コンストラクターでエラー内容を表現した文字列を受け取り、メンバー関数whatでエラー内容の文字列を取得する。

必要な情報はすべて学んだ。あとはメンバー関数atを実装するだけだ。

array::reference array::at( std::size_t n )
{
    if ( n >= size() )
        throw std::out_of_range("Error: Out of Range") ;

    return storage[n] ;
}

例外を捕まえる

現状では、エラーを発見して例外を投げたら即座にプログラムが終了してしまう。投げた例外を途中で捕まえて、プログラムを通常の実行に戻す機能がほしい。その機能が「例外のキャッチ」だ。

例外のキャッチにはtryキーワードとcatchキーワードを使う。

try {
    // 例外を投げるコード
} catch( 型 名前 )
{
    エラー処理
}

try {}ブロックの中で投げられた例外は、catchで型が一致する場合にキャッチされる。例外がキャッチされた場合、catchのブロックが実行される。そして実行が再開される。

int main()
{

    try {
        throw 123 ; // int型
    }
    // キャッチする
    catch( int e )
    {
        std::cout << e ;
    }

    // 実行される
    std::cout << "resumed.\n"s ;
}

catchの型と投げられた例外の型が一致しない場合は、キャッチしない。

int main()
{
    try {
        throw 3.14 ; // double型
    }
    // キャッチしない
    catch( int e ) { }

    // 実行されない
    std::cout << "You won't read this.\n"s ;
}

catchは複数書くことができる。

int main()
{
    try {
        throw "error"s ; // std::string型
    }
    // キャッチしない
    catch( int e ) { }
    // キャッチしない
    catch( double e ) { }
    // キャッチする
    catch( std::string & e )
    {
        std::cout << e ;
    }
}

tryブロックの中で投げられた例外は、たとえ複雑な関数呼び出しの奥底にある例外でもあますところなくキャッチされる。

void f()
{
    throw 123 ;
}

void g() { f() ; } 
void h() { g() ; }


int main()
{
    try {
        h() ;
    }
    // キャッチされる
    catch( int e ) { }
}

関数hは関数gを呼び出し、関数gは関数fを呼び出し、関数fは例外を投げる。このように複雑な関数呼び出しの結果として投げられる例外もキャッチできる。

すでに学んだように、std::array<T>::atに範囲外のインデックスを渡したときはstd::out_of_rangeクラスが例外として投げられる。これをキャッチしてみよう。

int main()
{
    std::array<int, 1> a = {0} ;

    try { a[1000] ; }
    catch( std::out_of_range & e )
    {
        // エラー内容を示す文字列
        std::cout << e.what() ;
    }
}

例外による巻き戻し

例外が投げられた場合、その例外が投げられた場所を囲むtryブロックと対応するcatchに到達するまで、関数呼び出しが巻き戻される。これをスタックアンワインディング(stack unwinding)という。

void f() { throw 0 ; } 
void g() { f() ; }
void h() { g() ; }

int main()
{
    try { h() ; }
    catch( int e ) { }

}

この例では、関数mainが関数hを呼び出し、その結果として最終的に関数fの中で例外が投げられる。投げられた例外は関数呼び出しを巻き戻して関数mainの中のtryブロックまで到達し、対応するcatchに捕まる。

もし関数mainを抜けてもなお対応するcatchがない場合はどうなるのか。

int main()
{
    throw 0 ;
    // 対応するcatchがない
}

その場合、std::terminate()という関数が呼ばれる。この関数が呼ばれた場合、プログラムは終了する。

int main()
{
    // プログラムは終了する
    std::terminate() ;
}

tryブロックはネストできる。その場合、対応するcatchが見つかるまで巻き戻しが起こる。

void f()
{
    try { throw 0 ; }
    catch ( double e ) { }
}

int main()
{
    try { // try 1
        try { // try 2
            f() ;
        } catch( std::string & e ) { }
    } catch ( int e )
    {
        // ここで捕まる
    }
}

上のコードは複雑なtryブロックのネストが行われている。プログラムがどのように実行されるのかを考えてみよう。

まず関数mainが関数fを呼び出す。関数fは例外を投げる。関数fの中のtryブロックは対応するcatchがないので関数mainに巻き戻る。関数mainの内側のtryブロック、ソースコードでは// try 2 とコメントをしているtryブロックのcatchには対応しない。さらに上のtryブロックに巻き戻る。// try 1tryブロックcatchint型なので、このcatchに捕まる。

例外が投げられ、スタックアンワインディングによる巻き戻しが発生した場合、通常のプログラムの実行は行われない。例えば以下のプログラムは何も出力しない。

void f()
{
    throw 0 ;
    // 例外を投げたあとの実行
    std::cout << "function f\n"s ;
}

void g()
{
    f() ;
    // 関数fを呼んだあとの実行
    std::cout << "function g\n"s ;
}

int main()
{
    g() ;
    // 関数gを呼んだあとの実行
    std::cout << "function main\n"s ;
}

スタックアンワインディング中に通常の実行は行われないが、変数の破棄は行われる。これはとても重要だ。変数が破棄されるとき、デストラクターが実行されるのを覚えているだろうか。

struct Object
{
    std::string name ;
    // コンストラクター
    Object( std::string const & name ) : name(name) 
    { std::cout << name << " is constructed.\n"s ; }

    // デストラクター
    ~Object()
    { std::cout << name << " is destructed.\n"s ; }
} ;

int main()
{
    // 変数objが構築される
    Object obj("obj"s) ;

    // 変数objが破棄される
}

実行結果

obj is constructed.
obj is destructed.

例外のスタックアンワインディングでは関数内の変数が破棄される。つまりデストラクターが実行される。

void f()
{
    Object obj("f"s) ;
    throw 0 ;
}

void g()
{
    Object obj("g"s) ;
    f() ;
}

int main()
{
    Object obj("main"s) ;

    try {
        g() ;
    } catch( int e )
    {
        std::cout << "caught.\n"s ;
    }

}

このプログラムを実行した結果は以下のようになる。

main is constructed.
g is constructed.
f is constructed.
f is destructed.
g is destructed.
caught.
main is destructed.

なぜこの順番に出力されるか考えてみよう。

  1. プログラムの実行は関数mainから始まる。そのためまずmainが構築される
  2. 関数mainは関数gを呼ぶ。gが構築される
  3. 関数gは関数fを呼ぶ。fが構築される
  4. 関数fは例外を投げるので、fは破棄される
  5. 関数gに巻き戻ったがcatchがないのでさらに巻き戻る。gが破棄される
  6. 関数mainに巻き戻ったところ対応するcatchがあるのでスタックアンワインディングは停止する
  7. caught.が出力される
  8. mainが破棄される

例外が投げられると通常の実行は飛ばされるので、例外が投げられるかもしれない処理のあとに、例外の有無にかかわらず絶対に実行したい処理がある場合は、クラスのデストラクターに書くとよい。

C++20以降では、標準ライブラリにstd::scope_exitが追加される予定だ。std::scope_exitは渡した関数オブジェクトをデストラクターで実行してくれる。

int f()
{
    auto ptr = new ;
    std::scope_exit e( [&]{ delete ptr ; } ) ;

    // 処理
}

このように書くと、後続の処理でreturnで関数から戻ろうが、throwしようが、delete ptrが実行される。