Bài 36.2: Đa hình và hàm ảo trong C++

Chào mừng bạn đến với bài viết tiếp theo trong series về C++! Hôm nay, chúng ta sẽ cùng nhau khám phá một trong những khái niệm mạnh mẽ và quan trọng nhất trong Lập trình hướng đối tượng (OOP): Đa hình (Polymorphism) và cách C++ triển khai nó thông qua Hàm ảo (Virtual Functions).

Đa hình là gì? Đơn giản mà nói, đa hình (từ tiếng Hy Lạp: poly - nhiều, morph - hình dạng) là khả năng một đối tượng có thể mang nhiều hình dạng hoặc được xử lý theo nhiều cách khác nhau tùy thuộc vào loại thực tế của nó. Trong ngữ cảnh của C++ và lập trình hướng đối tượng, đa hình thường đề cập đến khả năng gọi một phương thức trên một đối tượng thông qua con trỏ hoặc tham chiếu của lớp cơ sở, nhưng thực thi phiên bản phương thức đó của lớp phái sinh phù hợp.

Nghe có vẻ hơi trừu tượng? Đừng lo, chúng ta sẽ đi sâu vào chi tiết với các ví dụ cụ thể.

Ràng buộc (Binding): Tĩnh và Động

Để hiểu đa hình trong C++, chúng ta cần biết về khái niệm ràng buộc (binding) - quá trình liên kết một lời gọi hàm với định nghĩa hàm cụ thể.

  1. Ràng buộc tĩnh (Static Binding) / Ràng buộc thời gian biên dịch (Compile-time Binding): Đây là hành vi mặc định của C++. Trình biên dịch xác định hàm nào sẽ được gọi dựa trên kiểu của con trỏ hoặc tham chiếu tại thời điểm biên dịch. Điều này nhanh chóng và hiệu quả vì mọi thứ đã được quyết định trước khi chương trình chạy.

    Hãy xem xét ví dụ sau:

    #include <iostream>
    
    class Animal {
    public:
        void sound() {
            cout << "Generic animal sound\n";
        }
    };
    
    class Dog : public Animal {
    public:
        void sound() {
            cout << "Woof!\n";
        }
    };
    
    int main() {
        Dog myDog;
        Animal* ptrAnimal = &myDog; // Con trỏ lớp cơ sở trỏ đến đối tượng lớp phái sinh
    
        // Gọi hàm sound thông qua con trỏ lớp cơ sở
        ptrAnimal->sound(); // <--- Điều gì sẽ xảy ra?
    
        return 0;
    }
    

    Khi bạn chạy đoạn code trên, bạn sẽ thấy kết quả là Generic animal sound. Tại sao lại vậy? Bởi vì trình biên dịch nhìn vào kiểu của ptrAnimal, đó là Animal*. Nó thấy Animal có hàm sound(), nên nó "buộc" lời gọi ptrAnimal->sound() vào Animal::sound() ngay tại thời điểm biên dịch. Kiểu thực tế của đối tượng mà con trỏ đang trỏ tới (Dog) bị bỏ qua trong trường hợp này. Đây chính là ràng buộc tĩnh.

    Ràng buộc tĩnh hoạt động tốt trong nhiều trường hợp, nhưng nó hạn chế khả năng của chúng ta khi muốn viết mã linh hoạt, có thể xử lý các loại đối tượng khác nhau một cách đồng nhất thông qua một giao diện chung (lớp cơ sở).

  2. Ràng buộc động (Dynamic Binding) / Ràng buộc thời gian chạy (Runtime Binding): Đây là nơi đa hình phát huy tác dụng. Với ràng buộc động, hàm được gọi sẽ được xác định tại thời điểm chương trình đang chạy, dựa trên kiểu thực tế của đối tượng mà con trỏ hoặc tham chiếu đang trỏ tới, chứ không phải kiểu của con trỏ/tham chiếu đó.

    Để đạt được ràng buộc động trong C++, chúng ta sử dụng hàm ảo (virtual functions).

Hàm ảo (Virtual Functions)

Một hàm ảo là một hàm thành viên trong lớp cơ sở mà bạn khai báo bằng từ khóa virtual. Khi một hàm được khai báo là virtual trong lớp cơ sở và được ghi đè (override) trong lớp phái sinh, lời gọi hàm thông qua một con trỏ hoặc tham chiếu của lớp cơ sở sẽ được phân giải tại thời gian chạy, và phiên bản của hàm trong lớp thực tế của đối tượng sẽ được thực thi.

Hãy sửa lại ví dụ trên bằng cách thêm từ khóa virtual:

#include <iostream>

class Animal {
public:
    // Khai báo hàm sound là virtual
    virtual void sound() {
        cout << "Generic animal sound\n";
    }

    // Quan trọng: Destructor cũng nên là virtual
    // khi làm việc với đa hình và quản lý bộ nhớ động.
    virtual ~Animal() {
        cout << "Animal destructor called\n";
    }
};

class Dog : public Animal {
public:
    // Ghi đè hàm virtual. Từ khóa 'override' là tùy chọn
    // nhưng giúp trình biên dịch kiểm tra xem bạn có
    // ghi đè đúng hàm ảo của lớp cơ sở hay không.
    void sound() override {
        cout << "Woof!\n";
    }

    ~Dog() override {
        cout << "Dog destructor called\n";
    }
};

class Cat : public Animal {
public:
    void sound() override {
        cout << "Meow!\n";
    }

    ~Cat() override {
        cout << "Cat destructor called\n";
    }
};


int main() {
    // Sử dụng con trỏ lớp cơ sở
    Animal* ptrDog = new Dog();
    Animal* ptrCat = new Cat();

    cout << "Gọi hàm sound thông qua con trỏ Animal*:\n";
    ptrDog->sound(); // <--- Bây giờ sẽ gọi Dog::sound()!
    ptrCat->sound(); // <--- Bây giờ sẽ gọi Cat::sound()!

    // Dọn dẹp bộ nhớ
    delete ptrDog; // <--- Destructor ảo đảm bảo Dog destructor được gọi
    delete ptrCat; // <--- Destructor ảo đảm bảo Cat destructor được gọi

    return 0;
}

Kết quả của đoạn code này sẽ là:

Gọi hàm sound thông qua con trỏ Animal*:
Woof!
Meow!
Dog destructor called
Animal destructor called
Cat destructor called
Animal destructor called

Giải thích:

  1. Chúng ta khai báo sound()virtual trong lớp Animal.
  2. Lớp DogCat ghi đè hàm sound().
  3. Trong main, chúng ta tạo con trỏ Animal* nhưng trỏ tới đối tượng DogCat.
  4. Khi gọi ptrDog->sound()ptrCat->sound(), trình biên dịch biết rằng sound() là hàm ảo. Thay vì sử dụng ràng buộc tĩnh, nó sử dụng ràng buộc động. Tại thời điểm chạy, chương trình kiểm tra kiểu thực tế của đối tượng mà con trỏ đang trỏ tới (DogCat) và gọi đúng phiên bản sound() của lớp đó.

Đó chính là sức mạnh của đa hình thời gian chạy!

Tại sao Đa hình và Hàm ảo lại quan trọng?

Đa hình mang lại sự linh hoạt và khả năng mở rộng tuyệt vời cho các hệ thống phần mềm.

  • Code linh hoạt hơn: Bạn có thể viết mã xử lý một tập hợp các đối tượng thuộc các lớp phái sinh khác nhau thông qua một giao diện chung (lớp cơ sở). Điều này giảm sự phụ thuộc vào các kiểu cụ thể và làm cho mã dễ bảo trì hơn.
  • Dễ dàng mở rộng: Khi bạn thêm một lớp phái sinh mới (ví dụ: thêm lớp Cow kế thừa từ Animal với hàm sound() riêng), bạn không cần thay đổi mã hiện có đã sử dụng con trỏ/tham chiếu Animal*. Mã đó sẽ tự động hoạt động với đối tượng Cow mới nhờ đa hình.

Hãy xem một ví dụ minh họa sự tiện lợi khi mở rộng:

#include <iostream>
#include <vector>
#include <string>

// Giữ nguyên các lớp Animal, Dog, Cat như trên
class Animal {
public:
    virtual void sound() const { // Const vì hàm không thay đổi trạng thái đối tượng
        cout << "Generic animal sound\n";
    }
    virtual ~Animal() = default; // Destructor mặc định là virtual
};

class Dog : public Animal {
public:
    void sound() const override {
        cout << "Woof!\n";
    }
};

class Cat : public Animal {
public:
    void sound() const override {
        cout << "Meow!\n";
    }
};

// Thêm một lớp phái sinh mới mà không cần thay đổi logic xử lý chung
class Cow : public Animal {
public:
    void sound() const override {
        cout << "Moo!\n";
    }
};


int main() {
    // Tạo một vector chứa các con trỏ tới lớp cơ sở
    // nhưng trỏ đến các đối tượng thuộc các lớp phái sinh khác nhau
    vector<Animal*> farm;
    farm.push_back(new Dog());
    farm.push_back(new Cat());
    farm.push_back(new Cow()); // Thêm đối tượng Cow mới

    cout << "Nghe xem các con vật trong nông trại kêu gì nào:\n";
    // Lặp qua vector và gọi hàm sound() thông qua con trỏ Animal*
    for (const auto& animal_ptr : farm) {
        animal_ptr->sound(); // Nhờ đa hình, hàm sound() đúng của từng đối tượng sẽ được gọi
    }

    // Rất quan trọng: Dọn dẹp bộ nhớ đã cấp phát động
    for (const auto& animal_ptr : farm) {
        delete animal_ptr;
    }
    farm.clear(); // Xóa hết các con trỏ khỏi vector

    return 0;
}

Kết quả:

Nghe xem các con vật trong nông trại kêu gì nào:
Woof!
Meow!
Moo!
Animal destructor called
Cat destructor called
Animal destructor called
Dog destructor called
Animal destructor called

Trong ví dụ này, vòng lặp for không cần biết chính xác đối tượng là Dog, Cat hay Cow. Nó chỉ làm việc với con trỏ Animal* và gọi hàm sound(). Nhờ hàm ảo, C++ tự động đảm bảo rằng phiên bản sound() phù hợp với kiểu thực tế của đối tượng được gọi. Khi chúng ta thêm lớp Cow, vòng lặp này không hề thay đổi! Đây là minh chứng rõ ràng nhất về khả năng mở rộng mà đa hình mang lại.

Cơ chế hoạt động (Sơ lược): VTable

Dù không bắt buộc phải hiểu sâu về cơ chế bên dưới, biết một chút về Virtual Table (VTable) có thể giúp củng cố kiến thức.

Khi một lớp có ít nhất một hàm ảo, trình biên dịch sẽ tạo ra một bảng được gọi là VTable cho lớp đó. VTable là một mảng các con trỏ hàm, mỗi con trỏ trỏ đến phiên bản của hàm ảo tương ứng cho lớp đó.

Mỗi đối tượng của lớp đó (hoặc các lớp phái sinh của nó) sẽ có một con trỏ ẩn (thường gọi là vptr) trỏ đến VTable của lớp thực tế của đối tượng đó.

Khi bạn gọi một hàm ảo thông qua một con trỏ hoặc tham chiếu của lớp cơ sở (ví dụ: ptrAnimal->sound()), chương trình sẽ làm những việc sau tại thời gian chạy:

  1. Truy cập con trỏ vptr trong đối tượng mà ptrAnimal đang trỏ tới để tìm VTable của lớp thực tế của đối tượng đó.
  2. Tìm trong VTable đó con trỏ hàm tương ứng với hàm sound(). Vị trí của con trỏ cho hàm sound() trong VTable là cố định cho tất cả các lớp trong cùng hệ thống phân cấp kế thừa.
  3. Sử dụng con trỏ hàm tìm được để gọi hàm sound() thực tế.

Quá trình tra cứu VTable này là lý do tại sao ràng buộc động lại có một chi phí nhỏ hơn so với ràng buộc tĩnh (chỉ là một vài lệnh truy cập bộ nhớ và nhảy). Tuy nhiên, trong hầu hết các ứng dụng, chi phí này là không đáng kể so với lợi ích về thiết kế và khả năng mở rộng mà đa hình mang lại.

Hàm ảo thuần túy (Pure Virtual Functions) và Lớp trừu tượng (Abstract Classes)

Mở rộng thêm một chút về hàm ảo, C++ cho phép bạn khai báo hàm ảo thuần túy. Đây là hàm ảo trong lớp cơ sở mà bạn không cung cấp cài đặt (implementation), chỉ khai báo nó và gán bằng 0.

class Shape {
public:
    // Hàm ảo thuần túy - Bắt buộc lớp phái sinh phải cài đặt hàm area()
    virtual double area() const = 0;

    virtual ~Shape() = default;
};

// Lớp Rectangle phải cài đặt hàm area()
class Rectangle : public Shape {
private:
    double width;
    double height;
public:
    Rectangle(double w, double h) : width(w), height(h) {}

    double area() const override {
        return width * height;
    }
};

// Lớp Circle cũng phải cài đặt hàm area()
class Circle : public Shape {
private:
    double radius;
public:
    Circle(double r) : radius(r) {}

    double area() const override {
        return 3.14159 * radius * radius;
    }
};

int main() {
    // Không thể tạo đối tượng của lớp Shape vì nó là lớp trừu tượng
    // Shape myShape; // Lỗi biên dịch!

    Shape* shapes[2];
    shapes[0] = new Rectangle(5, 10);
    shapes[1] = new Circle(7);

    cout << "Diện tích của hình chữ nhật: " << shapes[0]->area() << endl;
    cout << "Diện tích của hình tròn: " << shapes[1]->area() << endl;

    delete shapes[0];
    delete shapes[1];

    return 0;
}

Kết quả:

Diện tích của hình chữ nhật: 50
Diện tích của hình tròn: 153.935

Một lớp chứa ít nhất một hàm ảo thuần túy được gọi là lớp trừu tượng (abstract class). Bạn không thể tạo đối tượng của một lớp trừu tượng. Lớp trừu tượng chỉ tồn tại như một giao diện chung hoặc một "khuôn mẫu" để các lớp phái sinh kế thừa và cung cấp cài đặt cụ thể cho các hàm ảo thuần túy đó.

Hàm ảo thuần túy rất hữu ích khi bạn muốn đảm bảo rằng tất cả các lớp phái sinh phải cung cấp một cài đặt cụ thể cho một hành vi nào đó, nhưng bản thân lớp cơ sở không có cài đặt mặc định hợp lý cho hành vi đó (ví dụ: lớp Shape không biết cách tính diện tích nếu không biết nó là hình gì cụ thể).

Comments

There are no comments at the moment.