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)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.

⚠️ CẢNH BÁO QUAN TRỌNG VỀ VIRTUAL DESTRUCTOR:
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ớ:

  1. 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.
  2. 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ảng VTable củ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ỏ _vptr nằm ở đầu đối tượng.
  • Bước 2: Từ _vptr, CPU tìm đến bảng VTable tương ứng của lớp con.
  • Bước 3: CPU tra cứu địa chỉ của hàm execute() trong bảng VTable và thực hiện bước nhảy (Assembly call) đế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:

oop_polymorphism.cpp
#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;

abstract_class.cpp
#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.

virtual_destructor.cpp
#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()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).

rtti_example.cpp
#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.

inheritance_access.cpp
#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?

A. Chương trình sẽ không chạy hàm hủy của lớp cha, nhưng lớp con vẫn bị hủy.
B. C++ chỉ thực thi destructor của lớp cha và bỏ qua destructor của lớp con, gây rò rỉ bộ nhớ ở lớp con.
C. Trình biên dịch sẽ báo lỗi cú pháp ngay lập tức và không cho phép compile.

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