Nạp chồng toán tử
C++ cho phép định nghĩa ý nghĩa của các toán tử (+, -, ==, <<...) cho kiểu
của ta. Nhờ đó a + b với hai vector đọc tự nhiên như toán học, thay vì phải gọi
cong(a, b). Bản chất: toán tử cũng chỉ là một hàm có tên đặc biệt.
Trong toán học ta viết a + b, a == b một cách tự nhiên. Nhưng khi a và b là đối
tượng của lớp ta tự định nghĩa, ví dụ một phân số hay một số phức, trình biên dịch mặc định
không biết cộng hay so sánh chúng thế nào. Không có nạp chồng, ta buộc phải viết
cong(a, b), bang(a, b), mã trở nên rời rạc và xa lạ với ký hiệu quen thuộc. Nạp chồng
toán tử cho phép dạy trình biên dịch nghĩa của +, ==, <<... trên kiểu của ta, để biểu
thức đọc gần với ngôn ngữ tự nhiên của bài toán.
Đặt vấn đề: lớp của ta bị đối xử như công dân hạng hai
Hãy thử làm một quan sát nhỏ. Với kiểu dựng sẵn như int hay double, ta viết được
x + y, x < y, x += 1, cout << x. Trình biên dịch hiểu tất cả những ký hiệu đó vì
chúng đã được nhà ngôn ngữ cài sẵn. Bây giờ ta tự viết một lớp Phanso để biểu diễn phân
số. Lớp này rõ ràng là một loại số, phép cộng hai phân số là chuyện phổ thông, thế nhưng
viết a + b lại bị báo lỗi. Ta phải hạ xuống viết cong(a, b), bang(a, b),
in(cout, a). Kiểu của ta trở thành công dân hạng hai so với kiểu dựng sẵn.
Hậu quả không chỉ nằm ở chỗ mã xấu. Một biểu thức toán học như (a + b) * c / d khi phải
viết bằng hàm sẽ thành chia(nhan(cong(a, b), c), d). Người đọc phải giải mã ngược từ
trong ra ngoài, thứ tự ưu tiên phép toán biến mất khỏi hình dạng của mã, và mỗi lần sửa
công thức là một lần nguy cơ đặt sai dấu ngoặc. Càng nhiều phép toán lồng nhau, khoảng
cách giữa công thức trên giấy và mã trong máy càng lớn. Chính khoảng cách này sinh ra lỗi.
Nạp chồng toán tử xóa khoảng cách đó. Nó là cách C++ thực hiện một nguyên tắc thiết kế
quan trọng: kiểu do người dùng định nghĩa nên dùng được giống hệt kiểu dựng sẵn, không
có đặc quyền nào dành riêng cho int. Thư viện chuẩn sống nhờ nguyên tắc này. std::string
nối được bằng +, std::vector truy cập được bằng []. Tất cả đều là toán tử nạp chồng,
không phải phép màu của trình biên dịch.
Thử ngay: xem nạp chồng toán tử chạy từng bước
Chưa cần hiểu cú pháp operator+ lúc này. Hãy bấm ▶ Chạy (hoặc kéo thanh trượt) và
nhìn đúng một điều: dòng a + b thật ra biến thành lời gọi hàm nào, và ai là người tạo ra
kết quả. Các mục bên dưới sẽ giải thích từng chi tiết bạn vừa thấy.
1class Vec2 {2public:3 int x, y;4 Vec2 operator+(const Vec2& o) const {5 return Vec2{x + o.x, y + o.y};6 }7};89int main() {10 Vec2 a{1, 2};11 Vec2 b{3, 4};12 Vec2 c = a + b;13 cout << c.x << "," << c.y;14}
Ý tưởng cốt lõi
a + bđược biên dịch thành lời gọi hàma.operator+(b).- Hàm trả về một đối tượng mới (kết quả), không sửa
ahayb. - Đánh dấu
constđể khẳng định toán tử không thay đổi toán hạng.
Mã nguồn
#include <iostream>
using namespace std;
class Vec2 {
public:
int x, y;
Vec2 operator+(const Vec2& o) const { // a.operator+(b)
return Vec2{x + o.x, y + o.y};
}
};
int main() {
Vec2 a{1, 2};
Vec2 b{3, 4};
Vec2 c = a + b; // gọi a.operator+(b)
cout << c.x << "," << c.y; // 4,6
}
Cú pháp chung
Một toán tử nạp chồng khai báo giống hàm thường, chỉ khác ở tên: từ khóa operator ghép
với ký hiệu toán tử. Có hai dạng.
- Dạng hàm thành viên:
KieuTraVe operator@(ThamSo) const;. Toán hạng trái chính là đối tượng gọi, tứcthis, nên chỉ cần khai báo một tham số cho toán hạng phải. - Dạng hàm tự do (thường kèm
friend):KieuTraVe operator@(ToanHangTrai, ToanHangPhai);. Cả hai toán hạng đều là tham số tường minh.
Với toán tử hai ngôi là hàm thành viên, a @ b được dịch thành a.operator@(b). Với hàm
tự do, a @ b được dịch thành operator@(a, b).
Có ba giới hạn mà ngôn ngữ đặt ra và ta không được vượt qua. Thứ nhất, không tạo được
toán tử mới. Bạn không thể phát minh ra ký hiệu ** cho lũy thừa, chỉ được gán nghĩa
mới cho tập ký hiệu đã có sẵn. Thứ hai, không đổi được số ngôi. + là toán tử hai
ngôi thì nó vẫn nhận đúng hai toán hạng, ! là một ngôi thì vẫn một toán hạng. Thứ ba,
không đổi được độ ưu tiên và tính kết hợp. Dù bạn định nghĩa + và * thế nào,
a + b * c vẫn luôn được hiểu là a + (b * c), vì thứ tự ưu tiên đã cố định trong ngữ
pháp của ngôn ngữ. Đây chính là lý do ta phải giữ ngữ nghĩa trực giác: hình dạng của biểu
thức đã bị đóng khung, chỉ có ý nghĩa là do ta quyết định.
Ngoài ra còn ràng buộc mềm nhưng thực tế: ít nhất một toán hạng phải là kiểu do người
dùng định nghĩa. Bạn không thể nạp chồng operator+ cho hai số int để đổi nghĩa phép
cộng số nguyên. Điều này bảo vệ ý nghĩa của kiểu dựng sẵn khỏi bị viết lại tùy tiện.
Toán tử không nạp chồng được
Năm toán tử sau bị cấm nạp chồng, và mỗi cái đều có lý do rõ ràng.
| Toán tử | Ý nghĩa | Vì sao cấm |
|---|---|---|
. | truy cập thành viên | nếu đổi nghĩa được thì không còn cách nào truy cập thành viên gốc |
.* | truy cập qua con trỏ thành viên | cùng lý do với . |
:: | phân giải phạm vi | làm việc trên tên lúc biên dịch, không trên giá trị lúc chạy |
?: | điều kiện ba ngôi | có ngữ nghĩa lười, chỉ một nhánh được tính; hàm thì tính hết mọi đối số |
sizeof | kích thước kiểu | do trình biên dịch quyết định từ bố cục bộ nhớ, không phải phép toán trên giá trị |
Trường hợp ?: đáng suy nghĩ nhất và cũng hay được hỏi trong bài kiểm tra. Với
dieuKien ? a : b, chỉ một trong hai nhánh a hoặc b được tính. Nếu nó là hàm,
mọi đối số phải được tính xong trước khi vào thân hàm, tức là cả hai nhánh đều chạy. Ngữ
nghĩa lười này không thể diễn đạt bằng lời gọi hàm thông thường, nên ngôn ngữ cấm luôn.
Cùng logic đó, && và || được phép nạp chồng nhưng khi nạp chồng thì chúng mất
tính đoản mạch, nên trên thực tế gần như không ai nạp chồng chúng.
Toán tử thành viên hay hàm bạn
Quy tắc chọn dựa vào toán hạng trái.
- Nếu toán hạng trái là kiểu của ta, hãy ưu tiên hàm thành viên. Đây là lựa chọn tự
nhiên cho
+,-,==,+=. - Nếu toán hạng trái không phải kiểu của ta, ta không thể thêm hàm thành viên vào lớp
đó, nên phải viết hàm tự do. Trường hợp kinh điển là
cout << v: toán hạng trái làostream, không phải lớp của ta, nênoperator<<bắt buộc là hàm tự do. - Khi hàm tự do cần đọc dữ liệu riêng tư (
private) của lớp, khai báofriendbên trong lớp để cấp quyền truy cập trực tiếp cho hàm đó.
Lý do sâu xa của quy tắc này rất đơn giản: hàm thành viên luôn lấy toán hạng trái làm
this, mà this bắt buộc phải thuộc lớp chứa hàm. Bạn không có quyền mở lớp ostream
của thư viện chuẩn ra để nhét thêm hàm vào, nên cout << v không thể là hàm thành viên
của bất cứ lớp nào bạn viết. Nó buộc phải là hàm tự do, nhận ostream& làm tham số thứ
nhất.
Cùng lý do đó giải thích một tình huống hay gặp: nạp chồng operator* để nhân vector với
số. Nếu viết dạng hàm thành viên Vec2 operator*(int k) const, bạn chỉ dùng được
v * 3, còn 3 * v sẽ báo lỗi, vì toán hạng trái lúc này là int và int không có
operator* nhận Vec2. Muốn hỗ trợ cả hai chiều, bạn cần thêm một hàm tự do
Vec2 operator*(int k, const Vec2& v). Đây là một trong những chỗ khiến sinh viên bối
rối nhất, nhưng chỉ cần nhớ nguyên tắc "toán hạng trái quyết định" là gỡ được ngay.
Còn về friend, cần hiểu đúng: nó không phải cách lười biếng để phá vỡ tính đóng gói, mà
là khai báo có chủ đích rằng lớp tin tưởng hàm đó. Nếu lớp đã có sẵn hàm truy cập công
khai đủ dùng, hàm tự do cứ gọi qua đó, không cần friend.
Những toán tử hay được nạp chồng
Không phải toán tử nào cũng đáng nạp chồng. Trong thực tế chỉ vài nhóm sau là hay gặp.
| Nhóm | Toán tử | Dạng thường dùng | Kiểu trả về tiêu biểu |
|---|---|---|---|
| số học | + - * / | thành viên const | đối tượng mới |
| gán phức hợp | += -= *= | thành viên, không const | T& (chính *this) |
| so sánh | == != < > | thành viên const | bool |
| xuất nhập | << >> | hàm tự do, thường friend | ostream& hoặc istream& |
| truy cập | [] () | thành viên | tham chiếu tới phần tử |
| tăng giảm | ++ -- | thành viên | T& (tiền tố) |
Chú ý sự đối lập giữa nhóm số học và nhóm gán phức hợp. a + b không đổi a, nên
operator+ là const và trả về đối tượng mới. Ngược lại a += b có đổi a, nên
operator+= không const và trả về T& để có thể viết (a += b) += c. Một mẹo thiết kế
kinh điển: viết operator+= trước, rồi cài operator+ bằng cách sao chép toán hạng trái
và gọi += lên bản sao. Như vậy logic cộng chỉ nằm ở một chỗ, sửa một lần là đúng cả hai.
Giữ ngữ nghĩa trực giác
Đây là điều quan trọng nhất của cả bài. Nạp chồng là một thứ quyền lực: bạn có thể làm +
xóa cơ sở dữ liệu nếu muốn, trình biên dịch không ngăn cản. Nhưng khi người đọc nhìn thấy
a + b, trong đầu họ đã tự dựng lên một loạt kỳ vọng, trước cả khi họ kịp mở định nghĩa
lớp của bạn ra xem. Rằng + tạo giá trị mới và không đụng vào toán hạng. Rằng == là quan
hệ tương đương, tức a == a luôn đúng và nếu a == b thì b == a. Rằng < sắp xếp được,
không thể có chuyện a < b và b < a cùng đúng. Rằng [] truy cập phần tử chứ không in
ra màn hình.
Phá vỡ những kỳ vọng này thì mã vẫn biên dịch, vẫn chạy, nhưng mọi người đọc đều hiểu sai.
Cái sai kiểu đó rất khó tìm, vì người ta không đi kiểm tra thứ mà họ tin là hiển nhiên. Một
hàm tên xoaTatCa() thì ai cũng cảnh giác; một dấu + thì không ai nghi ngờ.
Từ đó rút ra quy tắc thực dụng. Chỉ nạp chồng khi kiểu của bạn thật sự có một phép toán
tương ứng tự nhiên với ký hiệu đó. Phân số, số phức, vector, ma trận, tiền tệ: cộng trừ
có nghĩa rõ ràng, cứ nạp chồng. Còn với lớp TaiKhoan thì a + b nghĩa là gì, gộp hai tài
khoản hay cộng số dư? Không rõ, vậy đừng nạp chồng, hãy viết hàm có tên nói rõ ý định như
gopTaiKhoan(a, b). Khi ký hiệu không nói lên được ý nghĩa, một cái tên đầy đủ luôn tốt
hơn một ký hiệu ngắn gây hiểu nhầm.
Ví dụ: lớp Phanso
Phân số là ví dụ gọn để thấy cả ba dạng toán tử hay gặp cùng lúc: + trả về giá trị mới,
== trả về bool, và << để in ra màn hình.
#include <iostream>
using namespace std;
class Phanso {
public:
int tu, mau; // tử số, mẫu số
Phanso(int t, int m) : tu(t), mau(m) {}
Phanso operator+(const Phanso& o) const { // a/b + c/d = (a*d + c*b)/(b*d)
return Phanso{tu * o.mau + o.tu * mau, mau * o.mau};
}
bool operator==(const Phanso& o) const { // a/b == c/d khi a*d == c*b
return tu * o.mau == o.tu * mau;
}
friend ostream& operator<<(ostream& os, const Phanso& p) {
os << p.tu << "/" << p.mau; // hàm tự do, được friend
return os; // trả stream để nối tiếp <<
}
};
int main() {
Phanso a{1, 2}, b{1, 3};
cout << a + b << "\n"; // 5/6
cout << (a == Phanso{2, 4}) << "\n"; // 1, vì 1/2 == 2/4
}
operator+ cộng hai phân số bằng quy đồng chéo rồi trả về một Phanso mới, không đụng tới
a hay b. Hãy chú ý ba chi tiết trong chữ ký của nó. Tham số là const Phanso& o: dùng
tham chiếu để không phải sao chép đối tượng, dùng const để hứa rằng ta chỉ đọc toán hạng
phải chứ không sửa nó. Hàm được đánh dấu const ở cuối: đây là lời hứa rằng toán hạng
trái, tức this, cũng không bị sửa. Và kiểu trả về là Phanso theo giá trị, không phải
tham chiếu: kết quả là một phân số hoàn toàn mới, sinh ra bên trong hàm, nên trả về tham
chiếu tới nó sẽ là tham chiếu tới một đối tượng đã chết. Đây là lỗi kinh điển, phải nhớ.
operator== so sánh bằng tích chéo, tức a/b == c/d khi và chỉ khi a*d == c*b. Cách này
tránh được việc phải rút gọn phân số trước khi so, nên 1/2 và 2/4 được coi là bằng nhau
mà không cần tính ước chung lớn nhất. Kiểu trả về là bool chứ không phải Phanso, vì kết
quả của một phép so sánh là một mệnh đề đúng sai, không phải một phân số. Đây là chỗ sinh
viên hay máy móc: thấy operator+ trả về Phanso nên tưởng operator== cũng phải vậy.
Kiểu trả về do ngữ nghĩa của phép toán quyết định, không do lớp quyết định.
operator<< là hàm tự do được friend để đọc tu, mau. Nó phải là hàm tự do vì toán
hạng trái là cout, thuộc kiểu ostream của thư viện chuẩn. Kiểu trả về ostream& là
chìa khóa: nó trả về chính luồng đã nhận vào, nhờ vậy cout << a << "\n" hoạt động
được. Trình biên dịch đọc dòng đó từ trái sang phải: cout << a gọi hàm của ta và cho lại
cout, rồi cout << "\n" chạy tiếp trên kết quả đó. Nếu trả về void, chuỗi bị đứt ngay
sau lần << đầu tiên.
- Cho toán tử làm sai kỳ vọng:
+mà đi sửa toán hạng trái. Muốn thay đổi tại chỗ thì dùng+=, còn+nên trả về kết quả mới. - Quên
constở hàm thành viên khiến không gọi được toán tử trên đối tượng hằng. - Quên trả về
ostream&trongoperator<<, làm mất khả năng nối<<nhiều lần. - Đặt
operator<<làm hàm thành viên của lớp, sai vì toán hạng trái làostreamchứ không phải lớp của ta.
Bốn lỗi vừa liệt kê nhìn thì khác nhau nhưng đều mọc từ một hiểu lầm: coi toán tử là một cơ chế đặc biệt của ngôn ngữ thay vì coi nó là một hàm bình thường.
Hãy soi lại qua lăng kính "toán tử là hàm". Quên const: hàm không const thì không gọi
được trên đối tượng hằng, operator+ không được miễn trừ. Quên trả về ostream&: muốn nối
lời gọi thì hàm phải trả về thứ mà lời gọi kế tiếp cần. Đặt operator<< làm hàm thành
viên: tham số đầu tiên chính là this, mà this phải thuộc lớp chứa hàm, nên không có
cách nào để cout làm this của lớp bạn. Cho + sửa toán hạng trái: nếu đây là hàm
thường tên cong(a, b) mà lại lén sửa a, bạn thấy sai ngay; dấu + không làm điều đó
bớt sai đi chút nào.
Vậy khi bí, hãy viết lại toán tử dưới dạng lời gọi hàm tường minh: a + b thành
a.operator+(b), cout << v thành operator<<(cout, v). Nhìn ở dạng đó, hầu hết lỗi tự
hiện ra.
Xem chạy từng bước: Phanso c = a + b
Bản trace đầu dùng Vec2 để thấy a + b được dịch thành a.operator+(b). Bản này lặp lại cùng cơ chế trên một kiểu khác, lớp Phanso, để nhấn mạnh một điểm: operator+ sinh ra một đối tượng kết quả hoàn toàn mới, còn hai toán hạng a và b không hề bị đụng tới. Hãy nhìn kỹ lúc phép quy đồng chéo tạo ra phân số #3, rồi phân số ấy được gán cho c trong khi a, b vẫn y nguyên.
1class Phanso {2public:3 int tu, mau;4 Phanso operator+(const Phanso& o) const {5 return Phanso{tu * o.mau + o.tu * mau, mau * o.mau};6 }7};89int main() {10 Phanso a{1, 2};11 Phanso b{1, 3};12 Phanso c = a + b;13 cout << c.tu << "/" << c.mau;14}
Bài tập thực hành
Bài tập 1: nạp chồng operator- và operator==
Thêm cho lớp Vec2 hai toán tử: operator- trả về hiệu của hai vector, và
operator== trả về true khi hai vector có cùng tọa độ. Viết main để kiểm thử:
tính a - b, rồi so sánh a == b và a == a.
Lời giải chi tiết
Cả hai toán tử đều không thay đổi toán hạng nên ta đánh dấu const. operator- lấy
hiệu từng thành phần và trả về một Vec2 mới, giống operator+. operator== so sánh
từng cặp tọa độ, trả về kiểu bool. Khi cả x lẫn y đều bằng nhau thì hai vector
được coi là bằng nhau.
#include <iostream>
using namespace std;
class Vec2 {
public:
int x, y;
Vec2 operator-(const Vec2& o) const { // component-wise subtraction
return Vec2{x - o.x, y - o.y};
}
bool operator==(const Vec2& o) const { // equality on both fields
return x == o.x && y == o.y;
}
};
int main() {
Vec2 a{5, 7};
Vec2 b{2, 3};
Vec2 d = a - b; // {3, 4}
cout << d.x << "," << d.y << "\n";
cout << (a == b) << "\n"; // 0 (false)
cout << (a == a) << "\n"; // 1 (true)
}
Mỗi toán tử chỉ là một hàm thành viên const trả về kết quả mới (operator-) hoặc
một giá trị bool (operator==). Cả hai chỉ đọc dữ liệu của this, không sửa đổi nó.
Bài tập 2: nạp chồng operator* để nhân vô hướng
Thêm operator* cho Vec2 để nhân vector với một số nguyên (scalar): v * k cho ra
vector mà mỗi tọa độ được nhân với k. Viết main tính a * 3 và in kết quả.
Lời giải chi tiết
Ở đây toán hạng trái là Vec2, toán hạng phải là int, nên hàm thành viên
operator*(int k) là phù hợp: a * 3 dịch thành a.operator*(3). Ta nhân từng tọa
độ với k rồi trả về một Vec2 mới. Lưu ý cách này chỉ phục vụ vector * số; muốn
viết số * vector thì cần một hàm tự do, không phải hàm thành viên.
#include <iostream>
using namespace std;
class Vec2 {
public:
int x, y;
Vec2 operator*(int k) const { // scale each component by k
return Vec2{x * k, y * k};
}
};
int main() {
Vec2 a{1, 2};
Vec2 r = a * 3; // {3, 6}
cout << r.x << "," << r.y << "\n";
}
Khi toán hạng hai khác kiểu (số nguyên), tham số của toán tử cứ khai báo đúng kiểu đó;
v * k vẫn được dịch thành v.operator*(k).
Bài tập 3: nạp chồng operator<< để in, dùng friend
Cho phép in trực tiếp cout << v để hiển thị một Vec2 theo dạng (x, y). Vì toán
hạng trái là ostream (không phải Vec2), hãy dùng một hàm friend nhận ostream&
và const Vec2&, trả về ostream& để có thể nối chuỗi nhiều lần <<.
Lời giải chi tiết
cout << v tương đương operator<<(cout, v), với toán hạng trái là ostream. Ta
không thể đặt nó làm hàm thành viên của Vec2, nên viết một hàm tự do. Khai báo
friend bên trong lớp cho phép hàm này truy cập trực tiếp x và y. Hàm trả về
ostream& (chính tham chiếu os truyền vào) để các lời gọi << có thể nối tiếp nhau,
ví dụ cout << a << b.
#include <iostream>
using namespace std;
class Vec2 {
public:
int x, y;
// friend grants access to private members and is a free function
friend ostream& operator<<(ostream& os, const Vec2& v) {
os << "(" << v.x << ", " << v.y << ")";
return os; // return stream to allow chaining
}
};
int main() {
Vec2 a{1, 2};
Vec2 b{3, 4};
cout << a << " " << b << "\n"; // (1, 2) (3, 4)
}
Khi toán hạng trái không phải kiểu của ta (ở đây là ostream), toán tử phải là hàm tự
do; trả về ostream& chính là chìa khóa để nối nhiều << trong một câu lệnh.
Bài tập 4: cặp đôi operator+= và operator+
Cho lớp Tien giữ số tiền theo đơn vị đồng. Hãy cài operator+= để cộng dồn vào chính
đối tượng, rồi cài operator+ dựa trên operator+= thay vì viết lại logic cộng lần
thứ hai. Giải thích vì sao += trả về Tien& còn + trả về Tien theo giá trị.
Lời giải chi tiết
Hai toán tử này có ngữ nghĩa đối lập nhau và chính sự đối lập đó quyết định chữ ký của
chúng. a += b thay đổi a, nên operator+= không được đánh dấu const, và nó trả
về Tien&, cụ thể là *this, tức chính đối tượng vừa bị đổi. Trả về tham chiếu ở đây an
toàn vì *this vẫn sống sau khi hàm kết thúc, và nó cho phép nối chuỗi kiểu
(a += b) += c. Ngược lại, a + b không đổi a hay b mà sinh ra một giá trị mới,
nên operator+ được đánh dấu const và trả về Tien theo giá trị. Ở đây tuyệt đối không
được trả về tham chiếu, vì kết quả là một đối tượng cục bộ sinh ra trong hàm; trả tham
chiếu tới nó là trỏ vào vùng nhớ đã bị hủy.
Mẹo cài đặt: operator+ nhận toán hạng trái theo bản sao (Tien a chứ không phải
const Tien& a). Bản sao ấy là đối tượng riêng của hàm, ta cứ việc a += b lên nó rồi
trả về. Nhờ vậy logic cộng chỉ được viết đúng một lần, nằm trong operator+=. Nếu sau này
phép cộng cần đổi (thêm kiểm tra tràn số chẳng hạn), ta chỉ sửa một chỗ.
#include <iostream>
using namespace std;
class Tien {
public:
long long soDong;
explicit Tien(long long d = 0) : soDong(d) {}
// modifies *this, so not const, returns reference for chaining
Tien& operator+=(const Tien& o) {
soDong += o.soDong;
return *this;
}
friend ostream& operator<<(ostream& os, const Tien& t) {
os << t.soDong << " dong";
return os;
}
};
// left operand taken BY VALUE: it is our own copy to modify and return
Tien operator+(Tien a, const Tien& b) {
a += b; // reuse the single source of truth
return a; // return by value: a fresh result object
}
int main() {
Tien x{50000}, y{20000};
cout << x + y << "\n"; // 70000 dong, x va y khong doi
x += y;
cout << x << "\n"; // 50000 dong -> 70000 dong
}
Ý chính: += sửa tại chỗ và trả tham chiếu; + sinh giá trị mới và trả theo giá trị. Cài
+ dựa trên += để logic phép cộng chỉ tồn tại ở một nơi duy nhất.
Tự kiểm tra
Vì sao nên trả về đối tượng mới thay vì sửa this?
Vì + theo nghĩa toán học không làm thay đổi toán hạng: a + b cho ra giá trị
mới, a và b giữ nguyên. Nếu operator+ sửa this thì a + b lại đổi a, rất
khó hiểu và dễ gây lỗi. (Toán tử thay đổi như += mới sửa this.)
Câu hỏi tự kiểm
- 1Khi operator+ là hàm thành viên, trình biên dịch dịch biểu thức a + b thành gì?
- 2Toán tử nào sau đây KHÔNG nạp chồng được trong C++?
- 3Nếu chỉ cài operator* dạng hàm thành viên Vec2 operator*(int k) const, biểu thức nào lỗi biên dịch?
- 4Vì sao operator+= thường trả về T& còn operator+ trả về T theo giá trị?
Tóm tắt bài học
Toán tử trong C++ là hàm có tên đặc biệt. a @ b chỉ là cách viết gọn của a.operator@(b)
khi toán tử là hàm thành viên, hoặc operator@(a, b) khi nó là hàm tự do. Hiểu được điều
này thì mọi quy tắc còn lại đều suy ra được chứ không cần học thuộc.
Chọn dạng hàm dựa vào toán hạng trái: là kiểu của ta thì dùng hàm thành viên (+, -,
==, +=), không phải kiểu của ta thì bắt buộc dùng hàm tự do (cout << v), và khai báo
friend khi hàm tự do cần đọc dữ liệu riêng tư. Chữ ký phải phản ánh đúng ngữ nghĩa: toán
tử không sửa toán hạng thì const và trả về đối tượng mới theo giá trị; += thì không
const và trả về T&; so sánh trả về bool; << trả về ostream&. Ngôn ngữ đặt giới
hạn cứng: không tạo toán tử mới, không đổi số ngôi, không đổi độ ưu tiên, và năm toán tử
., .*, ::, ?:, sizeof bị cấm nạp chồng.
Quan trọng hơn mọi quy tắc cú pháp: chỉ nạp chồng khi ký hiệu thật sự khớp với ý nghĩa. Mã ngắn mà gây hiểu nhầm thì tệ hơn mã dài mà rõ ràng.
Toán tử là hàm có tên đặc biệt. a @ b ≡ a.operator@(b). Dùng nạp chồng để mã
đọc tự nhiên, nhưng giữ đúng kỳ vọng quen thuộc của toán tử (đừng cho + đi xóa dữ liệu).