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ị allowNonPublicAccessors
là true
.
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ị allowNonPublicAccessors
là true
.
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ị allowNonPublicAccessors
là true
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 allowNonPublicAccessors
mà TypeAccessor sẽ lấy về getter/setter public hay không public. Đây là lý do ta phải đặt giá trị allowNonPublicAccessors
là true
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.
One Thought on “Dùng FastMember để đọc và ghi các trường public tại runtime”