Hàm viết ngay nơi dùng
Ở trang trước bạn đã thấy nạp chồng cho phép nhiều hàm dùng chung một tên. Nhưng đôi khi bạn hoàn toàn không cần một hàm có tên: bạn chỉ cần một mẩu logic nhỏ một lần, ngay chỗ dùng, và đặt tên cho nó chỉ làm code thêm rối. Đó chính là lambda: một hàm ẩn danh mà bạn định nghĩa ngay tại chỗ.
Lambda có hình dạng bốn phần rất dễ nhận ra:
[capture](parameters) -> return_type { body }
Cặp [] là dấu hiệu cho biết bạn đang nhìn một lambda. Kiểu trả về không bắt buộc, vì trình biên dịch thường tự suy ra. Đây là lambda đơn giản nhất:
greet chỉ là một biến (kiểu của nó không thể viết ra được, nên bạn lưu nó bằng auto) mà bạn có thể gọi bằng (). Lambda có tham số hoạt động y như hàm bình thường:
Capture: với tới phạm vi xung quanh
Thứ khiến lambda không chỉ là hàm không tên chính là danh sách capture, cặp []. Nó cho phép lambda dùng các biến trong phạm vi nơi lambda được định nghĩa, chứ không chỉ các tham số của riêng nó.
Capture theo giá trị bằng [x]: lambda có bản sao riêng, cố định tại thời điểm lambda được tạo.
Để ý scale(5) in ra 50, dùng giá trị factor là 10 tồn tại lúc tạo lambda. Capture theo giá trị chụp lại một ảnh tĩnh.
Capture theo tham chiếu bằng [&x]: lambda tham chiếu tới biến gốc, thấy được các thay đổi sau đó và có thể sửa nó.
Bạn cũng có thể capture mọi thứ lambda dùng bằng [=] (tất cả theo giá trị) hoặc [&] (tất cả theo tham chiếu). Chúng tiện lợi, nhưng viết tường minh như [total] hay [&total] ghi rõ chính xác lambda đụng tới những gì và dễ suy luận hơn.
Cái bẫy dangling reference
Capture theo tham chiếu vừa mạnh mẽ vừa nguy hiểm ngang nhau. Tham chiếu chỉ hợp lệ khi biến gốc còn sống. Nếu lambda sống lâu hơn thứ nó capture, bạn có một dangling reference và undefined behavior: chương trình có thể crash, có thể in ra rác, cũng có thể tình cờ trông như chạy đúng.
Đây là lỗi kinh điển: trả về một lambda capture một biến cục bộ theo tham chiếu.
auto makeCounter() {
int count = 0;
return [&count]() { return ++count; }; // BUG: count dies here
}
// The returned lambda now references destroyed memory.
Khi makeCounter trả về, biến cục bộ count bị hủy, nhưng lambda vẫn giữ tham chiếu tới nó. Gọi lambda được trả về sẽ đụng vào vùng nhớ đã chết. Cách sửa là capture theo giá trị để lambda tự sở hữu trạng thái của mình:
Quy tắc chung: chỉ capture theo tham chiếu khi lambda được dùng ngay lập tức và tại chỗ (như với các thuật toán bên dưới). Một khi lambda được lưu lại, trả về, hoặc chạy sau đó, hãy ưu tiên capture theo giá trị.
mutable và kiểu trả về
Bạn có để ý mutable trong ví dụ vừa rồi không? Mặc định, biến capture theo giá trị là const bên trong lambda: bạn đọc được bản sao nhưng không thay đổi được. Thêm mutable cho phép lambda sửa các bản sao của chính nó giữa các lần gọi.
mutable chỉ ảnh hưởng tới bản sao riêng của lambda: biến seen bên ngoài không bị đụng tới, và đó chính là mục đích của việc capture theo giá trị.
Phần lớn thời gian trình biên dịch suy ra kiểu trả về rất ổn. Bạn chỉ cần viết rõ bằng -> khi có sự mơ hồ, chẳng hạn một lambda có thể trả về các kiểu khác nhau ở các nhánh khác nhau:
// Without -> the compiler can't decide between int and double
auto half = [](int n) -> double {
if (n % 2 == 0) return n / 2; // int
return n / 2.0; // double
};
Lambda và thuật toán: lợi ích thực sự
Lý do lambda được thêm vào C++ là để đưa những mẩu logic ngắn vào các thuật toán của thư viện chuẩn. Trước khi có lambda, bạn phải viết một hàm có tên riêng hoặc một function object cồng kềnh ở xa nơi dùng. Giờ đây logic nằm ngay tại chỗ gọi.
Ví dụ phổ biến nhất là một thứ tự sắp xếp tùy chỉnh:
Capture thật sự tỏa sáng ở đây, vì lambda có thể kéo vào một giá trị để lọc hay đếm theo. Đoạn này đếm có bao nhiêu số vượt ngưỡng do người dùng chọn:
Vì các lambda này được dùng ngay và không sống lâu hơn hàm bao quanh, capture theo tham chiếu ([&passMark]) ở đây cũng an toàn, nhưng capture theo giá trị cũng rõ ràng không kém và không bao giờ bị dangling.
Tiếp theo: con trỏ
Lambda âm thầm đặt ra một câu hỏi sâu hơn: khi bạn capture [&x], lambda đang giữ vị trí của x, và vị trí đó chỉ hợp lệ khi x còn sống. Ý tưởng đó, một giá trị chỉ tới nơi một thứ nằm trong bộ nhớ, và điều gì xảy ra khi thứ nó trỏ tới biến mất, chính là nội dung của trang tiếp theo. Chúng ta sẽ đối mặt trực tiếp với con trỏ: cách lấy một địa chỉ, cách đi theo nó, và cách vấn đề dangling bạn vừa thấy xuất hiện khắp nơi trong C++.
Câu hỏi thường gặp
Lambda trong C++ là gì?
Lambda là một hàm ẩn danh mà bạn viết ngay tại chỗ, đúng nơi cần dùng. Cú pháp là [captures](parameters){ body }. Nó rất hợp cho các thao tác ngắn, chỉ dùng một lần, như phép so sánh bạn truyền cho std::sort, mà không phải khai báo một hàm có tên riêng ở chỗ khác.
Capture theo giá trị và theo tham chiếu trong lambda C++ khác nhau thế nào?
[x] capture một bản sao của x, cố định tại thời điểm lambda được tạo. [&x] capture một tham chiếu tới x gốc, nên lambda thấy được các thay đổi sau đó và có thể sửa nó. Chỉ dùng [&] khi chắc chắn các biến được capture sống lâu hơn lambda, nếu không bạn sẽ có một dangling reference.
Vì sao lambda C++ báo không thể sửa biến đã capture?
Mặc định, các biến capture theo giá trị là const bên trong lambda. Thêm từ khóa mutable, như [x]() mutable { x++; }, để lambda được sửa bản sao của chính nó. Lưu ý điều này chỉ thay đổi bản sao trong lambda, không phải biến gốc bên ngoài.