Mở đầu
Chắc hẳn các bạn đã quen thuộc với thao tác khai báo một hàm. Tên hàm, kiểu trả về và tham số là 3 thứ ta cần quan tâm. Ở bài trước, với khuôn mẫu (template), ta có thể làm linh động kiểu dữ liệu của giá trị trả về và tham số. Nhưng có một thứ mà ta chưa làm được - đó là khiến cho số lượng tham số cũng trở nên linh động.
Làm điều đó thế nào nhỉ? Đó chắc chắn là câu hỏi của tất cả mọi người trong những ngày đầu học C - khi nhìn thấy hàm printf. Sao nó có thể nhét được vô hạn đối số như vậy?
printf("A lot of things to print: %d %f %s", 1, 2.5, "ahihi");
Khái niệm về một hàm có thể nhận một lượng đối số không cố định như vậy được gọi là Biến tham* (Variadic).
Đôi lời về dịch thuật
Là một người viết hướng đến độc giả Việt Nam, tôi luôn cố gắng sử dụng thuật ngữ tiếng Việt nhiều nhất có thể, thay vì chỉ bê nguyên từ tiếng Anh sang. Trong quá trình lựa chọn bản dịch cho “Variadic”, tôi đã phải đắn đo nhiều phương án như “bất định”, “tham lượng động“, “biến thể“, ... Trong đó, cách dịch mà tôi đánh giá là thoát ý nhất là “khả biến tham số (可變參數)“ theo Wikipedia tiếng Trung. Tuy nhiên, phương án này có tận 4 chữ, không tương xứng về mặt hình thức với nguyên gốc tiếng Anh “variadic” khá ngắn. Tôi quyết định rút gọn còn “biến tham“ để giữ được điều này.
va_arg - Di sản từ C
Trong thời đại của mình, ngôn ngữ C cũng có cách để để truyền được rất nhiều đối số vào một hàm. Kỹ thuật đó là va_arg.
Dưới đây là một hàm tính tổng tất cả các tham số được truyền vào:
int MySum(int count, ...)
{
int sum = 0;
va_list args;
va_start(args, count);
for (int i = 0; i < count; ++i)
{
sum += va_arg(args, int);
}
va_end(args);
return sum;
}
....
int sum = MySum(3, 1, 2, 3);
Biến args thuộc kiểu va_list chứa thông tin của các tham số.
Macro va_start cho phép args truy xuất đến các tham số sau count.
Macro va_arg để lấy tham số bên trong args theo kiểu dữ liệu được truyền.
va_end để dọn dẹp.
Nhìn vào ví dụ, ta có thể thấy ngay vấn đề của kỹ thuật cổ xưa này.
Đầu tiên, quan trọng nhất: Nó cần tham số đầu tiên làm chỉ báo xem có bao nhiêu tham số tiếp theo đó. Để tính tổng 3 số 1, 2, 3, ta muốn viết MySum(1, 2, 3), nhưng lại phải viết thành MySum(3, 1, 2, 3).
Thứ hai, va_arg cần thông tin của kiểu dữ liệu sẽ sử dụng (ở đây là int) chứ không có khả năng tự động nhận dạng.
Cuối cùng, đoạn code trên đọc khá khó hiểu.
Khuôn mẫu Biến tham (Variadic Template)
Quay trở lại với người bạn đến từ C++ của chúng ta: Khuôn mẫu.
Ta sẽ thử viết phiên bản đơn giản hơn của printf, đó là hàm print giống của Python như sau:
print("A lot of things to print: ", 1, 2.5, "ahihi");
Hàm của chúng ta sẽ có dạng như sau:
template <typename... Ts>
void print(Ts... args)
{
/// std::cout << args...;
}
Tuyệt. Ta không còn phải sử dụng đống macro kì lạ nữa. Bài toán thừa tham số cũng đã được giải quyết xong.
Nhưng một bài toán mới lại xuất hiện. Toán tử «
của std::cout không hỗ trợ cú pháp với gói tham số (parameter pack) như ta mong đợi ở trên.
Từ bản C++14 đổ xuống, nạp chồng là giải pháp phổ biến nhất trong trường hợp này.
template <typename T>
void print(T arg)
{
std::cout << arg;
}
template <typename T, typename... Ts>
void print(T first, Ts... args)
{
std::cout << first;
print(args...);
}
Tất nhiên rồi. Viết thêm một hàm có nội dung gần như y hệt, chỉ thêm 1 dòng đệ quy, là điều mà không ai muốn cả.
Biểu thức Gập (Fold Expression)
Xu hướng của thời đại mới là tối giản. Cú pháp của C++ cũng không nằm ngoài xu hướng này.
C++17 đã cho ra mắt Biểu thức Gập chính là để hạn chế việc nạp chồng hàm. Đóng biểu thức lại trong dấu ngoặc (), ta có thể viết gọn lại như sau.
template <typename... Ts>
void print(Ts... args)
{
(std::cout << ... << args);
}
Biểu thức trên sẽ được trải ra thế này:
std::cout << arg1 << arg2 << arg3;
Đúng như ta kỳ vọng: Đơn giản mà hiệu quả.
Ở trên ta đã gập phải (right fold) với toán tử dịch “«”. Ngoài ra, ta còn có thể gập trái (left fold) với toán tử phẩy “,” như sau:
template <typename... Ts>
void print(Ts... args)
{
((std::cout << args), ...);
}
Biểu thức trên sẽ được trải ra thế này:
(std::cout << arg1), (std::cout << arg2), (std::cout << arg3);
Trông cồng kềnh hơn hẳn cách ban đầu nhỉ. Vậy nó được dùng để làm gì?
template <typename... Ts>
void print(Ts... args)
{
((std::cout << args << " "), ...);
}
Đúng rồi đấy. Nếu ta muốn hiển thị thêm dấu cách giữa các phần tử, ta có thể bổ sung vào các biểu thức con.