TopC++ 入門 › STEP 11 › 第66回 例外 vs 戻り値エラー

第66回 例外 vs 戻り値エラー — どう使い分けるか

「例外を投げるべきか、optional を返すべきか、エラーコードで返すべきか」。これは毎回悩む問題です。このページでは判断フローチャートで整理し、4 つの代表的な選択肢(例外 / optional / expected / エラーコード)の得意分野を具体例で示します。

最低限ここだけ

  • 日常的な失敗 → std::optional
  • 異常事態 → 例外
  • 理由も返したい → std::expected(C++23)

余裕があれば

  • 例外を使わない場面(組込・ゲーム)
  • エラーハンドリングのコスト比較
  • C 関数との境界設計

1. まず触ってみる — 4 つの選択肢

同じ「整数をパースする関数」を 4 通りで書いてみます。どれも正しい。違うのは「失敗をどう伝えるか」です。

1_exception.cpp① 例外
int parse(const std::string& s) { size_t n; int v = std::stoi(s, &n); if (n != s.size()) throw std::invalid_argument("bad"); return v; } // 呼び出し側: try/catch で受ける
2_optional.cpp② optional
std::optional<int> parse(const std::string& s) { try { size_t n; int v = std::stoi(s, &n); if (n != s.size()) return std::nullopt; return v; } catch (...) { return std::nullopt; } } // 呼び出し側: if (auto x = parse(s))
3_expected.cpp③ expected (C++23)
std::expected<int, std::string> parse(const std::string& s) { size_t n; try { int v = std::stoi(s, &n); if (n != s.size()) return std::unexpected("trailing"); return v; } catch (...) { return std::unexpected("invalid"); } } // 値か「理由付きの失敗」のどちらか
4_outparam.cpp④ エラーコード(C 風)
bool parse(const std::string& s, int& out) { size_t n; try { out = std::stoi(s, &n); return n == s.size(); } catch (...) { return false; } } // 呼び出し側: if (!parse(s, x)) { ... }

「どれも使える」けど向き不向きがあります。選択は失敗の性質で決まります。

2. どれを使う?判断フロー

まずは下のボタンを辿って、自分のケースでどれが合うか見てみてください。

▶ エラー戦略セレクタ
Q1. その失敗は「想定内で頻繁に起こる」?
はい(キーが無い等)
いいえ(めったに起きない)

→ optional / expected を使う

「見つからない」「不正な入力」のような日常的なケースは、例外ではなく戻り値で表現する方が自然かつ高速です。

  • 失敗の理由はどうでもいい(あるか/ないかだけ) → std::optional<T>
  • 失敗の理由を呼び出し側に伝えたいstd::expected<T, E>(C++23)、なければ tl::expected や自作 Result

→ Q2 へ

滅多に起きないが、起きたら深刻 — という場合は例外が候補です。

Q2. プロジェクトで例外が許可されている?
はい(通常のアプリ開発)
いいえ(組込・ゲームエンジン等)

→ 例外を使う

例外的状況(ディスク満杯、ネットワーク切断、コンストラクタ失敗、メモリ不足…)は例外が最適。

  • 中間関数で if チェックが不要
  • デストラクタによる自動クリーンアップ(RAII)が効く
  • 正常系のコストはゼロ

→ エラーコード / expected 相当の自作型

例外禁止環境では、戻り値エラー + RAII で丁寧に組み立てるしかない。

  • bool 戻り値 + 出力引数
  • enum class ErrorCode を返す
  • 自作 Result<T, E> 型 or std::variant<T, E>

Google C++ Style は基本的に例外禁止。Unreal Engine も同様。そうした流儀は各プロジェクトのガイドラインに従う。

3. 具体例で使い分けを見る

3-1. map 検索 — optional 一択

「キーがあるかもしれないし、ないかもしれない」は完全に optional の領域。

lookup.cppOK
std::optional<User> find(int id) { auto it = db.find(id); if (it == db.end()) return std::nullopt; return it->second; } if (auto u = find(42)) { use(*u); } else { std::cout << "not found"; }

3-2. ファイルオープン — 例外が向く

ファイルが開けないのは「そうそう起きない深刻な事態」。中間関数に伝播させたい。

open.cppOK
std::vector<std::string> loadLines(const std::string& p) { std::ifstream f(p); if (!f) throw std::runtime_error("open: " + p); std::vector<std::string> lines; for (std::string l; std::getline(f, l); ) lines.push_back(std::move(l)); return lines; // 正常系はスッキリ }

3-3. 入力パース — expected が向く

「失敗しうる上に、理由が複数」なら expected が最適。C++20 以前なら自作 Result や std::variant<T, Error> で代用。

parse.cppOK (C++23)
enum class PErr { Empty, NonDigit, Overflow }; std::expected<int, PErr> parseInt(const std::string& s) { if (s.empty()) return std::unexpected(PErr::Empty); ... } auto r = parseInt(input); if (r) use(*r); else switch (r.error()) { ... }

3-4. コンストラクタの失敗 — 例外しかない

コンストラクタには戻り値がそもそも無いので、例外以外に失敗を伝える手段がありません(あるいは「常に成功する設計にする」かのどちらか)。

ctor.cppOK
class Conn { SOCKET s_; public: Conn(const std::string& host) { s_ = connect(host); if (s_ < 0) throw std::runtime_error("conn"); } ~Conn() { close(s_); } };

4. 比較表で整理

観点例外optionalexpectedエラーコード
理由を伝えられる◎ 任意の型✗ 有無のみ◎ 任意の型△ 整数のみ
正常系のコスト◎ ほぼ 0○ 小○ 小△ 毎回 if
異常系のコスト△ 重い◎ 軽い○ 軽い◎ 軽い
中間関数の書きやすさ◎ 素通り△ 毎回チェック△ モナド記法で改善△ 毎回チェック
C 関数との境界✗ 渡せない△ 整数で代用△ 整数で代用◎ そのまま
コンストラクタ◎ 唯一の方法
C++ 標準全バージョンC++17〜C++23〜全バージョン
デフォルトの選び方: 戻り値で返せる範囲(検索ミス・パース失敗)は optional / expected。コンストラクタや深い階層からの伝播、滅多に起きないが深刻なエラーは例外。両方使うハイブリッド設計が実務で多い。

5. C 関数との境界での設計

C++ 側で例外を使っていても、C のヘッダを提供する境界では例外を渡せません(C には例外が無い)。境界関数では例外を捕まえてエラーコードに変換します。

c_bridge.cppC++ → C
extern "C" int my_init(const char* cfg) noexcept { try { Engine::init(cfg); // ← C++ 側は例外で書く return 0; } catch (const std::bad_alloc&) { return -2; } catch (const std::exception&) { return -1; } catch (...) { return -99; } }

ポイント:

  • extern "C" 関数は必ず noexcept(例外を外に出すと未定義動作)
  • catch(...) を最後に置いて、想定外の例外も受け止める
  • bad_alloc などの特定例外は個別コードに変換できる
コールバックも同じ: C のライブラリに関数ポインタを渡す時、その関数内から例外を漏らしてはいけない。入口で try/catch を張るのが鉄則。

6. 理解度チェック

4 問。

Q1. map から値を引くとき、キーが見つからないケースに最適なのは?

throw std::runtime_error で通知
std::optional<T> を返す
グローバル errno にセットする
「見つからない」は日常的ケース。例外は重いし、意味的にも「例外的な状況」ではない。optional が自然かつ速い。

Q2. コンストラクタで失敗した時の正しい扱いは?

失敗フラグをメンバに保存し、呼び出し側に isValid() で確認させる
例外を投げる
戻り値で失敗を返す
コンストラクタには戻り値がない。失敗フラグを残すと「使い物にならないオブジェクト」が生まれ、呼び出し側が毎回 isValid() チェックを強いられる。例外が唯一の正攻法。

Q3. extern "C" 関数から C++ の例外を漏らすとどうなる?

C 側でも普通に catch できる
未定義動作(実装依存だが多くの場合クラッシュ)
自動で戻り値 -1 に変換される
C には例外のスタック巻き戻し機構が無いので、境界で漏らすと UB。入口で try/catch してエラーコードに変換するのが鉄則。

Q4. std::expected<T, E> が optional より優れる点は?

必ず例外を投げずに済む
失敗の「理由」を呼び出し側に伝えられる
C++17 で使える
optional は「値がある/ない」の 2 値。expected は失敗側に任意の型 E を持たせられ、なぜ失敗したかを伝えられる。C++23 で標準化。