⚔️

⚔️ 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
// 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