C#のMemberNotNullWhenとCS8618を完全解説:抑制なしで非null保証を実現する5つの実装パターン

非 null 参照型とコンストラクターの「確定代入」は、実装を少し変えるだけで警告の出方が大きく変わります。特に [MemberNotNullWhen] と CS8618(「非 null フィールドがコンストラクター終了時に初期化されていない」)の組み合わせはつまずきやすいポイント。この記事では、なぜ噛み合わないのかを言語仕様の文脈で噛み砕き、抑制なしで警告を解消する実装パターンを、サンプルコードと比較表で徹底解説します。

目次

問題の背景と最小再現

非 null 参照型フィールドをコンストラクターで直接代入せず、別メソッドに委譲したときに CS8618 が出る、という相談はよくあります。例えば次のようなコードです。

// Nullable コンテキストは有効(<Nullable>enable</Nullable>)
public sealed class Example
{
    private IServiceProvider _serviceProvider; // 非 null 参照型

    public Example(IServiceProvider? serviceProvider)
    {
        // 直接代入せずメソッドに委譲
        SetServiceProvider(serviceProvider);
        // ↑ CS8618: 非 null フィールド '_serviceProvider' が
        //   コンストラクターの終了時に非 null であることをコンパイラが証明できない
    }

    [MemberNotNullWhen(true, nameof(_serviceProvider))]
    private bool SetServiceProvider(IServiceProvider? serviceProvider)
    {
        _serviceProvider = serviceProvider!;
        return serviceProvider is not null;
    }
}

serviceProvider != null のときにフィールドが初期化されることを [MemberNotNullWhen(true, ...)] で伝えているのに、なぜ警告が消えないのでしょうか?

CS8618 と注釈属性の役割を正しく分解する

  • CS8618 は「コンストラクター終了時点」での確定代入(definite assignment)の証明を求めます。コンストラクターの全ての正常終了パスで、非 null フィールドに値があることが必要です。
  • [MemberNotNullWhen] は「メソッドの戻り値が特定の条件を満たした後」の世界でのみ効くポストコンディションです。すなわち、呼び出し側がその条件を満たすことを自ら確認した場合に限り、その先のコードで指定フィールドを非 null と扱えるようになります。

したがって、コンストラクター側で戻り値 true を保証しない(チェックしない)まま終了しようとすると、コンパイラは「もし false で戻ってきたら?」という経路を排除できず、CS8618 を出し続けます。

結論の要点(最短解)

  • 最も単純で確実なのは、コンストラクターで直接代入して例外で守ること。
  • 委譲メソッドを使いたいなら、[MemberNotNull] を使って void メソッドで代入し、コンストラクターから無条件に呼ぶか、戻り値 bool をチェックして失敗時に例外を投げ、正常終了パスを true のみへ絞り込む。
  • C# 11 以降なら required メンバー(多くは プロパティで利用)も選択肢。呼び出し元に初期化義務を負わせられる。
  • どうしても初期化時点が不明なら、フィールド自体を nullable にし、使用直前でガードする設計に転換する。

解決策の比較表

解決策実施内容CS8618 解消メリット留意点
コンストラクターで直接代入_serviceProvider = serviceProvider ?? throw new ArgumentNullException(...)◎最短・明快。コンパイラが即証明可能。ロジックをメソッドに分離したい場合は冗長に感じることがある。
void + [MemberNotNull]代入を行うヘルパーを void で用意し、[MemberNotNull(nameof(_serviceProvider))] を付け、コンストラクターで無条件に呼ぶ◎委譲しつつ確定代入を伝えられる。必ず呼ばれること(全パス)を保つ。readonly フィールドには使えない(後述)。
bool + [MemberNotNullWhen(true,...)]if (!TrySet(...)) throw ...; と戻り値を検証して正常終了パスを true のみに限定◎Try パターンと相性が良い。戻り値チェック必須。チェックしないと CS8618 は残る。
required メンバー(C# 11+)public required IServiceProvider ServiceProvider { get; init; } など、プロパティで初期化を強制◎呼び出し元の責務として初期化を保証。レコードやオブジェクト初期化子と合う。多くの場合 フィールドではなくプロパティで使う。DI ではコンストラクター注入が主流。
フィールドを nullable にするIServiceProvider? _serviceProvider; とし、使用直前で例外やガードを入れる◎(警告対象が変わる)段階的移行に向く。ライフサイクルに柔軟。実行時チェックが必須。設計意図を明文化すること。
抑制(!/#pragma)最終手段として警告を局所的に無効化—既存 DTO/ORM などで型の自由度がないときの逃げ道。安全性は担保しない。理由と範囲を最小化。

実装レシピ:最短パス(推奨)

public sealed class Example
{
    private readonly IServiceProvider _serviceProvider;


public Example(IServiceProvider serviceProvider)
{
    _serviceProvider = serviceProvider 
        ?? throw new ArgumentNullException(nameof(serviceProvider));
}


} 

これが最も単純で、readonly も活かせます。フィールドの生涯不変(再代入不可)を保証でき、設計上の意図が明確です。

委譲したい派:[MemberNotNull] で void ヘルパー

「代入ロジックはまとめたい」「検証と代入を一箇所に置きたい」場合は次が素直です。

public sealed class Example
{
    private IServiceProvider _serviceProvider; // readonly にはできない(注)


public Example(IServiceProvider serviceProvider)
{
    InitializeServiceProvider(serviceProvider); // 無条件で呼ぶ
}

[MemberNotNull(nameof(_serviceProvider))]
private void InitializeServiceProvider(IServiceProvider serviceProvider)
{
    ArgumentNullException.ThrowIfNull(serviceProvider);
    _serviceProvider = serviceProvider;
}


} 

重要:readonly フィールドは「コンストラクターの本体かフィールド初期化子」でしか代入できません。別メソッドでの代入は、たとえコンストラクターから呼ばれていてもコンパイラが禁止します。従ってこのパターンでは readonly を外すか、「直接代入」パターンに切り替えましょう。

Try パターンで進めたい派:[MemberNotNullWhen(true,...)] + 例外化

戻り値で成功/失敗を表現したい場合は、呼び出し側で戻り値をチェックし、失敗時に例外を投げることで正常終了パスを true に絞り、CS8618 を解消します。

public sealed class Example
{
    private IServiceProvider _serviceProvider;


public Example(IServiceProvider? serviceProvider)
{
    if (!TrySetServiceProvider(serviceProvider))
        throw new ArgumentNullException(nameof(serviceProvider));
    // この時点で、正常経路は Try が true のみ。_serviceProvider は非 null と解釈される
}

[MemberNotNullWhen(true, nameof(_serviceProvider))]
private bool TrySetServiceProvider(IServiceProvider? serviceProvider)
{
    if (serviceProvider is null) return false;
    _serviceProvider = serviceProvider;
    return true;
}


} 

よくある誤用:return: [NotNullIfNotNull(nameof(serviceProvider))] を bool 戻り値に付けても意味がありません。これは「参照型の戻り値」に対する注釈で、bool には効果がありません。

C# 11 の選択肢:required メンバーで「呼び出し元に責務を移す」

required は「オブジェクト生成時に必ず値を設定しなければならない」ことを言語が保証する仕組みです。多くのケースでは プロパティに適用します。

public sealed class Example
{
    public required IServiceProvider ServiceProvider { get; init; }
}
// 生成側
var ex = new Example { ServiceProvider = sp };  // OK
var bad = new Example();                         // コンパイル時エラー(必須未設定)

コンストラクター内で必須メンバーを満たすなら、[SetsRequiredMembers] を付けて「このコンストラクターを通れば全部満たす」ことをコンパイラへ伝えられます。

public sealed class Example
{
    public required IServiceProvider ServiceProvider { get; init; }


[SetsRequiredMembers]
public Example(IServiceProvider sp)
{
    ServiceProvider = sp; // ここで必須を満たす
}


} 

DI(依存性注入)で「コンストラクター注入」が前提のプロジェクトでは、required より「コンストラクターで直接代入」のほうが自然です。プロパティ注入を採用している場合は required が生きます。

あえて nullable にする設計:使用直前ガード

初期化のタイミングが生成時に確定しない(遅延初期化やフレームワークのライフサイクル)場合は、フィールドを nullable にし、使う瞬間に強い検証を入れます。

public sealed class Example
{
    private IServiceProvider? _serviceProvider;


public void Configure(IServiceProvider serviceProvider)
{
    ArgumentNullException.ThrowIfNull(serviceProvider);
    _serviceProvider = serviceProvider;
}

private IServiceProvider ServiceProvider =>
    _serviceProvider ?? throw new InvalidOperationException("ServiceProvider が未設定です。");

public object Resolve(Type serviceType) => ServiceProvider.GetService(serviceType)!;


} 

責務は「利用箇所の直前」に集約され、CS8618 の対象から外れます。

なぜ [MemberNotNullWhen] だけでは足りないのか

ポイントは「戻り値が true だった場合のみ」という条件付きであることです。コンパイラは次の 2 点を証明できません。

  1. コンストラクターでそのメソッドが必ず呼ばれるか。
  2. その戻り値が必ずtrue になるか(= 正常終了経路が true であること)。

したがって、コンストラクターの末尾で「全経路が true を通った」ことが示されない限り、_serviceProvider を非 null と断定できず、CS8618 が残ります。逆に、if (!Try...) throw ...; の形にすると、コンストラクターが正常終了できる経路は「戻り値が true のときだけ」に絞られるため、コンパイラはフィールドの非 null を確定できます。

アンチパターンと落とし穴

  • 戻り値が bool なのに [NotNullIfNotNull] を付ける:意味を持ちません。戻り値が参照型のときだけ有効です。
  • readonly フィールドをメソッドで代入:コンストラクターの外側(別メソッドの本体)は「代入可能な例外扱い」になりません。直接代入か、readonly を外す必要があります。
  • 仮に ! で抑制:短期的には消えますが、安全性は担保されません。抑制が必要な理由と範囲をコメントで説明し、将来の負債を可視化しましょう。
  • 引数の型が曖昧:本当に null を許すのか? 許さないのであれば、IServiceProvider(非 null)を受け取り、ThrowIfNull で守るほうが伝わります。

属性の使い分け早見表

属性対象効果典型用途
[MemberNotNull]メソッド/プロパティ呼び出し後、指定メンバーが非 null とみなされるvoid 初期化メソッドでの代入を伝える
[MemberNotNullWhen(bool,...)]メソッド/プロパティ戻り値が指定条件のときに限り、メンバーが非 nullTry パターン(成功時のみ非 null)
[NotNullWhen(bool)]引数(out/ref)戻り値条件付きで、out 引数が非 nullbool TryParse(string s, out T value) など
[NotNullIfNotNull(nameof(param))]戻り値指定パラメーターが非 null なら戻り値も非 null参照型のファクトリー/ラッパー
requiredプロパティ/フィールド生成時に必ず設定が必要設定忘れ防止、DTO/レコードとの相性が良い

安全性と拡張性を両立する設計指針

  • コンストラクターは「不変条件を作る場所」:終了時にクラス不変が保たれるようにする。CS8618 はその指標。
  • 外部入力の境界で強く検証:ArgumentNullException.ThrowIfNull を活用し、境界を越えたら null を許さない。
  • 責務の分離:初期化ロジックをヘルパーに分けるのはOK。ただし void + [MemberNotNull] か、Try + 例外化で「正常経路の絞り込み」を明示。
  • 仮に遅延初期化が必要なら:nullable + アクセサーガードで「使うまでに必ずチェック」を徹底。
  • 仮想呼び出しを避ける:コンストラクターから virtual メソッドを呼ばない(未初期化状態でのオーバーライド実行を避ける)。

ケーススタディ:DI(依存性注入)と組み合わせる

コンストラクター注入(推奨)

public sealed class ServiceConsumer
{
    private readonly IServiceProvider _sp;


public ServiceConsumer(IServiceProvider sp)
{
    _sp = sp ?? throw new ArgumentNullException(nameof(sp));
}

public T Get<T>() => (T)_sp.GetService(typeof(T))!;


} 

プロパティ注入 + required

public sealed class ServiceConsumer
{
    public required IServiceProvider ServiceProvider { get; init; }


public T Get<T>() => (T)ServiceProvider.GetService(typeof(T))!;


}
// 生成(コンテナ/ビルダー側)
var consumer = new ServiceConsumer { ServiceProvider = rootProvider }; 

フレームワークがプロパティ注入をサポートしている場合に有効です。

実際の「警告の消し方」コード一式

パターン A:void + [MemberNotNull]

public sealed class A
{
    private IServiceProvider _serviceProvider;


public A(IServiceProvider sp)
{
    Initialize(sp); // 無条件で呼ぶので CS8618 は出ない
}

[MemberNotNull(nameof(_serviceProvider))]
private void Initialize(IServiceProvider sp)
{
    ArgumentNullException.ThrowIfNull(sp);
    _serviceProvider = sp;
}


} 

パターン B:Try + [MemberNotNullWhen(true,...)] + 例外化

public sealed class B
{
    private IServiceProvider _serviceProvider;


public B(IServiceProvider? sp)
{
    if (!TrySet(sp))
        throw new ArgumentNullException(nameof(sp));
}

[MemberNotNullWhen(true, nameof(_serviceProvider))]
private bool TrySet(IServiceProvider? sp)
{
    if (sp is null) return false;
    _serviceProvider = sp;
    return true;
}


} 

パターン C:required プロパティ

public sealed class C
{
    public required IServiceProvider ServiceProvider { get; init; }
}
// 生成側:必ず設定が必要
var c = new C { ServiceProvider = sp };

パターン D:nullable + 使用時ガード

public sealed class D
{
    private IServiceProvider? _sp;


public void Configure(IServiceProvider sp) =>
    _sp = sp ?? throw new ArgumentNullException(nameof(sp));

private IServiceProvider SP => _sp ?? 
    throw new InvalidOperationException("未初期化のまま使用されました。");

public T Get<T>() => (T)SP.GetService(typeof(T))!;


} 

意思決定フロー(どれを選ぶ?)

  • 生成時に必ず値がある → 直接代入(readonly が使え、最も安全)。
  • ロジックを分けたいが生成時に必ず値がある → void + [MemberNotNull] を無条件呼び出し。
  • 生成時に null の可能性があり、Try で表現したい → [MemberNotNullWhen(true,...)] + 例外化で正常経路を true のみに。
  • 呼び出し側で設定させたい → required プロパティ。
  • 初期化タイミングが後 → nullable + 使用直前ガード。

FAQ(よくある質問)

Q. [MemberNotNullWhen] を付けたのに CS8618 が消えません。

A. コンストラクターの正常終了パスが true であることをコンパイラが確認できていません。if (!TryXxx(...)) throw ...; の形にし、正常終了できるのは true のときだけにしてください。もしくは [MemberNotNull] の void メソッドを無条件呼び出しに切り替えます。

Q. return: [NotNullIfNotNull] を付けるのは有効ですか?(戻り値が bool)

A. 無効です。これは「戻り値が参照型」のときだけ効きます。bool は値型で null になりません。

Q. readonly フィールドにしたいのですが、メソッドで代入できますか?

A. できません。readonly は「フィールド初期化子」か「同じ型のインスタンス・コンストラクターの本体」でのみ代入可能です。別メソッドでの代入はコンパイル エラーになります。readonly にしたいなら「コンストラクターで直接代入」パターンにしてください。

Q. required を private フィールドに付ければ解決しますか?

A. 一般にはお勧めしません。required は主に公開(または初期化子で設定可能な)プロパティで使う設計です。private フィールドに付けると、結局コンストラクターでの代入義務(別の警告)に置き換わるだけで、CS8618 の本質的な解決にはなりません。

Q. 抑制(! や #pragma)は完全に悪ですか?

A. いいえ。外部フレームワークで生成される値が必ず入るが、コンパイラが追跡できないケース(例:ORM マッピング)では局所的な抑制が pragmatic です。ただし、理由をコメントし、スコープを必要最小限に保ちましょう。

まとめ

[MemberNotNullWhen] は「条件付きの後置保証」であり、CS8618 が求める「コンストラクター終了時点の確定代入」とはゴールが違います。抑制に頼らなくても、

  • 直接代入(最短・readonly 可)
  • void + [MemberNotNull] の無条件呼び出し
  • Try + [MemberNotNullWhen(true,...)] と戻り値チェック
  • required プロパティによる初期化義務の移譲
  • nullable + 使用直前ガード

といったパターンで、抑制なしに警告を解消できます。目的(安全性・読みやすさ・拡張性)に合わせて最適解を選び、コンパイラに「何がいつ非 null になるか」を正しく伝えましょう。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次