何をしたいのか?
大昔、汎用IsNull,IsNotNullの実装と言うエントリを書いたのですが、
どうにもこうにも以下のような不満がありました。
- 本来不要なボックス化が発生する
- 上記に伴う、値型のボクシングコストや、それに付随するマネージヒープを利用し、GCが動くことによるパフォーマンスの低下
という、不満があったけど、当時は解決できなかった。
で、今回、その辺割とうまく捌けたのでちょっと書いてみようかなと言うのが今日のお題。
前提としている条件
以下の議論では、次のような条件を設けているのでご了承の程。
- Nullable<T>を除く値型は、Nullと比較した結果は常にFalseとなる。
今回のゴール
- Nullable<T>の判別時ボックス化させない。
- Nullable<T>を除く値型に対する返却値の定数化
当たりが目的になってます。
実装
実装に関しては以下の通り。
public static class NullCheckExtensions
{
//新型のNull/IsNull
public static bool IsNull<T>(this T value) => NullChecker<T>.IsNull(value);
public static bool IsNotNull<T>(this T value) => !IsNull(value);
//従来型の汎用Null/IsNull
public static bool TraditionalIsNull(this object obj) => obj == null;
public static bool TraditionalIsNotNull(this object obj) => obj != null;
private static class NullChecker<T>
{
private static readonly TypeType _type;
static NullChecker()
{
var type = typeof (T);
if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof (Nullable<>))
{
_type = TypeType.Nullable;
}
else if (type.IsValueType)
{
_type = TypeType.Value;
}
else
{
_type = TypeType.Reference;
}
}
public static bool IsNull(T value)
{
switch (_type)
{
//値型なら常にFalseを返す。
case TypeType.Value:
return false;
//参照形は都度判定
case TypeType.Reference:
return value == null;
//NullableはEqualメソッド呼び出す。
case TypeType.Nullable:
return value.Equals(null);
default:
throw new InvalidOperationException("Unexpected type");
}
}
private enum TypeType
{
//Nullable以外の値型
Value,
//参照形
Reference,
//Null許容形
Nullable
}
}
}
実装のおおまかな解説
ここで、キモになるのは、内部にNullCheckerというStaticClassを持っている点となります。
コレは何をしてるかというと、NullCheckerはジェネリック型なので、Tの種類だけクラスが生成されることになり、
生成毎にNullChekerの静的コンストラクタによって、所与のTがどのよう型なのか、より具体的には、
- Nullable<>型
- 値型
- 参照型
を判定し、IsNullメソッドの呼び出し時に、かつてのようにObject型へのキャスト無しに判定を実行可能にしています。
また、静的コンストラクタは、静的コンストラクター (C# プログラミング ガイド)記載の通り、アプリケーションのライフサイクルの中で必要であれば1回だけ実行されるので、随時判定より特に値型の判定に大きなアドバンデージを持つことになります。
Nullableを特殊扱いしているわけ
Nullableに対しては、実装を見ればわかるとおり、特殊扱いを行っています。これは、文字通り、Nullableは値型かつNullを許容できるので、一般的な値型のコンテキストで判定してしまうと、HasValueがfalseであっても、IsNullメソッドがtrueになってしまうので、この点を何とかする必要がありました。
対策として、Equalsメソッドを明示的に呼び出すことにより、ボックス化を経ること無く判定を可能にしています。
参照型でEqualsメソッドを利用すると酷い目に遭う
case TypeType.Reference:
return value == null;
この部分を、以下のように書き換えると、
case TypeType.Reference:
return value.Equals(null);
以下のようなシナリオで、StackOverFlowが発生するので、注意が必要です。
public class RecursiveCall
{
public int Value { get; set; }
public override bool Equals(object obj)
{
var tmp = obj as RecursiveCall;
if (tmp == null) return false;
//ここでOpEqualが呼ばれて、無限再帰が完成する。
return tmp == this;
}
public static bool operator ==(RecursiveCall x, RecursiveCall y)
{
//ここで、Equalsが呼ばれる。
if (x.IsNull() && y.IsNull()) return true;
return (y.IsNotNull()) && (x.IsNotNull()) && (x.Value == y.Value);
}
public static bool operator !=(RecursiveCall x, RecursiveCall y) => !(x == y);
}
どれくらい速くなったか?
最後に、パフォーマンスがどの程度改善したのが検証してみたいと思います。
まず最初に、
- Nullable
- string
- int
型のIsNotNullを判定させてみました。
private static void Main()
{
const int seed = 42;
const int count = 25000000;
var chrono = new Stopwatch();
var rnd = MiscExtensions.GetRandomSequence(seed, count);
// 今回新たに実装した方式。
chrono.Start();
var cnt = GcCounter.GetCurrent();
var nullableCount = rnd.Select(x => x%3 == 0 ? null : new int?(x)).Count(x => x.IsNotNull());
var stringCount = rnd.Select(x => x%3 == 0 ? null : x.ToString()).Count(x => x.IsNotNull());
var intCount = rnd.Select(x => x).Count(x => x.IsNotNull());
chrono.Stop();
cnt = GcCounter.GetCurrent() - cnt;
Console.WriteLine("NewMethod");
Console.WriteLine($"Nullable:{nullableCount} String:{stringCount} Integer:{intCount}");
Console.WriteLine($"Elapsed:{chrono.Elapsed}");
Console.WriteLine(cnt.ToString());
Console.WriteLine();
Console.WriteLine();
//従来のオブジェクトにキャストする方式
rnd = MiscExtensions.GetRandomSequence(seed, count);
chrono.Restart();
cnt = GcCounter.GetCurrent(true);
nullableCount = rnd.Select(x => x%3 == 0 ? null : new int?(x)).Count(x => x.TraditionalIsNotNull());
stringCount = rnd.Select(x => x % 3 == 0 ? null : x.ToString()).Count(x => x.TraditionalIsNotNull());
intCount = rnd.Select(x => x).Count(x => x.TraditionalIsNotNull());
chrono.Stop();
cnt = GcCounter.GetCurrent() - cnt;
Console.WriteLine("TraditionalMethod");
Console.WriteLine($"Nullable:{nullableCount} String:{stringCount} Integer:{intCount}");
Console.WriteLine($"Elapsed:{chrono.Elapsed}");
Console.WriteLine(cnt.ToString());
}
このサンプルの結果は、デバッガアタッチ無しのリリースビルドで、以下の通りでした。
NewMethod
Nullable:16671891 String:16664383 Integer:25000000
Elapsed:00:00:05.1121741
Gen0:135 Gen1:0 Gen2:0 TTL:135
TraditionalMethod
Nullable:16671891 String:16664383 Integer:25000000
Elapsed:00:00:06.3988913
Gen0:254 Gen1:0 Gen2:0 TTL:254
パフォーマンスに、一応差が付いているのがわかります。
さらに、今回チューニングを行った、値型、Nullable型に動作を絞った以下のサンプルを作りテストしてみた場合、
private static void Main()
{
const int seed = 42;
const int count = 25000000;
var chrono = new Stopwatch();
var rnd = MiscExtensions.GetRandomSequence(seed, count);
// 今回新たに実装した方式。
chrono.Start();
var cnt = GcCounter.GetCurrent();
var nullableCount = rnd.Select(x => x%3 == 0 ? null : new int?(x)).Count(x => x.IsNotNull());
var intCount = rnd.Select(x => x).Count(x => x.IsNotNull());
chrono.Stop();
cnt = GcCounter.GetCurrent() - cnt;
Console.WriteLine("NewMethod");
Console.WriteLine($"Nullable:{nullableCount} Integer:{intCount}");
Console.WriteLine($"Elapsed:{chrono.Elapsed}");
Console.WriteLine(cnt.ToString());
Console.WriteLine();
Console.WriteLine();
//従来のオブジェクトにキャストする方式
rnd = MiscExtensions.GetRandomSequence(seed, count);
chrono.Restart();
cnt = GcCounter.GetCurrent(true);
nullableCount = rnd.Select(x => x%3 == 0 ? null : new int?(x)).Count(x => x.TraditionalIsNotNull());
intCount = rnd.Select(x => x).Count(x => x.TraditionalIsNotNull());
chrono.Stop();
cnt = GcCounter.GetCurrent() - cnt;
Console.WriteLine("TraditionalMethod");
Console.WriteLine($"Nullable:{nullableCount} Integer:{intCount}");
Console.WriteLine($"Elapsed:{chrono.Elapsed}");
Console.WriteLine(cnt.ToString());
}
NewMethod
Nullable:16671891 Integer:25000000
Elapsed:00:00:01.7142565
Gen0:0 Gen1:0 Gen2:0 TTL:0
TraditionalMethod
Nullable:16671891 Integer:25000000
Elapsed:00:00:03.3442754
Gen0:119 Gen1:0 Gen2:0 TTL:119
よりパフォーマンスの差異が顕著になります。
まとめ
以前、不備が有りつつもそれ以上できないと考えていた、値型のIsNull/IsNotNull判定を比較的うまく捌けたかなと思います。
その際、Nullableが問題になりましたが、コレも又、Equalsメソッドの呼び出しでマネージヒープを使うこと無く判定可能になりました。
次期C#で実装が予定されている、Null非許容の参照型が追加された場合は、別途調整が必要となると思います。1
色々な実装
本エントリをアップ後、@haxe先生と、@yfakariya先生から、別実装のご提案を頂いたので、
ここに紹介させて頂きます。
この場をお借りして、両先生にお礼申し上げます。
yfakariya先生の実装
オリジナルはこちら
sing System;
static class Program
{
static void Main()
{
Test( "a" );
Test( 1 );
Test<int?>( 1 );
}
static void Test<T>( T nonNull)
{
Console.WriteLine( "IsNull<{0}>(default({0})) :{1}", typeof( T ).Name, NullChecker<T>.IsNull( default( T ) ) );
Console.WriteLine( "IsNull<{0}>(nonNull) :{1}", typeof( T ).Name, NullChecker<T>.IsNull( nonNull ) );
}
}
internal static class NullChecker<T>
{
private static readonly Func<T, bool> _isNull =
InitializeIsNull();
private static Func<T, bool> InitializeIsNull()
{
if ( !typeof( T ).IsValueType )
{
return value => value == null;
}
var nullableUnderlyingType = Nullable.GetUnderlyingType( typeof( T ) );
if ( nullableUnderlyingType != null )
{
return
( Func<T, bool> )
typeof( NullableNullChecker<> )
.MakeGenericType( nullableUnderlyingType )
.GetMethod( "IsNull" )
.CreateDelegate( typeof( Func<T, bool> ) );
}
return _ => false;
}
public static bool IsNull( T value )
{
return _isNull( value );
}
}
internal static class NullableNullChecker<T>
where T : struct
{
public static bool IsNull( Nullable<T> value )
{
return !value.HasValue;
}
}
動的に、Nullable.HasValueプロパティを呼び出す実装となっております。
haxe先生の実装
オリジナルはこちら
元実装のNullCheckerのみ抜粋
static class NullableChecker<T>
{
private static readonly bool IsValueType;
static NullableChecker()
{
Type type = typeof (T);
IsValueType = type.IsValueType && Nullable.GetUnderlyingType(type) == null;
}
public static bool IsNull(T value) => IsValueType ? false : value == null;
}
Switchをシンプルでクールな条件判定になさっています。
-
とはいえ、多分効率を別とすれば動くとは思います。 ↩