Như tôi đã viết trước đây, tôi đang sử dụng tính bất biến (immutability) khá mạnh tay trong mã nguồn của trang web bầu cử. Cho đến tháng 9 năm 2025 (mãi tôi mới có thời gian viết bài blog này), điều đó đồng nghĩa với việc kết hợp giữa record, ImmutableList<T> và ImmutableDictionary<TKey, TValue>.
Tuy nhiên, trong một cuộc họp về tiêu chuẩn ECMA C#, Joseph Musser đã truyền đạt cho tôi một phản hồi rất giá trị – rằng những kiểu bất biến đó kém hiệu quả hơn đáng kể so với ImmutableArray<T> và FrozenDictionary<TKey, TValue> đối với cách tôi sử dụng chúng.
Mục lục
Các mẫu sử dụng của collection bất biến
Có hai mẫu sử dụng rất khác biệt khi bạn dùng collection bất biến.
Mẫu mà ImmutableList<T> và ImmutableDictionary<TKey, TValue> hỗ trợ rất tốt là “xây dựng dần theo thời gian, dùng các collection trung gian”. Cả hai đều có phương thức Add trả về một collection bất biến mới dựa trên collection trước đó, nhưng có thêm một phần tử. Chắc chắn có rất nhiều nơi mẫu sử dụng này là phù hợp – nhưng trang web bầu cử của tôi hầu như không bao giờ làm điều này.
Thay vào đó, mã của tôi có xu hướng tạo một collection (thường dùng LINQ, nhưng không chỉ có vậy) rồi không bao giờ “sửa đổi” nó nữa. Khi dữ liệu có gì đó thay đổi, tôi tạo một collection mới hoàn toàn từ đầu thay vì dựng collection mới dựa trên collection hiện có. Đây chính là mẫu sử dụng mà ImmutableArray<T> và FrozenDictionary<TKey, TValue> được thiết kế để phục vụ.
Di chuyển (Migration)
Việc di chuyển từ ImmutableDictionary sang FrozenDictionary thực sự rất đơn giản:
- Thêm import cho namespace
System.Collections.Frozen - Đổi tên các extension method của tôi (ví dụ
ToImmutableReferenceDictionarythànhToFrozenReferenceDictionary) - Gọi
ToFrozenDictionarythay vìToImmutableDictionary - Thay đổi khai báo cho một vài property và tham số record
Chỉ có vậy thôi! Ngoài những thứ khác, sự đơn giản của thay đổi đó chính là minh chứng rằng tôi thực sự không hề dùng các phương thức đặc thù của ImmutableDictionary để dựng dictionary bất biến mới từ những cái đã có.
Việc di chuyển từ ImmutableList sang ImmutableArray thì rắc rối hơn một chút, dù không bao giờ thực sự khó. Có hai điểm khác biệt đáng chú ý:
ImmutableArraylà kiểu giá trị (value type) (với một trường duy nhất là tham chiếu tới mảng nền phía sau)ImmutableArraycó propertyLength(giống mảng) thay vì propertyCounttrongImmutableList
Một lần nữa, nhìn lại commit, tôi không thấy chỗ nào mình dùng phương thức Add đặc thù của ImmutableList và phải thay đổi cách dựng danh sách. Tôi không có helper method nào cần đổi tên cho ImmutableArray, nhưng bản chất là kiểu giá trị lại kéo theo những thay đổi helper method khác. Ví dụ, hãy xem khai báo này:
public static bool ElementsEqual<T>(ImmutableList<T>? left, ImmutableList<T>? right)
Điều đó hoàn toàn ổn ngay cả khi caller có tham chiếu non-nullable, và dù sao bạn cũng không thể overload nó với tham số non-nullable tương đương – trong khi với ImmutableArray, gần như chắc chắn bạn không muốn bọc một giá trị ImmutableArray non-nullable trong ImmutableArray? chỉ để rồi kiểm tra và mở gói lại, nên cuối cùng tôi có các overload:
public static bool ElementsEqual<T>(ImmutableArray<T>? left, ImmutableArray<T>? right)
public static bool ElementsEqual<T>(ImmutableArray<T> left, ImmutableArray<T> right)
(Các khả năng “chỉ một bên nullable” không đáng để thêm vào codebase của tôi, dù chúng hoàn toàn hợp lệ.)
Tương tự, ở nhiều nơi tôi có mã như thế này:
public void DoSomething(ImmutableList<string>? list)
{
if (list is null)
{
// Whatever needs to happen for the null case
return;
}
// Now use list in a non-nullable way. The compiler knows it's not null,
// so I don't get warnings.
// For example:
foreach (var element in list)
{
...
}
}
Cách đó không hoạt động với việc di chuyển tầm thường sang ImmutableArray vì tham số là một kiểu giá trị nullable. Vì vậy có hai lựa chọn khác nhau. Chúng ta có thể “mở gói” sang kiểu giá trị non-nullable bằng property .Value, thường là lựa chọn đơn giản nhất khi chỉ dùng nó một lần:
public void DoSomething(ImmutableArray<string>? array)
{
if (array is null)
{
// Whatever needs to happen for the null case
return;
}
// Use .Value to access the array
foreach (var element in array.Value)
{
...
}
}
Hoặc dùng toán tử is để khai báo một biến cục bộ mới:
public void DoSomething(ImmutableArray<string>? array)
{
if (array is not ImmutableArray<string> arrayValue)
{
// Whatever needs to happen for the null case
return;
}
// Now we have a new non-nullable local variable, arrayValue
foreach (var element in arrayValue)
{
...
}
}
Cách sau là cách tôi áp dụng ở hầu hết những nơi dùng mảng nhiều lần trong cùng một member.
Lợi ích về hiệu năng
Ban đầu tôi hơi hoài nghi về lợi ích tiềm năng của việc đổi kiểu collection. Các kiểu hiện tại có thể tệ đến mức nào chứ? Dù sao trang web cũng đã hoạt động “đủ tốt” với tôi rồi.
Tại thời điểm di chuyển, tôi chỉ có ba benchmark:
- Dựng
ConstituenciesVM - Dựng
CandidatesVM - Validate
ElectionContext
Cả ba đều cải thiện sau khi di chuyển, nhưng việc dựng chỉ cải thiện một chút… trong khi ElectionContext.Validate cải thiện từ 5.5ms xuống 0.826ms – một cải thiện khổng lồ, do nó thực hiện rất nhiều thao tác đọc.
Tôi có thực sự quan tâm nhiều đến ElectionContext.Validate không? Thật ra không – nếu chỉ riêng nó thì không đủ để biện minh cho việc thay đổi, nhất là nếu mã kết quả phức tạp hơn hoặc khó đọc hơn. Nhưng nó cho thấy lợi ích không chỉ là lý thuyết suông. Vì mẫu sử dụng của tôi tương quan rất, rất chặt chẽ với thiết kế của FrozenDictionary/ImmutableArray, tôi nghĩ mình cứ nên liều thử một phen.
Nói thực tế, kể cả nếu cuối cùng tôi có thật nhiều lưu lượng truy cập (vẫn còn là một chữ “nếu” to đùng), gần như toàn bộ lưu lượng sẽ được phục vụ từ các đường dẫn vốn đã hiệu quả tự nhiên mà không nhận được nhiều lợi ích từ việc di chuyển này. Các phần “công việc” liên quan cần làm là:
- Nạp dữ liệu từ Firestore và chuyển đổi thành các model cấp thấp cùng
ElectionContexttổng thể - Dựng các view-model theo từng view từ
ElectionContext - Render các trang Razor dựa trên view-model
- Phục vụ các trang (và các yêu cầu API)
Trong số đó, chỉ có bước 3 và 4 xảy ra trên đường phục vụ (serving path). Bước 1 và 2 chỉ xảy ra khi dữ liệu thực sự thay đổi, và ngay cả khi đó chúng cũng diễn ra trong các lần kiểm tra sức khỏe (health check), nên không ảnh hưởng tới người dùng.
Ngay cả bước 3 cũng chỉ xảy ra mỗi 10 giây cho mỗi trang, mỗi instance – tất cả các trang (và hầu hết phản hồi API) đều nằm sau một cache 10 giây.
Tôi nên (và sẽ) thêm nhiều benchmark hơn cho việc render trang Razor và phục vụ API. Có thể sẽ backport các benchmark đó về một nhánh mã trước khi di chuyển. Tôi kỳ vọng thấy mức cải thiện khiêm tốn từ việc di chuyển trong hầu hết trường hợp, và có thể là cải thiện đáng kể hơn trong một vài trường hợp. Tôi không kỳ vọng thấy sự suy giảm nào, nhưng rõ ràng một nửa ý nghĩa của việc benchmark là vì kỳ vọng thường hay bị đảo lộn.
Kết luận
Nhìn chung, đây là một thay đổi đơn giản để thực hiện, và tôi vui vì đã làm nó.
Tuy vậy, tôi vẫn còn hai điều băn khoăn:
Thứ nhất, việc có các tiền tố tên khác nhau (thậm chí cả namespace khác nhau) hơi có cảm giác lộn xộn. Đâu phải tôi đang dùng các kiểu này theo những cách khác biệt lắm, vậy tại sao một cái tên Immutable còn cái kia tên Frozen? Đây thuần túy là băn khoăn về thẩm mỹ, và tôi đoán nó sẽ làm phiền một số người khác, còn khiến những người còn lại hoàn toàn bối rối không hiểu vì sao tôi lại nhắc đến nó.
Thứ hai, bản chất kiểu giá trị của ImmutableArray chắc chắn gây vướng víu một chút. Điều này chủ yếu nằm ở khía cạnh nullability, nhưng nó cũng hơi có gì đó sai sai… vì thực ra chúng ta đang xử lý một tham chiếu, chỉ là được bọc trong kiểu ImmutableArray. Gần như chúng ta cần một loại kiểu mới: “kiểu này chỉ bọc một tham chiếu duy nhất, nên hãy truyền tham chiếu trực tiếp bên dưới, nhưng dùng nó thông qua các member được khai báo trong kiểu bọc (và không cho phép truy cập trực tiếp vào tham chiếu thật)”. Tôi chắc rằng trước đây bản thân tôi đã viết khá nhiều kiểu như vậy, và hiện tại chúng hoặc luôn cần một object vô nghĩa tham gia (kèm chi phí hiệu năng cho việc tạo và dọn rác các wrapper này), hoặc bạn kết thúc với kiểu bọc là value type kèm theo sự kỳ quặc đi kèm. Độ phức tạp ngôn ngữ tăng thêm có lẽ không đáng để đề xuất điều này, nhưng đó sẽ là một thí nghiệm tư duy thú vị.
Tôi có thể giải quyết cả hai băn khoăn ở một mức độ nào đó bằng cách tự giới thiệu một lớp FrozenList của riêng mình, nhưng nó sẽ gánh chịu chi phí hiệu năng đã nói ở trên, cùng khả năng (thậm chí là xác suất) làm hỏng việc. Các băn khoăn này chưa đủ nghiêm trọng để biện minh cho việc đó.
Còn bây giờ, tôi sẽ sống chung với những băn khoăn ấy và tận hưởng hiệu năng được cải thiện. Ở các dự án khác, tôi vẫn dùng ImmutableList và ImmutableDictionary ở những nơi tôi thực sự không quan tâm đến hiệu năng, nhưng thật tốt khi biết về các lựa chọn thay thế. Có thể tôi nên biến FrozenDictionary và ImmutableArray thành các kiểu bất biến “mặc định” của mình, nhưng tôi vẫn chưa sẵn sàng đến mức đó…



