Một tên, nhiều phiên bản
Bạn thường cần cùng một thao tác cho nhiều loại dữ liệu khác nhau: in một int, in một string, in một double. Ở một số ngôn ngữ, bạn sẽ phải đặt ra printInt, printString, printDouble. C++ cho phép bạn đặt cùng một tên cho tất cả và phân biệt chúng qua tham số. Đó là nạp chồng hàm (function overloading).
Quy tắc rất đơn giản: nhiều hàm có thể dùng chung tên, miễn là danh sách tham số khác nhau, về số lượng tham số, kiểu hoặc thứ tự của chúng. Trình biên dịch xem các đối số ở từng chỗ gọi và chọn phiên bản phù hợp giúp bạn.
Ba hàm, một tên. Mỗi lời gọi rơi vào phiên bản có kiểu tham số khớp với đối số. Chính điều này giúp std::cout << x chạy được với cả int, double lẫn chuỗi: operator<< được nạp chồng rất nhiều lần.
Thế nào là một bản nạp chồng khác
Một bản nạp chồng chỉ được phân biệt bằng danh sách tham số. Bạn có thể thay đổi:
int area(int side); // 1 param
int area(int width, int height); // 2 params -> different
double area(double r); // different type -> different
void log(string msg, int level); // order matters...
void log(int level, string msg); // ...so this is different too
Mỗi cái trên đều là một bản nạp chồng hợp lệ và riêng biệt. Trình biên dịch lập một tập ứng viên từ mọi hàm tên area, rồi so khớp theo số lượng và kiểu đối số.
Chỉ khác kiểu trả về là không đủ
Đây là điểm dễ nhầm làm gần như ai cũng vấp: bạn không thể nạp chồng chỉ dựa vào kiểu trả về. Giá trị trả về không đóng vai trò gì trong việc chọn bản nạp chồng, vì trình biên dịch quyết định gọi hàm nào dựa trên đối số, trước khi nó xem tới thứ được trả về.
int convert(double x); // OK
double convert(double x); // ERROR: redefinition - only the return type differs
Đoạn đó không biên dịch được. Nếu danh sách tham số giống hệt nhau, hai khai báo là cùng một hàm xét về mặt nạp chồng, và bạn nhận lỗi định nghĩa lại. Để rẽ nhánh theo kiểu kết quả, hãy đổi một tham số (hoặc dùng template hay ép kiểu tại chỗ gọi).
Cách phân giải nạp chồng chọn ra hàm thắng
Khi bạn gọi hàm, trình biên dịch xếp hạng mọi bản nạp chồng khả thi và chọn bản khớp nhất. Đại khái, nó ưu tiên theo thứ tự:
- Khớp chính xác (không cần chuyển đổi).
- Nâng kiểu (promotion, ví dụ
charhoặcshort->int,float->double). - Chuyển đổi chuẩn (ví dụ
int->double,double->int, con trỏ tới lớp cơ sở).
Nếu đúng một bản nạp chồng tốt hơn hẳn mọi bản khác, bản đó thắng. Hãy xem cách khớp chính xác đánh bại một phép chuyển đổi:
'A' là một char, nhưng nâng kiểu lên int được xếp hạng cao hơn chuyển đổi sang double, nên nó gọi bản nạp chồng int. Những quy tắc xếp hạng này là lý do phân giải nạp chồng thường "tự làm đúng", và thỉnh thoảng lại khiến bạn bất ngờ.
Bẫy mơ hồ
Nếu hai bản nạp chồng tốt ngang nhau, không bản nào tốt hơn hẳn, trình biên dịch từ chối đoán và báo lời gọi mơ hồ (ambiguous call). Trường hợp kinh điển là hai bản nạp chồng mà mỗi bản đều cần một phép chuyển đổi cùng hạng:
void f(int x);
void f(double x);
f(0L); // ERROR: ambiguous - long -> int and long -> double are equal-rank conversions
Cả int lẫn double đều không khớp chính xác với long, và hai phép chuyển đổi cùng hạng, nên lời gọi bị mơ hồ. Bạn có hai cách sửa gọn gàng:
Một bất ngờ liên quan: truyền vào một literal chuỗi. void g(const string&) và void g(bool) đều tranh nhau g("hi"), và bool có thể thắng, vì const char* chuyển sang bool (khác null -> true) ít bước hơn so với dựng một std::string. Nếu bạn thấy một literal chuỗi bí ẩn gọi vào bản nạp chồng bool của mình, đây chính là lý do: hãy thêm một bản nạp chồng const char* hoặc const string& để nhận phần khớp chính xác.
Nạp chồng và đối số mặc định không hợp nhau
Đối số mặc định không thay thế được nạp chồng, và kết hợp cả hai sẽ tạo ra sự mơ hồ. Mỗi cái đều trả lời được cùng một lời gọi, nên trình biên dịch không chọn được:
void connect(string host, int port = 8080); // can be called with 1 arg
void connect(string host); // also callable with 1 arg
connect("localhost"); // ERROR: ambiguous - both match a single argument
Hãy chọn một cách cho mỗi dạng lời gọi. Dùng đối số mặc định khi hành vi giống hệt nhau và bạn chỉ muốn có tham số tùy chọn; dùng nạp chồng khi các danh sách đối số khác nhau cần chạy code thật sự khác nhau. Trộn hai cách khiến hai chữ ký va nhau ở cùng một số lượng đối số chắc chắn sẽ gây lỗi mơ hồ.
Thêm một phân biệt nữa cần nắm chắc: nạp chồng (overloading) không phải ghi đè (overriding). Nạp chồng được phân giải lúc biên dịch giữa các hàm cùng phạm vi, cùng tên nhưng khác tham số. Ghi đè thay thế một hàm virtual trong lớp dẫn xuất lúc chạy và đòi hỏi chữ ký giống hệt, một chủ đề của hàm ảo ở phần sau.
Tiếp theo: lambda
Nạp chồng cho một tên nhiều cài đặt theo kiểu, được chọn lúc biên dịch. Tuy vậy, đôi khi bạn không muốn một hàm có tên chút nào: bạn cần một hàm nhỏ dùng một lần, định nghĩa ngay tại chỗ dùng, thường để truyền vào một thuật toán như sort. Đó chính là lambda: hàm ẩn danh bạn viết ngay trong dòng, có thể capture các biến xung quanh và truyền đi chỉ trong một biểu thức. Tiếp theo ta sẽ xem cách viết lambda và khi nào nó tốt hơn một hàm có tên đầy đủ.
Câu hỏi thường gặp
Nạp chồng hàm trong C++ là gì?
Nạp chồng hàm (function overloading) cho phép bạn định nghĩa nhiều hàm cùng tên, miễn là danh sách tham số khác nhau (về số lượng, kiểu hoặc thứ tự). Trình biên dịch chọn hàm cần gọi dựa vào các đối số bạn truyền, nên print(42) và print("hi") có thể gọi hai hàm print khác nhau.
Hai hàm C++ có thể chỉ khác nhau ở kiểu trả về không?
Không. Các bản nạp chồng phải khác nhau ở danh sách tham số. int f(int) và double f(int) là lỗi biên dịch: kiểu trả về không thuộc chữ ký dùng để phân giải nạp chồng, vì trình biên dịch chọn bản nạp chồng dựa trên đối số tại chỗ gọi, trước khi giá trị trả về được dùng tới.
Điều gì gây ra lỗi "ambiguous call" với hàm nạp chồng?
Lỗi này xảy ra khi hai bản nạp chồng khớp tốt ngang nhau và trình biên dịch không ưu tiên được bản nào. Ví dụ kinh điển là f(int) và f(double) được gọi với f(0L) (một long), khi cả hai đều cần một phép chuyển đổi cùng hạng. Hãy sửa bằng cách thêm một bản nạp chồng khớp chính xác hoặc ép đối số sang kiểu bạn muốn.