Bài viết này đi sâu vào khái niệm tham chiếu giá trị rvalue (rvalue reference) trong C++11, cách thức hoạt động của nó và các ứng dụng quan trọng như ngữ nghĩa di chuyển (move semantics) và truyền tham số chính xác (perfect forwarding).
1. Các Khái Niệm Cơ Bản
1.1 Giá Trị Lvalue và Rvalue
Sự khác biệt chính giữa lvalue và rvalue nằm ở khả năng lấy địa chỉ của chúng. Trong các phiên bản C++ cũ, lvalue là biểu thức mà bạn có thể lấy địa chỉ và có thể xuất hiện ở vế trái của phép gán. Tuy nhiên, với sự ra đời của const, một số biểu thức có thể lấy địa chỉ nhưng không thể gán giá trị:
const int& i = 10;
Rvalue là đối tượng không thể lấy địa chỉ trực tiếp, thường là các giá trị hằng, kết quả trả về của hàm, hoặc biểu thức lambda. Mặc dù không thể lấy địa chỉ, rvalue có thể được sửa đổi nếu được liên kết với một tham chiếu rvalue.
1.2 Tham Chiếu Lvalue và Tham Chiếu Rvalue
Tham chiếu truyền thống trong C++ được gọi là tham chiếu lvalue:
int i = 10;
int & ii = i;
C++11 giới thiệu tham chiếu rvalue. Khi một tham chiếu rvalue liên kết với một rvalue, rvalue đó được lưu trữ tại một vị trí bộ nhớ cụ thể và tham chiếu rvalue trỏ đến vị trí đó. Điều này có nghĩa là, mặc dù bản thân rvalue không thể lấy địa chỉ, tham chiếu rvalue lại có thể, biểu thị vị trí lưu trữ của đối tượng tạm thời:
int && iii = 10;
1.3 Mã Assembly của Tham Chiếu Lvalue và Rvalue
Xem xét mã assembly cho các khai báo trên (sử dụng kiến trúc x86):
int i = 1;
int & ii = i;
0x080483f3 movl $0x1,-0x10(%ebp) // Gán 1 cho i
0x080483fa lea -0x10(%ebp),%eax // Lấy địa chỉ của i vào EAX
0x080483fd mov %eax,-0x8(%ebp) // Gán địa chỉ của i cho ii
Trong đoạn mã này, tham chiếu lvalue ii lưu trữ địa chỉ của biến i.
int && iii = 10;
0x08048400 mov $0xa,%eax // Gán 10 vào EAX
0x08048405 mov %eax,-0xc(%ebp) // Gán giá trị 10 vào vị trí bộ nhớ -0xc(%ebp) (đối tượng tạm thời)
0x08048408 lea -0xc(%ebp),%eax // Lấy địa chỉ của đối tượng tạm thời vào EAX
0x0804840b mov %eax,-0x4(%ebp) // Gán địa chỉ này cho iii
Ở đây, iii (tham chiếu rvalue) lưu trữ địa chỉ của đối tượng tạm thời chứa giá trị 10. Cả hai loại tham chiếu (lvalue và rvalue) đều chiếm 4 byte vì chúng lưu trữ địa chỉ.
Điều quan trọng cần hiểu là đối tượng tạm thời (như 10 trong ví dụ trên) về cơ bản giống như một biến không tên, với vòng đời tương đương với khung stack của hàm.
1.4 Tham Chiếu Lvalue Hằng Số (const lvalue reference)
Việc khai báo một tham chiếu lvalue với giá trị rvalue là không hợp lệ:
int & i = 10; // Lỗi biên dịch
Tuy nhiên, một tham chiếu lvalue const có thể liên kết với rvalue:
const int & i = 10;
Mã assembly cho trường hợp này tương tự như tham chiếu rvalue, nơi i lưu trữ địa chỉ của đối tượng tạm thời 10.
1.5 Gán Chéo Giữa Tham Chiếu Lvalue và Rvalue
Một tham chiếu rvalue có thể được gán cho một tham chiếu lvalue. Điều này là do theo quy tắc cũ, tham chiếu không phân biệt lvalue/rvalue, và tham chiếu lvalue có thể liên kết với bất kỳ đối tượng nào:
int&& iii = 10;
int& ii = iii; // ii tham chiếu đến đối tượng mà iii đang trỏ tới. Thay đổi ii cũng ảnh hưởng đến iii.
2. Tham Chiếu Rvalue và Ngữ Nghĩa Di Chuyển
C++ cũ thường tạo ra các bản sao không cần thiết khi đối tượng sắp bị hủy. C++11 giới thiệu ngữ nghĩa di chuyển (move semantics) để giảm thiểu các thao tác sao chép này, và tham chiếu rvalue là nền tảng cho ngữ nghĩa này.
2.1 Hàm std::move
Theo quy tắc, tham chiếu rvalue không thể liên kết trực tiếp với lvalue. Tuy nhiên, C++11 cung cấp hàm std::move (trong <utility>) để tạo một tham chiếu rvalue từ một lvalue. Khi sử dụng std::move, bạn cam kết rằng lvalue đó sẽ không còn được sử dụng sau thao tác di chuyển, ngoại trừ việc sao chép hoặc hủy bỏ nó.
int&& iii = std::move(ii);
2.2 Suy Luận Tham Số Mẫu và Tham Chiếu
Xem xét các trường hợp suy luận tham số mẫu với tham chiếu:
- Với tham số
T&:
template<class T> void f1(T&) {}
f1(i); // T suy luận là int
f1(ci); // T suy luận là const int
// f1(5); // Lỗi: không thể truyền rvalue cho tham chiếu lvalue
const T&:
template<class T> void f2(const T&) {}
f2(i); // T suy luận là int (int có thể chuyển thành const int)
f2(ci); // T suy luận là int (const int là const T)
f2(5); // T suy luận là int (const reference có thể liên kết với rvalue)
T&& (tham chiếu rvalue):
template<class T> void f3(T&&) {}
f3(5); // T suy luận là int
2.3 Gấp Tham Chiếu (Reference Collapsing) và Tham Số Rvalue
Thông thường, f3(i) sẽ gây lỗi vì tham chiếu rvalue không thể liên kết với lvalue. Tuy nhiên, C++ có hai ngoại lệ cho quy tắc liên kết thông thường, cho phép điều này xảy ra, và là cơ sở cho std::move:
- Ngoại lệ suy luận loại tham chiếu rvalue: Khi một lvalue được truyền cho tham số rvalue của hàm mẫu (
T&&), trình biên dịch suy luậnTlà một tham chiếu lvalue. Ví dụ, khi gọif3(i),Tđược suy luận thànhint&thay vìint. - Gấp tham chiếu: Khi định nghĩa "tham chiếu của tham chiếu", các tham chiếu này sẽ "gấp lại". Hầu hết các trường hợp sẽ gấp thành tham chiếu lvalue thông thường. Ngoại lệ duy nhất là
T&& &&sẽ gấp thànhT&&. Các trường hợp khác nhưX& &&,X&& &,X&& &&đều gấp thànhX&.
2.4 Hiểu Rõ Gấp Tham Chiếu và Suy Luận Loại Tham Số
Với hàm f3, dựa trên quy tắc suy luận loại tham chiếu rvalue:
f3(i): Đối số là lvalue,Tsuy luận làint&.f3(ci): Đối số là lvalue,Tsuy luận làconst int&.
Khi T được suy luận là int&, hàm f3 sẽ được nội suy thành:
void f3<int&>(int& &&)
Theo quy tắc gấp tham chiếu, điều này sẽ gấp lại thành:
void f3<int&>(int&)
Hai quy tắc này dẫn đến kết quả quan trọng:
- Tham số hàm là tham chiếu rvalue tới tham số mẫu (
T&&) có thể liên kết với cả lvalue và rvalue. - Nếu đối số là lvalue, kiểu tham số mẫu được suy luận sẽ là một tham chiếu lvalue, và tham số hàm sẽ được nội suy thành một tham chiếu lvalue thông thường (
T&).
Điều đáng chú ý là một hàm có tham số T&& có thể chấp nhận mọi loại đối số, tương tự như hàm có tham số const T&.
2.5 Tham Chiếu Rvalue làm Tham Số Hàm Mẫu
Khi tham chiếu rvalue được sử dụng làm tham số hàm mẫu, kiểu T thường được suy luận thành một kiểu tham chiếu. Điều này ảnh hưởng đến logic bên trong hàm:
template<class T>
void f3(T&& val)
{
T t = val; // Nếu T là int&, thì t là int&
// ...
}
Nếu gọi f3(i) với i là lvalue, T sẽ là int&. Khi đó, t cũng sẽ là một tham chiếu tới val. Bất kỳ thay đổi nào đối với t sẽ ảnh hưởng trực tiếp đến val.
Tham chiếu rvalue thường được sử dụng trong hai trường hợp: truyền tham số chính xác và nạp chồng hàm.
Khi nạp chồng hàm, một hàm nhận tham chiếu rvalue sẽ ưu tiên liên kết với rvalue không hằng, trong khi hàm nhận tham chiếu lvalue hằng sẽ liên kết với lvalue và rvalue hằng:
template<class T> void f(T&&); // Ưu tiên cho rvalue không hằng
template<class T> void f(const T&); // Cho lvalue và rvalue hằng
2.6 Triển Khai Hàm std::move
Định nghĩa của std::move (trong Visual Studio 2017) sử dụng kỹ thuật remove_reference và tham chiếu rvalue:
template<class T>
constexpr std::remove_reference_t<T>&& move(T&& arg) noexcept
{
return static_cast<std::remove_reference_t<T>&&>(arg);
}
Hàm này nhận một tham chiếu rvalue T&& (có thể là lvalue hoặc rvalue) và trả về một tham chiếu rvalue tới kiểu đã loại bỏ tham chiếu. Điều này cho phép chuyển đổi một lvalue thành một rvalue tham chiếu để di chuyển.
3. Truyền Tham Số Chính Xác (Perfect Forwarding)
Trong một số trường hợp, bạn cần truyền các đối số từ một hàm đến một hàm khác, giữ nguyên tất cả các thuộc tính của đối số, bao gồm cả việc nó là const hay không, và là lvalue hay rvalue.
Xem xét hàm flip cố gắng gọi hàm f:
void f(int v1, int& v2) {
std::cout << v1 << " " << ++v2 << std::endl;
}
template <typename F, typename T1, typename T2>
void flip(F f, T1 t1, T2 t2) {
f(t2, t1); // Lỗi: t1 và t2 là bản sao, thay đổi t1 không ảnh hưởng đến biến gốc
}
int i = 1;
int j = 2;
flip(f, j, i); // Sẽ không thay đổi giá trị của j
Lý do là t1 và t2 là các bản sao của j và i. Việc thay đổi t1 bên trong flip không ảnh hưởng đến j bên ngoài.
3.1 Định Nghĩa Tham Số Hàm Giữ Nguyên Kiểu
Nếu định nghĩa các tham số của flip là tham chiếu rvalue, các quy tắc suy luận và gấp tham chiếu sẽ giúp giữ nguyên kiểu:
template <typename F, typename T1, typename T2>
void flip(F f, T1&& t1, T2&& t2) {
f(t2, t1); // Bây giờ t1 và t2 là tham chiếu tới biến gốc
}
Tuy nhiên, điều này sẽ gây lỗi nếu hàm f mong đợi tham chiếu lvalue nhưng lại được gọi với rvalue, hoặc ngược lại, vì các quy tắc ngoại lệ về tham chiếu rvalue chỉ áp dụng cho hàm mẫu.
3.2 Sử Dụng std::forward để Giữ Nguyên Kiểu
std::forward được sử dụng để truyền các đối số một cách chính xác, đảm bảo chúng được truyền dưới dạng lvalue hoặc rvalue tùy thuộc vào kiểu ban đầu.
template <typename F, typename T1, typename T2>
void flip(F f, T1&& t1, T2&& t2) {
f(std::forward<T2>(t2), std::forward<T1>(t1));
}
std::forward<T>(arg) sẽ trả về một tham chiếu rvalue tới arg nếu T là một loại tham chiếu rvalue, và trả về một tham chiếu lvalue nếu T là một loại tham chiếu lvalue. Điều này đảm bảo rằng các thuộc tính lvalue/rvalue của đối số gốc được bảo toàn khi chuyển đến hàm f.