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->vnghĩa là "thuộc tínhvcủ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:blà bí 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
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; }89int main() {10 Box a;11 a.set(1);12 theoGiaTri(a);13 theoThamChieu(a);14}
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ớ:
thislà một con trỏ (kiểuBox*), nên truy cập thành viên bằngthis->v. Còn*thislà chính đối tượng (kiểuBox), nên viết(*this).vcũng đúng.- Trong phương thức
const,thiscó kiểuconst 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óaconstsau danh sách tham số. - Phương thức
statickhô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àmstaticbạ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ết | Có sao chép? | Sửa được bản gốc? | Dùng khi |
|---|---|---|---|
void f(Box b) | có | không | đối tượng nhỏ, hoặc hàm cần bản nháp riêng |
void f(Box& b) | không | có | hàm cần thay đổi bản gốc |
void f(const Box& b) | không | không | chỉ đọc, đây là lựa chọn mặc định |
void f(Box* b) | không | có | đố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.
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ải
là const 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.
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ố.
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 a và b 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ì x và y chỉ là bản sao của a và b.
Việc tráo chúng chỉ tráo trên bản sao, còn a và b ở main 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 set và tang (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 x và y. 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 f2 và f3, 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 f2 và f3 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
- 1Trong một phương thức, this là gì?
- 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?
- 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?
- 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
thislà con trỏ tới đối tượng đang gọi phương thức.this->vlà thuộc tính,*thislà chính đối tượng.- Cần
thiskhi 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ứcstatickhô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.
consttham 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ùngreturn *thisđể nối chuỗi lời gọi. Bỏ dấu&là sai âm thầm.
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ũ".