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

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ể.
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 << "Tieng dong vat chung\n"; } }; class Dog : public Animal { public: void sound() { cout << "Gau!\n"; } }; int main() { Dog cho; Animal* a = &cho; a->sound(); return 0; }
Tieng dong vat chung
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ủaptrAnimal
, đó làAnimal*
. Nó thấyAnimal
có hàmsound()
, nên nó "buộc" lời gọiptrAnimal->sound()
vàoAnimal::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ở).
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:
virtual void sound() {
cout << "Tieng dong vat chung\n";
}
virtual ~Animal() {
cout << "Huy Animal\n";
}
};
class Dog : public Animal {
public:
void sound() override {
cout << "Gau!\n";
}
~Dog() override {
cout << "Huy Dog\n";
}
};
class Cat : public Animal {
public:
void sound() override {
cout << "Meo!\n";
}
~Cat() override {
cout << "Huy Cat\n";
}
};
int main() {
Animal* cho = new Dog();
Animal* meo = new Cat();
cout << "Goi am thanh qua Animal*:\n";
cho->sound();
meo->sound();
delete cho;
delete meo;
return 0;
}
Goi am thanh qua Animal*:
Gau!
Meo!
Huy Dog
Huy Animal
Huy Cat
Huy Animal
Giải thích:
- Chúng ta khai báo
sound()
làvirtual
trong lớpAnimal
. - Lớp
Dog
vàCat
ghi đè hàmsound()
. - Trong
main
, chúng ta tạo con trỏAnimal*
nhưng trỏ tới đối tượngDog
vàCat
. - Khi gọi
ptrDog->sound()
vàptrCat->sound()
, trình biên dịch biết rằngsound()
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 (Dog
vàCat
) và gọi đúng phiên bảnsound()
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àmsound()
riêng), bạn không cần thay đổi mã hiện có đã sử dụng con trỏ/tham chiếuAnimal*
. Mã đó sẽ tự động hoạt động với đối tượngCow
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>
class Animal {
public:
virtual void sound() const {
cout << "Tieng chung\n";
}
virtual ~Animal() {
cout << "Huy Animal\n";
}
};
class Dog : public Animal {
public:
void sound() const override {
cout << "Gau!\n";
}
~Dog() override {
cout << "Huy Dog\n";
}
};
class Cat : public Animal {
public:
void sound() const override {
cout << "Meo!\n";
}
~Cat() override {
cout << "Huy Cat\n";
}
};
class Cow : public Animal {
public:
void sound() const override {
cout << "Bo!\n";
}
~Cow() override {
cout << "Huy Cow\n";
}
};
int main() {
vector<Animal*> nn;
nn.push_back(new Dog());
nn.push_back(new Cat());
nn.push_back(new Cow());
cout << "Nghe cac con vat keu:\n";
for (const auto& a : nn) {
a->sound();
}
for (const auto& a : nn) {
delete a;
}
nn.clear();
return 0;
}
Nghe cac con vat keu:
Gau!
Meo!
Bo!
Huy Dog
Huy Animal
Huy Cat
Huy Animal
Huy Cow
Huy Animal
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:
- 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 đó. - Tìm trong VTable đó con trỏ hàm tương ứng với hàm
sound()
. Vị trí của con trỏ cho hàmsound()
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. - 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
.
#include <iostream>
class Shape {
public:
virtual double area() const = 0;
virtual ~Shape() = default;
};
class Rectangle : public Shape {
private:
double r, c;
public:
Rectangle(double w, double h) : r(w), c(h) {}
double area() const override {
return r * c;
}
};
class Circle : public Shape {
private:
double bk;
public:
Circle(double rad) : bk(rad) {}
double area() const override {
return 3.14159 * bk * bk;
}
};
int main() {
Shape* h[2];
h[0] = new Rectangle(5, 10);
h[1] = new Circle(7);
cout << "DT HCN: " << h[0]->area() << endl;
cout << "DT Hinh tron: " << h[1]->area() << endl;
delete h[0];
delete h[1];
return 0;
}
DT HCN: 50
DT Hinh tron: 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