Note: phiên bản Tiếng Anh của bài này ở link dưới.
https://duongnt.com/nullable-value-tuple/
Một bug tôi gặp phải
Hôm trước, tôi gặp lỗi sau đây khi đang refactor một đoạn code sử dụng MemoryCache
. Đây là đoạn code trước khi refactor.
private readonly IMemoryCache _cache;
// Code để khởi tạo _cache bằng MemoryCache
public async Task<MyObject> GetObjectByUuIdAsync(Guid parentUuid, Guid objectUuid)
{
return await _cache.GetOrCreateAsync(
objectUuid, async _ =>
{
await RefreshCacheByParentUuid(parentUuid);
// code để lấy MyObject từ cache, nếu vẫn không thấy thì trả về null
});
}
private async Task RefreshCacheByParentUuid(Guid parentUuid)
{
// Hàm GetObjectUnderParent trả về tất cả object thuộc về cùng một object cha
var objects = await GetObjectUnderParent(parentUuid);
foreach (var object in objects)
{
// Ta lưu tất cả object thuộc cùng một object cha vào cache đề phòng sau này cần tới
_cache.Set(object.Uuid, object);
}
}
Tôi đổi đoạn code đó thành.
public async Task<MyObject> GetObjectByUuIdAsync(Guid parentUuid, Guid objectUuid)
{
var key = (objectUuid, "thông tin bổ sung khác"); // <-- CHÚ Ý
return await _cache.GetOrCreateAsync(
key, async _ =>
{
await RefreshCacheByParentUuid(parentUuid);
// code để lấy MyObject từ cache, nếu vẫn không thấy thì trả về null
});
}
private async Task RefreshCacheByParentUuid(Guid parentUuid)
{
// Hàm GetObjectUnderParent trả về tất cả object thuộc về cùng một object cha
var objects = await GetObjectUnderParent(parentUuid);
foreach (var object in objects)
{
// Ta lưu tất cả object thuộc cùng một object cha vào cache đề phòng sau này cần tới
var key = (object.Uuid, "thông tin bổ sung khác"); // <-- CHÚ Ý
_cache.Set(key, object);
}
}
Sau khi thực hiện thay đổi này, một vài unit test của tôi trả về lỗi (ơn trời là có unit test). Thông báo lỗi của các test đó đều chỉ ra cùng một nguyên nhân, không hiểu sao trong một số tình huống tôi lại bị cache miss trong khi lẽ ra phải là cache hit.
Sau khi tìm hiểu, tôi nhận ra biến objectUuid
truyền vào GetObjectByUuIdAsync
có kiểu là Guid
trong khi object.Uuid
trả về từ GetObjectUnderParent
lại có kiểu là Guid?
. Vì trong use case hiện tại object.Uuid
chắc chắn không null nên tôi chỉ cần thay đổi một dòng code để sửa lỗi này.
var key = (object.Uuid, "thông tin bổ sung khác");
// thành
var key = (object.Uuid.Value, "thông tin bổ sung khác");
Sau khi sửa xong lỗi, tôi thử tìm hiểu vì sao lỗi này chỉ xuất hiện khi tôi dùng tuple làm key cho cache. Rõ ràng là ngay từ đầu cache đã chứa lẫn lộn cả Guid
lẫn Guid?
nhưng lúc đó mọi thứ vẫn chạy. Cuối cùng tôi nhận ra nguyên nhân là do cơ chế hoạt động của nullable, value tuple và MemoryCache
.
MemoryCache hoạt động thế nào
MemoryCache
lưu dữ liệu trong ConcurrentDictionary
. Vì ta có thể dùng bất kỳ kiểu dữ liệu nào để làm key, MemoryCache
phải dùng ConcurrentDictionary<object, CacheEntry>
(để ý là key ở đây có kiểu là object
). Trong code tôi sử dụng thêm một extension method tên là GetOrCreateAsync
để lấy dữ liệu từ cache. Hàm này dùng key để tìm dữ liệu tương ứng, nếu không thấy thì nó sẽ dùng delegate ta truyền vào để tạo dữ liệu mới cho key đó. Code của GetOrCreateAsync
ở đây.
public static async Task<TItem> GetOrCreateAsync<TItem>(this IMemoryCache cache, object key, Func<ICacheEntry, Task<TItem>> factory)
{
if (!cache.TryGetValue(key, out object result))
{
var entry = cache.CreateEntry(key);
result = await factory(entry);
entry.SetValue(result);
// need to manually call dispose instead of having a using
// in case the factory passed in throws, in which case we
// do not want to add the entry to the cache
entry.Dispose();
}
return (TItem)result;
}
Có thể thấy nó gọi hàm TryGetValue
của MemoryCache
, và MemoryCache
lại gọi TryGetValue
của ConcurrentDictionary
ở đây.
Tiếp theo ta xem code của ConcurrentDictionary
, hàm TryGetValue
của nó như thế này. Vì kiểu của key là object
, mà object
là reference type, đồng thời MemoryCache
không truyền comparer
khi khởi tạo ConcurrentDictionary
nên đoạn code này được thực hiện.
for (Node? n = Volatile.Read(ref tables.GetBucket(hashcode)); n != null; n = n._next)
{
if (hashcode == n._hashcode && _defaultComparer.Equals(n._key, key))
{
value = n._value;
return true;
}
}
Hash code của key được xét tới trước, sau đó _defaultComparer
được dùng để so sánh key. _defaultComparer
như dưới đây, chú ý TKey
là object
.
_defaultComparer = EqualityComparer<TKey>.Default;
Cách so sánh object
Ở phần trước ta đã thấy rằng EqualityComparer<object>.Default
được dùng để so sánh key của ConcurrentDictionary
. Đoạn sau được trích từ tài liệu về EqualityComparer<T>
.
The Default property checks whether type T implements the System.IEquatable<T> interface and, if so, returns an EqualityComparer<T> that uses that implementation. Otherwise, it returns an EqualityComparer<T> that uses the overrides of Object.Equals and Object.GetHashCode provided by T.
Tạm dịch.
Property Default kiểm tra xem kiểu T có implement interface System.IEquatable<T> không. Nếu có thì interface đó được dùng để thực hiện so sánh. Nếu không thì việc so sánh được thực hiện dựa vào kết quả của hàm Object.Equals và hàm Object.GetHashCode mà T đã override.
Vì lớp object
không implement IEquatable<object>
nên ta phải dùng Object.Equals
và Object.GetHashCode
. Mặc dù ở đây kiểu của key là object
nhưng thực ra ta truyền vào Guid
hoặc tuple, vì thế tính đa hình sẽ phát huy tác dụng. Trong phần sau ta sẽ xem hai hàm Equals
và GetHashCode
được gọi ra sao.
Nếu kiểu của key là Guid
Hàm GetHashCode
của Nullable<T>
rất đơn giản.
The hash code of the object returned by the Value property if the HasValue property is true, or zero if the HasValue property is false.
Tạm dịch.
Hash code của Nullable<T> là hash code của object chứa trong property Value nếu như HasValue là true. Nếu HasValue là false thì hash code của Nullable<T> sẽ bằng 0.
Dễ thấy rằng hash code của Nullable<Guid>
bằng với hash code của Guid
nếu giá trị bọc trong Nullable<Guid>
bằng với giá trị của Guid
.
Hàm Equals cũng tương tự, hàm này trả về true
trong trường hợp sau.
The HasValue property is false, and the other parameter is null (that is, two null values are equal by definition), OR the HasValue property is true, and the value returned by the Value property is equal to the other parameter.
Tạm dịch.
Nếu như HasValue có giá trị false và giá trị của object còn lại mà ta muốn so sánh là null (tức là 2 giá trị null thì mặc định là bằng nhau), HOẶC nếu như HasValue là true, và giá trị lưu trong property Value bằng với giá trị của object ta muốn so sánh.
Từ 2 điều trên, ta rút ra kết luận rằng có thể dùng Guid
làm key để tìm kiếm dữ liệu vốn được lưu bằng key là Nullable<Guid>
. Đây là lý do vì sao code ban đầu của tôi không bị bug.
Nếu kiểu của key là tuple
Mọi việc trở nên phức tạp hơn khi ta sử dụng tuple làm key. Đây là code của ValueTuple
(vì tuple ta đang xét có arity là 2 nên ta chỉ quan tâm tới struct ValueTuple<T1, T2>
).
Đầu tiên ta xem hàm GetHashCode
.
public override int GetHashCode()
{
return HashCode.Combine(Item1?.GetHashCode() ?? 0,
Item2?.GetHashCode() ?? 0);
}
Key mà tôi sử dụng để lưu dữ liệu có dạng (Guid?, string)
còn key để tìm dữ liệu có dạng (Guid, string)
. Vì string
không đổi, và từ phần Nếu kiểu của key là Guid
ta đã biết rằng hash code của Guid
và Guid?
mà ta sử dụng là bằng nhau, vì thế hash code của (Guid?, string)
bằng hash code của (Guid, string)
.
Tiếp theo ta xét hàm Equals
, code của nó như sau.
public override bool Equals([NotNullWhen(true)] object? obj)
{
return obj is ValueTuple<T1, T2> tuple && Equals(tuple);
}
Đây chính là nguyên nhân của lỗi mà tôi gặp phải. Vế đầu tiên trong thân if tương đướng với obj is ValueType<Guid?, string>
, nhưng object trong dictionary của ta có kiểu là ValueType<Guid, string>
. Vì thế hàm Equals
sẽ trả về false
, ConcurrentDictionary
không thể tìm được dữ liệu ứng với key và thế là cache miss.
Khi nullable không phải là null
Ta đã tìm ra thủ phạm gây bug, nhưng tuple vẫn còn có một số điểm thú vị mà ta chưa nhắc đến, ví dụ như đoạn code này.
Guid id1 = Guid.NewGuid();
Guid? id2 = id1;
Console.WriteLine((id1, "Test").Equals((id1, "Test"))); // False
Console.WriteLine((id1, "Test") == (id2, "Test")); // True!!
Tại sao so sánh bằng ==
thì trả về true
trong khi Equals
lại trả về false
. Chức năng so sánh tuple bằng ==
hoặc !=
mới chỉ được bổ sung trong bản C# 7.3. Nó được implement như sau.
Allow expressions t1 == t2 where t1 and t2 are tuple or nullable tuple types of same cardinality, and evaluate them roughly as temp1.Item1 == temp2.Item1 && temp1.Item2 == temp2.Item2 (assuming var temp1 = t1; var temp2 = t2;). Conversely it would allow t1 != t2 and evaluate it as temp1.Item1 != temp2.Item1 || temp1.Item2 != temp2.Item2.
Tạm dịch.
Cho phép viết so sánh t1 == t2 trong đó t1 và t2 là tuple hoặc nullable tuple với cùng số biến generic. So sánh này được thực hiện tương đương với temp1.Item1 == temp2.Item1 && temp1.Item2 == temp2.Item2 (trong đó var temp1 = t1; var temp2 = t2;). Cách viết so sánh t1 != t2 cũng là hợp lệ, và nó được thực hiện tương đướng với temp1.Item1 != temp2.Item1 || temp1.Item2 != temp2.Item2.
Rõ ràng 2 string là bằng nhau rồi, còn Guid
và Guid?
thì sao? Khi id1
là Guid
và id2
là Nullable<Guid>
thì compiler thực hiện Operator Lifting. Chi tiết về Operator Lifting xin tham khảo trong phần Nullable Value Types – Operator Lifting của cuốn C#9.0 in a Nutshell viết bởi Joseph Albahari. Cơ chế của nó như sau.
- Struct
Nullable<T>
không định nghĩa các operator như<
,>
hay==
. - Nhưng compiler có thể mượn tạm (lift) operator
==
từ value type màNullable<T>
bọc ngoài. Compiler chuyển đổix == y
thành đoạn code sau đây.(x.HasValue && y.HasValue) ? (x.Value == y.Value);
Operator lifting khiến cho so sánh Guid
và Guid?
bằng ==
trong trường hợp của chúng ta cũng trả về true
, dẫn tới (id1, "Test") == (id2, "Test")
cũng là true
. Đây là nguyên nhân của sự khác biệt khi so sánh tuple bằng ==
và so sánh bằng Equals
.
Kết thúc
Hy vọng rằng bài viết này là hữu ích đối với các bạn. Nếu có gì muốn góp ý xin hãy gửi cho tôi qua phần bình luận.
One Thought on “Nullable, value tuple và MemoryCache”