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ú ý TKeyobject.

_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.EqualsObject.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 EqualsGetHashCode đượ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 GuidGuid? 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 GuidGuid? thì sao? Khi id1Guidid2Nullable<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 đổi x == y thành đoạn code sau đây.
    (x.HasValue && y.HasValue) ? (x.Value == y.Value);
    

Operator lifting khiến cho so sánh GuidGuid? 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.

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

One Thought on “Nullable, value tuple và MemoryCache”

Leave a Reply