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>

// 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í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>

// 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 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>

// 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ả 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>

// 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ê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>

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ừ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.