0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

R09 【掟・判例】NullReferenceExceptionを止める ── null 許容参照型(有効化) / string? と ! / Guardで最初に決める

Posted at

連載Index(読む順・公開済(リンク)はここが最新): S00_門前の誓い_総合Index

落ちた理由が NullReferenceException だった。
ログを見ると「どこでnullが入り込んだか」が分からない。
しかも、例外が表に出た場所は、nullが入り込んだ起点(設定取得/IO受信/画面入力/外部API受領/DI注入など)とは限らないことが多い。

このページは、起点の近くで止めるために「警告(有効化)」「型(string?)」「境目(Guard)」を先に揃える。


1. このページで手に入るもの(最短)

狙い: スクロールの価値を最初に確定させ、どこから読んでも迷子にならない状態へ寄せる。

  • null 許容参照型(Nullable Reference Types)を有効化し、「入り込み得る経路」を警告で見える化する
  • 「外から入る」直後で止めるGuard(境目)を用意し、null(と空/空白)をそこで止める
  • 内部は string / 非nullableで揃え、null判定を散らさない
  • 最短テンプレ(コピペ): 設定取得直後で必須 string を止める Require(コメント込み)
  • !(null許諾)を置ける条件 / 置けない条件の線引きと、警告消しを増殖させないルール
  • ?. / ?? / ??= の定義と、nullable連鎖を増やさない使い分け

2. 先に逆引き(症状→原因→対策)

狙い: 症状から最短で“見る場所”へ飛び、切り分けの距離を短くする。

症状 ありがちな原因 見る場所 最短の対策 再発防止(ルール化)
本番だけ落ちる 設定値が欠ける/空 設定の解決順と取得箇所 設定取得直後でGuard 設定取得を共通化
操作により落ちる UI値がnull/型違い UI値取得直後 UI値取得直後でGuard UI入力の扱いを統一
たまに落ちる LINQ結果が空 FirstOrDefault周り ?? throwで扱いを決める 空の扱いを例外/Tryへ統一
警告は消えたが落ちる !で押し切った !の出現箇所 !撤去、根拠をコード化 !の利用条件を決める
“nullかも”が連鎖する nullableが内部まで残る 引数/戻り値の注釈 境目でnon-nullへ寄せる 契約(string/string?)を揃える

3. 最短テンプレ(コピペ)

狙い: 境目(設定取得直後)で止める形を先に置き、後段のnull判定を増やさない。

結論: 必須値は取得直後で止め、以降は string 前提へ寄せる。

// 目的: 外から入る値(設定/入力/IO等)を受けた直後で必須条件を確定させる
// 境目: 設定取得直後 / UI値取得直後 / IO直後 / 生成直後
public static string Require(string? value, string name)
    => !string.IsNullOrWhiteSpace(value)
        ? value
        : throw new InvalidOperationException($"{name} is missing.");

// 使い方: 設定取得直後で止め、以降は string 前提で進める
var apiKey = Require(config["ApiKey"], "ApiKey");

4. null 許容参照型の位置付け(ここを誤解しない)

狙い: string? が何を意味するかを先に揃え、契約(必須/任意)が曖昧にならない状態へ寄せる。

結論: null 許容参照型は実行時にnullを禁止する仕組みではなく、コンパイラが「nullになり得る経路」を警告として出す仕組み

4-1. stringstring? の意味

狙い: 必須/任意を型で表し、止める責務が散らない状態へ寄せる。

  • string : nullを許さない契約
  • string? : nullの可能性を契約として認める

契約が揃うと、どこで止めるか(責務)が散りにくい。

4-2. int?string? の違い

狙い: 同じ ? に見えて別物である点を先に揃え、誤解を減らす。

  • int? : Nullable<int>(値型のnull許可)
  • string? : 参照型のnull可能性を注釈で表す(null 許容参照型)

同じ ? でも意味が違う。


5. 有効化(プロジェクト設定)

狙い: 「入り込み得る経路」を警告として先に見える化し、混入点の近くで止める材料を増やす。

結論: この設定は実行時の挙動を変えるものではない。代わりに、型注釈(string? 等)と警告で「どこからnullが入り得るか」を見える形へ寄せる。

何が良くなるか(現場で効く順):

  • string 前提の場所へ string? が混ざる経路が警告で浮く
  • ! で押し切って警告だけ消す形が見つかりやすい
  • “どこで止めるか”を設定取得直後/IO直後/UI値取得直後へ寄せやすい

5-1. csproj

狙い: まず enable で警告を出し、混入点を可視化する。

<PropertyGroup>
  <Nullable>enable</Nullable>
</PropertyGroup>

段階的に強くする例:

<PropertyGroup>
  <Nullable>enable</Nullable>
  <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>

補足:

  • 既存資産が多い場合、まず enable で警告を可視化し、増え続けない運用へ寄せるのが現実的

6. Guardの置き場(止める場所が前に寄るほど短くなる)

狙い: 例外が表に出る場所の揺れを減らし、切り分けを短くするための“境目”を先に列挙する。

結論: nullが入り込みやすい混入点は限られる。境目が前に寄るほど、切り分けが短くなる。

nullが入り込みやすいのは、だいたいここ。

  • 設定/環境変数(取得直後)
  • DB/ファイル/ネットワーク(IO直後)
  • UIイベント(UI値取得直後)
  • 外部APIレスポンス(受信直後)
  • DI注入(生成直後)

止める場所が前に寄るほど、切り分けが短くなる。
逆に、内部ロジックへnullが入り込むほど、null判定が散って仕様が濁る。


7. Guardの型(最小テンプレ)

狙い: Guardを「構文」ではなく「境目で契約を決める小さな関数群」として定義し、貼る場所と効き所が迷子にならない状態へ寄せる。

結論: Guardは予約語でも特別な構文でもない。境目で必須条件を判定して止めるための最小ユーティリティ(所作+型)を指す。

GuardはC#の予約語でも特別な構文でもない。
このページでのGuardは、設定/IO/UI/DI/外部API受領など “外から入る値” を受け取った直後 に、必須条件を判定して止めるための 最小ユーティリティ(所作+型) を指す。

Guardが担う役割は次の3つ。

  • 境目で必須条件を判定する(null/空/空白など)
  • 条件を満たさない場合、その場で例外(またはTry)として止める(落ち方を「境目」に寄せる)
  • 条件を満たす場合、以降を string など non-null 前提で進められる形に寄せる(null判定を後段へ持ち込まない)

テンプレは「貼って終わり」にするため、用途コメント込みで置く。

7-1. 最小:必須stringを止める(空/空白も含む)

狙い: string? を境目で止めて string に寄せ、後段のnull判定を増やさない。

public static class Guard
{
    // 必須文字列: null/空/空白なら止める(設定値・入力値の必須チェック用)
    public static string NotBlank(string? value, string name)
        => !string.IsNullOrWhiteSpace(value)
            ? value
            : throw new InvalidOperationException($"{name} is missing.");
}

// 使い方: 境目(設定取得直後)で止め、以降は string 前提で進める
var apiKey = Guard.NotBlank(config["ApiKey"], "ApiKey");

ポイント:

  • nullだけでなく「空/空白」も同じ扱いに寄せると、後段の条件が減ることが多い
  • 例外種別は運用に合わせる(入力なら ArgumentException、設定なら InvalidOperationException が馴染みやすい)

7-2. .NET 6+(.NET 8含む):引数nullは即止める

狙い: 引数境界で止め、以降の処理をnon-null前提へ寄せる。

public sealed class UserService
{
    public void UpdateName(string userId, string name)
    {
        // 参照型引数がnullなら止める(.NET 6+ の定番)
        ArgumentNullException.ThrowIfNull(userId);
        ArgumentNullException.ThrowIfNull(name);

        // 追加の契約(空/空白など)も、ここでまとめて決める
        if (userId.Length == 0) throw new ArgumentException("userId is empty.", nameof(userId));
        if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("name is blank.", nameof(name));

        // ここから先は non-null 前提(nullチェックを撒かない)
    }
}

7-3. .NET Framework 4.8:同じ考え方を手書きで寄せる

狙い: ThrowIfNull が無い環境でも“境目で止める”を同じ型に寄せる。

public sealed class UserService
{
    public void UpdateName(string userId, string name)
    {
        // 参照型引数がnullなら止める(ThrowIfNull が無い環境の書き方)
        if (userId is null) throw new ArgumentNullException(nameof(userId));
        if (name is null) throw new ArgumentNullException(nameof(name));

        // 追加の契約(空/空白など)も、ここでまとめて決める
        if (userId.Length == 0) throw new ArgumentException("userId is empty.", nameof(userId));
        if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("name is blank.", nameof(name));
    }
}

8. !(null許諾)の扱い

狙い: 警告消しのための ! を増殖させず、根拠をコードへ寄せる。

結論: ! は「ここはnullにならない」と言い張る道具。根拠がない場所で使うと、警告は消えるが原因は残る。

悪い例:

var apiKey = config["ApiKey"]!;

直す(根拠をコード化する):

var apiKey = config["ApiKey"];
if (string.IsNullOrWhiteSpace(apiKey))
    throw new InvalidOperationException("ApiKey is missing.");

! を使うなら最低条件を決める。

  • 根拠がコードで担保されている(直前でGuard済み等)
  • その行が「契約の境目」になっている(散らさない)
  • 警告消しのために増殖させない

9. 禁書庫A(早見):契約パターンの選び方

狙い: 「nullになり得る」に遭遇した瞬間に、返し方を例外/Try/nullable/既定値へ寄せる。

結論: 「例外」「Try」「nullable」「既定値」のどれかに寄せると、全体の読み筋が立つ。

「nullになり得る」状態に遭遇したとき、どう返すかを先に決める。

状況 契約(推奨) 返し方 メリット 注意点
必須が欠けた 例外 ArgumentException / InvalidOperationException 失敗が明確 メッセージに根拠を残す
見つからないが起き得る Try bool TryGetXxx(..., out T value) 分岐が揃う outdefaultを入れる
任意値が仕様 nullable T? 型で表せる 呼び出し側へ責務が移る
既定値が仕様 既定値 ?? defaultValue 分岐が減る 意味が崩れやすい

混ざると、nullが流れ続けて後段の判定が増える。


10. 判例:混入経路が多い順に潰す(設定/DI/LINQ/as/Dictionary/UI/イベント)

狙い: 混入点の近くで止める形へ寄せ、例外が表に出る場所の揺れを抑える。

10-1. 設定取得直後:欠けている/空

狙い: 設定の欠けを取得直後で止めると、後段での例外位置が揺れにくい。

悪い例:

var apiKey = config["ApiKey"];
Call(apiKey);

直す:

var apiKey = Guard.NotBlank(config["ApiKey"], "ApiKey");
Call(apiKey);

ポイント:

  • 止める場所は「設定取得直後」で揃える
  • 必須は ?? "" 等で曖昧にせず例外へ寄せる
  • 例外メッセージは “何が欠けたか” を残す

10-2. 生成直後(DI含む):必須依存がnull

狙い: 生成直後で契約を決め、以降は non-null 前提へ寄せて扱いを揃える。

悪い例:

public sealed class Worker
{
    private readonly ILogger _log;

    public Worker(ILogger log)
    {
        _log = log;
    }
}

直す:

public sealed class Worker
{
    private readonly ILogger _log;

    public Worker(ILogger log)
    {
        ArgumentNullException.ThrowIfNull(log); // 依存は生成直後で止める
        _log = log;
    }
}

ポイント:

  • DIの必須依存は生成直後で止める
  • “後で落ちる”状態を作らず、境目で例外へ寄せる
  • 以降はnon-null前提(null判定を散らさない)

10-3. LINQ:FirstOrDefault が空を返す

狙い: LINQの「空」は静かに流れやすいので、見つからない時の扱いをその場で確定させる。

悪い例:

var user = users.FirstOrDefault((x) => x.Id == id);
return user.Name;

直す(空の扱いを決める):

var user = users.FirstOrDefault((x) => x.Id == id)
    ?? throw new InvalidOperationException($"User not found. id={id}");

return user.Name;

ポイント:

  • FirstOrDefault の空は例外/Try/既定値のどれかへ寄せる
  • ?? throw は“境目で止める”意図を短く書ける
  • 後段で user? を連鎖させない

10-4. as キャスト:静かにnullになる

狙い: as の失敗はnullとして流れるので、型が要件ならその場で止める。

悪い例:

var view = obj as IView;
view.Show();

直す:

if (obj is not IView view)
    throw new InvalidOperationException("IView is required.");

view.Show();

ポイント:

  • as は失敗しても例外にならずnullとして流れる
  • 型が必須なら is not で境目を作り例外へ寄せる
  • nullを後段へ持ち込まず、切り分けを短くする

10-5. Dictionary:添字アクセス

狙い: 「無い」を例外で落とすなら、メッセージを厚くして原因が追える形へ寄せる。

悪い例:

var v = map[key];

直す:

if (!map.TryGetValue(key, out var v))
    throw new KeyNotFoundException($"key not found: {key}");

ポイント:

  • 添字アクセスは“無い”が例外で表に出るが情報が薄くなりがち
  • TryGetValue で分岐を揃え、必要なら例外メッセージを厚くする
  • 返し方は Try/例外のどちらかへ寄せる

10-6. UI値取得直後(WinForms):Value がnull

狙い: UIは未選択/型違い/nullが混ざりやすいので、取得直後で止めて業務側へ持ち込まない。

悪い例:

var text = dataGridView1.CurrentCell.Value.ToString();

直す(UI値取得直後で止める):

var cell = dataGridView1.CurrentCell;
if (cell is null) return;

var value = cell.Value;
if (value is null) return;

var text = value.ToString();

ポイント:

  • UIは“未選択/未入力”が混ざる前提で境目を作る
  • CurrentCellValue の2段で止める
  • 業務側へ null を持ち込まない

10-7. イベント:sender が想定外

狙い: sender は想定型で来ない経路があるので、型チェックで境目を作る。

悪い例:

void OnClick(object sender, EventArgs e)
{
    var btn = (Button)sender;
    btn.Text = "x";
}

直す:

void OnClick(object? sender, EventArgs e)
{
    if (sender is not Button btn) return;
    btn.Text = "x";
}

ポイント:

  • sender は想定型で来る保証が弱い
  • object? と型チェックで境目を作る
  • キャスト例外/NullReferenceExceptionの混入点を早める

11. ?.?? の使い所

狙い: nullを「通す/止める/置き換える」を短く書きつつ、nullable連鎖を増やさない。

?.?? は、null を「通す/止める/置き換える」を短く書くための演算子。
ただし便利さの反面、型が nullable 側へ寄るので、雑に使うと後段へ ? が連鎖しやすい。

11-1. ?.(null 条件演算子)は何か

狙い: 左がnullのときに評価を止め、結果をnullへ寄せる仕組みを先に揃える。

?. は「左が null なら、右側(プロパティ/メソッド呼び出し/添字)を評価せず、結果は null にする」。

  • obj?.Prop : obj が null なら null、そうでなければ obj.Prop
  • obj?.Method() : obj が null なら呼ばない(副作用も起きない)
  • obj?[i] : 添字版(indexer)

例(参照型):

string? name = user?.Name; // user が null なら name は null

例(値型へ変換される):

int? len = user?.Name?.Length; // Length は int だが、途中に null があるので int? になる

注意:

  • ?. を挟むと、結果が T?(nullable)側へ寄る
    string ではなく string?int ではなく int? が増えやすい

11-2. ??(null 合体演算子)は何か

狙い: 既定値へ置き換える構文を先に揃え、意図が曖昧にならない状態へ寄せる。

?? は「左が null なら右を採用する」。

  • left ?? right : left が null でなければ left、null なら right
  • 右側は 必要なときだけ評価される(遅延評価)
string name = user?.Name ?? "(unknown)";   // Name が null のときだけ "(unknown)"
int len = user?.Name?.Length ?? 0;         // int? を int に戻す

11-3. ??=(null 合体代入)は何か

狙い: null のときだけ代入する構文を先に揃え、初期化の意図を短くする。

x ??= y は「x が null のときだけ y を代入する」。

_cache ??= new Dictionary<string, string>();

11-4. 使い所(使うと整理されるパターン)

狙い: “仕様として通す/置き換える/止める”のどれかへ寄せ、nullable連鎖を増やさない。

(A) 任意値として null を通すのが仕様
例: 表示項目が無いなら空欄で良い、ログの追加情報が無いなら省略で良い、など。

var detail = user?.Profile?.Detail; // 無いなら null のまま

(B) 既定値が仕様(null を “意味のある値” へ置き換える)
例: 無いなら 0、無いなら false、無いなら "(unknown)"。

var displayName = user?.Name ?? "(unknown)";
var timeoutMs = settings?.TimeoutMs ?? 10_000;

(C) イベント呼び出し(購読が無いなら何もしない)
これは ?.Invoke が読みやすい。

Updated?.Invoke(this, EventArgs.Empty);

(D) 「無いなら止める」を式で書く?? throw
境目で止める意図が明確になる。

var user = users.FirstOrDefault((x) => x.Id == id)
    ?? throw new InvalidOperationException($"User not found. id={id}");

11-5. 使い所ではない(ここは Guard へ寄せる)

狙い: 必須値の欠けを隠さず、取得直後で止める形へ寄せる。

必須値?. / ?? で曖昧にすると、欠けた事実が隠れて後段で詰まりやすい。
必須は「入ってきた直後」で止める(Guard)に寄せる。

// 必須のつもりで ?? "" に寄せると、欠けた事実が消える(空文字が混ざる)
var apiKey1 = config["ApiKey"] ?? "";

// 必須は取得直後で止める(欠けている事実を残す)
var apiKey2 = Guard.NotBlank(config["ApiKey"], "ApiKey");

12. チェックリスト(レビューで見る所)

狙い: 見落としやすい混入点を短い観点に落とし、指摘の揺れを減らす。

  • <Nullable>enable</Nullable> が入っている
  • string / string? が契約として一貫している
  • 設定取得直後/IO直後/UI値取得直後でGuardしている
  • ! が散っていない(根拠が読める一点へ寄っている)
  • FirstOrDefault / as / Dictionary添字 / UI値の扱いが揃っている
  • null判定が内部ロジックへ拡散していない

12-1. 最短チェック(持ち帰り用)

狙い: 有効化 / 契約 / 境目 / ! / 空の扱い / UIを揃える。

観点 確認ポイント よくあるズレ 見る場所
有効化 <Nullable>enable</Nullable> が入っている そもそも警告が出ない csproj
契約 string / string? が一貫している 入出力で混在 引数/戻り値/フィールド
境目 設定取得直後/IO直後/UI値取得直後でGuard 深い場所で突然落ちる 設定/IO/UIイベント
! 根拠が読める一点へ寄っている 警告消しとして散る ! の出現箇所
空の扱い FirstOrDefault 等の空を例外/Try/既定値へ寄せる 放置してnullが流れる LINQ/コレクション
UI Value のnull/型違いを境目で止める 業務側へ持ち込む WinForms
拡散 null判定が内部へ広がっていない 条件が増えて仕様が濁る 内部ロジック

12-2. 判例表(OK/NG)

狙い: 揃えるべき差分を表にし、判断を短くする。

観点 OK例 NG例 理由(壊れ方) レビューで見る所
契約の型 必須はstring、任意はstring? 何でもstringで受ける 扱いの責務が散る 引数/戻り値の注釈
Guardの位置 入力直後で例外/Tryへ寄せる 深い場所で突然落ちる 切り分けが長引く 設定/IO/UI直後
!の置き場 根拠がある一点へ寄せる 警告消しとして多用 原因が隠れる !の出現箇所
空の扱い 空を例外/Tryで扱う FirstOrDefault放置 nullが後段へ流れる LINQ結果の扱い

12-3. レビュー観点

狙い: 方向性だけを揃え、手戻りを短くする。

観点 ありがちな見落とし 壊れ方 指摘コメント例(直球禁止)
注釈の整合 string/string?が混在 扱いの責務が揺れる 入出力の注釈が揃っているか確認したい
Guard配置 Guardが深い位置にある 切り分けが長引く 入力直後で止めた後はnon-null前提へ寄せたい
! !が増える 原因が隠れる !の根拠がコードから読める形へ寄せたい
LINQ 空の扱いが曖昧 nullが流れ込む 空の扱いを例外/Tryで揃えたい
UI値 Valueのnullを見落とす 再現が揺れる UI値取得直後で止める形へ寄せたい

13. セルフチェック(5問)

狙い: その場で判定できる問いにして、型/境目/演算子の混在を減らす。

  1. その string? は「任意」か「境目で止め忘れ」か
  2. その ! は「根拠」か「警告消し」か
  3. FirstOrDefault の「空」は例外/Try/既定値のどれへ寄せるか
  4. as の失敗を null で流す設計にしていないか
  5. ?. / ?? で必須値を曖昧にしていないか(欠けた事実が残るか)

関連トピック

狙い: 起点になるIndexと、周辺ルールを必要な順に並べる。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?