例外 | Programming Place Plus C++編【言語解説】 第32章

トップページC++編

C++編で扱っている C++ は 2003年に登場した C++03 という、とても古いバージョンのものです。C++ はその後、C++11 -> C++14 -> C++17 -> C++20 -> C++23 と更新されています。
なかでも C++11 での更新は非常に大きなものであり、これから C++ の学習を始めるのなら、C++11 よりも古いバージョンを対象にするべきではありません。特に事情がないなら、新しい C++ を学んでください。 当サイトでは、C++14 をベースにした新C++編を作成中です。

この章の概要

この章の概要です。


関連する話題が、以下のページにあります。

例外

従来の if文を使ったエラー判定に代わる方法として、例外という仕組みがあります。

if文を使ったエラーの処理には、以下のようにいくつかの問題があります。

例外の仕組みを使うと、こういった問題点を解消できます。

しかし一方で、別の難しさもあるため、C++ の例外は敬遠されがちでもありますが、例外という考え方自体は、多くのプログラミング言語で採用されているものなので、考え方だけでも理解しておく価値はあります。

例外を使ったコードの基本形は以下のようになります。

try {
    throw /* 送出する例外オブジェクト */;
}
catch( /* 補足するオブジェクトの型 */ ) {
}

例外は、何らかのエラーが発生したタイミングで、例外オブジェクトと呼ばれるオブジェクトを生成して、送出(スロー)します。「オブジェクト」と言っても、必ずしもクラスのインスタンス化を行う必要は無く、単なる「100」のような int型の値を使うこともできますが、通常は、クラスのオブジェクトを使います。

送出には、throwキーワードを使用し、キーワードに続けて、例外オブジェクトを記述します。

例外の送出が行われるコードの範囲は、tryブロックで取り囲む必要があります。また、tryブロックに続く形で、catchブロックを形成します。catchブロックは複数あっても良いです。catchブロックは、例外が送出されない限り、実行されることはありません

例外が送出されると、現在の実行位置(throwキーワードのところ)からジャンプして、throw を取り囲む tryブロックに続く catchブロックへと移ります。

catchキーワードの直後には型の指定が記述されており、送出された例外オブジェクトと型が一致すれば、catchブロック内のコードの実行が始まります。これは、例外が捕捉(キャッチ)されたと表現されます。catchブロックの末尾まで実行を終えたら、今回発生した例外に対する処理は完了となり、catchブロックを抜けた先から実行が再開します。

一方、catchキーワードに指定された型と、例外オブジェクトの型が一致しなければ、その catchブロックでは捕捉されず、次の catchブロックへとジャンプします。

現在の関数内の catchブロックが無くなったら、そのまま呼び出し元の関数に巻き戻っていき、引き続き、catchブロックを探し続けます。大元の関数まで戻っても、結局、捕捉されなかった場合には、std::terminate関数が呼び出されます。std::terminate関数は、動作を変更することが可能ですが、デフォルトでは、std::abort関数を使ってプログラムを異常終了させます。

ここで、実例を挙げておきましょう。

#include <iostream>

int divide(int a, int b)
{
    if (b == 0) {
        throw "ゼロ除算が発生しました。";
    }
    return a / b;
}

int main()
{
    try {
        int result = divide(10, 0);
        std::cout << result << std::endl;
    }
    catch (const char* s) {
        std::cerr << s << std::endl;
    }

    std::cout << "プログラムを終了します。" << std::endl;
}

実行結果:

ゼロ除算が発生しました。
プログラムを終了します。

ゼロ除算が行われてしまう前に、例外を送出することでエラーを通知しています。ここでは、例外オブジェクトとして、文字列リテラルを使用しているので、catchブロックのところでは、const char*型で受け取るようになっています。

例外が捕捉された場合でも、「プログラムを終了します。」が出力されていることにも注目してください。catchブロックの処理を終えたら、プログラムはそのまま続けて実行されます。

例外安全

ところで、例外が発生すると、tryブロック内の続きのコードは実行されません。そのため、次のようなプログラムだと問題があります。

#include <iostream>

int divide(int a, int b)
{
    if (b == 0) {
        throw "ゼロ除算が発生しました。";
    }
    return a / b;
}

int main()
{
    try {
        int* result = new int();
        *result = divide(10, 0);  // ここで例外が送出されると、以下の2文は実行されない
        std::cout << *result << std::endl;
        delete result;
    }
    catch (const char* s) {
        std::cerr << s << std::endl;
    }
}

つまり、new による動的なメモリ確保を行っていますが、delete の呼び出しは飛ばされてしまうということです。このように、初期化と解放をセットにしなければならない処理と、例外とを組み合わせると、解放を飛ばしてしまう問題が起こり得ます。

この問題を解消するためには、初期化と解放をクラスのコンストラクタとデストラクタで行うようにします。

#include <iostream>

int divide(int a, int b)
{
    if (b == 0) {
        throw "ゼロ除算が発生しました。";
    }
    return a / b;
}

int main()
{
    try {

        class Integer {
        public:
            Integer() : mValue(new int())
            {}

            ~Integer()
            {
                delete mValue;
            }

        public:
            void Set(int value)
            {
                *mValue = value;
            }
            int Get() const
            {
                return *mValue;
            }

        private:
            int*  mValue;
        };

        Integer result;
        result.Set(divide(10, 0));
        std::cout << result.Get() << std::endl;
    }
    catch (const char* s) {
        std::cerr << s << std::endl;
    }
}

例外の送出によって tryブロックを抜け出した場合でも、そのブロック内で作られたオブジェクトのデストラクタは呼び出されます。そのため、解放処理をデストラクタに任せるようにしておけば、例外の送出の有無に関わらず、適切に解放されます。

C++ では、このような実装方法を RAII(Resource Acquisition Is Initialization、リソースの取得は初期化)と呼んでおり、重要な考え方になっているのですが、上記の例では少々面倒過ぎます。今回のようなケースでは、RAII の考え方を用いて実装されたスマートポインタを使うと楽です。

#include <iostream>
#include <memory>

int divide(int a, int b)
{
    if (b == 0) {
        throw "ゼロ除算が発生しました。";
    }
    return a / b;
}

int main()
{
    try {
        std::auto_ptr<int> result(new int());
        *result = divide(10, 0);
        std::cout << *result << std::endl;
    }
    catch (const char* s) {
        std::cerr << s << std::endl;
    }
}

スマートポインタ(賢いポインタ)とは、通常のポインタには無い賢い機能を付加するために、ポインタをクラス化したものです。このサンプルプログラムで使っている std::auto_ptr は、標準ライブラリに含まれています。詳細は、【標準ライブラリ】第16章で解説しているので、そちらを参照してください。

単一のポインタ変数ではなく、配列が必要であれば、std::vector を使えば同様の効果を得られます(【標準ライブラリ】第5章)。

このように、例外が送出されても、メモリの解放抜け(メモリリーク)などの問題が起きないようにすることを、例外安全にするといいます。例外を使うのであれば、例外安全について、つねに気を配る必要があります。


捕捉する型

ここまでに取り上げたサンプルプログラムでは、例外オブジェクトとして文字列を使用しました。実際には、このように単なる文字列を使うよりも、クラスのオブジェクトを使った方が、どういう類いの例外なのかが分かりやすくなり、より良い方法とされています。

クラスを使った場合、catchブロックのところに基底クラスの型の参照を指定しておくことで、その派生クラスのオブジェクトが送出された場合に捕捉できます。これは例外をある程度カテゴリ分けしたいときに便利です。

例外オブジェクトに使うクラスは、自分でクラスを定義してもいいですが、標準ライブラリにはいくつかの例外クラスが用意されているので、これらを使うのが良いでしょう。標準ライブラリに用意されている例外クラスから、新たな派生クラスを定義して使っても構いません。標準ライブラリの例外クラスについては、【標準ライブラリ】第17章で取り上げているので、そちらを参照してください。

クラス型の例外オブジェクトを捕捉する場合には、スライシング(第26章)を避けるため、参照を使用すべきです。const の有無は自由ですが、例外オブジェクトを書き換えることがないのなら、付けておくと良いでしょう。

class MyClass : public std::exception {};

try {
}
catch (const std::exception& e) {
}

また、すべての例外オブジェクトを捕捉するような指定もできます。そのためには、「catch (…)」のように「…」を使用します

try {
}
catch (const std::exception& e) {  // std::exception 派生のオブジェクトを捕捉
}
catch (...) {  // 上の catch で捕捉されなかった、それ以外の例外オブジェクトを捕捉
}

… を使う場合、名前を付けることができないため、catchブロック内で例外オブジェクトをアクセスできません。そのため、できることが限られてしまいます。また、何でもとりあえず捕捉してしまうという考え方自体があまり良いものではありません。

ただ、例外がまったく捕捉されないと、そのままプログラムの異常終了となってしまうので、main関数のような大元のところで、すべて捕捉されるようにしておくという手法は悪くはありません。

再送出

ある例外が捕捉されている状態(catchブロック内にいる状態)で、単に「throw;」と記述すると、同じ例外をそのまま、もう1度送出することを意味します。これは、とりあえずエラーログだけ取っておいて、残りの処置はさらに上位の関数に任せたい場合などに活用できる機能です。

try {
}
catch (const std::exception& e) {  // std::exception 派生のオブジェクトを捕捉
    throw;  // 再送出
}

なお、例外が捕捉されていない状態で「throw;」とした場合は、std::terminate関数が呼び出されて、プログラムが異常終了します

関数tryブロック

ほとんどのコードは、ここまでに見た tryブロックで囲めますが、対応できないものもあります。それは、クラスのメンバ変数を初期化する際に送出された例外や、基底クラスのコンストラクタから送出された例外です。

class MyClass : public BaseClass {
public:
    MyClass() : BaseClass(100),
        mOther(200)
    {
        // ここに tryブロックを書いても駄目
    }

private:
    OtherClass mOther;
};

そこで、関数tryブロックを使用します。

class MyClass : public BaseClass {
public:
    MyClass()
    try
      : BaseClass(100),
        mOther(200)
    {
    }
    catch {
    }

private:
    OtherClass mOther;
};

かなり変な構文ですが、メンバイニシャライザの「:」の手前側に「try」を置きます。また、try のブロック({}) と、コンストラクタの実装コードの範囲({}) とが1つにまとまっており、2つの意味を兼ねています。catchブロックは、コンストラクタの実装コードの末尾から続く形で記述します。


コンストラクタと例外

コンストラクタから例外を送出する場合には、少々注意すべきことがあります。それは、コンストラクタの実装の末尾まで処理が正常に終了しなかった場合、今インスタンス化しようとしていたオブジェクトは「作られていない」ということです。オブジェクトが作られなかったため、破棄すべきものもないので、デストラクタも呼ばれません

コンストラクタが「途中まで」は実行されてしまっているので、明示的に解放処理が必要なものを作ってしまっているかもしれません。そして、その解放処理が、デストラクタに書かれているのだとすると、前述のとおり、デストラクタが呼ばれないことが問題になります。たとえば、次のようなコードでは問題になります。

class MyClass {
public:
    MyClass::MyClass() :
        mValue(new int())
    {
        *mValue = divide(10, 0);
    }

    MyClass::~MyClass()
    {
        delete mValue;
    }

private:
    int*  mValue;
};

この場合、new演算子を実行し終えた後、divide関数内で例外が送出されると、コンストラクタは正常に終了しなかったため、デストラクタが呼ばれることもありません。そのため、mValue に対して delete を行う機会を失ってしまいます。

問題になり得るのは、あくまでもコンストラクタの外へ例外を送出した場合であることを間違えないでください。次のコードでは問題は起こりません。

class MyClass {
public:
    MyClass::MyClass() :
        mValue(new int())
    {
        try {
            *mValue = divide(10, 0);
        }
        catch (const char*) {
            std::cerr << "mValue の初期値の設定に失敗。" << std::endl;
        }
    }

    MyClass::~MyClass()
    {
        delete mValue;
    }

private:
    int*  mValue;
};

この場合、コンストラクタ内で発生した例外を、コンストラクタ内で捕捉して処理を完了しています。これだと、コンストラクタは末尾まで実行を終えられるので、オブジェクトは作成されたことになり、いずれ、デストラクタが呼び出されます。

コンストラクタの外に例外を送出する場合には、何らかの解決策を適用しなければなりません。1つには、mValue を、動的に確保された領域を指すポインタではなく、実体で持つことです。明示的な解放処理を不要にしてしまえば良いという考え方です。

もう1つの方法として、発想は同じことがですが、スマートポインタを使うことが考えられます。

class MyClass {
public:
    MyClass::MyClass() :
        mValue(new int())
    {
        *mValue = divide(10, 0);
    }

    MyClass::~MyClass()
    {
    }

private:
    std::auto_ptr<int>  mValue;
};

この場合、std::auto_ptr が作成されたのであれば、std::auto_ptr のデストラクタは呼び出されるので、MyClass のデストラクタが呼び出されないとしても、正しく解放が行われます。(作成済みのオブジェクトに対してはデストラクタは必ず呼び出されますが、作成済みとみなされないオブジェクトに対しては、デストラクタは呼ばれません)。

デストラクタと例外

デストラクタから例外を外へ送出することは避けてください。正しく対処を行えば問題がない「コンストラクタからの例外送出」とは違って、デストラクタからの例外送出はどうやっても問題を防げません。

問題はいくつかありますが、分かりやすいのは、デストラクタの途中で抜け出してしまったら、2度とその続きを実行できないということです。当然、そこには必要な終了処理があったでしょうから、問題が起こるのは明らかです。

もう1つの問題は、例外が同時に複数発生する原因になってしまう点です。どこかで例外が送出されたことがきっかけでオブジェクトの解放が行われたとすると、その解放処理の最中(つまり、デストラクタ内)に新たな例外が送出されると、例外が複数同時発生します。

例外オブジェクトが作られてから、その例外が捕捉されるまでの間に、他の例外を送出すると、std::terminate関数が呼び出されて、プログラムは異常終了することになっています。このような事態を避けるためには、デストラクタは例外を外へ送出しないようにすることが必要です。


例外指定(例外仕様)

例外指定(例外仕様)は、ある関数が、その関数の呼び出し側に送出する例外の種類を明示する機能です。

int divide(int a, int b) throw(const char*)
{
    if (b == 0) {
        throw "ゼロ除算が発生しました。";
    }
    return a / b;
}

関数宣言の末尾に、throwキーワードに続く ( ) の中に、送出される例外の種類を列挙します。ある程度の価値を感じさせる機能のようですが、実際には問題があり、あまり使われない機能になっています

また、Visual Studio 2017 では、例外仕様を記述できますが、無視されます

C++11 で非推奨になりました。部分的な代替策として、noexcept が追加されています。

実は、例外指定で指定されていない種類の例外を、関数の呼び出し側に向かって送出しようとしても、別にコンパイルエラーになるわけではなく、プログラムの実行時にチェックされます

もし、例外指定にない例外を送出すると、std::unexpected関数が呼び出されます。std::unexpected関数の動作は、std::set_unexpected関数を使って関数を登録しておくことで変更可能ですが、デフォルトでは、std::terminate関数を呼び出すことになっています。ただし、送出された例外が、std::bad_exception(【標準ライブラリ】第17章)の場合には、そのまま再送出を行います

よって、std::unexpected関数の動作を変更していない限り、例外指定で指定されていない種類の例外の送出は、プログラムの異常終了につながります

もし、std::set_unexpected関数を使って、std::unexpected関数の動作を変更するのなら、例外指定のところで指定していた例外を再送出するように実装すれば、プログラムを続行させることが可能です

一方、例外指定で指定されていない例外を送出するように実装した場合には、例外指定に std::bad_exception が含まれていれば、これに変換されて送出されます。例外指定に std::bad_exception が含まれていなければ、結局、std::terminate関数の呼び出しに行き着くことになります。

ところが、例外指定を使っている関数はプログラム中に多数ありえるのに対して、std::set_unexpected関数を使って登録できる関数は1つだけです。そのため、場面に応じて適切な例外を再送出するように実装することなど、ほぼ間違いなく不可能です。結局、考えるだけ無駄と言わざるを得ず、例外指定自体、諦めた方が無難です。

例外指定の問題点の1つは、一度、指定する例外を決めて、その関数が広く使われるようになった後で、その関数が送出する例外を増やすことが困難になることです。後から例外指定を変更すると、関数の呼び出し側は、それに備えた catch を記述しなければならないはずですが、その対応が正しく行われなければ、プログラムの異常終了という結果になってしまいます。

また、関数はさらに他の関数を呼んでいるでしょうから、深いところにある関数が、送出する例外を増やそうとすると、呼び出し経路上にあるすべての関数の例外指定が影響を受けてしまいます。

C++11 (noexpect)

C++11

C++11 では、問題が多く非推奨となった例外指定の代わりに、noexceptキーワードが追加されました。

関数宣言の末尾に noexcept と記述すると、その関数は例外を送出しないことを明示できます。

void func() noexcept;

例外指定の場合と同様に、関数内部で例外を送出するように実装すること自体は可能ですし、コンパイルも通ります。プログラムの実行時に例外が送出されると、std::terminate関数を呼び出して、プログラムを異常終了させます。例外指定と違って、std::unexpected関数や std::bad_exception例外などの複雑なルールはありません。

また、noexcept には bool型の定数を与えることができ、その場合、true なら例外を送出しない、false なら例外を送出する可能性があるという意味になります

void func1() noexcept(true);  // 例外を送出しない
void func2() noexcept(false); // 例外を送出する可能性がある

さらに、noexcept式という使い方があり、これは noexcept に式を与えると、その式が例外を送出する可能性があるかどうかを返します。

void func1() noexcept;

bool b = noexcept(func1());  // func1() は noexcept なので、true を返す

void func2() noexcept(noexcept(func1));  // func2 の例外送出の有無は func1 に従う

デストラクタに関しては、暗黙的に noexcept が指定されています。ただし、明示的に noexcept(false) を与えた場合には、例外を送出する可能性があることになります。しかしながら、デストラクタが例外を送出することは避けるべきなので、指定を変更しない方が良いです。


練習問題

問題① std::vector で要素をアクセスする際、[]演算子を使うと範囲外アクセスのチェックは行われず、atメンバ関数を使うと、範囲外アクセス時に std::out_of_range例外を送出します。範囲外アクセス時に、呼び出し側でエラーメッセージを出力したい場合、それぞれどのようなコードになりますか?


解答ページはこちら

参考リンク


更新履歴

’2019/2/12 VisualStudio 2015 の対応終了。

’2018/4/5 VisualStudio 2013 の対応終了。

’2018/4/2 「VisualC++」という表現を「VisualStudio」に統一。

’2018/1/5 コンパイラの対応状況について、対応している場合は明記しない方針にした。

’2017/7/30 clang 3.7 (Xcode 7.3) を、Xcode 8.3.3 に置き換え。

’2017/3/25 VisualC++ 2017 に対応。

’2016/10/30 新規作成。



前の章へ (第31章 RTTI)

次の章へ (第33章 メンバ関数テンプレート)

C++編のトップページへ

Programming Place Plus のトップページへ



はてなブックマーク に保存 Pocket に保存 Facebook でシェア
X で ポストフォロー LINE で送る noteで書く
rss1.0 取得ボタン RSS 管理者情報 プライバシーポリシー
先頭へ戻る