1. Giới thiệu
Hãy tưởng tượng thế này: ở nhà bạn có tivi, dàn nhạc, điều hòa và bóng đèn thông minh. Mỗi cái lại có một remote riêng. Muốn bật tivi thì cầm remote tivi, bấm nút "Bật". Muốn bật dàn nhạc thì lại cầm remote dàn nhạc, cũng bấm nút "Bật". Bạn thấy chưa? Quá lộn xộn đúng không?
Thế nếu bạn có một remote đa năng thì sao? Bạn chỉ cần cầm nó, bấm nút "Bật", và nó sẽ tự biết thiết bị nào cần bật, gửi đúng lệnh cho thiết bị đó. Remote này thậm chí không cần biết cách cụ thể để bật tivi hay cách cụ thể để bật dàn nhạc. Nó chỉ biết là tất cả các thiết bị đều có "chức năng bật".
Đó chính là ví dụ đời thường của đa hình trong lập trình!
Đa hình
Từ "đa hình" (polymorphism) xuất phát từ tiếng Hy Lạp: poly (nhiều) và morph (hình dạng). Nghĩa đen là "nhiều hình dạng". Trong OOP, nó là khả năng một đối tượng có thể có nhiều hình dạng khác nhau, hay nói chính xác hơn, một phương thức có thể hành xử khác nhau tùy vào loại đối tượng mà nó được gọi trên đó.
Trong C#, đa hình chủ yếu đạt được nhờ kế thừa và dùng các phương thức virtual và override.
Ý tưởng cốt lõi của đa hình là ép kiểu lên (upcasting). Nó là gì nhỉ?
Quay lại ví dụ hệ phân cấp Animal → Dog, Cat. Chúng ta biết rằng chó là động vật. Mèo là động vật. Đây là quan hệ "is-a" — nền tảng của kế thừa.
public class Animal
{
public virtual void MakeSound()
{
Console.WriteLine("Một âm thanh nào đó...");
}
}
public class Dog : Animal
{
public override void MakeSound()
{
Console.WriteLine("Gâu-gâu!");
}
}
public class Cat : Animal
{
public override void MakeSound()
{
Console.WriteLine("Meo!");
}
}
Nhờ quan hệ "is-a", ta có thể gán đối tượng của lớp con cho biến kiểu lớp cha. Đó gọi là ép kiểu lên (upcasting), vì ta "nâng" đối tượng lên kiểu tổng quát hơn. Compiler C# cho phép làm điều này tự động:
// Chúng ta có một chú chó cụ thể
Dog myDog = new Dog();
// Có thể gán đối tượng Dog cho biến kiểu Animal!
// Đây chính là ép kiểu lên (upcasting).
// Bên phải là Dog cụ thể, bên trái là Animal tổng quát hơn.
Animal generalAnimal = myDog;
// Và ta có thể gọi MakeSound()!
generalAnimal.MakeSound(); // In ra "Gâu-gâu!" (Không phải "Một âm thanh nào đó...", mà là "Gâu-gâu!")
Chuyện gì đã xảy ra ở đây?
- Khi viết Animal generalAnimal = myDog;, bạn không tạo ra động vật mới. Bạn chỉ lấy đối tượng myDog (thực chất là Dog) và bỏ nó vào "hộp" dán nhãn Animal.
- Ở giai đoạn biên dịch (khi code chuyển thành bytecode), biến generalAnimal có kiểu Animal. Nên compiler "biết" nó chỉ gọi được các method và property có trong lớp Animal.
- Nhưng điều thú vị là: khi chạy chương trình (ở runtime), khi gọi generalAnimal.MakeSound(), môi trường .NET không nhìn vào kiểu biến (Animal), mà nhìn vào kiểu thực tế của đối tượng bên trong (Dog)! Vì method MakeSound() được đánh dấu virtual trong Animal và override trong Dog, nên nó gọi đúng implementation của Dog.
Chính cái "ma thuật" này, khi hành vi method được quyết định bởi kiểu thực tế của object lúc runtime, chứ không phải kiểu biến, gọi là dynamic dispatch hay đa hình thời gian chạy.
Hãy tưởng tượng một cái hộp: bên ngoài ghi "Trái cây" (kiểu biến Animal). Bên trong bạn bỏ quả táo (object Dog). Khi bạn bảo "Trái cây, phát ra âm thanh!" (gọi MakeSound()), hộp biết bên trong là táo nên phát ra tiếng "crunch", chứ không phải tiếng "trái cây" chung chung.
2. Demo đa hình thực tế
Cách tốt nhất để hiểu đa hình là xem nó hoạt động, nhất là khi bạn có cả loạt object khác nhau.
Giả sử mình mở rộng app "Sở thú mini". Bạn làm việc ở phòng khám thú y, có nhiều loài động vật đến khám. Mỗi loài cần khám theo cách riêng, nhưng code gọi khám thì phải chung.
Đầu tiên, thêm method Examine() virtual vào lớp cha Animal và các lớp con Dog, Cat:
public class Animal
{
public string Name { get; set; }
public Animal(string name) { Name = name; }
public virtual void MakeSound() { Console.WriteLine($"{Name} phát ra một âm thanh nào đó..."); }
public virtual void Examine()
{
Console.WriteLine($"Khám {Name}:");
Console.WriteLine(" - Kiểm tra hô hấp.");
Console.WriteLine(" - Đo nhiệt độ.");
}
}
public class Dog : Animal
{
public Dog(string name) : base(name) { }
public override void MakeSound() { Console.WriteLine($"{Name} nói: Gâu!"); }
public override void Examine()
{
base.Examine();
Console.WriteLine(" - Kiểm tra răng.");
Console.WriteLine(" - Tiêm phòng dại.");
}
}
public class Cat : Animal
{
public Cat(string name) : base(name) { }
public override void MakeSound() { Console.WriteLine($"{Name} nói: Meo!"); }
public override void Examine()
{
base.Examine();
Console.WriteLine(" - Kiểm tra móng.");
Console.WriteLine(" - Thuốc tẩy giun.");
}
}
Giờ ta có các method Examine() chuyên biệt cho chó và mèo. Lưu ý base.Examine(); trong method override. Nó giúp thực hiện các bước khám chung (từ lớp cha Animal), rồi thêm phần riêng cho từng loài. Rất tiện!
Giờ tưởng tượng phòng khám thú y của bạn. Có danh sách động vật đến khám:
class Program
{
static void Main()
{
Animal[] animals = {
new Dog("Rex"),
new Cat("Miu"),
new Animal("Thỏ")
};
Console.WriteLine("--- Khám động vật ---");
foreach (Animal animal in animals)
{
animal.Examine();
Console.WriteLine();
}
Console.WriteLine("--- Giờ phát tiếng ---");
foreach (Animal animal in animals)
animal.MakeSound();
}
}
Kết quả:
--- Khám động vật ---
Khám Rex:
- Kiểm tra hô hấp.
- Đo nhiệt độ.
- Kiểm tra răng.
- Tiêm phòng dại.
Khám Miu:
- Kiểm tra hô hấp.
- Đo nhiệt độ.
- Kiểm tra móng.
- Thuốc tẩy giun.
Khám Thỏ:
- Kiểm tra hô hấp.
- Đo nhiệt độ.
--- Giờ phát tiếng ---
Rex nói: Gâu!
Miu nói: Meo!
Thỏ phát ra một âm thanh nào đó...
Đó chính là sức mạnh của đa hình! Bạn chỉ cần một vòng lặp foreach (Animal animal in animals), mỗi lần gọi cùng một method animal.Examine() (hoặc animal.MakeSound()). Nhưng mỗi lần lại chạy đúng implementation riêng cho chó, mèo hay động vật chung. Code vừa đơn giản, vừa sạch, vừa tổng quát, còn chi tiết thì ẩn trong từng class.
Ngày mai nếu có thêm hamster Hamster, chỉ cần tạo class Hamster : Animal, override Examine(), là nó tự động hoạt động trong hệ thống khám chung mà không phải sửa vòng lặp foreach! Đó mới là sức mạnh thực sự của đa hình.
3. Vai trò của đa hình trong OOP
Đa hình không chỉ là một từ ngầu hay trick code. Nó là nguyên lý nền tảng giúp chương trình của bạn linh hoạt, dễ mở rộng và dễ bảo trì. Cùng xem nó đóng vai trò gì nhé:
Linh hoạt và mở rộng (Open/Closed Principle)
Đây có lẽ là lợi ích lớn nhất. Đa hình cho phép bạn viết code mở để mở rộng, đóng để chỉnh sửa. Nghĩa là sao?
- Mở để mở rộng: Bạn có thể thêm loài động vật mới (ví dụ Bird, Fish, Hamster) chỉ bằng cách tạo class mới kế thừa Animal và override các method cần thiết.
- Đóng để chỉnh sửa: Bạn không cần sửa code cũ đang làm việc với Animal[]. Vòng lặp foreach gọi animal.Examine() vẫn giữ nguyên, bất kể bạn thêm bao nhiêu loài mới.
Hãy tưởng tượng nếu không có đa hình, bạn sẽ phải viết và sửa hàng đống if-else if hoặc switch mỗi khi có loài mới! Đúng là ác mộng.
Đơn giản hóa code
Đa hình giúp code làm việc với collection các object khác loại nhưng cùng họ trở nên cực kỳ đơn giản. Thay vì phải kiểm tra từng loại object rồi gọi method riêng, bạn chỉ cần gọi method chung của lớp cha, hệ thống tự biết gọi đúng implementation.
Giống như bạn có một nút "Mở cửa" cho nhiều loại cửa: cửa thường, cửa trượt, cửa xoay. Bạn không cần biết phải kéo, đẩy hay xoay — chỉ cần "mở", còn cửa tự xử lý.
Trừu tượng hóa
Đa hình gắn liền với trừu tượng hóa, một trụ cột khác của OOP mà mình sẽ nói kỹ ở bài sau. Nó giúp bạn tập trung vào "cái gì" object làm (ví dụ "phát ra âm thanh", "được khám") thay vì "làm như thế nào". Bạn làm việc với ý tưởng trừu tượng "động vật", không phải "chó" hay "mèo" cụ thể. Nhờ đó code của bạn ở mức cao, sạch sẽ, không phụ thuộc chi tiết cài đặt thấp.
Tái sử dụng code
Kế thừa giúp tái sử dụng code bằng cách dùng lại property và method của lớp cha, còn đa hình làm điều này mạnh hơn nữa. Bạn có thể tạo thuật toán và cấu trúc dữ liệu tổng quát (ví dụ Animal[]), thao tác với bất kỳ object nào kế thừa từ lớp cha mà không phải lặp lại logic cho từng loại con.
Nói ngắn gọn, đa hình giúp code của bạn "pro" hơn, sẵn sàng cho thay đổi — điều cực kỳ quan trọng khi làm dự án thực tế, nơi yêu cầu thay đổi liên tục.
4. Một số lưu ý hay ho
Sơ đồ đa hình
classDiagram
class Animal {
+MakeSound()
}
class Dog {
+MakeSound()
}
class Cat {
+MakeSound()
}
class Parrot {
+MakeSound()
}
Animal <|-- Dog
Animal <|-- Cat
Animal <|-- Parrot
Khi gọi MakeSound() qua biến kiểu Animal — sẽ gọi method của class mà object thực sự nằm trong biến đó.
Dùng đa hình với mảng và collection
Rất thường gặp là bạn cần duyệt qua collection các entity khác loại và "chạy" cùng một logic cho tất cả.
// Trong app luyện tập chính:
Animal[] zoo = { new Dog("Sharik"), new Cat("Barsik"), new Parrot("Kesha") };
foreach (Animal animal in zoo)
{
animal.MakeSound();
}
Trong dự án thực tế, kiểu này dùng để xử lý event, vẽ lên màn hình (mỗi hình vẽ khác nhau), xử lý thanh toán (nhiều loại thẻ và dịch vụ khác nhau).
Đa hình hoạt động thế nào
| Kiểu object | Kiểu biến | Method nào được gọi? |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
Quan trọng nhất — method phải là virtual hoặc abstract ở lớp cha và override ở lớp con!
5. Lỗi thường gặp khi dùng đa hình
Thực tế sinh viên hay mắc mấy lỗi này:
- Không khai báo method virtual ở lớp cha — và kết quả là luôn chỉ có một kiểu hành vi, không có đa hình.
- Bị nhầm: dùng class nào cho biến? Hãy nhớ: biến có kiểu (ví dụ Animal), còn object bạn gán vào là instance (ví dụ new Dog()).
- Cố dùng các thành phần mới không có ở lớp cha qua biến kiểu cha. Ví dụ:
Animal pet = new Dog();
pet.Bark(); // Lỗi! Animal không có Bark()
Giải quyết thế nào? Nếu thực sự cần, hãy ép kiểu, nhưng tốt nhất là thiết kế code chỉ dùng các method có ở lớp cha thôi.
GO TO FULL VERSION