Menu

Biểu thức lambda trong C#: cú pháp, Func và Action, closure, LINQ

Biểu thức lambda trong C# hoạt động thế nào: cú pháp => ở mọi dạng, lưu lambda vào Func và Action, truyền lambda cho phương thức và LINQ, statement lambda, closure với biến được capture, và bug capture trong vòng lặp for mà foreach không mắc phải.

Trang này có trình soạn thảo chạy trực tiếp. Sửa, chạy và xem đầu ra ngay lập tức.

Biểu thức lambda (lambda expression) là một hàm ẩn danh nhỏ viết ngay tại chỗ bằng toán tử => (đọc là "goes to"). Tham số nằm bên trái, kết quả nằm bên phải:

Đầu ra:

60.0
12
ORDER SHIPPED!
96.0, 180.0

Lambda không có kiểu riêng. Nó trở thành một delegate (đối tượng trỏ tới một hàm) thuộc kiểu delegate mà ngữ cảnh mong đợi: ở đây là Func<decimal, decimal>, với List.FindAll là Predicate<T>, với một sự kiện là EventHandler. Delegate Func trả về giá trị còn Action thì không; trang Func và Action liệt kê đầy đủ cả họ delegate này.

Các dạng cú pháp

x => x * x                         // one parameter: parentheses optional
(x, y) => x + y                    // several parameters
() => DateTime.Now.Year            // no parameters
(int x, string s) => s.Length > x  // explicit parameter types
order =>                           // statement lambda: a block with return
{
    decimal subtotal = order.Qty * order.Price;
    return subtotal > 100m ? subtotal * 0.9m : subtotal;
}

Expression lambda có một biểu thức duy nhất sau =>, và giá trị của biểu thức đó là kết quả. Statement lambda có một khối lệnh trong ngoặc nhọn, chứa được mọi câu lệnh và phải return một giá trị trên mọi nhánh nếu delegate có giá trị trả về (nếu không sẽ gặp CS1643, Not all code paths return a value in lambda expression).

Các phiên bản mới hơn bổ sung thêm vài dạng, tất cả đều không bắt buộc:

  • C# 9: tham số discard, nên nhiều tham số không dùng có thể cùng là _ ((_, _) => Save()), và lambda static không được phép capture biến.
  • C# 10: "kiểu tự nhiên", nên var square = (int x) => x * x; suy ra Func<int, int>. Trước đó, var f = x => x * 2; báo lỗi; ngay cả ở C# 10 nó vẫn lỗi nếu thiếu kiểu tham số, với CS8917 The delegate type could not be inferred.
  • C# 12: giá trị mặc định cho tham số, (int x, int y = 1) => x + y.

Truyền lambda cho phương thức

Mọi phương thức nhận tham số delegate đều chấp nhận lambda. Đó là cách bạn truyền hành vi, chứ không chỉ dữ liệu:

Đầu ra:

report.pdf, scan.pdf
photo.jpg, cv.txt, scan.pdf
cv.txt, scan.pdf, photo.jpg, report.pdf

Filter không biết "giữ lại" nghĩa là gì; nơi gọi quyết định bằng một lambda. List.Sort cũng hoạt động như vậy: lambda là một phép so sánh trả về số âm, 0 hoặc số dương, và Sort gọi nó bao nhiêu lần tùy nhu cầu.

Lambda trong LINQ

LINQ là nơi phần lớn code C# gặp lambda. Mỗi toán tử truy vấn nhận một lambda: Where nhận điều kiện, Select nhận phép chiếu, OrderBy nhận khóa, Sum nhận giá trị cần cộng:

Đầu ra:

Ben: 250
Ana: 60
Cy: 45
Ana spent 80
Ben spent 250
Cy spent 45

Lambda không chạy ở nơi nó được viết. LINQ lưu chúng lại và gọi khi truy vấn được duyệt bằng foreach, ToList() hoặc Sum(), cơ chế này gọi là thực thi trì hoãn (deferred execution). Trang LINQ trình bày chi tiết các toán tử.

Closure: biến được capture

Lambda có thể dùng biến cục bộ và tham số của phương thức chứa nó. Nó capture biến, không phải giá trị hiện tại, và biến tiếp tục sống chừng nào lambda còn sống:

Đầu ra:

1
2
3
1
50.0

Mỗi lần gọi MakeCounter tạo một count mới, nên hai bộ đếm độc lập với nhau. Lambda giảm giá đọc rate lúc nó chạy chứ không phải lúc được viết, nên nó áp dụng 50%. Trình biên dịch làm được điều này bằng cách chuyển các biến được capture vào một lớp ẩn mà phương thức và lambda dùng chung.

Cái bẫy capture trong vòng lặp

Capture biến lặp là chỗ closure dễ gây lỗi. Vòng lặp for chỉ có một biến cho cả vòng lặp, nên mọi lambda tạo trong đó đều dùng chung biến ấy:

Đầu ra:

3 3 3 <- for loop, shared i
0 1 2 <- for loop, copied
0 1 2 <- foreach

Cả ba lambda trong vòng lặp đầu chạy sau khi vòng lặp đã kết thúc, lúc i bằng 3. Sao chép i vào một biến khai báo bên trong thân vòng lặp sẽ cho mỗi lambda một biến riêng. foreach tự xử lý đúng: từ C# 5, biến lặp của nó là một biến mới ở mỗi lần lặp. Trước C# 5 nó cũng mắc bug này, nên nhiều câu trả lời cũ trên mạng vẫn khuyên sao chép biến cả với foreach.

Cái bẫy này áp dụng cho mọi thứ chạy về sau: trình xử lý sự kiện, timer, Task.Run, và truy vấn LINQ được tạo trong vòng lặp rồi duyệt sau đó.

Lambda, phương thức ẩn danh và hàm cục bộ

Lambda đã thay thế cú pháp phương thức ẩn danh của C# 2, cú pháp này vẫn biên dịch được:

Func<int, bool> isEven = delegate (int n) { return n % 2 == 0; };   // C# 2 anonymous method
Func<int, bool> isEven2 = n => n % 2 == 0;                          // lambda

Với một hàm phụ chỉ dùng trong một phương thức, C# 7.0 còn có hàm cục bộ (local function): có tên, có thể đệ quy và không cấp phát delegate trừ khi bạn chuyển nó thành delegate. Dùng lambda khi bạn truyền hành vi cho thứ khác (LINQ, Sort, một sự kiện); dùng hàm cục bộ hoặc phương thức private khi chính bạn gọi nó.

Còn một khác biệt nữa xuất hiện với các LINQ provider như Entity Framework: lambda gán cho Expression<Func<T, bool>> hoàn toàn không được biên dịch thành code, mà thành một cấu trúc dữ liệu mô tả code, để provider dịch sang SQL. Cú pháp giống hệt nhau; kiểu tham số của phương thức bạn gọi quyết định bạn nhận được loại nào.

Câu hỏi thường gặp

Biểu thức lambda trong C# là gì?

Là một hàm ẩn danh viết bằng toán tử =>: x => x * 2 nhận x và trả về x * 2. Lambda không có tên riêng; nó được chuyển thành một kiểu delegate như Func<int, int> hoặc Action<string> và có thể lưu vào biến, truyền cho một phương thức hoặc được một phương thức trả về.

Làm sao viết lambda có nhiều tham số hoặc không có tham số?

Đặt các tham số trong ngoặc tròn: (a, b) => a + b cho hai tham số, () => DateTime.Now khi không có tham số. Chỉ khi có đúng một tham số mới được bỏ ngoặc (x => x + 1). Bạn cũng có thể ghi rõ kiểu: (int a, int b) => a + b.

Closure trong lambda C# là gì?

Một lambda dùng biến cục bộ của phương thức bao quanh sẽ capture chính biến đó, không phải một bản chụp giá trị của nó. Biến sống lâu bằng lambda, và nếu một bên thay đổi biến về sau thì bên kia cũng thấy thay đổi. Đây là điều giúp bộ đếm và callback hoạt động, và cũng là nguyên nhân của bug capture kinh điển trong vòng lặp for.

Tại sao các lambda trong vòng lặp for đều thấy cùng một giá trị?

Vòng lặp for chỉ có một biến lặp cho cả vòng lặp, nên mọi lambda tạo bên trong đều capture cùng một i và thấy giá trị cuối cùng của nó khi chạy về sau. Hãy sao chép nó vào một biến cục bộ trong thân vòng lặp (int copy = i;) và capture bản sao đó. foreach không gặp vấn đề này từ C# 5, vì mỗi lần lặp nó tạo một biến mới.

Tại sao var f = x => x * 2 không biên dịch được?

Trình biên dịch không biết bạn muốn kiểu delegate nào và x có kiểu gì, nên báo lỗi CS8917 The delegate type could not be inferred (trước C# 10 là CS0815). Hãy khai báo kiểu (Func<int, int> f = x => x * 2;) hoặc, từ C# 10 trở đi, ghi kiểu cho tham số: var f = (int x) => x * 2;.

Hình minh họa các ngôn ngữ lập trình của Coddy

Học lập trình cùng Coddy

Bắt đầu