非 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 点を証明できません。
- コンストラクターでそのメソッドが必ず呼ばれるか。
- その戻り値が必ず
trueになるか(= 正常終了経路が true であること)。
したがって、コンストラクターの末尾で「全経路が true を通った」ことが示されない限り、_serviceProvider を非 null と断定できず、CS8618 が残ります。逆に、if (!Try...) throw ...; の形にすると、コンストラクターが正常終了できる経路は「戻り値が true のときだけ」に絞られるため、コンパイラはフィールドの非 null を確定できます。
アンチパターンと落とし穴
- 戻り値が
boolなのに[NotNullIfNotNull]を付ける:意味を持ちません。戻り値が参照型のときだけ有効です。 - readonly フィールドをメソッドで代入:コンストラクターの外側(別メソッドの本体)は「代入可能な例外扱い」になりません。直接代入か、readonly を外す必要があります。
- 仮に
!で抑制:短期的には消えますが、安全性は担保されません。抑制が必要な理由と範囲をコメントで説明し、将来の負債を可視化しましょう。 - 引数の型が曖昧:本当に null を許すのか? 許さないのであれば、
IServiceProvider(非 null)を受け取り、ThrowIfNullで守るほうが伝わります。
属性の使い分け早見表
| 属性 | 対象 | 効果 | 典型用途 |
|---|---|---|---|
[MemberNotNull] | メソッド/プロパティ | 呼び出し後、指定メンバーが非 null とみなされる | void 初期化メソッドでの代入を伝える |
[MemberNotNullWhen(bool,...)] | メソッド/プロパティ | 戻り値が指定条件のときに限り、メンバーが非 null | Try パターン(成功時のみ非 null) |
[NotNullWhen(bool)] | 引数(out/ref) | 戻り値条件付きで、out 引数が非 null | bool 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 になるか」を正しく伝えましょう。

コメント