فصل ۱۸: AssemblyBuilder و TypeBuilder، Generic Emission و نوشتن IL Disassembler
فروش یا انتشار این ترجمه منوط به داشتن مجوز لازم از صاحب حقوق اثر است.
تولید اسمبلیها و Typeها (Emitting Assemblies and Types)
با اینکه DynamicMethod راحت است، فقط میتواند متد تولید کند. اگر لازم باشد هر سازهٔ دیگری—یا یک type کامل—را emit کنید، باید از API کامل و «سنگینوزن» استفاده کنید. این یعنی ساخت پویای یک assembly و یک module.
assembly لازم نیست روی دیسک حضور داشته باشد؛ در واقع نمیتواند، زیرا .NET 5+ و .NET Core اجازه نمیدهند assemblyهای تولیدشده را روی دیسک ذخیره کنید.
فرض کنیم میخواهیم یک type را بهصورت پویا بسازیم. چون type باید در moduleای داخل assembly قرار داشته باشد، ابتدا باید assembly و module را بسازیم و بعد type را ایجاد کنیم. این کار بر عهدهٔ typeهای AssemblyBuilder و ModuleBuilder است:
AssemblyName aname = new AssemblyName ("MyDynamicAssembly");
AssemblyBuilder assemBuilder =
AssemblyBuilder.DefineDynamicAssembly (aname, AssemblyBuilderAccess.Run);
ModuleBuilder modBuilder = assemBuilder.DefineDynamicModule ("DynModule");
بعد از داشتن moduleای که type بتواند در آن قرار گیرد، با TypeBuilder type را ایجاد میکنیم. کد زیر کلاسی به نام Widget تعریف میکند:
TypeBuilder tb = modBuilder.DefineType ("Widget", TypeAttributes.Public);
flags enumِ TypeAttributes modifierهای type در CLR را پشتیبانی میکند که هنگام disassemble کردن type با ildasm مشاهده میکنید. علاوه بر flagهای visibility اعضا، modifierهایی مانند Abstract و Sealed و نیز Interface برای تعریف interface در .NET را شامل میشود. همچنین Serializable را دارد که معادل اعمال attributeِ [Serializable] در C# است و Explicit که معادل اعمال [StructLayout(LayoutKind.Explicit)] است. نحوهٔ اعمال انواع دیگر attribute را بعدتر در بخش «Attaching Attributes» در صفحهٔ ۸۴۹ توضیح میدهیم.
اکنون میتوانیم داخل type عضو ایجاد کنیم:
MethodBuilder methBuilder = tb.DefineMethod ("SayHello",
MethodAttributes.Public,
null, null);
ILGenerator gen = methBuilder.GetILGenerator();
gen.EmitWriteLine ("Hello world");
gen.Emit (OpCodes.Ret);
حالا آمادهایم type را ایجاد کنیم؛ این کار تعریف آن را نهایی میکند:
Type t = tb.CreateType();
پس از ایجاد type، میتوانیم از Reflection معمولی برای بررسی و Late Binding استفاده کنیم:
object o = Activator.CreateInstance (t);
t.GetMethod ("SayHello").Invoke (o, null); // Hello world
مدل شیء Reflection.Emit
شکل ۱۸-۲ typeهای اصلی در System.Reflection.Emit را نشان میدهد. هر type یک سازهٔ CLR را توصیف میکند و بر یک همتای خود در فضای نام System.Reflection استوار است. این موضوع اجازه میدهد هنگام ساخت یک type، سازههای emitشده را بهجای سازههای معمول بهکار ببرید. برای مثال، پیشتر Console.WriteLine را چنین فراخوانی کردیم:
MethodInfo writeLine = typeof(Console).GetMethod ("WriteLine",
new Type[] { typeof (string) });
gen.Emit (OpCodes.Call, writeLine);
به همان سادگی میتوانیم با دادن MethodBuilder بهجای MethodInfo در gen.Emit یک متد پویا را فراخوانی کنیم. این قابلیت ضروری است؛ در غیر این صورت نمیتوانستید یک dynamic method بنویسید که متد dynamic دیگری را در همان type فراخوانی کند.
شکل ۱۸-۲ — System.Reflection.Emit و ارتباط Builderها با نوعهای Reflection
به یاد داشته باشید پس از پایان پرکردن یک TypeBuilder باید CreateType را فراخوانی کنید. فراخوانی CreateType، TypeBuilder و همهٔ اعضای آن را seal میکند—پس دیگر چیزی نمیتوان اضافه یا تغییر داد—و یک Type واقعی تحویل میدهد که قابل نمونهسازی است.
پیش از فراخوانی CreateType، TypeBuilder و اعضای آن در وضعیت «ایجادنشده» (Uncreated) هستند. محدودیتهای مهمی دربارهٔ کارهایی که با سازههای ایجادنشده میتوان انجام داد وجود دارد. بهویژه نمیتوانید هیچکدام از عضوهایی را که شیء MemberInfo برمیگردانند—مانند GetMembers، GetMethod یا GetProperty—فراخوانی کنید؛ همه exception ایجاد میکنند. اگر بخواهید به اعضای یک type ایجادنشده ارجاع دهید، باید emissionهای اصلی را نگه دارید:
TypeBuilder tb = ...
MethodBuilder method1 = tb.DefineMethod ("Method1", ...);
MethodBuilder method2 = tb.DefineMethod ("Method2", ...);
ILGenerator gen1 = method1.GetILGenerator();
// Suppose we want method1 to call method2:
gen1.Emit (OpCodes.Call, method2); // Right
gen1.Emit (OpCodes.Call, tb.GetMethod ("Method2")); // Wrong
بعد از فراخوانی CreateType، علاوه بر Type برگرداندهشده، میتوانید روی خود شیء اصلی TypeBuilder نیز Reflection و Activation انجام دهید. در واقع TypeBuilder به یک proxy برای Type واقعی تبدیل میشود. در بخش «Awkward Emission Targets» در صفحهٔ ۸۵۱ خواهید دید چرا این قابلیت مهم است.
تولید اعضای Type (Emitting Type Members)
تمام مثالهای این بخش فرض میکنند یک TypeBuilder با نام tb به شکل زیر نمونهسازی شده است:
AssemblyName aname = new AssemblyName ("MyEmissions");
AssemblyBuilder assemBuilder = AssemblyBuilder.DefineDynamicAssembly (
aname, AssemblyBuilderAccess.Run);
ModuleBuilder modBuilder = assemBuilder.DefineDynamicModule ("MainModule");
TypeBuilder tb = modBuilder.DefineType ("Widget", TypeAttributes.Public);
تولید متدها
هنگام فراخوانی DefineMethod میتوانید نوع بازگشتی و نوع پارامترها را مشخص کنید، درست همانطور که هنگام نمونهسازی DynamicMethod انجام میشود. برای مثال، متد زیر:
public static double SquareRoot (double value) => Math.Sqrt (value);
میتواند چنین تولید شود:
MethodBuilder mb = tb.DefineMethod ("SquareRoot",
MethodAttributes.Static | MethodAttributes.Public,
CallingConventions.Standard,
typeof (double), // Return type
new[] { typeof (double) } ); // Parameter types
mb.DefineParameter (1, ParameterAttributes.None, "value"); // Assign name
ILGenerator gen = mb.GetILGenerator();
gen.Emit (OpCodes.Ldarg_0); // Load 1st arg
gen.Emit (OpCodes.Call, typeof(Math).GetMethod ("Sqrt"));
gen.Emit (OpCodes.Ret);
Type realType = tb.CreateType();
double x = (double) tb.GetMethod ("SquareRoot").Invoke (null,
new object[] { 10.0 });
Console.WriteLine (x); // 3.16227766016838
فراخوانی DefineParameter اختیاری است و معمولاً برای نامگذاری پارامتر انجام میشود. عدد ۱ به پارامتر اول اشاره میکند—۰ به مقدار بازگشتی اشاره دارد. اگر DefineParameter را فراخوانی نکنید، پارامتر بهطور ضمنی __p1، __p2 و مانند آن نام میگیرد. اگر اسمبلی را روی دیسک مینویسید، نامگذاری منطقی است و متدها را برای مصرفکنندگان خواناتر میکند.
برای emit کردن پارامتر pass-by-reference مانند متد C# زیر:
public static void SquareRoot (ref double value)
=> value = Math.Sqrt (value);
روی نوع پارامتر MakeByRefType را فراخوانی کنید:
MethodBuilder mb = tb.DefineMethod ("SquareRoot",
MethodAttributes.Static | MethodAttributes.Public,
CallingConventions.Standard,
null,
new Type[] { typeof (double).MakeByRefType() } );
mb.DefineParameter (1, ParameterAttributes.None, "value");
ILGenerator gen = mb.GetILGenerator();
gen.Emit (OpCodes.Ldarg_0);
gen.Emit (OpCodes.Ldarg_0);
gen.Emit (OpCodes.Ldind_R8);
gen.Emit (OpCodes.Call, typeof (Math).GetMethod ("Sqrt"));
gen.Emit (OpCodes.Stind_R8);
gen.Emit (OpCodes.Ret);
Type realType = tb.CreateType();
object[] args = { 10.0 };
tb.GetMethod ("SquareRoot").Invoke (null, args);
Console.WriteLine (args[0]); // 3.16227766016838
opcodeهای اینجا از یک متد C# disassembleشده کپی شدهاند. به تفاوت semantics دسترسی به پارامترهایی که by reference پاس داده شدهاند توجه کنید: Ldind و Stind بهترتیب «load indirectly» و «store indirectly» معنا میدهند. پسوند R8 یعنی عدد floating-point هشتبایتی.
فرایند emit کردن پارامترهای out یکسان است، جز اینکه DefineParameter را به شکل زیر فراخوانی میکنید:
mb.DefineParameter (1, ParameterAttributes.Out, "value");
تولید متدهای Instance
برای تولید یک instance method، هنگام فراخوانی DefineMethod مقدار MethodAttributes.Instance را مشخص کنید:
MethodBuilder mb = tb.DefineMethod ("SquareRoot",
MethodAttributes.Instance | MethodAttributes.Public
...
در instance methodها، آرگومان صفر بهطور ضمنی this است و آرگومانهای باقیمانده از ۱ شروع میشوند. بنابراین Ldarg_0، this را روی evaluation stack بارگذاری میکند و Ldarg_1 نخستین آرگومان واقعی متد را.
Override کردن متدها
Override کردن یک virtual method در base class ساده است: متدی با نام، signature و return type یکسان تعریف کنید و هنگام فراخوانی DefineMethod مقدار MethodAttributes.Virtual را مشخص کنید. همین قاعده هنگام پیادهسازی متدهای interface نیز برقرار است.
TypeBuilder همچنین متدی به نام DefineMethodOverride دارد که متدی با نام متفاوت را override میکند. این فقط در explicit interface implementation معنا دارد؛ در سناریوهای دیگر از DefineMethod استفاده کنید.
HideBySig
اگر از type دیگری subclass میسازید، تقریباً همیشه ارزش دارد هنگام تعریف متدها MethodAttributes.HideBySig را مشخص کنید. HideBySig تضمین میکند semantics مربوط به method hiding در C# اعمال شود؛ یعنی متد base فقط وقتی مخفی شود که subtype متدی با signature یکسان تعریف کند. بدون HideBySig، method hiding فقط نام را در نظر میگیرد؛ در نتیجه Foo(string) در subtype میتواند Foo() در base type را مخفی کند که معمولاً نامطلوب است.
تولید Field و Property
برای ایجاد field، روی TypeBuilder متد DefineField را فراخوانی میکنید و نام، type و visibility موردنظر را مشخص میکنید. کد زیر field خصوصی integerای به نام length میسازد:
FieldBuilder field = tb.DefineField ("length", typeof (int),
FieldAttributes.Private);
ایجاد property یا indexer چند مرحلهٔ بیشتر میخواهد. ابتدا روی TypeBuilder، DefineProperty را فراخوانی کنید و نام و type property را بدهید:
PropertyBuilder prop = tb.DefineProperty (
"Text", // Name of property
PropertyAttributes.None,
typeof (string), // Property type
new Type[0] // Indexer types
);
اگر indexer مینویسید، آرگومان آخر آرایهای از typeهای indexer است. توجه کنید visibility property را هنوز مشخص نکردهایم؛ visibility بهصورت جداگانه روی accessor methodها تعیین میشود.
گام بعد نوشتن متدهای get و set است. طبق convention، نام آنها با get_ یا set_ شروع میشود. سپس با فراخوانی SetGetMethod و SetSetMethod روی PropertyBuilder آنها را به property متصل میکنید.
برای یک مثال کامل، declaration زیر را در نظر بگیرید:
string _text;
public string Text
{
get => _text;
internal set => _text = value;
}
و آن را بهصورت پویا تولید کنید:
FieldBuilder field = tb.DefineField ("_text", typeof (string),
FieldAttributes.Private);
PropertyBuilder prop = tb.DefineProperty (
"Text", // Name of property
PropertyAttributes.None,
typeof (string), // Property type
new Type[0]); // Indexer types
MethodBuilder getter = tb.DefineMethod (
"get_Text", // Method name
MethodAttributes.Public | MethodAttributes.SpecialName,
typeof (string), // Return type
new Type[0]); // Parameter types
ILGenerator getGen = getter.GetILGenerator();
getGen.Emit (OpCodes.Ldarg_0); // Load "this" onto eval stack
getGen.Emit (OpCodes.Ldfld, field); // Load field value onto eval stack
getGen.Emit (OpCodes.Ret); // Return
MethodBuilder setter = tb.DefineMethod (
"set_Text",
MethodAttributes.Assembly | MethodAttributes.SpecialName,
null, // Return type
new Type[] { typeof (string) } ); // Parameter types
ILGenerator setGen = setter.GetILGenerator();
setGen.Emit (OpCodes.Ldarg_0); // Load "this" onto eval stack
setGen.Emit (OpCodes.Ldarg_1); // Load 2nd arg, i.e., value
setGen.Emit (OpCodes.Stfld, field); // Store value into field
setGen.Emit (OpCodes.Ret); // return
prop.SetGetMethod (getter); // Link the get method and property
prop.SetSetMethod (setter); // Link the set method and property
Property را میتوانیم چنین تست کنیم:
Type t = tb.CreateType();
object o = Activator.CreateInstance (t);
t.GetProperty ("Text").SetValue (o, "Good emissions!", new object[0]);
string text = (string) t.GetProperty ("Text").GetValue (o, null);
Console.WriteLine (text); // Good emissions!
توجه کنید در تعریف MethodAttributes accessorها، SpecialName را اضافه کردیم. این کار به کامپایلرها میگوید هنگام reference ایستا به assembly، direct binding به این متدها را مجاز ندانند. همچنین تضمین میکند ابزارهای Reflection و IntelliSense در Visual Studio accessorها را درست مدیریت کنند.
تولید Constructorها
میتوانید با فراخوانی DefineConstructor روی type builder، constructorهای خودتان را تعریف کنید. مجبور نیستید این کار را انجام دهید: اگر هیچ constructorی تعریف نکنید، یک constructor پیشفرض بدون پارامتر بهصورت خودکار فراهم میشود. اگر subtype میسازید، constructor پیشفرض مانند C#، constructorِ base class را فراخوانی میکند. تعریف یک یا چند constructor، این constructor پیشفرض را کنار میزند.
اگر لازم است fieldها را initialize کنید، constructor جای مناسبی است. در واقع تنها جای ممکن همین است: field initializerهای C# پشتیبانی ویژهای در CLR ندارند و فقط یک میانبر نحوی برای نسبتدادن مقدار به fieldها در constructor هستند.
بنابراین برای بازتولید این کد:
class Widget
{
int _capacity = 4000;
}
constructor را چنین تعریف میکنید:
FieldBuilder field = tb.DefineField ("_capacity", typeof (int),
FieldAttributes.Private);
ConstructorBuilder c = tb.DefineConstructor (
MethodAttributes.Public,
CallingConventions.Standard,
new Type[0]); // Constructor parameters
ILGenerator gen = c.GetILGenerator();
gen.Emit (OpCodes.Ldarg_0); // Load "this" onto eval stack
gen.Emit (OpCodes.Ldc_I4, 4000); // Load 4000 onto eval stack
gen.Emit (OpCodes.Stfld, field); // Store it to our field
gen.Emit (OpCodes.Ret);
فراخوانی Constructorهای Base
اگر از type دیگری subclass بسازید، constructor بالا constructorِ base class را دور میزند. این با C# فرق دارد؛ در C# constructorِ base class همیشه مستقیم یا غیرمستقیم فراخوانی میشود. برای نمونه، در کد زیر:
class A { public A() { Console.Write ("A"); } }
class B : A { public B() {} }
کامپایلر در عمل خط دوم را چنین ترجمه میکند:
class B : A { public B() : base() {} }
هنگام تولید IL چنین اتفاقی خودکار نیست؛ اگر میخواهید constructorِ base اجرا شود—که تقریباً همیشه میخواهید—باید صریحاً آن را فراخوانی کنید. با فرض اینکه base class نامش A است:
gen.Emit (OpCodes.Ldarg_0);
ConstructorInfo baseConstr = typeof (A).GetConstructor (new Type[0]);
gen.Emit (OpCodes.Call, baseConstr);
فراخوانی constructorهایی که آرگومان دارند دقیقاً مانند متدهاست.
متصلکردن Attributeها (Attaching Attributes)
میتوانید با فراخوانی SetCustomAttribute همراه یک CustomAttributeBuilder، custom attribute را به یک سازهٔ dynamic متصل کنید. برای مثال، فرض کنید میخواهیم declaration زیر را به یک field یا property متصل کنیم:
[XmlElement ("FirstName", Namespace="http://test/", Order=3)]
این declaration به constructorِ XmlElementAttribute متکی است که یک string میپذیرد. برای استفاده از CustomAttributeBuilder باید این constructor و نیز دو property اضافی که میخواهیم تنظیم کنیم—Namespace و Order—را بازیابی کنیم:
Type attType = typeof (XmlElementAttribute);
ConstructorInfo attConstructor = attType.GetConstructor (
new Type[] { typeof (string) } );
var att = new CustomAttributeBuilder (
attConstructor, // Constructor
new object[] { "FirstName" }, // Constructor arguments
new PropertyInfo[]
{
attType.GetProperty ("Namespace"), // Properties
attType.GetProperty ("Order")
},
new object[] { "http://test/", 3 } // Property values
);
myFieldBuilder.SetCustomAttribute (att);
// or propBuilder.SetCustomAttribute (att);
// or typeBuilder.SetCustomAttribute (att); etc
تولید متدها و Typeهای Generic
همهٔ مثالهای این بخش فرض میکنند modBuilder به شکل زیر نمونهسازی شده است:
AssemblyName aname = new AssemblyName ("MyEmissions");
AssemblyBuilder assemBuilder = AssemblyBuilder.DefineDynamicAssembly (
aname, AssemblyBuilderAccess.Run);
ModuleBuilder modBuilder = assemBuilder.DefineDynamicModule ("MainModule");
تعریف متدهای Generic
برای emit کردن یک generic method:
DefineGenericParameters را روی MethodBuilder فراخوانی کنید تا آرایهای از اشیای GenericTypeParameterBuilder بگیرید.
SetSignature را روی MethodBuilder و با استفاده از همین generic type parameterها فراخوانی کنید.- در صورت تمایل، پارامترها را مانند حالت عادی نامگذاری کنید.
برای مثال متد Generic زیر:
public static T Echo<T> (T value)
{
return value;
}
میتواند چنین emit شود:
TypeBuilder tb = modBuilder.DefineType ("Widget", TypeAttributes.Public);
MethodBuilder mb = tb.DefineMethod ("Echo", MethodAttributes.Public |
MethodAttributes.Static);
GenericTypeParameterBuilder[] genericParams
= mb.DefineGenericParameters ("T");
mb.SetSignature (genericParams[0], // Return type
null, null,
genericParams, // Parameter types
null, null);
mb.DefineParameter (1, ParameterAttributes.None, "value"); // Optional
ILGenerator gen = mb.GetILGenerator();
gen.Emit (OpCodes.Ldarg_0);
gen.Emit (OpCodes.Ret);
متد DefineGenericParameters هر تعداد آرگومان رشتهای را میپذیرد؛ این رشتهها نام نوعهای Generic موردنظر هستند. در این مثال فقط به یک نوع Generic با نام T نیاز داشتیم. GenericTypeParameterBuilder بر پایهٔ System.Type است، پس هنگام emit کردن opcodeها میتوانید آن را بهجای TypeBuilder استفاده کنید.
GenericTypeParameterBuilder همچنین اجازه میدهد constraint مربوط به base type را مشخص کنید:
genericParams[0].SetBaseTypeConstraint (typeof (Foo));
و constraintهای interface:
genericParams[0].SetInterfaceConstraints (typeof (IComparable));
برای بازتولید کد زیر:
public static T Echo<T> (T value) where T : IComparable<T>
مینویسید:
genericParams[0].SetInterfaceConstraints (
typeof (IComparable<>).MakeGenericType (genericParams[0]) );
برای انواع دیگر constraint، SetGenericParameterAttributes را فراخوانی کنید. این متد عضوی از enumِ GenericParameterAttributes را میپذیرد که از جمله مقادیر زیر را دارد:
DefaultConstructorConstraint
NotNullableValueTypeConstraint
ReferenceTypeConstraint
Covariant
Contravariant
دو مقدار آخر معادل اعمال modifierهای out و in روی type parameterها هستند.
تعریف Typeهای Generic
میتوانید typeهای Generic را به شیوهای مشابه تعریف کنید. تفاوت این است که DefineGenericParameters را بهجای MethodBuilder روی TypeBuilder فراخوانی میکنید. بنابراین برای بازتولید:
public class Widget<T>
{
public T Value;
}
چنین عمل میکنید:
TypeBuilder tb = modBuilder.DefineType ("Widget", TypeAttributes.Public);
GenericTypeParameterBuilder[] genericParams
= tb.DefineGenericParameters ("T");
tb.DefineField ("Value", genericParams[0], FieldAttributes.Public);
constraintهای Generic را نیز درست مانند متد میتوان اضافه کرد.
مقصدهای دشوار برای Emission (Awkward Emission Targets)
همهٔ مثالهای این بخش فرض میکنند modBuilder مانند بخشهای قبل نمونهسازی شده است.
Genericهای بستهٔ ایجادنشده
فرض کنید میخواهید متدی emit کنید که از یک نوع Generic بسته استفاده کند:
public class Widget
{
public static void Test() { var list = new List<int>(); }
}
فرایند نسبتاً ساده است:
TypeBuilder tb = modBuilder.DefineType ("Widget", TypeAttributes.Public);
MethodBuilder mb = tb.DefineMethod ("Test", MethodAttributes.Public |
MethodAttributes.Static);
ILGenerator gen = mb.GetILGenerator();
Type variableType = typeof (List<int>);
ConstructorInfo ci = variableType.GetConstructor (new Type[0]);
LocalBuilder listVar = gen.DeclareLocal (variableType);
gen.Emit (OpCodes.Newobj, ci);
gen.Emit (OpCodes.Stloc, listVar);
gen.Emit (OpCodes.Ret);
حالا فرض کنید بهجای لیستی از integerها، لیستی از widgetها میخواهیم:
public class Widget
{
public static void Test() { var list = new List<Widget>(); }
}
از نظر نظری تغییر سادهای است؛ فقط این خط را:
Type variableType = typeof (List<int>);
با این خط جایگزین میکنیم:
Type variableType = typeof (List<>).MakeGenericType (tb);
متأسفانه، وقتی بعداً GetConstructor را فراخوانی میکنیم، این کار باعث NotSupportedException میشود. مشکل این است که نمیتوانید GetConstructor را روی یک نوع Generic که با یک type builder ایجادنشده بسته شده است فراخوانی کنید. همین موضوع برای GetField و GetMethod نیز صدق میکند.
راهحل غیرشهودی است. TypeBuilder سه متد static زیر را ارائه میکند:
public static ConstructorInfo GetConstructor (Type, ConstructorInfo);
public static FieldInfo GetField (Type, FieldInfo);
public static MethodInfo GetMethod (Type, MethodInfo);
با اینکه از ظاهرشان مشخص نیست، این متدها مشخصاً برای بهدستآوردن اعضای typeهای Generic که با type builder ایجادنشده بسته شدهاند وجود دارند. پارامتر اول نوع Generic بسته است؛ پارامتر دوم عضوی است که روی نوع Generic نامقید میخواهید. نسخهٔ اصلاحشدهٔ مثال:
MethodBuilder mb = tb.DefineMethod ("Test", MethodAttributes.Public |
MethodAttributes.Static);
ILGenerator gen = mb.GetILGenerator();
Type variableType = typeof (List<>).MakeGenericType (tb);
ConstructorInfo unbound = typeof (List<>).GetConstructor (new Type[0]);
ConstructorInfo ci = TypeBuilder.GetConstructor (variableType, unbound);
LocalBuilder listVar = gen.DeclareLocal (variableType);
gen.Emit (OpCodes.Newobj, ci);
gen.Emit (OpCodes.Stloc, listVar);
gen.Emit (OpCodes.Ret);
وابستگیهای دوری (Circular Dependencies)
فرض کنید میخواهید دو type بسازید که به یکدیگر reference دارند، مانند این دو:
class A { public B Bee; }
class B { public A Aye; }
میتوانید آنها را بهصورت پویا چنین تولید کنید:
var publicAtt = FieldAttributes.Public;
TypeBuilder aBuilder = modBuilder.DefineType ("A");
TypeBuilder bBuilder = modBuilder.DefineType ("B");
FieldBuilder bee = aBuilder.DefineField ("Bee", bBuilder, publicAtt);
FieldBuilder aye = bBuilder.DefineField ("Aye", aBuilder, publicAtt);
Type realA = aBuilder.CreateType();
Type realB = bBuilder.CreateType();
توجه کنید تا زمانی که هر دو object را پر نکردیم، روی aBuilder یا bBuilder، CreateType را صدا نزدیم. اصل کار این است: ابتدا همهچیز را به هم متصل کنید و سپس روی هر type builder، CreateType را فراخوانی کنید.
نکتهٔ جالب اینکه typeِ realA معتبر است اما تا وقتی CreateType روی bBuilder فراخوانی نشود عملاً کار نمیکند. اگر پیش از آن استفاده از aBuilder را شروع کنید، هنگام تلاش برای دسترسی به fieldِ Bee exception رخ میدهد.
ممکن است بپرسید bBuilder بعد از ساخت realB از کجا میداند realA را «fix up» کند. پاسخ این است که نمیداند: realA دفعهٔ بعد که استفاده شود میتواند خودش را اصلاح کند. این امکان وجود دارد چون بعد از CreateType، یک TypeBuilder به proxy برای runtime type واقعی تبدیل میشود. بنابراین realA با referenceهایش به bBuilder بهسادگی metadata لازم برای ارتقا را بهدست میآورد.
این سیستم زمانی کار میکند که type builder از type ساختهنشده اطلاعات سادهای بخواهد—اطلاعاتی که از پیش قابل تعییناند—مانند referenceهای type، member و object. هنگام ساخت realA، type builder لازم نیست مثلاً بداند realB در نهایت چند byte در حافظه اشغال خواهد کرد؛ و خوب است که لازم نیست، چون realB هنوز ساخته نشده است. اما حالا تصور کنید realB یک struct باشد؛ اندازهٔ نهایی realB اکنون برای ساخت realA اطلاعات حیاتی است.
اگر رابطه noncyclical باشد، مثلاً:
struct A { public B Bee; }
struct B { }
میتوانید ابتدا structِ B و سپس A را بسازید. اما این را در نظر بگیرید:
struct A { public B Bee; }
struct B { public A Aye; }
این را emit نمیکنیم چون داشتن دو struct که یکدیگر را در خود نگه دارند بیمعناست؛ C# هم اگر تلاش کنید در زمان کامپایل خطا میدهد. اما variation زیر هم قانونی است و هم مفید:
public struct S<T> { ... } // S can be empty and this demo will work.
class A { S<B> Bee; }
class B { S<A> Aye; }
در ساخت A، اکنون TypeBuilder باید footprint حافظهٔ B را بداند و برعکس. برای نمایش مسئله، فرض میکنیم structِ S بهصورت static تعریف شده است. کد emit کردن کلاسهای A و B:
var pub = FieldAttributes.Public;
TypeBuilder aBuilder = modBuilder.DefineType ("A");
TypeBuilder bBuilder = modBuilder.DefineType ("B");
aBuilder.DefineField ("Bee", typeof(S<>).MakeGenericType (bBuilder), pub);
bBuilder.DefineField ("Aye", typeof(S<>).MakeGenericType (aBuilder), pub);
Type realA = aBuilder.CreateType(); // Error: cannot load type B
Type realB = bBuilder.CreateType();
اکنون CreateType فارغ از ترتیب فراخوانی، TypeLoadException ایجاد میکند:
- اگر ابتدا
aBuilder.CreateType را فراخوانی کنید، میگوید «cannot load type B». - اگر ابتدا
bBuilder.CreateType را فراخوانی کنید، میگوید «cannot load type A».
برای حل این مسئله باید اجازه دهید type builder در میانهٔ ساخت realA، realB را بسازد. این کار را با handle کردن eventِ TypeResolve روی کلاس AppDomain درست پیش از فراخوانی CreateType انجام میدهید. در مثال ما دو خط آخر را با این کد جایگزین میکنیم:
TypeBuilder[] uncreatedTypes = { aBuilder, bBuilder };
ResolveEventHandler handler = delegate (object o, ResolveEventArgs args)
{
var type = uncreatedTypes.FirstOrDefault (t => t.FullName == args.Name);
return type == null ? null : type.CreateType().Assembly;
};
AppDomain.CurrentDomain.TypeResolve += handler;
Type realA = aBuilder.CreateType();
Type realB = bBuilder.CreateType();
AppDomain.CurrentDomain.TypeResolve -= handler;
eventِ TypeResolve هنگام فراخوانی aBuilder.CreateType، در نقطهای که لازم است شما CreateType را روی bBuilder فراخوانی کنید، fire میشود.
Parse کردن IL
میتوانید با فراخوانی GetMethodBody روی یک شیء MethodBase اطلاعات محتوای یک متد موجود را بهدست آورید. این متد یک شیء MethodBody برمیگرداند که propertyهایی برای بررسی local variableها، clauseهای exception handling، اندازهٔ stack و نیز IL خام دارد؛ چیزی شبیه معکوس Reflection.Emit.
بررسی IL خام یک متد میتواند برای profiling کد مفید باشد. یک کاربرد ساده این است که مشخص کنید هنگام update شدن یک assembly، کدام متدها تغییر کردهاند.
برای نشاندادن parse کردن IL، برنامهای مینویسیم که IL را به سبک ildasm disassemble کند. این برنامه میتواند نقطهٔ شروع یک ابزار تحلیل کد یا یک disassembler زبان سطح بالاتر باشد.
نوشتن یک Disassembler
در ادامه نمونهای از خروجیای را میبینید که disassembler ما تولید خواهد کرد:
IL_00EB: ldfld Disassembler._pos
IL_00F0: ldloc.2
IL_00F1: add
IL_00F2: ldelema System.Byte
IL_00F7: ldstr "Hello world"
IL_00FC: call System.Byte.ToString
IL_0101: ldstr " "
IL_0106: call System.String.Concat
برای تولید این خروجی باید tokenهای باینری تشکیلدهندهٔ IL را parse کنیم. نخستین گام فراخوانی GetILAsByteArray روی MethodBody است تا IL را بهصورت آرایهٔ byte بهدست آوریم. برای آسانترشدن ادامهٔ کار، این منطق را داخل یک کلاس به شکل زیر مینویسیم:
public class Disassembler
{
public static string Disassemble (MethodBase method)
=> new Disassembler (method).Dis();
StringBuilder _output; // The result to which we'll keep appending
Module _module; // This will come in handy later
byte[] _il; // The raw byte code
int _pos; // The position we're up to in the byte code
Disassembler (MethodBase method)
{
_module = method.DeclaringType.Module;
_il = method.GetMethodBody().GetILAsByteArray();
}
string Dis()
{
_output = new StringBuilder();
while (_pos < _il.Length) DisassembleNextInstruction();
return _output.ToString();
}
}
متد staticِ Disassemble تنها عضو public این کلاس خواهد بود. همهٔ اعضای دیگر private و مخصوص فرایند disassembly هستند. متد Dis حلقهٔ «اصلی» را در خود دارد که هر instruction را پردازش میکند.
با آمادهشدن این اسکلت، تنها چیزی که باقی میماند نوشتن DisassembleNextInstruction است. اما پیش از آن بهتر است همهٔ opcodeها را در یک dictionary ایستا بارگذاری کنیم تا بتوانیم بر اساس مقدار ۸ یا ۱۶ بیتی به آنها دسترسی داشته باشیم. سادهترین راه، استفاده از Reflection برای بازیابی همهٔ fieldهای static کلاس OpCodes است که type آنها OpCode است:
static Dictionary<short,OpCode> _opcodes = new Dictionary<short,OpCode>();
static Disassembler()
{
Dictionary<short, OpCode> opcodes = new Dictionary<short, OpCode>();
foreach (FieldInfo fi in typeof (OpCodes).GetFields
(BindingFlags.Public | BindingFlags.Static))
if (typeof (OpCode).IsAssignableFrom (fi.FieldType))
{
OpCode code = (OpCode) fi.GetValue (null); // Get field's value
if (code.OpCodeType != OpCodeType.Nternal)
_opcodes.Add (code.Value, code);
}
}
این کد را در static constructor نوشتهایم تا فقط یکبار اجرا شود.
اکنون میتوانیم DisassembleNextInstruction را بنویسیم. هر instruction در IL از یک opcode یک یا دو بایتی تشکیل شده است که پس از آن operandای با طول صفر، یک، دو، چهار یا هشت byte میآید. یک استثنا opcodeهای inline switch هستند که پس از آنها تعداد متغیری operand میآید. بنابراین opcode را میخوانیم، سپس operand را و نتیجه را مینویسیم:
void DisassembleNextInstruction()
{
int opStart = _pos;
OpCode code = ReadOpCode();
string operand = ReadOperand (code);
_output.AppendFormat ("IL_{0:X4}: {1,-12} {2}",
opStart, code.Name, operand);
_output.AppendLine();
}
برای خواندن یک opcode، یک byte جلو میرویم و بررسی میکنیم آیا instruction معتبری داریم یا نه. اگر نه، byte دیگری جلو میرویم و بهدنبال instruction دوبایتی میگردیم:
OpCode ReadOpCode()
{
byte byteCode = _il [_pos++];
if (_opcodes.ContainsKey (byteCode)) return _opcodes [byteCode];
if (_pos == _il.Length) throw new Exception ("Unexpected end of IL");
short shortCode = (short) (byteCode * 256 + _il [_pos++]);
if (!_opcodes.ContainsKey (shortCode))
throw new Exception ("Cannot find opcode " + shortCode);
return _opcodes [shortCode];
}
برای خواندن operand ابتدا باید طول آن را تعیین کنیم. این کار را میتوان بر اساس operand type انجام داد. چون بیشتر operandها چهار byte هستند، استثناها را نسبتاً ساده با یک شرط جدا میکنیم.
گام بعد فراخوانی FormatOperand است که تلاش میکند operand را format کند:
string ReadOperand (OpCode c)
{
int operandLength =
c.OperandType == OperandType.InlineNone
? 0 :
c.OperandType == OperandType.ShortInlineBrTarget ||
c.OperandType == OperandType.ShortInlineI ||
c.OperandType == OperandType.ShortInlineVar
? 1 :
c.OperandType == OperandType.InlineVar
? 2 :
c.OperandType == OperandType.InlineI8 ||
c.OperandType == OperandType.InlineR
? 8 :
c.OperandType == OperandType.InlineSwitch
? 4 * (BitConverter.ToInt32 (_il, _pos) + 1) :
4; // All others are 4 bytes
if (_pos + operandLength > _il.Length)
throw new Exception ("Unexpected end of IL");
string result = FormatOperand (c, operandLength);
if (result == null)
{ // Write out operand bytes in hex
result = "";
for (int i = 0; i < operandLength; i++)
result += _il [_pos + i].ToString ("X2") + " ";
}
_pos += operandLength;
return result;
}
اگر نتیجهٔ FormatOperand مقدار null باشد، یعنی operand به formatting ویژهای نیاز ندارد و آن را ساده به صورت hexadecimal مینویسیم. در این مرحله میتوانیم disassembler را با متد FormatOperandای که همیشه null برمیگرداند آزمایش کنیم. خروجی شبیه این خواهد بود:
IL_00A8: ldfld 98 00 00 04
IL_00AD: ldloc.2
IL_00AE: add
IL_00AF: ldelema 64 00 00 01
IL_00B4: ldstr 26 04 00 70
IL_00B9: call B6 00 00 0A
IL_00BE: ldstr 11 01 00 70
IL_00C3: call 91 00 00 0A
...
هرچند opcodeها درستاند، operandها کاربرد زیادی ندارند. بهجای عددهای hexadecimal، نام memberها و stringها را میخواهیم. متد FormatOperand پس از نوشتهشدن این مشکل را حل میکند و حالتهای خاصی را که از چنین formattingای سود میبرند تشخیص میدهد. این حالتها بیشتر operandهای چهار بایتی و instructionهای short branch را شامل میشوند:
string FormatOperand (OpCode c, int operandLength)
{
if (operandLength == 0) return "";
if (operandLength == 4)
return Get4ByteOperand (c);
else if (c.OperandType == OperandType.ShortInlineBrTarget)
return GetShortRelativeTarget();
else if (c.OperandType == OperandType.InlineSwitch)
return GetSwitchTarget (operandLength);
else
return null;
}
سه نوع operand چهار بایتی را به شکل ویژه پردازش میکنیم. نخست reference به memberها یا typeهاست؛ در این حالت با فراخوانی ResolveMember روی module تعریفکننده، نام member یا type را استخراج میکنیم. حالت دوم stringها هستند؛ stringها در metadata ماژول assembly ذخیره میشوند و با ResolveString قابل بازیابیاند. حالت نهایی branch targetهاست که operand در آنها به یک byte offset در IL اشاره میکند. این مقدار را با محاسبهٔ آدرس مطلق پس از instruction جاری—بهعلاوهٔ چهار byte—format میکنیم:
string Get4ByteOperand (OpCode c)
{
int intOp = BitConverter.ToInt32 (_il, _pos);
switch (c.OperandType)
{
case OperandType.InlineTok:
case OperandType.InlineMethod:
case OperandType.InlineField:
case OperandType.InlineType:
MemberInfo mi;
try { mi = _module.ResolveMember (intOp); }
catch { return null; }
if (mi == null) return null;
if (mi.ReflectedType != null)
return mi.ReflectedType.FullName + "." + mi.Name;
else if (mi is Type)
return ((Type)mi).FullName;
else
return mi.Name;
case OperandType.InlineString:
string s = _module.ResolveString (intOp);
if (s != null) s = "'" + s + "'";
return s;
case OperandType.InlineBrTarget:
return "IL_" + (_pos + intOp + 4).ToString ("X4");
default:
return null;
}
}
برای هر opcode چهار بایتی دیگر null برمیگردانیم؛ این باعث میشود ReadOperand operand را بهصورت رقمهای hexadecimal format کند.
آخرین نوعهای operand که نیاز به توجه ویژه دارند short branch targetها و inline switchها هستند. یک short branch target، offset مقصد را بهصورت یک byte علامتدار در انتهای instruction جاری—یعنی بهعلاوهٔ یک byte—توصیف میکند. یک switch target پس از خود تعداد متغیری مقصد branch چهار بایتی دارد:
string GetShortRelativeTarget()
{
int absoluteTarget = _pos + (sbyte) _il [_pos] + 1;
return "IL_" + absoluteTarget.ToString ("X4");
}
string GetSwitchTarget (int operandLength)
{
int targetCount = BitConverter.ToInt32 (_il, _pos);
string [] targets = new string [targetCount];
for (int i = 0; i < targetCount; i++)
{
int ilTarget = BitConverter.ToInt32 (_il, _pos + (i + 1) * 4);
targets [i] = "IL_" + (_pos + ilTarget + operandLength).ToString ("X4");
}
return "(" + string.Join (", ", targets) + ")";
}
با این بخش، disassembler کامل میشود. میتوانیم آن را با disassemble کردن یکی از متدهای خودش آزمایش کنیم:
MethodInfo mi = typeof (Disassembler).GetMethod (
"ReadOperand", BindingFlags.Instance | BindingFlags.NonPublic);
Console.WriteLine (Disassembler.Disassemble (mi));