C++のプログラム設計において、エラーハンドリングは常に開発者の頭を悩ませる重要なテーマです。
かつてのC++では例外(Exception)が主要な手段でしたが、現代ではより多様な選択肢が提供されています。
C++20以降、特にC++23で導入されたstd::expectedにより、エラーハンドリングの設計思想は大きな転換期を迎えました。
本記事では、例外処理とstd::expectedの適切な使い分けを軸に、C++26に向けた最新の動向まで詳しく見ていきましょう。
C++における例外処理の現状と課題
C++の例外処理は、プログラムの正常な流れと異常系の処理を明確に分離できる強力な仕組みです。
try-catchブロックを用いることで、深い階層の関数呼び出しから一気に脱出し、適切なハンドラでエラーを捕捉できます。
しかし、例外処理には実行時のオーバーヘッドという避けては通れない課題が存在します。
特に例外が発生した際のスタック展開(Stack Unwinding)は、リソースの解放を伴うため、非常にコストの高い処理となります。
また、例外は「どこで発生し、どこで捕捉されるか」が静的に分かりにくいという側面も持っています。
このため、リアルタイム性が要求されるシステムや、組み込み開発の現場では、例外の使用が制限されることも少なくありません。
現代のC++開発では、「例外を投げるべき状況」と「値を返すべき状況」を明確に区別することが求められています。
例外安全性の3つの保証レベル
例外を使用する上で、エンジニアが必ず意識しなければならないのが「例外安全性」です。
C++には、例外が発生した際のプログラムの状態に関して、主に3つの保証レベルが定義されています。
第一は「基本保証」であり、例外が発生してもリソース漏れが発生せず、オブジェクトが不整合な状態にならないことを指します。
第二は「強い保証」で、例外が発生した場合に、関数を呼び出す前の状態に完全にロールバックされることを保証します。
第三は「例外を投げない保証(noexcept)」であり、関数が例外を絶対に投げないことを明示する最高レベルの保証です。
モダンC++では、可能な限りnoexceptを活用してコンパイラの最適化を促すのが定石となっています。
C++23で登場したstd::expectedの衝撃
C++23で導入されたstd::expectedは、エラーハンドリングのパラダイムを大きく変える可能性を秘めています。
これは、正常な値またはエラー値のいずれかを保持できるテンプレート型です。
従来のstd::optionalでは「値がない」ことしか表現できませんでしたが、std::expectedは「なぜ失敗したか」という情報を保持できます。
これにより、例外を投げずにエラーの詳細を呼び出し元に伝えることが可能になりました。
関数の戻り値としてエラーを表現するため、呼び出し側は必ずエラーの可能性を意識することになります。
これは「チェック例外」に近い性質を持ちながら、実行時のパフォーマンス低下を最小限に抑えられるという利点があります。
std::expectedの基本的な使い方
std::expectedを使用することで、エラー処理を直感的なコードで記述できます。
以下のサンプルコードは、数値のパース処理においてstd::expectedを活用した例です。
#include <iostream>
#include <expected>
#include <string>
#include <string_view>
// エラー状態を定義する列挙型
enum class ParseError {
InvalidInput,
OutOfRange
};
// 数値をパースする関数。成功すればint、失敗すればParseErrorを返す
std::expected<int, ParseError> parse_number(std::string_view str) {
if (str.empty()) {
return std::unexpected(ParseError::InvalidInput);
}
try {
size_t pos;
int value = std::stoi(std::string(str), &pos);
if (pos != str.length()) {
return std::unexpected(ParseError::InvalidInput);
}
return value;
} catch (const std::out_of_range&) {
return std::unexpected(ParseError::OutOfRange);
} catch (...) {
return std::unexpected(ParseError::InvalidInput);
}
}
int main() {
auto result = parse_number("123");
if (result) {
std::cout << "Success: " << *result << std::endl;
} else {
if (result.error() == ParseError::InvalidInput) {
std::cerr << "Error: Invalid Input" << std::endl;
}
}
return 0;
}
Success: 123
このように、std::unexpectedを使用してエラー値をラップして返すのが特徴です。
呼び出し側では、if (result)のように真偽値として成功判定を行えます。
モナド風の操作による可読性の向上
std::expectedの真の価値は、C++23から追加されたモナド風の操作メソッドにあります。
and_thenやor_else、transformといったメソッドを使用することで、エラー処理の連鎖をスマートに記述できます。
これにより、従来のif-elseによるネストの深いエラーチェックを排除することが可能です。
#include <iostream>
#include <expected>
#include <string>
std::expected<int, std::string> get_value() { return 10; }
std::expected<int, std::string> double_value(int n) { return n * 2; }
int main() {
// 処理を連結していく
auto result = get_value()
.and_then(double_value)
.transform([](int n) { return n + 5; });
if (result) {
std::cout << "Final Result: " << *result << std::endl;
}
return 0;
}
Final Result: 25
このように、パイプラインのような形式で処理を記述できるため、ロジックの見通しが劇的に改善されます。
例外とstd::expectedの使い分けの基準
どちらの仕組みを使うべきか迷った際、一つの明確な基準となるのが「そのエラーがどれほど頻繁に起こるか」です。
一般的に、「本当に予期せぬ異常事態」には例外を用い、「ビジネスロジック上で想定される失敗」にはstd::expectedを用いるのがベストプラクティスとされています。
例えば、メモリ不足(bad_alloc)やハードウェアの故障などは、プログラマがその場で詳細に制御すべきことではなく、上位層でまとめて捕捉すべき「例外的な事象」です。
一方で、ユーザー入力のバリデーションエラーやファイルが見つからないといった事象は、プログラムの流れの中で頻繁に発生し得る「期待されるエラー」です。
後者にstd::expectedを採用することで、制御フローが明確になり、パフォーマンスも安定します。
例外とstd::expectedの比較表
それぞれの特徴を下表にまとめました。
| 機能・特性 | 例外(try-catch) | std::expected |
|---|---|---|
| 主な用途 | 致命的なエラー、稀な異常系 | 予測可能なエラー、正常系の一部 |
| パフォーマンス(正常時) | 非常に高速(ほぼゼロコスト) | 戻り値のコピーコストが発生 |
| パフォーマンス(異常時) | 非常に低速(スタック展開が発生) | 正常時とほぼ変わらず高速 |
| 明示性 | 低い(どの関数が投げるか不明確) | 高い(型としてエラーが明示される) |
| 強制力 | 捕捉しなくてもコンパイルは通る | 戻り値を確認しないと警告が出る([[nodiscard]]) |
C++26に向けた動向:エラーハンドリングの進化
2026年に向けて策定が進んでいるC++26では、エラーハンドリングをより効率化・簡略化する提案がいくつか議論されています。
その中でも注目すべきは、std::expectedをさらに使いやすくするためのユーティリティの追加です。
現在、エラーが発生した際に即座に関数からリターンするための糖衣構文(シンタックスシュガー)の提案などが検討されています。
これはRust言語における?演算子に似た挙動を目指しており、エラーハンドリングの記述量を大幅に削減する可能性があります。
また、C++26の標準ライブラリ全体で、従来の例外ベースのAPIに加えてstd::expectedを返すオーバーロードを増やす方向性も示唆されています。
Deterministic Exceptions(P0709)の影響
C++の将来像を語る上で欠かせないのが、Herb Sutter氏が提案している「決定論的例外(Deterministic Exceptions)」の議論です。
これは、現在の例外処理の強力な表現力を維持しつつ、std::expectedのような予測可能なパフォーマンスを実現しようとする試みです。
この提案が完全に標準化されるのはまだ先の話かもしれませんが、C++26の議論を通じて、例外のオーバーヘッドを劇的に減らす実装技術が洗練されつつあります。
静的な例外チェックをより強化し、動的な型情報に頼らないエラー伝播の仕組みが検討されている点は、全てのC++開発者が注目すべきポイントです。
実践的なエラーハンドリング戦略
モダンなプロジェクトにおいて、例外とstd::expectedを混在させる場合には、明確なレイヤー分けが必要です。
低レイヤーのライブラリやOSに近い部分では、エラーコードやstd::expectedを返してパフォーマンスを優先します。
一方で、アプリケーションのビジネスロジックを記述する高レイヤーでは、細かいエラーチェックを省略してコードを簡潔に保つために、例外をキャッチする構造にします。
この「ハイブリッド戦略」を成功させる鍵は、境界部分での型変換です。
std::expectedから例外へ、あるいは例外からstd::expectedへとスムーズに変換するためのラッパー関数を用意しておくことで、一貫性のある設計が可能になります。
[[nodiscard]]の活用
std::expectedを使用する際は、必ず[[nodiscard]]属性を意識してください。
C++標準のstd::expectedの実装には、この属性が付与されていることが多いですが、自作のエラー型でも同様に適用すべきです。
エラーが含まれている可能性のある戻り値を無視することをコンパイル時に防ぐことは、バグの早期発見に直結します。
「エラーを無視できない」という制約を型システムによって導入することこそが、モダンC++のエラーハンドリングの本質と言えるでしょう。
まとめ
モダンC++におけるエラーハンドリングは、単一の正解があるわけではなく、状況に応じた「使い分け」が重要です。
例外処理は、スタック展開のコストはあるものの、依然として致命的な異常事態を扱うための最も強力なツールです。
一方でC++23以降は、std::expectedを活用した「値としてのエラー処理」が標準的な選択肢となりました。
これにより、エラーの発生を静的に表現し、関数合成のようなエレガントな記述が可能になっています。
C++26に向けて、これらの仕組みはさらに洗練され、開発者の負担を軽減する方向に進化していくでしょう。
私たちが今できる最善の策は、それぞれの特性を正しく理解し、型安全で予測可能なコードを書くための設計を積み重ねることです。
最新の言語仕様を追いかけながら、プロジェクトの要件に最適なエラーハンドリング戦略を選択していきましょう。
