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
Animalcó một thuộc tínhagevà phương thứceat(), cả hai đều làpublic. - Lớp
Dogđược khai báo kế thừapublictừAnimal. Điều này có nghĩa là:- Các thành viên
publiccủaAnimal(age,eat()) vẫn làpublictrongDog. - Các thành viên
protectedcủaAnimal(nếu có) sẽ làprotectedtrongDog. - Các thành viên
privatecủaAnimalkhông thể truy cập trực tiếp trongDog.
- Các thành viên
- Constructor của
Dogsử 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ởAnimaltrướ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ượngmyDogthuộ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ừpubliccủ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
agetrongAnimalgiờ làprotected. - Khi
Dogkế thừapublictừAnimal, thành viênprotected agecủaAnimaltrở thànhprotectedtrongDog. - Điều này có nghĩa là:
- Bạn không thể truy cập
myDog.agetrự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
agetừ 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ả
DogvàCatđều kế thừa từAnimal. - Lớp
Animalcó phương thứcspeak(). - Các lớp dẫn xuất
Dogvà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ượngDoghoặ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ềvirtualvà đ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ấuoverridekhô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() overridelà 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:
SportsCarkế thừa từCar, vàCarkế thừa từVehicle.SportsCarcó quyền truy cập đến các thành viênpublicvàprotectedcủaCar, và thông quaCar, nó cũng có quyền truy cập đến các thành viênpublicvàprotectedcủ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ủaCarlạ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
mySportsCarcó thể truy cập trực tiếp các thành viênpublictừ 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:
Processorkế thừaprivatetừBaseUtility.- Với
private inheritance:- Các thành viên
publiccủaBaseUtility(logMessage) trở thànhprivatetrongProcessor. - Các thành viên
protectedcủaBaseUtility(internalCounter,incrementCounter) trở thànhprivatetrongProcessor. - Các thành viên
privatecủ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,incrementCounterchỉ có thể được truy cập từ bên trong các phương thức của lớpProcessor(nhưprocessDatahoặcshowInternalDetails). - Bạn không thể truy cập chúng bằng một đối tượng
Processortừ 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