Bài 36.5: Bài tập thực hành kế thừa và đa hình trong C++

Bài 36.5: Bài tập thực hành kế thừa và đa hình trong C++
Chào mừng bạn đến với bài thực hành quan trọng tiếp theo trong hành trình chinh phục C++! Hôm nay, chúng ta sẽ "đào sâu" vào hai trụ cột quan trọng nhất của Lập trình hướng đối tượng (OOP): Kế thừa và Đa hình. Đây không chỉ là những lý thuyết khô khan, mà là những công cụ cực kỳ mạnh mẽ giúp bạn xây dựng các ứng dụng linh hoạt, dễ mở rộng và dễ quản lý hơn rất nhiều.
Chúng ta sẽ không dừng lại ở định nghĩa, mà sẽ đi thẳng vào thực hành với nhiều ví dụ code C++ cụ thể để bạn có thể thấy chúng hoạt động như thế nào trong thế giới thực!
Kế Thừa (Inheritance): Xây dựng Quan Hệ "Là Một Loại Của"
Kế thừa cho phép bạn tạo ra một lớp mới (gọi là lớp con hay lớp kế thừa - _derived class_) dựa trên một lớp đã tồn tại (gọi là lớp cha hay lớp cơ sở - _base class_). Lớp con sẽ kế thừa các thuộc tính (dữ liệu) và hành vi (phương thức) từ lớp cha.
Hãy tưởng tượng thế giới động vật. Một con chó là một loại động vật. Một con mèo cũng là một loại động vật. Cả chó và mèo đều có những đặc điểm chung của động vật (có tên, biết ăn, biết kêu), nhưng mỗi loại lại có những đặc điểm và hành vi riêng biệt. Kế thừa giúp chúng ta mô hình hóa mối quan hệ "là một loại của" này một cách hiệu quả.
Ví dụ 1: Động Vật và Chó
Chúng ta sẽ định nghĩa một lớp Animal
(lớp cha) và một lớp Dog
(lớp con kế thừa từ Animal
).
#include <iostream>
#include <string>
#include <vector> // Sẽ dùng sau cho Đa hình
// Sử dụng namespace std để code ngắn gọn hơn
using namespace std;
// Lớp cơ sở (Base class) - Animal
class Animal {
public:
string name; // Thuộc tính chung
// Hàm khởi tạo của lớp cha
Animal(const string& n) : name(n) {
cout << "Animal '" << name << "' created." << endl;
}
// Phương thức chung
void eat() const {
cout << name << " is eating." << endl;
}
// Phương thức ảo (virtual) - Quan trọng cho Đa hình
// Mặc định mỗi loài kêu khác nhau
virtual void speak() const {
cout << name << " makes a generic sound." << endl;
}
// Hàm hủy ảo (virtual destructor) - RẤT QUAN TRỌNG khi làm việc với đa hình
virtual ~Animal() {
cout << "Animal '" << name << "' destroyed." << endl;
}
};
// Lớp kế thừa (Derived class) - Dog
// Cú pháp kế thừa: class DerivedClass : access_specifier BaseClass
class Dog : public Animal {
public:
// Hàm khởi tạo của lớp con, gọi hàm khởi tạo của lớp cha
Dog(const string& n) : Animal(n) {
cout << "Dog '" << name << "' created." << endl;
}
// Ghi đè (override) phương thức speak của lớp cha
// 'override' là từ khóa tùy chọn nhưng nên dùng để kiểm tra lỗi
void speak() const override {
cout << name << " barks loudly: Woof! Woof!" << endl;
}
// Phương thức riêng của lớp Dog
void fetch() const {
cout << name << " fetches the ball." << endl;
}
// Ghi đè hàm hủy của lớp cha (tùy chọn nhưng tốt)
~Dog() override {
cout << "Dog '" << name << "' destroyed." << endl;
}
};
// int main() { // Tạm thời comment main để tránh xung đột khi gộp code
// cout << "*** Minh họa Kế thừa ***" << endl;
// // Tạo một đối tượng Dog
// Dog myDog("Buddy");
// // Gọi phương thức được kế thừa từ lớp Animal
// myDog.eat();
// // Gọi phương thức đã được ghi đè trong lớp Dog
// myDog.speak();
// // Gọi phương thức riêng của lớp Dog
// myDog.fetch();
// cout << "*** Kết thúc Minh họa Kế thừa ***" << endl;
// return 0;
// }
Giải thích code:
- Lớp
Dog
kế thừapublic
từAnimal
. Điều này có nghĩa là tất cả các thành viênpublic
vàprotected
củaAnimal
đều trở thànhpublic
vàprotected
trongDog
(tương ứng). - Hàm khởi tạo của
Dog
(Dog(const string& n)
) phải gọi hàm khởi tạo tương ứng củaAnimal
(: Animal(n)
) để khởi tạo phần dữ liệu kế thừa. myDog.eat()
: Phương thứceat()
không được định nghĩa trongDog
, nhưng nó được kế thừa từAnimal
, nên chúng ta có thể gọi nó.myDog.speak()
: Phương thứcspeak()
được định nghĩa cả trongAnimal
vàDog
. Khi gọi trên đối tượngDog
, phiên bản trongDog
sẽ được sử dụng (đây gọi là ghi đè - overriding). Từ khóaoverride
giúp trình biên dịch kiểm tra xem bạn có thực sự ghi đè một phương thức ảo của lớp cha hay không.myDog.fetch()
: Phương thứcfetch()
chỉ tồn tại trong lớpDog
. Chúng ta chỉ có thể gọi nó trên đối tượng kiểuDog
.
Kế thừa giúp chúng ta tái sử dụng mã và định nghĩa một cấu trúc phân cấp rõ ràng cho các đối tượng của mình.
Đa Hình (Polymorphism): Một Giao Diện, Nhiều Hình Thái
Đa hình theo nghĩa đen có nghĩa là "nhiều hình thái". Trong C++, đa hình lúc chạy (_runtime polymorphism_) cho phép bạn sử dụng một con trỏ hoặc tham chiếu đến lớp cơ sở (Animal*
hoặc Animal&
) để trỏ đến các đối tượng của các lớp kế thừa khác nhau (Dog
, Cat
, Bird
...). Khi bạn gọi một phương thức ảo (_virtual method_) thông qua con trỏ/tham chiếu lớp cơ sở đó, phiên bản của phương thức trong lớp thực tế của đối tượng sẽ được thực thi, chứ không phải phiên bản của lớp cơ sở.
Đây chính là sức mạnh của đa hình: bạn có thể viết code tổng quát làm việc với các đối tượng thuộc một "gia đình" (cùng kế thừa từ một lớp cha) mà không cần biết chính xác kiểu cụ thể của chúng tại thời điểm viết code.
Để đa hình hoạt động, phương thức trong lớp cha phải được đánh dấu là virtual
. Nếu lớp con ghi đè phương thức đó, nó nên dùng từ khóa override
.
Ví dụ 2: Các Loại Hình Học
Hãy xem xét các hình học khác nhau như hình tròn và hình vuông. Chúng đều là "hình dạng" (Shape
) và đều có thể tính diện tích, nhưng cách tính thì khác nhau.
// Tiếp tục sử dụng các lớp đã khai báo ở trên (Animal, Dog)
// Bổ sung thêm các lớp Shape, Circle, Square
#include <iostream>
#include <vector>
#include <cmath> // Dùng M_PI cho hình tròn
using namespace std;
// Lớp cơ sở (Base class) - Shape
// Chúng ta có thể không muốn tạo đối tượng Shape thuần túy, chỉ muốn nó làm giao diện
// -> Sử dụng Pure virtual function (hàm ảo thuần túy) và biến Shape thành Abstract class
class Shape {
public:
// Phương thức ảo thuần túy (Pure virtual function)
// Bắt buộc các lớp con phải triển khai (implement) phương thức này
virtual double calculateArea() const = 0;
// Hàm hủy ảo - LUÔN LUÔN cần thiết khi lớp có phương thức ảo
virtual ~Shape() {
cout << "Shape destructor called." << endl;
}
};
// Lớp kế thừa (Derived class) - Circle
class Circle : public Shape {
private:
double radius;
public:
Circle(double r) : radius(r) {}
// Ghi đè phương thức ảo thuần túy
double calculateArea() const override {
return M_PI * radius * radius;
}
~Circle() override {
cout << "Circle destructor called." << endl;
}
};
// Lớp kế thừa (Derived class) - Square
class Square : public Shape {
private:
double side;
public:
Square(double s) : side(s) {}
// Ghi đè phương thức ảo thuần túy
double calculateArea() const override {
return side * side;
}
~Square() override {
cout << "Square destructor called." << endl;
}
};
// int main() { // Tạm thời comment main để tránh xung đột khi gộp code
// cout << "\n*** Minh họa Đa hình ***" << endl;
// // Tạo một vector chứa các con trỏ tới lớp cơ sở Shape
// vector<Shape*> shapes;
// // Thêm các đối tượng của các lớp kế thừa vào vector
// // Chúng ta đang lưu con trỏ đến Circle và Square,
// // nhưng vector chỉ biết chúng là Shape*
// shapes.push_back(new Circle(5.0)); // Thêm một hình tròn bán kính 5
// shapes.push_back(new Square(4.0)); // Thêm một hình vuông cạnh 4
// shapes.push_back(new Circle(3.0)); // Thêm một hình tròn bán kính 3
// // Lặp qua vector và gọi phương thức calculateArea() cho từng đối tượng
// cout << "Calculating areas of different shapes:" << endl;
// for (const auto& shape_ptr : shapes) {
// // Đây chính là đa hình!
// // shape_ptr là Shape*, nhưng C++ biết đối tượng thực sự là gì
// // và gọi đúng phương thức calculateArea() của Circle hoặc Square
// cout << " Area: " << shape_ptr->calculateArea() << endl;
// }
// // Dọn dẹp bộ nhớ đã cấp phát động
// cout << "\nCleaning up memory:" << endl;
// for (const auto& shape_ptr : shapes) {
// delete shape_ptr; // Hàm hủy ảo đảm bảo hàm hủy của lớp con được gọi
// }
// shapes.clear(); // Xóa các con trỏ khỏi vector (không bắt buộc sau delete)
// cout << "*** Kết thúc Minh họa Đa hình ***" << endl;
// return 0;
// }
Giải thích code:
- Lớp
Shape
được định nghĩa với một phương thứcvirtual double calculateArea() const = 0;
. Dấu= 0
biếncalculateArea
thành hàm ảo thuần túy (_pure virtual function_), và do đóShape
trở thành 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. Mục đích của nó là định nghĩa một giao diện chung mà các lớp con phải tuân theo. - Các lớp
Circle
vàSquare
kế thừa từShape
và bắt buộc phải triển khai phương thứccalculateArea()
. - Trong
main()
, chúng ta tạo mộtvector<Shape*>
. Điều này cho phép chúng ta lưu trữ các con trỏ đến bất kỳ đối tượng nào kế thừa từShape
. - Khi lặp qua
shapes
và gọishape_ptr->calculateArea()
, C++ (nhờ từ khóavirtual
và cơ chế V-table) sẽ tự động xác định loại đối tượng thực sự màshape_ptr
đang trỏ tới (làCircle
haySquare
) và gọi đúng phương thứccalculateArea
tương ứng của lớp đó. Đây là ví dụ điển hình và mạnh mẽ nhất của đa hình lúc chạy. - Lưu ý quan trọng: Chúng ta phải cấp phát đối tượng bằng
new
và dùng con trỏ để đưa vào vector, và phải có hàm hủy ảo (virtual ~Shape()
) trong lớp cha. Điều này đảm bảo khi bạn gọidelete shape_ptr
, hàm hủy đúng của lớp con (như~Circle()
hoặc~Square()
) được gọi trước, sau đó mới đến hàm hủy của lớp cha. Nếu không có hàm hủy ảo, chỉ hàm hủy củaShape
được gọi, dẫn đến rò rỉ bộ nhớ (_memory leak_) nếu lớp con có tài nguyên cần giải phóng.
Kế Thừa và Đa Hình: Cặp Đôi Hoàn Hảo
Kế thừa và đa hình thường đi đôi với nhau. Kế thừa tạo ra một cấu trúc phân cấp các lớp có liên quan, còn đa hình cho phép bạn tương tác với các lớp đó một cách thống nhất thông qua giao diện của lớp cha. Điều này giúp code của bạn trở nên:
- Linh hoạt: Dễ dàng thêm các loại đối tượng mới (lớp con mới) mà không cần thay đổi nhiều code hiện có (chỉ cần thêm lớp mới và đưa đối tượng của nó vào container/sử dụng con trỏ lớp cha).
- Dễ bảo trì: Thay đổi chi tiết triển khai của một lớp con không ảnh hưởng đến code sử dụng con trỏ lớp cha.
- Dễ mở rộng: Bạn có thể thêm các chức năng mới thông qua các lớp con mới một cách độc lập.
Ví dụ 3: Sử dụng Đa hình trong Hàm
Hãy xem cách đa hình cho phép chúng ta viết một hàm có thể xử lý bất kỳ đối tượng nào thuộc họ Shape
.
// Tiếp tục sử dụng các lớp đã khai báo ở trên (Animal, Dog, Shape, Circle, Square)
// ... (Bao gồm định nghĩa các lớp Animal, Dog, Shape, Circle, Square ở đây cho đầy đủ) ...
#include <iostream>
#include <vector>
#include <string>
#include <cmath>
using namespace std;
// (Định nghĩa lại các lớp Animal, Dog, Shape, Circle, Square ở đây
// để ví dụ này hoàn chỉnh nếu chạy độc lập, hoặc giả định chúng đã được định nghĩa)
// Hàm sử dụng con trỏ lớp cơ sở - Thể hiện đa hình
void displayArea(const Shape* shape) {
if (shape) { // Kiểm tra con trỏ có hợp lệ không
// Gọi phương thức ảo thông qua con trỏ lớp cơ sở
// Đa hình đảm bảo phương thức đúng của đối tượng thực được gọi
cout << "Area calculated via displayArea function: " << shape->calculateArea() << endl;
} else {
cout << "Cannot display area for a null shape pointer." << endl;
}
}
// Hàm sử dụng tham chiếu lớp cơ sở - Cũng thể hiện đa hình
void processAnimal(Animal& animal) {
cout << "Processing animal: " << animal.name << endl;
// Gọi phương thức ảo thông qua tham chiếu lớp cơ sở
animal.speak(); // Sẽ gọi speak() của Dog nếu đối tượng thực là Dog
animal.eat(); // Gọi phương thức không ảo, luôn gọi của lớp Animal (hoặc lớp con nếu override)
cout << "Processing finished." << endl;
}
int main() {
cout << "\n*** Minh họa Kế thừa & Đa hình kết hợp trong Hàm ***" << endl;
// --- Minh họa với Shape và displayArea ---
Circle smallCircle(2.5);
Square bigSquare(8.0);
// Truyền địa chỉ của đối tượng lớp con vào hàm nhận con trỏ lớp cha
displayArea(&smallCircle); // Hàm displayArea không cần biết nó đang xử lý Circle hay Square
displayArea(&bigSquare);
cout << endl; // Xuống dòng
// --- Minh họa với Animal và processAnimal ---
Dog maxDog("Max");
// Animal genericPet("Pet"); // Không thể gọi processAnimal(&genericPet) nếu processAnimal nhận Dog&
// Truyền đối tượng lớp con vào hàm nhận tham chiếu lớp cha
processAnimal(maxDog); // Hàm processAnimal không cần biết nó đang xử lý Dog
// Ví dụ với vector Shape* và hàm displayArea
vector<Shape*> reportShapes;
reportShapes.push_back(new Circle(1.0));
reportShapes.push_back(new Square(10.0));
reportShapes.push_back(new Circle(5.5));
cout << "\nProcessing shapes in vector using displayArea function:" << endl;
for (const auto& s_ptr : reportShapes) {
displayArea(s_ptr); // Gọi hàm cho từng đối tượng trong vector
}
// Dọn dẹp bộ nhớ
cout << "\nCleaning up memory for reportShapes vector:" << endl;
for (const auto& s_ptr : reportShapes) {
delete s_ptr;
}
reportShapes.clear();
cout << "*** Kết thúc Minh họa Kế thừa & Đa hình kết hợp ***" << endl;
return 0;
}
Giải thích code:
- Hàm
displayArea(const Shape* shape)
nhận một con trỏ tớiShape
. Nhờ đa hình, khi bạn truyền địa chỉ của một đối tượngCircle
hoặcSquare
vào hàm này, lời gọishape->calculateArea()
sẽ thực thi đúng phương thứccalculateArea()
của lớpCircle
hoặcSquare
tương ứng. HàmdisplayArea
không cần phải có code riêng cho từng loại hình học! - Tương tự, hàm
processAnimal(Animal& animal)
nhận một tham chiếu tớiAnimal
. Khi truyền đối tượngDog
vào, lời gọianimal.speak()
sẽ thực thiDog::speak()
. - Điều này cho thấy bạn có thể viết các hàm, lớp, hoặc container làm việc với một giao diện chung (lớp cha), và chúng sẽ tự động hoạt động với bất kỳ đối tượng cụ thể nào kế thừa và triển khai giao diện đó. Đây là một nguyên lý thiết kế OOP rất mạnh mẽ.
Những Lưu Ý Quan Trọng Khi Thực Hành
- Từ khóa
virtual
: Bắt buộc phải có ở phương thức trong lớp cha nếu bạn muốn nó có hành vi đa hình qua con trỏ/tham chiếu lớp cha. - Từ khóa
override
: Nên dùng trong lớp con khi ghi đè phương thức ảo của lớp cha để trình biên dịch giúp bạn kiểm tra. - Con trỏ/Tham chiếu lớp cơ sở: Đa hình lúc chạy chỉ xảy ra khi bạn truy cập đối tượng của lớp kế thừa thông qua con trỏ hoặc tham chiếu tới lớp cơ sở của nó. Truy cập trực tiếp qua đối tượng lớp con sẽ luôn gọi phương thức của lớp con (hoặc lớp cha nếu không ghi đè).
- Hàm hủy ảo
virtual ~BaseClass()
: Cực kỳ quan trọng nếu bạn cấp phát động các đối tượng kế thừa và lưu trữ chúng trong con trỏ/tham chiếu lớp cơ sở (như trongvector<Shape*>
). Nó đảm bảo rằng khi bạndelete
con trỏ lớp cơ sở trỏ đến đối tượng lớp con, hàm hủy đúng của lớp con sẽ được gọi trước, ngăn chặn rò rỉ bộ nhớ. - Lớp trừu tượng (
Abstract Class
): Một lớp có ít nhất một hàm ảo thuần túy (= 0
). Bạn không thể tạo đối tượng của lớp trừu tượng. Chúng dùng để định nghĩa giao diện bắt buộc cho các lớp con. - Hàm ảo thuần túy (
Pure Virtual Function
): Một hàm ảo được khai báo với= 0
. Lớp chứa nó là lớp trừu tượng. Các lớp con không trừu tượng bắt buộc phải triển khai hàm này.
Việc nắm vững và áp dụng kế thừa và đa hình sẽ mở ra rất nhiều khả năng trong việc thiết kế và xây dựng các hệ thống phần mềm phức tạp bằng C++. Hãy dành thời gian thực hành với các ví dụ này và thử nghiệm các kịch bản khác nhau để củng cố kiến thức của bạn!
Comments