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

https://duongnt.com/linq

LINQ (Language Integrated Query) là một tập hợp nhiều tính năng cho phép thực hiện query trực tiếp trong cho C#. LINQ được ra mắt vào năm 2007 và hỗ trợ C# từ phiên bản 3.0 với .NET Framework từ bản 3.5 trở đi. Phần lớn các chức năng mới của C# 3.0 đều nhằm hỗ trợ cho LINQ. Đây là một trong những tính năng của C# mà tôi ưa thích nhất. Lý do không chỉ bởi LINQ rất hữu ích mà còn bởi nó là sự kết hợp nhịp nhàng của nhiều tính năng thoạt nghe có vẻ không mấy liên quan.

Có nhiều phiên bản LINQ như: LINQ to Objects, LINQ to SQL, LINQ to XML,… Tuy nhiên, trong bài này chúng ta sẽ tập trung vào LINQ to Objects và tìm hiểu một số sai sót thường gặp khi sử dụng nó.

Ta sẽ dùng class dưới đây trong tất cả các ví dụ.

public class Item
{
    private string _name;

    public int Id { get; set; }
    public string Name
    {
        get
        {
            Console.WriteLine($"Get name for Id: {Id}");
            return _name;
        }
        set
        {
            _name = value;
        }
    }
    public decimal Price { get;set; }

    public Item(int id, string name, decimal price)
    {
        Id = id;
        Name = name;
        Price = price;
    }
}

Thận trọng với tính năng deferred execution

Giả sử ta có một tập hợp các đối tượng thuộc lớp Item như dưới đây.

IEnumerable<Item> items = new List<Item>
{
    new Item(1, "Book", 30.00m),
    new Item(2, "Toy Car", 31.50m),
    new Item(3, "Water Gun", 32m),
    new Item(4, "Headphone", 33.50m)
};

Ta sẽ dùng LINQ để tạo hai đối tượng IEnumerable<int> như sau.

var maxPrice = 31m;
var itemUnder31 = items.Where(i => i.Price < maxPrice).Select(i => i.Id);

maxPrice = 33m;
var itemUnder33 = items.Where(i => i.Price < maxPrice).Select(i => i.Id);

// Code để in itemUnder31 and itemUnder33 ra console

Nếu thoạt nhìn qua, có thể ta sẽ cho rằng kết quả của đoạn code trên là như dưới đây.

# itemUnder31: 1
# itemUnder33: 1, 2, 3

Nhưng thực tế là kết quả lại như sau.

# itemUnder31: 1, 2, 3
# itemUnder33: 1, 2, 3

Nguyên nhân là do LINQ sử dụng một kỹ thuật gọi là deferred execution. Các query LINQ sẽ chỉ được thực thi khi ta cần đến kết quả của chúng thay vì ngay lúc được tạo ra. Tại thời điểm ta thực hiện duyệt các thành phần của hai IEnumerable<int> ở trên, giá trị của maxPrice==33m, và LINQ sẽ dùng giá trị này cho cả hai query. Vì vậy ta cần thận trọng khi dùng biến cục bộ để tạo các query.

Nguyên tắc chung là LINQ sẽ chỉ thực hiện query khi nó cần đến giá trị của một hoặc nhiều phần tử trong kết quả của query đó.

Thận trọng để không lặp lại bước duyệt IEnumerable<T>

Rõ ràng là khi ta gọi những hàm như ToList hay ToArray, hoặc dùng từ khóa foreach thì ta sẽ phải duyệt qua tất cả các thành phần trong IEnumerable<T>. Tuy nhiên nhiều hàm khác cũng thực hiện bước duyệt này, ví dụ như trong đoạn code sau đây.

var names = items.Select(i => i.Name);
var isEmpty = names.Any();
var count = names.Count();

Đoạn code trên sẽ ghi những dòng sau ra console.

Get name for Id: 1
Get name for Id: 1
Get name for Id: 2
Get name for Id: 3

Có thể thấy rằng hàm Any duyệt qua phần tử thứ nhất, còn hàm Count duyệt qua tất cả các phần tử của names. Lý do là vì hàm Any cần phải tìm ra ít nhất một phần tử trong names thì nó mới kết luật được là names không rỗng. Tương tự thế, để biết được names có bao nhiêu phần tử thì Count cần duyệt tất cả các phần tử.

Dưới đây là một số hàm thường dùng.

  • Duyệt tất cả các phần tử: Single, Max, Min, Average, Sum,…
  • Duyệt tới phần tử đầu tiên thỏa mãn điều kiện: First, Any, ElementAt,…
  • Không thực hiện duyệt phần tử: Where, Select, Intersect, Union, Skip, Take,…

Tất nhiên không phải lúc nào việc duyệt phần tử cũng là xấu. Đôi khi LINQ có thể bỏ qua bước duyệt này; cũng có lúc bước duyệt này ảnh hưởng nghiêm trọng tới hiệu năng, hay thậm chí gây ra lỗi cho hệ thống.

Khi nào LINQ có thể bỏ qua bước duyệt phần tử

Nếu như IEnumerable<T> của ta thực chất là một ICollection<T> thì trong một số trường hợp AnyCount sẽ không thực hiện duyệt phần tử. Ta có thể xem code của hàm Any tại đây.

if (source is ICollection<TSource> collectionoft)
{
    return collectionoft.Count != 0;
}

Có thể thấy là nếu LINQ nhận ra đây là một ICollection<T> thì nó sẽ sử dụng property Count của ICollection<T>. Chú ý là bước tối ưu này chỉ được thực hiện nếu ta gọi hàm Any mà không sử dụng predicate. Nếu ta sử dụng predicate thì LINQ sẽ gọi đoạn code này. Lúc đó LINQ không kiểm tra xem đối tượng hiện tại có phải là ICollection<T> hay không.

Hàm Count cũng sử dụng tối ưu hóa tương tự như ta thấy tại đây. Và cũng giống như hàm Any, tối ưu hóa này chỉ được áp dụng nếu ta gọi hàm Count mà không sử dụng predicate.

Vậy ta nên dùng hàm Any của LINQ hay so sánh property Count > 0 khi muốn kiểm tra xem một ICollection<T> là rỗng hay không? Có người cho rằng ta nên dùng hàm Any để thể hiện rõ ý định so sánh; cũng có người cho rằng nên so sánh property Count > 0 vì hàm Any cuối cùng cũng sẽ gọi đến property đó. Theo cá nhân tôi thì cả hai cách này đều được, chênh lệch hiệu năng giữa chúng là quá nhỏ để ta phải bận tâm.

Khi nào lặp lại việc duyệt phần tử là không ổn

Có hai trường hợp mà ta cần đặc biệt chú ý để không lặp lại việc duyệt phần tử.

  • Việc duyệt qua các phần tử tốn nhiều thời gian.
  • Việc duyệt qua các phần tử làm thay đổi phần khác của hệ thống (side effect).

Một lỗi tôi đã gặp

Đoạn code sau đây dựa theo một lỗi trong dự án tôi từng tham gia.


private static int ParallelLimit = 10;

public async Task<CustomType> GetSomeInfoAsync(string url)
{
    // code để tải thông tin từ URL.
}

public async Task<IList<CustomType>> GetAllInfoAsync(IEnumerable<string> urls)
{
    var rs = new List<CustomType>();

    for (var iteration = 0; ; iteration++)
    {
        var retrieveTasks = urls.Skip(iteration * ParallelLimit)
            .Take(ParallelLimit)
            .Select(url => GetSomeInfoAsync(url))

        if (!retrieveTasks.Any())
        {
            break;
        }

        var infos = await Task.WhenAll(retrieveTasks);
        rs.AddRange(infos);
    }

    return rs;
}

Về cơ bản, đoạn code trên không sai. Tác giả muốn cùng lúc tải dữ liệu từ nhiều URL khác nhau, đồng thời muốn hạn chế số request chạy song song để đảm bảo server không bị quá tải. Tuy nhiên câu lệnh retrieveTasks.Any() sẽ duyệt qua phần tử đầu tiên trong retrieveTasks. Sau đó lúc ta gọi await Task.WhenAll(retrieveTasks), ta lại duyệt qua tất cả các phần tử của retrieveTasks từ đầu đến cuối. Có nghĩa là với mỗi lô request, ta sẽ gọi URL đầu tiên trong lô đó tổng cộng là 2 lần.

May mắn là hàm GetSomeInfoAsync đó chỉ tải dữ liệu chứ không cập nhật thông tin gì. Vì thế dù ta có gọi hàm đó 2 lần với cùng URL thì dữ liệu cũng không bị lỗi (những thao tác có thể được lặp lại nhiều lần như thế được gọi là idempotent). Nhưng nếu hàm ta gọi là non-idempotent thì sao?

Lỗi rò rỉ tiền

Giả sử một người khác tham khảo đoạn code ở trên và viết ra đoạn dưới đây.

public async Task TransferMoneyAsync(Guid accountId, decimal amount)
{
    // code để chuyển một lượng tiền vào tài khoản
}

public async Task<IList<int>> GetAllContentLengthAsync(IEnumerable<string> urls)
{
    for (var iteration = 0; ; iteration++)
    {
        var transferTasks = acountIds.Skip(iteration * ParallelLimit)
            .Take(ParallelLimit)
            .Select(acountId => TransferMoneyAsync(acountId, amount))

        if (!transferTasks.Any())
        {
            break;
        }

        var contentLengthCollection = await Task.WhenAll(contentLengthTasks);
    }
}

Lúc này thì cứ 10 tài khoản lại có 1 tài khoản được nhận gấp đôi số tiền lẽ ra họ được nhận.

Cách sửa lỗi trên.

Cách sửa lỗi trên rất đơn giản, ta chỉ cần chuyển transferTasks từ kiểu IEnumerable<Task> sang List<Task>. Hàm ToList sẽ duyệt qua toàn bộ các phần tử, đồng thời tạo và chạy tất cả các task. Sau đó, ta có thể duyệt các phần tử trong List<Task> tùy thích mà không sợ chạy lặp lại task nào. Tất nhiên là lệnh await Task.WhenAll(transferTasks) vẫn sẽ đợi đến lúc tất cả các task chạy xong.

var transferTasks = acountIds.Skip(iteration * ParallelLimit)
    .Take(ParallelLimit)
    .Select(acountId => TransferMoneyAsync(acountId, amount))
    .ToList();

Vậy ta có chuyển tất cả IEnumerable<T> thành List<T> không? Câu trả lời là không. Việc chuyển IEnumerable<T> to List<T> trước khi ta cần đến List<T> sẽ tiêu tốn tài nguyên bộ nhớ và CPU một cách vô ích, đặc biệt là khi ta có nhiều phần tử và từng phần tử lại có kích thước lớn. Ta chỉ nên gọi ToList nếu ta biết trước rằng mình sẽ phải duyệt IEnumerable<T> đó nhiều lần.

Kết thúc

LINQ cho phép ta rút gọn code một cách đáng kể, nhưng một đoạn code LINQ trông có vẻ đơn giản đôi khi lại thực hiện nhiều thứ ở background hơn ta tưởng. Vì thế nếu bất cẩn thì những đoạn code sử dụng LINQ của ta có thể sẽ có những lỗi khó tìm.

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

One Thought on “LINQ to Objects và một số sai sót thường gặp”

Leave a Reply