This programming guide is only available in Vietnamese. Switch to Vietnamese to read the full article.
Hai tính chất tối quan trọng nâng tầm C++ so với C trong việc tổ chức hệ thống phần mềm lớn là Tính kế thừa (Inheritance) và Tính đa hình (Polymorphism). Trong bài học này, chúng ta sẽ cùng nghiên cứu cách viết code kế thừa sạch, sử dụng hàm ảo (virtual function), lớp trừu tượng (abstract class) và lột trần cơ chế quản lý con trỏ hàm ngầm bên dưới của C++: Virtual Method Table (VTable).
1. Tính kế thừa và từ khóa override/final
C++ cho phép một lớp con kế thừa từ một hoặc nhiều lớp cha. Để viết mã nguồn an toàn và tường minh, Modern C++ cung cấp hai từ khóa:
-
override: Bắt buộc trình biên dịch phải kiểm tra xem hàm này có thực sự ghi đè một hàm ảo ở lớp cha hay không (tránh gõ sai tên hàm hoặc sai tham số dẫn đến việc tạo hàm mới). -
final: Ngăn chặn không cho lớp khác tiếp tục kế thừa lớp này, hoặc ngăn không cho ghi đè phương thức ảo này ở các lớp con tiếp theo.
2. Tính đa hình động & Hiểm họa rò rỉ bộ nhớ từ Destructor
Đa hình động là khả năng gọi đúng phương thức của đối tượng con thông qua con trỏ hoặc tham chiếu của
lớp cha tại thời điểm runtime (Runtime Binding/Dynamic Dispatch). Điều này được kích hoạt thông qua
việc khai báo từ khóa virtual trước phương thức ở lớp cha.
Bất kỳ lớp nào đóng vai trò là lớp cha (Base Class) và có ít nhất một phương thức ảo bắt buộc phải khai báo hàm hủy là ảo (virtual destructor):
virtual ~Base() = default;.Nếu không, khi bạn giải phóng đối tượng con thông qua con trỏ lớp cha (ví dụ:
Base* ptr = new Derived(); delete ptr;), trình biên dịch sẽ thực hiện Static Binding
(liên kết tĩnh): nó chỉ gọi hàm hủy của Base và bỏ qua hoàn toàn hàm hủy của
`Derived`. Mọi tài nguyên, bộ nhớ Heap được cấp phát trong đối tượng con sẽ bị rò rỉ (Memory Leak)
hoàn toàn!
3. VTable & VPtr hoạt động thế nào trong bộ nhớ RAM?
Khi một lớp khai báo ít nhất một hàm ảo (virtual), trình biên dịch C++ thực hiện hai thay đổi ngầm quan trọng trong cấu trúc bộ nhớ:
- Khởi tạo VTable (Virtual Table): Trình biên dịch tạo ra một bảng tĩnh duy nhất cho lớp đó. Bảng này chứa danh sách các con trỏ hàm trỏ đến địa chỉ thực tế của các phương thức ảo của lớp.
-
Chèn con trỏ VPtr (Virtual Pointer): Trình biên dịch chèn thêm một con trỏ ẩn
_vptr(có kích thước 8 bytes trên hệ thống 64-bit) vào đầu đối tượng (offset 0). Con trỏ này tự động được gán trỏ tới bảngVTablecủa lớp tương ứng trong quá trình chạy Constructor.
Bố cục bộ nhớ của đối tượng (RAM Layout):
┌──────────────────────────┐ VTable của lớp Derived:
│ Đối tượng Derived │ ┌───────────────────────────────────┐
├──────────────────────────┤ │ &Derived::execute() │
│ _vptr (Con trỏ ảo) ├─────────►├───────────────────────────────────┤
├──────────────────────────┤ │ &Derived::~Derived() (Hàm hủy ảo) │
│ Thuộc tính lớp cha │ └───────────────────────────────────┘
├──────────────────────────┤
│ Thuộc tính lớp con │
└──────────────────────────┘
Khi bạn gọi phương thức ảo qua con trỏ lớp cha (cmd->execute()):
-
Bước 1: CPU truy cập vào ô nhớ đối tượng và đọc giá trị của con trỏ
_vptrnằm ở đầu đối tượng. -
Bước 2: Từ
_vptr, CPU tìm đến bảngVTabletương ứng của lớp con. -
Bước 3: CPU tra cứu địa chỉ của hàm
execute()trong bảngVTablevà thực hiện bước nhảy (Assemblycall) đến đoạn mã máy của hàm con trỏ đã lấy ra.
Phép tra cứu này diễn ra ở runtime với chi phí cực thấp ($O(1)$ lookup), cho phép hệ thống triển khai đa hình động mà vẫn bảo toàn tốc độ cực cao.
4. Code thực hành: oop_polymorphism.cpp
Dưới đây là ví dụ hoàn chỉnh về cách hệ thống V8 pipeline sử dụng đa hình động để thực thi các lệnh biên dịch:
#include <iostream>
#include <vector>
#include <string>
// Lớp trừu tượng (Abstract Class) đóng vai trò là Interface
class JSCommand {
public:
// Virtual destructor rất quan trọng khi sử dụng đa hình để hủy đúng đối tượng con
virtual ~JSCommand() = default;
// Pure virtual function (hàm thuần ảo)
virtual void execute() = 0;
};
// Lớp con 1
class ParseHTMLCommand : public JSCommand {
private:
std::string filePath;
public:
ParseHTMLCommand(std::string path) : filePath(path) {}
// Sử dụng từ khóa override để trình biên dịch kiểm tra tính chính xác
void execute() override {
std::cout << "[V8 Parser] Đang phân tích HTML file: " << filePath << std::endl;
}
};
// Lớp con 2
class CompileJSCommand : public JSCommand {
private:
std::string scriptSource;
public:
CompileJSCommand(std::string src) : scriptSource(src) {}
void execute() override {
std::cout << "[V8 Compiler] Đang biên dịch JS source (JIT): " << scriptSource << std::endl;
}
};
int main() {
// Tạo danh sách các con trỏ lớp cha, trỏ đến đối tượng lớp con (Đa hình động)
std::vector<JSCommand*> pipeline;
pipeline.push_back(new ParseHTMLCommand("index.html"));
pipeline.push_back(new CompileJSCommand("console.log('Hello V8')"));
std::cout << "--- Bắt đầu chạy V8 Pipeline ---" << std::endl;
for (auto* cmd : pipeline) {
cmd->execute(); // Tính đa hình thực thi đúng phương thức ở lớp con tại runtime
}
// Giải phóng bộ nhớ cấp phát bằng new
for (auto* cmd : pipeline) {
delete cmd;
}
pipeline.clear();
return 0;
}
4. Hàm ảo thuần (Pure Virtual Function) & Lớp trừu tượng (Abstract Class)
Một hàm ảo thuần (Pure Virtual Function) là một hàm ảo được khai báo nhưng không được định nghĩa trong lớp cha. Lớp con bắt buộc phải cung cấp định nghĩa cho hàm này, nếu không lớp con cũng sẽ trở thành một lớp trừu tượng.
Lớp trừu tượng: Một lớp có chứa ít nhất một hàm ảo thuần được gọi là lớp trừu tượng. Bạn không thể tạo đối tượng từ lớp trừu tượng; chúng chỉ dùng để làm bản thiết kế (Interface) cho các lớp con cụ thể.
Cú pháp khai báo hàm ảo thuần: virtual void methodName() = 0;
#include <iostream>
#include <string>
// Lớp trừu tượng Shape (không thể tạo đối tượng)
class Shape {
protected:
std::string color;
public:
Shape(std::string col) : color(col) {}
virtual ~Shape() = default;
// Hàm ảo thuần - bắt buộc lớp con phải implement
virtual void draw() = 0;
virtual double getArea() const = 0;
};
// Lớp con cụ thể: Circle
class Circle : public Shape {
private:
double radius;
public:
Circle(std::string col, double r) : Shape(col), radius(r) {}
void draw() override {
std::cout << "Drawing Circle (color: " << color << ")" << std::endl;
}
double getArea() const override {
return 3.14159 * radius * radius;
}
};
// Lớp con cụ thể: Rectangle
class Rectangle : public Shape {
private:
double width, height;
public:
Rectangle(std::string col, double w, double h)
: Shape(col), width(w), height(h) {}
void draw() override {
std::cout << "Drawing Rectangle (color: " << color << ")" << std::endl;
}
double getArea() const override {
return width * height;
}
};
int main() {
// Shape s("red"); // LỖI! Không thể tạo đối tượng từ lớp trừu tượng
Circle c("blue", 5);
Rectangle r("red", 4, 6);
c.draw();
std::cout << "Area: " << c.getArea() << std::endl;
r.draw();
std::cout << "Area: " << r.getArea() << std::endl;
return 0;
}
5. Virtual Destructor & Dọn dẹp an toàn trong chuỗi kế thừa
Như đã nói ở phần trước, bất kỳ lớp cha nào có ít nhất một phương thức ảo bắt buộc phải khai báo virtual destructor. Điều này đảm bảo rằng khi bạn giải phóng đối tượng con thông qua con trỏ lớp cha, toàn bộ chuỗi destructors (từ lớp con cho đến lớp cha) được gọi theo thứ tự ngược lại, đảm bảo mọi tài nguyên được dọn dẹp đúng cách.
Quy tắc: Nếu lớp con cấp phát tài nguyên trong constructor, và lớp cha có virtual method, thì lớp con PHẢI khai báo virtual destructor để giải phóng tài nguyên đó khi đối tượng bị hủy.
#include <iostream>
#include <string>
// Lớp cha với virtual destructor
class Database {
protected:
std::string dbName;
public:
Database(std::string name) : dbName(name) {
std::cout << "[DB Base] Opened: " << dbName << std::endl;
}
// QUAN TRỌNG: Virtual destructor
virtual ~Database() {
std::cout << "[DB Base] Closed: " << dbName << std::endl;
}
virtual void connect() = 0;
};
// Lớp con: MySQL
class MySQLDB : public Database {
private:
char* connectionBuffer; // Cấp phát Heap
public:
MySQLDB(std::string name) : Database(name) {
connectionBuffer = new char[1024];
std::cout << "[MySQL] Allocated connection buffer" << std::endl;
}
// Virtual destructor ở lớp con (optional nhưng khuyến khích)
virtual ~MySQLDB() {
delete[] connectionBuffer;
std::cout << "[MySQL] Freed connection buffer" << std::endl;
}
void connect() override {
std::cout << "[MySQL] Connected to " << dbName << std::endl;
}
};
int main() {
{
// Con trỏ lớp cha trỏ đến đối tượng lớp con
Database* db = new MySQLDB("production_db");
db->connect();
// Khi delete, virtual destructor sẽ gọi đúng destructor của MySQLDB
delete db; // Gọi ~MySQLDB() rồi sau đó ~Database()
}
return 0;
}
6. RTTI (Run-Time Type Information): typeid() & dynamic_cast
RTTI cho phép bạn xác định kiểu thật sự của một đối tượng tại runtime (thời gian
chạy) thông qua hai công cụ chính: typeid() và dynamic_cast. Điều này rất
hữu ích khi bạn cần xử lý đặc biệt dựa trên kiểu thực tế của đối tượng.
typeid(): Trả về thông tin loại của một đối tượng. Kết quả có thể so sánh với
typeid(ConcreteClass).
dynamic_cast: Cố gắng chuyển đổi an toàn một con trỏ hoặc tham chiếu lớp cha sang lớp
con. Nếu chuyển đổi không hợp lệ, nó trả về nullptr (cho con trỏ) hoặc ném exception (cho
tham chiếu).
#include <iostream>
#include <typeinfo>
#include <vector>
class Animal {
public:
virtual ~Animal() = default;
virtual void makeSound() = 0;
};
class Dog : public Animal {
public:
void makeSound() override {
std::cout << "Woof!" << std::endl;
}
void fetch() {
std::cout << "Fetching the ball..." << std::endl;
}
};
class Cat : public Animal {
public:
void makeSound() override {
std::cout << "Meow!" << std::endl;
}
void scratch() {
std::cout << "Scratching..." << std::endl;
}
};
int main() {
std::vector<Animal*> animals;
animals.push_back(new Dog());
animals.push_back(new Cat());
animals.push_back(new Dog());
for (auto* animal : animals) {
// Sử dụng typeid để xác định kiểu
std::cout << "Type: " << typeid(*animal).name() << std::endl;
// Sử dụng dynamic_cast để an toàn chuyển đổi kiểu
if (Dog* dog = dynamic_cast<Dog*>(animal)) {
animal->makeSound();
dog->fetch(); // Gọi phương thức riêng của Dog
} else if (Cat* cat = dynamic_cast<Cat*>(animal)) {
animal->makeSound();
cat->scratch(); // Gọi phương thức riêng của Cat
}
std::cout << std::endl;
}
// Dọn dẹp bộ nhớ
for (auto* animal : animals) {
delete animal;
}
return 0;
}
7. Kiểm soát truy cập trong kế thừa: public/protected/private
Trong C++, cách bạn kế thừa từ một lớp cha (public, protected, hoặc private inheritance) ảnh hưởng đến mức độ truy cập của các thành viên ở lớp con. Điều này cho phép bạn kiểm soát nghiêm ngặt xem lớp con có "là một" (is-a) hay chỉ "có một" (has-a) mối quan hệ với lớp cha.
Public Inheritance: Là mối quan hệ "IS-A". Lớp con là một trường hợp đặc biệt của lớp cha. Các thành viên public/protected của lớp cha vẫn giữ mức truy cập giống nhau ở lớp con.
Protected Inheritance: Các thành viên public của lớp cha trở thành protected ở lớp con. Hữu ích khi bạn muốn ẩn giao diện lớp cha từ thế giới bên ngoài.
Private Inheritance: Tất cả thành viên public/protected của lớp cha trở thành private ở lớp con. Đây là một mối quan hệ "HAS-A" chuyên biệt, ít khi được sử dụng.
#include <iostream>
class Vehicle {
public:
void start() { std::cout << "Vehicle started" << std::endl; }
protected:
int speed = 0;
private:
int secretValue = 42;
};
// Public Inheritance: "IS-A" relationship
class Car : public Vehicle {
public:
void accelerate() {
speed += 10; // Có thể truy cập protected member
std::cout << "Car accelerating. Speed: " << speed << std::endl;
}
};
// Protected Inheritance: Ẩn interface của lớp cha
class ElectricCar : protected Vehicle {
public:
void charge() {
start(); // Có thể gọi public method vì nó được kế thừa as protected
std::cout << "Charging..." << std::endl;
}
};
int main() {
Car car;
car.start(); // Có thể gọi trực tiếp vì public inheritance
car.accelerate();
ElectricCar eCar;
// eCar.start(); // LỖI! start() trở thành protected với protected inheritance
eCar.charge();
return 0;
}
Sơ đồ chuỗi kế thừa với Multiple Inheritance VTable
Khi một lớp con kế thừa từ nhiều lớp cha (Multiple Inheritance), mỗi lớp cha có VTable riêng, và đối tượng con chứa nhiều VPtr (một cho mỗi lớp cha). Điều này giúp C++ duy trì đa hình động chính xác ngay cả trong cấu trúc kế thừa phức tạp:
Bố cục bộ nhớ với Multiple Inheritance:
┌─────────────────────────────────────┐
│ Đối tượng DerivedClass │
├─────────────────────────────────────┤
│ _vptr1 ──────────►VTable của Base1 │
├─────────────────────────────────────┤
│ Thành viên của Base1 │
├─────────────────────────────────────┤
│ _vptr2 ──────────►VTable của Base2 │
├─────────────────────────────────────┤
│ Thành viên của Base2 │
├─────────────────────────────────────┤
│ Thành viên của DerivedClass │
└─────────────────────────────────────┘
Câu hỏi ôn tập kiến thức
Điều gì xảy ra nếu bạn giải phóng bộ nhớ của một đối tượng lớp con bằng con trỏ lớp cha mà lớp cha
KHÔNG có virtual destructor?
Tải mã nguồn mẫu bài học
Bạn có thể tải file mã nguồn C++ mẫu hoàn chỉnh của bài học này để tiến hành thực hành trực tiếp trên máy tính cá nhân.
Tải oop_polymorphism.cpp
Comments
Bình luận