Khái niệm cốt lõi và điều kiện kích hoạt
Đa hình (Polymorphism) trong lập trình hướng đối tượng mô tả khả năng một hành vi chung có thể được thực thi dưới nhiều hình thái khác nhau tùy thuộc vào đối tượng cụ thể. Cụ thể, khi cùng một lệnh gọi phương thức được thực hiện trên các đối tượng thuộc các lớp khác nhau trong hệ thống kế thừa, hệ thống sẽ tự động chọn phiên bản hàm phù hợp với kiểu dữ liệu thực tế tại thời điểm chạy.
Để cơ chế này hoạt động chính xác, hai điều kiện bắt buộc phải được thỏa mãn đồng thời:
- Phương thức được gọi phải được khai báo với từ khóa
virtualở lớp cơ sở. - Phải thực hiện lời gọi thông qua con trỏ hoặc tham chiếu trỏ đến lớp cơ sở, đồng thời lớp dẫn xuất phải ghi đè (override) hàm ảo đó.
Hàm ảo và cơ chế ghi đè
Một hàm thành viên được gắn thẻ virtual được gọi là hàm ảo. Khi lớp dẫn xuất định nghĩa lại hàm này với cùng chữ ký (cùng tên, cùng danh sách tham số và cùng kiểu trả về), quá trình này được gọi là ghi đè hàm ảo.
class BookingAgent {
public:
virtual void CalculateFee() const {
std::cout << "Tính phí: Mức giá chuẩn" << std::endl;
}
};
class DiscountClient : public BookingAgent {
public:
virtual void CalculateFee() const override {
std::cout << "Tính phí: Áp dụng ưu đãi 50%" << std::endl;
}
};
void ProcessTransaction(BookingAgent& client) {
client.CalculateFee();
}
int main() {
BookingAgent regularUser;
DiscountClient vipUser;
ProcessTransaction(regularUser);
ProcessTransaction(vipUser);
return 0;
}
Lưu ý: Mặc dù việc bỏ từ khóa virtual ở lớp dẫn xuất vẫn được bộ biên dịch chấp nhận do tính chất kế thừa, nhưng việc luôn khai báo rõ ràng virtual (hoặc sử dụng override) là thực hành tốt để tăng độ rõ ràng của mã nguồn.
Các ngoại lệ trong quy tắc ghi đè
1. Kiểu trả về biến đổi (Covariant Return Types)
Quy tắc thông thường yêu cầu kiểu trả về phải giống hệt nhau. Tuy nhiên, nếu hàm ảo của lớp cơ sở trả về con trỏ hoặc tham chiếu đến đối tượng của chính lớp đó (hoặc lớp cơ sở), hàm ghi đè ở lớp dẫn xuất được phép trả về con trỏ/tham chiếu đến đối tượng của lớp dẫn xuất. Cơ chế này giúp duy trì tính an toàn kiểu trong các mẫu thiết kế như Factory.
class BaseData { /* ... */ };
class ExtendedData : public BaseData { /* ... */ };
class DataRepository {
public:
virtual BaseData* FetchRecord() { return new BaseData(); }
};
class AdvancedRepository : public DataRepository {
public:
virtual ExtendedData* FetchRecord() override { return new ExtendedData(); }
};
2. Ghi đè hàm hủy (Destructor Override)
Khi hàm hủy của lớp cơ sở được khai báo ảo, hàm hủy của lớp dẫn xuất sẽ tự động tham gia vào cơ chế ghi đè, bất kể tên hàm có khác nhau hay không (do đặc thù cú pháp destructor). Bộ biên dịch thực sự ánh xạ tất cả destructor về cùng một ký hiệu nội bộ, đảm bảo việc dọn dẹp tài nguyên diễn ra chính xác khi sử dụng delete trên con trỏ lớp cơ sở.
class ResourceBase {
public:
virtual ~ResourceBase() { std::cout << "Dọn dẹp tài nguyên cơ sở" << std::endl; }
};
class ResourceDerived : public ResourceBase {
int* buffer;
public:
ResourceDerived() : buffer(new int[100]) {}
virtual ~ResourceDerived() override {
delete[] buffer;
std::cout << "Giải phóng bộ nhớ dẫn xuất" << std::endl;
}
};
void Cleanup(ResourceBase* ptr) { delete ptr; } // Sẽ gọi đúng destructor tương ứng
Từ khóa kiểm soát C++11: override và final
Để tránh các lỗi tinh vi như viết sai chính tả hàm hoặc nhầm lẫn tham số, C++11 giới thiệu hai bộ chỉ thị biên dịch:
final: Đánh dấu một hàm ảo không thể bị ghi đè thêm ở các lớp con sâu hơn.override: Bắt buộc lớp dẫn xuất phải ghi đè đúng một hàm ảo từ lớp cha. Nếu không khớp chữ ký, bộ biên dịch sẽ báo lỗi ngay lập tức.
class Engine {
public:
virtual void Ignite() const final {}
virtual void Shutdown() const {}
};
class V8Engine : public Engine {
public:
// virtual void Ignite() const {} // Lỗi biên dịch: không thể ghi đè hàm final
virtual void Shutdown() const override { std::cout << "Tắt động cơ V8" << std::endl; }
};
So sánh: Overload, Override và Hide
Ba khái niệm này thường gây nhầm lẫn trong quá trình phát triển:
- Nạp chồng (Overload): Cùng tên hàm, khác danh sách tham số, xảy ra trong cùng một phạm vi (cùng lớp).
- Ghi đè (Override): Cùng chữ ký hàm, lớp dẫn xuất ghi đè hàm ảo của lớp cơ sở, kích hoạt liên kết động.
- Che khuất (Hide/Shadowing): Cùng tên hàm (có thể cùng hoặc khác tham số), lớp dẫn xuất định nghĩa lại hàm của lớp cơ sở mà không cần
virtual, làm ẩn hàm gốc khi gọi thông qua đối tượng lớp dẫn xuất.
Khám phá cơ chế底层: Bảng hàm ảo (vtable) và con trỏ vptr
Để hiểu tại sao đa hình hoạt động, cần phân tích cấu trúc bộ nhớ của đối tượng. Khi một lớp chứa hàm ảo, trình biên dịch tự động chèn một con trỏ ẩn (thường gọi là _vptr hoặc vptr) vào đầu mỗi đối tượng. Con trỏ này trỏ tới một mảng trong vùng nhớ chỉ-đọc chứa địa chỉ của các hàm ảo, gọi là vtable.
Kích thước của đối tượng sẽ tăng thêm một kích thước con trỏ (thường là 4 hoặc 8 byte tùy kiến trúc) so với lớp không có hàm ảo. Điều này giải thích tại sao kích thước bộ nhớ của lớp có hàm ảo luôn lớn hơn lớp chỉ chứa hàm thông thường.
Quá trình phân giải lời gọi đa hình
Khi thực hiện ptr->VirtualMethod(), trình biên dịch sinh mã lệnh thực hiện các bước sau:
- Đọc giá trị của
vptrtừ vùng nhớ của đối tượng. - Dùng
vptrđể truy cập vàovtabletương ứng. - Tính toán offset để lấy ra địa chỉ hàm ảo cụ thể cần gọi.
- Thực thi hàm tại địa chỉ đó.
Điều quan trọng cần lưu ý là các tham số mặc định (default arguments) không được phân giải động. Chúng được xác định dựa trên kiểu khai báo của con trỏ/tham chiếu tại thời điểm biên dịch. Do đó, nếu lớp cơ sở và dẫn xuất ghi đè hàm ảo nhưng có tham số mặc định khác nhau, lời gọi sẽ luôn sử dụng tham số mặc định của lớp cơ sở nếu gọi thông qua con trỏ lớp cơ sở.
Liên kết tĩnh và Liên kết động
- Liên kết tĩnh (Static/Early Binding): Quyết định hàm nào được gọi xảy ra lúc biên dịch. Áp dụng cho hàm thông thường, hàm tĩnh, và nạp chồng. Ưu điểm là hiệu suất cao.
- Liên kết động (Dynamic/Late Binding): Quyết định được hoãn lại đến lúc chạy, dựa trên kiểu thực tế của đối tượng. Áp dụng cho hàm ảo thông qua con trỏ/tham chiếu. Đem lại tính linh hoạt nhưng có chi phí runtime nhỏ do phải tra cứu bảng.
Lớp trừu tượng và Kế thừa giao diện
Một hàm ảo được gán giá trị = 0 được gọi là hàm ảo thuần túy (pure virtual function). Lớp chứa ít nhất một hàm này trở thành lớp trừu tượng. Không thể khởi tạo trực tiếp đối tượng từ lớp trừu tượng. Chỉ khi lớp dẫn xuất ghi đè đầy đủ tất cả các hàm ảo thuần túy, nó mới trở thành lớp cụ thể có thể khởi tạo.
Mô hình này thúc đẩy kế thừa giao diện: lớp cơ sở chỉ định nghĩa "giao diện" (đầu cuối hàm), còn lớp dẫn xuất cung cấp "triển khai". Ngược lại, kế thừa hàm thông thường là kế thừa triển khai (sẵn sàng sử dụng mã nguồn đã viết).
class IDrawable {
public:
virtual void Render() const = 0;
virtual ~IDrawable() = default;
};
class Triangle : public IDrawable {
public:
void Render() const override { std::cout << "Vẽ hình tam giác" << std::endl; }
};
// IDrawable obj; // Lỗi biên dịch
// Triangle t; // Hợp lệ
Cấu trúc vtable trong kế thừa đa thức
Khi một lớp dẫn xuất kế thừa từ nhiều lớp cơ sở chứa hàm ảo, bộ nhớ của đối tượng sẽ chứa nhiều vptr, mỗi con trỏ trỏ đến vtable riêng tương ứng với từng lớp cơ sở. Thứ tự các vptr và dữ liệu thành phần trong bộ nhớ tuân thủ chính xác thứ tự kế thừa trong khai báo lớp.
Quá trình xây dựng vtable của lớp dẫn xuất trong trường hợp này diễn ra như sau:
- Copy nội dung
vtablecủa từng lớp cơ sở vào vùng nhớ tương ứng. - Thay thế các địa chỉ hàm bị ghi đè bằng địa chỉ hàm của lớp dẫn xuất.
- Các hàm ảo mới được khai báo ở lớp dẫn xuất chỉ được thêm vào
vtablecủa lớp cơ sở đầu tiên trong danh sách kế thừa.
Cơ chế này đảm bảo tính nhất quán khi ép kiểu (cast) xuống bất kỳ lớp cơ sở nào và gọi phương thức ảo một cách an toàn.
Câu hỏi thường gặp trong phỏng vấn kỹ thuật
- Hàm
inlinecó thể là hàm ảo không? Có, nhưng khi được gọi qua cơ chế đa hình, trình biên dịch sẽ bỏ qua thuộc tínhinlineđể thực thi thông quavtable. - Hàm tĩnh (
static) có thể ảo không? Không. Hàm tĩnh không liên kết với đối tượng cụ thể (không cóthis), do đó không thể tra cứuvptr. - Hàm tạo (
constructor) có thể ảo không? Không.vptrchỉ được khởi tạo hoàn chỉnh sau khi hàm tạo lớp cơ sở kết thúc. Gán ảo cho constructor vô nghĩa và bị cấm. - Hàm hủy (
destructor) nên được khai báo ảo khi nào? Luôn khai báo destructor lớp cơ sở làvirtualnếu lớp đó được thiết kế để kế thừa và xóa thông qua con trỏ lớp cơ sở, nhằm ngăn ngừa rò rỉ tài nguyên. - Hiệu năng: Gọi hàm thường so với hàm ảo? Với đối tượng trực tiếp, tốc độ tương đương. Với con trỏ/tham chiếu, gọi hàm thường nhanh hơn do không cần chi phí tra cứu
vtabletại runtime. - Vị trí lưu trữ của
vtable? Được trình biên dịch tạo ra và đặt trong vùng nhớ chỉ-đọc (Read-Only Data / Code Segment), chia sẻ chung cho tất cả các đối tượng cùng kiểu.