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

Con trỏ this và cách truyền đối tượng

Mỗi phương thức có một con trỏ ẩn this trỏ tới đối tượng đang gọi nó. Khi truyền đối tượng vào hàm, có ba kiểu rất khác nhau về bộ nhớ: theo giá trị (bản sao), theo tham chiếu (bí danh), và theo con trỏ.

Đặt vấn đề

Hai lỗi dưới đây xuất hiện trong bài của sinh viên nhiều hơn mọi lỗi khác cộng lại.

Lỗi thứ nhất: "em gọi hàm sửa rồi mà giá trị vẫn cũ". Hàm nhận đối tượng, sửa bên trong, in ra thấy đúng, nhưng về main thì đối tượng không đổi. Nguyên nhân là hàm nhận bản sao, và bản sao đó chết ngay khi hàm kết thúc, mang theo mọi thay đổi.

Lỗi thứ hai: "chương trình chạy đúng nhưng chậm kinh khủng". Sinh viên truyền theo giá trị một đối tượng chứa vector mười nghìn phần tử vào hàm chỉ để in ra. Gọi trong vòng lặp một nghìn lần là mười triệu phép sao chép hoàn toàn vô ích.

Cả hai bắt nguồn từ một câu hỏi mà C++ buộc bạn trả lời rõ ràng, khác với Java hay Python: hàm này nhận bản sao, hay nhận chính đối tượng?. Bài này trả lời câu đó, và giới thiệu con trỏ this, thứ giúp mỗi phương thức biết nó đang làm việc trên đối tượng nào.

Ý tưởng cốt lõi

  • this->v nghĩa là "thuộc tính v của chính đối tượng này".
  • Theo giá trị Box b: hàm nhận một bản sao, sửa không ảnh hưởng bản gốc.
  • Theo tham chiếu Box& b: bbí danh của chính đối tượng gốc, sửa là sửa thật.

Mã nguồn

#include <iostream>
using namespace std;

class Box {
public:
int v;
void set(int x) { this->v = x; } // this = đối tượng gọi
};

void theoGiaTri(Box b) { b.v = 99; } // nhận BẢN SAO
void theoThamChieu(Box& b) { b.v = 99; } // nhận chính đối tượng

int main() {
Box a;
a.set(1);
theoGiaTri(a); // a.v vẫn = 1
theoThamChieu(a); // a.v = 99
}

Thử ngay: xem con trỏ this chạy từng bước

Bản sao vs tham chiếu trong bộ nhớ C++
1class Box {
2public:
3 int v;
4 void set(int x) { this->v = x; }
5};
6void theoGiaTri(Box b) { b.v = 99; }
7void theoThamChieu(Box& b) { b.v = 99; }
8
9int main() {
10 Box a;
11 a.set(1);
12 theoGiaTri(a);
13 theoThamChieu(a);
14}
a.set(1)this->v = 1
Ngăn xếp stack
main()
#1Box
v1
Bộ nhớ động heap
(trống)
Tạo Box a. a.set(1) chạy: this trỏ tới a nên this->v = 1.
1/6

this thật ra là gì

Khi bạn tạo mười đối tượng Box, bộ nhớ chứa mười bản dữ liệu, nhưng mã máy của phương thức set chỉ có một bản duy nhất. Vậy làm sao một đoạn mã dùng chung lại biết phải ghi vào ô nhớ của đối tượng nào? Câu trả lời là trình biên dịch lén truyền thêm địa chỉ của đối tượng gọi vào phương thức, dưới cái tên this.

Nói cách khác, khi bạn viết:

void set(int x) { v = x; }

trình biên dịch hiểu nó gần như là:

void set(Box* this, int x) { this->v = x; } // conceptual: this is passed in secretly

Ba hệ quả cần nhớ:

  • this là một con trỏ (kiểu Box*), nên truy cập thành viên bằng this->v. Còn *thischính đối tượng (kiểu Box), nên viết (*this).v cũng đúng.
  • Trong phương thức const, this có kiểu const Box*, vì thế bạn không sửa được thành viên. Đó chính là cơ chế thực thi của từ khóa const sau danh sách tham số.
  • Phương thức static không có this, vì nó thuộc về lớp chứ không thuộc đối tượng nào. Đó là lý do trong hàm static bạn không thể nhắc tới thuộc tính thường.

Khi nào thật sự cần viết this

Phần lớn thời gian bạn viết v = x là đủ, trình biên dịch tự hiểu là this->v = x. Có bốn tình huống bắt buộc hoặc nên viết this ra:

1. Tham số trùng tên thuộc tính. Đây là trường hợp phổ biến nhất. Trong hàm dưới đây, ten trần trụi ám chỉ tham số (thứ gần hơn), nên ten = ten là gán tham số cho chính nó và thuộc tính không hề đổi. this->ten chỉ đích danh thuộc tính.

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

class SinhVien {
string ten;
int tuoi;
public:
void dat(string ten, int tuoi) {
// ten = ten; // BUG: assigns the parameter to itself, the field never changes
this->ten = ten; // left side is the member, right side is the parameter
this->tuoi = tuoi;
}
void in() const { cout << ten << " " << tuoi << "\n"; }
};

int main() {
SinhVien sv;
sv.dat("An", 20);
sv.in(); // An 20
}

2. Trả về *this để nối chuỗi lời gọi. Xem mục riêng bên dưới.

3. Truyền chính đối tượng hiện tại cho một hàm khác: ví dụ dangKy(*this) hoặc ds.push_back(*this).

4. So sánh xem có phải tự gán không: trong toán tử gán, người ta viết if (this == &khac) return *this; để tránh trường hợp a = a tự phá hủy dữ liệu của chính mình.

Các cách truyền đối tượng

Cách viếtCó sao chép?Sửa được bản gốc?Dùng khi
void f(Box b)khôngđối tượng nhỏ, hoặc hàm cần bản nháp riêng
void f(Box& b)khônghàm cần thay đổi bản gốc
void f(const Box& b)khôngkhôngchỉ đọc, đây là lựa chọn mặc định
void f(Box* b)khôngđối tượng có thể vắng mặt (truyền nullptr)

Quy tắc thực hành gói gọn trong một câu: mặc định dùng const Box&, bỏ const khi cần sửa, chỉ truyền theo giá trị khi đối tượng nhỏ hoặc khi bạn thật sự muốn một bản nháp.

Sao chép tốn kém tới mức nào

Lý thuyết thì trừu tượng, hãy đếm cụ thể. Ta cho lớp một hàm khởi tạo sao chép in ra mỗi lần nó chạy, rồi gọi hai hàm với hai kiểu truyền khác nhau.

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

class DuLieu {
public:
vector<int> so;
DuLieu(int n) : so(n, 1) {}
DuLieu(const DuLieu& khac) : so(khac.so) { // copy constructor
cout << " [sao chep " << so.size() << " phan tu]\n";
}
long tong() const {
long t = 0;
for (int x : so) t += x;
return t;
}
};

void theoGiaTri(DuLieu d) { cout << "tong = " << d.tong() << "\n"; }
void theoConstThamChieu(const DuLieu& d) { cout << "tong = " << d.tong() << "\n"; }

int main() {
DuLieu big(100000);

cout << "Goi theo gia tri:\n";
theoGiaTri(big); // prints the copy message: 100000 elements duplicated

cout << "Goi theo const tham chieu:\n";
theoConstThamChieu(big); // no copy message at all
}

Hàm thứ nhất sao chép một trăm nghìn phần tử chỉ để cộng lại rồi vứt bản sao đi. Hàm thứ hai làm đúng việc đó mà không nhân đôi một byte nào. Với đối tượng chứa vector, string, map hay bất cứ thứ gì cấp phát bộ nhớ động, hãy mặc định const Box&.

Khi nào truyền theo giá trị vẫn hợp lý? Khi đối tượng chỉ vài byte (một Point gồm hai số nguyên chẳng hạn), và khi hàm cần nghịch một bản riêng mà không đụng bản gốc.

Cắt lát đối tượng khi truyền lớp con theo giá trị

Truyền theo giá trị còn một tác hại tinh vi hơn cả chi phí sao chép, và nó chỉ lộ ra khi có kế thừa. Giả sử HinhTron kế thừa Hinh và ghi đè phương thức dienTich. Nếu một hàm nhận tham số kiểu Hinh theo giá trị mà bạn đưa vào một HinhTron, trình biên dịch chỉ sao chép phần Hinh của đối tượng vào tham số, còn phần riêng của HinhTron (bán kính, bảng phương thức ảo trỏ về đúng lớp con) bị bỏ lại bên ngoài. Hiện tượng này gọi là cắt lát đối tượng (object slicing): cái vào tới hàm không còn là hình tròn nữa, mà là một hình chung cụt đầu.

#include <iostream>
using namespace std;

struct Hinh {
virtual double dienTich() const { return 0; }
virtual ~Hinh() = default;
};
struct HinhTron : Hinh {
double r;
HinhTron(double ban_kinh) : r(ban_kinh) {}
double dienTich() const override { return 3.14159 * r * r; }
};

void inTheoTri(Hinh h) { cout << h.dienTich() << "\n"; } // slices the object
void inTheoRef(const Hinh& h) { cout << h.dienTich() << "\n"; } // keeps the real type

int main() {
HinhTron c(10);
inTheoTri(c); // prints 0: the HinhTron part was sliced away
inTheoRef(c); // prints 314.159: correct dispatch to HinhTron::dienTich
}

Hàm thứ nhất in 0, dù c rõ ràng là một hình tròn bán kính mười. Hàm thứ hai in đúng 314.159. Khác biệt duy nhất là dấu &. Gốc rễ nằm ở chỗ tham số Hinh h có kiểu tĩnh là lớp cơ sở, nên bản sao chỉ chứa đúng những gì một Hinh có, và lời gọi ảo bên trong phân giải về Hinh::dienTich chứ không về lớp con. Tham chiếu const Hinh& thì trái lại: nó không tạo bản sao nào, chỉ mượn chính đối tượng gốc, nên kiểu động vẫn là HinhTron và cơ chế đa hình hoạt động đúng.

Đa hình chỉ sống qua tham chiếu hoặc con trỏ, không qua bản sao

Quy tắc gốc rễ: muốn giữ hành vi đa hình khi truyền một đối tượng lớp con, tham số phảiconst Base& hoặc Base*, tuyệt đối không phải Base theo giá trị. Bản sao theo giá trị luôn mang kiểu tĩnh của tham số, và mọi phần thuộc lớp con bị cắt bỏ âm thầm, không một lời cảnh báo nào từ trình biên dịch. Đây chính là một lý do nữa để lấy const Base& làm mặc định khi truyền đối tượng.

Cắt lát không chỉ xảy ra ở tham số hàm. Nếu bạn cất các hình vào một vector<Hinh>, mỗi lần push_back một HinhTron cũng sao chép đúng phần cơ sở và cắt bỏ phần lớp con, nên cả vùng chứa chỉ còn toàn hình cụt. Muốn giữ tính đa hình trong một vùng chứa, ta cất con trỏ chứ không cất bản thân đối tượng: vector<unique_ptr<Hinh>> giữ nguyên kiểu thật của từng phần tử, vì con trỏ thì không bị cắt lát bao giờ.

Trả về *this để nối chuỗi lời gọi

Nếu mỗi phương thức trả về tham chiếu tới chính đối tượng vừa gọi nó, bạn có thể viết liền một mạch nhiều thao tác. Kiểu này gọi là giao diện trôi chảy (fluent interface), và bạn đã gặp nó rồi: cout << a << b << c chạy được chính vì operator<< trả về tham chiếu tới cout.

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

class CauLenh {
string sql = "SELECT *";
public:
CauLenh& tu(const string& bang) { sql += " FROM " + bang; return *this; }
CauLenh& loc(const string& dk) { sql += " WHERE " + dk; return *this; }
CauLenh& sapXep(const string& c) { sql += " ORDER BY " + c; return *this; }
const string& xem() const { return sql; }
};

int main() {
CauLenh q;
q.tu("sinh_vien").loc("tuoi > 18").sapXep("ten"); // each call returns the same object
cout << q.xem() << "\n";
// SELECT * FROM sinh_vien WHERE tuoi > 18 ORDER BY ten
}

Kiểu trả về phải là CauLenh& chứ không phải CauLenh. Nếu bỏ dấu &, mỗi lời gọi sẽ trả về một bản sao mới, lời gọi kế tiếp thao tác trên bản sao đó, và đối tượng q gốc chỉ nhận được thay đổi của lời gọi đầu tiên. Chương trình vẫn biên dịch, vẫn chạy, và cho kết quả sai. Đây là loại lỗi khó tìm nhất.

Quên dấu và (&) là quên luôn hiệu lực

void tang(Box b) { b.v++; } biên dịch trót lọt, chạy không báo gì, và không làm gì cả đối với bản gốc. Trình biên dịch không thể cảnh báo, vì truyền theo giá trị là hợp lệ. Mỗi khi hàm của bạn "chạy đúng mà không thấy tác dụng", hãy soi ngay danh sách tham số.

Đừng trả về tham chiếu tới biến cục bộ
Box& taoBox() {
Box b; // local object, dies when the function returns
return b; // UNDEFINED BEHAVIOR: reference to a destroyed object
}

Đối tượng b bị hủy ngay khi hàm kết thúc, nên tham chiếu trả về trỏ vào vùng nhớ đã chết. Chương trình có thể chạy đúng hôm nay và sập vào hôm bảo vệ đồ án. Muốn trả về đối tượng mới thì trả theo giá trị: Box taoBox() { Box b; return b; }. Trình biên dịch hiện đại tối ưu việc này, gần như không mất chi phí sao chép.

Bài tập thực hành

Bài tập 1: hàm hoán vị hai đối tượng

Viết hàm hoanVi nhận hai đối tượng Box và đổi giá trị v của chúng cho nhau. Sau khi gọi hoanVi(a, b), giá trị bên trong ab phải thật sự được tráo. Hãy giải thích vì sao truyền theo giá trị sẽ không có tác dụng.

Lời giải chi tiết

Nếu khai báo hoanVi(Box x, Box y) thì xy chỉ là bản sao của ab. Việc tráo chúng chỉ tráo trên bản sao, còn abmain không hề đổi. Muốn sửa đúng bản gốc, hai tham số phải là tham chiếu: Box& x, Box& y. Khi đó x là bí danh của a, y là bí danh của b, nên thao tác tráo tác động trực tiếp lên bản gốc.

#include <iostream>
using namespace std;

class Box {
public:
int v;
void set(int x) { this->v = x; }
};

// pass by reference so the originals are modified
void hoanVi(Box& x, Box& y) {
int tmp = x.v;
x.v = y.v;
y.v = tmp;
}

int main() {
Box a, b;
a.set(1);
b.set(2);
hoanVi(a, b);
cout << a.v << " " << b.v << endl; // 2 1
}

Muốn hàm sửa được bản gốc thì tham số phải là tham chiếu, vì tham chiếu là bí danh chứ không phải bản sao.

Bài tập 2: trả về *this để nối chuỗi lời gọi

Thêm cho Box hai phương thức settang (tăng v thêm một lượng), mỗi phương thức trả về tham chiếu tới chính đối tượng để có thể viết a.set(1).tang(5).tang(2). Hãy cho biết kiểu trả về phải là gì và vì sao phải trả về *this.

Lời giải chi tiết

Để nối chuỗi lời gọi, mỗi phương thức phải trả về chính đối tượng đang gọi, tức là *this, với kiểu trả về là tham chiếu Box&. Nếu trả về Box (theo giá trị) thì mỗi lời gọi sẽ thao tác trên một bản sao khác nhau, kết quả cuối cùng không gộp lại trên một đối tượng. Trả về Box& giúp lời gọi sau tiếp tục làm việc trên đúng đối tượng gốc.

#include <iostream>
using namespace std;

class Box {
public:
int v = 0;
// return reference to the calling object for chaining
Box& set(int x) { this->v = x; return *this; }
Box& tang(int d) { this->v += d; return *this; }
};

int main() {
Box a;
a.set(1).tang(5).tang(2); // 1 -> 6 -> 8
cout << a.v << endl; // 8
}

Trả về *this theo kiểu Box& cho phép mỗi lời gọi tiếp theo tác động lên cùng một đối tượng, nhờ đó nối được chuỗi phương thức.

Bài tập 3: phân biệt const& và &

Viết hai hàm tự do: inGiaTri(const Box& b) chỉ đọc và in b.v, và tangGapDoi(Box& b) nhân đôi b.v. Giải thích vì sao thử gán b.v = 0 bên trong inGiaTri sẽ bị trình biên dịch báo lỗi, còn trong tangGapDoi thì không.

Lời giải chi tiết

Tham chiếu hằng const Box& vẫn tránh được việc sao chép đối tượng lớn, nhưng cấm mọi thao tác làm thay đổi đối tượng. Vì vậy trong inGiaTri, viết b.v = 0 sẽ bị từ chối ngay lúc biên dịch, đây là lớp bảo vệ giúp ta khẳng định hàm chỉ đọc. Ngược lại, Box& (không có const) cho phép sửa, nên tangGapDoi có thể nhân đôi giá trị thật của bản gốc. Quy tắc thực hành: chỉ-đọc thì dùng const Box&, cần sửa thì dùng Box&.

#include <iostream>
using namespace std;

class Box {
public:
int v = 0;
void set(int x) { this->v = x; }
};

// read-only: const reference forbids modification
void inGiaTri(const Box& b) {
// b.v = 0; // compile error: b is const
cout << b.v << endl;
}

// modifiable: plain reference allows changing the original
void tangGapDoi(Box& b) {
b.v *= 2;
}

int main() {
Box a;
a.set(5);
inGiaTri(a); // 5
tangGapDoi(a);
inGiaTri(a); // 10
}

Dùng const Box& cho tham số chỉ-đọc để trình biên dịch chặn sửa nhầm, và dùng Box& khi thật sự cần thay đổi bản gốc.

Bài tập 4: đếm số lần sao chép

Viết lớp Diem có hai thuộc tính x, y và một hàm khởi tạo sao chép in ra dòng sao chep mỗi lần chạy. Viết ba hàm: f1(Diem d), f2(Diem& d), f3(const Diem& d), cả ba chỉ in ra xy. Gọi lần lượt ba hàm với cùng một đối tượng và đếm xem có bao nhiêu dòng sao chep được in. Giải thích kết quả.

Lời giải chi tiết

Chỉ f1 in ra dòng sao chep, đúng một lần. Hàm khởi tạo sao chép chỉ chạy khi một đối tượng mới được dựng từ một đối tượng đã có, và điều đó chỉ xảy ra khi truyền theo giá trị. Với f2f3, tham số là bí danh của bản gốc nên không có đối tượng mới nào ra đời.

Khác biệt giữa f2f3 không nằm ở chi phí (cả hai đều bằng không) mà ở quyền hạn: f2 sửa được bản gốc, f3 bị trình biên dịch cấm sửa. Vì cả ba hàm chỉ in ra, f3 là chữ ký đúng nhất.

#include <iostream>
using namespace std;

class Diem {
public:
int x, y;
Diem(int a, int b) : x(a), y(b) {}
Diem(const Diem& k) : x(k.x), y(k.y) { // copy constructor
cout << "sao chep\n"; // fires only when a new object is built from another
}
};

void f1(Diem d) { cout << "f1: " << d.x << "," << d.y << "\n"; } // makes a copy
void f2(Diem& d) { cout << "f2: " << d.x << "," << d.y << "\n"; } // alias, no copy
void f3(const Diem& d) { cout << "f3: " << d.x << "," << d.y << "\n"; } // alias, read-only

int main() {
Diem a(3, 4);
f1(a); // prints "sao chep" first, then f1: 3,4
f2(a); // no copy
f3(a); // no copy
// total: exactly one "sao chep" line
}

Ý chính: dấu & trong tham số quyết định có hay không một bản sao. Với đối tượng lớn, đó là khác biệt giữa chương trình nhanh và chương trình chậm.

Tự kiểm tra

Khi nào nên truyền theo tham chiếu hằng const Box&?

Khi đối tượng lớn và ta không muốn sao chép (tốn bộ nhớ + thời gian) và không định sửa nó. const Box& vừa tránh sao chép, vừa cấm sửa nhầm, đây là cách truyền mặc định nên dùng cho đối tượng lớn chỉ-đọc.

Câu hỏi tự kiểm

Kiểm tra nhanh: con trỏ this và cách truyền đối tượng0/4 đúngchưa trả lời
  1. 1Trong một phương thức, this là gì?
  2. 2Hàm void theoGiaTri(Box b) sửa b.v bên trong nhưng về main đối tượng gốc không đổi. Vì sao?
  3. 3Khi hàm chỉ đọc một đối tượng lớn, cách truyền nào nên làm mặc định?
  4. 4Truyền một HinhTron vào hàm nhận tham số kiểu Hinh theo giá trị (không phải tham chiếu) gây hiện tượng gì?

Tóm tắt

  • this là con trỏ tới đối tượng đang gọi phương thức. this->v là thuộc tính, *this là chính đối tượng.
  • Cần this khi tham số trùng tên thuộc tính, khi trả về *this, và khi truyền chính mình đi nơi khác. Phương thức static không có this.
  • Theo giá trị: tạo bản sao, sửa không ảnh hưởng bản gốc, tốn chi phí.
  • Theo tham chiếu: bí danh của bản gốc, sửa là sửa thật, không tốn sao chép.
  • const tham chiếu: không sao chép và không sửa được. Đây là mặc định nên dùng.
  • Trả về Box& cùng return *this để nối chuỗi lời gọi. Bỏ dấu & là sai âm thầm.
Ghi nhớ

this = đối tượng đang gọi. Truyền theo giá trị tạo bản sao (an toàn nhưng tốn); theo tham chiếu thao tác trên bản gốc (nhanh, sửa được). Hiểu rõ điều này tránh vô số lỗi "sao đổi rồi mà giá trị vẫn cũ".