[Tự học C++] Biến tham chiếu trong C++ » Cafedev.vn

Đến nay, chúng ta đã thảo luận về hai loại biến cơ bản:

  • Các biến thường, là những biến mà chứa trực tiếp giá trị.
  • Các biến con trỏ, là những biến mà giữ địa chỉ của một giá trị nào đó (hoặc giá trị null), và có thể được dereferenced để lấy về giá trị tại địa chỉ mà chúng đang trỏ đến.

Tham chiếu chính là kiểu dữ liệu cơ bản thứ ba của biến, mà C++ hỗ trợ. Một tham chiếu (reference) là một kiểu dữ liệu của biến trong C++, hoạt động giống như một bí danh cho một đối tượng hoặc giá trị khác.

C++ hỗ trợ 3 kiểu tham chiếu:

  • Tham chiếu đến các giá trị không phải hằng (thường được gọi là “tham chiếu”, hoặc “tham chiếu không hằng”), chúng ta sẽ nói đến loại tham chiếu này ngay trong bài hiện tại.
  • Tham chiếu đến các giá trị hằng (thường được gọi là “tham chiếu hằng”), chúng ta sẽ tìm hiểu về kiểu tham chiếu này trong bài sau.
  • C++ 11 đã bổ sung thêm tham chiếu r-value, nó sẽ được nói đến ngay trong bài này.

1. Tham chiếu đến các giá trị không phải hằng

Một tham chiếu (đến một giá trị không phải hằng) đưowjc khai báo bằng cách sử dụng ký hiệu & giữa kiểu dữ liệu của tham chiếu và tên biến:

int value{ 5 }; // normal integer
int &ref{ value }; // reference to variable value

Trong ngữ cảnh này, ký hiệu & không mang ý nghĩa “địa chỉ của”, nó có nghĩa là “tham chiếu tới”.

Các tham chiếu tới những giá trị không phải hằng đều thường được gọi ngắn gọn là “tham chiếu”.

2. Tham chiếu đóng vai trò là bí danh

Các tham chiếu thường có vai trò giống hệt với vai trò của các giá trị mà chúng đang tham chiếu tới. Theo nghĩa này, một tham chiếu sẽ đóng vai trò là một bí danh cho đối tượng đang được tham chiếu. Ví dụ:

int x{ 5 }; // normal integer
int &y{ x }; // y is a reference to x
int &z{ y }; // z is also a reference to x

Trong đoạn code trên, việc gán hay lấy giá trị của biến x, y, hay z là như nhau (đều chỉ là gán hoặc lấy giá trị của biến x).

Đoạn code sau mô tả một trường hợp mà tham chiếu đóng vai trò là bí danh cho một biến:

/**
* Cafedev.vn - Kênh thông tin IT hàng đầu Việt Nam
*
* @author cafedevn
* Contact: [email protected]
* Fanpage: https://www.facebook.com/cafedevn
* Instagram: https://instagram.com/cafedevn
* Twitter: https://twitter.com/CafedeVn
* Linkedin: https://www.linkedin.com/in/cafe-dev-407054199/
*/

#include <iostream>
 
int main()
{
    int value{ 5 }; // normal integer
    int &ref{ value }; // reference to variable value
 
    value = 6; // value is now 6
    ref = 7; // value is now 7
 
    std::cout << value << '\n'; // prints 7
    ++ref;
    std::cout << value << '\n'; // prints 8
 
    return 0;
}

Kết quả in ra:

7
8

Trong ví dụ trên, các biến ref và value được coi là như nhau.

Việc sử dụng toán tử “địa chỉ của” trên một tham chiếu sẽ trả về địa chỉ của giá trị đang được tham chiếu tới.

cout << &value; // prints 0012FF7C
cout << &ref; // prints 0012FF7C

Bởi vì ref hoạt động như một bí danh cho value, nên hai câu lệnh trên cho ra kết quả giống nhau.

3. l-values và r-values

Trong C++, các biến đều là kiểu l-value (phát âm là ell-value). Một l-value là một giá trị mà có một địa chỉ (trong bộ nhớ). Bởi vì tất cả các biến đều có địa chỉ, cho nên tất cả các biến đều là l-values. Cái tên l-value bắt nguồn từ việc các giá trị l-values là các giá trị duy nhất mà có thể nằm ở bên trái của một câu lệnh gán. Khi chúng ta thực hiện phép gán, phía bên trái của toán tử gán phải là một l-value. Do đó, một câu lệnh như 5 = 6; sẽ gây ra lỗi biên dịch, bởi vì 5 không phải là một l-value. Giá trị 5 không được cấp phát bộ nhớ, và do đó không có giá trị nào có thể được gán cho nó. 5 có nghĩa là 5, và giá trị của nó không thể được gán lại. Khi một l-value được gán cho một giá trị, giá trị hiện tại ở địa chỉ bộ nhớ đó sẽ bị ghi đè lên.

Ngược lại với l-values là các r-values (phát âm là arr-values). Một r-value có thể cập nhật giá trị mới đang được gán cho một l-value. Các r-values luôn tạo ra chỉ một giá trị duy nhất. Ví dụ về r-values có thể là chữ số (chẳng hạn 5), các biến (chẳng hạn như x, có giá trị được tính là giá trị nào đó đã được gán cho nó vào lần cuối), hoặc biểu thức (như 2 + x, được tính là giá trị của x cộng với 2).

Dưới đây là ví dụ về một số câu lệnh gán, cho thấy cách mà các r-values được tính giá trị:

/**
* Cafedev.vn - Kênh thông tin IT hàng đầu Việt Nam
*
* @author cafedevn
* Contact: [email protected]
* Fanpage: https://www.facebook.com/cafedevn
* Instagram: https://instagram.com/cafedevn
* Twitter: https://twitter.com/CafedeVn
* Linkedin: https://www.linkedin.com/in/cafe-dev-407054199/
*/

int y;      // define y as an integer variable
y = 4;      // 4 evaluates to 4, which is then assigned to y
y = 2 + 5;  // 2 + 5 evaluates to 7, which is then assigned to y
 
int x;      // define x as an integer variable
x = y;      // y evaluates to 7 (from before), which is then assigned to x.
x = x;      // x evaluates to 7, which is then assigned to x (useless!)
x = x + 1;  // x + 1 evaluates to 8, which is then assigned to x.

Hãy cùng xem xét kỹ hơn về câu lệnh gán cuối cùng ở ví dụ trên, bời vì nó có thể gây ra sự nhầm lẫn nhiều nhất.

x = x + 1;

Trong câu lệnh này, biến x đang được sử dụng trong hai ngữ cảnh khác nhau. Ở phía bên trái của toán tử gán (tức là dấu =), “x” đang được sử dụng như một l-value (biến với một địa chỉ bộ nhớ đã được cấp phát). Còn ở phía bên phải của toán tử gán, x đang được sử dụng như một r-value, và sẽ được tính toán để tạo ra một giá trị (trong trường hợp này là 7). Khi C++ tính toán câu lệnh trên, nó sẽ tính như sau:

x = 7 + 1;

Và hiển nhiên là sau đó, C++ sẽ gán giá trị 8 trở lại cho biến x.

Điểm đáng chú ý trong câu lệnh này là ở phía bên trái của phép gán, bạn phải có một cái gì đó để đại diện cho một địa chỉ bộ nhớ (chẳng hạn như một biến). Tất cả mọi thứ ở phía bên phải của phép gán sẽ được tính toán để tạo ra một giá trị.

Lưu ý: Các biến hằng được coi là các l-values không thể thay đổi

4. Tham chiếu phải được khởi tạo

Các tham chiếu cần phải được khợi tạo khi được tạo ra:

int value{ 5 };
int &ref{ value }; // valid reference, initialized to variable value
 
int &invalidRef; // invalid, needs to reference something

Không giống như con trỏ có thể giữ một giá trị null, đối với tham chiếu thì không thể có tham chiếu null.

Các tham chiếu đến các giá trị không phải hằng chỉ có thể được khởi tạo với các l-values không phải hằng. Chúng không thể được khởi tạo với các l-values hoặc r-values, là hằng.

int x{ 5 };
int &ref1{ x }; // okay, x is an non-const l-value
 
const int y{ 7 };
int &ref2{ y }; // not okay, y is a const l-value
 
int &ref3{ 6 }; // not okay, 6 is an r-value

Lưu ý rằng, trong đoạn code nằm giữa ở ví dụ trên, bạn không thể khởi tạo một tham chiếu không hằng với một đối tượng hằng – nếu không thì bạn sẽ có thể thay đổi giá trị của đối tượng hằng thông qua tham chiếu, điều này sẽ vi phạm tính hằng của đối tượng này.

5. Tham chiếu không thể được gán lại

Một khi đã được khởi tạo, một tham chiếu không thể được thay đổi để tham chiếu đến một biến khác. Xét đoạn code sau:

int value1{ 5 };
int value2{ 6 };
 
int &ref{ value1 }; // okay, ref is now an alias for value1
ref = value2; // assigns 6 (the value of value2) to value1 -- does NOT change the reference!

Lưu ý rằng câu lệnh thứ hai có thể không thực hiện những gì mà bạn mong đợi! Thay vì thay đổi ref để tham chiếu đến biến value2, nó gán giá tị của value2 cho value1.

6. Tham chiếu đóng vai trò là tham số truyền vào hàm

Các tham chiếu cũng thường được sử dụng làm tham số truyền vào cho các hàm. Trong ngữ cảnh này, tham số tham chiếu đóng vai trò là bí danh cho đối số thực sự được truyền vào hàm, và không có bản sao nào của đối số được tạo ra để truyền vào tham số hàm. Điều này giúp cải thiện hiệu năng tốt hơn trong những trường hợp mà đối số truyền vào có kích thước lớn hoặc tiêu tốn nhiều tài nguyên để sao chép.

Trong bài 6.8. Con trỏ và mảng, chúng ta đã nói về cách làm thế nào mà việc truyền một đối số là con trỏ cho một hàm, sẽ cho phép hàm này có thể dereference con trỏ để sửa đổi trực tiếp giá trị của đối số.

Tham chiếu hoạt động tương tự trong tình huống này. Bởi vì tham số truyền vào hàm là tham chiếu sẽ đóng vai trò là bí danh cho đối số, nên một hàm mà sử dụng tham chiếu làm tham số thì sẽ có khả năng sửa đổi đối số được truyền vào.

/**
* Cafedev.vn - Kênh thông tin IT hàng đầu Việt Nam
*
* @author cafedevn
* Contact: [email protected]
* Fanpage: https://www.facebook.com/cafedevn
* Instagram: https://instagram.com/cafedevn
* Twitter: https://twitter.com/CafedeVn
* Linkedin: https://www.linkedin.com/in/cafe-dev-407054199/
*/

#include <iostream>
 
// ref is a reference to the argument passed in, not a copy
void changeN(int &ref)
{
	ref = 6;
}
 
int main()
{
	int n{ 5 };
 
	std::cout << n << '\n';
 
	changeN(n); // note that this argument does not need to be a reference
 
	std::cout << n << '\n';
	return 0;
}

Đoạn chương trình trên in ra:

5
6

Khi đối số n được truyền vào hàm, tham số ref của hàm sẽ thiết lập một tham chiếu tới đối số n. Điều này cho phép hàm changeN có thể thay đổi giá trị của của đối số n thông qua tham số tham chiếu ref! Lưu ý rằng, bản thân đối số n không cần thiết phải là một tham chiếu.

Thực hành tốt: Hãy truyền đối số vào hàm thông qua tham chiếu không hằng khi đối số cần được sửa đổi bởi hàm.

7. Sử dụng tham chiếu để truyền các mảng kiểu C-style cho hàm

Một trong những vấn đề khó chịu nhất đối với các mảng kiểu C-style là trong hầu hết các trường hợp, chúng phân rã thành con trỏ khi được xét. Tuy nhiên, nếu một mảng C-style được truyền theo kiểu tham chiếu, sự phân rã này sẽ không xảy ra.

Dưới đây là một ví dụ về điều này:

/**
* Cafedev.vn - Kênh thông tin IT hàng đầu Việt Nam
*
* @author cafedevn
* Contact: [email protected]
* Fanpage: https://www.facebook.com/cafedevn
* Instagram: https://instagram.com/cafedevn
* Twitter: https://twitter.com/CafedeVn
* Linkedin: https://www.linkedin.com/in/cafe-dev-407054199/
*/

#include <iostream>
#include <iterator> 
 
// Note: You need to specify the array size in the function declaration
void printElements(int (&arr)[4])
{
  int length{ static_cast<int>(std::size(arr)) }; // we can now do this since the array won't decay
  
  for (int i{ 0 }; i < length; ++i)
  {
    std::cout << arr[i] << '\n';
  }
}
 
int main()
{
    int arr[]{ 99, 20, 14, 80 };
    
    printElements(arr);
 
    return 0;
}

Lưu ý rằng để đoạn code này hoạt động, bạn phải chỉ định rõ ràng kích thước của mảng trong phần tham số truyền vào hàm.

8. Tham chiếu đóng vai trò là shortcut

Một cách sử dụng thứ hai của tham chiếu (cách này ít được sử dụng hơn cách chính nhiều) là để cung cấp sự truy cập tới dữ liệu lồng (nested data). Cùng xét struct sau:

struct Something
{
    int value1;
    float value2;
};
 
struct Other
{
    Something something;
    int otherValue;
};
 
Other other;

Giả sử, chúng ta cần phải làm việc với trường value1 thuộc struct Something của biến other. Thông thường, chúng ta sẽ truy cập vào biến thành viên theo kiểu other.something.value1. Nếu có nhiều truy cập riêng biệt vào biến thành viên này, code của bạn có thể trở nên lộn xộn. Tham chiếu cho phép bạn truy cập biến thành viên một cách dễ dàng hơn nhiều:

int &ref{ other.something.value1 };
// ref can now be used in place of other.something.value1

Hai câu lệnh dưới đây là như nhau:

other.something.value1 = 5;
ref = 5;

Việc này có thể giúp code của bạn gọn gàng và dễ đọc hơn.

9. Tham chiếu và con trỏ

Tham chiếu và con trỏ có một mối quan hệ thú vị — Khi tham chiếu được truy cập, nó sẽ hoạt động giống như một con trỏ đang được dereferenced một cách ngầm định (không tường minh). Các tham chiếu thường được trình biên dịch cài đặt một cách nội bộ (internally) bằng các con trỏ. Xét ví dụ sau:

int value{ 5 };
int *const ptr{ &value };
int &ref{ value };

*ptr và ref là như nhau. Kết quả là hai câu lệnh dưới đây sẽ tạo ra cùng một kết quả:

*ptr = 5;
ref = 5;

Bởi vì các tham chiếu phải được khởi tạo với các đối tượng (objects) hợp lệ (không thể là null) và không thể được sửa đổi một khi đã thiết lập, cho nên việc sử dụng tham chiếu thường an toàn hơn nhiều so với con trỏ (bởi vì không có rủi ro về việc vô tình dereferencing một con trỏ null). Tuy nhiên, chúng cũng hạn chế hơn về mặt chức năng so với con trỏ.

Nếu một tác vụ nhất định có thể được giải quyết bằng tham chiếu hoặc con trỏ, thì tham chiếu sẽ thường được ưu tiên hơn. Con trỏ chỉ nên được sử dụng trong các tình huống mà tham chiếu không đáp ứng được đầy đủ (chẳng hạn như việc cấp phát động bộ nhớ).

10. Tổng kết

Tham chiếu cho phép chúng ta định nghĩa những biệt danh cho các đối tượng hoặc giá trị khác nhau. Tham chiếu tới các giá trị không phải hằng chỉ có thể được khởi tạo với các l-values không phải hằng. Các tham chiếu không thể được gán lại một khi đã được khởi tạo.

Tham chiếu thường được sử dụng làm tham số truyền vào cho hàm khi chúng ta hoặc là muốn sửa đổi giá trị của đối số truyền vào, hoặc là khi chúng ta muốn tránh việc tạo ra một bản sao của đối số (việc này sẽ tiêu tốn tài nguyên máy tính nếu đối số có kích thước lớn).

Đăng ký kênh youtube để ủng hộ Cafedev nha các bạn, Thanks you!