今回のレポートは,C#7のクラスライブラリをnull許容参照型を使用するC# 8にアップグレードするケーススタディです。ここで使用したTortuga Anchorプロジェクトは,MVVM形式のベースクラスとリフレクションコード,さまざまなユーティリティ関数を集めたものです。適度に小さく,慣用的なパターンと一般的でないパターンが混在していることから,このプロジェクトを選択しました。
プロジェクト設定
現在,null許容参照型が使用できるのは、.NET Standardプロジェクトと.NET Coreプロジェクトのみですが,Visual Studio 2019が製品化されるまでには.NET Frameworkでもサポートされるものと思われます。
プロジェクトファイルに以下の設定を追加あるいは修正する必要があります。
</PropertyGroup>
<LangVersion>8.0</LangVersion>
<NullableContextOptions>enable</NullableContextOptions>
</PropertyGroup>
保存するとすぐにnull許容エラーが表示され始めるはずですが,そうでない場合は,プロジェクトをビルドしてみてください。
型がnull許容であることを示す
インターフェースメソッドGetPreviousValue
の戻り値はnull許容型ですが,これを明示的にするため,objectにnull許容参照型修飾子(?)を追加しています。
object? GetPreviousValue(string propertyName);
先程のコンパイラエラーの大半は,変数,パラメータ,戻り型にこの型修飾子を追加すれば解消すると思います。
遅延読み込みプロパティ
プロパティの演算が処理的に高価である場合,遅延読み込みパターンを用いることがよくあります。このパターンでは,プライベートフィールドがnullであれば,その値が未生成であるということを示します。
C# 8はこの状況をうまく処理してくれます。コードを修正しなくても,以下のコードを正しく分析すれば,戻り値がnull許容型であるにも関わらず,Getterの結果は常に非nullであるということが分かります。
string? m_CSharpFullName;
public string CSharpFullName
{
get
{
if (m_CSharpFullName == null)
{
var result = new StringBuilder(m_TypeInfo.ToString().Length);
BuildCSharpFullName(m_TypeInfo.AsType(), null, result);
m_CSharpFullName = result.ToString();
}
return m_CSharpFullName;
}
}
この例には,潜在的に競合状態があることに注目してください。理論上,別のスレッドが m_CSharpFullName
の値を(訳注: ifブロック内で)nullに戻す可能性がありますが,コンパイラはそれを検出できません。従って,マルチスレッドコードを扱う場合には特に注意が必要です。
変数のnull許容性が別の変数によって決定される
次のコード例のクラスは,m_ItemPropertyChanged
がnullでない場合にのみ,m_ListeningToItemEvents
が真であるようにデザインされています。このルールはコンパイラには分かりません。そこでnull許容演算子(!)を追加して,変数(この場合はm_ItemPropertyChanged
)がその時点ではnullにならない,と示すことができます。
if (m_ListeningToItemEvents)
{
if (item is INotifyPropertyChangedWeak)
((INotifyPropertyChangedWeak)item).AddHandler(m_ItemPropertyChanged!);
else if (item is INotifyPropertyChanged)
((INotifyPropertyChanged)item).PropertyChanged += OnItemPropertyChanged;
}
明示的なキャストによる誤判定の修正
次の例では,コンパイラは,m_Base
.Values
のnull許容性がIEnumerable<TValue>
と互換性がない,という誤った報告をします。警告を取り除くため,下のように明示的なキャストを追加しました。
readonly Dictionary<ValueTuple<TKey1, TKey2>, TValue> m_Base;
IEnumerable<TValue> IReadOnlyDictionary<ValueTuple<TKey1, TKey2>, TValue>.Values
{
get { return (IEnumerable<TValue>)m_Base.Values; }
}
すると今度はコンパイラが,この行に冗長なキャストがあるというフラグを立てます。通常これはコンパイラのメッセージであって,警告ではありませんが,リリース時には修正されている予定です。
一時変数あるいは条件付きキャストによる誤判定の修正
次に示す例では,CancelEdit
の行にエラーがあると示されます。直前のif文でitem.Value
がnullでないことが保証されていても,コンパイラは,次にitem.Value
を読み出した時もnullではないとは信じてくれません。
foreach (var item in m_CheckpointValues)
{
if (item.Value is IEditableObject)
((IEditableObject)item.Value).CancelEdit();
}
コンパイラを納得させる方法のひとつは,item.Valueを一時変数に格納することです。
foreach (var item in m_CheckpointValues)
{
object? value = item.Value;
if (value is IEditableObject)
((IEditableObject)value).CancelEdit();
}
しかし,このケースでは,条件付きキャスト("as"演算子)を使用して,条件付きメソッド呼び出し("?."演算子)を続けることで,もっと単純にすることが可能です。
foreach (var item in m_CheckpointValues)
{
(item.Value as IEditableObject)?.CancelEdit();
}
総称型とnull許容型
総称型を多用していると,null許容型の問題に突き当たることがあります。次のデリゲートを考えてみましょう。
public delegate void ValueChanged<in T>(T oldValue, T newValue);
このデリゲートの設計意図では,oldValue
とnewValue
は共にnullを許容します。それならばクエスチョンマークを2つ付ければよい,と思うかも知れません。ところが,そのようにすると,次のようなエラーメッセージが返されます。
> Error CS8627
A nullable type parameter must be known to be a value type or non-nullable reference type. Consider adding a 'class', 'struct', or type constraint.
値と参照型の両方をサポートする必要のある場合は、これを簡単に修正する方法はありません。型制約には"or"を記述できないので、クラスと構造体それぞれにデリゲートを用意する必要があります。
public delegate void ValueChanged<in T>(T? oldValue, T? newValue) where T : class;
public delegate void ValueChanged<T>(T? oldValue, T? newValue) where T : struct;
しかしながら、この方法ではうまくいきません。両方のデリゲートが同じ名前だからです。別々の名前を付ければよいのですが、そうすると、その使用するコードもすべて2つにする必要があります。
幸いなことに、C#にはエスケープ値があります。#nullableディレクティブを使用すれば、C# 7のセマンティクスに戻すことが可能なので、コードは引き続き意図通り動作します。
#nullable disable
public delegate void ValueChanged<in T>(T oldValue, T newValue);
#nullable enable
この回避策には,問題がない訳ではありません。null許容参照機能の無効化はオール・オア・ナッシングなので、oldValueやnewValueをnull許容にすることもできなくなります。
コンストラクタ、デシリアライザ、および初期化メソッド
次の例では、シリアライザが使っているいくつかのトリックについて知っておく必要があります。あまり知られていませんが、クラスのコンストラクタをバイパスするFormatterServices.GetUninitializedObject.
という関数があります。 DataContractSerializer
など一部のシリアライザは、これを使ってパフォーマンスを向上しているのです。
では,コンストラクタのロジックを必ず実行しなければならない場合は,どうなるでしょう?OnDeserializing
属性はそのためにあります。この属性は代理コンストラクタとして動作し、GetUninitializedObject
の後にコールされます。
冗長性とエラーの可能性を低減するため、下の例のような共通初期化メソッドが使用されることがよくあります。
protected AbstractModelBase()
{
Initialize();
}
[OnDeserializing]
void _ModelBase_OnDeserializing(StreamingContext context)
{
Initialize();
}
void Initialize()
{
m_PropertyChangedEventManager = new PropertyChangedEventManager(this);
m_Errors = new ErrorsDictionary();
}
これはnullチェッカにとって問題になります。先程の2つの変数がコンストラクタで明示的に設定されていないため、初期化されていないものとしてフラグ付けされるのです。これはつまり、エラーを回避するためにコピー・アンド・ペースト作業を行う必要がある、ということになります。
OnDeserializing
メソッドを含めるのを忘れる、というリスクもあります。nullチェッカはOnDeserializing
メソッドを理解しないので、意図しないnullの可能性について警告することはできません。
ほとんどの開発者は、この振る舞いを分かりにくいと感じています。そのため.NET Coreでは、DataContractSerializer
がコンストラクタを呼び出すようになる予定です。しかしながら,.NET Standardをターゲットにする場合には、この振る舞いの違いを考慮に入れて、デシリアライズコードを.NET Frameworkと.NET Coreの両方でテストする必要があります。
NULL許容パラメータとCallerMemberName
このライブラリではCallerMemberName
パターンが多用されています。使用する属性にちなんで名付けられたこのパターンの基本的な考え方は、メソッドの最後にオプションパラメータを追加する、というものです。コンパイラはこのCallerMemberName
を見て、そのパラメータの値を暗黙的に提供します。
public override bool IsDefined([CallerMemberName] string propertyName = null)
propertyNameparameterを明示的にnull設定することも理論的には可能ですが、そうするべきではないことは広く認知されています。予期しないエラーが発生する可能性があるからです。
このコードをC# 8に変換する場合、パラメータをnulll許容型としてマークしたくなるかも知れませんが、メソッドは実際にはnullを処理するように設計されていませんので、これは誤解を招 きます。そうではなく、nullを空の文字列に置き換えるべきでしょう。
public override bool IsDefined([CallerMemberName] string propertyName = "")
それでもnull引数チェックは必要か?
広範に使用される(NGetなどで)ライブラリを開発しているであれば、イエスです。すべての公開メソッドでは、依然としてnull引数のチェックを行う必要があります。ライブラリを使用するアプリケーションがnull許容参照型を使用するとは限りません。実際、C# 8をまったく使っていないかも知れないのです。
すべてのアプリケーションコードでnull許容参照型が使用されているとしても、答はやはり、"おそらくはイエス"です。理屈の上では、予期しないnullを受けることはないはずですが、動的コードやリフレクション、あるいはnull許容演算子(!)の誤用によって紛れ込む可能性があるからです。
結論
60足らずのクラスファイルのプロジェクトでも、そのうち24ファイルに変更が必要でした。. ただし、特別に重要なものはなく、全体のプロセスに要したのは1時間未満でした。結果として,作業はそれほど大変ではなく、概ね期待どおりに動作しています。この機能は、ほとんどのプロジェクトにメリットがあると思われますので、C# 8がリリースされたならば、積極的に利用するべきです。
著者について
Jonathan Allen氏は,90年代の終わりから診療所のMISプロジェクトに携わり、AccessとExcelからエンタープライズソリューションへの段階的な移行を実施しました。金融機関向けの自動トレーディングシステムの開発に5年間関与した後、コンサルタントとして、ロボット倉庫のUI,がん研究ソフトウェアのミドル層、大手不動産保険会社のビッグデータニーズなど、さまざまなプロジェクトに関わっています。フリータイムには16世紀のマーシャルアーツについて研究し、記事を書いています。