Note: see the link below for the English version of this article.

https://duongnt.com/fastmember

Trong phần lớn các trường hợp, C# là một ngôn ngữ tĩnh (static); type của các object và tên của các biến tại thời điểm compile là cố định. Điều này giúp giảm lỗi, đồng thời cho phép compiler thực hiện nhiều tối ưu hóa một các sát sao hơn. Tuy nhiên nó cũng khiến cho C# trở nên kém linh hoạt hơn khi so sánh với những ngôn ngữ động (dynamic) như Python hay JavaScript. Thông thường, nếu ta không biết trước type hay tên biến thì cách duy nhất để tương tác với chúng là bằng Reflection API. Tuy nhiên Reflection API chậm hơn nhiều so với code C# bình thường, vì thế nó không phù hợp khi ta cần hiệu năng cao.

Hôm nay, chúng ta sẽ tìm hiểu về FastMember, đây là một thư viện cho phép ta sử dụng các trường public của một object mà tên ta chỉ biết tại runtime. Đồng thời ta cũng sẽ xem thư viện đó có những hạn chế nào và chạy thử test để so sánh nó với Reflection API và với code C# tĩnh.

Cách dùng FastMember

Ta dùng class dưới đây làm ví dụ, class này hiện chỉ có 1 public property.

public class Account
{
    public string Name { get; set; }
}
var account = new Account();

FastMember cho phép ta sử dụng các trường của một object theo 2 cách. Ta có thể tạo một accessor cho một đối tượng cụ thể của lớp Account và dùng nó để đọc hay ghi dữ liệu của trường Name.

var objectAccessor = ObjectAccessor.Create(account);
var name = objectAccessor["Name"] as string; // Cần phải tự cast giá trị trả về sang đúng type
objectAccessor["Name"] = "New name" // Đặt giá trị mới cho "Name"

Nếu ta cần thao tác trên nhiều đối tượng cùng kiểu thì ta có thể tạo 1 accessor duy nhất cho kiểu đó rồi tái sử dụng nó.

var typeAccessor = TypeAccessor.Create(typeof(Account));
var name = typeAccessor[account, "Name"] as string; // Đừng quên lệnh cast
objectAccessor[account, "Name"] = "New name" // Đặt giá trị mới cho "Name"

Tiếp theo, ta thêm một property với private setter vào class Account.

public class Account
{
    private int _balance;

    public int Balance
    {
        private set => _balance = value;

        get => _balance;
    }

    public string Name { get; set; }
}

Kết quả khi ta thử đọc và ghi giá trị của Balance là như sau.

var balance = typeAccessor[account, "Balance"] as int; // OK, trả về 0
typeAccessor[account, "Balance"] = 2_000 // Xảy ra lỗi!

Nếu muốn ghi giá trị mới vào Balance, ta cần khởi tạo typeAccessor với giá trị allowNonPublicAccessorstrue.

var typeAccessor = TypeAccessor.Create(typeof(Account), true);
var balance = typeAccessor[account, "Balance"] as int; // OK, trả về 0
typeAccessor[account, "Balance"] = 2_000 // OK, giá trị của Balance bây giờ là 2,000

Ta cũng có thể sử dụng objectAccessor để thực hiện các thao tác ở trên, chỉ cần khởi tạo nó với giá trị allowNonPublicAccessorstrue.

var objectAccessor = ObjectAccessor.Create(account, true);

Hạn chế của FastMember

Tuy chậm nhưng Reflection API lại rất linh hoạt, nó cho phép ta sử dụng các trường, hàm,… trong một object. Hơn nữa, nếu đặt đúng giá trị cho BindingFlag thì ta còn có thể đọc và ghi các trường không public của object. Ngược lại, FastMember chỉ cho phép ta đọc và ghi các trường public. Kể cả khi ta đặt giá trị allowNonPublicAccessorstrue khi khởi tạo thì ta vẫn không thể sử dụng accessor để đọc hay ghi các trường không public. Khi dùng ObjectAccesor ta cũng gặp phải hạn chế tương tự.

public class Account
{
    private string _password;
    protected string Address { get;set; }
    public string Name { get; set; }
}

var typeAccessor = TypeAccessor.Create(typeof(Account), true);
var password = typeAccessor[account, "_password"] as string; // Xảy ra lỗi!
var address = typeAccessor[account, "Address"] as string; // Cũng xảy ra lỗi!

Sau đây ta sẽ xem mã nguồn của TypeAccessor để tìm nguyên nhân của hạn chế đó. Từ mã nguồn này, ta có thể thấy rằng TypeAccessor lưu các accessor của tất cả các type nó biết trong một bảng băm. Mỗi khi ta tạo accessor cho một type mới, nó gọi hàm CreateNew và truyền vào giá trị allowNonPublicAccessors để tạo accessor tương ứng. Chú ý là TypeAccessor gọi hàm CreateNew static tại đây chứ không gọi hàm virtual tại đây. Trong hàm CreateNew, ta thấy hai dòng code sau đây.

PropertyInfo[] props = type.GetTypeAndInterfaceProperties(BindingFlags.Public | BindingFlags.Instance);
FieldInfo[] fields = type.GetFields(BindingFlags.Public | BindingFlags.Instance);

Từ giá trị của các BindingFlag, ta có thể thấy rằng TypeAccessor chỉ đọc được các trường public mà không đọc các trường non-public hoặc static. Để biết mục đích thực sự của biến allowNonPublicAccessors ta cần xem dòng này và dòng này.

else if (member is PropertyInfo prop)
{
    // Nhiều code

    var accessor = (isGet | isByRef) ? prop.GetGetMethod(allowNonPublicAccessors) : prop.GetSetMethod(allowNonPublicAccessors)

    // Nhiều code
};

Khi ta dùng TypeAccessor để đọc hoặc ghi một property, nó sẽ gọi hàm GetGetMethod hoặc GetSetMethod và truyền vào giá trị allowNonPublicAccessors để lấy về getter hoặc setter tương ứng. Tùy vào giá trị của allowNonPublicAccessorsTypeAccessor sẽ lấy về getter/setter public hay không public. Đây là lý do ta phải đặt giá trị allowNonPublicAccessorstrue khi gọi hàm TypeAccessor.Create nếu muốn dùng accessor trả về để lấy giá trị của property Balance.

Tuy nhiên, tôi không cho rằng hạn chế này là một vấn đề lớn. Nếu như một trường nào đó được đặt là không public thì hẳn là phải có lý do, và việc tùy tiện sử dụng trường đó có thể dẫn tới nhiều vấn đề sau này. Một ngoại lệ của quy luật trên là trong code test; đôi khi cách tốt nhất để kiểm tra xem code của ta có chạy đúng hay không là tìm cách đọc giá trị của trường không public. Nhưng lúc đó ta có thể sử dụng Reflection API để tận dụng độ linh hoạt của reflection.

Test thử hiệu năng

Như đã nói ở trên, FastMember nhanh hơn Reflection API và tất nhiên nó không thể nhanh bằng code C# static. Để biết chính xác FastMember nhanh chậm thế nào, tôi đã viết một project nhỏ dưới đây. Project này sử dụng thư viện BenchmarkDotNet, đây là một tool thử hiệu năng rất hữu ích.

https://github.com/duongntbk/FastMemberBenchmark

Đọc một property public

Method Mean Error StdDev Median Gen 0 Gen 1 Gen 2 Allocated
FastMember_TypeAccessor_PublicGet 41.2149 ns 0.9083 ns 1.5669 ns 40.9240 ns
FastMember_ObjectAccessor_PublicGet 44.3860 ns 0.9716 ns 0.9088 ns 44.3685 ns
Static_PublicGet 0.0045 ns 0.0163 ns 0.0249 ns 0.0000 ns
Reflection_PublicGet 116.7330 ns 1.6150 ns 1.4317 ns 116.8871 ns

Ghi một property public

Method Mean Error StdDev Gen 0 Gen 1 Gen 2 Allocated
FastMember_TypeAccessor_PublicSet 42.953 ns 0.9328 ns 1.8193 ns
FastMember_ObjectAccessor_PublicSet 46.171 ns 0.9169 ns 1.3440 ns
Static_PublicSet 1.939 ns 0.1290 ns 0.3640 ns
Reflection_PublicSet 202.760 ns 4.0677 ns 6.9073 ns 0.0100 64 B

Đọc một property không public

Method Mean Error StdDev Gen 0 Gen 1 Gen 2 Allocated
FastMember_TypeAccessor_PrivateGet 42.61 ns 1.014 ns 0.996 ns
FastMember_ObjectAccessor_PrivateGet 43.73 ns 1.032 ns 1.724 ns
Reflection_PrivateGet 120.02 ns 2.462 ns 2.418 ns

Ghi một property không public

Method Mean Error StdDev Gen 0 Gen 1 Gen 2 Allocated
FastMember_TypeAccessor_PrivateSet 46.66 ns 0.924 ns 1.802 ns 0.0038 24 B
FastMember_ObjectAccessor_PrivateSet 48.02 ns 0.991 ns 1.289 ns 0.0038 24 B
Reflection_PrivateSet 237.63 ns 4.705 ns 4.401 ns 0.0138 88 B

Từ những bài test trên, ta có thể thấy rằng FastMember nhanh hơn Reflection API tầm 3 tới 5 lần, và FastMember cũng dùng ít memory hơn khi ghi giá trị vào property. Ngoài ra, TypeAccessor nhanh hơn ObjectAccessor, mặc dù sự khác biệt ở đây là rất nhỏ. Sau đây ta sẽ so sanh quá trình khởi tạo của 2 loại accessor này.

Method Mean Error StdDev Gen 0 Gen 1 Gen 2 Allocated
TypeAccessor_Create_DisallowNonPublic 23.47 ns 0.514 ns 0.859 ns
TypeAccessor_Create_AllowNonPublic 23.51 ns 0.463 ns 0.387 ns
ObjectAccessor_Create_DisallowNonPublic 47.87 ns 0.938 ns 1.488 ns 0.0051 32 B
ObjectAccessor_Create_AllowNonPublic 43.05 ns 0.905 ns 0.802 ns 0.0051 32 B

Quá trình khởi tạo của TypeAccessor cũng nhanh hơn ObjectAccessor, tuy nhiên ít khi nào ta lại cần tạo nhiều accessor tới nỗi sự khác biệt này là có ý nghĩa.

Kết thúc

Mặc dù tôi luôn ưu tiên viết code static mỗi khi có thể, đôi khi việc đọc hay ghi một trường mà tên ta chỉ biết tại runtime sẽ giúp ta đơn giản hóa code. Trong những lúc đó, FastMember là một công cụ hữu ích, và tôi dùng nó cả trong code production lẫn code test.

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

One Thought on “Dùng FastMember để đọc và ghi các trường public tại runtime”

Leave a Reply