Note: see the link below for the English version of this article.
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 Any
và Count
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.
One Thought on “LINQ to Objects và một số sai sót thường gặp”