2020年12月6日日曜日

列挙型の濃ゆい話

この記事はC#アドベントカレンダー2020その2の6日目のはずです。

その昔(*1)、Rico Marian(*2)は言いました。「CLRがfloatまたはdouble型を基になる型として使用する列挙体をサポートしていることをご存知でしたか?」と。

ご存知のように、C#では列挙体の基になる型としてはプリミティブの整数値しか認めていません。具体的には、bytesbyteshortushortintuintlongulongです。 Ricoが言うように、C#ではfloatdoubleをサポートしていません。 さらに、Ricoは「これはECMA標準です」と言っています。 11年前の私はわざわざ検証しませんでしたが、このあたりを深堀していこうと思います。

floatやdoubleを基になる型にする列挙型を定義してみる

念のため、以下のようなC#コードを書いてコンパイルしてみます。

public enum EFloat : float { None = 0, Value = 1.0f }

結果は

error CS1008: byte、sbyte、short、ushort、int、uint、long または ulong のいずれかの型を使用してください

そうですよね。まぁ無理です。

しかし、諦めたらそこで試合終了です。C#コンパイラーでそのような列挙型をコンパイルできないからと言って、C#でそのような列挙型を定義できないことにはなりません。System.Reflection.Emit名前空間にあるリフレクション出力APIを使用して列挙型を定義すればよいのです。C#のコードなのでアドベントカレンダーとしても問題ないはずです。

列挙型というのは、ようは基底クラスがSystem.Enumで、列挙値をpublicな定数として持ち、specialnamertspecialname属性がついて、フィールドの型が基になる型になっているインスタンスフィールドvalue__を持つクラスです。さくっと定義しましょう。なお、面倒なことを少しだけ楽にしてくれるEnumBuilderというクラスがあるのでそれを使います。

var type = typeof(float); var none = 0.0f; var value = 1.0f; var ab = AssemblyBuilder.DefineDynamicAssembly(new AssemblyName("Enums"), AssemblyBuilderAccess.Run); var mb = ab.DefineDynamicModule("Enums.dll"); var eb = mb.DefineEnum($"E{type.Name}", TypeAttributes.Public, type); eb.DefineLiteral("None", none); eb.DefineLiteral("Value", value; var t = eb.CreateTypeInfo(); Console.WriteLine($"Success! ({t} : {Enum.GetUnderlyingType(t)})");

こんな感じのコードを書いて実行すると、

Success! (ESingle : System.Single)

と表示されます(floatSystem.Singleを表すC#のキーワードにすぎないので、ESingleという名前になっています)。 なるほど、確かにfloatdoubleを基になる型にした列挙型は存在できるようです。

ECMA-335を見てみる

さて、Ricoは「これはECMA標準です」と言っていました。一応、ECMA-335 Common Language Infrastructureの最新版5th Editionを見てみましょう。以下、該当部分の抜粋です(太字は筆者)。

II.14.3 Enums An enum (short for enumeration) defines a set of symbols that all have the same type. A type shall be an enum if and only if it has an immediate base type of System.Enum. Since System.Enum itself has an immediate base type of System.ValueType, (see Partition IV) enums are value types (§II.13) The symbols of an enum are represented by an underlying integer type: one of { bool, char, int8, unsigned int8, int16, unsigned int16, int32, unsigned int32, int64, unsigned int64, native int, unsigned native int }

……おや。ここにはfloat32float64(ILではfloatdoubleをそのように表現します)がありませんね。どういうことでしょうか。

アーカイブサイトで確認すると、2nd editionまでは以下のようになっていました。

...The symbols of an enum are represented by an underlying type: one of { bool, char, int8, unsigned int8, int16, unsigned int16, int32, unsigned int32, int64, unsigned int64, float32, float64, native int, unsigned native int }

なるほど。3rd editionからECMA標準ではなくなったようですね(*3)。つまり、floatdoubleを基になる型にした列挙型はECMA標準ではありません。CLRやCoreCLRが(確かめていませんがMonoも)これらを認めているのは後方互換性を保つためでしょう(*4)。

整数でないプリミティブ型を基になる型とする列挙型

さて、上記のECMA 335を見ると、以下の型も基になる型として認められていることが分かります。

  • boolSystem.Boolean
  • charSystem.Char
  • native intSystem.IntPtr
  • unsigned native intSystem.UIntPtr

これらについても試してみましょう。

先ほどのコンパイルエラーメッセージから、C#で書けないことはもう分かっているので、いつものようにリフレクション出力APIで作ってみましょう。

先ほどのコードのtypenonevalueを適当にいじって実行してみます。そうすると、boolcharについては動作しますが、IntPtrUIntPtrについては動作しません。

System.ArgumentException: System.IntPtr is not a supported constant type. at System.Reflection.Emit.TypeBuilder.SetConstantValue(ModuleBuilder module, Int32 tk, Type destType, Object value) at System.Reflection.Emit.FieldBuilder.SetConstant(Object defaultValue) at System.Reflection.Emit.EnumBuilder.DefineLiteral(String literalName, Object literalValue)

なるほど、DefineLiteralの引数としてIntPtrは使えないようです。なので、その行をコメントアウトすれば

Success! (EIntPtr : System.IntPtr)

と表示されます。

ちなみに、この定義した列挙値の動作ですが、基になる型がIntPtrとかUIntPtrだと、クラスライブラリ側が完全に対応しておらず、かなり怪しい動きをします。 たとえば、定義済みの列挙値についてToString()すると列挙値の名前(NoneとかValue)が返ってくるはずですが、必ず基になる値(01)で返ってきたりします。

このあたりの動作は、ILASMを使用して列挙型を定義すればできます。C#アドベントカレンダーですが、コンパイル後のコードに触れてはいけないとは書いてないので、興味のある方は試してみてください。

// .NET Frameworkの場合 //#define CORE_ASSEMBLY "mscorlib" //#define CORE_VERSION "2:0:0:0" // .NET Coreの場合 #define CORE_ASSEMBLY "System.Runtime" #define CORE_VERSION "4:2:2:0" #define THIS_ASSEMBLY "NativeIntEnums" #define THIS_MODULE "NativeIntEnums.dll" .assembly extern CORE_ASSEMBLY { .publickeytoken = (B7 7A 5C 56 19 34 E0 89 ) .ver CORE_VERSION } .assembly THIS_ASSEMBLY { .custom instance void [CORE_ASSEMBLY]System.Runtime.CompilerServices.CompilationRelaxationsAttribute::.ctor(int32) = ( 01 00 08 00 00 00 00 00 ) .custom instance void [CORE_ASSEMBLY]System.Runtime.CompilerServices.RuntimeCompatibilityAttribute::.ctor() = ( 01 00 01 00 54 02 16 57 72 61 70 4E 6F 6E 45 78 63 65 70 74 69 6F 6E 54 68 72 6F 77 73 01 ) .custom instance void [CORE_ASSEMBLY]System.Diagnostics.DebuggableAttribute::.ctor(valuetype [CORE_ASSEMBLY]System.Diagnostics.DebuggableAttribute/DebuggingModes) = ( 01 00 02 00 00 00 00 00 ) .hash algorithm 0x00008004 .ver 1:0:0:0 } .module THIS_MODULE .imagebase 0x00400000 .file alignment 0x00000200 .stackreserve 0x00100000 .subsystem 0x0003 // WINDOWS_CUI .corflags 0x00000001 // ILONLY .class sealed public auto ansi NativeIntEnum extends [mscorlib]System.Enum { .field public specialname rtspecialname native int value__ .field public static literal valuetype NativeIntEnum None = int32(0) .field public static literal valuetype NativeIntEnum Value = int32(1) }

以上、『Framework Design Guidelines 3rd Edition』翻訳の現場からお伝えしました。

*1 『.NETのクラスライブラリ設計』のp90

*2 .NET開発チームの人です。

*3 『.NETのクラスライブラリ設計』が訳されたのが2009年であり、ECMA-335の第3版が2005年であったという事実に気づいてはいけません。次の版では訳注を入れようと思います。

*4 IL2CPPや今は亡きCoreRTが気になりますね。