Giới thiệu
Gần đây, tôi đã thực hiện một cuộc khảo sát trên LinkedIn về việc lựa chọn phương thức trong các cuộc gọi phương thức bị quá tải và khá ngạc nhiên khi chỉ 20% câu trả lời là đúng. Điều này đã thúc đẩy tôi viết bài này vì tôi cảm thấy rằng các phương thức cùng tên không được đánh giá cao cho những rắc rối mà chúng có thể gây ra.
Bài viết này sẽ được chia thành ba phần. Phần 1 sẽ chỉ tập trung vào việc quá tải phương thức trong một định nghĩa loại duy nhất (lớp, giao diện hoặc enum). Cơ bản, chúng ta sẽ xem xét cách mà phương thức cụ thể nhất được chọn chỉ bằng cách nhìn vào các tham số của phương thức. Phần 2 sẽ đề cập đến việc quá tải với generics. Phần 3 sẽ xem xét những phức tạp ngoài trường hợp loại đơn, chẳng hạn như kế thừa, đa hình và ẩn phương thức.
Trong toàn bộ bài viết, tôi sẽ không đi vào các trường hợp lỗi biên dịch khác nhau, vì quá trình xây dựng và các IDE đang xử lý tốt những điều đó.
Ví dụ cơ bản về Overloading
Để bắt đầu sự nhầm lẫn, hãy nhìn vào một ví dụ ngắn về hai lớp (tất cả các quá tải đều được định nghĩa trong một lớp, như chúng ta đã nói):
java
public class Example1 {
public static class Helper {
private void m(double num) { System.out.println("m(double)"); }
public void m(char... chars) { System.out.println("m(char...)"); }
public void m(Comparable c) { System.out.println("m(Comparable)"); }
public void m(Object obj) { System.out.println("m(Object)"); }
}
public static void main(String... args) {
Helper h = new Helper();
h.m('a');
}
}
import Example1.Helper;
public class Example2 {
public static void main(String... args) {
Helper h = new Helper();
h.m('a');
}
}
Khi thực hiện Example1, kết quả in ra sẽ là:
m(double)
Khi thực hiện Example2, kết quả in ra sẽ là:
m(Comparable)
Tại sao điều này lại xảy ra? Chúng ta sẽ trở lại vấn đề này sau khi đã đề cập một số nội dung.
Nguy cơ từ Overloading
Nguy cơ lớn nhất với việc quá tải là một thay đổi trong chữ ký phương thức hoặc việc thêm một phương thức mới có thể thay đổi phương thức nào đang được thực thi bởi một caller không nghi ngờ, vì nó có thể không cho bạn bất kỳ lỗi hoặc cảnh báo nào. Đối với phạm vi của bài viết này, điều đó có phần đơn giản, mặc dù không phải lúc nào cũng hiển nhiên. Nhưng khi chúng ta đi đến các phần tiếp theo, sẽ ngày càng khó để phát hiện ra điều đó.
Khi hai phương thức chia sẻ cùng một tên trong Java, chúng có thể tham gia vào việc quá tải, ghi đè, hoặc ẩn, tùy thuộc vào chữ ký, bộ điều chỉnh và cấu trúc kế thừa của chúng. Tôi sẽ cố gắng giữ cho các ví dụ của mình ngắn gọn, nhưng tôi phải nói rằng, sự lộn xộn mà bạn có thể tạo ra bằng cách kết hợp cả ba là vô cùng ấn tượng.
Ý tưởng về việc quá tải thực sự đã có trước cả OOP—FORTRAN đã cho phép bạn viết ABS cho các số nguyên hoặc số thực vào những năm 1950. C++ vào những năm 1980 đã đưa việc quá tải phương thức tự định nghĩa vào OOP chính thống, và Java đã kế thừa nó từ ngày đầu.
Định nghĩa Overloading
Vậy, quá tải là gì?
Trước tiên là định nghĩa chính thức từ JLS (JLS 8.4.9):
Nếu hai phương thức của một lớp (dù cả hai đều được khai báo trong cùng một lớp, hoặc cả hai được kế thừa bởi một lớp, hoặc một được khai báo và một được kế thừa) có cùng tên nhưng chữ ký không tương đương ghi đè, thì tên phương thức được coi là bị quá tải. Điều này không gây khó khăn và không bao giờ dẫn đến lỗi biên dịch chỉ bởi chính nó.
Vì trong phần này chúng ta chỉ xem xét các phương thức được định nghĩa trong một loại duy nhất, chúng ta sẽ bỏ qua “tương đương ghi đè”, “được kế thừa” hoặc thậm chí không đề cập đến các hàm tĩnh được nhập khẩu (chúng ta sẽ xem xét mọi thứ chi tiết trong Phần 3) và chỉ nói rằng:
Nếu một khai báo loại chứa các phương thức cùng tên, các phương thức đó bị quá tải.
Điều này áp dụng bất kể sự khác biệt về kiểu trả về, số lượng tham số, phương thức tĩnh hay phương thức thể hiện, hoặc mức độ truy cập của chúng. Điều này cũng giả định rằng không có lỗi biên dịch nào.
Một số lưu ý quan trọng
- Nếu lớp của bạn định nghĩa nhiều hơn một constructor, chúng cũng được coi là quá tải giống như các phương thức.
- Tất cả các quy tắc giải quyết quá tải mà chúng tôi thảo luận ở đây cũng áp dụng cho các constructor.
Ví dụ:
java
static void m(int x) {}
private int m() { return 0; }
public String m(int x, long y) { return ""; }
Việc quá tải chủ yếu là về việc trình biên dịch chọn một phương thức để thực thi cho bạn. Vì nó nằm trong phạm vi của trình biên dịch, quyết định này được thực hiện khi trình biên dịch đã hoàn thành công việc của mình, và nó có thể không trùng khớp với ý định của bạn. Nó sẽ không bị thay đổi tại thời gian chạy, và nếu có điều gì đó thay đổi bạn phải biên dịch lại. Hãy để tôi cho bạn thấy điều này có nghĩa là gì.
Ví dụ:
Chúng ta có hai lớp, Main và Helper:
java
package team1;
public class Helper {
public void m(Object o) {
System.out.println("m(Object)");
}
}
import team1.Helper;
public class Main {
public static void main(String... args) {
new Helper().m("test");
}
}
Giả sử rằng nhóm đã viết lớp Main không có quyền kiểm soát đối với lớp Helper, và nó được cung cấp bởi một nhóm khác dưới dạng tệp JAR.
Khi bạn xây dựng và thực hiện Main, nó tạo ra đầu ra sau:
m(Object)
Bây giờ, hãy chứng minh rằng nếu chúng ta thay đổi phương thức m trong lớp Helper, JVM sẽ bắt được sự thay đổi mà không cần biên dịch lại Main. Điều này nên hoạt động tốt theo JLS 13.4.22: “Các thay đổi trong thân của một phương thức hoặc constructor không phá vỡ tính tương thích với các nhị phân đã tồn tại trước đó.”
Chúng ta hãy chỉnh sửa lớp Helper và xây dựng lại tệp jar:
java
package team1;
public class Helper {
public void m(Object o) {
System.out.println("V2 m(Object)");
}
}
Bây giờ, chúng ta chạy lại Main, và xem đầu ra mới:
V2 m(Object)
Rõ ràng, nó đã được bắt. Bây giờ là lúc thay đổi Helper một lần nữa và lặp lại quy trình. Chúng ta sẽ thêm một phương thức mới vào đó, điều này cũng ổn theo JLS 13.4.12: “Thêm một phương thức vào một lớp không phá vỡ tính tương thích với các nhị phân đã tồn tại trước đó.”
java
package team1;
public class Helper {
public void m(String o) {
System.out.println("m(String)");
}
public void m(Object o) {
System.out.println("V2 m(Object)");
}
}
Nhìn vào mã, về lý thuyết, cuộc gọi đến new Helper().m("test") nên in ra:
m(String)
Nhưng vì Main không được biên dịch lại, chúng ta vẫn sẽ nhận được:
V2 m(Object)
Và chỉ sau khi chúng ta biên dịch lại Main với tệp JAR mới và chạy nó, chúng ta sẽ nhận được:
m(String)
Điều này rõ ràng cho thấy rằng việc giải quyết quá tải xảy ra tại thời điểm biên dịch: mà không biên dịch lại Main, JVM không thể chọn quá tải m(String) mới được thêm vào. Lưu ý, không có thay đổi nào đối với lớp Main được thực hiện. Không có phương thức nào bị sửa đổi. Một phương thức mới đã được thêm vào và trình biên dịch quyết định rằng đó là một phù hợp tốt hơn cho tên và các tham số mà bạn đã cung cấp.
Quy tắc chọn phương thức
Điều này giải thích tại sao việc thêm một quá tải mới hoặc thay đổi một chữ ký một chút có thể thay đổi phương thức nào được gọi mà không tạo ra bất kỳ lỗi hoặc cảnh báo nào.
Ngay cả trong trường hợp đơn giản của một loại duy nhất, các quy tắc có thể dẫn đến những kết quả không rõ ràng. Tính khả dụng riêng tư, phương thức tĩnh so với phương thức thể hiện, varargs, boxing và unboxing, widening, và chuyển đổi tham chiếu đều đóng vai trò trong việc xác định quá tải nào được chọn.
Phần này chỉ xem xét các loại cụ thể trong một lớp, giao diện hoặc enum. Trong phần tiếp theo, chúng ta sẽ chuyển sang generics, nơi mà việc suy diễn loại, xóa bỏ và chuyển đổi không kiểm tra làm cho các quy tắc giải quyết càng tinh vi hơn và khó đoán hơn.
Những thực tiễn tốt nhất khi làm việc với Overloading
- Tính rõ ràng: Đảm bảo rằng các phương thức quá tải có chữ ký rõ ràng và dễ phân biệt. Việc sử dụng các tên khác cho các phương thức tương tự có thể giúp tránh nhầm lẫn.
- Kiểm tra kỹ lưỡng: Luôn kiểm tra mã của bạn để đảm bảo rằng các phương thức quá tải đang hoạt động như mong đợi.
- Tránh quá tải quá mức: Đừng quá tải quá nhiều phương thức chỉ vì bạn có thể. Điều này có thể gây khó khăn cho việc bảo trì mã.
Những cạm bẫy phổ biến
- Nhầm lẫn giữa các phương thức: Đôi khi, việc có nhiều phương thức cùng tên với các tham số khác nhau có thể gây nhầm lẫn cho lập trình viên khác.
- Không chú ý đến tính khả dụng: Các phương thức riêng tư không thể được truy cập từ các lớp khác, điều này có thể dẫn đến lỗi không mong muốn.
Kết luận
Việc hiểu rõ về cách thức hoạt động của quá tải trong Java là rất quan trọng cho bất kỳ lập trình viên nào. Qua bài viết này, bạn đã có cái nhìn sâu sắc về cách mà trình biên dịch chọn phương thức nào để thực thi. Hãy tiếp tục theo dõi phần tiếp theo của bài viết, nơi chúng ta sẽ khám phá về generics và những thách thức đi kèm với chúng.
Câu hỏi thường gặp (FAQ)
1. Overloading có khác gì với Overriding?
Overloading là việc có nhiều phương thức cùng tên nhưng khác chữ ký trong cùng một lớp, trong khi overriding là việc định nghĩa lại một phương thức của lớp cha trong lớp con.
2. Tại sao không có lỗi biên dịch khi thêm một phương thức mới?
Khi thêm phương thức mới, trình biên dịch không tìm thấy xung đột với các phương thức đã có và do đó không tạo ra lỗi biên dịch.
3. Làm thế nào để kiểm tra xem phương thức nào được gọi khi có quá tải?
Bạn có thể sử dụng các câu lệnh in ra để kiểm tra, hoặc sử dụng debugger để theo dõi dòng thực thi của chương trình.
Hãy thực hành và áp dụng những kiến thức này vào các dự án của bạn để trở thành một lập trình viên giỏi hơn!