Bài 38.2: Bài tập thực hành kế thừa trong C++

Bài 38.2: Bài tập thực hành kế thừa trong C++
Chào mừng bạn trở lại với chuỗi bài viết về C++! Sau khi đã cùng nhau tìm hiểu về lý thuyết của kế thừa (Inheritance) – một trụ cột quan trọng của Lập trình hướng đối tượng (OOP), hôm nay chúng ta sẽ bắt tay vào thực hành. Lý thuyết khô khan có thể khó thấm, nhưng khi áp dụng vào các bài tập cụ thể, bạn sẽ thấy kế thừa mạnh mẽ và hữu ích như thế nào trong việc xây dựng các ứng dụng thực tế.
Hãy cùng đi sâu vào các ví dụ để củng cố kiến thức và làm quen với việc sử dụng kế thừa trong C++ nhé!
Kế Thừa Là Gì? (Tóm tắt nhanh)
Trước khi bắt đầu thực hành, chúng ta cùng điểm lại một chút về kế thừa. Đơn giản nhất, kế thừa cho phép bạn tạo ra một lớp mới (lớp dẫn xuất - Derived Class) dựa trên một lớp đã có (lớp cơ sở - Base Class). Lớp dẫn xuất sẽ thừa hưởng (kế thừa) các thuộc tính (dữ liệu thành viên) và hành vi (phương thức thành viên) của lớp cơ sở. Điều này giúp chúng ta:
- Tái sử dụng mã nguồn: Không cần viết lại những thứ chung cho nhiều lớp.
- Tổ chức mã nguồn: Tạo ra một cấu trúc phân cấp rõ ràng, phản ánh mối quan hệ "là một" (is-a) trong thế giới thực (ví dụ: Một con chó là một loài động vật).
Trong C++, chúng ta sử dụng cú pháp sau để khai báo lớp dẫn xuất:
class TenLopDanXuat : public TenLopCoSo {
// Các thành viên riêng của lớp dẫn xuất
};
Trong đó public
, protected
, hoặc private
là các chỉ định truy cập (access specifier) cho việc kế thừa, quyết định cách các thành viên của lớp cơ sở sẽ được truy cập trong lớp dẫn xuất và từ bên ngoài lớp dẫn xuất. Phổ biến nhất là public inheritance
, giữ nguyên mức độ truy cập của các thành viên công khai (public
) và được bảo vệ (protected
) của lớp cơ sở trong lớp dẫn xuất.
Bây giờ, hãy bắt đầu thực hành thôi!
Bài Tập 1: Kế thừa cơ bản - Động vật và Chó
Mục tiêu: Hiểu cách lớp dẫn xuất kế thừa thuộc tính và phương thức công khai từ lớp cơ sở.
Bài toán: Chúng ta có một lớp cơ sở Animal
với thuộc tính chung là tuổi (age
) và một phương thức eat()
. Tạo một lớp dẫn xuất Dog
kế thừa từ Animal
, thêm thuộc tính riêng là giống (breed
) và một phương thức riêng bark()
.
Code:
#include <iostream>
#include <string>
using namespace std;
class Animal {
public:
int tuoi;
Animal(int t) : tuoi(t) {
cout << "Animal constructor called with age: " << tuoi << endl;
}
void an() const {
cout << "This animal is eating." << endl;
}
~Animal() {
cout << "Animal destructor called." << endl;
}
};
class Dog : public Animal {
public:
string giong;
Dog(int t, const string& gCho) : Animal(t), giong(gCho) {
cout << "Dog constructor called for breed: " << giong << endl;
}
void sua() const {
cout << "Woof! Woof!" << endl;
}
~Dog() {
cout << "Dog destructor called." << endl;
}
};
int main() {
Dog cho(3, "Labrador");
cout << "My dog is " << cho.tuoi << " years old and is a " << cho.giong << "." << endl;
cho.an();
cho.sua();
return 0;
}
Output:
Animal constructor called with age: 3
Dog constructor called for breed: Labrador
My dog is 3 years old and is a Labrador.
This animal is eating.
Woof! Woof!
Dog destructor called.
Animal destructor called.
Giải thích:
- Lớp
Animal
có một thuộc tínhage
và phương thứceat()
, cả hai đều làpublic
. - Lớp
Dog
được khai báo kế thừapublic
từAnimal
. Điều này có nghĩa là:- Các thành viên
public
củaAnimal
(age
,eat()
) vẫn làpublic
trongDog
. - Các thành viên
protected
củaAnimal
(nếu có) sẽ làprotected
trongDog
. - Các thành viên
private
củaAnimal
không thể truy cập trực tiếp trongDog
.
- Các thành viên
- Constructor của
Dog
sử dụng cú phápAnimal(initialAge)
trong danh sách khởi tạo (: Animal(initialAge), breed(dogBreed)
) để gọi constructor phù hợp của lớp cơ sởAnimal
trước khi thực thi thân constructor củaDog
. Đây là điều quan trọng khi làm việc với constructor trong kế thừa. - Trong
main
, chúng ta tạo đối tượngmyDog
thuộc lớpDog
. - Chúng ta có thể truy cập trực tiếp
myDog.age
(kế thừa từAnimal
) vàmyDog.breed
(riêng củaDog
) vì cả hai đều làpublic
(hoặcage
được kế thừa công khai từpublic
củaAnimal
). - Chúng ta có thể gọi
myDog.eat()
(kế thừa từAnimal
) vàmyDog.bark()
(riêng củaDog
). - Thứ tự gọi Constructor và Destructor: Khi tạo đối tượng của lớp dẫn xuất, constructor của lớp cơ sở luôn được gọi trước constructor của lớp dẫn xuất. Khi đối tượng bị hủy, destructor của lớp dẫn xuất được gọi trước destructor của lớp cơ sở. Bạn có thể thấy rõ điều này qua output của chương trình.
Bài Tập 2: Thành viên được bảo vệ (protected
)
Mục tiêu: Hiểu cách thành viên protected
hoạt động trong kế thừa.
Bài toán: Thay đổi thuộc tính age
trong lớp Animal
thành protected
. Xem điều gì xảy ra khi truy cập từ lớp dẫn xuất và từ bên ngoài.
Code:
#include <iostream>
#include <string>
using namespace std;
class Animal {
protected:
int tuoi;
public:
Animal(int t) : tuoi(t) {
cout << "Animal constructor called with age: " << tuoi << endl;
}
void an() const {
cout << "This animal is eating." << endl;
}
~Animal() {
cout << "Animal destructor called." << endl;
}
};
class Dog : public Animal {
public:
string giong;
Dog(int t, const string& gCho) : Animal(t), giong(gCho) {
cout << "Dog constructor called for breed: " << giong << endl;
}
void sua() const {
cout << "Woof! Woof!" << endl;
}
void hienTuoi() const {
cout << "Dog's age (accessed from Dog class): " << tuoi << endl;
}
~Dog() {
cout << "Dog destructor called." << endl;
}
};
int main() {
Dog cho(4, "Poodle");
cho.hienTuoi();
// cho.tuoi = 5; // LỖI: Không thể truy cập thành viên protected từ bên ngoài lớp
cho.an();
cho.sua();
return 0;
}
Output:
Animal constructor called with age: 4
Dog constructor called for breed: Poodle
Dog's age (accessed from Dog class): 4
This animal is eating.
Woof! Woof!
Dog destructor called.
Animal destructor called.
Giải thích:
- Thuộc tính
age
trongAnimal
giờ làprotected
. - Khi
Dog
kế thừapublic
từAnimal
, thành viênprotected age
củaAnimal
trở thànhprotected
trongDog
. - Điều này có nghĩa là:
- Bạn không thể truy cập
myDog.age
trực tiếp từ hàmmain()
(bên ngoài lớpDog
). Dòng codemyDog.age = 5;
sẽ gây lỗi biên dịch. - Bạn có thể truy cập
age
từ bên trong các phương thức của lớp dẫn xuấtDog
, như trong phương thứcdisplayAge()
.
- Bạn không thể truy cập
Thành viên protected
rất hữu ích khi bạn muốn chia sẻ dữ liệu hoặc phương thức với các lớp kế thừa nhưng vẫn muốn ẩn chúng khỏi thế giới bên ngoài.
Bài Tập 3: Ghi đè phương thức (Method Overriding)
Mục tiêu: Hiểu cách lớp dẫn xuất có thể cung cấp cài đặt riêng cho một phương thức đã có ở lớp cơ sở.
Bài toán: Lớp Animal
có phương thức speak()
chung. Lớp Dog
và Cat
(kế thừa từ Animal
) cần có cách phát ra âm thanh riêng của chúng.
Code:
#include <iostream>
using namespace std;
class Animal {
public:
Animal() {
cout << "Animal created." << endl;
}
void phatAm() const {
cout << "Animal makes a sound." << endl;
}
virtual ~Animal() {
cout << "Animal destroyed." << endl;
}
};
class Dog : public Animal {
public:
Dog() {
cout << "Dog created." << endl;
}
void phatAm() const {
cout << "Woof!" << endl;
}
~Dog() override {
cout << "Dog destroyed." << endl;
}
};
class Cat : public Animal {
public:
Cat() {
cout << "Cat created." << endl;
}
void phatAm() const {
cout << "Meow!" << endl;
}
~Cat() override {
cout << "Cat destroyed." << endl;
}
};
int main() {
Animal dvChung;
Dog cho;
Cat meo;
cout << "--- Calling phatAm() ---" << endl;
dvChung.phatAm();
cho.phatAm();
meo.phatAm();
cout << "--- End of program ---" << endl;
return 0;
}
Output:
Animal created.
Animal created.
Dog created.
Animal created.
Cat created.
--- Calling phatAm() ---
Animal makes a sound.
Woof!
Meow!
--- End of program ---
Cat destroyed.
Animal destroyed.
Dog destroyed.
Animal destroyed.
Animal destroyed.
Giải thích:
- Cả
Dog
vàCat
đều kế thừa từAnimal
. - Lớp
Animal
có phương thứcspeak()
. - Các lớp dẫn xuất
Dog
vàCat
định nghĩa lại (ghi đè) phương thứcspeak()
với cùng tên, kiểu trả về và danh sách tham số. - Khi bạn gọi
speak()
trên một đối tượngDog
hoặcCat
, phiên bảnspeak()
của lớp dẫn xuất sẽ được thực thi, chứ không phải phiên bản của lớp cơ sởAnimal
. - Lưu ý rằng để đạt được tính đa hình (Polymorphism) thực sự (gọi đúng phương thức trên đối tượng dẫn xuất khi sử dụng con trỏ hoặc tham chiếu lớp cơ sở), phương thức trong lớp cơ sở cần được khai báo là
virtual
. Tuy nhiên, bài tập này chỉ tập trung vào cơ chế ghi đè cơ bản. Chúng ta sẽ tìm hiểu sâu hơn vềvirtual
và đa hình sau. - Việc sử dụng từ khóa
override
(từ C++11) là một thực hành tốt để chỉ rõ rằng bạn có ý định ghi đè một phương thức của lớp cơ sở. Trình biên dịch sẽ báo lỗi nếu phương thức bạn đánh dấuoverride
không thực sự ghi đè được phương thức nào ở lớp cơ sở (ví dụ: sai tên, sai tham số...). virtual ~Animal()
và~Dog() override
,~Cat() override
là các destructor ảo. Điều này cực kỳ quan trọng khi sử dụng đa hình để đảm bảo destructor của lớp dẫn xuất được gọi đúng cách trước destructor của lớp cơ sở khi hủy đối tượng qua con trỏ/tham chiếu lớp cơ sở.
Bài Tập 4: Kế thừa đa cấp (Multi-level Inheritance)
Mục tiêu: Hiểu cách kế thừa có thể diễn ra qua nhiều cấp độ.
Bài toán: Chúng ta có Vehicle
(phương tiện), Car
(ô tô) kế thừa từ Vehicle
, và SportsCar
(xe thể thao) kế thừa từ Car
.
Code:
#include <iostream>
#include <string>
using namespace std;
class Vehicle {
public:
int tocDoToiDa;
Vehicle(int td) : tocDoToiDa(td) {
cout << "Vehicle constructor called with speed: " << tocDoToiDa << endl;
}
void hienTocDo() const {
cout << "Max speed: " << tocDoToiDa << " km/h" << endl;
}
~Vehicle() { cout << "Vehicle destroyed." << endl; }
};
class Car : public Vehicle {
public:
string hsx;
string mau;
Car(int td, const string& _hsx, const string& _mau)
: Vehicle(td), hsx(_hsx), mau(_mau) {
cout << "Car constructor called for " << hsx << " " << mau << endl;
}
void hienTt() const {
cout << "Car: " << hsx << " " << mau << endl;
hienTocDo();
}
~Car() override { cout << "Car destroyed." << endl; }
};
class SportsCar : public Car {
public:
bool coTurbo;
SportsCar(int td, const string& _hsx, const string& _mau, bool turbo)
: Car(td, _hsx, _mau), coTurbo(turbo) {
cout << "SportsCar constructor called. Turbo: " << (coTurbo ? "Yes" : "No") << endl;
}
void hienTtSports() const {
hienTt();
cout << "Has Turbo: " << (coTurbo ? "Yes" : "No") << endl;
}
~SportsCar() override { cout << "SportsCar destroyed." << endl; }
};
int main() {
SportsCar xeTt(300, "Ferrari", "458", true);
cout << "--- Displaying info ---" << endl;
xeTt.hienTtSports();
cout << "--- Accessing members ---" << endl;
cout << "Make: " << xeTt.hsx << endl;
cout << "Max Speed: " << xeTt.tocDoToiDa << endl;
cout << "--- End of program ---" << endl;
return 0;
}
Output:
Vehicle constructor called with speed: 300
Car constructor called for Ferrari 458
SportsCar constructor called. Turbo: Yes
--- Displaying info ---
Car: Ferrari 458
Max speed: 300 km/h
Has Turbo: Yes
--- Accessing members ---
Make: Ferrari
Max Speed: 300
--- End of program ---
SportsCar destroyed.
Car destroyed.
Vehicle destroyed.
Giải thích:
SportsCar
kế thừa từCar
, vàCar
kế thừa từVehicle
.SportsCar
có quyền truy cập đến các thành viênpublic
vàprotected
củaCar
, và thông quaCar
, nó cũng có quyền truy cập đến các thành viênpublic
vàprotected
củaVehicle
.- Trong constructor của
SportsCar
, chúng ta gọi constructor của lớp cơ sở trực tiếp làCar(speed, carMake, carModel)
. Constructor củaCar
lại gọi constructor củaVehicle
. Như vậy, khi tạo đối tượngSportsCar
, thứ tự gọi constructor sẽ làVehicle
->Car
->SportsCar
. - Tương tự, thứ tự gọi destructor sẽ ngược lại:
SportsCar
->Car
->Vehicle
. - Đối tượng
mySportsCar
có thể truy cập trực tiếp các thành viênpublic
từ cả ba cấp độ (maxSpeed
,make
,model
,hasTurbo
).
Kế thừa đa cấp giúp mô hình hóa các mối quan hệ phức tạp hơn trong hệ thống của bạn.
Bài Tập 5: Kế thừa riêng tư (private inheritance
)
Mục tiêu: Hiểu sự khác biệt của private inheritance
so với public inheritance
.
Bài toán: Sử dụng private inheritance
để một lớp kế thừa chỉ vì mục đích tái sử dụng cài đặt, chứ không phải mô hình hóa mối quan hệ "là một" cho thế giới bên ngoài.
Code:
#include <iostream>
#include <string>
using namespace std;
class BaseUtility {
public:
void ghiLog(const string& tinNhan) const {
cout << "[LOG] " << tinNhan << endl;
}
protected:
int demNoiBo = 0;
void tangDem() {
demNoiBo++;
}
private:
int giaTriBiMat = 100;
};
class Processor : private BaseUtility {
public:
void xuLyDuLieu(const string& dl) {
ghiLog("Processing data: " + dl);
tangDem();
// giaTriBiMat = 200; // LỖI: Không thể truy cập thành viên private của BaseUtility
}
void hienThiTrangThai() {
// Các thành viên public/protected của BaseUtility đã trở thành private trong Processor.
// Chúng CHỈ CÓ THỂ được truy cập BÊN TRONG các phương thức của lớp Processor.
// Chúng KHÔNG THỂ được truy cập BỞI đối tượng Processor từ bên ngoài main().
ghiLog("Current count (accessed within Processor method): " + to_string(demNoiBo));
cout << "[STATUS] Processing complete. Internal count: " << demNoiBo << endl;
}
};
int main() {
Processor xl;
xl.xuLyDuLieu("Initial data");
xl.xuLyDuLieu("More data");
// xl.ghiLog("This message?"); // LỖI: ghiLog là private trong Processor
// xl.tangDem(); // LỖI: tangDem là private trong Processor
// xl.demNoiBo; // LỖI: demNoiBo là private trong Processor
xl.hienThiTrangThai();
return 0;
}
Output:
[LOG] Processing data: Initial data
[LOG] Processing data: More data
[LOG] Current count (accessed within Processor method): 2
[STATUS] Processing complete. Internal count: 2
Giải thích:
Processor
kế thừaprivate
từBaseUtility
.- Với
private inheritance
:- Các thành viên
public
củaBaseUtility
(logMessage
) trở thànhprivate
trongProcessor
. - Các thành viên
protected
củaBaseUtility
(internalCounter
,incrementCounter
) trở thànhprivate
trongProcessor
. - Các thành viên
private
củaBaseUtility
(secretValue
) vẫn không thể truy cập trongProcessor
.
- Các thành viên
- Điều này có nghĩa là các thành viên
logMessage
,internalCounter
,incrementCounter
chỉ có thể được truy cập từ bên trong các phương thức của lớpProcessor
(nhưprocessData
hoặcshowInternalDetails
). - Bạn không thể truy cập chúng bằng một đối tượng
Processor
từ bên ngoài lớp (như trong hàmmain()
). Các dòng code nhưmyProcessor.logMessage(...)
sẽ gây lỗi biên dịch.
Private inheritance
không mô hình hóa mối quan hệ "là một" công khai. Thay vào đó, nó thường được sử dụng khi bạn muốn lớp dẫn xuất sử dụng cài đặt của lớp cơ sở nhưng không muốn "lộ" ra giao diện công khai của lớp cơ sở thông qua đối tượng của lớp dẫn xuất. Nó giống như việc "được cài đặt dựa trên" (is-implemented-in-terms-of) hơn là "là một" (is-a).
Tại sao kế thừa lại quan trọng?
Qua các ví dụ trên, bạn có thể thấy kế thừa giúp chúng ta:
- Tái sử dụng mã nguồn: Viết code chung ở lớp cơ sở, các lớp dẫn xuất chỉ cần thêm hoặc sửa đổi những phần riêng biệt.
- Dễ bảo trì: Thay đổi logic chung ở lớp cơ sở sẽ ảnh hưởng đến tất cả các lớp dẫn xuất (trừ những phương thức đã bị ghi đè).
- Mô hình hóa mối quan hệ: Thể hiện cấu trúc phân cấp và mối quan hệ "là một" trong dữ liệu, giúp code dễ hiểu và có cấu trúc hơn.
- Chuẩn bị cho Đa hình: Kế thừa là nền tảng để triển khai tính đa hình (Polymorphism), một khái niệm cực kỳ mạnh mẽ trong OOP (sẽ tìm hiểu chi tiết sau).
Comments