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>
using namespace std;
class Animal {
public:
string ten; // Thuộc tính chung
Animal(const string& n) : ten(n) {
cout << "Animal '" << ten << "' created.\n";
}
void an() const {
cout << ten << " is eating.\n";
}
virtual void keu() const {
cout << ten << " makes a generic sound.\n";
}
virtual ~Animal() {
cout << "Animal '" << ten << "' destroyed.\n";
}
};
class Dog : public Animal {
public:
Dog(const string& n) : Animal(n) {
cout << "Dog '" << ten << "' created.\n";
}
void keu() const override {
cout << ten << " barks loudly: Woof! Woof!\n";
}
void choi() const {
cout << ten << " fetches the ball.\n";
}
~Dog() override {
cout << "Dog '" << ten << "' destroyed.\n";
}
};
int main() {
cout << "*** Minh họa Kế thừa ***\n";
Dog c("Buddy"); // c cho "chó"
c.an();
c.keu();
c.choi();
cout << "*** Kết thúc Minh họa Kế thừa ***\n";
return 0;
}
Output:
*** Minh họa Kế thừa ***
Animal 'Buddy' created.
Dog 'Buddy' created.
Buddy is eating.
Buddy barks loudly: Woof! Woof!
Buddy fetches the ball.
Dog 'Buddy' destroyed.
Animal 'Buddy' destroyed.
*** Kết thúc Minh họa Kế thừa ***
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. c.an()
: Phương thứcan()
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ó.c.keu()
: Phương thứckeu()
đượ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.c.choi()
: Phương thứcchoi()
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.
#include <iostream>
#include <vector>
#include <cmath> // Dùng M_PI cho hình tròn
using namespace std;
class Hinh { // 'Shape'
public:
virtual double dt() const = 0; // 'calculateArea'
virtual ~Hinh() {
cout << "Shape destructor called.\n";
}
};
class Tron : public Hinh { // 'Circle'
private:
double bk; // 'radius'
public:
Tron(double r) : bk(r) {}
double dt() const override {
return M_PI * bk * bk;
}
~Tron() override {
cout << "Circle destructor called.\n";
}
};
class Vuong : public Hinh { // 'Square'
private:
double c; // 'side'
public:
Vuong(double s) : c(s) {}
double dt() const override {
return c * c;
}
~Vuong() override {
cout << "Square destructor called.\n";
}
};
int main() {
cout << "\n*** Minh họa Đa hình ***\n";
vector<Hinh*> hinhs; // 'shapes'
hinhs.push_back(new Tron(5.0));
hinhs.push_back(new Vuong(4.0));
hinhs.push_back(new Tron(3.0));
cout << "Calculating areas of different shapes:\n";
for (const auto& h : hinhs) { // 'shape_ptr'
cout << " Area: " << h->dt() << "\n";
}
cout << "\nCleaning up memory:\n";
for (const auto& h : hinhs) {
delete h;
}
hinhs.clear();
cout << "*** Kết thúc Minh họa Đa hình ***\n";
return 0;
}
Output:
*** Minh họa Đa hình ***
Calculating areas of different shapes:
Area: 78.5398
Area: 16
Area: 28.2743
Cleaning up memory:
Circle destructor called.
Shape destructor called.
Square destructor called.
Shape destructor called.
Circle destructor called.
Shape destructor called.
*** Kết thúc Minh họa Đa hình ***
Giải thích code:
- Lớp
Hinh
được định nghĩa với một phương thứcvirtual double dt() const = 0;
. Dấu= 0
biếndt
thành hàm ảo thuần túy (_pure virtual function_), và do đóHinh
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
Tron
vàVuong
kế thừa từHinh
và bắt buộc phải triển khai phương thứcdt()
. - Trong
main()
, chúng ta tạo mộtvector<Hinh*>
. Đ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ừHinh
. - Khi lặp qua
hinhs
và gọih->dt()
, 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àh
đang trỏ tới (làTron
hayVuong
) và gọi đúng phương thứcdt
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 ~Hinh()
) trong lớp cha. Điều này đảm bảo khi bạn gọidelete h
, hàm hủy đúng của lớp con (như~Tron()
hoặc~Vuong()
) đượ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ủaHinh
đượ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
.
#include <iostream>
#include <vector>
#include <string>
#include <cmath>
using namespace std;
// Định nghĩa lại các lớp Animal, Dog, Hinh, Tron, Vuong ở đây
// để ví dụ này hoàn chỉnh nếu chạy độc lập
class Animal {
public:
string ten;
Animal(const string& n) : ten(n) {
cout << "Animal '" << ten << "' created.\n";
}
void an() const {
cout << ten << " is eating.\n";
}
virtual void keu() const {
cout << ten << " makes a generic sound.\n";
}
virtual ~Animal() {
cout << "Animal '" << ten << "' destroyed.\n";
}
};
class Dog : public Animal {
public:
Dog(const string& n) : Animal(n) {
cout << "Dog '" << ten << "' created.\n";
}
void keu() const override {
cout << ten << " barks loudly: Woof! Woof!\n";
}
void choi() const {
cout << ten << " fetches the ball.\n";
}
~Dog() override {
cout << "Dog '" << ten << "' destroyed.\n";
}
};
class Hinh { // 'Shape'
public:
virtual double dt() const = 0; // 'calculateArea'
virtual ~Hinh() {
cout << "Shape destructor called.\n";
}
};
class Tron : public Hinh { // 'Circle'
private:
double bk; // 'radius'
public:
Tron(double r) : bk(r) {}
double dt() const override {
return M_PI * bk * bk;
}
~Tron() override {
cout << "Circle destructor called.\n";
}
};
class Vuong : public Hinh { // 'Square'
private:
double c; // 'side'
public:
Vuong(double s) : c(s) {}
double dt() const override {
return c * c;
}
~Vuong() override {
cout << "Square destructor called.\n";
}
};
// Hàm sử dụng con trỏ lớp cơ sở - Thể hiện đa hình
void hienThiDt(const Hinh* h) { // 'displayArea', 'shape'
if (h) {
cout << "Area calculated via hienThiDt function: " << h->dt() << "\n";
} else {
cout << "Cannot display area for a null shape pointer.\n";
}
}
// Hàm sử dụng tham chiếu lớp cơ sở - Cũng thể hiện đa hình
void xuLyDv(Animal& dv) { // 'processAnimal', 'animal'
cout << "Processing animal: " << dv.ten << "\n";
dv.keu();
dv.an();
cout << "Processing finished.\n";
}
int main() {
cout << "\n*** Minh họa Kế thừa & Đa hình kết hợp trong Hàm ***\n";
// --- Minh họa với Hinh và hienThiDt ---
Tron htNho(2.5); // 'smallCircle'
Vuong hvLon(8.0); // 'bigSquare'
hienThiDt(&htNho);
hienThiDt(&hvLon);
cout << "\n";
// --- Minh họa với Animal và xuLyDv ---
Dog cMax("Max"); // 'maxDog'
xuLyDv(cMax);
// Ví dụ với vector Hinh* và hàm hienThiDt
vector<Hinh*> dsHinh; // 'reportShapes'
dsHinh.push_back(new Tron(1.0));
dsHinh.push_back(new Vuong(10.0));
dsHinh.push_back(new Tron(5.5));
cout << "\nProcessing shapes in vector using hienThiDt function:\n";
for (const auto& h : dsHinh) { // 's_ptr'
hienThiDt(h);
}
cout << "\nCleaning up memory for dsHinh vector:\n";
for (const auto& h : dsHinh) {
delete h;
}
dsHinh.clear();
cout << "*** Kết thúc Minh họa Kế thừa & Đa hình kết hợp ***\n";
return 0;
}
Output:
*** Minh họa Kế thừa & Đa hình kết hợp trong Hàm ***
Area calculated via hienThiDt function: 19.635
Area calculated via hienThiDt function: 64
Animal 'Max' created.
Dog 'Max' created.
Processing animal: Max
Max barks loudly: Woof! Woof!
Max is eating.
Processing finished.
Processing shapes in vector using hienThiDt function:
Area calculated via hienThiDt function: 3.14159
Area calculated via hienThiDt function: 100
Area calculated via hienThiDt function: 95.0332
Cleaning up memory for dsHinh vector:
Circle destructor called.
Shape destructor called.
Square destructor called.
Shape destructor called.
Circle destructor called.
Shape destructor called.
Dog 'Max' destroyed.
Animal 'Max' destroyed.
Square destructor called.
Shape destructor called.
Circle destructor called.
Shape destructor called.
*** Kết thúc Minh họa Kế thừa & Đa hình kết hợp ***
Giải thích code:
- Hàm
hienThiDt(const Hinh* h)
nhận một con trỏ tớiHinh
. Nhờ đa hình, khi bạn truyền địa chỉ của một đối tượngTron
hoặcVuong
vào hàm này, lời gọih->dt()
sẽ thực thi đúng phương thứcdt()
của lớpTron
hoặcVuong
tương ứng. HàmhienThiDt
không cần phải có code riêng cho từng loại hình học! - Tương tự, hàm
xuLyDv(Animal& dv)
nhận một tham chiếu tớiAnimal
. Khi truyền đối tượngDog
vào, lời gọidv.keu()
sẽ thực thiDog::keu()
. - Đ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<Hinh*>
). 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