例外処理

【C++】try-catchが動かない原因と今すぐ試せる例外処理の解決ステップ

C++でtry-catchが効かない原因は、例外がそもそもthrowされていない、catchの型不一致、コンパイル時に例外機能が無効化されている、noexcept指定やスレッド境界で握り潰される、が大半です。

例外対応のオプションを有効にし、throw経路を確認しつつcatchをconst std::exception&など広めに受けて試せば多くのケースで解消できます。

例外処理が想定通り動かないときの全体像

C++の例外処理は、trycatchthrowの3つのキーワードを使ってエラーを検出し、適切に処理する仕組みです。

しかし、実際にプログラムを書いてみると、例外が発生してもcatchブロックが動作しなかったり、例外が捕捉されずにプログラムが異常終了したりすることがあります。

こうした問題を理解し、解決するためには、例外処理の全体像を正しく把握することが重要です。

ここでは、例外処理が想定通りに動かない原因を探るために、まず「発生フェーズと捕捉フェーズの関係」と「エラー種類とランタイム挙動」の2つの観点から解説します。

発生フェーズと捕捉フェーズの関係

例外処理は大きく分けて「例外の発生(throw)」と「例外の捕捉(catch)」の2つのフェーズに分かれます。

これらのフェーズが正しく連携しないと、例外処理が機能しません。

例外の発生(throw)

例外は、プログラムの実行中に何らかの異常が起きたときにthrowキーワードを使って発生させます。

throwは例外オブジェクトを投げる動作で、これにより現在の処理の流れが中断され、例外処理のためのスタックの巻き戻し(スタックアンワインド)が始まります。

#include <iostream>
#include <stdexcept>
void func() {
    throw std::runtime_error("エラーが発生しました");
}
int main() {
    try {
        func();
    } catch (const std::runtime_error& e) {
        std::cout << "例外を捕捉しました: " << e.what() << std::endl;
    }
    return 0;
}
例外を捕捉しました: エラーが発生しました

この例では、func関数内でstd::runtime_errorの例外が発生し、main関数のtryブロック内で捕捉されます。

例外の捕捉(catch)

catchブロックは、tryブロック内で発生した例外を受け取って処理します。

catchは例外オブジェクトの型にマッチしたものだけを捕捉し、それ以外の例外は捕捉されずにさらに上位のtry-catchへ伝播します。

捕捉される例外の型が合わない場合や、tryブロックの外で例外が発生した場合は、例外は捕捉されません。

スタックアンワインドの重要性

例外が発生すると、C++ランタイムは現在の関数から呼び出し元へとスタックを巻き戻しながら、対応するcatchブロックを探します。

この過程を「スタックアンワインド」と呼びます。

もし適切なcatchが見つからなければ、プログラムはstd::terminateを呼び出して異常終了します。

このため、例外の発生場所と捕捉場所の関係が非常に重要です。

tryブロックの外で例外が発生したり、catchの型が合わなかったりすると、例外は捕捉されずにプログラムが終了してしまいます。

例外の伝播イメージ

funcA() {
    funcB() {
        throw ExceptionType();
    }
    // funcB内で例外発生 → funcAに伝播
}
main() {
    try {
        funcA();
    } catch (const ExceptionType& e) {
        // ここで例外を捕捉
    }
}

このように、例外は発生した関数から呼び出し元へと伝播し、最終的にcatchで捕捉されるまで巻き戻されます。

エラー種類とランタイム挙動

C++の例外処理が想定通りに動かない原因は、例外の種類やランタイムの挙動にも関係しています。

ここでは、例外の種類とそれに伴うランタイムの動作について説明します。

C++標準例外とユーザー定義例外

C++標準ライブラリにはstd::exceptionを基底クラスとする多くの例外クラスが用意されています。

これらはcatchで型を指定して捕捉しやすいように設計されています。

一方で、ユーザーが独自に定義した例外クラスや、プリミティブ型(例えばintconst char*)をthrowすることも可能です。

ただし、型が異なるとcatchで捕捉されないことがあるため注意が必要です。

try {
    throw 42;  // int型の例外を投げる
} catch (const std::exception& e) {
    // 捕捉されない
} catch (int e) {
    std::cout << "int型の例外を捕捉: " << e << std::endl;
}

noexcept指定と例外の強制終了

C++11以降、関数にnoexcept指定を付けることで、その関数が例外を投げないことを保証できます。

もしnoexcept関数内で例外が発生すると、例外は捕捉されずにstd::terminateが呼ばれてプログラムが強制終了します。

このため、noexcept関数内で例外を投げると、catchが動かないように見えることがあります。

例外機構の無効化

コンパイラのオプションで例外機構を無効化している場合もあります。

例えば、GCCやClangで-fno-exceptionsを指定すると、throwcatchが無効化され、例外処理が動作しません。

この場合、例外が発生してもcatchは動かず、プログラムは異常終了します。

スレッド間の例外伝播

C++11以降のスレッド機能を使う場合、スレッド内で発生した例外はスレッドの外側に自動的には伝播しません。

std::threadのエントリポイントで例外が発生すると、プログラムはstd::terminateを呼びます。

例外をスレッド外に伝えたい場合は、std::promisestd::futureを使って例外情報を明示的に受け渡す必要があります。

C言語インターフェースとの境界

extern "C"で宣言された関数はC言語の呼び出し規約に従うため、C++の例外を投げることは推奨されません。

C言語のコードから呼び出されるC++関数で例外が発生すると、例外が捕捉されずにプログラムが異常終了することがあります。

ABIやリンク時の不整合

複数のコンパイラや異なるバージョンのランタイムを混在させると、例外処理の内部データ構造(例外テーブルやRTTI)が不整合を起こし、例外が正しく捕捉されないことがあります。

特にDLLや共有ライブラリを使う場合は、例外の伝播に注意が必要です。

これらのポイントを理解しておくことで、例外処理が想定通りに動かない原因を特定しやすくなります。

try-catchが効かない典型的な原因

throwが呼び出されていない

例外処理が動作しない最も基本的な原因は、そもそもthrowが実行されていないことです。

throwが呼ばれなければ、catchは絶対に動作しません。

returnとthrowの取り違え

関数内でエラーを検出した際に、throwではなくreturnを使ってしまうケースがあります。

例えば、例外を投げるつもりが誤って値を返していると、例外処理は発動しません。

#include <iostream>
#include <stdexcept>
double divide(int a, int b) {
    if (b == 0) {
        return -1;  // 例外ではなくエラー値を返している
    }
    return static_cast<double>(a) / b;
}
int main() {
    try {
        double result = divide(10, 0);
        std::cout << "結果: " << result << std::endl;
    } catch (const std::exception& e) {
        std::cout << "例外を捕捉: " << e.what() << std::endl;
    }
    return 0;
}
結果: -1

この場合、throwがないためcatchは動作しません。

エラーを例外として扱いたいなら、throwを使う必要があります。

条件分岐の抜け道がある

throwを含む条件分岐がある場合、例外が発生する条件に達しなければthrowは呼ばれません。

条件式のロジックミスや想定外の入力により、例外がスローされないことがあります。

void func(int x) {
    if (x > 0) {
        throw std::runtime_error("xは正の数です");
    }
    // x <= 0 の場合は例外なし
}
int main() {
    try {
        func(0);  // 例外は発生しない
    } catch (const std::exception& e) {
        std::cout << "例外: " << e.what() << std::endl;
    }
    return 0;
}
※何も表示されません

何も表示されません。

条件分岐の抜け道により例外が発生しないため、catchは動作しません。

catchの型不一致

catchブロックは例外オブジェクトの型にマッチしたものだけを捕捉します。

型が合わないと例外は捕捉されず、プログラムは異常終了することがあります。

const修飾の有無

例外オブジェクトの型にconstが付いているかどうかでマッチしないことがあります。

一般的にはcatchconst参照で受けるのが安全です。

try {
    throw std::runtime_error("エラー");
} catch (std::runtime_error& e) {  // constがない
    std::cout << "捕捉: " << e.what() << std::endl;
}

この場合も捕捉はされますが、constを付けるのが推奨されます。

逆にcatch (std::runtime_error e)のように値渡しすると、コピーが発生し、例外オブジェクトの情報が失われることもあります。

ポインタと参照の違い

例外をポインタでthrowした場合、catchもポインタで受ける必要があります。

参照で受けるとマッチしません。

try {
    throw new std::runtime_error("ポインタ例外");
} catch (std::runtime_error* e) {
    std::cout << "ポインタ例外捕捉: " << e->what() << std::endl;
    delete e;
}

逆に参照で受けると捕捉されません。

継承関係の順序ミス

例外クラスの継承関係を考慮しないと、派生クラスの例外が基底クラスのcatchに捕捉されないことがあります。

特に複数のcatchがある場合、基底クラスのcatchを先に書くと派生クラスの例外が捕捉されず、意図しない動作になります。

try {
    throw std::out_of_range("範囲外");
} catch (std::exception& e) {
    std::cout << "基底クラスで捕捉: " << e.what() << std::endl;
} catch (std::out_of_range& e) {  // ここは実行されない
    std::cout << "派生クラスで捕捉: " << e.what() << std::endl;
}
基底クラスで捕捉: std::out_of_range

派生クラスのcatchは実行されません。

派生クラスのcatchを先に書く必要があります。

例外機構を無効化するコンパイルオプション

コンパイラの設定によっては例外機構が無効化され、try-catchが動作しません。

GCC・Clang -fno-exceptions

GCCやClangで-fno-exceptionsオプションを付けると、例外処理機構が無効になります。

throwcatchはコンパイルは通りますが、実行時に例外が発生しても捕捉されず、プログラムは異常終了します。

MSVC /EHs /EHsc

MSVCでは例外処理の動作を制御するオプションがあります。

/EHsはC++例外のみをサポートし、/EHscはC++例外とC言語例外の両方をサポートします。

これらの設定が不適切だと例外が捕捉されないことがあります。

組み込み向けクロスコンパイラの制限

組み込み開発用のクロスコンパイラでは、例外機構をサポートしないことが多いです。

例外を使ってもcatchは動作せず、代わりにプログラムが強制終了することがあります。

noexcept指定による強制終了

noexcept指定された関数内で例外が発生すると、例外は捕捉されずにstd::terminateが呼ばれます。

暗黙的なnoexcept推論

C++17以降、noexceptの推論が強化され、条件付きnoexceptが導入されました。

これにより、意図せずnoexcept関数と推論され、例外が捕捉されないことがあります。

ライブラリ内部でのnoexcept伝播

標準ライブラリやサードパーティライブラリの関数がnoexcept指定されている場合、その内部で例外が発生すると捕捉されずにプログラムが終了することがあります。

スレッド境界で握り潰される例外

std::threadエントリポイントの落とし穴

std::threadのスレッド関数内で例外が発生しても、スレッド外のtry-catchでは捕捉できません。

スレッド関数内で例外を捕捉しないと、std::terminateが呼ばれます。

#include <iostream>
#include <thread>
#include <stdexcept>
void threadFunc() {
    throw std::runtime_error("スレッド内例外");
}
int main() {
    try {
        std::thread t(threadFunc);
        t.join();
    } catch (const std::exception& e) {
        std::cout << "例外捕捉: " << e.what() << std::endl;
    }
    return 0;
}
terminate called after throwing an instance of 'std::runtime_error'
  what():  スレッド内例外

try-catchmainのスレッドでしか効かず、スレッド内の例外は捕捉されません。

std::asyncとstd::futureでの再スロー

std::asyncで非同期実行した関数内の例外は、std::future::get()を呼んだときに再スローされます。

get()を呼ばないと例外は捕捉されません。

#include <iostream>
#include <future>
#include <stdexcept>
int asyncFunc() {
    throw std::runtime_error("非同期例外");
    return 0;
}
int main() {
    auto fut = std::async(std::launch::async, asyncFunc);
    try {
        fut.get();  // ここで例外が再スローされる
    } catch (const std::exception& e) {
        std::cout << "例外捕捉: " << e.what() << std::endl;
    }
    return 0;
}
例外捕捉: 非同期例外

Cインターフェース経由の例外漏れ

extern “C”との非互換

C言語の呼び出し規約を使うextern "C"関数は、C++の例外を投げることができません。

extern "C"関数内で例外が発生すると、例外は捕捉されずにプログラムが異常終了します。

DLL境界とモジュール間例外

異なるDLLやモジュール間で例外を投げると、ABIの違いや例外テーブルの不整合により例外が捕捉されないことがあります。

特にWindows環境で注意が必要です。

静的リンク・動的リンク時のABI不一致

RTTIと例外テーブルの整合性

例外処理はランタイム型情報(RTTI)と例外テーブルを使って型判定やスタックアンワインドを行います。

異なるコンパイラやバージョン間でABIが異なると、これらの情報が不整合を起こし、例外が正しく捕捉されません。

原因影響内容対策例
コンパイラのバージョン違い例外テーブルの不整合同一コンパイラ・バージョンで統一
静的リンクと動的リンクの混在例外伝播がDLL境界で失敗DLL間で例外を投げない設計にする
RTTI無効化型情報がなく例外の型判定ができないRTTIを有効にする

これらの問題は特に大規模プロジェクトや複数のライブラリを組み合わせる場合に発生しやすいです。

例外処理が動かない場合は、リンク設定やコンパイラのバージョンを確認してください。

デバッグで例外経路を追跡する方法

ログ出力での確認ポイント

例外処理が正しく動作しているかを確認するために、ログ出力は非常に有効な手段です。

例外が発生した箇所や捕捉された箇所にログを入れることで、例外の発生から捕捉までの経路を追跡しやすくなります。

例外発生直前のログ

throwを実行する直前にログを出力しておくと、例外が発生したかどうかを確実に把握できます。

例えば、以下のように記述します。

#include <iostream>
#include <stdexcept>
void func(int x) {
    if (x == 0) {
        std::cerr << "[LOG] 例外をスローします: x == 0" << std::endl;
        throw std::invalid_argument("xは0にできません");
    }
}
int main() {
    try {
        func(0);
    } catch (const std::exception& e) {
        std::cerr << "[LOG] 例外を捕捉しました: " << e.what() << std::endl;
    }
    return 0;
}
[LOG] 例外をスローします: x == 0
[LOG] 例外を捕捉しました: xは0にできません

このように、例外が発生したかどうか、どの例外が捕捉されたかをログで明示的に確認できます。

例外捕捉時の詳細ログ

catchブロック内で例外の型やメッセージをログに出すことで、どの例外が捕捉されたかを把握できます。

複数のcatchがある場合は、それぞれにログを入れておくと、どのcatchが実行されたかがわかります。

スタックトレースのログ

例外発生時にスタックトレースを取得できる環境であれば、ログにスタックトレースを出力すると原因解析が容易になります。

Linux環境ではbacktrace()関数、WindowsではCaptureStackBackTrace()などを利用します。

gdb/lldbのcatchポイント活用

GDBやLLDBなどのデバッガには、例外発生時にプログラムを停止させる「catchpoint(キャッチポイント)」機能があります。

これを使うと、例外がスローされた瞬間にブレークし、スタックや変数の状態を詳しく調査できます。

GDBでのcatch throwの設定

GDBではcatch throwコマンドを使って、例外がthrowされた時点で停止させられます。

(gdb) catch throw
Catchpoint 1 (throw)
(gdb) run

例外がスローされるとプログラムが停止し、スタックトレースを表示できます。

(gdb) bt
#0  __cxa_throw (...)
#1  0x0000000000401134 in func() at main.cpp:10
#2  0x0000000000401150 in main() at main.cpp:18

これにより、例外発生箇所を特定しやすくなります。

LLDBでのcatch throwの設定

LLDBでも同様にcatch throwコマンドを使います。

(lldb) catch throw
(lldb) run

例外がスローされると停止し、thread backtraceでスタックを確認できます。

catch catchpointの活用

catch catchを使うと、例外がcatchされたタイミングで停止します。

これにより、例外がどこで捕捉されたかを調査できます。

(gdb) catch catch

ただし、すべての環境でサポートされているわけではないため、利用可能か確認してください。

Visual Studioのブレーク設定

Visual Studioのデバッガは例外処理の追跡に強力な機能を備えています。

例外が発生した瞬間にブレークしたり、特定の例外だけを捕捉したりできます。

例外設定ウィンドウの利用

Visual Studioでは「例外設定」ウィンドウDebug > Windows > Exception Settingsから、例外の種類ごとにブレークの有無を設定できます。

  • Common Language Runtime Exceptions(C#など)
  • C++ Exceptions(C++の例外)

C++例外のチェックボックスをオンにすると、例外がスローされた瞬間にブレークします。

特定の例外でブレークする

例外設定ウィンドウで特定の例外型を指定してブレークを有効にできます。

例えば、std::out_of_rangeだけでブレークしたい場合は、例外名を追加して設定します。

例外発生時のコールスタック確認

例外でブレークしたら、コールスタックウィンドウで例外発生箇所までの呼び出し履歴を確認できます。

変数の値やメモリの状態も同時に調査可能です。

例外が捕捉されない場合の対処

Visual Studioのデバッガは、未捕捉例外(catchされない例外)でもブレークできます。

これにより、例外がどこで失われているかを特定しやすくなります。

これらのデバッグ手法を組み合わせることで、例外の発生から捕捉までの経路を詳細に追跡し、問題の原因を効率的に特定できます。

正しいcatchブロック設計

広い型で受けて詳細で分岐するパターン

例外処理では、まず広い型で例外を受け取り、その後に例外の詳細な種類に応じて処理を分岐させる方法がよく使われます。

これにより、例外の種類が増えた場合でも柔軟に対応でき、コードの保守性が向上します。

#include <iostream>
#include <stdexcept>
#include <typeinfo>
void process() {
    throw std::out_of_range("範囲外エラー");
}
int main() {
    try {
        process();
    } catch (const std::exception& e) {  // 広い型で受ける
        std::cout << "例外発生: " << e.what() << std::endl;
        // 例外の詳細な型で分岐
        if (typeid(e) == typeid(std::out_of_range)) {
            std::cout << "範囲外エラーに対する処理を実行します。" << std::endl;
        } else if (typeid(e) == typeid(std::invalid_argument)) {
            std::cout << "無効な引数エラーに対する処理を実行します。" << std::endl;
        } else {
            std::cout << "その他の例外に対する処理を実行します。" << std::endl;
        }
    }
    return 0;
}
例外発生: 範囲外エラー
範囲外エラーに対する処理を実行します。

このパターンのポイントは、catchstd::exceptionのような基底クラスで受けてから、typeiddynamic_castを使って例外の詳細な型を判別し、適切な処理を行うことです。

こうすることで、例外の種類が増えてもcatchブロックを増やす必要がなく、コードがすっきりします。

ただし、typeiddynamic_castを使う場合は、例外クラスに仮想関数がありRTTIが有効であることが前提です。

再スローで上位に委ねるパターン

例外を捕捉したものの、その場で処理を完結させずに、上位の呼び出し元に例外処理を委ねたい場合があります。

このときは、catchブロック内でthrow;を使って例外を再スローします。

#include <iostream>
#include <stdexcept>
void innerFunction() {
    throw std::runtime_error("内部関数でエラー発生");
}
void middleFunction() {
    try {
        innerFunction();
    } catch (const std::exception& e) {
        std::cout << "middleFunctionで例外を一時的に捕捉: " << e.what() << std::endl;
        // ここで例外を再スローして上位に委ねる
        throw;
    }
}
int main() {
    try {
        middleFunction();
    } catch (const std::exception& e) {
        std::cout << "mainで例外を最終捕捉: " << e.what() << std::endl;
    }
    return 0;
}
middleFunctionで例外を一時的に捕捉: 内部関数でエラー発生
mainで例外を最終捕捉: 内部関数でエラー発生

このように、catch内でthrow;と書くと、現在捕捉している例外をそのまま再度投げることができます。

これにより、例外の情報を失わずに上位の例外処理に委ねられます。

再スローは、ログを残したり一時的な処理を行ったりした後に、例外を伝播させたい場合に便利です。

例外安全なリソース管理とRAII

例外が発生すると、通常の処理フローが中断されるため、リソースの解放漏れや不整合が起きやすくなります。

これを防ぐために、C++ではRAII(Resource Acquisition Is Initialization)という設計パターンが推奨されています。

RAIIは、リソースの獲得と解放をオブジェクトのライフタイムに結びつける方法です。

具体的には、コンストラクタでリソースを獲得し、デストラクタで必ず解放することで、例外が発生しても自動的にリソースが解放されます。

#include <iostream>
#include <fstream>
#include <stdexcept>
void writeFile(const std::string& filename) {
    std::ofstream ofs(filename);
    if (!ofs) {
        throw std::runtime_error("ファイルを開けませんでした");
    }
    ofs << "データを書き込みます\n";
    // ここで例外が発生しても ofs のデストラクタでファイルは閉じられる
    throw std::runtime_error("書き込み中にエラーが発生しました");
}
int main() {
    try {
        writeFile("output.txt");
    } catch (const std::exception& e) {
        std::cout << "例外捕捉: " << e.what() << std::endl;
    }
    return 0;
}
例外捕捉: 書き込み中にエラーが発生しました

この例では、std::ofstreamがRAIIを実装しているため、例外が発生してもファイルは確実に閉じられます。

もし手動でclose()を呼んでいた場合、例外発生時に呼ばれずリソースリークが起きる可能性があります。

独自リソース管理クラスの例

#include <iostream>
class FileHandle {
    FILE* fp;
public:
    FileHandle(const char* filename) : fp(fopen(filename, "w")) {
        if (!fp) throw std::runtime_error("ファイルオープン失敗");
    }
    ~FileHandle() {
        if (fp) fclose(fp);
    }
    void write(const char* data) {
        if (fputs(data, fp) == EOF) {
            throw std::runtime_error("書き込み失敗");
        }
    }
};
int main() {
    try {
        FileHandle fh("output.txt");
        fh.write("RAIIで安全に書き込み\n");
        throw std::runtime_error("途中で例外発生");
    } catch (const std::exception& e) {
        std::cout << "例外捕捉: " << e.what() << std::endl;
    }
    return 0;
}
例外捕捉: 途中で例外発生

FileHandleクラスはコンストラクタでファイルを開き、デストラクタで必ず閉じるため、例外が発生してもファイルは確実に閉じられます。

これがRAIIの基本的な考え方です。

正しいcatchブロック設計は、例外の種類に応じた柔軟な処理、例外の伝播制御、そして例外安全なリソース管理を組み合わせることで、堅牢で保守性の高いコードを実現します。

例外を使わないエラー処理の選択肢

std::optionalとエラーコード併用

C++17で導入されたstd::optionalは、値が存在するかどうかを表現できる型で、例外を使わずにエラーを表現する手段としてよく使われます。

std::optionalは成功時に値を返し、失敗時は値が「ない」状態を示します。

ただし、std::optional単体ではエラーの詳細情報を持てないため、エラーコードや別の手段と組み合わせて使うことが多いです。

#include <iostream>
#include <optional>
#include <string>
enum class ErrorCode {
    None,
    InvalidInput,
    NotFound
};
struct Result {
    std::optional<int> value;
    ErrorCode error;
};
Result parseInt(const std::string& str) {
    try {
        int val = std::stoi(str);
        return {val, ErrorCode::None};
    } catch (...) {
        return {std::nullopt, ErrorCode::InvalidInput};
    }
}
int main() {
    auto res = parseInt("123");
    if (res.value) {
        std::cout << "変換成功: " << *res.value << std::endl;
    } else {
        std::cout << "変換失敗 エラーコード: " << static_cast<int>(res.error) << std::endl;
    }
    auto res2 = parseInt("abc");
    if (res2.value) {
        std::cout << "変換成功: " << *res2.value << std::endl;
    } else {
        std::cout << "変換失敗 エラーコード: " << static_cast<int>(res2.error) << std::endl;
    }
    return 0;
}
変換成功: 123
変換失敗 エラーコード: 1

このように、std::optionalで値の有無を表しつつ、エラーコードで失敗理由を伝えられます。

例外を使わずにエラー処理を行いたい場合に有効です。

std::expectedによる戻り値管理

C++23で標準化予定のstd::expectedは、成功時に値を、失敗時にエラー情報を持つ型です。

std::optionalよりもエラー情報を明確に扱えるため、例外を使わないエラー処理の強力な選択肢となります。

標準化前はBoostや他のライブラリで類似の型が提供されています。

#include <iostream>
#include <string>
#include <variant>
template<typename T, typename E>
class Expected {
    std::variant<T, E> data;
public:
    Expected(const T& value) : data(value) {}
    Expected(const E& error) : data(error) {}
    bool has_value() const { return std::holds_alternative<T>(data); }
    T& value() { return std::get<T>(data); }
    E& error() { return std::get<E>(data); }
};
Expected<int, std::string> parseInt(const std::string& str) {
    try {
        int val = std::stoi(str);
        return Expected<int, std::string>(val);
    } catch (...) {
        return Expected<int, std::string>("無効な入力です");
    }
}
int main() {
    auto res = parseInt("456");
    if (res.has_value()) {
        std::cout << "変換成功: " << res.value() << std::endl;
    } else {
        std::cout << "変換失敗: " << res.error() << std::endl;
    }
    auto res2 = parseInt("xyz");
    if (res2.has_value()) {
        std::cout << "変換成功: " << res2.value() << std::endl;
    } else {
        std::cout << "変換失敗: " << res2.error() << std::endl;
    }
    return 0;
}
変換成功: 456
変換失敗: 無効な入力です

std::expectedは戻り値で成功・失敗の両方を表現できるため、例外を使わずにエラー情報を詳細に伝えられます。

例外処理のオーバーヘッドを避けたい場面で有効です。

ステータスオブジェクトとチェーン処理

複雑な処理で複数の関数を連続して呼び出す場合、各関数のエラーを逐一チェックし、エラーがあれば処理を中断するパターンがあります。

これを「チェーン処理」と呼びます。

ステータスオブジェクトを使うと、エラーコードやメッセージを一元管理しやすくなります。

#include <iostream>
#include <string>
struct Status {
    bool success;
    std::string message;
    static Status Ok() { return {true, ""}; }
    static Status Error(const std::string& msg) { return {false, msg}; }
};
Status step1(int x) {
    if (x < 0) return Status::Error("step1: xは0以上でなければなりません");
    return Status::Ok();
}
Status step2(int y) {
    if (y == 0) return Status::Error("step2: yは0であってはいけません");
    return Status::Ok();
}
Status process(int x, int y) {
    Status s = step1(x);
    if (!s.success) return s;
    s = step2(y);
    if (!s.success) return s;
    // 処理続行
    return Status::Ok();
}
int main() {
    Status result = process(-1, 10);
    if (!result.success) {
        std::cout << "エラー発生: " << result.message << std::endl;
    } else {
        std::cout << "処理成功" << std::endl;
    }
    return 0;
}
エラー発生: step1: xは0以上でなければなりません

この方法は例外を使わずにエラーを伝播でき、処理の流れを明示的に制御できます。

特にリアルタイム処理やパフォーマンスが重要な場面で有効です。

これらの例外を使わないエラー処理の選択肢は、例外のオーバーヘッドを避けたい場合や、例外処理が適さない環境での堅牢なエラー管理に役立ちます。

用途や要件に応じて使い分けることが重要です。

コンパイラ別の追加注意点

GCC

バージョンごとのSJLJ vs DWARF

GCCの例外処理機構は、内部的に例外のスタックアンワインド(巻き戻し)を実現するために、主に2つの方式が使われています。

これがSJLJ(SetJump/LongJump)方式とDWARF方式です。

GCCのバージョンやターゲットプラットフォームによって、どちらの方式が使われるかが異なります。

  • SJLJ方式

古いGCCや一部の32bit環境で採用されている方式です。

setjmplongjmpを使って例外処理を実装しており、例外の発生時にスタックを巻き戻すためにジャンプ処理を行います。

メリットは移植性が高く、例外処理の実装が単純なことですが、パフォーマンス面でやや劣ることがあります。

  • DWARF方式

DWARFはデバッグ情報のフォーマットの一つですが、GCCではDWARFのアンワインド情報を利用して例外処理を行います。

64bit環境や新しいGCCバージョンで主に採用されており、パフォーマンスが良いのが特徴です。

ただし、DWARF方式は例外処理のためのアンワインド情報が必要なため、デバッグ情報の生成設定に影響を受けることがあります。

GCCのバージョンやターゲット環境によっては、例外処理の挙動やパフォーマンスに違いが出るため、特に組み込みやパフォーマンスクリティカルな環境では注意が必要です。

Clang

Apple環境でのObjective-C共存

AppleのmacOSやiOS向けのClangコンパイラでは、C++の例外処理とObjective-Cの例外処理が共存する特殊な環境となっています。

Objective-Cは独自の例外機構@try@catchを持ち、C++の例外処理とは異なるため、両者の連携に注意が必要です。

  • Objective-C++

.mm拡張子のファイルでC++とObjective-Cのコードを混在させる場合、例外処理の境界が複雑になります。

Objective-Cの例外はC++のtry-catchでは捕捉できず、逆も同様です。

そのため、例外が異なる言語境界を越える場合は、明示的に例外をキャッチして変換するなどの対策が必要です。

  • 例外の有効化設定

Apple Clangでは、例外処理を有効にするために-fobjc-exceptions-fexceptionsオプションを適切に設定する必要があります。

これがないと、例外が正しく伝播しません。

  • ARC(Automatic Reference Counting)との関係

ARCを使う場合、例外処理とメモリ管理の相互作用に注意が必要です。

例外発生時にオブジェクトの解放が正しく行われるように設計されていますが、複雑な例外処理では予期せぬ動作をすることがあります。

MSVC

/EHdecatchと_set_se_translator

Microsoft Visual C++(MSVC)では、例外処理の動作を制御するために複数のコンパイルオプションが用意されています。

その中でも/EHオプションは例外の種類や伝播方法を指定します。

  • /EHsc

C++例外のみをサポートし、C言語の構造化例外(SEH)は捕捉しません。

最も一般的に使われる設定です。

  • /EHs

C++例外をサポートしつつ、C言語のSEHも捕捉可能にします。

  • /EHdecatch

これはデバッグ用のオプションで、例外がcatchされる直前にブレークするように設定します。

例外の発生と捕捉の流れを詳細に追跡したい場合に便利です。

また、MSVCではWindowsの構造化例外処理(SEH)をC++例外に変換するための関数_set_se_translatorが用意されています。

これを使うと、アクセス違反やゼロ除算などのハードウェア例外をC++例外として捕捉可能になります。

#include <iostream>
#include <windows.h>
#include <eh.h>
void seTranslator(unsigned int code, EXCEPTION_POINTERS* ep) {
    throw std::runtime_error("SEH例外が発生しました");
}
int main() {
    _set_se_translator(seTranslator);
    try {
        int* p = nullptr;
        *p = 42;  // アクセス違反を発生させる
    } catch (const std::exception& e) {
        std::cout << "例外捕捉: " << e.what() << std::endl;
    }
    return 0;
}
例外捕捉: SEH例外が発生しました

この機能を使うことで、Windows固有の例外もC++例外として扱え、例外処理の一元化が可能です。

Intel oneAPI

SIMD拡張と例外サポート

Intel oneAPIのC++コンパイラは、IntelのCPU向けに最適化されたコンパイラであり、SIMD(Single Instruction Multiple Data)命令セットを活用した高速化が特徴です。

しかし、SIMD命令を使うコードと例外処理の組み合わせには注意が必要です。

  • SIMD命令と例外の相性

SIMD命令は並列処理を行うため、例外が発生した場合の制御フローが複雑になります。

特に、SIMDレジスタ内の複数のデータ要素のうち一部で例外が発生した場合、どのように例外を伝播させるかはコンパイラやランタイムの実装に依存します。

  • 例外処理のパフォーマンス影響

SIMD最適化を行う際、例外処理が有効だとパフォーマンスが低下することがあります。

Intel oneAPIでは、例外処理を無効化するオプションや、例外を使わないエラーハンドリングを推奨するケースがあります。

  • コンパイラオプションの設定

Intel oneAPIコンパイラでは、例外処理の有効化や無効化を明示的に設定できます。

SIMDコードと例外処理を両立させる場合は、適切なオプションを選択し、動作検証を行うことが重要です。

  • ライブラリとの連携

Intelの数学ライブラリや並列処理ライブラリは、例外を使わずにエラーを返す設計のものも多いため、例外処理と組み合わせる際は注意が必要です。

これらのコンパイラ固有の注意点を理解し、適切に設定や設計を行うことで、例外処理のトラブルを防ぎ、安定した動作を実現できます。

ライブラリ・フレームワーク固有の例

Boost.Asio非同期ハンドラ

Boost.AsioはC++で非同期I/Oを扱うためのライブラリで、ネットワーク通信やタイマー処理などに広く使われています。

Boost.Asioの非同期ハンドラは、コールバック関数として例外処理の扱いに独特の注意点があります。

例外の伝播が禁止されている

Boost.Asioの非同期ハンドラ内で例外をthrowすると、例外は非同期処理の呼び出し元に伝播しません。

これは、非同期処理の実行コンテキストが呼び出し元とは異なるためで、例外が未捕捉のままプログラムをクラッシュさせるリスクがあります。

そのため、非同期ハンドラ内では例外を投げるのではなく、例外を捕捉してエラーコードや状態オブジェクトに変換し、明示的にエラーハンドリングを行うことが推奨されています。

#include <boost/asio.hpp>
#include <iostream>
#include <stdexcept>
void asyncHandler(const boost::system::error_code& ec) {
    try {
        if (ec) {
            throw std::runtime_error("非同期処理でエラー発生");
        }
        std::cout << "非同期処理成功" << std::endl;
    } catch (const std::exception& e) {
        std::cerr << "例外捕捉: " << e.what() << std::endl;
        // 例外をここで処理し、外に伝播させない
    }
}
int main() {
    boost::asio::io_context io;
    boost::asio::steady_timer timer(io, std::chrono::seconds(1));
    timer.async_wait(asyncHandler);
    io.run();
    return 0;
}

この例では、非同期ハンドラ内で例外を捕捉し、外に伝播させずに処理しています。

例外をそのまま投げると、プログラムが予期せず終了する可能性があります。

Qtのシグナルスロット

Qtフレームワークのシグナルとスロット機構は、オブジェクト間のイベント通知を実現するための仕組みです。

Qtのスロット内で例外を投げることは推奨されていません。

例外がスロットから伝播しない

Qtのシグナルスロットは内部的にイベントループで動作しており、スロット内で例外が発生しても、例外はシグナルの呼び出し元に伝播しません。

例外が未捕捉のままだと、Qtのイベントループが異常終了することがあります。

例外処理のベストプラクティス

スロット内で例外が発生する可能性がある場合は、スロット内で例外を捕捉し、適切に処理する必要があります。

例えば、エラーメッセージを表示したり、ログに記録したりします。

#include <QCoreApplication>
#include <QObject>
#include <QDebug>
#include <stdexcept>
class MyObject : public QObject {
    Q_OBJECT
public slots:
    void onSignal() {
        try {
            throw std::runtime_error("スロット内で例外発生");
        } catch (const std::exception& e) {
            qWarning() << "例外捕捉:" << e.what();
        }
    }
};
int main(int argc, char* argv[]) {
    QCoreApplication app(argc, argv);
    MyObject obj;
    QObject::connect(&obj, &MyObject::onSignal, &obj, &MyObject::onSignal);
    emit obj.onSignal();
    return 0;
}

このように、スロット内で例外を捕捉し、外に伝播させない設計が必要です。

Unreal Engineと例外境界

Unreal Engine(UE)はゲーム開発向けの大規模フレームワークで、例外処理の扱いに独自のルールがあります。

例外の使用が制限されている

UEの標準的なコーディング規約では、C++の例外処理は基本的に使用しない方針です。

これは、例外処理がパフォーマンスに与える影響や、プラットフォーム間の互換性の問題を避けるためです。

代替手段としてのエラーハンドリング

UEでは、エラー処理は戻り値やステータスコード、ログ出力、checkマクロやensureマクロなどのアサーション機構を使って行います。

例外を使わずに堅牢なエラーハンドリングを実現しています。

例外境界の注意点

UEのコードベースでは、例外が発生すると未定義動作やクラッシュの原因になるため、外部ライブラリなどで例外が発生する可能性がある場合は、例外を捕捉してUEのエラーハンドリングに変換する必要があります。

try {
    // 外部ライブラリの呼び出し
} catch (const std::exception& e) {
    UE_LOG(LogTemp, Error, TEXT("例外発生: %s"), *FString(e.what()));
    // 適切なエラー処理
}

このように、UEの例外境界では例外を捕捉し、ログやエラーハンドリングに変換することが重要です。

これらのライブラリやフレームワークは、それぞれ独自の例外処理ルールや制約を持っています。

例外処理を設計する際は、対象の環境に合わせた適切な方法を選択することが安定した動作の鍵となります。

付録:チェックリスト

C++の例外処理が想定通りに動かない場合や、例外処理の設計・実装で問題が起きたときに確認すべきポイントをまとめたチェックリストです。

問題の切り分けや原因特定に役立ててください。

チェック項目内容・確認ポイント
1. throwが正しく呼ばれているか– 例外を投げるべき箇所でthrowが実行されているか
– 条件分岐の抜け道がないか
returnと混同していないか
2. catchの型が例外と一致しているか– 例外オブジェクトの型とcatchの型が合っているか
const修飾や参照・ポインタの違いに注意
– 継承関係の順序が正しいか
3. コンパイラの例外機構設定– GCC/Clangで-fno-exceptionsが付いていないか
– MSVCの/EHオプションが適切か
– 組み込み環境で例外が無効化されていないか
4. noexcept指定の影響– 例外を投げる関数にnoexceptが付いていないか
– ライブラリ内部でnoexceptが伝播していないか
5. スレッド内の例外処理std::threadのスレッド関数内で例外を捕捉しているか
std::asyncfuture.get()で例外を受け取っているか
6. C言語インターフェースとの境界extern "C"関数内で例外を投げていないか
– DLLやモジュール間で例外のABI不整合がないか
7. リンク時のABI整合性– コンパイラやランタイムのバージョンが混在していないか
– RTTIや例外テーブルの整合性が保たれているか
8. ライブラリ・フレームワークの例外ルール– Boost.AsioやQtなど、使用しているライブラリの例外処理ルールを守っているか
– 例外を投げるべきでない箇所で投げていないか
9. 例外の再スローと伝播設計– 必要に応じてthrow;で例外を再スローしているか
– 例外を捕捉して処理を完結させるか伝播させるか明確か
10. 例外安全なリソース管理– RAIIを使ってリソースを管理しているか
– 例外発生時にリソースリークが起きていないか
11. デバッグ環境の活用– gdb/lldbのcatch throwcatch catchを使っているか
– Visual Studioの例外設定でブレークしているか
– ログ出力で例外発生・捕捉を確認しているか
12. 例外を使わないエラー処理の検討– パフォーマンスや環境制約で例外が使えない場合、std::optionalstd::expected、ステータスオブジェクトを使っているか

このチェックリストを順に確認することで、例外処理が動作しない原因を効率的に特定できます。

また、例外処理の設計段階でもこれらのポイントを意識することで、堅牢で保守性の高いコードを書くことが可能です。

まとめ

この記事では、C++の例外処理が想定通り動かない原因や典型的なトラブル、デバッグ手法、正しいcatch設計、例外を使わない代替手段、コンパイラやライブラリ固有の注意点を詳しく解説しました。

例外の発生から捕捉までの流れや型の一致、コンパイラ設定の確認が重要であること、またRAIIによる例外安全なリソース管理や非同期処理での例外扱いにも注意が必要です。

これらを理解し実践することで、堅牢で安定した例外処理を実現できます。

Back to top button
目次へ