Trong bài – Nạp chồng hàm (function overloading), bạn đã học về cách nạp chồng các hàm, đây là một cơ chế giúp tạo ra và phân giải các lời gọi hàm tới nhiều hàm có cùng tên, miễn là mỗi hàm có một phần khuôn mẫu hàm (function prototype) riêng biệt. Điều này sẽ cho phép bạn tạo ra các biến thể của một hàm để làm việc với các kiểu dữ liệu khác nhau, mà không cần phải nghĩ cách để đặt tên khác nhau cho từng biến thể.
Trong C++, các toán tử (operators) được cài đặt dưới dạng các hàm (functions). Bằng cách sử dụng nạp chồng hàm (function overloading) trên các hàm toán tử (operator functions), bạn có thể định nghĩa các phiên bản toán tử của riêng mình, để làm việc với các kiểu dữ liệu khác nhau (bao gồm cả các kiểu dữ liệu class mà bạn đã viết). Sử dụng nạp chồng hàm để nạp chồng các toán tử được gọi là nạp chồng toán tử (operator overloading).
Trong chương này, chúng ta sẽ tìm hiểu về các chủ để liên quan đến nạp chồng toán tử.
Nội dung chính
1. Toán tử chính là hàm
Xét ví dụ sau:
int x = 2;
int y = 3;
std::cout << x + y << '\n';
Trong trình biên dịch của chúng ta có chứa một phiên bản được tích hợp sẵn của toán tử cộng (+) dành cho các toán hạng kiểu integer – Hàm này cộng các số nguyên x và y lại với nhau và trả về một kết quả số nguyên. Khi nhìn thấy biểu thức x + y, bạn có thể liên tưởng ngay tới một lời gọi hàm operator+(x, y) (trong đó operator+ là tên của hàm).
Cùng xem đoạn code tương tự sau:
double z = 2.0;
double w = 3.0;
std::cout << w + z << '\n';
Trình biên dịch cũng đi kèm với một phiên bản được tích hợp sẵn của toán tử cộng (+) dành cho các toán hạng kiểu double. Biểu thức w + z trở thành lời gọi hàm operator+(w, z), và nạp chồng hàm được sử dụng để xác định rằng trình biên dịch nên gọi tới phiên bản dành cho kiểu double của hàm này, thay vì phiên bản dành cho kiểu integer.
Bây giờ, cùng xem điều gì sẽ xảy ra nếu ta cố gắng cộng hai đối tượng của một class do người dùng định nghĩa ra:
Mystring string1 = "Hello, ";
Mystring string2 = "World!";
std::cout << string1 + string2 << '\n';
Bạn mong đợi điều gì sẽ xảy ra trong trường hợp này? Kết quả được mong đợi theo cảm quan có lẽ là chuỗi “Hello, World!” sẽ được in ra trên màn hình. Tuy nhiên, bởi vì Mystring là một class do người dùng tự định nghĩa, nên trình biên dịch sẽ không thể có phiên bản được tích hợp sẵn của toán tử cộng mà có thể sử dụng cho các toán hạng kiểu Mystring. Vì vậy trong trường hợp này, ta sẽ nhận được một thông báo lỗi. Để làm cho nó hoạt động được như mong muốn, chúng ta cần phải viết một hàm được nạp chồng để nói cho trình biên dịch biết hàm toán tử +operator nên hoạt động như thế nào đối với hai toán hạng có kiểu Mystring. Chúng ta sẽ cùng tìm hiểu về cách để làm điều này trong bài học tiếp theo.
2. Phân giải các toán tử được nạp chồng
Khi đánh giá một biểu thức có chứa một toán tử, trình biên dịch sẽ sử dụng các quy tắc sau:
- Nếu tất cả các toán hạng đều thuộc các kiểu dữ liệu cơ bản, trình biên dịch sẽ gọi tới một đoạn chương trình con (routine) được tích hợp sẵn nếu có. Nếu không tồn tại hàm nào thỏa mãn, một thông báo lỗi trình biên dịch sẽ được đưa ra.
- Nếu có bất kỳ toán hạng nào thuộc các kiểu dữ liệu do người dùng tự định nghĩa ra (ví dụ: Một trong những class của bạn, hoặc là một kiểu enum), trình biên dịch sẽ xem liệu rằng có hàm toán tử được nạp chồng nào phù hợp để sử dụng cùng kiểu dữ liệu này không. Nếu không thể tìm thấy hàm toán tử nào phù hợp, nó sẽ cố gắng chuyển đổi một hoặc nhiều toán hạng thuộc kiểu dữ liệu người dùng tự định nghĩa thành các kiểu dữ liệu cơ bản để có thể sử dụng được với một hàm toán tử được tích hợp sẵn nào đó (thông qua một overloaded typecast – phép chuyển đổi kiểu dữ liệu đã được nạp chồng, chúng ta sẽ nói tới sau trong chương này).
3. Những hạn chế của nạp chồng toán tử
Đầu tiên, hầu hết mọi toán tử hiện có trong C++ đều có thể được nạp chồng. Có một số ngoại lệ là: toán tử điều kiện (?:), sizeof – toán tử tìm kích thước kiểu dữ liệu, scope – toán tử phạm vi (::), toán tử lựa chọn thành viên (.), toán tử lựa chọn con trỏ thành viên (.*), toán tử typeid, và các toán tử chuyển đổi kiểu dữ liệu.
Thứ hai, bạn chỉ có thể nạp chồng các toán tử đang tồn tại. Bạn không thể tạo ra những toán tử mới hoặc đổi tên các toán tử hiện có. Ví dụ, bạn không thể tạo ra một toán tử ** để thực hiện phép toán số mũ.
Thứ ba, ít nhất một trong số các toán hạng của toán tử được nạp chồng phải thuộc kiểu dữ dữ liệu do người dùng tự định nghĩa. Điều này có nghĩa là bạn không thể nạp chồng toán tử cộng để làm việc với một số integer và một số double. Tuy nhiên, bạn có thể nạp chồng toán tử cộng để làm việc với một số integer và một đối tượng của class Mystring.
Thứ tư, không thể thay đổi số lượng của các toán hạng mà một toán tử hỗ trợ,
Cuối cùng, tất cả cá toán tử đều giữ nguyên mức độ ưu tiên và tính ghép nối mặc định của chúng (bất kể chúng được sử dụng cho mục đích gì), và điều này không thể thay đổi.
Một số lập trình viên mới cố gắng nạp chồng toán tử XOR – Ký hiệu ^ (toán tử này làm việc với các bits) để thực hiện phép lũy thừa. Tuy nhiên, trong C++, toán tử ^ có mức độ ưu tiên thấp hơn so với các toán tử số học cơ bản, điều này khiến cho việc đánh giá các biểu thức trở nên thiếu chính xác.
Trong toán học cơ bản, phép lũy thừa sẽ được giải quyết trước các phép số học cơ bản, vì vậy 4 + 3^2 được phân giải thành 4 + (3^2) => 4 + 9 => 13. Tuy nhiên, trong C++, các toán tử số học có mức độ ưu tiên cao hơn toán tử ^, vì vậy 4 + 3^2 sẽ được phân giải thành (4+3)^2 => 7^2 => 49.
Bạn sẽ cần phải đặt rõ ràng các dấu ngoặc tròn cho phần lũy thừa (ví dụ: 4 + (3^2)) mỗi khi bạn sử dụng nó để code chạy đúng. Điều này sẽ khiến chương trình của bạn dễ bị lỗi.
Do vấn đề về mức độ ưu tiên khi phân giải các toán tử này, chúng ta chỉ nên sử dụng các toán tử được nạp chồng theo cùng mục đích ban đầu của chúng.
Quy tắc: Khi nạp chồng các toán tử, hãy giữ cho chức năng của các toán tử được nạp chồng gần với mục đích ban đầu của toán tử gốc càng nhiều càng tốt.
Hơn nữa, bởi vì các toán tử đều không có tên giàu tính mô tả, ta không thể xác định rõ chúng được sử dụng cho mục đích chính xác nào. Ví dụ, toán tử cộng có thể được sử dụng cho mục đích nối chuỗi (string concatenation). Nhưng đối với toán tử – thì sao? Bạn sẽ mong đợi nó thực hiện điều gì? Điều này thật sự không rõ ràng.
Quy tắc: Nếu ý nghĩa của một toán tử khi được áp dụng cho một class tùy chỉnh là không rõ ràng và không trực quan, hãy sử dụng một hàm được đặt tên rõ ràng thay vì sử dụng toán tử đã được nạp chồng.
Mặc dù tồn tại những hạn chế này, song bạn vẫn sẽ tìm thấy được rất nhiều chức năng hữu ích mà phép nạp chồng có thể mang lại cho các class tùy chỉnh của mình! Bạn có thể nạp chồng toán tử cộng để thực hiện phép nối chuỗi trong các string class (lớp kế thừa kiểu string hoặc có làm việc với các strings), hoặc để cộng hai đối tượng thuộc class Fraction (lớp phân số) với nhau. Bạn có thể nạp chồng toán tử << để dễ dàng in class của mình ra màn hình (hoặc ra một file). Bạn có thể nạp chồng toán tử bằng (==) để so sánh hai đối tượng thuộc các class nào đó. Điều này làm cho nạp chồng toán tử trở thành một trong những tính năng hữu ích nhất của C++, đơn giản là bởi vì nó cho phép bạn làm việc với các class của mình một cách trực quan hơn.
Trong các bài học sắp tới, chúng ta sẽ xem xét sâu hơn về việc nạp chồng các loại toán tử khác nhau.