1. Mở đầu
Đa hình — là một trong ba trụ cột của lập trình hướng đối tượng (bên cạnh kế thừa và đóng gói). Về mặt từ nguyên, nó đến từ tiếng Hy Lạp “poly” (nhiều) và “morph” (hình dạng). Trong lập trình, điều đó có nghĩa: một interface — nhiều triển khai.
Định nghĩa
Đa hình — là khả năng các đối tượng thuộc những lớp khác nhau phản ứng khác nhau trước cùng một thông điệp (lời gọi phương thức).
Nghĩa là, nếu bạn có phương thức makeSound(), bạn có thể gọi nó trên bất kỳ con vật nào, nhưng mèo sẽ kêu meo, chó sẽ sủa gâu, còn bò sẽ kêu ò. Đối với lập trình viên — đó chỉ là lời gọi animal.makeSound(), còn điều thực sự xảy ra phụ thuộc vào đối tượng cụ thể đứng sau biến này.
Ví dụ đời thực
Hãy tưởng tượng ở nhà bạn có một chiếc điều khiển TV, và với chính chiếc điều khiển đó bạn có thể điều khiển loa, máy chiếu và thậm chí cả máy pha cà phê. Bạn bấm nút “Bật” — turnOn(), và mỗi thiết bị phản ứng theo cách riêng. Quan trọng là — tất cả đều có “nút” bật, nhưng cách triển khai khác nhau.
Ví dụ bằng Java
class Animal {
void makeSound() {
System.out.println("Một âm thanh nào đó...");
}
}
class Dog extends Animal {
@Override
void makeSound() {
System.out.println("Gâu!");
}
}
class Cat extends Animal {
@Override
void makeSound() {
System.out.println("Meo!");
}
}
class Cow extends Animal {
@Override
void makeSound() {
System.out.println("Ò ò!");
}
}
Giờ chúng ta có thể làm như sau:
Animal animal1 = new Dog();
Animal animal2 = new Cat();
Animal animal3 = new Cow();
animal1.makeSound(); // Gâu!
animal2.makeSound(); // Meo!
animal3.makeSound(); // Ò ò!
Lưu ý: tất cả biến đều có kiểu Animal, nhưng kết quả lời gọi phụ thuộc vào kiểu thực tế của đối tượng.
2. Các loại đa hình
Trong Java (và đa số ngôn ngữ hướng đối tượng), có hai dạng đa hình chính:
Đa hình ở thời điểm biên dịch (tĩnh) — quá tải phương thức (overloading)
Đây là khi trong cùng một lớp có nhiều phương thức cùng tên nhưng khác tham số. Trình biên dịch tự quyết định gọi phương thức nào dựa trên các đối số truyền vào.
Ví dụ (nhá hàng — chi tiết hơn ở bài giảng kế tiếp):
class Printer {
void print(int x) {
System.out.println("Số: " + x);
}
void print(String s) {
System.out.println("Chuỗi: " + s);
}
}
Đa hình khi chạy (động) — ghi đè phương thức (overriding)
Đây là khi một phương thức được định nghĩa ở lớp cơ sở và sau đó được ghi đè ở các lớp con. Phương thức nào sẽ được gọi — được quyết định lúc runtime, tùy thuộc vào kiểu thực tế của đối tượng.
Ví dụ — xem phần trên với các loài vật.
3. Vì sao cần đa hình?
Đa hình — không chỉ là một từ hoa mỹ để phỏng vấn. Đây là công cụ giúp mã của bạn linh hoạt, dễ mở rộng và dễ bảo trì.
Tính tổng quát của mã
Bạn có thể viết mã làm việc với các đối tượng kiểu cơ sở mà không cần bận tâm đến chi tiết triển khai của chúng. Ví dụ, nếu bạn có một danh sách các con vật, bạn có thể duyệt qua và gọi makeSound() trên từng con, không cần nghĩ đó là mèo hay chó.
Animal[] animals = { new Dog(), new Cat(), new Cow() };
for (Animal animal : animals) {
animal.makeSound(); // Mỗi lần sẽ gọi phương thức "đúng"
}
Dễ mở rộng
Nếu ngày mai sếp bảo: “Hãy thêm con vẹt đi!”, bạn chỉ cần viết lớp mới Parrot extends Animal và thêm nó vào mảng. Phần còn lại của mã giữ nguyên. Đây là mở cho việc mở rộng và đóng cho việc sửa đổi (nguyên tắc OCP trong SOLID).
Đơn giản hóa kiến trúc
Bạn có thể xây dựng các hệ thống phức tạp, nơi các phần tương tác với nhau thông qua các abstraction (lớp cơ sở hoặc interface), mà không cần bận tâm đến các triển khai cụ thể. Điều này tiết kiệm thời gian, thần kinh và cả cà phê.
4. Các khái niệm chính: kiểu tham chiếu và kiểu thực tế
Kiểu tham chiếu của biến
Khi bạn viết Animal animal = new Dog();, biến animal có kiểu tham chiếu là Animal, tức là trình biên dịch “cho rằng” đây là một con vật và chỉ cho phép các phương thức được khai báo trong lớp Animal.
Kiểu thực tế (thực sự) của đối tượng
Nhưng trong bộ nhớ, thực tế bạn có một đối tượng kiểu Dog. Chính nó quyết định phương thức nào sẽ được gọi khi truy cập makeSound().
Minh họa
Animal animal = new Dog();
animal.makeSound(); // Sẽ gọi Dog.makeSound(), chứ không phải Animal.makeSound()
Quan trọng! Thông qua tham chiếu kiểu cơ sở (Animal) bạn sẽ không thể gọi các phương thức chỉ có ở Dog, nếu chúng không được khai báo trong lớp cơ sở.
Ràng buộc muộn (động)
Đây là “phép màu” diễn ra khi chạy: khi bạn gọi phương thức thông qua tham chiếu kiểu cơ sở, JVM nhìn vào kiểu thực tế của đối tượng và gọi triển khai “đúng”. Đó chính là đa hình trong hành động.
5. Ví dụ thực tiễn: đa hình trong ứng dụng
Hãy tiếp tục phát triển ứng dụng học tập của chúng ta. Giả sử ta viết một mô phỏng sở thú đơn giản. Có lớp cơ sở Animal và một vài lớp con của nó. Ta muốn tất cả động vật đều có thể “phát ra âm thanh”, nhưng không muốn mỗi lần phải viết mã riêng cho từng loại.
Bước 1: Lớp cơ sở và các lớp con
class Animal {
void makeSound() {
System.out.println("Một âm thanh nào đó...");
}
}
class Dog extends Animal {
@Override
void makeSound() {
System.out.println("Gâu!");
}
}
class Cat extends Animal {
@Override
void makeSound() {
System.out.println("Meo!");
}
}
Bước 2: Mảng các con vật
Animal[] zoo = { new Dog(), new Cat(), new Animal() };
Bước 3: Duyệt và gọi phương thức
for (Animal animal : zoo) {
animal.makeSound();
}
Kết quả chạy:
Gâu!
Meo!
Một âm thanh nào đó...
Lưu ý: chúng ta không biết trước chính xác ai nằm trong mảng — nhưng chương trình tự quyết định và gọi đúng phương thức.
6. Sơ đồ minh họa đa hình
. Animal (makeSound)
/ \
Dog Cat
(makeSound) (makeSound)
Animal animal = new Dog();
animal.makeSound(); // --> Dog.makeSound()
Animal animal = new Cat();
animal.makeSound(); // --> Cat.makeSound()
7. Ví dụ nữa: đa hình trong một bài toán thực tế
Giả sử bạn viết chương trình quản lý nhân viên của công ty. Bạn có lớp cơ sở Employee và hai lớp con: Manager và Developer. Tất cả nhân viên đều có thể làm việc — work(), nhưng cách làm khác nhau.
class Employee {
void work() {
System.out.println("Nhân viên đang làm việc.");
}
}
class Manager extends Employee {
@Override
void work() {
System.out.println("Quản lý đang họp.");
}
}
class Developer extends Employee {
@Override
void work() {
System.out.println("Lập trình viên đang viết mã.");
}
}
Giờ bạn có thể làm như sau:
Employee[] staff = { new Manager(), new Developer(), new Employee() };
for (Employee emp : staff) {
emp.work();
}
Kết quả:
Quản lý đang họp.
Lập trình viên đang viết mã.
Nhân viên đang làm việc.
8. Khi đa hình KHÔNG hoạt động
Đa hình chỉ hoạt động cho các phương thức được khai báo trong lớp cơ sở. Nếu một lớp con có phương thức riêng của nó, bạn sẽ không nhìn thấy nó thông qua tham chiếu kiểu cơ sở.
class Dog extends Animal {
void fetchStick() {
System.out.println("Chó mang que lại!");
}
}
Animal animal = new Dog();
// animal.fetchStick(); // Lỗi biên dịch! Không thể thấy phương thức này qua Animal
Để gọi phương thức đặc thù, cần ép kiểu biến về kiểu phù hợp:
if (animal instanceof Dog) {
((Dog) animal).fetchStick();
}
Nhưng đó là câu chuyện khác — điều chính: qua đa hình chỉ truy cập được các phương thức được khai báo trong lớp cơ sở.
9. Những lỗi thường gặp khi làm việc với đa hình
Lỗi số 1: Kỳ vọng rằng thông qua tham chiếu kiểu cơ sở sẽ thấy tất cả phương thức của lớp con. Thực tế chỉ thấy những phương thức được khai báo trong lớp cơ sở.
Lỗi số 2: Không dùng annotation @Override khi ghi đè phương thức. Thiếu nó, bạn có thể vô tình viết sai chữ ký phương thức, và khi đó đa hình sẽ không hoạt động (phương thức lớp cơ sở không bị ghi đè).
Lỗi số 3: Cố gắng gọi phương thức đặc thù của lớp con mà không ép kiểu. Trình biên dịch sẽ không cho phép, vì nó không biết ai đang “ngồi” sau tham chiếu kiểu cơ sở.
Lỗi số 4: Nhầm lẫn giữa quá tải (overloading) và ghi đè (overriding). Quá tải — là nhiều phương thức cùng tên nhưng tham số khác nhau trong cùng một lớp. Ghi đè — là thay đổi hành vi của phương thức trong lớp con.
GO TO FULL VERSION