sh1’s diary

プログラミング、読んだ本、資格試験、ゲームとか私を記録するところ

C# Null 非参照型と許容参照型 (CS8600)

f:id:shikaku_sh:20210823173353p:plain:w300

C# 8.0 から「C の null 値を許容する参照型」という機能が実装されています。

もともと、C# の参照型は null 値を許容しているので、改めて実装される意味がわかりづらいかもしれないです。

現在、null 参照をすることは避けられるようになっていて、C# でも、C# 8.0 から参照型であってもデフォルトでは null を参照しないようになりました。そのため、Visual Studio 2022 なんかでプログラムを書くと、null を参照する(許容する)クラスなんかで、そこそこ CS8600 の警告と出くわすことになります。

f:id:shikaku_sh:20220108153242p:plain

実際は、もともと null が許容されていた言語なので、そこから null を完全に排除することは難しいです。なんで、「型によって null の許容/拒否」がわかるように表現されるようになった、というところを押さえておく必要があります。

null を許容する例

テキストを System.Text.JsonSerializer で変換する場合、戻り値は null を許容する型になっています。なので、以下のようになります:

public static class JsonSerializer
{
    ...
    public static TValue? Deserialize<TValue>(string json, JsonSerializerOptions? options = null);
    ...
}
// CS8600 の警告が出る
SampleData data = JsonSerializer.Deserialize<SampleData>(text, options);

// OK
SampleData? data = JsonSerializer.Deserialize<SampleData>(text, options);

従来は SampleData はクラスなので参照型。なので SampleData 型は null を扱えるため問題なかったのですが、今は見ただけでこの型は null を許容しているのかどうかわかるように書くことになりました。よって、null 値を許容する場合は値型と同じで ? を付ける必要があります。

これにメリットがあるのかどうか、わかりづらいと思いましたが、あるメソッドから何らかの参照型(クラス)IEnumerable<T> を返却する場合、取得に失敗したときに返却するデータは、空の IEnumerable<T> 、または null を返却するのか、XML ドキュメント コメントや仕様書に記載が無い場合、従来だと動作がわからないことがありました。(例外を投げる場合も同様)

正常な動作は書いてあるのだけど、上手くいかない場合の動作がわからず、data.Count() > 0 のようなことをして null 参照エラーになる、または、(data?.Count() ?? 0) > 0 のようなことになります。

それが、C#8.0 からは null を許容する参照型は ? を付けることになったため、デフォルトは null を許容しない参照型になりました。コードを見れば読み取れることが増えてますね。こんな XML ドキュメント コメントは不要になりそう。

/// <summary>
/// とある要素の <see cref="SampleData"/> コレクションを取得します。
/// </summary>
/// <returns><see cref="SampleData"/> のコレクション。取得に失敗した場合 <value>null</value> を返却します。</returns>
public IEnumerable<SampleData> GetSample() => ...;

C# 8.0 からは、SampleData は null 非許容型なので、取得に失敗したときは空の配列を返却するか、例外を投げるだろうと予測できそうです。

補足

null 値を許容する(null-forgiving)

一時的に null を許容する場合は null-forgiving operator を使って警告を回避することもできます。(他言語は not-null assertion, force unwrap など)

! 演算子 (null-forgiving) を利用すると、CS8600 のチェックから外れるので、NullReferenceException がどこで発生してもおかしくない状態になります。

エラーが発生するのは従来どおり、その値を参照したタイミングなので、コードに問題がある箇所よりも後ろになるはずで、変数が渡っていってしまうと、まったく別のところでエラーが発生するのも従来どおり。

SampleData data = JsonSerializer.Deserialize<SampleData>(text, options)!;

アノテーション属性

属性でもいろいろな準備があって、部分的に null を許容したりするような動きを作れる。

public struct Int32 
{
    ...
    public static bool TryParse([NotNullWhen(true)] string? s, out int result);
    ...
}

int.TryParse 戻り値の戻り値が true は、翻って s は not null という制約。 string.IsNullOrEmpty[NotNullWhen(false)]コンパイラの最適化に役立ちます。

値/参照型で null 参照型の実装は全然違う

? の実装は、値型は Nullable<T> でしたが、参照型は元々から null を代入できるので整合性の面で微妙なところがあります。

そのあたりはオーバーロードtypeof() で悩ましい部分が出ていますが、そうそう入り込んだコードでもなければ遭遇しない自体だと思いますが、実装は異なっている、という点は重要です。

ジェネリクス

T を非 null 参照型だと明示できるようになっています。

class ResasClient<T> where T: notnull
{
    ...
}

まとめ

  • null を参照できるコードは損失をもたらしてきた歴史がある
  • C# も null 参照問題の対策として「型で null 参照/非参照」がわかるようにした
  • 基本的には、null 参照問題を避けたいので null を許容しない
    • でも、完全に無くすコストは大きいので、部分的に null を許容する

参考