nullのあれこれ

C#のデータ型には値型参照型という分類があります。
(→値型と参照型参照)
値型変数は値をメモリ上に直接保存し、参照型変数は値のメモリ上の位置を保存します。
参照型にはnullという、メモリ上のどこも指していない状態を表わす値を指定できます。

ここではnullにまつわるいくつかの機能を説明します。

null許容値型

nullはメモリ上のどこも指していない状態を示すものなので、この性質上、値型の変数にはnullを代入することはできません。
これを可能にするためにnull許容値型という特殊な型が用意されています。

null許容値型は従来はnull許容型と呼ばれていましたが、C#8.0より「null許容参照型」という型が追加されたため、従来の値型に対するnull許容型はnull許容型と呼ばれます。

null許容値型は、例えばデータベースとの連携で使用されます。
データベースでは、数値などのC#では値型となるデータ型でも「値なし」という0とは別の状態を取ります。
このようなデータを表すのに値型でもnullの状態になれると都合が良い場合があります。

文法

null許容値型は、値型のデータ型名の末尾に「?」を記述することで定義します。


int? num1 = 123;
int? num2 = null; //nullが代入可能

int?型はnull許容型となるので、nullを代入することができます。

null許容値型は内部的にはジェネリック構造体(System.Nullable<T>)となっていて、いくつかのプロパティとメソッドを持っています。

Value、HasValueプロパティ

null許容値型はValueプロパティとHasValueプロパティを持っています。
Valueプロパティは値がnullでない場合の実際の値です。
HasValueプロパティは値がnull以外ならば真となります。


int? num1 = 123;
int? num2 = null;

bool b1 = num1.HasValue; //true
bool b2 = num2.HasValue; //false

Console.WriteLine(num1.Value); //123
Console.WriteLine(num2.Value); //エラー

ValueプロパティはHasValueプロパティがfalseの場合に呼び出すと例外(InvalidOperationException)が発生します。

null許容値型が使用可能か否かはHasValueプロパティを使用しても良いですし、単純にnullと比較することでも判別できます。


int? num = null;

if (num.HasValue)
    Console.WriteLine(num);
else
    Console.WriteLine("num is null");

if (num != null)
    Console.WriteLine(num);
else
    Console.WriteLine("num is null");
num is null
num is null

GetValueOrDefaultメソッド

GetValueOrDefaultメソッドは、null許容値型の値がnullならば既定値を返し、null以外ならばその値を返します。
引数に値を指定すると、null許容値型がnullの場合に返す値を既定値から変更できます。


int? num1 = 123;
int? num2 = null;

int val1 = num1.GetValueOrDefault();    //123
int val2 = num2.GetValueOrDefault();    //0
int val3 = num2.GetValueOrDefault(999); //999

型変換

null許容値型と通常の(null非許容の)値型とは相互変換可能です。
値型→null許容値型は暗黙的に型変換されますが、null許容値型→値型の場合はキャスト演算子が必要です。


int val = 123;
int? nul = null;

//OK
nul = val;

//数値リテラルはint型なので
//これも値型からnull許容値型へ
//暗黙的に型変換されている
nul = 123;

//明示的な型変換
val = (int)nul;

nul = null;

//値がnullの場合は
//例外発生
val = (int)nul;

null許容型の値がnullの場合(HasValueプロパティがfalseの場合)、値型への変換は例外(InvalidOperationException)が発生します。

演算子の適用

null許容値型は元のデータ型で使用可能な演算子をそのまま適用できます。
値がnullの場合は演算の結果もnullとなります。


int? num1 = 123;
int? num2 = null;

int? num3 = num1 + num2;    //null
num1++;                     //124

比較演算子は、一方または両方がnullの場合にfalseとなります。
両方が非nullならば通常の比較が行われます。

等価演算子は、両方がnullならばtrueとなります。
一方がnullならばfalseとなり、両方が非nullならば通常の比較が行われます。
非等価演算子はこの逆です。


int? num1 = 123;
int? num2 = null;

bool b1 = num1 <= num2; //false
bool b2 = num1 == num2; //false
bool b3 = num1 != num2; //true

ここまでの結果はおおむね感覚的に理解できるものですが、bool?型に対する論理演算子は通常の演算とは異なる特殊な結果となります。

a b a&b a|b
true true true true
true false false true
true null null true
false true false true
false false false false
false null false null
null true null true
null false false null
null null null null

null許容型の検査と値の取得

C#7.0以降、null許容型に対してis演算子でnullの検査と値の取得を同時に行えます。


int? num = 123;

//変数numの値がint型(非null)なら
//値を変数numValに格納
if(num is int numVal)
{
    //numValは通常のint型
    Console.WriteLine(numVal);
}
else
{
    Console.WriteLine("num is null");
}
123

変数numValはif文ブロック内で使用可能なローカル変数となります。

null合体演算子

参照型やnull許容型に対してはnull合体演算子という演算子が使用できます。
これは値がnullの場合に別の値を割り当てることができます。
null合体演算子は「??」を使用します。


int? nul1 = 123;
int? nul2 = null;

//変数がnullだった場合は
//0を返す
int num1 = nul1 ?? 0;
int num2 = nul2 ?? 0;

//以下のコードと同等
int num3 = nul1 == null ? 0 : (int)nul1;

Console.WriteLine(num1); //123
Console.WriteLine(num2); //0

string str1 = null;

//参照型にも使用可能
string str2 = str1 ?? "none";

Console.WriteLine(str2); //none

null合体演算子は連続して使用することもできます。


int? num1 = null;
int? num2 = null;

//num1がnullならnum2
//num2がnullなら0
int? num3 = num1 ?? num2 ?? 0;

null条件演算子

クラスを自作する場合、別のクラスのインスタンスをフィールドにすることはよくあります。
そのクラス内でも別のクラスのインスタンスをフィールドに持っていたりすると、階層がどんどんと深くなっていきます。

例えば以下のコードでは、ClassCのインスタンスからClassAのフィールドstrにアクセスする場合、「ClassC→ClassB→ClassA→str」と順番にアクセスする必要があります。


class ClassA
{
    public string str = "abc";
}

class ClassB
{
    public ClassA a;
}

class ClassC
{
    public ClassB b = new ClassB();
}

static void Main(string[] args)
{
    ClassC c = new ClassC();
    Console.WriteLine(c.b.a.str);
}

クラスのインスタンス変数はnullになる可能性があるため、このコードはもしかしたらエラーになるかもしれません。
実際にClassBの内部ではClassAのインスタンスが初期化されていないため、インスタンスa(値はnull)からフィールドstrを呼び出そうとするとエラーになります。

エラーを回避するためにはnullチェックをするのですが、全てのインスタンス変数はnullである可能性があるため、階層が深くなると少々記述が面倒です。


if (c != null && 
    c.b != null &&
    c.b.a != null)
{
    Console.WriteLine(c.b.a.str);
}

これを簡単に記述するためにnull条件演算子を使用できます。
null条件演算子は変数名の後ろに「?.」記号を記述します。


string s = c?.b?.a?.str;

null条件演算子は、変数の値がnullならnullを返し、null以外ならばドット演算子でメンバーにアクセスします。
途中にnullがあった時点で処理が終了するので、エラーは発生しません。
もちろん最終的に得られる値はnullである可能性があるので、そのnullチェックは必要です。

null合体演算子と併用すれば以下のように書けます。


string s = c?.b?.a?.str ?? "none";

これならば全てのインスタンス変数とフィールドstrが非nullならばフィールドstrの値が得られ、どこかにnullがあれば「none」という文字列が得られます。

インデクサーに対するnull条件演算子

null条件演算子はインデクサーにも使用できます。
インデクサーというのは配列等に使用する角括弧(添え字演算子)のことです。


int[] arr = null;

int? num = arr?[0];

配列変数がnullならばnullを返し、非nullならば配列の要素にアクセスできます。

これもnull合体演算子と併用できます。
ただし要素の範囲外アクセスまでは回避できないので注意してください。


int[] arr = new int[]{};

//範囲外アクセスなのでエラー
int num = arr?[0] ?? 0;