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

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 ab 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.

a + b chính là a.operator+(b) C++
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};
8
9int main() {
10 Vec2 a{1, 2};
11 Vec2 b{3, 4};
12 Vec2 c = a + b;
13 cout << c.x << "," << c.y;
14}
Ngăn xếp stack
main()
#1Vec2
x1
y2
#2Vec2
x3
y4
Bộ nhớ động heap
(trống)
Tạo a{1,2} và b{3,4} trên stack.
1/5

Ý tưởng cốt lõi

  • a + b được biên dịch thành lời gọi hàm a.operator+(b).
  • Hàm trả về một đối tượng mới (kết quả), không sửa a hay b.
  • Đá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ức this, 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 +* 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ĩaVì sao cấm
.truy cập thành viênnế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êncùng lý do với .
::phân giải phạm vilà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ôicó ngữ nghĩa lười, chỉ một nhánh được tính; hàm thì tính hết mọi đối số
sizeofkích thước kiểudo 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 đó, &&|| đượ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ên operator<< 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áo friend bê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à intint 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ómToán tửDạng thường dùngKiể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 constT& (chính *this)
so sánh== != < >thành viên constbool
xuất nhập<< >>hàm tự do, thường friendostream& hoặc istream&
truy cập[] ()thành viêntham chiếu tới phần tử
tăng giảm++ --thành viênT& (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+const và trả về đối tượng mới. Ngược lại a += b đổ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 < bb < 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/22/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.

Lỗi thường gặp
  • 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& trong operator<<, 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à ostream chứ không phải lớp của ta.
Nguyên nhân gốc của các lỗi trên

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 ab 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.

operator+ tạo đối tượng mới, a và b không đổi C++
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};
8
9int main() {
10 Phanso a{1, 2};
11 Phanso b{1, 3};
12 Phanso c = a + b;
13 cout << c.tu << "/" << c.mau;
14}
Ngăn xếp stack
main()
#1Phanso
tu1
mau2
Bộ nhớ động heap
(trống)
Tạo phân số a = 1/2 trên stack.
1/6

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 == ba == 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&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 xy. 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?

+ 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, ab 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

Kiểm tra nhanh: nạp chồng toán tử0/4 đúngchưa trả lời
  1. 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ì?
  2. 2Toán tử nào sau đây KHÔNG nạp chồng được trong C++?
  3. 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?
  4. 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.

Ghi nhớ

Toán tử là hàm có tên đặc biệt. a @ ba.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).