Để kiểu của bạn dùng như kiểu có sẵn
Bạn đã biết std::string cho phép viết a + b để nối chuỗi và cout << s để in ra. Đó không phải mẹo đặc biệt nào của trình biên dịch: chúng là các hàm bình thường với cái tên hơi lạ. Nạp chồng toán tử (operator overloading) là tính năng cho phép các lớp của bạn dùng cùng cú pháp đó, để một kiểu Vector2 hay Money có thể cộng, so sánh và in ra y như một int.
Cơ chế này rất đơn giản khi bạn đã thấy nó: một biểu thức như a + b chỉ là cách viết tắt. Trình biên dịch viết lại nó thành lời gọi tới hàm tên operator+ và tìm một hàm khớp với kiểu của các toán hạng. Định nghĩa hàm đó cho lớp của bạn là a + b bỗng chạy được. Đây thực chất là một dạng đặc biệt của nạp chồng hàm: các quy tắc phân giải tên ở đó vẫn áp dụng, chỉ khác là tên có hình dạng toán tử.
Lưu ý hàm nhận cả hai toán hạng bằng const&: phép tính số học không nên sửa đầu vào, và tham chiếu giúp tránh sao chép. Hàm trả về một Vector2 mới theo giá trị: p + q phải cho ra một kết quả mới mà không đụng tới p hay q, giống như 2 + 3 không làm thay đổi 2.
Hàm thành viên và hàm không thành viên
Có hai chỗ để định nghĩa toán tử: là một thành viên của lớp, hoặc là một hàm tự do (không thành viên). Khi là thành viên, toán hạng bên trái là this ngầm định, nên một toán tử hai ngôi chỉ nhận một tham số tường minh:
Chữ const sau danh sách tham số rất quan trọng: a + b không nên sửa a, nên hàm thành viên được đánh dấu const. Hãy dùng dạng thành viên cho các toán tử gắn liền với toán hạng bên trái và không cần chuyển đổi kiểu cho nó: +=, [], (), ->, và các toán tử một ngôi như -x hay ++x.
Điểm vướng của dạng thành viên: toán hạng bên trái không thể được chuyển đổi kiểu. Với operator+ thành viên ở trên, a + 50 chạy được (50 được chuyển thành Money ở vế phải), nhưng 50 + a thì không biên dịch được: toán hạng bên trái 50 là một int, và bạn không thể thêm hàm thành viên cho int. Một toán tử không thành viên giải quyết được chuyện này vì cả hai toán hạng đều là tham số tường minh và đều chuyển đổi được:
Quy tắc chung: làm các toán tử hai ngôi đối xứng (+, ==, *) thành hàm không thành viên để chuyển đổi kiểu hoạt động ở cả hai phía; làm các toán tử phải sửa đổi hoặc gắn với toán hạng bên trái (+=, [], =) thành hàm thành viên.
Nạp chồng toán tử stream
Toán tử được nạp chồng nhiều nhất là << để in ra. Bạn không thể biến nó thành thành viên của lớp, vì toán hạng bên trái là một std::ostream (như cout), không phải kiểu của bạn, và bạn không sở hữu ostream. Vì vậy nó luôn là hàm không thành viên, nhận stream bằng tham chiếu không const và trả về chính stream đó:
Hai chi tiết giúp cách này hoạt động. Stream được truyền vào và trả về theo tham chiếu (ostream&): stream không sao chép được, và việc trả về chính stream đó cho phép bạn viết nối tiếp cout << "p = " << p << "\n". Mỗi << trả về stream để << tiếp theo có thứ để gắn vào. Quên return os; là chuỗi lời gọi bị đứt.
Toán tử so sánh
Để so sánh các đối tượng của bạn bằng ==, < và các toán tử cùng họ, hãy nạp chồng các toán tử so sánh. Trước C++20 bạn phải tự viết từng toán tử; cái bẫy chính là operator< phải trả về bool và định nghĩa một thứ tự nhất quán:
Tự viết cả sáu phép so sánh (==, !=, <, <=, >, >=) vừa tẻ nhạt vừa dễ sai. C++20 bổ sung toán tử so sánh ba chiều <=> (còn gọi là "spaceship"). Đặt nó và == ở dạng mặc định sẽ sinh ra mọi phép so sánh cho bạn:
= default bảo trình biên dịch so sánh các thành viên theo thứ tự khai báo, đúng với thứ tự từ điển mà bạn sẽ tự viết. Hãy ưu tiên cách này trên các trình biên dịch hiện đại.
Toán tử gán và những cái bẫy
operator= (gán sao chép) là trường hợp đặc biệt: trình biên dịch tự sinh một phiên bản cho bạn, và với các lớp đơn giản thì bản mặc định đó là đúng. Bạn chỉ cần tự viết khi lớp quản lý một tài nguyên, như bộ nhớ thô hay một file handle, nơi việc sao chép từng thành viên một sẽ sai. Signature chuẩn trả về *this theo tham chiếu để phép gán có thể nối tiếp (a = b = c):
Có hai cái bẫy trong hàm ngắn này. Thứ nhất là kiểm tra tự gán if (this == &other): thiếu nó, a = a sẽ delete[] data rồi đọc từ other.data vừa bị giải phóng, đó là undefined behavior. Thứ hai, thứ tự rất quan trọng: trong phiên bản tự viết, bạn không được xóa bộ đệm cũ trước khi đã sao chép an toàn bộ đệm mới (cách cài đặt thực tế thường cấp phát trước, hoặc dùng idiom copy-and-swap, để nếu cấp phát thất bại thì đối tượng vẫn nguyên vẹn).
Một cái bẫy rộng hơn: đừng nạp chồng toán tử theo những cách gây bất ngờ. Một operator+ lén sửa toán hạng bên trái, hay một operator== không đối xứng, sẽ làm mọi người đọc code bối rối và phá vỡ code của thư viện chuẩn vốn giả định ý nghĩa thông thường. Chỉ nạp chồng toán tử khi phép toán thật sự "giống phép cộng" hay "giống phép so sánh bằng" đối với kiểu của bạn.
Tiếp theo: access specifier
Để ý rằng mọi ví dụ đều giữ các thành viên dữ liệu ở private và chỉ để lộ hành vi qua một bề mặt public nhỏ: hàm khởi tạo, toán tử và vài phương thức. Ranh giới giữa những gì thế giới bên ngoài nhìn thấy và những gì được giấu bên trong lớp do các access specifier kiểm soát: public, private và protected. Tiếp theo, chúng ta sẽ xem chính xác mỗi loại cho phép những gì, vì sao dữ liệu private cùng phương thức public là mặc định cho tính đóng gói tốt, và protected khớp vào kế thừa như thế nào.
Câu hỏi thường gặp
Nạp chồng toán tử trong C++ là gì?
Nạp chồng toán tử cho phép bạn định nghĩa ý nghĩa của các toán tử có sẵn như +, == hay << đối với kiểu dữ liệu của riêng bạn. Bạn viết một hàm có tên đặc biệt, như operator+, operator==, v.v., và trình biên dịch gọi nó mỗi khi toán tử xuất hiện với toán hạng thuộc lớp của bạn. Đây chính là cách string + string nối chuỗi và cout << obj in ra một đối tượng tùy chỉnh.
Trong C++, toán tử nên là hàm thành viên hay hàm không thành viên (friend)?
Dùng hàm thành viên khi toán hạng bên trái là lớp của bạn và không cần chuyển đổi kiểu (ví dụ +=, [], ()). Dùng hàm không thành viên (thường là friend) khi toán hạng bên trái có thể là kiểu có sẵn hoặc khi bạn muốn chuyển đổi đối xứng ở cả hai phía: điều này là bắt buộc với operator<< vì toán hạng bên trái là std::ostream, không phải lớp của bạn.
Những toán tử nào trong C++ không thể nạp chồng?
Bạn không thể nạp chồng :: (phân giải phạm vi), . (truy cập thành viên), .* (truy cập qua con trỏ thành viên), ?: (toán tử ba ngôi) và sizeof. Bạn cũng không thể tạo ra toán tử hoàn toàn mới hay thay đổi số ngôi hoặc độ ưu tiên của toán tử: + luôn là toán tử hai ngôi với cùng độ ưu tiên, dù nó cộng các int hay Vector2 của bạn.