⚔️ C#クエスト―例外処理の遺跡 🐉
序章:遺跡の入口 ― 古代の仕掛けに挑む
旅人は古びた遺跡の前に立っていた。
壁面には C# のコード片が刻まれ、ところどころ黒く焦げている。
かつての開発者たちが残した“例外処理にまつわる仕掛け”が眠る場所だ。
これらの仕掛けは、ただの罠ではない。
正しい理解があれば容易に抜けられるが、
知識があいまいなまま挑むと、簡単に足をすくわれる。
旅人は深呼吸し、最初の通路へと足を踏み入れた。
🏛️ 仕掛け1:例外の雨 ― ループ内例外の連続発生
遺跡の天井から、一定のリズムで“石の雨”が降り注いでいた。
旅人は必死に身をかわしながら、前へ進む。
そのとき──
壁面に青白い光が走り、古代のコードが浮かび上がった。
for (int i = 0; i < items.Length; i++)
{
try
{
Process(items[i]);
}
catch (Exception ex)
{
Log(ex);
}
}
旅人は思わず声を上げた。
「うわ、これ毎回 try/catch してるじゃん! そりゃ石も降るわ!」
石の雨の正体は、ループ内で繰り返し発生している例外だった。
例外は重い。発生のたびにスタックトレースを生成し、処理を止め、
環境に大きな負荷をかけていたのだ。
さらに光が揺らめき、もうひとつのコードが浮かぶ。
foreach (var item in items)
{
if (!IsValid(item))
continue;
Process(item);
}
旅人は雨を避けながら小さくつぶやいた。
「……そうか。避けられるものは、例外にしなければいい」
その瞬間、石の雨はぴたりと止んだ。
仕掛けが解除されたのだ。
(参考)実験結果
| エラー割合:10% | エラー割合:1% | エラー割合:0.1% | |
|---|---|---|---|
| Parse + try/catch(5回) | 96 ms | 11 ms | 3 ms |
| TryParse(5回) | 2 ms | 2 ms | 2 ms |
TryParseはエラー率に関わらずほぼ一定でしたが、
Parse + try/catchはエラーが混ざった途端に時間が大きく伸びました。
性能差は明らかですが、本当に問うべきは「それは想定外の異常なのか?」という意味付けです。
例外は“めったに起きない事態”だけに使う──それがこの仕掛けの核心です。
🏞️ 仕掛け2:記憶改ざんの沼
旅人は先へ進んだ。
だが行く手は突然ぬかるんだ沼で途切れ、道はそこで終わっていた。
「……行き止まりか。戻るしかないな。」
そう言って振り返った瞬間、旅人は叫んだ。
「道が……消えてる!? さっき歩いたはずなのに!」
足跡も、曲がり角も、辿ってきた記録すべてが白く塗りつぶされたように消失している。
まるで“通ってきた記憶そのもの”が奪われたようだった。
そのとき、沼の中央から半透明の石碑がせり上がり、旅人の前に姿を現した。
❌ 石碑A:記憶を改ざんする呪文
try
{
Dangerous();
}
catch (Exception ex)
{
Log(ex);
throw ex; // ← 沼が記憶を消した理由
}
旅人は石碑を見つめ、静かにつぶやく。
「……throw ex; が StackTrace を消してるんだ。」
沼の表面が揺れ、道の輪郭が少し戻る。
◎ 石碑B:記憶を保つ正しい呪文
try
{
Dangerous();
}
catch
{
Log("error");
throw; // ← 元のStackTraceを保ったまま再スロー
}
旅人:「……やっぱり“元の足跡のまま”進めるのはこっちだな。」
光が走り、沼は固まり、旅人は次の道へと進むことができた。
技術解説
throw ex;で例外を投げ直すと、元の StackTrace が失われ、
“どこで本当に起きたエラーか”という足跡が消えてしまいます。
再スローするならthrow;を使い、発生地点の記録をそのまま残すことが大切です。
例外は原因追跡の生命線──記憶を改ざんすると先へ進めなくなる。
それが、この沼が旅人に伝えたかった教えです。
🕳️ 仕掛け3:ブラックホールの広間
沼を抜けた旅人の前に、深い静寂が落ちていた。
音も気配もなく、光さえ吸い込まれていく広間に足を踏み入れた瞬間──
「……なんだ、この空気……?」
声を出しても、広間に吸い込まれて消えていく。
まるで、何かが“外へ届くことを拒んでいる” ようだった。
やがて闇の奥から三つの石碑が浮かび上がる。
それぞれが、異なる“例外の扱い”を示していた。
❌ 石碑A:完全黙殺の呪文(ブラックホールそのもの)
try
{
Dangerous();
}
catch
{
// ……なにも無かったことにする
}
旅人は叫んだ。
「いや、絶対“何か”起きただろ!?
なんで黙って飲み込むんだよ!」
だが叫びは闇に吸われ、返事はない。
ここでは、起きたことが起きなかったことにされる。
△ 石碑B:記録し、知らせるが、継続する呪文(条件付きで正しい)
try
{
Dangerous();
}
catch (Exception ex)
{
Log(ex);
NotifyUser("一部の処理に失敗しました");
// 状態が保たれるなら、ここで処理を続ける
}
旅人はつぶやいた。
「……見えてはいる。記録も残る。
けど、“進んでいい状況かどうか”を判断しないと危険なんだな。」
ここは “隠さないが、伝えきれてもいない” 場。
継続してよい処理だけが許される、中立の石碑。
◎ 石碑C:光を外へ返す呪文(伝えるべき例外)
try
{
Dangerous();
}
catch (Exception ex)
{
Log(ex);
throw; // 呼び出し側に判断を委ねる
}
旅人は静かに言った。
「……そうか。“本当に危険な異常”は、隠さず外に伝えるべきなんだ。」
その瞬間、闇が揺れ、吸い込まれていた光が戻り始めた。
広間に道が浮かび上がり、旅人はようやく先へ進める。
技術解説
例外が“誰にも伝わらない”状態が最も危険です。
UI 通知やログを残し、継続しても良い場面では石碑Bのように処理できます。
しかし、上位が判断すべき例外は石碑Cのように必ず外へ渡す必要があります。
黙殺はブラックホール──消えていい例外と、消えてはいけない例外を見極めること。
これが、広間の仕掛けが旅人に教えた原則でした。
🔥 仕掛け4:忘れられた河原 ― Dispose の影
🌙 ストーリー:忘れられた湖と、焚火の夜
沼地と断裂した回廊をなんとか抜け、ようやく開けた場所に出た。
そこには静かな湖と、丸く平らな河原が広がっている。
旅人は思わず息をついた。
すでに夕暮れ。
道を急げば足を滑らせる。野営するなら今しかない。
そこで旅人は荷物を下ろし、焚火の準備を始めた。
彼は布袋から、使い慣れた FireStream を取り出した。
手にした瞬間、ふと背筋に寒気が走る。
――火は便利だが、後始末を誤ればすべてを焼き尽くす。
旅人は慎重に、しかし手際よく書いた。
FireStream fire = null;
try
{
fire = new FireStream(FireString); // 火の調達
fire.Ignite(); // 点火
fire.AddFuel(); // 薪をくべる
fire.Burn(); // 夜を照らす
}
catch (FireException ex)
{
Log(ex); // 火の扱いで問題が起きたら記録する
}
finally
{
fire?.Dispose(); // 朝、必ず火の始末をする
}
焚火は暖かく、旅人を包んだ。
夜が深まり、湖面に星が映る。
やがて朝。
旅人は静かに灰を埋め、河原を後にしようと背を向けた。
「使ったものは必ず片付ける――旅の基本だ。」
そう呟いた、その瞬間だった。
足元の砂利がかすかに震え、
昨夜は気づかなかった薄い石板が、朝日を受けて淡く光った。
旅人は思わずかがみ込む。
石板には、焼け焦げた跡が薄く残り、
その中央に見慣れぬ紋章が浮かび上がった。
やがて紋章はゆっくりと動きだし、
まるで炎の軌跡が文字へと変わるように――
ひとつの呪文へと収束していった。
using (var fire = new FireStream(FireString))
{
fire.Ignite();
fire.AddFuel();
fire.Burn();
}
旅人は息をのんだ。
「……火を囲う“結界”のように、範囲を抜ければ自ら静かに消える……
これは、後始末そのものを忘れないための術か。」
石板は最後に軽く明滅し、
まるで「心得よ」と語るように沈黙した。
旅人はコードを胸に刻むように見つめ、立ち上がった。
「なるほど。これなら、どんな旅でも安全に火が使える。」
朝の光が湖面で揺れ、
旅人は新たな呪文を携えて次の仕掛けへと歩き始めた。
🔧 技術解説:try/catch/finally → using の“旅の掟”
旅人が焚火で学んだことは、そのまま C# のリソース管理の基本だ。
🔹 try / catch / finally の役割
- try:使う
- catch:記録する
- finally:必ず片付ける
重要なのは “後始末を必ず行う” こと。
ただし、毎回 finally を書くのはミスの温床になる。
そこへ現れるのが、石板が示した 呪文《using》。
✨ using の効能
using var fire = new FireStream(FireString);
fire.Ignite();
fire.AddFuel();
fire.Burn();
- Dispose が自動で呼ばれる
- 片付け忘れがなくなる
- コードが短く、意図も明確
正しい後処理を“構文”として保証する仕組みだ。
Dispose の忘れ物は、すぐには問題を起こさない。
だが――
- ファイルが開きっぱなし
- コネクションが枯れる
- メモリがじわりと漏れる
そんな影が蓄積し、やがて大きな不具合となる。
旅人が焚火の跡を丁寧に消したように、
コードの世界でも 後始末は静かに、確実に。
Dispose は、片付けであり、守りである。
🪨 仕掛け5:洪水の二段襲来 ― 例外中の例外
焚火の跡を片づけた旅人は、
湖から伸びる古代遺跡の入口へと歩を進めた。
石造りの細い通路に踏み入れた瞬間、
背後から轟音が響き、湖水が遺跡内へ押し寄せた。
旅人は慌てて脇道へ飛び込む。
「危なかった……ここなら……」
だが、安堵は一瞬だった。
避難した通路にも 二次浸水 が始まっている。
本流の圧で壁の隙間から水が染み込み、
逃げ込んだ先すら水に満たされつつある。
旅人は状況を見てつぶやく。
「元の崩落を記録しようとして、
その“記録手段”まで壊れる……
例外中の例外、まさにこれだ。」
そして彼は避難ルートを書き換えた。
try
{
DangerousOperation(); // 洪水その1
}
catch (Exception ex)
{
try
{
LogToFile(ex); // 洪水その2(ログも危険)
}
catch
{
LogToFallback(ex); // 最後の避難ポケット
}
}
水は徐々に引き、静寂が戻る。
旅人は濡れた袖を絞りながら、
遺跡の入口に刻まれた言葉を目にした。
「catch の中で外部リソースを触るなら、
必ずもう一段の逃げ道を設けよ。」
それは、この遺跡が伝え続けてきた戒めだった。
🔧 技術解説
catch の中で行うログ書き込みや通知処理は、
ファイル・DB・ネットワークといった 壊れやすい外部リソース を扱うため、
そこでも再び例外が発生する(二次浸水) 可能性がある。
その瞬間、
- 元の例外
- ログ側の例外
両方が消える最悪の状態 になる。
原因追跡の足跡が途絶え、崩落の全容がわからなくなる。
だからこそ――
catch の中で外部リソースを触れるなら、
さらにもう一段の try/catch(逃げ道)を設けること。
これは保険ではなく、
“記録を失わないための設計者の矜持” である。
ログライブラリが内部で安全に処理しているケースもあるので、プロジェクト内で確認を推奨します。
🧩 仕掛け6:消失の階段 ― async void の落とし穴
洪水の余韻が残る遺跡の奥へ進むと、
旅人の前に 二つの階段 が現れた。
ひとつは上へと続き、
もうひとつは下へ向かって消えている。
どちらも古く、表面には刻印がある。
🔹 下り階段の刻印
async void DoWork() { ... }
旅人は階段の暗がりへ小石を投げ入れた。
──音が、返ってこない。
石は途中でふっと消えたかのようだった。
「……戻らない。
声も、気配も、跡形もなく吸い込まれるのか。」
async void はまさにこれだ。
例外を呼び出し元へ返さず、“消失”させる。
🔹 上り階段の刻印
async Task DoWork() { ... }
今度は上り階段へ石を投げる。
カンッという反響音が返ってきた。
「道が続いている……こちらは伝わる。」
Task は 例外が呼び出し元へ届く“帰り道” を持つ。
旅人が一段踏み出すと、
上り階段は柔らかい光で満ち、次のフロアへ続いた。
🔧 技術解説
async void は 例外が呼び出し元へ届かない特殊な形 であり、
まるで下り階段の途中で石が消えるように、
エラーが“闇に吸い込まれて”行方不明になる。
一方で async Task は、
呼び出し元へ例外を正しく返す 帰り道 を持つ。
つまり、
伝えたい処理は Task で書く。
消えてよい処理だけが void を許される。
async void の使用箇所は イベントハンドラに限られる。
それ以外で使えば、例外は記録されず、気づかれず、
問題は静かに積み重なっていく。
道が途中で断裂する階段を選ばないこと。
“戻り道” を保証できる形で書くこと。
それが、非同期処理における最初の安全策である。
コラム:仕掛けミス ― 巨大 try と外郭未 catch
遺跡の脇部屋には、
巨大な網のような try/catch が張り巡らされていた。
小さな異変も、大きな例外も、
とにかく全部を追い込むための網だ。
旅人は慎重に歩を進め、
案の定、その網に絡め取られた。
――が。
その先には、何もなかった。
出口も、道標も、次の指示もない。
ただ、静まり返った空間だけが残っている。
旅人は周囲を見回し、首をかしげた。
「……なんだこりゃ」
捕まえたのに、行き先がない。
誘導されたはいいが、誰も待っていない。
壁には、いくつかの古い注意書きが残されていた。
「すべてを一つの try に押し込むな」
「例外の違いを、区別せよ」
「catch は終点ではない」
巨大な網は、
“安心しているつもり” になるための仕掛けだったのかもしれない。
だが、
捕まえたあとに何もしないなら、それは仕掛けではない。
旅人は肩をすくめ、
何もない部屋をあとにした。
🕳 仕掛け7:ゼロの裂け目 ― 0除算の扱い
通路を歩いていくと
旅人の前に、静かな石畳の広場が現れた。
ただ、その中央には 一本の巨大な裂け目 が走っている。
裂け目のまわりでは、数字たちが渦を巻くように飛び交っていた。
足元には、見覚えのある刻印。
var r = x / y;
旅人がそっと y の刻まれた石に触れると、
y == 0 の地点で、裂け目が鋭く広がった。
「……ゼロで割った瞬間、世界が裂ける。」
しばらく観察していると、
裂け目の縁に 二つの小さな石のかけら が落ちているのに気づく。
🔹 一つ目の欠片:ライブラリの側
ひとつ目の欠片には、細かな文字が刻まれていた。
「ここは低レベル層。
yは 0 にならない前提で動く。」
旅人がその欠片を、裂け目の 奥側 にそっとはめ込むと、
その部分だけ、石畳がぴたりと固まった。
同時に、裂け目の向こう側の床に コードが浮かび上がる。
// ライブラリ側(前提:y != 0)
public int Divide(int x, int y)
{
// 契約違反は 0除算例外として通知する
return x / y;
}
「……同じ出来事でも、立っている場所が違えば、
例外として扱うべきかどうかも変わるのか」
🔹 二つ目の欠片:アプリケーションの側
もうひとつの欠片には、今度は条件分岐が刻まれていた。
if (y == 0)
{
// 業務上の意味に従って扱う
return 0; // 例:割合 0 として扱う
}
「こっちは……ユーザ入力側だな。
0 は普通にありえる。」
旅人がその欠片を、裂け目の 手前側 にはめ込むと、
こちら側も石畳が固まり、足元に 完成したコード が浮かび上がった。
// アプリケーション側(0 は想定内)
int DivideForView(int x, int y)
{
if (y == 0)
{
// 業務仕様に合わせた扱い
return 0; // または特別なステータス
}
return Divide(x, y); // 契約に従うライブラリ呼び出し
}
裂け目全体は静かに縮み、
代わりに しっかりした石橋 が架かった。
try/catch だけが例外処理ではない。
どの層で何を前提にし、どこで扱うのか――
その “役割分担” こそが例外設計の土台である。
旅人は橋を渡りながら、そうつぶやいた。
🔧📝 技術解説・まとめ(統合)
0除算は、「どこからどこまでを例外とみなすか」 を考える題材として優秀。
-
ライブラリや低レベル層(奥側)
-
y != 0を前提とする。0 が入るのは 契約違反 - 頻繁に呼び出されるので効率化のため内部チェックを省き、「前提違反の通知」とするのもアリ
-
-
アプリケーション層(手前側)
- ユーザ入力や外部データなら、0 は普通に起こる
- ここで例外を投げると、「想定内」の状況でアプリが落ちる
-
if (y == 0)で 制御フロー側に落とし込み、業務仕様に沿った振る舞いを選ぶのが筋
予測できる状態を、なんでも例外にしない。
例外は「ありえないはずの事態」「契約違反」を表すために使う。
二つの欠片がはまった結果、
ライブラリ側とアプリ側のコードがそれぞれ“完成した”ように、
境界の引き方が決まった瞬間に、設計は安定した姿をとる。
例外対処のレイヤー分担はプロジェクトの基準に従ってください。
終章:遺跡の中心 ― 例外処理の黄金律
遺跡の中心には、大きな石碑があった。
そこには旅人が通ってきた仕掛けの本質が刻まれていた。
- 予想できる事態は例外にしない(仕掛け1・7)
- 例外の履歴は改ざんしない(仕掛け2)
- 例外を黙って消さない(仕掛け3・コラム)
- 伝播可能な形式で書く(仕掛け6)
- 後始末は例外時も保証する(仕掛け4)
- 例外処理中の例外は防御する(仕掛け5)
静かに石碑をなでた。
「例外は敵ではない。
ただ、扱いを誤れば“仕掛け”になるだけだ。」
石碑の裏側には、細い通路が続いていた。
外の光が、かすかに差し込んでいる。
旅人はそれを確かめると、迷いなく歩き出した。
歩き出した旅人の背に、静かな声が重なる。
「例外の扱いは、独りで決めるものではない。
この遺跡を出た先では、仲間との約束に従うことになる。」
付録A: ベンチマークサンプル
「仕掛け1:例外の雨 ― ループ内例外の連続発生」で使用したベンチマークのサンプルです。
dotnet new console -n TryCatch
cd .\TryCatch\
で、プロジェクトを作成して、
dotnet run -c Release
で、実行します。
// Program.cs
using System;
using System.Diagnostics;
class Program
{
static void Main()
{
var lines = new string[1_000_00];
RunScenario("error 10%", lines, i => (i % 10 == 0) ? "ABC" : "123");
RunScenario("error 1%", lines, i => (i % 100 == 0) ? "ABC" : "123");
RunScenario("error 0.1%", lines, i => (i % 1000 == 0) ? "ABC" : "123");
}
static void RunScenario(string label, string[] lines, Func<int,string> generator)
{
for (int i = 0; i < lines.Length; i++)
lines[i] = generator(i);
Console.WriteLine($"--- {label} ---");
// ウォームアップ
ParseWithTryCatch(lines);
TryParseVersion(lines);
Measure("Parse + try/catch", () => ParseWithTryCatch(lines));
Measure("TryParse ", () => TryParseVersion(lines));
}
static void ParseWithTryCatch(string[] lines)
{
var sum = 0;
foreach (var text in lines)
{
try
{
var value = int.Parse(text);
sum += value;
}
catch (FormatException)
{
// 何か軽い処理があると想定
}
}
}
static void TryParseVersion(string[] lines)
{
var sum = 0;
foreach (var text in lines)
{
if (!int.TryParse(text, out var value))
{
// 必要ならログなど
continue;
}
sum += value;
}
}
static void Measure(string title, Action action)
{
var sw = Stopwatch.StartNew();
const int N = 5;
for (int i = 0; i < N; i++)
action();
sw.Stop();
Console.WriteLine($"{title}: {sw.ElapsedMilliseconds} ms (x{N})");
}
}
Discussion