Chuyển tới nội dung chính

Con trỏ và tham chiếu

Đây là khái niệm bản lề để bước vào OOP. Mọi biến đều nằm ở một ô nhớ có địa chỉ. Tham chiếu (&) là một bí danh cho ô đó; con trỏ (*) là một biến chứa địa chỉ của ô khác. Hiểu hai thứ này, bạn sẽ hiểu vì sao truyền đối tượng vào hàm lúc thì đổi được bản gốc, lúc thì không.

Hãy hình dung một câu chuyện thực tế. Bạn muốn nhờ bạn cùng phòng sửa lại con số ghi trên tấm bảng treo tường. Có hai cách: một là chép con số ra tờ giấy đưa cho họ, họ sửa trên giấy nhưng tấm bảng vẫn nguyên; hai là chỉ cho họ vị trí tấm bảng để họ tới tận nơi sửa. Cách một là truyền theo giá trị, cách hai là truyền theo địa chỉ. Con trỏ và tham chiếu chính là cơ chế ngôn ngữ cho cách hai. Đây là lý do nhiều người mới bối rối khi hàm của họ tính đúng bên trong nhưng biến bên ngoài không đổi.

Bộ nhớ, địa chỉ và hai toán tử & với *

Bộ nhớ máy tính là một dãy dài các ô, mỗi ô có một địa chỉ đánh số như số nhà. Khi bạn viết int a = 5;, hệ thống dành một ô cho a và ghi 5 vào đó. Hai toán tử giúp bạn làm việc với địa chỉ:

  • &a đọc là "địa chỉ của a", cho biết ô nhớ của a nằm ở đâu.
  • *p đọc là "giá trị tại địa chỉ mà p giữ", gọi là giải tham chiếu (dereference).

Hai toán tử này ngược nhau: & đi từ biến ra địa chỉ, * đi từ địa chỉ về giá trị.

#include <iostream>
using namespace std;

int main() {
int a = 5;
cout << a << "\n"; // 5, giá trị
cout << &a << "\n"; // một địa chỉ dạng 0x..., nơi a nằm
}

Địa chỉ in ra trông như 0x7ffd..., khác nhau mỗi lần chạy, đó là chuyện bình thường. Điều quan trọng không phải con số cụ thể mà là ý niệm: mỗi biến có một chỗ ở riêng.

Hãy hình dung bộ nhớ như một dãy phố dài với những căn nhà đánh số liên tiếp. Tên biến giống biệt danh bạn đặt cho một căn nhà, còn địa chỉ là số nhà thật; máy tính không quan tâm biệt danh, sau khi biên dịch nó chỉ làm việc với số nhà. Căn nhà cấp cho một double rộng hơn căn nhà của một int, thường tám byte thay vì bốn.

Chi tiết "rộng bao nhiêu byte" chính là lý do con trỏ phải mang kiểu. Một int* không chỉ nhớ số nhà, nó còn ngầm biết căn nhà đó rộng bốn byte và phải đọc theo quy ước số nguyên. Đưa cùng địa chỉ ấy cho một double* thì nó đọc tám byte theo quy ước số thực, cho ra giá trị vô nghĩa. Vậy con trỏ không chỉ nói "ở đâu", nó còn nói "đọc như thế nào".

Ý tưởng cốt lõi

  • int& r = a;, rbí danh của a: đổi r là đổi chính a.
  • int* p = &a;, p giữ địa chỉ của a; *p là "đi tới ô mà p trỏ", *p = 9 đổi a.
  • &a nghĩa là "địa chỉ của a"; *p nghĩa là "giá trị tại địa chỉ p".

Mã nguồn

#include <iostream>
using namespace std;

int main() {
int a = 5;
int& r = a; // r là bí danh của a
int* p = &a; // p giữ địa chỉ của a
r = 7; // đổi a qua bí danh -> a = 7
*p = 9; // đổi a qua con trỏ -> a = 9
cout << a; // 9
}

Thử ngay: xem con trỏ và tham chiếu chạy từng bước

Bí danh và địa chỉ cùng trỏ về một ô nhớ C++
1int main() {
2 int a = 5;
3 int& r = a;
4 int* p = &a;
5 r = 7;
6 *p = 9;
7 cout << a;
8}
Ngăn xếp stack
main()
#1int a
giá trị5
Bộ nhớ động heap
(trống)
Tạo ô nhớ a chứa 5 (xem a như một ô có địa chỉ riêng, đánh số #1).
1/6

Khai báo con trỏ và con trỏ null

Con trỏ cũng là một biến, nhưng thứ nó chứa là địa chỉ, không phải giá trị thường. Khai báo có dạng kiểu* tên. Con trỏ phải khớp kiểu với ô nó trỏ tới: int* trỏ tới int, double* trỏ tới double.

#include <iostream>
using namespace std;

int main() {
int a = 5;
int* p = &a; // p giữ địa chỉ của a
cout << *p << "\n"; // 5, đi tới ô p trỏ và đọc
*p = 12; // ghi vào ô đó -> a = 12
cout << a << "\n"; // 12
}

Một con trỏ chưa trỏ vào đâu nên gán nullptr để đánh dấu "rỗng". Đừng bao giờ giải tham chiếu một con trỏ null, vì *p khi pnullptr sẽ làm chương trình sập.

int* p = nullptr; // chưa trỏ vào ô nào
if (p != nullptr) { // luôn kiểm tra trước khi dùng
cout << *p; // chỉ chạy khi p thực sự trỏ vào đâu đó
}

Vì sao cần một giá trị đặc biệt như vậy? Bởi con trỏ thường dùng để diễn đạt ý "có thể có, có thể không". Một hàm đi tìm sinh viên theo mã số có thể tìm thấy hoặc không; trả về con trỏ cho phép nó nói "đây là người bạn cần" hoặc "không có ai cả" bằng nullptr.

Điều nguy hiểm không nằm ở bản thân nullptr mà ở chỗ quên kiểm tra. Khi bạn viết *p với p bằng nullptr, chương trình đi tới địa chỉ 0, vùng mà hệ điều hành cố tình chặn, nên tiến trình bị giết ngay. Nghe thì tệ, nhưng đó lại là kịch bản may mắn: lỗi hiện ra rõ ràng, đúng chỗ. Đáng sợ hơn là con trỏ chưa khởi tạo, ví dụ int* p; rồi dùng luôn: lúc đó p chứa rác, có thể tình cờ là một địa chỉ hợp lệ, và *p = 9; sẽ lặng lẽ ghi đè lên dữ liệu của thứ khác. Chương trình vẫn chạy, chỉ có kết quả sai ở một nơi hoàn toàn không liên quan. Quy tắc rút ra: con trỏ nào cũng phải được khởi tạo ngay khi khai báo.

Tham chiếu và khác biệt với con trỏ

Tham chiếu là bí danh cho một biến đã có. Khai báo int& r = a; không tạo ô mới, r chỉ là một cái tên khác của a. Từ đó mọi thao tác trên r là thao tác thẳng trên a, không cần dấu * gì cả.

#include <iostream>
using namespace std;

int main() {
int a = 5;
int& r = a; // r là tên khác của a
r = 20; // đổi r tức đổi a
cout << a; // 20
}

Ba khác biệt cần nhớ giữa hai công cụ:

  • Khởi tạo. Tham chiếu phải gắn vào một biến ngay lúc khai báo và không đổi mục tiêu được nữa. Con trỏ có thể khai báo trước, gán sau, đổi trỏ sang biến khác tùy ý.
  • Rỗng. Con trỏ có thể là nullptr. Tham chiếu luôn phải bám vào một biến thật.
  • Cú pháp. Dùng tham chiếu như biến thường (r = 7). Dùng con trỏ phải giải tham chiếu (*p = 7) mới chạm tới giá trị.

Hai đặc tính đầu đáng nói kỹ vì chúng gây nhầm lẫn nhiều nhất. Thứ nhất, int& r; không biên dịch được: tham chiếu bắt buộc phải có mục tiêu ngay từ lúc sinh ra, vì một bí danh không đặt cho ai thì vô nghĩa. Chính điều này làm tham chiếu an toàn hơn con trỏ: bạn không thể quên khởi tạo nó, và nó không bao giờ rỗng.

Thứ hai, và đây là chỗ dễ sai nhất, tham chiếu không gán lại được. Nhiều người nghĩ sau khi có int& r = a; thì viết r = b; sẽ khiến r quay sang làm bí danh của b. Không phải. Vì r chính là a, câu r = b; được hiểu là a = b;, tức chép giá trị của b vào a. Biến b không hề bị đụng tới, và r vẫn dính chặt vào a như cũ.

#include <iostream>
using namespace std;

int main() {
int a = 1, b = 99;
int& r = a; // r gắn vĩnh viễn vào a
r = b; // KHÔNG phải "cho r trỏ sang b", mà là a = 99
cout << a << " " << b << "\n"; // 99 99
b = 7; // sửa b thì a không đổi, vì r chưa bao giờ liên quan tới b
cout << a << " " << b << "\n"; // 99 7
}

Con trỏ thì ngược lại: p = &b; thực sự làm p quay sang trỏ vào b, còn *p = 5; mới là ghi giá trị. Vì con trỏ tách bạch hai thao tác "đổi mục tiêu" và "đổi giá trị" nên nó cần thêm dấu *, còn tham chiếu chỉ có một thao tác duy nhất nên không cần dấu gì.

Xem chạy từng bước: tham chiếu cố định, con trỏ đổi hướng

Trình mô phỏng đầu bài cho thấy r*p cùng dẫn về một ô nhớ. Trình dưới đây làm rõ điểm khác nhau then chốt: gán lại một tham chiếu chỉ là chép giá trị, còn gán lại một con trỏ mới thật sự đổi mục tiêu. Hãy nhìn kỹ hai dòng r = b;p = &b; để thấy chúng làm hai việc hoàn toàn khác nhau.

r = b không đổi mục tiêu, p = &b thì có C++
1#include <iostream>
2using namespace std;
3
4int main() {
5 int a = 1, b = 99;
6 int& r = a;
7 int* p = &a;
8 r = b;
9 p = &b;
10 cout << a << " " << b;
11}
Ngăn xếp stack
main()
#1int a
giá trị1
#2int b
giá trị99
Bộ nhớ động heap
(trống)
Tạo a = 1 (ô #1) và b = 99 (ô #2).
1/6

Chọn con trỏ hay tham chiếu

Tiêu chíTham chiếu int& rCon trỏ int* p
Bắt buộc khởi tạokhông, nhưng nên tự giác
Đổi mục tiêu sau đókhông đượcđược
Biểu diễn "không có gì"n/anullptr
Truy cập giá trịr*p
Rủi ro sập chương trìnhrất thấpcao nếu quên kiểm tra null

Nguyên tắc chọn rất gọn. Nếu đối tượng chắc chắn tồn tại và bạn chỉ cần một lối vào nó, hãy dùng tham chiếu: ít gõ hơn, không thể null, trình biên dịch canh giúp bạn. Nếu cần diễn đạt "có thể không có gì" hoặc cần đổi mục tiêu lúc chạy (duyệt danh sách, xây cây, cấp phát động), hãy dùng con trỏ.

Truyền tham chiếu vào hàm

Đây là chỗ con trỏ và tham chiếu tỏ ra hữu ích nhất. Mặc định C++ truyền tham số theo giá trị, tức sao chép, nên hàm chỉ sửa được bản sao. Thêm dấu & vào tham số để hàm làm việc trực tiếp trên biến gốc.

#include <iostream>
using namespace std;

void themMotSaiCach(int x) { x = x + 1; } // sửa bản sao, vô ích
void themMotDungCach(int& x) { x = x + 1; } // sửa biến gốc

int main() {
int a = 5;
themMotSaiCach(a);
cout << a << "\n"; // vẫn 5
themMotDungCach(a);
cout << a << "\n"; // 6
}

Ví dụ kinh điển là hàm hoán đổi hai biến. Không có tham chiếu thì không thể viết đúng.

#include <iostream>
using namespace std;

void hoanDoi(int& x, int& y) {
int tam = x; // x, y là bí danh của biến ngoài
x = y;
y = tam;
}

int main() {
int a = 3, b = 8;
hoanDoi(a, b);
cout << a << " " << b; // 8 3
}

Hãy theo dõi bằng lời cho thật chắc. Khi gọi hoanDoi(a, b), C++ không tạo hai biến mới, nó chỉ gắn x làm bí danh của ay làm bí danh của b. Dòng int tam = x; chép giá trị 3 ra một biến tạm, dòng x = y; viết 8 vào chính ô nhớ của a, dòng y = tam; viết 3 vào chính ô nhớ của b. Hàm kết thúc, tam biến mất, nhưng ab đã thực sự đổi chỗ. Bỏ hai dấu & đi thì toàn bộ việc đổi chỗ diễn ra trên hai bản sao rồi bị vứt đi, còn a với b ở ngoài vẫn y nguyên.

Tham chiếu hằng để tránh sao chép

Có một lý do thứ hai để dùng tham chiếu, thực tế còn phổ biến hơn cả việc sửa biến gốc: tránh sao chép. Truyền theo giá trị nghĩa là chép, mà chép một int thì rẻ, chép một chuỗi dài hay một danh sách nghìn phần tử thì đắt. Hàm chỉ cần đọc, nhưng vẫn phải trả giá cho một bản sao đầy đủ ở mỗi lần gọi. Giải pháp là tham chiếu hằng, viết const T&, nói hai điều cùng lúc: đừng chép, và tôi hứa không sửa.

#include <iostream>
#include <string>
using namespace std;

void inHoTen(const string& ten) { // không chép chuỗi, và không được sửa nó
cout << "Xin chao " << ten << "\n";
// ten = "khac"; // lỗi biên dịch: ten là hằng
}

int main() {
string ho = "Nguyen Van A";
inHoTen(ho); // ho không bị chép, cũng không thể bị hàm làm hỏng
}

Chữ const ở đây không phải hình thức, nó là cam kết mà trình biên dịch cưỡng chế: bất kỳ dòng nào trong hàm cố sửa ten đều bị chặn ngay lúc biên dịch, nhờ vậy người đọc chữ ký hàm biết chắc dữ liệu của mình an toàn mà không cần đọc thân hàm. Quy tắc thực dụng cho mọi tham số: kiểu nhỏ như int, double, char thì truyền theo giá trị; kiểu lớn như string, vector, đối tượng của lớp thì truyền const T& nếu chỉ đọc, và T& nếu cần sửa.

Con trỏ và mảng

Tên một mảng gần như là địa chỉ phần tử đầu, nên con trỏ và mảng liên hệ chặt chẽ. Con trỏ p = a; trỏ tới a[0], và p + 1 trỏ tới a[1]. Biểu thức *(p + i) cho đúng a[i].

#include <iostream>
using namespace std;

int main() {
int a[3] = {10, 20, 30};
int* p = a; // p trỏ tới a[0]
cout << *p << "\n"; // 10
cout << *(p + 2) << "\n";// 30, tức a[2]
}

Bạn chưa cần dùng cách này hằng ngày, nhưng nó giải thích vì sao chỉ số mảng bắt đầu từ 0: chỉ số i chính là bước nhảy i ô kể từ đầu.

Vì sao đây là bản lề sang OOP

Một đối tượng thường không nhỏ: một SinhVien có họ tên, mã số, danh sách điểm, chép nó là chép cả cụm dữ liệu bên trong. Vì vậy trong OOP, đối tượng gần như luôn được truyền quanh bằng tham chiếu chứ không phải bản sao. Bạn sẽ thấy const SinhVien& ở khắp nơi trong tham số hàm, và giờ bạn biết chính xác nó nghĩa là gì.

Sâu hơn, mỗi phương thức của một lớp đều ngầm nhận một con trỏ tên là this, trỏ tới chính đối tượng đang gọi nó. Khi bạn viết sv.capNhatDiem(9), trình biên dịch âm thầm truyền địa chỉ của sv vào. Đó là lý do một phương thức sửa được dữ liệu của đúng đối tượng gọi nó, chứ không phải của một đối tượng khác cùng lớp. Hiểu vì sao void f(int& x) sửa được biến gốc còn void f(int x) thì không, bạn sẽ hiểu ngay this mà không phải học thuộc gì thêm. Cuối cùng, tính đa hình chỉ hoạt động qua con trỏ hoặc tham chiếu: truyền theo giá trị sẽ cắt cụt phần dữ liệu của lớp con.

Tham chiếu không phải con trỏ đội lốt

Ba hiểu lầm hay gặp, đều bắt nguồn từ việc nghĩ tham chiếu chỉ là con trỏ viết gọn:

  • Tưởng gán lại được. int& r = a; r = b; không cho r quay sang b, nó gán giá trị của b vào a. Muốn đổi mục tiêu thì phải dùng con trỏ.
  • Tưởng cần dấu *. Với tham chiếu, r đã là giá trị rồi. Viết *r là lỗi biên dịch.
  • Tưởng tham chiếu có thể rỗng. Không có "tham chiếu null" hợp lệ. Nếu bạn cần biểu diễn trạng thái "không có gì", đó là dấu hiệu bạn nên dùng con trỏ.
Lỗi thường gặp với con trỏ
  • Giải tham chiếu con trỏ null hoặc chưa khởi tạo. int* p; *p = 5; khi p chưa trỏ vào đâu là hành vi không xác định, dễ sập chương trình. Luôn khởi tạo con trỏ.
  • Nhầm * khi khai báo với * khi giải tham chiếu. Trong int* p = &a; dấu * là phần khai báo kiểu. Trong *p = 9; dấu * là giải tham chiếu. Cùng ký hiệu, hai vai.
  • Quên & ở tham số hàm. Viết void f(int x) rồi mong đổi biến gốc là không được, phải là void f(int& x).
  • Trả về địa chỉ của biến cục bộ. Biến cục bộ biến mất khi hàm kết thúc, con trỏ trỏ tới nó thành trỏ vào vùng đã hủy.

Tự kiểm tra

Khác nhau cốt lõi giữa tham chiếu và con trỏ là gì?

Tham chiếu phải gắn vào một đối tượng ngay khi khai báo và không đổi mục tiêu được nữa, dùng như chính biến gốc (r = 7). Con trỏ là một biến độc lập chứa địa chỉ, có thể trỏ chỗ khác, có thể nullptr, và phải giải tham chiếu (*p) để chạm tới giá trị. Tham chiếu tiện và an toàn hơn; con trỏ linh hoạt hơn.

Bài tập

Bài 1: Viết hàm cộng thêm 10 vào một biến qua tham chiếu.
#include <iostream>
using namespace std;

void themMuoi(int& x) { x += 10; } // x là bí danh biến ngoài

int main() {
int a = 5;
themMuoi(a);
cout << a; // 15
}

Vì tham số là int&, hàm sửa thẳng vào a, không phải bản sao.

Bài 2: Viết hàm nhận hai biến qua con trỏ và tính tổng, hiệu.
#include <iostream>
using namespace std;

void tinh(int* a, int* b, int* tong, int* hieu) {
*tong = *a + *b; // đọc qua *a, *b; ghi qua *tong, *hieu
*hieu = *a - *b;
}

int main() {
int x = 8, y = 3, t = 0, h = 0;
tinh(&x, &y, &t, &h); // truyền địa chỉ bằng &
cout << t << " " << h; // 11 5
}

Ta lấy địa chỉ bằng &x, &y khi gọi, rồi trong hàm giải tham chiếu bằng *.

Bài 3: Dùng con trỏ để in mọi phần tử của một mảng.
#include <iostream>
using namespace std;

int main() {
int a[4] = {2, 4, 6, 8};
int* p = a; // p trỏ tới a[0]
for (int i = 0; i < 4; i++)
cout << *(p + i) << " "; // *(p+i) chính là a[i]
}

p + i nhảy i ô kể từ đầu mảng, giải tham chiếu ra đúng phần tử thứ i.

Bài 4: Viết hàm đếm số ký tự trong một chuỗi mà không sao chép chuỗi đó.
#include <iostream>
#include <string>
using namespace std;

int demKyTu(const string& s) { // const string&: không chép, không sửa
int dem = 0;
for (char c : s) dem++; // duyệt từng ký tự của chính chuỗi gốc
return dem;
}

int main() {
string ten = "Da Nang";
cout << demKyTu(ten); // 7
}

Điểm mấu chốt nằm ở kiểu tham số. Viết int demKyTu(string s) thì mỗi lần gọi hàm sẽ tạo một bản sao đầy đủ của chuỗi, tốn thời gian và bộ nhớ dù hàm chẳng sửa gì. Viết const string& s thì hàm làm việc thẳng trên chuỗi gốc, còn const bảo đảm nó không thể lỡ tay sửa. Đây là khuôn bạn sẽ dùng cho mọi tham số kiểu lớn về sau, kể cả với đối tượng của lớp trong OOP.

Bài 5: Viết hàm nhận con trỏ, trả về true nếu con trỏ hợp lệ và giá trị là số chẵn.
#include <iostream>
using namespace std;

bool laSoChanAnToan(int* p) {
if (p == nullptr) return false; // kiểm tra null TRƯỚC khi giải tham chiếu
return (*p % 2 == 0);
}

int main() {
int x = 8;
int* p = &x;
int* q = nullptr;
cout << laSoChanAnToan(p) << "\n"; // 1, x = 8 là số chẵn
cout << laSoChanAnToan(q) << "\n"; // 0, con trỏ rỗng nên trả về false
}

Thứ tự hai dòng trong hàm là toàn bộ bài học: phải kiểm tra p == nullptr trước, rồi mới được đụng tới *p. Nếu đảo lại, khi ai đó truyền vào con trỏ rỗng thì *p sẽ đi tới địa chỉ 0 và chương trình sập trước khi kịp kiểm tra. Bài này cũng cho thấy vì sao đôi khi ta chọn con trỏ thay vì tham chiếu: chỉ có con trỏ mới diễn đạt được trạng thái "không có giá trị nào cả".

Câu hỏi tự kiểm

Kiểm tra nhanh: con trỏ và tham chiếu0/4 đúngchưa trả lời
  1. 1Sau int a = 1, b = 99; int& r = a; r = b; thì a và b bằng bao nhiêu?
  2. 2Trong hàm nhận int* p rồi cần đọc giá trị, thứ tự nào tránh được sập chương trình?
  3. 3Tham số const string& s trong một hàm chỉ đọc cho ta điều gì?
  4. 4Vì sao con trỏ phải mang kiểu, ví dụ int* khác double*?

Tóm tắt

Mỗi biến sống trong một ô nhớ có địa chỉ, giống một căn nhà có số nhà. Toán tử & lấy địa chỉ, toán tử * giải tham chiếu để chạm giá trị tại địa chỉ đó. Con trỏ mang theo cả kiểu, nên nó không chỉ nói "ở đâu" mà còn nói "đọc như thế nào".

Tham chiếu là bí danh của một biến đã có: bắt buộc khởi tạo ngay, không bao giờ rỗng, và không đổi mục tiêu được, nên r = b; là gán giá trị chứ không phải cho r quay sang b. Con trỏ linh hoạt hơn: đổi trỏ được, có nullptr để diễn đạt "không có gì", nhưng phải giải tham chiếu bằng * và luôn phải kiểm tra null trước khi dùng. Đối tượng chắc chắn tồn tại thì dùng tham chiếu; cần trạng thái rỗng hoặc đổi mục tiêu lúc chạy thì dùng con trỏ.

Truyền tham chiếu vào hàm cho phép sửa biến gốc, còn const T& cho phép đọc dữ liệu lớn mà không tốn một bản sao nào. Đó chính là bản lề sang OOP: đối tượng được truyền quanh bằng tham chiếu, mỗi phương thức ngầm nhận con trỏ this, và tính đa hình chỉ chạy qua con trỏ hoặc tham chiếu.

Ghi nhớ

Mọi biến có địa chỉ. &a lấy địa chỉ, *p truy cập giá trị tại địa chỉ. Tham chiếu là bí danh cố định; con trỏ là biến chứa địa chỉ linh hoạt. Đây chính là cơ chế đằng sau "truyền theo tham chiếu" ở bài OOP về this.