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:

  1. 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.
  2. 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ính age và phương thức eat(), cả hai đều là public.
  • Lớp Dog được khai báo kế thừa public từ Animal. Điều này có nghĩa là:
    • Các thành viên public của Animal (age, eat()) vẫn là public trong Dog.
    • Các thành viên protected của Animal (nếu có) sẽ là protected trong Dog.
    • Các thành viên private của Animal không thể truy cập trực tiếp trong Dog.
  • Constructor của Dog sử dụng cú pháp Animal(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ủa Dog. Đâ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ượng myDog thuộc lớp Dog.
  • 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ủa Dog) vì cả hai đều là public (hoặc age được kế thừa công khai từ public của Animal).
  • Chúng ta có thể gọi myDog.eat() (kế thừa từ Animal) và myDog.bark() (riêng của Dog).
  • 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 trong Animal giờ là protected.
  • Khi Dog kế thừa public từ Animal, thành viên protected age của Animal trở thành protected trong Dog.
  • Điều này có nghĩa là:
    • Bạn không thể truy cập myDog.age trực tiếp từ hàm main() (bên ngoài lớp Dog). Dòng code myDog.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ất Dog, như trong phương thức displayAge().

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 DogCat (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ả DogCat đều kế thừa từ Animal.
  • Lớp Animal có phương thức speak().
  • Các lớp dẫn xuất DogCat định nghĩa lại (ghi đè) phương thức speak() 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ượng Dog hoặc Cat, phiên bản speak() 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ấu override 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()~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ên publicprotected của Car, và thông qua Car, nó cũng có quyền truy cập đến các thành viên publicprotected của Vehicle.
  • 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ủa Car lại gọi constructor của Vehicle. Như vậy, khi tạo đối tượng SportsCar, 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ên public 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ừa private từ BaseUtility.
  • Với private inheritance:
    • Các thành viên public của BaseUtility (logMessage) trở thành private trong Processor.
    • Các thành viên protected của BaseUtility (internalCounter, incrementCounter) trở thành private trong Processor.
    • Các thành viên private của BaseUtility (secretValue) vẫn không thể truy cập trong Processor.
  • Đ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ớp Processor (như processData hoặc showInternalDetails).
  • 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àm main()). 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

There are no comments at the moment.