Note: see the link below for the English version of this article.
https://duongnt.com/dynamic-code-generation
Sau khi viết bài về FastMember, tôi đã thử tìm hiểu thêm về dynamic code generation. Đúng là việc sử dụng Expression hay ILGenerator để thực hiện meta-programming là rất phức tạp, nhưng kỹ thuật này cũng có nhiều điểm thú vị.
Trong bài hôm nay, chúng ta sẽ so sánh hiệu năng của dynamic code generation và reflection trong 2 trường hợp đơn giản: khởi tạo một object và gọi hàm của object đó (bật mí: code generation chạy nhanh hơn đáng kể). Các bạn có thể tải code của tất cả các ví dụ trong bài từ link dưới đây.
https://github.com/duongntbk/DynamicCodeGenerationSample
Mục đích cuối cùng
Như đã biết, ta có thể dùng Reflection API để khởi tạo object của một lớp như dưới đây.
var assemblyPath = "<Đường dẫn tới file dll>";
var assembly = Assembly.LoadFrom(assemblyPath);
var className = "<Tên lớp>";
var type = assembly.GetType(typeName);
var instance = Activator.CreateInstance(type); // Lớp này phải có hàm khởi tạo không cần parameter.
Và ta có thể gọi hàm của object đó như sau.
var methodName = "<Tên hàm>"
var methodInfo = type.GetMethod(methodName);
var arguments = new object[]
{
// danh sách các biến của hàm
}
var result = methodInfo.Invoke(instance, arguments); // giá trị trả về sẽ có kiểu là object
Còn nếu hàm đó trả về void
thì ta gọi hàm như sau.
methodInfo.Invoke(instance, arguments);
Chú ý là hàm GetMethod
ở đây không được phép có overload.
Tuy nhiên các hàm Activator.CreateInstance
và methodInfo.Invoke
này tương đối chậm, vì thế chúng không phù hợp để ta sử dụng với tần suất cao. Với dynamic code generation, ta sẽ tự sinh tất cả các mã IL (Intermediate Language) cần thiết và hy vọng rằng quá đó sẽ có được hiệu năng cao hơn.
Cách tìm xem ta cần mã IL như thế nào
Trước hết, cần nói rõ là ta sẽ không viết lại tất cả mã IL từ con số 0. Thay vào đó, ta sẽ tham khảo mã IL mà compiler tạo ra rồi tìm cách tái hiện lại. Ta sẽ dùng lớp dưới đây trong tất cả các ví dụ.
public class TestClass
{
private string _firstName = "Default first name";
private string _lastName = "Default last name";
public void SetName(string firstName, string lastName)
{
_firstName = firstName;
_lastName = lastName;
}
public string GetLastName() => _lastName;
public string Introduce(string first, string last) => $"The name is {last}, {first} {last}.";
}
Và ta định nghĩa hàm dưới đây để khởi tạo đối tượng thuộc lớp TestClass
.
// Vì ta muốn bắt chước *Reflection API*
// nên giá trị trả về ở đây có kiểu là `object` thay vì `TestClass`
public static object CreateClass() => new TestClass();
Ta sẽ tìm hiểu xem mã IL của hàm trên là như thế nào. Có rất nhiều công cụ để đọc mã IL, tuy nhiên hôm nay ta sẽ dùng ildasm vì nó đi kèm luôn với Visual Studio.
- Build đoạn code ở trên để tạo ra file dll.
- Mở Developer Command Prompt từ Visual Studio.
- Chạy lệnh ildasm.
- Từ giao diện của ildasm, ấn nút Open, tìm đến folder debug và chọn file dll vừa tạo ở trên.
- Trong cây thư mục, tìm đến hàm
CreateClass
và click đúp, ta sẽ thấy màn hình dưới đây.
Khởi tạo object với dynamic code generation
Ta sẽ tạo một delegate và tái sử dụng nó mỗi khi muốn khởi tạo object. Vì trong bài hôm nay ta chỉ quan tâm tới trường hợp lớp có hàm khởi tạo không cần parameter nên delegate của ta sẽ có kiểu là Func<object>
. Bước đầu tiên cần làm là load file assembly và lấy TypeInfo
của lớp, bước này cũng giống như khi ta dùng Reflection API.
var assembly = Assembly.LoadFrom(assemblyPath);
var type = assembly.GetType(typeName);
Tiếp theo, ta nghiên cứu mã IL vừa đọc được ở phần trước. Đoạn quan trọng nhất là đây.
IL_0000: newobj instance void DynamicCodeGenerationSample.TestClass::.ctor()
IL_0005: ret
Đoạn mã IL này rất đơn giản. Ta chỉ cần một lệnh newobj
(phân vùng bộ nhớ cho một object chưa khởi tạo rồi gọi hàm ctor
) và một lệnh ret
(trả giá trị về từ hàm). Các bạn có thể đọc danh sách các lệnh IL tại đây. Vì ta muốn tạo delegate nên ta sẽ gọi hàm DynamicMethod
trong namespace System.Reflection.Emit
.
var dynCtor = new DynamicMethod($"{type.FullName}_ctor", type, Type.EmptyTypes, true);
Ý nghĩa của từng parameter là như sau.
$"{type.FullName}_ctor"
: đây là tên của delegate. Ta có thể đặt tên nào tùy thích, nhưng ta nên đặt tên sao cho dễ hiểu delegate này làm gì.type
: kiểu của giá trị trả về của delegate, ở đây làTestClass
. Cũng giống như ReflectionAPI, mặc dù delegate của ta trả vềobject
, nhưngobject
đó thực chất có kiểu làTestClass
.Type.EmptyTypes
: kiểu của tất cả các parameter của delegate. Vì delegate của ta không cần parameter nên ta truyền vàoType.EmptyTypes
ở đây.true
: khi đặt làtrue
thì compiler sẽ không kiểm tra permission khi tạo delegate.
Ta cũng cần khởi tạo IL generator.
var il = dynCtor.GetILGenerator();
Vì lớp TestClass
chỉ có một hàm khởi tạo không có parameter nên ta lại dùng Type.EmptyTypes
khi lấy thông tin về hàm khởi tạo đó.
var ctorInfo = type.GetConstructor(Type.EmptyTypes);
Bước tiếp theo là thêm vào các hai lệnh newobj
và ret
(trước đó ta có thể kiểm tra xem ctorInfo
có null không).
il.Emit(OpCodes.Newobj, ctorInfo); // nhớ truyền ctorInfo ở đây
il.Emit(OpCodes.Ret);
Bước cuối cùng là tạo delegate và cast nó sang kiểu ta cần (ở đây là Func<object>
).
var ctorDelegate = (Func<object>)dynCtor.CreateDelegate(typeof(Func<object>));
Giờ đây ta có thể dùng ctorDelegate
mỗi khi muốn tạo object thuộc lớp TestClass
.
var instance = ctorDelegate();
Kiểu của instance ở đây là object
giống như khi ta gọi Activator.CreateInstance
, nhưng về bản chất instance ta nhận được là một TestClass
.
Gọi hàm không parameter và trả về string
Hàm đầu tiên ta thử gọi là GetLastName
. Mã IL để gọi hàm đó giống với mã IL của hàm dưới đây.
// Để giống với Reflection API, ta sẽ gọi hàm GetLastName trên object
public static string CallGetFirstName(object instance) => ((TestClass)instance).GetLastName();
Bằng ildasm, ta thấy được mã IL của hàm trên là như sau.
IL_0000: ldarg.0
IL_0001: castclass DynamicCodeGenerationSample.TestClass
IL_0006: callvirt instance string DynamicCodeGenerationSample.TestClass::GetLastName()
IL_000b: ret
Ta sẽ tạo một delegate với kiểu là Func<object, string>
, ở đây parameter object
thực chất là TestClass
. Khi hàm của ta không phải là static thì bao giờ object hiện tại cũng được tự động truyền vào làm tham số đầu tiên. Cũng giống như ở phần trước, bước đầu tiên là load assembly và TypeInfo
. Và vì ở đây ta muốn gọi hàm nên ta còn cần cả MethodInfo
nữa.
var assembly = Assembly.LoadFrom(assemblyPath);
var type = assembly.GetType(typeName);
var methodInfo = type.GetMethod("GetLastName");
Đoạn code để tạo DynamicMethod
và IL generator là như sau.
var dynInk = new DynamicMethod($"{type.FullName}_{methodInfo.Name}_Ink", typeof(string), new[] { typeof(object) }, true);
var il = dynInk.GetILGenerator();
So với trong phần trước, ta thấy một số điểm khác biệt như sau.
typeof(string)
: vì delegate của ta giờ đây trả vềstring
.new[] { typeof(object) }
: vì delegatte của ta giờ đây cần 1 parameter với kiểuobject
.
Mã IL đầu tiên ta cần là lệnh ldarg.0
. Mã này sẽ load tham số đầu tiên (ở đây là instance
) lên stack.
il.Emit(OpCodes.Ldarg_0);
Sau đó ta cast instance
sang kiểu TestClass
bằng lệnh castclass
.
il.Emit(OpCodes.Castclass, type);
Trong đoạn mã IL ở trên, ta thấy rằng lệnh tiếp theo là callvirt
. Nhưng vì ta đã biết là hàm GetLastName
không phải là hàm virtual nên ta có thể đổi sang gọi lệnh call
.
il.EmitCall(OpCodes.Call, methodInfo, null); // nhớ truyền methodInfo tại đây
Lệnh cuối cùng là ret
để trả về giá trị.
il.Emit(OpCodes.Ret);
Cũng giống như trong phần trước, ta tạo delegate và cast nó sang kiểu phù hợp (ở đây là Func<object, string>
).
var inkDelegate = (Func<object, string>)dynInk.CreateDelegate(typeof(Func<object, string>));
Và ta có thể dùng delegate này để gọi hàm GetLastName
của đối tượng thuộc lớp TestClass
.
var instance = ctorDelegate();
var lastName = inkDelegate(instance);
// lastName là instance._lastName
Gọi hàm có 2 parameter và trả về void
Mục tiêu tiếp theo của ta là hàm SetName
. Vì hàm này có 2 parameter nhưng không có giá trị trả về nên delegate của ta sẽ có dạng Action<object, string, string>
. Ta dùng hàm dưới đây để giúp tìm mã IL cần thiết.
public static void CallSetName(object instance, string first, string last) =>
((TestClass)instance).SetName(first, last);
Mã IL có dạng sau.
IL_0000: ldarg.0
IL_0001: castclass DynamicCodeGenerationSample.TestClass
IL_0006: ldarg.1
IL_0007: ldarg.2
IL_0008: callvirt instance void DynamicCodeGenerationSample.TestClass::SetName(string,
string)
IL_000d: nop
IL_000e: ret
Trong trường hợp này ta cũng cần load assembly, TypeInfo
và MethodInfo
giống như trong các phần trước, ta không viết lại đoạn code đó tại đây. Bước tạo DynamicMethod
và IL generator có một số khác biệt như sau.
var parameterTypes = new[]
{
typeof(object),
typeof(string),
typeof(string)
};
var dynInk = new DynamicMethod($"{type.FullName}_{method.Name}_Ink", null, parameterTypes, true);
var il = dynInk.GetILGenerator();
Vì delegate của ta có dạng Action<object, string, string>
nên ta phải thay đổi parameter như dưới đây.
null
: vì delegate của ta không trả lại gì nên kiểu giá trị trả về của nó lànull
.parameterTypes
: đây là mảng với kiểu làType
. Giá trị trong mảng này trùng với kiểu của delegate là<object, string, string>
.
Bước đọc giá trị của instance
và cast nó sang kiểu TestClass
không có gì thay đổi.
il.Emit(OpCodes.Ldarg_0);
il.Emit(OpCodes.Castclass, type);
Bước tiếp theo là gọi lệnh ldarg.1
và ldarg.2
để tải 2 parameter (là 2 biến string
) lên trên stack. Và cũng giống như trong phần trước, ta đổi lệnh callvirt
thành call
.
il.Emit(OpCodes.Ldarg_1);
il.Emit(OpCodes.Ldarg_2);
il.EmitCall(OpCodes.Call, methodInfo, null); // nhớ truyền methodInfo tại đây
Hai lệnh cuối cùng là nop
(không làm gì cả) và ret
. Ta không cần gọi lệnh nop
, vì thế ở đây chỉ còn lại lệnh ret
.
il.Emit(OpCodes.Ret);
Giờ ta có thể tạo và sử dụng delegate này.
var inkDelegate = (Action<object, string, string>)dynInk.CreateDelegate(typeof(Action<object, string, string>));
var instance = ctorDelegate();
inkDelegate(instance, "new first name", "new last name");
// instance._firstName có giá trị mới là "new first name",
// và instance._lastName có giá trị mới là "new last name"
Gọi hàm có 2 parameter và trả về string
Hàm cuối cùng mà ta gọi là Introduce
, với hàm này ta sẽ bắt chước Reflection API. Như đã nhắc đến trước đây, khi sử dụng methodInfo.Invoke , ta chứa tất cả các argument trong một array với kiểu là object
, và giá trị trả về cũng có kiểu là object
. Tương tự thế, ta sẽ tạo delegate với kiểu là Func<object, object[], object>
.
Mảng chứa các tham số của ta sẽ như sau.
var arguments = new object[] { "James", "Bond" }
Đoạn code để tạo DynamicMethod
và IL generator lúc này có dạng là.
var parameterTypes = new[] { typeof(object), typeof(object[]) };
var dynInk = new DynamicMethod($"{type.FullName}_{methodInfo.Name}_Ink", typeof(object), parameterTypes, true);
var il = dynInk.GetILGenerator();
Và ta dùng hàm này để tìm mã IL cần thiết.
public static object CallIntroduce(object instance, object[] arguments) =>
((TestClass)instance).Introduce((string)arguments[0], (string)arguments[1]);
Đoạn mã IL đó là.
IL_0000: ldarg.0
IL_0001: castclass DynamicCodeGenerationSample.TestClass
IL_0006: ldarg.1
IL_0007: ldc.i4.0
IL_0008: ldelem.ref
IL_0009: castclass [System.Runtime]System.String
IL_000e: ldarg.1
IL_000f: ldc.i4.1
IL_0010: ldelem.ref
IL_0011: castclass [System.Runtime]System.String
IL_0016: callvirt instance string DynamicCodeGenerationSample.TestClass::Introduce(string,
string)
IL_001b: ret
Khi so sánh với mã IL trong các phần trước, ta thấy một số khác biệt dưới đây.
- Có thêm 3 lệnh mới để đọc tham số từ mảng. Các lệnh
ldc.i4.0
vàldc.i4.1
sẽ lưu các giá trị0
và1
lên stack; và lệnhldelem.ref
sẽ dùng giá trị trên đỉnh của stack làm index để lấy dữ liệu từ phần tử tương ứng trong mảng. - Vì mảng của ta có kiểu là
object
nhưng hàmIntroduce
cần 2 parameter với kiểu làstring
nên ta cần cast các tham số từ kiểuobject
sang kiểustring
. Để làm điều đó, ta cần thêm 2 lệnhcastclass
.
Nếu chỉ nhìn vào đoạn mã IL ở trên thì có lẽ sẽ có người viết code như dưới đây.
il.Emit(OpCodes.Ldarg_0);
il.Emit(OpCodes.Castclass, type);
il.Emit(OpCodes.Ldarg_1);
il.Emit(OpCodes.Ldc_I4_0);
il.Emit(OpCodes.Ldelem_Ref);
il.Emit(OpCodes.Castclass, typeof(string));
il.Emit(OpCodes.Ldarg_1);
il.Emit(OpCodes.Ldc_I4_1);
il.Emit(OpCodes.Ldelem_Ref);
il.Emit(OpCodes.Castclass, typeof(string));
Đoạn code này sẽ chạy được, nhưng ta đã cố định luôn là delegate của ta chỉ gọi hàm có 2 parameter với kiểu là string
. Nếu đã thế thì việc gì còn phải truyền tham số trong 1 mảng object
? Thay vào đó, ta sẽ dùng một vòng lặp để xử lý từng tham số trong mảng như dưới đây.
il.Emit(OpCodes.Ldarg_0);
il.Emit(OpCodes.Castclass, type);
var methodParameters = methodInfo.GetParameters();
var paramLen = arguments.Length;
for (var i = 0; i < paramLen; i++)
{
il.Emit(OpCodes.Ldarg_1);
il.Emit(OpCodes.Ldc_I4_S, i);
il.Emit(OpCodes.Ldelem_Ref);
il.Emit(OpCodes.Castclass, methodParameters[i].ParameterType);
}
Chú ý là thay vì dùng ldc.i4.0
and ldc.i4.1
, ta dùng lệnh ldc.i4.s
và một tham số để tải index của mảng lên stack. Đồng thời ta cũng dùng methodInfo.GetParameters().ParameterType
để lấy ra kiểu của từng parameter trong hàm Introduce
thay vì cố định là dùng string
.
Sau đó ta có thể thêm nốt các lệnh còn thiếu như bình thường.
il.EmitCall(OpCodes.Call, methodInfo, null);
il.Emit(OpCodes.Ret);
Bước cuối cùng là tạo và sử dụng delegate.
var inkDelegate = (Func<object, object[], object>)dynInk.CreateDelegate(typeof(Func<object, object[], object>));
var instance = ctorDelegate();
var result = inkDelegate(instance, arguments);
Console.WriteLine(result) // Sẽ xuất ra console dòng sau: "The name is Bond, James Bond."
Value type và boxing
Đoạn code ở trên chỉ gọi được hàm Introduce
nếu như giá trị trả về là reference type. Ta sẽ thử thay đổi hàm Introduce
một chút để xem điều gì sẽ xảy ra nếu giá trị trả về là value type.
public DateTime Introduce(string first, string last) => DateTime.Now;
Bây giờ nếu ta chạy lệnh inkDelegate(instance, arguments)
thì sẽ xảy ra lỗi sau.
Fatal error. Internal CLR error. (0x80131506)
Lý do là vì ta đang cố trả về DateTime
, một value type, dưới dạng object
, một reference type. Thông thường, nếu gặp trường hợp này thì compiler sẽ thực hiện boxing để bọc giá trị DateTime
đó trong một object
. Tuy nhiên, khi tạo mã IL, ta đã bỏ qua bước đó. Để sửa lỗi này, ta cần thêm một lệnh box
vào trước lệnh ret
.
if(methodInfo.ReturnType.IsValueType)
{
il.Emit(OpCodes.Box, methodInfo.ReturnType);
}
Sau khi bổ sung bước kiểm tra này, code của ta sẽ xử lý được cả hàm trả về reference type lẫn hàm trả về value type.
So sánh hiệu năng
Các bạn có thể tải code để test hiệu năng của dynamic code generation và Reflection API từ đường link dưới đây.
https://github.com/duongntbk/DynamicCodeGenerationSample
Đây là kết quả khi khởi tạo object.
Method | Mean | Error | StdDev | Median | Gen 0 | Gen 1 | Gen 2 | Allocated |
---|---|---|---|---|---|---|---|---|
Reflection_CreateInstance | 37.999 ns | 0.8597 ns | 1.364 ns | 37.8111 ns | 0.0051 | – | – | 32 B |
DynamicGenerator_CreateInstance | 9.297 ns | 1.0219 ns | 2.849 ns | 8.1900 ns | 0.0051 | – | – | 32 B |
Static_CreateInstance | 8.955 ns | 0.4365 ns | 1.259 ns | 8.6350 ns | 0.0051 | – | – | 32 B |
Còn đây là kết quả khi gọi hàm. Ở đây ta chỉ test hàm Introduce
.
Method | Mean | Error | StdDev | Gen 0 | Gen 1 | Gen 2 | Allocated |
---|---|---|---|---|---|---|---|
Reflection_Invoke | 302.55 ns | 5.654 ns | 4.721 ns | 0.0329 | – | – | 208 B |
DynamicGenerator_Invoke | 99.21 ns | 2.107 ns | 5.838 ns | 0.0267 | – | – | 168 B |
Static_Invoke | 98.71 ns | 3.990 ns | 10.989 ns | 0.0267 | – | – | 168 B |
Có thể thấy rằng trong cả 2 trường hợp, dynamic code generation là nhanh hơn hẳn so với Reflection API và không kém hơn code C# tĩnh bao nhiêu.
Kết thúc
Dynamic code generation có hiệu năng tốt hơn hẳn mà vẫn giữ được interface tương đối giống với Reflection API. Tôi đã phát triển ý tưởng này thành một library mã nguồn mở để khởi tạo object và gọi hàm một cách dynamic. Mời các bạn tìm hiểu về library FastAndFaster trong bài này.
One Thought on “Dynamic code generation với C#”