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>
// Lớp cơ sở
class Animal {
public:
int age;
Animal(int initialAge) : age(initialAge) { // Constructor
cout << "Animal constructor called with age: " << age << endl;
}
void eat() const { // Phương thức công khai
cout << "This animal is eating." << endl;
}
// Destructor (Quan sát thứ tự gọi)
~Animal() {
cout << "Animal destructor called." << endl;
}
};
// Lớp dẫn xuất kế thừa công khai từ Animal
class Dog : public Animal {
public:
string breed;
// Constructor của Dog, gọi constructor của Base class
Dog(int initialAge, const string& dogBreed) : Animal(initialAge), breed(dogBreed) {
cout << "Dog constructor called for breed: " << breed << endl;
}
void bark() const { // Phương thức riêng của Dog
cout << "Woof! Woof!" << endl;
}
// Destructor (Quan sát thứ tự gọi)
~Dog() {
cout << "Dog destructor called." << endl;
}
};
int main() {
// Tạo một đối tượng Dog
Dog myDog(3, "Labrador");
cout << "My dog is " << myDog.age << " years old and is a " << myDog.breed << "." << endl;
// Gọi phương thức kế thừa từ Animal
myDog.eat();
// Gọi phương thức riêng của Dog
myDog.bark();
// Khi myDog ra khỏi phạm vi, destructor sẽ được gọi theo thứ tự ngược lại
return 0;
}
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>
// Lớp cơ sở với thành viên protected
class Animal {
protected: // Thay đổi từ public sang protected
int age;
public:
Animal(int initialAge) : age(initialAge) {
cout << "Animal constructor called with age: " << age << endl;
}
void eat() const {
cout << "This animal is eating." << endl;
}
// Destructor
~Animal() {
cout << "Animal destructor called." << endl;
}
};
// Lớp dẫn xuất kế thừa công khai
class Dog : public Animal {
public:
string breed;
Dog(int initialAge, const string& dogBreed) : Animal(initialAge), breed(dogBreed) {
cout << "Dog constructor called for breed: " << breed << endl;
}
void bark() const {
cout << "Woof! Woof!" << endl;
}
// Phương thức riêng của Dog có thể truy cập age (protected)
void displayAge() const {
cout << "Dog's age (accessed from Dog class): " << age << endl;
}
// Destructor
~Dog() {
cout << "Dog destructor called." << endl;
}
};
int main() {
Dog myDog(4, "Poodle");
myDog.displayAge(); // Hợp lệ: Truy cập 'age' từ bên trong lớp Dog
// myDog.age = 5; // LỖI: Không thể truy cập thành viên protected từ bên ngoài lớp
myDog.eat();
myDog.bark();
return 0;
}
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>
// Lớp cơ sở
class Animal {
public:
Animal() {
cout << "Animal created." << endl;
}
// Phương thức chung (có thể ghi đè)
void speak() const {
cout << "Animal makes a sound." << endl;
}
virtual ~Animal() { // Destructor ảo để đảm bảo hủy đúng thứ tự với polymorphic
cout << "Animal destroyed." << endl;
}
};
// Lớp dẫn xuất 1
class Dog : public Animal {
public:
Dog() {
cout << "Dog created." << endl;
}
// Ghi đè phương thức speak()
void speak() const {
cout << "Woof!" << endl;
}
~Dog() override { // Có thể dùng override để kiểm tra xem có thật sự ghi đè không
cout << "Dog destroyed." << endl;
}
};
// Lớp dẫn xuất 2
class Cat : public Animal {
public:
Cat() {
cout << "Cat created." << endl;
}
// Ghi đè phương thức speak()
void speak() const {
cout << "Meow!" << endl;
}
~Cat() override {
cout << "Cat destroyed." << endl;
}
};
int main() {
Animal genericAnimal;
Dog myDog;
Cat myCat;
cout << "--- Calling speak() ---" << endl;
genericAnimal.speak(); // Gọi phương thức của Animal
myDog.speak(); // Gọi phương thức đã ghi đè của Dog
myCat.speak(); // Gọi phương thức đã ghi đè của Cat
cout << "--- End of program ---" << endl; // Destructor sẽ được gọi sau
return 0;
}
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>
// Lớp cơ sở cấp 1
class Vehicle {
public:
int maxSpeed;
Vehicle(int speed) : maxSpeed(speed) {
cout << "Vehicle constructor called with speed: " << maxSpeed << endl;
}
void displaySpeed() const {
cout << "Max speed: " << maxSpeed << " km/h" << endl;
}
~Vehicle() { cout << "Vehicle destroyed." << endl; }
};
// Lớp dẫn xuất cấp 2, kế thừa từ Vehicle
class Car : public Vehicle {
public:
string make;
string model;
Car(int speed, const string& carMake, const string& carModel)
: Vehicle(speed), make(carMake), model(carModel) { // Gọi constructor lớp cơ sở cấp 1
cout << "Car constructor called for " << make << " " << model << endl;
}
void displayInfo() const {
cout << "Car: " << make << " " << model << endl;
displaySpeed(); // Có thể gọi phương thức của lớp cơ sở cấp 1
}
~Car() override { cout << "Car destroyed." << endl; }
};
// Lớp dẫn xuất cấp 3, kế thừa từ Car
class SportsCar : public Car {
public:
bool hasTurbo;
SportsCar(int speed, const string& carMake, const string& carModel, bool turbo)
: Car(speed, carMake, carModel), hasTurbo(turbo) { // Gọi constructor lớp cơ sở cấp 2
cout << "SportsCar constructor called. Turbo: " << (hasTurbo ? "Yes" : "No") << endl;
}
void displaySportsInfo() const {
displayInfo(); // Có thể gọi phương thức của lớp cơ sở cấp 2 (mà lớp cơ sở cấp 2 gọi lớp cơ sở cấp 1)
cout << "Has Turbo: " << (hasTurbo ? "Yes" : "No") << endl;
}
~SportsCar() override { cout << "SportsCar destroyed." << endl; }
};
int main() {
SportsCar mySportsCar(300, "Ferrari", "458", true);
cout << "--- Displaying info ---" << endl;
mySportsCar.displaySportsInfo(); // Gọi phương thức lớp dẫn xuất cấp 3
// mySportsCar.displayInfo(); // Có thể gọi trực tiếp phương thức lớp dẫn xuất cấp 2
// mySportsCar.displaySpeed(); // Có thể gọi trực tiếp phương thức lớp cơ sở cấp 1
cout << "--- Accessing members ---" << endl;
cout << "Make: " << mySportsCar.make << endl; // Truy cập thành viên lớp cơ sở cấp 2
cout << "Max Speed: " << mySportsCar.maxSpeed << endl; // Truy cập thành viên lớp cơ sở cấp 1
cout << "--- End of program ---" << endl;
return 0;
}
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>
class BaseUtility {
public:
void logMessage(const string& msg) const {
cout << "[LOG] " << msg << endl;
}
protected:
int internalCounter = 0;
void incrementCounter() {
internalCounter++;
}
private:
int secretValue = 100;
};
// Lớp dẫn xuất sử dụng private inheritance
class Processor : private BaseUtility {
public:
void processData(const string& data) {
// Có thể truy cập thành viên public và protected của BaseUtility
logMessage("Processing data: " + data);
incrementCounter();
// secretValue = 200; // LỖI: Không thể truy cập thành viên private của BaseUtility
}
void displayStatus() const {
// logMessage("Current count: " + to_string(internalCounter)); // LỖI: Cannot access logMessage here!
// Vì BaseUtility::logMessage trở thành private trong Processor
// internalCounter; // LỖI: Cannot access internalCounter here!
// Vì BaseUtility::internalCounter trở thành private trong Processor
// Cần tạo một phương thức riêng trong Processor để truy cập
cout << "[STATUS] Processing complete. Internal count: " << internalCounter << endl; // VẪN LỖI!
// Okay, let's fix this. In private inheritance, public/protected members of Base
// become private in Derived. This means they *can* be accessed *inside* Derived,
// but NOT by objects *of* Derived *from outside*.
// Correct access:
// 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().
// Đoạn code dưới đây đúng ra phải nằm trong một method khác của Processor.
// Để demo access *within* Processor:
cout << "[STATUS] Processing complete. Internal count: " << internalCounter << endl; // OK (đang ở trong method của Processor)
// logMessage("Another status message."); // OK (đang ở trong method của Processor)
}
// Helper method inside Processor to demonstrate accessing private-inherited members
void showInternalDetails() {
logMessage("Showing internal details (accessed within Processor method)."); // OK
cout << "Internal counter is: " << internalCounter << endl; // OK
}
};
int main() {
Processor myProcessor;
myProcessor.processData("Initial data");
myProcessor.processData("More data");
// myProcessor.logMessage("This message?"); // LỖI: logMessage là private trong Processor
// myProcessor.incrementCounter(); // LỖI: incrementCounter là private trong Processor
// myProcessor.internalCounter; // LỖI: internalCounter là private trong Processor
myProcessor.showInternalDetails(); // Gọi phương thức riêng của Processor để truy cập thành viên kế thừa (giờ là private)
return 0;
}
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