旬をすぎてしまった感がありますが Visual Studio 2019 Preview 1 が公開されたようなので null 許容参照型(Nullable reference types)を手元のプロジェクトで試してみました。
「気になってるけど自分で試すのは面倒くさい」「自分以外の人がどう感じたか知りたい」「手元に程よい規模のソースがないから試しづらい」みたいな人は読んでみてください。
Visual Studio 2019 Preview 1 はこちらからダウンロードできます(たぶん時期が過ぎたらこのリンクは機能しなくなります)。
バグが見つかった
null
の可能性があるにも関わらず考慮していなかった箇所がさっそく見つかりました。
元々「この機能(Nullable reference types)、ぶっちゃけ微妙では…」と感じていたところがあった自分としては、「お、役に立つのでは」と印象が変わりました。
events.Where(x => TargetYear == null || x.Date.Year == TargetYear)
// x.Place が null だとここでヌルリ
.Where(x => !x.Place.Name.StartsWith("!_"))
events.Where(x => TargetYear == null || x.Date.Year == TargetYear)
// x.Place が null のものは除外してよかったので条件を追加した
.Where(x => x.Place != null && !x.Place.Name.StartsWith("!_"))
CS8618 Non-nullable field is uninitialized. がツラくなってきた
Kotlin なんかでも似たようなまどろっこしさがありますが、当然のことながら Non-nullable なフィールド・プロパティはコンストラクタやイニシャライザで初期化されなければいけません。
public class Hoge
~~~~ Non-nullable property 'Fuga' is uninitalized
{
public string Fuga { get; set; }
}
それはわかる、わかるのですが、POCO なプロパティの多いクラスですべてのプロパティをコンストラクタの引数として受け取るのは正直ツラく、また、API の戻り値としてデシリアライズによってインスタンス化されるクラスなんかの場合は「常に null はこないプロパティだけどコンストラクタは別に定義したくないんだよなぁ」となりモニョります。
定義側ではなく利用側に初期化の責務を負わせて、オブジェクト初期化子を使いたい(デシリアライズの場合はまぁ諦める。元々すべてを網羅することは諦めている機能だと思っているので)みたいな要望。
匿名型のように「不変(immutable)だけどコンストラクタパラメーターではなくオブジェクト初期化子で初期化したい」というのは以前から思ってはいたことで、調べてみたら Implement property initializers for immutable types #322 で既に提案されているようですね。
私が今回遭遇したケースにおいては Records を使うのが正しいアプローチのような気がします。C# よくわからないですが。
結局、 #pragma warning disable CS8618 // Non-nullable field is uninitialized.
をつけて回ることで一旦回避しました。
Caller Info 属性は考慮されない
元々 = null
と書かないといけないのが微妙だなぁとは思っていましたが、当然のことながら? Caller Info 属性は考慮されませんでした。
この辺は遭遇してみて初めて「あぁなるほど、こういうケースもあるのか」と思いましたね。
public void Hoge(
[CallerFilePath] string filePath = null,
~~~~ Cannot convert null literal to non-nullable reference or unconstrained type parameter.
[CallerMemberName] string member = null
~~~~ Cannot convert null literal to non-nullable reference or unconstrained type parameter.
)
仕方ないので !
をつけます。
public void Hoge(
[CallerFilePath] string filePath = null!,
[CallerMemberName] string member = null!
)
匿名型のプロパティが強制的に null 許容にされる
Anonymous type display mismatch for nullable vs non-nullable #25348 として報告が挙がっていました。
var hoge = new { Foo = new Foo() };
Console.WriteLine(hoge.Foo.Bar); // この場合はツールチップ上 Foo? になるだけで警告は出ない模様
私が遭遇したのは↓のケースで、Select
で匿名型を作ると問答無用で nullable なプロパティにされる様子?
var xs = Enumerable.Range(0, 1).Select(_ => new
{
Foo = new Foo()
});
foreach (var x in xs)
Console.WriteLine(x.Foo.Bar);
~~~~~ Possible dereference of a null reference.
今回はほとんどのケースにおいてタプルでよかったためタプルに修正して回避しました。
タプルかわいいよタプル。
object 型の null フロー解析がおかしい?
これはよくわかりませんでした。if (value == null) return "";
で弾いてるつもりなのに警告が出ます。
public string Hoge(object value)
{
if (value == null) return "";
if (value is bool b) return b ? "true" : "false";
return value.ToString();
~~~~~ Possible dereference of a null reference.
}
引数を object?
にしても同じ模様。とりあえず value!
にしました……
ジェネリック型の変性的なやつが微妙な挙動
Task<Foo?>
に対して Task.FromResult
を使うときになんとも微妙な挙動をします。
public Task<string?> GetBodyAsync()
=> Task.FromResult("");
~~~~~~~~~~~~~~~~~~~ Nullability of reference types in value of type 'Task<string>' doesn't match target type 'Task<string?>'.
型が合わないと警告が出るので、型引数の明示が必要なのですが、実際に書くと「不要」としてグレーアウトされます。
また、フロー解析がそこまで賢くないためかキャストでは警告が消えません。
プレビュー版だからでしょうか。
public Task<string?> GetBodyAsync()
=> Task.FromResult<string?>(""); // こうする。<string?> が冗長だとして薄くなるけどショーガナイ
public Task<string?> GetBodyAsync()
=> Task.FromResult((string?)""); // これだと Task<string?> とみなしてくれない
~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Nullability of reference types in value of type 'Task<string>' doesn't match target type 'Task<string?>'.
メソッドを通しての null チェックに弱い
Where
で null
を除外してもフロー解析が働きません。
Where
だけ特別扱いできないので、仕方ないといえば仕方ないですが結構ツラいです。
.Where(x => x.Foo != null)
.Select(x => x.Foo.Bar)
~~~~~ Possible dereference of a null reference.
同じ理由により、UT なんかで null
じゃないことをアサーションしたあとでもフロー解析は働きません。
Assert.NotNull(foo);
Assert.Equal("", foo.Bar);
~~~ Possible dereference of a null reference.
Kotlin 1.3 では Contracts によって解決を図っているので C# も頑張ってほしいですね。
(おまけ) TaskCompletionSource のために first-class Void が欲しくなる
もはや Non-null reference type あんまり関係ないですが、TaskCompletionSource.SetResult(null)
していたところが警告になったので System.Void as a first-class type #1603 が早く欲しくなりました。
public Task HogeAsync()
{
var taskCompletionSource = new TaskCompletionSource<object>();
_foo.Completed => (s, e) =>
{
taskCompletionSource.SetResult(null);
~~~~ Cannot convert null literal to non-nullable reference or unconstrained type parameter.
};
return taskCompletionSource.Task;
}
総評
月並みですが次のような点は「あった方がいいな」と感じました。
-
null
になり得る変数に対しての考慮漏れが実際に発見できた -
null
になり得るのかどうかを考えさせられる効果を感じた- これは必ず値がくるな、これは
null
にもなるな、という判断をしながら修正しました
- これは必ず値がくるな、これは
- その判断結果をコード上に表現できることが有用だと感じた(シグネチャとして表現される)
事前に思ってたより問題にならなそうだと思った点は以下でした。
- 「警告」だと結局無視されそう(大量の警告が放置されている秘伝のプロジェクトとか現場には多い印象)
- IDE 上で波線が引かれるので、ある程度はそこで気づけそうです
- あとはまぁ CI 環境で工夫するとか色々やりようはありますかね
一方で、実際にやってみてツラかったのは以下の点でした
- プロパティの初期化をコンストラクタで迫られるけど、コンストラクタで初期化するのはツラいこともある
- 一応、コンストラクタを自動生成する機能が VS 側に搭載されたようなので、手間はある程度軽減されます
- メソッドを通すと解析が効かないのが結構しんどい
- 特に LINQ
- 「歴史的な理由」「どのように実装されている機能か」を知らないと色々ハマりそう
- 構造体との違い含め、かなり後付けな実装なのである程度の C# 理解力(というか型に対する理解力)が無いと法則性を理解するのが難しそう
- 「フレームワーク上で別の型として存在しているわけではない」というのが今までの C# からすると異質な感じが否めない
- 伝わるかわかりませんが、TypeScript の型システムを使いこなそうとするのに似た思考力の求められを感じました