現在のブログ
ゲーム開発ブログ (2025年~) Gamedev Blog (2025~)
レガシーブログ
テクノロジーブログ (2018~2024年) リリースノート (2023~2025年) MeatBSD (2024年)
【C++】例外は何故よく禁止されるのか?
C++を学んでいくと、いつかexceptionについて学ぶ事に成ります。
例外はエラーを処理する素晴らしい方法ですが、同時に屡々禁止されます。
企業向け、教育用ソフトウェア、Webソフトウェア等でC++を使う場合は例外が強く推奨されます。
然しゲーム開発、金融、AI、自動車、ロボット工学等の分野では、例外はほぼ全面的に禁止されるのが普通です。
何故でしょうか?
説明しましょう。
あたしの立場
個人的には例外に対しては賛否両論です。
デバッグには非常に役立ちますが、本番環境では避けたいと思っています。
最近Shader Playgroundを例外処理付きでリリースしましたが、あくまで教育ツールなので問題ありません。
例外とは何か?
正直に言うと、C++のエラーは非常に分かり憎い事が多いので、プログラムがクラッシュした時に何が起きたのかをデバッグするのは本当に大変です。
例外を使うと、クラッシュを防ぎ、エラーを投げて(throw)プログラムが何事もなかったかの様に続行出来ます(コードの重要部分でなければ)。
又、人間が読めるエラーを返す事が出来ます。
以下は架空のC++コードの例です:
#include <iostream>
#include <stdexcept>
#include <engine/gfx/vulkan.hh>
void InitVulkan() {
engine::gfx::Vulkan vk;
if (vk.IsError()) throw std::runtime_error(vk.ErrorMsg());
}
int Add(int a, int b, int expect) {
int sum = a + b;
if (sum != expect)
throw std::logic_error("計算結果が期待値と一致していません: "
+ std::to_string(sum) + " != "
+ std::to_string(expect));
return sum;
}
int main() {
try {
InitVulkan();
Add(2, 2, 5);
} catch (const std::runtime_error &e) {
std::cout << e.what() << std::endl;
return 1;
} catch (const std::logic_error &e) {
std::cout << e.what() << std::endl;
} catch (...) {
std::cout << "不明なエラー" << std::endl;
return 1;
}
std::cout << "Vulkan初期設定完了!" << std::endl;
return 0;
}
此の例ではVulkanを初期化し、2 + 2を計算して結果が5に成る事を期待しています。
Vulkanの初期化は、非対応GPUやVulkanライブラリ(.dllや.so)が存在しない場合に失敗する可能性があります。
然し加算関数は必ず失敗します。
何故なら2 + 2 = 4であって5ではないからです。
何が投げられるかは、最初に何が失敗するかによって変わります。
Vulkanの初期化が加算より先に失敗すればランタイムエラーに成ります。
成功すればロジックエラーに成ります。
又、ロジックエラーの箇所でreturn 1;を省略している事に気づくでしょう。
此れは、2+2が5か4である事がVulkanの初期化にとって重要ではない為、エラーを投げつつもプログラムの実行を継続させる為です。
例外の利点を示す為に意図的に入れています。
又、catch (...)も見えます。
此れは「例外処理の方法を此処に入れろ」という意味ではなく、実際に有効なC++コードです。
「投げられた他のあらゆるエラー」を意味し、此の場合は発生しませんが、大規模なコードベースでは十分に起こり得ます。
例外のコスト
此れが例外が禁止される主な理由です:パフォーマンスを著しく低下させる為です。
其の他の理由としてCライブラリとの連携(エラー処理のスタイルが複数に成る)、複雑性の増加等があります。
然し例外が「テロリスト要注意リスト」に載る本当の理由は、パフォーマンスコストです。
エラーが発生する度(大規模コードベースでは想像以上に頻繁に起こる)、throw時にメモリ確保が発生し、スタックが巻き戻され、オブジェクトが暫く生き続け、Run-Time Type Information(略してRTTI)が必要に成ります。
此れら全てがCPUサイクルとメモリ空間を消費し、パフォーマンスが重要視される分野では極めて貴重な資源です。
ゲームではフレームレートの低下を意味します。
金融ではタイミングの悪化による巨額の損失の可能性があります。
医療では電気ショックが正確なタイミングで与えられない事による死亡の可能性があります。
自動車では潜在的な衝突事故を意味します。
IoTデバイスではCPUパワーやメモリが不足する為ファームウェアが崩壊します。
SwitchやPS5等のゲーム機向けに開発する場合、SDKが意図的に例外を未対応為、例外を使うとコンパイルエラーに成ります。
其の為此れらのシステム向けゲームを作る会社では、例外は禁止されるだけでなく、技術的にも不可能です。
一方、企業、Web、教育、科学分野のソフトウェアでは例外が好まれる傾向があります。
此れらの分野ではパフォーマンスは殆ど問題にならないからです。
代わりにセキュリティとメモリ安全性が重要視されます。
但し、此処に落とし穴があります:此れらの分野ではC++は殆ど使われていません。
企業はC#やJavaを、教育・科学はPythonを、WebはPHP、Javascript、Go、Ruby、Rust等其の時々の流行の言語を好みます。
あたしの場合、C++をフルタイムの仕事と自分の会社で両方使っています。
自分の会社ではNintendo SwitchとSwitch 2向けゲームを作っている為、例外を一切使いません。
フルタイムの仕事では計算中心の建設ツールでは例外を多用しますが、LiDAR関連ソフトウェアでは避けています。
詰り、要件次第です。
何方の仕事も厳格な秘密保持契約に縛られているので、此処までしか話せません。
代替手段
Assert
幸い、代替手段はあります。
最も一般的なのは、結果を表すenumとassertを組み合わせる事です。
人間が読めるエラーメッセージの代わりにエラーコードが得られます。
assertにもメッセージを付ける事が出来ます。
assertの優れている点は、デバッグモードでのみassertして停止し、リリースモードでは動作しない事です。
此れはC/C++の趣味開発者には新しい概念かもしんが、プロの環境ではデバッグモードとリリースモードの区別が標準です。
簡単に言うと、デバッグモードではコンパイラの最適化を全てオフにし、ラベルを保持し、デバッグツールを有効にします。
一方リリースモードでは最適化を最大レベルにし、デバッグツールを全て削除し、必要に応じてラベルを削ってファイルサイズを減らします。
又、デバッグモードではDEBUGマクロが定義され、リリースモードではNDEBUGマクロが定義されるのが標準です。
スーパーマリオ64は日本版と米国版のニンテンドー64ハードウェア全てでデバッグモードで動作していましたが、欧州版では此のミスに気づき、リリースビルドに変更しました。
其の結果、ウォーターランドは日本版・米国版では大幅にラグが発生しましたが、欧州版では発生しませんでした。
一例:
#include <cassert>
#include <iostream>
#include <engine/gfx/vulkan.hh>
int main() {
engine::gfx::Vulkan vk;
assert(!vk.IsError() && vk.ErrorMsg().c_str());
int sum = 2 + 2;
assert(sum == 4 && "計算結果が期待値と一致していません。");
std::cout << "Vulkan初期設定完了!" << std::endl;
return 0;
}
上記のコードの問題点は、デバッグモードでのみ停止する事です。
此れを修正するには次の様にします:
#include <cassert>
#include <iostream>
#include <engine/gfx/vulkan.hh>
int main() {
engine::gfx::Vulkan vk;
if (vk.IsError()) {
assert(false && vk.ErrorMsg().c_str());
return -1;
}
int sum = 2 + 2;
assert(sum == 4 && "計算結果が期待値と一致していません。");
std::cout << "Vulkan初期設定完了!" << std::endl;
return 0;
}
此れにより、Vulkanの初期化に失敗した場合は実行を停止し、2 + 2が4にならない場合はリリースモードでも実行を継続させる、というtry-catch版を再現出来ました。
assertはC標準ライブラリ由来なので、C++ではなくCを使う場合はヘッダがcassertではなくassert.hに成り、同じ様に動作します。
此れにより文字列がconst char *型になる為、vk.ErrorMsg()がstd::stringを返すので.c_str()を付ける必要がありました。
C言語の場合:
#include <assert.h>
#include <stdio.h>
#include <engine/gfx/vulkan.h>
int main() {
if (vk_init() != 0) {
assert(false && vk_get_error());
return -1;
}
int sum = 2 + 2;
assert(sum == 4 && "計算結果が期待値と一致していません。");
puts("Vulkan初期設定完了!");
return 0;
}
Result
もう一つの一般的な方法はResult型を使う事です(自分で作る必要があります)。
任天堂のハードウェア向けゲームを作っている時に初めて出会い、とても便利だったのでPHPで再現し、今では此れなしでは生きられない程です。
Result型は、ResultOKで始まるenumの様な物で、全てのエッジケースを列挙し、其々のエラー種別を割り当て、其処からエラーメッセージを作成出来ます。
あたしは更にクラス(又は構造体)として作り、SuccessとErrorメソッドを持たせました。
何方のメソッドもメッセージ文字列とデータ配列を取ります。
Errorメソッドではメッセージ文字列のみ必須で、他のパラメータはオプションです。
PHPの場合(PHP 8.0以上必要):
class Result {
public bool $isSuccess;
public ?string $message;
public array $data;
public function __construct(bool $isSuccess, ?string $message = null, array $data = []) {
$this->isSuccess = $isSuccess;
$this->message = $message;
$this->data = $data;
}
public static function Success(?string $message = null, array $data = []): self {
return new self(true, $message, $data);
}
public static function Error(string $message, array $data = []): self {
return new self(false, $message, $data);
}
}
function Get(): Result {
return Result::Success("成功", [20, "詳細"]);
}
C++での同等の実装(C++17以上必要):
#include <string>
#include <vector>
#include <any>
class Result {
public:
bool isSuccess = false;
std::string message = "";
std::vector<std::any> data{};
Result(bool isSuccess, std::string message = "", std::vector<std::any> data = {})
: isSuccess(isSuccess)
, message(std::move(message))
, data(std::move(data)) {}
static Result Success(std::string message = "", std::vector<std::any> = {}) {
return Result(true, std::move(message), std::move(data));
}
static Result Error(std::string message, std::vector<std::any> = {}) {
return Result(false, std::move(message), std::move(data));
}
};
Result Get() {
return Result::Success("成功", {20, "詳細"});
}
std::expected
C++23ではstd::expectedが導入されました。
此れはパフォーマンスや本番信頼性を犠牲にせずにエラーを扱う新しい方法です。
assertの様な動作をしますが、リリースモードでも動作します。
例:
#include <print>
#include <expected>
#include <string>
std::expected<int, std::string> Add(int a, int b, int expected) {
int sum = a + b;
if (sum != expected) return std::unexpected("計算結果が期待値と一致していません。");
return sum;
}
int main() {
auto res = Add(2, 2, 5);
// auto res = Add(2, 2, 4);
if (res) std::println("結果:{}", *res);
else std::println("エラー:{}", res.error());
return 0;
}
又は:
int main() {
auto res = Add(2, 2, 5)
// auto res = Add(2, 2, 4)
.transform([](const auto &data) {
return data;
})
.or_else([](const auto &err) {
std::println("エラー:{}", err);
return -1;
});
std::println("結果:{}", *res);
return 0;
}
此れはGoのerror型を思い起こさせます:
package main
import (
"fmt"
"errors"
)
func Add(a, b, expected int64) (int64, error) {
sum := a + b
if sum != expected {
return 0, errors.New("計算結果が期待値と一致していません。")
}
return sum, nil
}
func main() {
sum, err := Add(2, 2, 5)
// sum, err := Add(2, 2, 4)
if err != nil {
fmt.Println("エラー:", err)
} else {
fmt.Println("結果:", sum)
}
}
以上