Bài 36.3: Các loại đa hình trong lập trình hướng đối tượng trong C++

Bài 36.3: Các loại đa hình trong lập trình hướng đối tượng trong C++
Chào mừng trở lại với series blog về C++! Hôm nay, chúng ta sẽ đào sâu vào một trong bốn trụ cột quan trọng nhất của Lập trình Hướng đối tượng (OOP) trong C++: Đa hình (Polymorphism). Nếu bạn đã nắm vững Đóng gói (Encapsulation) và Kế thừa (Inheritance), thì đa hình chính là mảnh ghép tiếp theo giúp code của bạn trở nên linh hoạt, dễ mở rộng và dễ bảo trì hơn rất nhiều.
Nói một cách đơn giản, đa hình có nghĩa là "nhiều hình thức" (many forms). Trong bối cảnh lập trình hướng đối tượng, nó cho phép các đối tượng thuộc các lớp khác nhau phản ứng với cùng một thông điệp (ví dụ: gọi một phương thức) theo cách riêng của chúng. Điều này có nghĩa là bạn có thể sử dụng một giao diện chung để làm việc với các đối tượng khác nhau.
C++ hỗ trợ hai loại đa hình chính:
- Đa hình tĩnh (Static Polymorphism): Xảy ra tại thời gian biên dịch (compile-time).
- Đa hình động (Dynamic Polymorphism): Xảy ra tại thời gian chạy (run-time).
Hãy cùng tìm hiểu chi tiết từng loại nhé!
1. Đa hình tĩnh (Static Polymorphism) hay Đa hình thời gian biên dịch
Đa hình tĩnh là loại đa hình mà trình biên dịch C++ có thể xác định được hàm hoặc toán tử nào sẽ được gọi ngay tại thời điểm biên dịch. Điều này làm cho việc gọi hàm nhanh hơn vì không cần phải xác định lúc chương trình đang chạy.
Hai cơ chế chính để đạt được đa hình tĩnh trong C++ là:
- Nạp chồng hàm (Function Overloading)
- Nạp chồng toán tử (Operator Overloading)
1.1. Nạp chồng hàm (Function Overloading)
Nạp chồng hàm cho phép bạn định nghĩa nhiều hàm cùng tên trong cùng một phạm vi (ví dụ: trong cùng một lớp hoặc cùng một namespace), miễn là chúng khác nhau về danh sách tham số. Danh sách tham số có thể khác nhau về:
- Số lượng tham số.
- Kiểu dữ liệu của tham số.
- Thứ tự của các kiểu dữ liệu tham số.
Trình biên dịch sẽ dựa vào các đối số được truyền khi gọi hàm để quyết định phiên bản hàm nào sẽ được thực thi.
Ví dụ minh họa nạp chồng hàm:
#include <iostream>
#include <string>
// Hàm add cho hai số nguyên
int add(int a, int b) {
cout << "Calling add(int, int)" << endl;
return a + b;
}
// Hàm add cho hai số thực
double add(double a, double b) {
cout << "Calling add(double, double)" << endl;
return a + b;
}
// Hàm add cho ba số nguyên
int add(int a, int b, int c) {
cout << "Calling add(int, int, int)" << endl;
return a + b + c;
}
// Hàm add cho hai chuỗi (nối chuỗi)
string add(string s1, string s2) {
cout << "Calling add(string, string)" << endl;
return s1 + s2;
}
int main() {
cout << "Result 1: " << add(5, 10) << endl; // Calls add(int, int)
cout << "Result 2: " << add(5.5, 10.1) << endl; // Calls add(double, double)
cout << "Result 3: " << add(1, 2, 3) << endl; // Calls add(int, int, int)
cout << "Result 4: " << add("Hello, ", "World!") << endl; // Calls add(string, string)
return 0;
}
Giải thích code:
Chúng ta có bốn hàm cùng tên add
, nhưng mỗi hàm có một signature (danh sách tham số) khác nhau.
add(int, int)
add(double, double)
add(int, int, int)
add(string, string)
Khi bạn gọi add(5, 10)
, trình biên dịch thấy hai đối số là số nguyên, nên nó chọn phiên bản add(int, int)
.
Khi bạn gọi add(5.5, 10.1)
, trình biên dịch thấy hai đối số là số thực, nên nó chọn phiên bản add(double, double)
.
Tương tự với các lời gọi hàm còn lại. Việc lựa chọn này xảy ra hoàn toàn tại thời điểm biên dịch. Đây chính là đa hình tĩnh.
1.2. Nạp chồng toán tử (Operator Overloading)
Nạp chồng toán tử cho phép bạn định nghĩa lại ý nghĩa của các toán tử C++ hiện có (như +
, -
, *
, /
, ==
, !=
, <<
, >>
, v.v.) khi chúng được sử dụng với các kiểu dữ liệu do bạn định nghĩa (các lớp hoặc struct). Bạn không thể tạo toán tử mới hoặc thay đổi ý nghĩa của toán tử khi dùng với các kiểu dữ liệu cơ bản (int, double, v.v.).
Việc nạp chồng toán tử giúp làm cho code của bạn trở nên trực quan và dễ đọc hơn khi làm việc với các đối tượng phức tạp.
Ví dụ minh họa nạp chồng toán tử:
Hãy định nghĩa một lớp Point
và nạp chồng toán tử +
để cộng hai điểm.
#include <iostream>
class Point {
private:
int x, y;
public:
// Constructor
Point(int x = 0, int y = 0) : x(x), y(y) {}
// Phương thức để hiển thị điểm
void display() const {
cout << "(" << x << ", " << y << ")";
}
// Nạp chồng toán tử +
// Đây là phương thức của lớp, toán tử + được gọi với đối tượng hiện tại
// và đối số 'other'.
Point operator+(const Point& other) const {
cout << "\nCalling operator+ for Points" << endl;
Point result;
result.x = this->x + other.x;
result.y = this->y + other.y;
return result;
}
};
int main() {
Point p1(1, 2);
Point p2(3, 4);
cout << "Point p1: ";
p1.display();
cout << endl;
cout << "Point p2: ";
p2.display();
cout << endl;
// Sử dụng toán tử + đã nạp chồng
Point p3 = p1 + p2; // Tương đương với p1.operator+(p2);
cout << "Point p3 (p1 + p2): ";
p3.display();
cout << endl;
return 0;
}
Giải thích code:
Chúng ta định nghĩa lớp Point
với hai thành viên x
và y
.
Chúng ta định nghĩa một phương thức operator+
bên trong lớp Point
. Phương thức này nhận một đối tượng Point
khác làm đối số (other
).
Khi dòng Point p3 = p1 + p2;
được thực thi, trình biên dịch nhận ra rằng toán tử +
đang được sử dụng giữa hai đối tượng kiểu Point
. Nó sẽ tìm và gọi phương thức operator+
của lớp Point
trên đối tượng p1
, truyền p2
làm đối số.
Việc trình biên dịch biết phải gọi hàm operator+
nào tại thời điểm biên dịch dựa vào kiểu dữ liệu của các toán hạng (p1
và p2
đều là Point
) chính là đa hình tĩnh.
2. Đa hình động (Dynamic Polymorphism) hay Đa hình thời gian chạy
Đa hình động là loại đa hình mà quyết định về hàm nào sẽ được gọi được đưa ra 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/tham chiếu đến.
Để đạt được đa hình động trong C++, bạn cần sử dụng:
- Kế thừa (Inheritance): Cần có mối quan hệ lớp cha - lớp con.
- Hàm ảo (Virtual Functions): Các hàm trong lớp cơ sở (lớp cha) cần được đánh dấu là
virtual
. - Con trỏ (Pointers) hoặc Tham chiếu (References): Bạn cần sử dụng con trỏ hoặc tham chiếu đến lớp cơ sở để trỏ đến các đối tượng của lớp dẫn xuất (lớp con).
Điểm mấu chốt là: 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ở, hệ thống run-time (thông qua cơ chế gọi là vtable - virtual table) sẽ xác định phiên bản hàm nào cần gọi dựa trên kiểu thực tế của đối tượng, chứ không phải kiểu của con trỏ/tham chiếu. Quá trình này được gọi là liên kết động (dynamic binding) hoặc liên kết trễ (late binding).
Ví dụ minh họa hàm ảo và đa hình động:
Hãy tạo một lớp cơ sở Shape
và các lớp dẫn xuất Circle
và Square
. Mỗi hình có một cách vẽ khác nhau.
#include <iostream>
// Lớp cơ sở Shape
class Shape {
public:
// Hàm draw() được đánh dấu là virtual
// Điều này cho phép các lớp con ghi đè (override) hành vi này
// và đảm bảo rằng phiên bản của lớp con sẽ được gọi
// khi sử dụng con trỏ/tham chiếu của lớp cơ sở.
virtual void draw() const {
cout << "Drawing a generic Shape." << endl;
}
// Destructor cũng nên là virtual nếu lớp có hàm ảo
virtual ~Shape() {
cout << "Shape destructor called." << endl;
}
};
// Lớp dẫn xuất Circle
class Circle : public Shape {
public:
// Ghi đè (override) hàm draw() của lớp cơ sở
void draw() const override { // 'override' keyword là tùy chọn nhưng nên dùng để kiểm tra
cout << "Drawing a Circle." << endl;
}
~Circle() override {
cout << "Circle destructor called." << endl;
}
};
// Lớp dẫn xuất Square
class Square : public Shape {
public:
// Ghi đè (override) hàm draw() của lớp cơ sở
void draw() const override {
cout << "Drawing a Square." << endl;
}
~Square() override {
cout << "Square destructor called." << endl;
}
};
int main() {
// Tạo đối tượng của các lớp dẫn xuất
Circle myCircle;
Square mySquare;
Shape genericShape;
// Sử dụng con trỏ của lớp cơ sở (Shape*)
Shape* shape1 = &myCircle; // Con trỏ Shape* trỏ tới đối tượng Circle
Shape* shape2 = &mySquare; // Con trỏ Shape* trỏ tới đối tượng Square
Shape* shape3 = &genericShape; // Con trỏ Shape* trỏ tới đối tượng Shape
cout << "--- Using base class pointers ---" << endl;
// Gọi hàm draw() thông qua con trỏ Shape*
// Nhờ hàm draw() là virtual, C++ sẽ gọi phiên bản draw()
// TƯƠNG ỨNG VỚI KIỂU THỰC TẾ của đối tượng mà con trỏ đang trỏ tới.
shape1->draw(); // Gọi draw() của Circle
shape2->draw(); // Gọi draw() của Square
shape3->draw(); // Gọi draw() của Shape
cout << "--- Using base class references ---" << endl;
// Tương tự với tham chiếu của lớp cơ sở (Shape&)
Shape& ref1 = myCircle; // Tham chiếu Shape& tham chiếu tới đối tượng Circle
Shape& ref2 = mySquare; // Tham chiếu Shape& tham chiếu tới đối tượng Square
Shape& ref3 = genericShape; // Tham chiếu Shape& tham chiếu tới đối tượng Shape
ref1.draw(); // Gọi draw() của Circle
ref2.draw(); // Gọi draw() của Square
ref3.draw(); // Gọi draw() của Shape
cout << "--- Using actual object types (no polymorphism here) ---" << endl;
// Khi gọi trực tiếp trên đối tượng hoặc con trỏ/tham chiếu cùng kiểu,
// không cần đến đa hình động
myCircle.draw(); // Gọi draw() của Circle
mySquare.draw(); // Gọi draw() của Square
genericShape.draw(); // Gọi draw() của Shape
return 0;
}
Giải thích code:
Chúng ta có lớp cơ sở Shape
với một phương thức draw()
được đánh dấu là virtual
. Các lớp dẫn xuất Circle
và Square
kế thừa từ Shape
và cung cấp cài đặt riêng cho phương thức draw()
. Từ khóa override
giúp trình biên dịch kiểm tra xem bạn có thực sự đang ghi đè một hàm ảo từ lớp cơ sở hay không. Destructor cũng được đánh dấu virtual
để đảm bảo việc giải phóng bộ nhớ đúng cách khi xóa các đối tượng dẫn xuất thông qua con trỏ lớp cơ sở.
Trong hàm main
, chúng ta tạo các đối tượng myCircle
, mySquare
, genericShape
.
Sau đó, chúng ta tạo các con trỏ shape1
, shape2
, shape3
đều có kiểu Shape*
, nhưng chúng lại trỏ đến các đối tượng có kiểu thực tế là Circle
, Square
, và Shape
tương ứng.
Khi chúng ta gọi shape1->draw()
, mặc dù shape1
là con trỏ Shape*
, nhờ draw()
là hàm ảo, hệ thống run-time sẽ kiểm tra kiểu thực tế của đối tượng mà shape1
đang trỏ tới (là Circle
) và gọi phiên bản draw()
của lớp Circle
.
Tương tự, shape2->draw()
gọi draw()
của Square
, và shape3->draw()
gọi draw()
của Shape
.
Đây chính là đa hình động: cùng một lời gọi shape->draw()
, nhưng hành vi thực tế lại khác nhau tùy thuộc vào kiểu đối tượng mà shape
đang trỏ tới tại thời gian chạy. Điều này mang lại sự linh hoạt cực kỳ lớn, cho phép bạn viết code làm việc với một tập hợp các đối tượng đa dạng thông qua một giao diện thống nhất (trong ví dụ này là con trỏ/tham chiếu Shape*
/Shape&
).
Sự khác biệt với liên kết tĩnh (static binding) (xảy ra khi hàm không phải là virtual
hoặc khi gọi trực tiếp trên đối tượng): nếu draw()
không phải là virtual
, thì shape1->draw()
và shape2->draw()
sẽ đều gọi phiên bản draw()
của lớp Shape
vì kiểu của con trỏ là Shape*
, bất kể đối tượng thực tế là gì.
Tổng kết về hai loại Đa hình
Đặc điểm | Đa hình tĩnh (Static Polymorphism) | Đa hình động (Dynamic Polymorphism) |
---|---|---|
Thời điểm | Thời gian biên dịch (Compile-time) | Thời gian chạy (Run-time) |
Cách đạt được | Nạp chồng hàm (Function Overloading), Nạp chồng toán tử (Operator Overloading) | Hàm ảo (Virtual Functions), Kế thừa, Con trỏ/Tham chiếu của lớp cơ sở |
Cơ chế liên kết | Liên kết tĩnh (Static Binding) | Liên kết động (Dynamic Binding) |
Hiệu năng | Nhanh hơn | Chậm hơn một chút (do tra cứu vtable) |
Tính linh hoạt | Ít linh hoạt hơn | Linh hoạt hơn, cho phép xử lý đối tượng không biết trước kiểu cụ thể |
Yêu cầu | Cùng tên hàm/toán tử, khác signature | Kế thừa, hàm ảo, dùng con trỏ/tham chiếu lớp cơ sở |
Hiểu rõ và áp dụng đa hình sẽ giúp bạn thiết kế các hệ thống OOP mạnh mẽ và dễ quản lý hơn trong C++. Nó cho phép bạn viết code xử lý các đối tượng ở mức trừu tượng (thông qua lớp cơ sở), và hệ thống sẽ tự động gọi đúng phiên bản hàm của lớp dẫn xuất cụ thể.
Comments