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ề voidthì 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.CreateInstancemethodInfo.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ưng object đó 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ào Type.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 newobjret (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ểu object.

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, TypeInfoMethodInfo 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.1ldarg.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.0ldc.i4.1 sẽ lưu các giá trị 01 lên stack; và lệnh ldelem.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àm Introduce cần 2 parameter với kiểu là string nên ta cần cast các tham số từ kiểu object sang kiểu string. Để làm điều đó, ta cần thêm 2 lệnh castclass.

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.

A software developer from Vietnam and is currently living in Japan.

One Thought on “Dynamic code generation với C#”

Leave a Reply