🪄

uint[] は IList<int> に変換できる

2024/10/19に公開

こんなポストが流れてきました。
https://x.com/dotMorten/status/1846674335295291668

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です。

https://sharplab.io/#v2:C4LgTgrgdgPgAgJgIwFgBQcAMACOSAsA3OogMzoDe62NuCC1tVatr2A9gEYBWApgMbBsAQzBhhAT2wBebFF4B3bBACWUYAG0AutgrYkAGgTYAvsTSM22AIJRhAGwkAvXgBUJAB14AKUeIkAlOasJpa4pLhIAGy4+DZ2ji7uXt5cfIIcAWHMVjQAzgoqwPwAFqlZLLk5uaz8wnm8kaQAPGrAAHzYAG4OELwgYTWseACc3gBEAJLqImKS40HYnGC8wgDWwUM0dQ1NzarqnT32fQOVW8NIY+MAqtNCfvOLy6sbg7kAJrwAZsIQ9qB3ltRhMblA1lB2AooNhgJ5eAtCEsVutNlZQudTOgTEA

細かい挙動を見てみる

実験してわかった挙動は以下の通りです。

  • uint[]IEnumerable<int>に直接キャストすることはできないが、いちどobjectを経由することでキャストできる
  • IEnumerable<int>以外にも、IList<int>IReadOnlyList<T>、果てはint[]へのキャストも可能
  • この方法で作成したIEnumerable<int>は普通に使え、intの範囲外の値はuintに再解釈される。配列の内容はコピーされず、直接参照される
  • リフレクションを使用してuint[]の実装するインターフェースを列挙すると、IEnumerable<int>等は実装されていない
  • これが起こるのはサイズの等しい整数型どうし(bytesbyteushortshortuintintulonglong)だけで、浮動小数点数(intfloatなど)では起こらない

総合して、uint[]int[]に自動的に再解釈するような挙動であるようです。

https://sharplab.io/#v2:C4LgTgrgdgPgAgJgIwFgBQcAMACOSAsA3OugG4CGY2lY5AntgLzZQCmA7thAJZTADaAXWwBvbEgA0CbAF9iadCPTYV2APRrsrMGAD2VAMIBlTJgDMmENgPkoUXcGwBjXVFLbHwOgAdW2AOQ8fEL+2MC6AXj4AHR4ZgA8vMAAfP7KqhrYFFSsUBAAttrkAEYANqwAknxM2AAUcYl8yQCUNPTyqlmUWnmFtGWV1cz1SAlJLbW6xQBWrE7ArTrt6DIkaEponZnaeoYm5pbWtvaOLm4eYT5+gUkhYRH+t4Jpmxma2dRLdFWOw0+LtDoHVUHza3yGdX+kxmcwWYPk6RUYJ+/EwwmYSFMwJUeAAnLUwajBM1COpNJjMAjXkivii0TUALRIbG4JD4wloklk7D4BC4/C4gBsAHY+QBWeSrBTrRHYAD6NVqFQAMtwAM7ARopZrQ2bzAHLanyxUVABKrHIABMAPJQUp0VUarUTKZ6uFfSVrAy6UrleYAIXIatYABUrmqCV9ogBxVjAMO+WrNCQsDhJklrPCC3D4aw+v3AQPBhOsCMly6+FMACSDAAsjHH4iXktgwKWIKVgGrmopZdwAGa1ACEbbVHa70QAgpbLbUvL5ms1cMKWXjagASABExSDfnnrCsIn3Mk3GaNfcH++iRdDV2wQ+YeV9S+9vthN5LEavH6uKdH4+7FlZX7fQLScWtag+HdizvXgK1YGM4x+bR+3IJxS3TWUNk6TpXwLH9fAjaDb0rVt207QDZSlGQgA

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

このisinstuint[]のインスタンスについて、IEnumerable<int>に対してtrueを返すことを意味しています。

CoreCLRのソースを見てみる

次に私が探したのはランタイム実装です。正確なところは自信ありませんが、このisinstではIsInstanceOfAny_NoCacheLookupという関数が呼び出されるようです。

https://github.com/dotnet/runtime/blob/9893d145ebb61ae592afa1a64baa2f6f094b1f6b/src/coreclr/vm/jithelpers.cpp#L1083C18-L1103

この先を掘っていくと……

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()という判定が入っています。

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()はこちらです。

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 }

https://sharplab.io/#v2:C4LgTgrgdgPgAgJgIwFgBQcAMACOSAsA3OugG4CGY2lY5AntgLzZQCmA7tgKIDaAugG9uAOgCCAGhEAhSV2EBhbAF9iaMpWwAbAJYBnYE2wAKAJIAZPcAA82qMAB8ASiMB7AEYArVgGNgjmvSq6Dr6PJh8hgiqeACcRgF0YXyOhNgA9GnY8iRorFAQALbc2EIS2DJZykA===

この挙動の背景は?

dotnet/runtimeのコミット履歴を遡ってみましたが、.NET Coreの初期リリースから存在しているため、おそらく.NET Framework時代からの挙動なのでしょう。

次に私は、これが.NET の仕様書に書かれていないかを調べました。
https://ecma-international.org/publications-and-standards/standards/ecma-335/

すると次のような項目がありました。

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

isinstcastclass命令における型の関係は、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.
(略)

Tarray-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:

  1. V is compatible-with W; or
  2. V and W have the same reduced type.

reduced typeはこちらです:

The reduced type of a type T is the following:

  1. 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.
  2. 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

https://sharplab.io/#v2:C4LgTgrgdgPgAgJgIwFgBQ6BuBDMACXMbATzwF48oBTAdzwEspgBtJAXWbYG51CTmADG3KVaDJq27osuAmCLEAqgEkmIgBQRGLDmwCU6gPYAjAFZUAxsD19iPDGlsqJQwcIpamAOgCy2AB4AatgANhBU9uhwSACc6rZubnpceAD0qXgAtKhoQA==

感想

配列型の符号あり・符号なしのキャストを、コピーなしに実現したいモチベーションは理解できます。ただ、最新のC#でこの類の操作をするならSpan<T>にしてからMemoryMarshal.Cast()を使ったり、あるいはUnsafe.As<T>()を使ったりすることでしょう(こちらなら整数以外にも変換できますし)。これが安全な、仕様に定義された手段として提供されていたとは驚きました。

Discussion