1. Polymorphism không phải lúc nào cũng là phép màu
Nếu nhìn vào mấy ví dụ cơ bản, polymorphism trong C# trông như là chân lý tuyệt đối: kế thừa, override, gọi qua base type — và mọi thứ chạy ngon lành. Nhưng thực tế thì có nhiều chi tiết nhỏ. Cùng xem kỹ hơn nhé.
Ẩn method và từ khóa new
Giả sử bạn có một class cha và một class con, mỗi cái đều có method cùng tên, nhưng không dùng từ khóa override. Nếu ở class con bạn định nghĩa lại method đó mà không có override, thì nó sẽ ẩn method của class cha, chứ không phải override. Compiler sẽ cảnh báo ngay và gợi ý bạn nên chỉ rõ new:
class Animal
{
public void Speak()
{
Console.WriteLine("Động vật phát ra âm thanh.");
}
}
class Cat : Animal
{
public new void Speak()
{
Console.WriteLine("Meo meo!");
}
}
// Sử dụng
Animal animal = new Cat();
animal.Speak(); // Sẽ in ra: "Động vật phát ra âm thanh."
Wow! Dù bạn tạo object kiểu Cat và gán vào biến kiểu Animal, thì method gốc của class cha vẫn được gọi. Tại sao? Vì method đó không được khai báo là virtual! Bẫy số một: nếu muốn dùng polymorphism, đừng quên từ khóa virtual và override. Dùng new chỉ khi bạn thật sự muốn ẩn method (mà thực ra rất hiếm khi cần).
Gọi constructor và polymorphism
Một điều nữa không rõ ràng: constructor không phải là virtual. Nếu bạn khai báo constructor ở class cha, rồi ở class con cũng có constructor riêng, thì chúng không polymorphic đâu. Ví dụ:
class Animal
{
public Animal()
{
Console.WriteLine("Constructor Animal");
}
}
class Cat : Animal
{
public Cat()
{
Console.WriteLine("Constructor Cat");
}
}
// Sử dụng
Animal animal = new Cat();
// Sẽ in ra:
// Constructor Animal
// Constructor Cat
Nhưng nếu bạn gọi method từ constructor của class cha mà method đó có thể bị override ở class con, kết quả có thể bất ngờ — virtual method sẽ được gọi trước khi class con được khởi tạo xong! Vì vậy, đừng gọi virtual/abstract method trong constructor.
Vấn đề "vỡ" encapsulation khi override
Virtual method rất hay, nhưng nếu bạn nghĩ rằng một method ở class cha sẽ luôn chạy đúng ý bạn, rồi sau đó class con override và làm gì đó nguy hiểm, thì có thể xuất hiện bug khó lường.
class Animal
{
public virtual void Eat()
{
Console.WriteLine("Động vật ăn.");
}
public void Live()
{
Eat(); // Có thể gọi bất kỳ phiên bản override nào!
}
}
class Cat : Animal
{
public override void Eat()
{
Console.WriteLine("Mèo ăn cá.");
}
}
Animal a = new Cat();
a.Live(); // Sẽ in ra "Mèo ăn cá."
Nếu ở class cha Animal method Eat() in ra "động vật ăn", rồi ở class con override Eat() làm gì đó nguy hiểm, thì có thể phá hỏng logic của cả class. Đây gọi là vi phạm nguyên lý thay thế Liskov (Liskov Substitution Principle, LSP). Khi thiết kế, luôn nghĩ xem hành vi của class con có còn hợp lý so với class cha không nhé.
Casting và vấn đề ép kiểu
Polymorphism cho phép bạn gom nhiều object khác nhau vào cùng một "đống": ví dụ, một list kiểu cha, trong đó có cả chó lẫn mèo (đều kế thừa Animal). Nhưng nếu bạn muốn gọi gì đó đặc biệt:
List<Animal> pets = new List<Animal> { new Cat(), new Dog() };
foreach (var pet in pets)
{
if (pet is Cat cat)
{
cat.Purr();
}
}
Nếu quên kiểm tra kiểu và ép kiểu bừa, bạn sẽ gặp lỗi InvalidCastException. Đôi khi điều này dẫn đến quá nhiều kiểm tra kiểu, code rối và cho thấy thiết kế của bạn cần xem lại.
2. Vấn đề với abstraction
Abstraction là công cụ tuyệt vời để đơn giản hóa việc dùng object và hạn chế truy cập vào trạng thái bên trong. Nhưng cũng có nhiều cạm bẫy!
Lạm dụng abstraction (Over-Abstraction)
Nhiều dev mới (và cả dev lâu năm) quá mê "OOP chuẩn" nên tạo ra cả đống class cha, interface và layer abstract chồng chất. Cuối cùng chính tác giả cũng không hiểu nó chạy thế nào.
interface IAnimal
{
void Speak();
}
abstract class Feline : IAnimal
{
public abstract void Speak();
}
class Cat : Feline
{
public override void Speak()
{
Console.WriteLine("Meo meo!");
}
}
Bạn thấy không, tạo abstract class trung gian mà chẳng thêm gì mới thì để làm gì? Abstraction vì abstraction chỉ làm code khó bảo trì và rối rắm.
Hierarchy không hợp lý
Hãy nghĩ xem nếu bạn đưa một hành động lên tận đỉnh hierarchy, nhưng thực ra không phải ai cũng làm được:
abstract class Animal
{
public abstract void Fly();
}
class Cat : Animal
{
public override void Fly()
{
throw new NotImplementedException("Mèo không biết bay!");
}
}
Bạn sẽ phải thêm method "giả" ném exception ở class con, hoặc chấp nhận interface của bạn không phản ánh thực tế. Đây là anti-pattern "hierarchy sai". Trong trường hợp này, nên tách method đó ra interface riêng (ví dụ IFlyable).
Mẹo: đừng cố tạo abstraction "dùng cho mọi trường hợp".
Vấn đề với abstract class và thay đổi API
Khi abstract class đã lên production và có nhiều class kế thừa, mọi thay đổi đều rất nguy hiểm. Thêm một abstract method mới là tất cả class con phải implement — không thì code không compile được. Điều này làm việc bảo trì library và public API rất khó.
Đó là lý do có Default Interface Methods (xem bài giảng 116): cho phép mở rộng interface mà không phải sửa hết code cũ.
Vi phạm encapsulation khi abstraction
Khi bạn làm class thành abstract, thường phải khai báo member là protected để class con truy cập được. Điều này dễ làm lộ logic bên trong mà lẽ ra nên giấu đi. Kết quả là class con có thể can thiệp vào dữ liệu và thao tác mà có thể phá vỡ tính toàn vẹn của class cha.
3. Tình huống lỗi thực tế
Đừng nghĩ mấy lỗi này chỉ có trong bài tập nhé, ngoài đời dev kỳ cựu cũng dính như thường.
Ví dụ với method không khai báo virtual
Giả sử bạn mở rộng app học logging (xem Ngày 24). Có class logger cơ bản:
class BaseLogger
{
public void Log(string message)
{
Console.WriteLine(message);
}
}
class FileLogger : BaseLogger
{
public void Log(string message)
{
// Ghi vào file
Console.WriteLine("Vào file: " + message);
}
}
// Sử dụng:
BaseLogger logger = new FileLogger();
logger.Log("Hello!"); // Mong đợi: "Vào file: Hello!", thực tế: "Hello!"
Dev viết FileLogger tưởng là override method, nhưng quên thêm override và method ở class cha không phải virtual. Kết quả là gọi version của class cha.
Khuyến nghị: luôn đánh dấu method muốn override là virtual ở class cha và override ở class con.
Ví dụ abstraction sai: "Động vật linh hoạt"
Tiếp tục chủ đề động vật! Tạo interface IFlyable để không bắt mọi động vật phải implement method Fly:
interface IFlyable
{
void Fly();
}
class Bird : IFlyable
{
public void Fly() => Console.WriteLine("Chim bay!");
}
class Cat
{
// Mèo không implement IFlyable
}
Giờ bạn có thể viết function chỉ làm việc với "biết bay", không đụng đến mèo:
void MakeItFly(object creature)
{
if (creature is IFlyable flyingThing)
{
flyingThing.Fly();
}
else
{
Console.WriteLine("Con này không biết bay.");
}
}
Cách này giúp kiến trúc không bị phá bởi mấy method abstract giả.
Vấn đề với abstract class "cứng" khi mở rộng
Giả sử bạn phát hành library với abstract class như này:
public abstract class Creature
{
public abstract void DoAction();
}
Người dùng library bắt đầu tạo class kế thừa. Một năm sau bạn muốn mở rộng API và thêm:
public abstract class Creature
{
public abstract void DoAction();
public abstract void Sleep(); // Method mới!
}
Giờ tất cả class người dùng không compile được, vì phải implement method mới. Đó là lý do nên cẩn thận khi thiết kế abstraction và ưu tiên interface với default method nếu có thể.
4. Mẹo tránh lỗi phổ biến
Hãy để app của bạn linh hoạt như vận động viên thể dục, nhưng đừng tự làm gãy chân mình nhé!
- Đừng lạm dụng inheritance: nếu dùng composition (nhúng object này vào object khác) được thì hãy dùng.
- Chỉ đánh dấu method là virtual nếu chắc chắn cần override.
- Đừng tạo abstract class vô nghĩa hay hierarchy "để dành".
- Kiểm tra logic khi override method: đừng phá vỡ quy tắc của class cha.
- Đừng thêm method abstract mới vào public base class và interface sau khi đã phát hành library.
- Dùng interface để giảm sự phụ thuộc giữa các phần của chương trình.
- Muốn mở rộng API thì dùng Default Interface Methods.
GO TO FULL VERSION