Lập trình hướng đối tượng sở hữu ba đặc điểm cốt lõi: đóng gói, kế thừa và đa hình.
Đóng gói che giấu cơ chế triển khai bên trong của lớp, cho phép thay đổi cấu trúc nội bộ của lớp mà không ảnh hưởng đến cách sử dụng, đồng thời bảo vệ dữ liệu. Đối với bên ngoài, các chi tiết nội bộ bị ẩn đi, chỉ có phương thức truy cập được công bố.
Kế thừa nhằm tái sử dụng mã từ lớp cha. Nếu hai lớp có mối quan hệ IS-A, chúng ta có thể sử dụng kế thừa. Đồng thời, kế thừa cũng tạo tiền đề cho việc triển khai đa hình. Vậy đa hình là gì và cơ chế triển khai của nó ra sao? Hãy cùng tôi giải đáp:
Đa hình là khi trong chương trình, biến tham chiếu được định nghĩa có thể trỏ đến thể hiện cụ thể của loại nào và phương thức được gọi qua biến tham chiếu đó không được xác định lúc biên dịch, mà chỉ được xác định trong quá trình chạy chương trình. Cụ thể là biến tham chiếu cuối cùng sẽ trỏ đến thể hiện của lớp nào, phương thức được gọi qua biến tham chiếu đó là phương thức của lớp nào, chỉ được quyết định khi chương trình chạy. Vì lớp cụ thể được xác định lúc chạy, như vậy không cần sửa mã nguồn, có thể cho biến tham chiếu liên kết với nhiều lớp triển khai khác nhau, dẫn đến phương thức cụ thể được gọi qua biến tham chiếu đó thay đổi, tức là không cần sửa mã nguồn có thể thay đổi mã được liên kết lúc chạy, cho phép chương trình chọn nhiều trạng thái chạy khác nhau, đó chính là đa hình.
Ví dụ, bạn là một người sành rượu, có tình yêu đặc biệt với rượu. Một ngày nào đó về nhà, bạn thấy trên bàn có vài ly chứa rượu trắng. Từ bên ngoài, chúng ta không thể biết đó là loại rượu gì, chỉ sau khi uống mới có thể đoán ra. Bạn uống một ly, đây là Kiếm Nam Xương; uống thêm một ly, đây là Ngũ Lương Dịch; lại uống thêm, đây là T Quỷ... Chúng ta có thể mô tả như sau:
Rượu a = Kiếm Nam Xương
Rượu b = Ngũ Lương Dịch
Rượu c = T Quỷ
...
Đây chính là biểu hiện của đa hình. Kiếm Nam Xương, Ngũ Lương Dịch, T Quỷ đều là lớp con của Rượu, chúng ta chỉ cần dùng lớp cha Rượu để tham chiếu đến các lớp con khác nhau, đó chính là đa hình - chúng ta chỉ biết được thể hiện cụ thể mà biến tham chiếu trỏ đến khi chương trình chạy.
Để hiểu đa hình, chúng ta phải hiểu "chuyển kiểu lên" là gì. Trong kế thừa, chúng ta đã giới thiệu chuyển kiểu lên, ở đây tôi sẽ giải thích thêm: Trong ví dụ uống rượu ở trên, Rượu (Wine) là lớp cha, Kiếm Nam Xương (JNC), Ngũ Lương Dịch (WLY), T Quỷ (JGJ) là lớp con. Chúng ta định nghĩa như sau:
JNC a = new JNC();
Đối với đoạn mã này, chúng ta dễ hiểu, đơn giản là tạo một thể hiện của lớp Kiếm Nam Xương! Nhưng thế này thì sao?
Wine a = new JNC();
Ở đây, chúng ta hiểu là định nghĩa một a có kiểu Wine, nó trỏ đến thể hiện của đối tượng JNC. Vì JNC kế thừa từ Wine, nên JNC có thể tự động chuyển kiểu lên thành Wine, do đó a có thể trỏ đến thể hiện của đối tượng JNC. Cách làm này có một lợi ích rất lớn, trong kế thừa chúng ta biết lớp con là mở rộng của lớp cha, nó có thể cung cấp chức năng mạnh hơn lớp cha. Nếu chúng ta định nghĩa một kiểu tham chiếu lớp cha trỏ đến lớp con, thì nó không chỉ có thể tham chiếu đến các tính chất chung của lớp cha, mà còn có thể sử dụng chức năng mạnh mẽ của lớp con.
Tuy nhiên, chuyển kiểu lên lên có một số hạn chế, đó là nó chắc chắn sẽ dẫn đến việc mất một số phương thức và thuộc tính, khiến chúng ta không thể truy cập chúng. Vì vậy, kiểu tham chiếu của lớp cha có thể gọi tất cả các thuộc tính và phương thức được định nghĩa trong lớp cha, đối với các phương thức và thuộc tính chỉ tồn tại ở lớp con, nó không thể tiếp cận được.
public class Rượu {
public void phương thức1(){
System.out.println("Phương thức của Rượu.....");
phương thức2();
}
public void phương thức2(){
System.out.println("Phương thức 2 của Rượu...");
}
}
public class KiếmNamXương extends Rượu{
/**
* @desc Lớp con nạp chồng phương thức của lớp cha
* Lớp cha không tồn tại phương thức này, sau khi chuyển kiểu lên, lớp cha không thể tham chiếu đến phương thức này
* @param a
* @return void
*/
public void phương thức1(String a){
System.out.println("Phương thức 1 của Kiếm Nam Xương...");
phương thức2();
}
/**
* Lớp con ghi đè phương thức của lớp cha
* Khi tham chiếu lớp cha trỏ đến lớp con gọi phương thức2, chắc chắn sẽ gọi phương thức này
*/
public void phương thức2(){
System.out.println("Phương thức 2 của Kiếm Nam Xương...");
}
}
public class KiểmTra {
public static void main(String[] args) {
Rượu a = new KiếmNamXương();
a.phương thức1();
}
}
-------------------------------------------------
Kết quả:
Phương thức của Rượu.....
Phương thức 2 của Kiếm Nam Xương...
Từ kết quả chạy chương trình, chúng ta thấy a.phương thức1() trước tiên chạy phương thức1() trong lớp cha Rượu, sau đó chạy phương thức2() trong lớp con KiếmNamXương.
Phân tích: Trong chương trình này, lớp con KiếmNamXương nạp chồng phương thức phương thức1() của lớp cha Rượu, ghi đè phương thức2(), và phương thức nạp chồng phương thức1(String a) với phương thức1() không phải là cùng một phương thức. Vì lớp cha không có phương thức này, sau khi chuyển kiểu lên sẽ mất phương thức này, do đó thực hiện tham chiếu kiểu Rượu của KiếmNamXương không thể tham chiếu đến phương thức phương thức1(String a). Trong khi lớp con KiếmNamXương đã ghi đè phương thức2() của lớp cha, thì tham chiếu Rượu trỏ đến KiếmNamXương sẽ gọi phương thức2() trong lớp KiếmNamXương.
Vì vậy, đối với đa hình, chúng ta có thể tóm tắt như sau:
Tham chiếu lớp cha trỏ đến lớp con do đã chuyển kiểu lên, nó chỉ có thể truy cập các phương thức và thuộc tính mà lớp cha có. Đối với các phương thức tồn tại ở lớp con nhưng không có ở lớp cha, tham chiếu này không thể sử dụng, dù là nạp chồng phương thức đó. Nếu lớp con đã ghi đè một số phương thức của lớp cha, khi gọi các phương thức này, chắc chắn sẽ sử dụng các phương thức được định nghĩa trong lớp con (kết nối động, gọi động).
Đối với lập trình hướng đối tượng, đa hình được chia thành đa hình tại thời điểm biên dịch và đa hình tại thời điểm chạy. Trong đó, đa hình tại thời điểm biên dịch là tĩnh, chủ yếu chỉ việc nạp chồng phương thức, nó phân biệt các hàm khác nhau dựa trên danh sách tham số, sau khi biên dịch sẽ trở thành hai hàm khác nhau, không thể nói đến đa hình trong lúc chạy. Trong khi đó, đa hình tại thời điểm chạy là động, nó được thực hiện thông qua ràng buộc động, chính là đa hình mà chúng ta nói.
Triển khai đa hình
2.1 Điều kiện triển khai
Như đã đề cập ban đầu, kế thừa đã tạo điều kiện cho việc triển khai đa hình. Lớp con Child kế thừa lớp cha Father, chúng ta có thể viết một tham chiếu kiểu lớp cha trỏ đến lớp con, tham chiếu này có thể xử lý đối tượng lớp Father, cũng có thể xử lý đối tượng lớp con, khi cùng một thông điệp được gửi đến đối tượng lớp con hoặc lớp cha, đối tượng đó sẽ thực hiện các hành vi khác nhau dựa trên loại tham chiếu của nó, đó chính là đa hình. Nghĩa là đa hình là cùng một thông điệp khiến các lớp khác nhau thực hiện các phản hồi khác nhau.
Java triển khai đa hình với ba điều kiện cần thiết: kế thừa, ghi đè, chuyển kiểu lên.
Kế thừa: Trong đa hình, phải tồn tại quan hệ kế thừa giữa lớp con và lớp cha.
Ghi đè: Lớp con định nghĩa lại một số phương thức của lớp cha, khi gọi các phương thức này sẽ gọi phương thức của lớp con.
Chuyển kiểu lên: Trong đa hình, cần gán tham chiếu của lớp con cho đối tượng lớp cha, chỉ như vậy tham chiếu mới có khả năng gọi phương thức của lớp cha và phương thức của lớp con.
Chỉ khi đáp ứng đủ ba điều kiện trên, chúng ta mới có thể sử dụng logic mã triển khai thống nhất trong cùng một cấu trúc kế thừa để xử lý các đối tượng khác nhau, từ đó đạt được hành vi thực hiện khác nhau.
Đối với Java, cơ chế triển khai đa hình của nó tuân theo một nguyên tắc: khi biến tham chiếu đối tượng lớp cha tham chiếu đến đối tượng lớp con, loại của đối tượng được tham chiếu chứ không phải loại của biến tham chiếu quyết định gọi phương thức thành viên nào, nhưng phương thức được gọi này phải được định nghĩa trong lớp cha, tức là phương thức bị lớp con ghi đè.
2.2 Hình thức triển khai
Trong Java có hai hình thức có thể triển khai đa hình: kế thừa và giao diện.
2.2.1 Đa hình dựa trên kế thừa
Cơ chế triển khai dựa trên kế thừa chủ yếu thể hiện ở việc lớp cha và một hoặc nhiều lớp con kế thừa lớp cha đó ghi đè một số phương thức, nhiều lớp con ghi đè cùng một phương thức có thể thể hiện các hành vi khác nhau.
public class LoạiRượu {
private String tên;
public String getTên() {
return tên;
}
public void setTên(String tên) {
this.tên = tên;
}
public LoạiRượu(){
}
public String uống(){
return "Uống " + getTên();
}
/**
* Ghi đè phương thức toString()
*/
public String toString(){
return null;
}
}
public class KiếmNamXương extends LoạiRượu{
public KiếmNamXương(){
setTên("Kiếm Nam Xương");
}
/**
* Ghi đè phương thức của lớp cha, triển khai đa hình
*/
public String uống(){
return "Uống " + getTên();
}
/**
* Ghi đè phương thức toString()
*/
public String toString(){
return "Loại rượu: " + getTên();
}
}
public class TQuỷ extends LoạiRượu{
public TQuỷ(){
setTên("T Quỷ");
}
/**
* Ghi đè phương thức của lớp cha, triển khai đa hình
*/
public String uống(){
return "Uống " + getTên();
}
/**
* Ghi đè phương thức toString()
*/
public String toString(){
return "Loại rượu: " + getTên();
}
}
public class KiểmTra {
public static void main(String[] args) {
// Định nghĩa mảng lớp cha
LoạiRượu[] cácLoạiRượu = new LoạiRượu[2];
// Định nghĩa hai lớp con
KiếmNamXương knx = new KiếmNamXương();
TQuỷ tq = new TQuỷ();
// Tham chiếu lớp cha trỏ đến đối tượng lớp con
cácLoạiRượu[0] = knx;
cácLoạiRượu[1] = tq;
for(int i = 0 ; i < 2 ; i++){
System.out.println(cácLoạiRượu[i].toString() + "--" + cácLoạiRượu[i].uống());
}
System.out.println("-------------------------------");
}
}
Kết quả:
Loại rượu: Kiếm Nam Xương--Uống Kiếm Nam Xương
Loại rượu: T Quỷ--Uống T Quỷ
-------------------------------
Trong đoạn mã trên, KiếmNamXương, TQuỷ kế thừa LoạiRượu, và ghi đè phương thức uống(), toString(), kết quả chạy chương trình là gọi phương thức trong lớp con, xuất tên của KiếmNamXương, TQuỷ, đó chính là biểu hiện của đa hình. Các đối tượng khác nhau có thể thực hiện cùng một hành vi, nhưng chúng đều cần thực hiện theo cách riêng của mình, điều này nhờ vào chuyển kiểu lên.
Chúng ta đều biết tất cả các lớp đều kế thừa từ lớp siêu Object, phương thức toString() cũng là phương thức của Object, khi chúng ta viết như thế này:
Object o = new TQuỷ();
System.out.println(o.toString());
Kết quả xuất ra sẽ là: Loại rượu: T Quỷ.
Quan hệ chuỗi kế thừa của Object, LoạiRượu, TQuỷ là: TQuỷ—>LoạiRượu—>Object. Vì vậy chúng ta có thể nói như vậy: khi phương thức được ghi đè của lớp con được gọi, chỉ có phương thức ở cuối cùng của chuỗi kế thừa đối tượng mới được gọi. Nhưng lưu ý nếu viết như thế này:
Object o = new LoạiRượu();
System.out.println(o.toString());
Kết quả xuất ra sẽ là null, vì TQuỷ không tồn tại trong chuỗi kế thừa đối tượng này.
Vì vậy, đa hình dựa trên kế thừa có thể tóm tắt như sau: Đối với kiểu tham chiếu lớp cha của lớp con, khi xử lý tham chiếu này, nó áp dụng cho tất cả các lớp con kế thừa lớp cha, thể hiện khác nhau của đối tượng lớp con, triển khai phương thức cũng khác nhau, hành vi sinh ra từ thực hiện cùng một hành động cũng khác nhau.
Nếu lớp cha là lớp trừu tượng, thì lớp con phải triển khai tất cả các phương thức trừu tượng của lớp cha, như vậy tất cả các lớp con của lớp cha nhất định có tồn tại giao diện bên ngoài thống nhất, nhưng triển khai cụ thể bên trong có thể khác nhau. Như vậy chúng ta có thể sử dụng giao diện thống nhất được cung cấp bởi lớp cấp trên để xử lý phương thức của tầng này.
2.2.2 Đa hình dựa trên giao diện
Kế thừa thể hiện qua việc một số lớp con khác nhau cùng ghi đè một phương thức của lớp cha, thì đa hình dựa trên giao diện thể hiện qua việc một số lớp khác nhau cùng triển khai giao diện và ghi đè cùng một phương thức trong giao diện.
Trong đa hình của giao diện, tham chiếu trỏ đến giao diện nhất định phải là thể hiện của một lớp đã triển khai giao diện đó, trong quá trình chạy, sẽ thực hiện phương thức tương ứng theo loại thực tế của đối tượng tham chiếu.
Kế thừa đều là kế thừa đơn, chỉ có thể cung cấp giao diện dịch vụ nhất quán cho một nhóm lớp liên quan. Nhưng giao diện có thể kế thừa và triển khai nhiều, nó có thể sử dụng một nhóm giao diện liên quan hoặc không liên quan để kết hợp và mở rộng, có thể cung cấp giao diện dịch vụ nhất quán bên ngoài. Vì vậy nó có tính linh hoạt tốt hơn so với kế thừa.
Ví dụ kinh điển
Qua phần trình bày trên, có thể nói đã hiểu rõ về đa hình. Bây giờ hãy cùng xem một ví dụ. Đây là ví dụ kinh điển về đa hình, trích từ: http://blog.csdn.net/thinkGhoster/archive/2008/04/19/2307001.aspx.
public class LớpA {
public String hiển thị(LớpD obj) {
return ("A và D");
}
public String hiển thị(LớpA obj) {
return ("A và A");
}
}
public class LớpB extends LớpA{
public String hiển thị(LớpB obj){
return ("B và B");
}
public String hiển thị(LớpA obj){
return ("B và A");
}
}
public class LớpC extends LớpB{
}
public class LớpD extends LớpB{
}
public class KiểmTra {
public static void main(String[] args) {
LớpA a1 = new LớpA();
LớpA a2 = new LớpB();
LớpB b = new LớpB();
LớpC c = new LớpC();
LớpD d = new LớpD();
System.out.println("1--" + a1.hiển thị(b));
System.out.println("2--" + a1.hiển thị(c));
System.out.println("3--" + a1.hiển thị(d));
System.out.println("4--" + a2.hiển thị(b));
System.out.println("5--" + a2.hiển thị(c));
System.out.println("6--" + a2.hiển thị(d));
System.out.println("7--" + b.hiển thị(b));
System.out.println("8--" + b.hiển thị(c));
System.out.println("9--" + b.hiển thị(d));
}
}
Kết quả chạy:
1--A và A
2--A và A
3--A và D
4--B và A
5--B và A
6--A và D
7--B và B
8--B và B
9--A và D
Ở đây, kết quả 1, 2, 3 còn dễ hiểu, từ 4 bắt đầu đã khó hiểu, đối với 4 tại sao kết quả xuất ra không phải là "B và B"?
Đầu tiên chúng ta xem một câu: Khi biến tham chiếu đối tượng lớp cha tham chiếu đến đối tượng lớp con, loại của đối tượng được tham chiếu chứ không phải loại của biến tham chiếu quyết định gọi phương thức thành viên nào, nhưng phương thức được gọi này phải được định nghĩa trong lớp cha, tức là phương thức bị lớp con ghi đè. Câu này đã tóm tắt về đa hình. Thực tế, trong chuỗi kế thừa, việc gọi phương thức của đối tượng tồn tại một thứ tự ưu tiên: this.hiển thị(O), super.hiển thị(O), this.hiển thị((super)O), super.hiển thị((super)O).
Phân tích:
Từ chương trình trên, chúng ta có thể thấy LớpA, LớpB, LớpC, LớpD có quan hệ như sau.
Đầu tiên chúng ta phân tích 5, a2.hiển thị(c), a2 là biến tham chiếu kiểu LớpA, nên this đại diện cho LớpA, a2.hiển thị(c), nó tìm trong lớp A không thấy, sau đó tìm trong lớp siêu của A (ngoại trừ Object), nên nhảy đến cấp thứ ba, tức là this.hiển thị((super)O), lớp siêu của C có B, A, nên (super)O là B, A, this vẫn là A, ở đây tìm thấy phương thức hiển thị(LớpA obj) trong lớp A, đồng thời vì a2 là tham chiếu của lớp B và lớp B đã ghi đè phương thức hiển thị(LớpA obj), nên cuối cùng sẽ gọi phương thức hiển thị(LớpA obj) của lớp con B, kết quả cũng là B và A.
Theo cùng một phương pháp, tôi cũng có thể xác nhận các câu trả lời khác.
Phương thức đã tìm thấy nhưng ở đây chúng ta vẫn còn một chút nghi ngờ, chúng ta vẫn xem câu này: Khi biến tham chiếu đối tượng lớp cha tham chiếu đến đối tượng lớp con, loại của đối tượng được tham chiếu chứ không phải loại của biến tham chiếu quyết định gọi phương thức thành viên nào, nhưng phương thức được gọi này phải được định nghĩa trong lớp cha, tức là phương thức bị lớp con ghi đè. Chúng ta dùng một ví dụ để giải thích ý nghĩa của câu này: a2.hiển thị(b);
Ở đây, a2 là biến tham chiếu, kiểu LớpA, nó tham chiếu đến đối tượng B, theo ý nghĩa của câu trên là do B quyết định gọi phương thức nào, nên a2.hiển thị(b) nên gọi phương thức hiển thị(LớpB obj) trong B, kết quả sinh ra nên là "B và B", nhưng tại sao lại khác kết quả chạy trước? Ở đây chúng ta đã bỏ qua câu sau "nhưng phương thức được gọi này phải được định nghĩa trong lớp cha", vậy phương thức hiển thị(LớpB obj) có tồn tại trong lớp A không? Hoàn toàn không tồn tại! Vậy câu này ở đây không áp dụng? Chẳng phải là câu này sai sao? Không hề! Thực ra câu này còn hàm ý: nó vẫn phải xác nhận theo thứ tự ưu tiên gọi phương thức trong chuỗi kế thừa. Vì vậy nó mới tìm thấy phương thức hiển thị(LớpA obj) trong lớp A, đồng thời vì B đã ghi đè phương thức này nên mới gọi phương thức của lớp B, nếu không sẽ gọi phương thức của lớp A.
Vì vậy, cơ chế đa hình tuân theo nguyên tắc có thể tóm tắt như sau: Khi biến tham chiếu đối tượng lớp cha tham chiếu đến đối tượng lớp con, loại của đối tượng được tham chiếu chứ không phải loại của biến tham chiếu quyết định gọi phương thức thành viên nào, nhưng phương thức được gọi này phải được định nghĩa trong lớp cha, tức là phương thức bị lớp con ghi đè, nhưng nó vẫn phải xác nhận theo thứ tự ưu tiên gọi phương thức trong chuỗi kế thừa, thứ tự ưu tiên này là: this.hiển thị(O), super.hiển thị(O), this.hiển thị((super)O), super.hiển thị((super)O).
Tài liệu tham khảo: http://blog.csdn.net/thinkGhoster/archive/2008/04/19/2307001.aspx.
Thư viện Baidu: http://wenku.baidu.com/view/73f66f92daef5ef7ba0d3c03.html