Một tên khác cho cùng một thứ
Ở trang tham số của hàm, mọi đối số đều được sao chép vào hàm. Chính bản sao đó là lý do hàm không thể thay đổi biến của nơi gọi: nó chỉ thấy bản sao của riêng mình. Tham chiếu (reference) phá bức tường đó. Nó là một alias: một cái tên thứ hai gắn với một biến đã có, dùng chung đúng vùng nhớ đó.
Bạn tạo tham chiếu bằng & trong khai báo. Khi đã gắn, tham chiếu và biến gốc không thể phân biệt được:
Hai quy tắc giúp tham chiếu an toàn và dễ đoán: tham chiếu phải được khởi tạo ngay lúc khai báo (int& r; là lỗi biên dịch), và không bao giờ gắn lại để trỏ sang thứ khác sau đó. Gán vào tham chiếu luôn là ghi vào thứ mà nó được gắn từ đầu.
Truyền tham chiếu: để hàm chạm tới nơi gọi
Lợi ích thực sự nằm ở hàm. Đặt & vào một tham số, hàm sẽ nhận một tên khác của đối số từ nơi gọi thay vì một bản sao. Giờ thay đổi bên trong hàm sẽ thấy được ở bên ngoài:
Bỏ & đi thì addBonus chỉ tăng một bản sao dùng xong bỏ, và total vẫn là 100. Chỉ một ký tự là toàn bộ khác biệt. Đây là cách chuẩn để viết một hàm trả về nhiều hơn một kết quả hoặc sửa trực tiếp đầu vào của nó. Ví dụ kinh điển là hoán đổi hai biến:
Không có tham chiếu, swapValues chỉ hoán đổi các bản sao cục bộ và x/y vẫn là 1 2. (Thư viện chuẩn đã có sẵn std::swap, nhưng tự viết sẽ cho bạn thấy chính xác tham số tham chiếu mang lại điều gì.)
Tham chiếu const: đọc nhanh, hứa không sửa
Truyền tham chiếu còn tránh được việc sao chép, và với một đối tượng lớn, bản sao đó có thể rất tốn kém. Nhưng một tham số T& thông thường ngầm nói "tôi có thể sửa cái này", điều gây hiểu lầm khi bạn chỉ muốn đọc. Cách khắc phục là const T&: bạn có tốc độ không sao chép của tham chiếu và một cam kết được trình biên dịch bảo đảm rằng hàm sẽ không thay đổi đối số.
Tham chiếu không const chỉ gắn được với một biến có thể sửa, nhưng tham chiếu const còn gắn được với literal và đối tượng tạm, đó là lý do greet("literal works too") biên dịch được. Một quy tắc thực tế để chọn kiểu tham số:
void f(int x) // cheap type, read-only -> just copy it
void f(const string& s) // heavy type, read-only -> const reference
void f(string& s) // you intend to modify the caller's object
Mặc định dùng const T& cho mọi kiểu lớp mà bạn chỉ đọc (string, vector, struct của riêng bạn), và chỉ dùng tham chiếu không const khi bạn thật sự muốn ghi ngược lại.
Trả về tham chiếu
Một hàm cũng có thể trả về tham chiếu, đưa cho nơi gọi một tên khác của thứ đã tồn tại. Điều này phổ biến trong code dạng container: đó là thứ giúp v[i] = 5 chạy được, và cũng là điều nạp chồng toán tử làm ở bên dưới:
Vì at trả về int&, biểu thức gọi at(data, 1) tự nó là một lvalue bạn có thể gán vào. Nếu trả về int thông thường, at(data, 1) = 42 sẽ không biên dịch được, vì bạn đang gán vào một bản sao tạm.
Cái bẫy lớn: tham chiếu treo
Tham chiếu không sở hữu gì cả; nó chỉ trỏ tới vùng nhớ nằm ở nơi khác. Nếu vùng nhớ đó bị hủy trong khi tham chiếu vẫn đang được dùng, bạn có một tham chiếu treo (dangling reference), và đọc qua nó là hành vi không xác định: nó có thể in ra rác, crash, hoặc có vẻ vẫn chạy cho đến khi gây họa trên production. Lỗi kinh điển là trả về tham chiếu tới một biến cục bộ:
int& broken() {
int local = 42;
return local; // BUG: local is destroyed when broken() returns
} // the returned reference dangles
int main() {
int& r = broken();
cout << r << "\n"; // UNDEFINED BEHAVIOR - reads dead memory
}
Biến cục bộ local biến mất ngay khi broken trả về, nên tham chiếu trỏ tới vùng stack đã bị thu hồi. Chỉ trả về tham chiếu tới thứ sống lâu hơn lời gọi: một tham số được truyền vào theo tham chiếu, một thành viên dữ liệu, hoặc một biến static. Nếu giá trị được tính bên trong hàm, hãy trả về theo giá trị và để trình biên dịch tối ưu bỏ bản sao. Cái bẫy tương tự cũng xảy ra với vòng lặp for dạng range và mọi tham chiếu gắn với đối tượng tạm: đừng bao giờ giữ tham chiếu lâu hơn vòng đời của thứ mà nó gọi tên.
Tiếp theo: nạp chồng hàm
Tham chiếu cho bạn thêm một lựa chọn ở mỗi tham số: sao chép hay tên khác, có thể sửa hay const, và lựa chọn đó liên quan trực tiếp đến chủ đề tiếp theo. Nạp chồng hàm cho phép bạn định nghĩa nhiều hàm cùng tên nhưng khác danh sách tham số, và trình biên dịch chọn đúng hàm bằng cách khớp kiểu đối số, kể cả việc chúng được truyền theo giá trị, theo tham chiếu hay theo tham chiếu const.
Câu hỏi thường gặp
Tham chiếu trong C++ là gì?
Tham chiếu là một tên khác (alias) của một biến đã có, tức là một cái tên nữa cho cùng vùng nhớ. Bạn tạo tham chiếu bằng & trong khai báo: int& r = x;. Sau đó r và x dùng thay thế cho nhau được; đổi cái này là đổi cái kia. Tham chiếu phải được khởi tạo ngay khi khai báo và không bao giờ gắn lại sang biến khác được.
Truyền tham trị và truyền tham chiếu trong C++ khác nhau thế nào?
Truyền tham trị (void f(int x)) sao chép đối số, nên hàm làm việc trên bản sao của riêng nó và biến của nơi gọi không bị đụng tới. Truyền tham chiếu (void f(int& x)) cho hàm truy cập trực tiếp biến của nơi gọi, nên thay đổi vẫn còn sau lời gọi, và không có bản sao nào được tạo, điều quan trọng với các đối tượng lớn.
Khi nào nên dùng tham số tham chiếu const trong C++?
Dùng const T& khi hàm chỉ cần đọc một tham số nhưng kiểu đó tốn kém để sao chép (string, vector, struct lớn). Bạn vừa có tốc độ không sao chép của tham chiếu, vừa được trình biên dịch đảm bảo hàm không sửa giá trị của nơi gọi. Với kiểu rẻ như int hay double, truyền tham trị thông thường đơn giản hơn và nhanh không kém.