uint[] は IList<int> に変換できる
こんなポストが流れてきました。
C#の挙動についてのちょっとしたクイズです。
以下のコードの出力結果は何になるでしょう?
public static class Program
{
public static void Main()
{
object array = new uint[] { 1,2 };
AnalyzeType(array);
}
public static void AnalyzeType(object o)
{
switch(o)
{
case IEnumerable<int> value:
Console.WriteLine("Int array"); break;
case IEnumerable<uint> value:
Console.WriteLine("UInt array"); break;
default:
Console.WriteLine("Unknown type"); break;
}
}
}
最初の自分の考えはこうでした。
- いちど
uint[]をobjectに変換しているけど、uint[]は参照型だから、boxingは関係しないはず -
uint[]はIList<uint>を実装するのでIEnumerable<uint>を実装するはず - 一方で、
uint[]はIEnumerable<int>を実装しないはず - 要素が参照型の
IEnumerable<T>の場合はTをアップキャストしたIEnumerable<T>にキャストできる「共変性」があるが、これは値型には適用されない
答えはUInt arrayだ!
違いました。正解はInt arrayです。
細かい挙動を見てみる
実験してわかった挙動は以下の通りです。
-
uint[]をIEnumerable<int>に直接キャストすることはできないが、いちどobjectを経由することでキャストできる -
IEnumerable<int>以外にも、IList<int>やIReadOnlyList<T>、果てはint[]へのキャストも可能 - この方法で作成した
IEnumerable<int>は普通に使え、intの範囲外の値はuintに再解釈される。配列の内容はコピーされず、直接参照される - リフレクションを使用して
uint[]の実装するインターフェースを列挙すると、IEnumerable<int>等は実装されていない - これが起こるのはサイズの等しい整数型どうし(
byteとsbyte、ushortとshort、uintとint、ulongとlong)だけで、浮動小数点数(intとfloatなど)では起こらない
総合して、uint[]をint[]に自動的に再解釈するような挙動であるようです。
ILを見てみる
is演算子による判定や型のパターンマッチングでは、isinstというIL命令が使われます。最初のコードのILには次のような部分があります。
IL_0000: ldarg.0
IL_0001: isinst class [System.Runtime]System.Collections.Generic.IEnumerable`1<int32>
IL_0006: brtrue.s IL_0012
このisinstがuint[]のインスタンスについて、IEnumerable<int>に対してtrueを返すことを意味しています。
CoreCLRのソースを見てみる
次に私が探したのはランタイム実装です。正確なところは自信ありませんが、このisinstではIsInstanceOfAny_NoCacheLookupという関数が呼び出されるようです。
この先を掘っていくと……
-
ObjIsInstanceOfCore()
https://github.com/dotnet/runtime/blob/9893d145ebb61ae592afa1a64baa2f6f094b1f6b/src/coreclr/vm/jithelpers.cpp#L979-L1036 -
MethodTable::CanCastTo()
https://github.com/dotnet/runtime/blob/9893d145ebb61ae592afa1a64baa2f6f094b1f6b/src/coreclr/vm/methodtable.cpp#L1373-L1418 -
MethodTable::ArraySupportsBizarreInterface()
https://github.com/dotnet/runtime/blob/9893d145ebb61ae592afa1a64baa2f6f094b1f6b/src/coreclr/vm/methodtable.cpp#L1421-L1444
ArraySupportsBizarreInterfaceといういかにもな名前の関数がいました。
BOOL MethodTable::ArraySupportsBizarreInterface(MethodTable * pInterfaceMT, TypeHandlePairList* pVisited)
{
/* 略 */
// IList<T> & IReadOnlyList<T> only supported for SZ_ARRAYS
if (this->IsMultiDimArray() ||
!IsImplicitInterfaceOfSZArray(pInterfaceMT))
{
CastCache::TryAddToCache(this, pInterfaceMT, FALSE);
return FALSE;
}
BOOL result = TypeDesc::CanCastParam(this->GetArrayElementTypeHandle(), pInterfaceMT->GetInstantiation()[0], pVisited);
CastCache::TryAddToCache(this, pInterfaceMT, (BOOL)result);
return result;
}
さらに、配列の要素型についてCanCastParam()という判定が入っています。
-
TypeDesc::CanCastParam()
https://github.com/dotnet/runtime/blob/9893d145ebb61ae592afa1a64baa2f6f094b1f6b/src/coreclr/vm/typedesc.cpp#L386-L433
BOOL TypeDesc::CanCastParam(TypeHandle fromParam, TypeHandle toParam, TypeHandlePairList *pVisited)
{
/* 略 */
else if(CorTypeInfo::IsPrimitiveType(fromParamCorType))
{
CorElementType toParamCorType = toParam.GetVerifierCorElementType();
if(CorTypeInfo::IsPrimitiveType(toParamCorType))
{
if (GetNormalizedIntegralArrayElementType(toParamCorType) == GetNormalizedIntegralArrayElementType(fromParamCorType))
return TRUE;
} // end if(CorTypeInfo::IsPrimitiveType(toParamCorType))
} // end if(CorTypeInfo::IsPrimitiveType(fromParamCorType))
// Anything else is not a match.
return FALSE;
}
GetNormalizedIntegralArrayElementType()はこちらです。
-
GetNormalizedIntegralArrayElementType()
https://github.com/dotnet/runtime/blob/main/src/coreclr/vm/array.cpp#L1003-L1026
CorElementType GetNormalizedIntegralArrayElementType(CorElementType elementType)
{
/* 略 */
// Array Primitive types such as E_T_I4 and E_T_U4 are interchangeable
// Enums with interchangeable underlying types are interchangeable
// BOOL is NOT interchangeable with I1/U1, neither CHAR -- with I2/U2
switch (elementType)
{
case ELEMENT_TYPE_U1:
case ELEMENT_TYPE_U2:
case ELEMENT_TYPE_U4:
case ELEMENT_TYPE_U8:
case ELEMENT_TYPE_U:
return (CorElementType)(elementType - 1); // normalize to signed type
default:
break;
}
return elementType;
}
なんだか覚えのある整数型の対応関係が見えてきました!
さらにコメントによるとenumの場合もunderlying typeとの変換が行えるようです。こちらも試してみました。
using System;
using System.Collections.Generic;
var array = new E[]{ E.A, E.B, E.C };
var list = (IList<int>)(object)array;
list[0] = 2;
Console.WriteLine(array[0]); // C
enum E { A, B, C }
この挙動の背景は?
dotnet/runtimeのコミット履歴を遡ってみましたが、.NET Coreの初期リリースから存在しているため、おそらく.NET Framework時代からの挙動なのでしょう。
次に私は、これが.NET の仕様書に書かれていないかを調べました。
すると次のような項目がありました。
I.8.7
- compatible-with – this is the relation used by castclass (§III.4.3) and isinst
(§III.4.6), and in determining the validity of variant generic arguments
isinstやcastclass命令における型の関係は、compatible-withという言葉で定義されるとのことです。
I.8.7.1
A signature type T is compatible-with a signature type U if and only if at least one of the following holds.
(略)
5. T is a zero-based rank-1 array V[], and U is a zero-based rank-1 array W[], and V is array-element-compatible-with W.
(略)
7. T is a zero-based rank-1 array V[], and U is IList<W>, and V is array-elementcompatible-with W.
(略)
型Tとarray-element-compatible-withな型Wに対して、T[]とW[]、T[]とIList<W>はcompatible-withであるようです。
array-element-compatible-withの定義はこちらです:
I.8.7.1
A signature type T is array-element-compatible-with a signature type U if and only if T has underlying type V and U has underlying type W and either:
- V is compatible-with W; or
- V and W have the same reduced type.
reduced typeはこちらです:
The reduced type of a type T is the following:
- If the underlying type of T is:
a. int8, or unsigned int8, then its reduced type is int8.
b. int16, or unsigned int16, then its reduced type is int16.
c. int32, or unsigned int32, then its reduced type is int32.
d. int64, or unsigned int64, then its reduced type is int64.
e. native int, or unsigned native int, then its reduced type is native int.- Otherwise, the reduced type is itself.
ありました!仕様で定義されていたんですね。
ところで、array-element-compatible-withの定義はcompatible-withの定義を拡張したものなので、今回の挙動はジャグ配列でも成立しそうです。試してみました。
using System;
var array = new int[1][];
array[0] = new int[1];
var arrayUInt = (uint[][])(object)array;
arrayUInt[0][0] = uint.MaxValue;
Console.WriteLine(array[0][0]); // -1
感想
配列型の符号あり・符号なしのキャストを、コピーなしに実現したいモチベーションは理解できます。ただ、最新のC#でこの類の操作をするならSpan<T>にしてからMemoryMarshal.Cast()を使ったり、あるいはUnsafe.As<T>()を使ったりすることでしょう(こちらなら整数以外にも変換できますし)。これが安全な、仕様に定義された手段として提供されていたとは驚きました。
Discussion