閉じる

【C++】例外処理(try-catch-throw)の使い方と基礎を徹底解説

C++を用いたアプリケーション開発において、予期せぬエラーへの対策は欠かせません。

実行時のメモリ不足、ファイルの読み込み失敗、あるいは不正な計算など、プログラムを強制終了させかねない事態に備える仕組みが例外処理(Exception Handling)です。

本記事では、C++の例外処理の基本であるtrycatchthrowの使い方から、メモリ管理に直結する重要な概念まで、初心者から中級者向けに徹底解説します。

例外処理とは何か

例外処理とは、プログラムの実行中に発生する「通常とは異なる状況」を検知し、適切に回復または安全に終了させるための仕組みです。

C言語などの古い言語では関数の戻り値でエラーを表現することが一般的でしたが、C++ではエラーの発生場所と処理場所を分離できる例外処理が推奨されています。

例外処理の最大のメリットは、エラー処理をメインのロジックから切り離せる点にあります。

これにより、正常系のコードが読みやすくなり、多層構造の関数呼び出しの奥深くで発生したエラーを一気に上位層で受け止めることが可能になります。

例外処理の基本キーワード

C++の例外処理は、主に以下の3つのキーワードで構成されます。

  1. throw:異常が発生したことを通知(例外を投げる)
  2. try:例外が発生する可能性のあるコードを囲む
  3. catch:投げられた例外を捕捉して対処する(例外を捕まえる)

これらが連携することで、プログラムがクラッシュすることなく、開発者が意図した通りのエラーハンドリングが実現されます。

基本的な構文と使い方

まずは、最もシンプルな例外処理の書き方を見ていきましょう。

C++では、数値や文字列、クラスオブジェクトなど、任意の型のデータを例外として投げることができますが、通常は標準ライブラリの例外クラスを使用します。

throw、try、catchのコード例

以下のサンプルコードは、0による除算を防ぐために例外を使用する例です。

C++
#include <iostream>
#include <stdexcept> // 例外クラスを使用するために必要

// 除算を行う関数
double divide(double a, double b) {
    if (b == 0) {
        // 0で割ろうとした場合に例外を投げる
        // std::invalid_argumentは標準の例外クラスの一つ
        throw std::invalid_argument("Division by zero error!");
    }
    return a / b;
}

int main() {
    double x = 10.0;
    double y = 0.0;

    try {
        // 例外が発生する可能性のある処理をtryブロックに記述
        std::cout << "計算を開始します。" << std::endl;
        double result = divide(x, y);
        // 例外が発生すると、これ以降の行は実行されません
        std::cout << "結果: " << result << std::endl;
    } 
    catch (const std::invalid_argument& e) {
        // throwされた型と一致するcatchブロックが実行される
        // e.what()で投げられたメッセージを取得できる
        std::cerr << "例外をキャッチしました: " << e.what() << std::endl;
    }

    std::cout << "プログラムを正常に終了します。" << std::endl;
    return 0;
}
実行結果
計算を開始します。
例外をキャッチしました: Division by zero error!
プログラムを正常に終了します。

構文の解説

このコードの動きを詳しく見てみましょう。

divide関数の中でb == 0という条件が満たされたとき、throwによって例外オブジェクトが生成されます。

この瞬間、関数の実行は直ちに中断され、現在の関数を呼び出している上位のtryブロックを探しに行きます。

main関数内のtryブロックの中で例外が発生したため、制御は対応するcatchブロックへ移ります。

ここで重要なのは、catchブロックが終了した後は、try-catch構文の次の行から処理が再開されるという点です。

プログラム全体が終了してしまうわけではありません。

標準例外クラスの活用

C++の標準ライブラリには、あらかじめ定義された例外クラスが多数用意されています。

これらは<stdexcept>ヘッダに定義されており、std::exceptionを親クラスとする継承関係を持っています。

主要な例外クラスの一覧

独自の型を投げることも可能ですが、基本的には以下の標準クラスを利用するか、それらを継承して独自の例外を作るのが一般的です。

クラス名主な用途
std::exceptionすべての標準例外の基底クラス
std::logic_errorプログラムの論理的な誤り(事前に回避可能)
std::runtime_error実行時の不可避なエラー(外部要因など)
std::out_of_range配列のインデックス範囲外アクセスなど
std::invalid_argument関数への引数が不正な場合
std::bad_allocnewによるメモリ確保失敗時

複数の例外をキャッチする

一つのtryブロックに対して、複数のcatchブロックを並べることができます。

これにより、エラーの種類に応じたきめ細やかな対応が可能になります。

C++
#include <iostream>
#include <vector>
#include <stdexcept>

void complexOperation(int index) {
    if (index < 0) {
        throw std::invalid_argument("インデックスは正である必要があります。");
    }
    std::vector<int> v = {1, 2, 3};
    if (index >= v.size()) {
        throw std::out_of_range("インデックスが範囲外です。");
    }
}

int main() {
    try {
        complexOperation(10); // 範囲外アクセスを誘発
    }
    catch (const std::out_of_range& e) {
        std::cerr << "範囲エラー: " << e.what() << std::endl;
    }
    catch (const std::invalid_argument& e) {
        std::cerr << "引数エラー: " << e.what() << std::endl;
    }
    catch (const std::exception& e) {
        // その他の標準例外をすべてキャッチ
        std::cerr << "何らかの標準例外: " << e.what() << std::endl;
    }
    return 0;
}
実行結果
範囲エラー: インデックスが範囲外です。

ここで注意すべき点は、catchブロックは上から順番に判定されるということです。

基底クラスであるstd::exceptionを最初に書いてしまうと、その下の派生クラスのブロックには一生たどり着きません。

そのため、派生クラス(具体的なエラー)から先に記述し、最後に基底クラス(一般的なエラー)を書くのが鉄則です。

スタックアンワインディング(スタックの解体)

例外処理において最も強力かつ重要な仕組みがスタックアンワインディング(Stack Unwinding)です。

例外が投げられると、その例外がキャッチされる場所まで関数の呼び出し履歴を遡ります。

その際、途中の関数内で生成されたローカルオブジェクトのデストラクタが自動的に呼び出される仕組みです。

この仕組みがあるおかげで、例外が発生してもメモリリークやリソースの解放漏れを防ぐことができます。

ただし、これはオブジェクトの寿命管理が正しく行われている場合に限られます

デストラクタが呼ばれる様子の確認

C++
#include <iostream>
#include <string>

class Resource {
    std::string name;
public:
    Resource(std::string n) : name(n) {
        std::cout << name << " を確保しました。" << std::endl;
    }
    ~Resource() {
        std::cout << name << " を解放しました(デストラクタ)。" << std::endl;
    }
};

void funcB() {
    Resource resB("リソースB");
    std::cout << "funcBで例外を投げます..." << std::endl;
    throw std::runtime_error("エラー発生");
}

void funcA() {
    Resource resA("リソースA");
    funcB();
}

int main() {
    try {
        funcA();
    } catch (const std::exception& e) {
        std::cout << "mainでキャッチ: " << e.what() << std::endl;
    }
    return 0;
}
実行結果
リソースA を確保しました。
リソースB を確保しました。
funcBで例外を投げます...
リソースB を解放しました(デストラクタ)。
リソースA を解放しました(デストラクタ)。
mainでキャッチ: エラー発生

実行結果を見ると、例外が発生した瞬間にfuncBのリソースだけでなく、呼び出し元のfuncAのリソースも自動的に破棄されていることがわかります。

これがC++における安全なプログラミングの根幹を支えています。

RAIIと例外安全

スタックアンワインディングの恩恵を最大限に受けるための設計手法がRAII (Resource Acquisition Is Initialization)です。

日本語では「リソースの確保は初期化時に行う」と訳されます。

なぜRAIIが必要なのか

もし、リソースをクラスのオブジェクトとして管理せず、生のポインタ(new)とdeleteで管理していたらどうなるでしょうか。

C++
void unsafeFunction() {
    int* data = new int[100]; // メモリ確保
    
    // 何らかの処理の途中で例外が発生すると...
    throw std::runtime_error("Oops!");

    delete[] data; // ここには到達しないため、メモリリークが発生!
}

このように、生のポインタを使用していると、例外発生時にdeleteがスキップされてしまいます。

これを解決するのが、スマートポインタやstd::vectorなどのRAIIクラスです。

スマートポインタによる安全な実装

RAIIを適用したコードでは、オブジェクトがスコープを抜ける際にデストラクタが必ず呼ばれる性質を利用します。

C++
#include <memory>

void safeFunction() {
    // std::unique_ptrはRAIIに従うスマートポインタ
    auto data = std::make_unique<int[]>(100);

    // 例外が発生しても、dataのデストラクタが走り、メモリは自動解放される
    throw std::runtime_error("Safe error!");
}

C++で例外処理を書く際は、「try-catchをたくさん書く」ことよりも「RAIIを用いて例外が起きても安全なコードにする」ことの方が遥かに重要です。

これを例外安全(Exception Safety)と呼びます。

キャッチオールと再スロー

特定の型だけでなく、あらゆる例外をキャッチしたい場合や、キャッチした例外をさらに上位へ流したい場合があります。

全ての例外をキャッチする (…)

catch(...)という特殊な構文を使うと、型に関わらずすべての例外を受け取ることができます。

C++
try {
    // 未知の例外が発生するかもしれない処理
} catch (...) {
    // すべての例外がここに来る
    std::cerr << "未知の例外が発生しました。" << std::endl;
}

ただし、この構文ではどのようなエラーが起きたのか詳細を知ることができません

そのため、ログの出力や最低限の後処理のみを行い、プログラムを安全に落とすための「最後の砦」として利用するのが一般的です。

例外の再スロー (throw;)

キャッチした例外を、一部処理だけして再度投げ直すことができます。

C++
try {
    // 処理
} catch (const std::exception& e) {
    std::cerr << "ログ出力: " << e.what() << std::endl;
    // 引数なしのthrowは、現在キャッチしている例外をそのまま投げ直す
    throw; 
}

これにより、中間層の関数で「ログだけ記録して、実際のエラー対応は呼び出し元に任せる」といった柔軟な運用が可能になります。

noexcept修飾子

C++11以降では、関数が「例外を投げないこと」を明示するnoexceptというキーワードが導入されました。

C++
void fastFunction() noexcept {
    // この関数は絶対に例外を投げない
}

noexceptのメリット

  1. 最適化:コンパイラは例外処理用の付随コードを生成しなくて済むため、実行速度やバイナリサイズが向上します。
  2. 信頼性:関数の利用者は、その関数を呼び出す際にtry-catchを考慮しなくて良いことが保証されます。

もしnoexceptを指定した関数内で例外が発生し、それが関数の外に漏れ出した場合、プログラムは直ちにstd::terminate()を呼び出して強制終了します。

特にデストラクタやムーブコンストラクタにはnoexceptを付与することが強く推奨されます

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

例外処理は強力ですが、乱用するとコードの複雑化やパフォーマンス低下を招きます。

以下のガイドラインを意識しましょう。

1. 例外をフロー制御に使わない

例外はあくまで「異常事態」のためにあります。

if文でチェックできるような通常の条件分岐(ユーザーの入力ミスなど)を例外で処理するのは避けましょう。

例外の送出は通常の処理に比べてコストが高いからです。

2. コンストラクタでの失敗は例外で知らせる

C++において、オブジェクトの生成に失敗したことを呼び出し元に伝える唯一のクリーンな方法は、コンストラクタから例外を投げることです。

これにより、「不完全な状態のオブジェクト」が作られるのを防げます。

3. デストラクタからは絶対に例外を投げない

スタックアンワインディング中にデストラクタからさらに例外が投げられると、C++のランタイムはどちらを優先すべきか判断できず、即座にプログラムを異常終了させます。

デストラクタ内のエラーは、内部で処理して外には出さないのが鉄則です。

4. 適切な粒度でキャッチする

何千行もあるコードを一つの巨大なtryブロックで囲むのは避けましょう。

どこで何が起きたのか特定しづらくなります。

意味のある単位でエラーハンドリングを行うのがコツです。

まとめ

C++の例外処理は、単にエラーを捕まえるだけの機能ではありません。

スタックアンワインディングによる自動的なリソース解放という、強力なメモリ管理の仕組みと密接に結びついています

try-catchの基本構文を理解することは第一歩に過ぎません。

真に堅牢なプログラムを書くためには、RAIIを徹底し、「例外が発生してもリソースが漏れない」構造を作ることが重要です。

また、標準例外クラスを適切に選択し、noexceptを活用することで、パフォーマンスと安全性のバランスを取ることができます。

例外処理を正しく使いこなし、予期せぬエラーにも動じない高品質なC++アプリケーションの開発を目指しましょう。

クラウドSSLサイトシールは安心の証です。

URLをコピーしました!