【C#】演算子オーバーロードとNULLチェックの関係について

エンジニアの今井です。
今回はC#のNULL判定の挙動について調査してみました。

C#のNULLチェック

C#のNULLは性能向上のため、内部的には0を使って表されます。
NULLが0のため、あるクラスがNULLかどうかの判定は0かどうかの判定だけ済むからです。
しかしながら、演算子オーバーロードで「==」を使っているとオーバーロードの判定が挟まり処理が遅くなります。

この問題はNULLチェックを「is null」で行うと解決されます。

// NULLが0と比較される事例
bool IsNullTestA(TestA x) => x == null;
class TestA { }
// NULLが0と比較されない事例
bool IsNullTestB(TestB x) => x == null;
class TestB
{
    public static bool operator ==(TestB x, TestB y) => ここが重たいと、この処理が走るので重たい;
    public static bool operator !=(TestB x, TestB y) => ここが重たいと、この処理が走るので重たい;
}

外部サイトの参考記事(「++C++; // 未確認飛行 C」様)
https://ufcpp.net/blog/2020/12/isnull/

実際にどうなのか?

NULLチェックの挙動が本当にそうなっていることを確認したいと思ったため、次の疑問を確認します。
疑問1. 本当に演算子オーバーロードがあると、単純な0比較されないのか?
疑問2. 本当に「is null」は演算子オーバーロードがない「==」と等価なのか?

さらに、stringのNULLチェックも気になります。
何故ならば、C#で頻繁に使うであろうstring型の実装を追いかけていくと演算子オーバーロードが使われてるからです。

// stringの演算子オーバーロードの実装
public static bool operator ==(string? a, string? b)
{
  return Equals(a, b);
}

public override bool Equals([NotNullWhen(true)] object? obj)
{
  if (this == obj)
  {
    return true;
  }
  if (!(obj is string text))
  {
    return false;
  }
  if (Length != text.Length)
  {
    return false;
  }
  return EqualsHelper(this, text);
}

このオーバーロードがどうなってるか知るために、stringのNULLチェックも調査します。

疑問3. string.IsNullOrEmptyは演算子オーバーロードがあるため、is nullよりも重くならないのか?

調査方法

疑問1の調査方法

  1. 演算子オペレータがないクラス(以後、TestA)とあるクラス(以後、TestB)を用意する
  2. TestAとTestBで「== null」の処理をコンパイルする
  3. ILを確認する

疑問2の調査方法

  1. TestBの「== null」と「is null」の処理をコンパイルする
  2. ILを確認する

疑問3の調査方法

  1. string変数strを用意する
  2. 「str == null」と「str is null」の処理をコンパイルする
  3. ILを確認する

動作環境

開発環境 : VisualStudio 2022
言語 : .NET6.0
Runtime : v4.0.30319
動作PC : Windows 10
構成 : Release
IL確認ツール : IL DASM

調査実施

疑問1の調査

実行コード

Console.WriteLine("TestA実行開始");
var testA = new TestA();
IsNullTestA(testA);
Console.WriteLine("TestA実行完了");

Console.WriteLine("TestB実行開始");
var testB = new TestB();
IsNullTestB(testB);
Console.WriteLine("TestB実行完了");

bool IsNullTestA(TestA x) => x == null;
bool IsNullTestB(TestB x) => x == null;
class TestA
{

	public string hoge { get; } = "aaa";
}
class TestB
{
	public static bool operator ==(TestB x, TestB y)
	{
		Console.WriteLine("重たい処理!!!");
		return true;
	}
    public static bool operator !=(TestB x, TestB y)
	{
		Console.WriteLine("重たい処理!!!");
		return true;
	}
}

実行結果

TestA実行開始
TestA実行完了
TestB実行開始
重たい処理!!!
TestB実行完了

IL

IsNullTestA
IL_0000:  ldarg.0
IL_0001:  ldnull
IL_0002:  ceq
IL_0004:  ret
IsNullTestB
IL_0000:  ldarg.0
IL_0001:  ldnull
IL_0002:  call       bool TestB::op_Equality(class TestB,
                                             class TestB)
IL_0007:  ret

結論
実行結果からも、ILの比較からも、演算子オーバーロードが実行されてることがわかる

疑問2の調査

実行コード

var testB = new TestB();

Console.WriteLine("TestBの==を実行開始");
IsNullTestEqual(testB);
Console.WriteLine("TestBの==を実行完了");

Console.WriteLine("TestBのis nullを実行開始");
IsNullTestIs(testB);
Console.WriteLine("TestBのis nullを実行完了");

bool IsNullTestEqual(TestB x) => x == null;
bool IsNullTestIs(TestB x) => x is null;
class TestB
{
	public static bool operator ==(TestB x, TestB y)
	{
		Console.WriteLine("重たい処理!!!");
		return true;
	}
    public static bool operator !=(TestB x, TestB y)
	{
		Console.WriteLine("重たい処理!!!");
		return true;
	}
}

実行結果

TestBの==を実行開始
重たい処理!!!
TestBの==を実行完了
TestBのis nullを実行開始
TestBのis nullを実行完了

IL

IsNullTestEqual
IL_0000:  ldarg.0
IL_0001:  ldnull
IL_0002:  call       bool TestB::op_Equality(class TestB,
                                             class TestB)
IL_0007:  ret
IsNullTestIs
IL_0000:  ldarg.0
IL_0001:  ldnull
IL_0002:  ceq
IL_0004:  ret

結論
実行結果とILからも、is nullの場合、演算子オーバーロードが呼び出されていないことがわかる

疑問3の調査

実行コード

var str = "sample";

IsTestNullEqual(str);
IsTestNullIs(str);

bool IsTestNullEqual(string s) => s == null;
bool IsTestNullIs(string s) => s is null;

実行結果
省略

IL

IsTestNullEqual
IL_0000:  ldarg.0
IL_0001:  ldnull
IL_0002:  ceq
IL_0004:  ret
IsTestNullIs
IL_0000:  ldarg.0
IL_0001:  ldnull
IL_0002:  ceq
IL_0004:  ret

結論
ILではstringの「==」と「is null」には差がない

まとめ

・演算子オーバーロードの場合、単純なnullチェックがしたくてもオーバーロード先の処理が走る
・「is null」を使えば回避できる
・stringの実装では演算子オーバーロードが使われてるが、最適化があるのか、ILレベルでは違いない

性能問題は外部とのやりとりによって発生するのが大半だと思いますが、このような事も知ってると余計な性能問題に頭を悩ませなくて済むかもしれませんね

関連記事

プロジェクトストーリー

おすすめ記事

技術

コメント

この記事へのコメントはありません。

カテゴリー

TOP
TOP