Bài 36.5: Bài tập thực hành kế thừa và đa hình trong C++

Chào mừng bạn đến với bài thực hành quan trọng tiếp theo trong hành trình chinh phục C++! Hôm nay, chúng ta sẽ "đào sâu" vào hai trụ cột quan trọng nhất của Lập trình hướng đối tượng (OOP): Kế thừaĐa hình. Đây không chỉ là những lý thuyết khô khan, mà là những công cụ cực kỳ mạnh mẽ giúp bạn xây dựng các ứng dụng linh hoạt, dễ mở rộng và dễ quản lý hơn rất nhiều.

Chúng ta sẽ không dừng lại ở định nghĩa, mà sẽ đi thẳng vào thực hành với nhiều ví dụ code C++ cụ thể để bạn có thể thấy chúng hoạt động như thế nào trong thế giới thực!

Kế Thừa (Inheritance): Xây dựng Quan Hệ "Là Một Loại Của"

Kế thừa cho phép bạn tạo ra một lớp mới (gọi là lớp con hay lớp kế thừa - _derived class_) dựa trên một lớp đã tồn tại (gọi là lớp cha hay lớp cơ sở - _base class_). Lớp con sẽ kế thừa các thuộc tính (dữ liệu) và hành vi (phương thức) từ lớp cha.

Hãy tưởng tượng thế giới động vật. Một con chó là một loại động vật. Một con mèo cũng là một loại động vật. Cả chó và mèo đều có những đặc điểm chung của động vật (có tên, biết ăn, biết kêu), nhưng mỗi loại lại có những đặc điểm và hành vi riêng biệt. Kế thừa giúp chúng ta mô hình hóa mối quan hệ "là một loại của" này một cách hiệu quả.

Ví dụ 1: Động Vật và Chó

Chúng ta sẽ định nghĩa một lớp Animal (lớp cha) và một lớp Dog (lớp con kế thừa từ Animal).

#include <iostream>
#include <string>
#include <vector> // Sẽ dùng sau cho Đa hình

// Sử dụng namespace std để code ngắn gọn hơn
using namespace std;

// Lớp cơ sở (Base class) - Animal
class Animal {
public:
    string name; // Thuộc tính chung

    // Hàm khởi tạo của lớp cha
    Animal(const string& n) : name(n) {
        cout << "Animal '" << name << "' created." << endl;
    }

    // Phương thức chung
    void eat() const {
        cout << name << " is eating." << endl;
    }

    // Phương thức ảo (virtual) - Quan trọng cho Đa hình
    // Mặc định mỗi loài kêu khác nhau
    virtual void speak() const {
        cout << name << " makes a generic sound." << endl;
    }

    // Hàm hủy ảo (virtual destructor) - RẤT QUAN TRỌNG khi làm việc với đa hình
    virtual ~Animal() {
        cout << "Animal '" << name << "' destroyed." << endl;
    }
};

// Lớp kế thừa (Derived class) - Dog
// Cú pháp kế thừa: class DerivedClass : access_specifier BaseClass
class Dog : public Animal {
public:
    // Hàm khởi tạo của lớp con, gọi hàm khởi tạo của lớp cha
    Dog(const string& n) : Animal(n) {
        cout << "Dog '" << name << "' created." << endl;
    }

    // Ghi đè (override) phương thức speak của lớp cha
    // 'override' là từ khóa tùy chọn nhưng nên dùng để kiểm tra lỗi
    void speak() const override {
        cout << name << " barks loudly: Woof! Woof!" << endl;
    }

    // Phương thức riêng của lớp Dog
    void fetch() const {
        cout << name << " fetches the ball." << endl;
    }

    // Ghi đè hàm hủy của lớp cha (tùy chọn nhưng tốt)
    ~Dog() override {
        cout << "Dog '" << name << "' destroyed." << endl;
    }
};

// int main() { // Tạm thời comment main để tránh xung đột khi gộp code

//     cout << "*** Minh họa Kế thừa ***" << endl;

//     // Tạo một đối tượng Dog
//     Dog myDog("Buddy");

//     // Gọi phương thức được kế thừa từ lớp Animal
//     myDog.eat();

//     // Gọi phương thức đã được ghi đè trong lớp Dog
//     myDog.speak();

//     // Gọi phương thức riêng của lớp Dog
//     myDog.fetch();

//     cout << "*** Kết thúc Minh họa Kế thừa ***" << endl;

//     return 0;
// }

Giải thích code:

  • Lớp Dog kế thừa public từ Animal. Điều này có nghĩa là tất cả các thành viên publicprotected của Animal đều trở thành publicprotected trong Dog (tương ứng).
  • Hàm khởi tạo của Dog (Dog(const string& n)) phải gọi hàm khởi tạo tương ứng của Animal (: Animal(n)) để khởi tạo phần dữ liệu kế thừa.
  • myDog.eat(): Phương thức eat() không được định nghĩa trong Dog, nhưng nó được kế thừa từ Animal, nên chúng ta có thể gọi nó.
  • myDog.speak(): Phương thức speak() được định nghĩa cả trong AnimalDog. Khi gọi trên đối tượng Dog, phiên bản trong Dog sẽ được sử dụng (đây gọi là ghi đè - overriding). Từ khóa override giúp trình biên dịch kiểm tra xem bạn có thực sự ghi đè một phương thức ảo của lớp cha hay không.
  • myDog.fetch(): Phương thức fetch() chỉ tồn tại trong lớp Dog. Chúng ta chỉ có thể gọi nó trên đối tượng kiểu Dog.

Kế thừa giúp chúng ta tái sử dụng mã và định nghĩa một cấu trúc phân cấp rõ ràng cho các đối tượng của mình.

Đa Hình (Polymorphism): Một Giao Diện, Nhiều Hình Thái

Đa hình theo nghĩa đen có nghĩa là "nhiều hình thái". Trong C++, đa hình lúc chạy (_runtime polymorphism_) cho phép bạn sử dụng một con trỏ hoặc tham chiếu đến lớp cơ sở (Animal* hoặc Animal&) để trỏ đến các đối tượng của các lớp kế thừa khác nhau (Dog, Cat, Bird...). Khi bạn gọi một phương thức ảo (_virtual method_) thông qua con trỏ/tham chiếu lớp cơ sở đó, phiên bản của phương thức trong lớp thực tế của đối tượng sẽ được thực thi, chứ không phải phiên bản của lớp cơ sở.

Đây chính là sức mạnh của đa hình: bạn có thể viết code tổng quát làm việc với các đối tượng thuộc một "gia đình" (cùng kế thừa từ một lớp cha) mà không cần biết chính xác kiểu cụ thể của chúng tại thời điểm viết code.

Để đa hình hoạt động, phương thức trong lớp cha phải được đánh dấu là virtual. Nếu lớp con ghi đè phương thức đó, nó nên dùng từ khóa override.

Ví dụ 2: Các Loại Hình Học

Hãy xem xét các hình học khác nhau như hình tròn và hình vuông. Chúng đều là "hình dạng" (Shape) và đều có thể tính diện tích, nhưng cách tính thì khác nhau.

// Tiếp tục sử dụng các lớp đã khai báo ở trên (Animal, Dog)
// Bổ sung thêm các lớp Shape, Circle, Square

#include <iostream>
#include <vector>
#include <cmath> // Dùng M_PI cho hình tròn

using namespace std;

// Lớp cơ sở (Base class) - Shape
// Chúng ta có thể không muốn tạo đối tượng Shape thuần túy, chỉ muốn nó làm giao diện
// -> Sử dụng Pure virtual function (hàm ảo thuần túy) và biến Shape thành Abstract class
class Shape {
public:
    // Phương thức ảo thuần túy (Pure virtual function)
    // Bắt buộc các lớp con phải triển khai (implement) phương thức này
    virtual double calculateArea() const = 0;

    // Hàm hủy ảo - LUÔN LUÔN cần thiết khi lớp có phương thức ảo
    virtual ~Shape() {
        cout << "Shape destructor called." << endl;
    }
};

// Lớp kế thừa (Derived class) - Circle
class Circle : public Shape {
private:
    double radius;
public:
    Circle(double r) : radius(r) {}

    // Ghi đè phương thức ảo thuần túy
    double calculateArea() const override {
        return M_PI * radius * radius;
    }

    ~Circle() override {
        cout << "Circle destructor called." << endl;
    }
};

// Lớp kế thừa (Derived class) - Square
class Square : public Shape {
private:
    double side;
public:
    Square(double s) : side(s) {}

    // Ghi đè phương thức ảo thuần túy
    double calculateArea() const override {
        return side * side;
    }

    ~Square() override {
        cout << "Square destructor called." << endl;
    }
};

// int main() { // Tạm thời comment main để tránh xung đột khi gộp code

//     cout << "\n*** Minh họa Đa hình ***" << endl;

//     // Tạo một vector chứa các con trỏ tới lớp cơ sở Shape
//     vector<Shape*> shapes;

//     // Thêm các đối tượng của các lớp kế thừa vào vector
//     // Chúng ta đang lưu con trỏ đến Circle và Square,
//     // nhưng vector chỉ biết chúng là Shape*
//     shapes.push_back(new Circle(5.0));  // Thêm một hình tròn bán kính 5
//     shapes.push_back(new Square(4.0));  // Thêm một hình vuông cạnh 4
//     shapes.push_back(new Circle(3.0));  // Thêm một hình tròn bán kính 3

//     // Lặp qua vector và gọi phương thức calculateArea() cho từng đối tượng
//     cout << "Calculating areas of different shapes:" << endl;
//     for (const auto& shape_ptr : shapes) {
//         // Đây chính là đa hình!
//         // shape_ptr là Shape*, nhưng C++ biết đối tượng thực sự là gì
//         // và gọi đúng phương thức calculateArea() của Circle hoặc Square
//         cout << "  Area: " << shape_ptr->calculateArea() << endl;
//     }

//     // Dọn dẹp bộ nhớ đã cấp phát động
//     cout << "\nCleaning up memory:" << endl;
//     for (const auto& shape_ptr : shapes) {
//         delete shape_ptr; // Hàm hủy ảo đảm bảo hàm hủy của lớp con được gọi
//     }
//     shapes.clear(); // Xóa các con trỏ khỏi vector (không bắt buộc sau delete)


//     cout << "*** Kết thúc Minh họa Đa hình ***" << endl;

//     return 0;
// }

Giải thích code:

  • Lớp Shape được định nghĩa với một phương thức virtual double calculateArea() const = 0;. Dấu = 0 biến calculateArea thành hàm ảo thuần túy (_pure virtual function_), và do đó Shape trở thành lớp trừu tượng (_abstract class_). Bạn không thể tạo đối tượng của một lớp trừu tượng. Mục đích của nó là định nghĩa một giao diện chung mà các lớp con phải tuân theo.
  • Các lớp CircleSquare kế thừa từ Shapebắt buộc phải triển khai phương thức calculateArea().
  • Trong main(), chúng ta tạo một vector<Shape*>. Điều này cho phép chúng ta lưu trữ các con trỏ đến bất kỳ đối tượng nào kế thừa từ Shape.
  • Khi lặp qua shapes và gọi shape_ptr->calculateArea(), C++ (nhờ từ khóa virtual và cơ chế V-table) sẽ tự động xác định loại đối tượng thực sự mà shape_ptr đang trỏ tới (là Circle hay Square) và gọi đúng phương thức calculateArea tương ứng của lớp đó. Đây là ví dụ điển hìnhmạnh mẽ nhất của đa hình lúc chạy.
  • Lưu ý quan trọng: Chúng ta phải cấp phát đối tượng bằng new và dùng con trỏ để đưa vào vector, và phải có hàm hủy ảo (virtual ~Shape()) trong lớp cha. Điều này đảm bảo khi bạn gọi delete shape_ptr, hàm hủy đúng của lớp con (như ~Circle() hoặc ~Square()) được gọi trước, sau đó mới đến hàm hủy của lớp cha. Nếu không có hàm hủy ảo, chỉ hàm hủy của Shape được gọi, dẫn đến rò rỉ bộ nhớ (_memory leak_) nếu lớp con có tài nguyên cần giải phóng.

Kế Thừa và Đa Hình: Cặp Đôi Hoàn Hảo

Kế thừa và đa hình thường đi đôi với nhau. Kế thừa tạo ra một cấu trúc phân cấp các lớp có liên quan, còn đa hình cho phép bạn tương tác với các lớp đó một cách thống nhất thông qua giao diện của lớp cha. Điều này giúp code của bạn trở nên:

  1. Linh hoạt: Dễ dàng thêm các loại đối tượng mới (lớp con mới) mà không cần thay đổi nhiều code hiện có (chỉ cần thêm lớp mới và đưa đối tượng của nó vào container/sử dụng con trỏ lớp cha).
  2. Dễ bảo trì: Thay đổi chi tiết triển khai của một lớp con không ảnh hưởng đến code sử dụng con trỏ lớp cha.
  3. Dễ mở rộng: Bạn có thể thêm các chức năng mới thông qua các lớp con mới một cách độc lập.

Ví dụ 3: Sử dụng Đa hình trong Hàm

Hãy xem cách đa hình cho phép chúng ta viết một hàm có thể xử lý bất kỳ đối tượng nào thuộc họ Shape.

// Tiếp tục sử dụng các lớp đã khai báo ở trên (Animal, Dog, Shape, Circle, Square)
// ... (Bao gồm định nghĩa các lớp Animal, Dog, Shape, Circle, Square ở đây cho đầy đủ) ...

#include <iostream>
#include <vector>
#include <string>
#include <cmath>

using namespace std;

// (Định nghĩa lại các lớp Animal, Dog, Shape, Circle, Square ở đây
// để ví dụ này hoàn chỉnh nếu chạy độc lập, hoặc giả định chúng đã được định nghĩa)

// Hàm sử dụng con trỏ lớp cơ sở - Thể hiện đa hình
void displayArea(const Shape* shape) {
    if (shape) { // Kiểm tra con trỏ có hợp lệ không
        // Gọi phương thức ảo thông qua con trỏ lớp cơ sở
        // Đa hình đảm bảo phương thức đúng của đối tượng thực được gọi
        cout << "Area calculated via displayArea function: " << shape->calculateArea() << endl;
    } else {
        cout << "Cannot display area for a null shape pointer." << endl;
    }
}

// Hàm sử dụng tham chiếu lớp cơ sở - Cũng thể hiện đa hình
void processAnimal(Animal& animal) {
    cout << "Processing animal: " << animal.name << endl;
    // Gọi phương thức ảo thông qua tham chiếu lớp cơ sở
    animal.speak(); // Sẽ gọi speak() của Dog nếu đối tượng thực là Dog
    animal.eat();   // Gọi phương thức không ảo, luôn gọi của lớp Animal (hoặc lớp con nếu override)
    cout << "Processing finished." << endl;
}

int main() {
    cout << "\n*** Minh họa Kế thừa & Đa hình kết hợp trong Hàm ***" << endl;

    // --- Minh họa với Shape và displayArea ---
    Circle smallCircle(2.5);
    Square bigSquare(8.0);

    // Truyền địa chỉ của đối tượng lớp con vào hàm nhận con trỏ lớp cha
    displayArea(&smallCircle); // Hàm displayArea không cần biết nó đang xử lý Circle hay Square
    displayArea(&bigSquare);

    cout << endl; // Xuống dòng

    // --- Minh họa với Animal và processAnimal ---
    Dog maxDog("Max");
    // Animal genericPet("Pet"); // Không thể gọi processAnimal(&genericPet) nếu processAnimal nhận Dog&

    // Truyền đối tượng lớp con vào hàm nhận tham chiếu lớp cha
    processAnimal(maxDog); // Hàm processAnimal không cần biết nó đang xử lý Dog

    // Ví dụ với vector Shape* và hàm displayArea
    vector<Shape*> reportShapes;
    reportShapes.push_back(new Circle(1.0));
    reportShapes.push_back(new Square(10.0));
    reportShapes.push_back(new Circle(5.5));

    cout << "\nProcessing shapes in vector using displayArea function:" << endl;
    for (const auto& s_ptr : reportShapes) {
        displayArea(s_ptr); // Gọi hàm cho từng đối tượng trong vector
    }

    // Dọn dẹp bộ nhớ
    cout << "\nCleaning up memory for reportShapes vector:" << endl;
    for (const auto& s_ptr : reportShapes) {
        delete s_ptr;
    }
    reportShapes.clear();


    cout << "*** Kết thúc Minh họa Kế thừa & Đa hình kết hợp ***" << endl;

    return 0;
}

Giải thích code:

  • Hàm displayArea(const Shape* shape) nhận một con trỏ tới Shape. Nhờ đa hình, khi bạn truyền địa chỉ của một đối tượng Circle hoặc Square vào hàm này, lời gọi shape->calculateArea() sẽ thực thi đúng phương thức calculateArea() của lớp Circle hoặc Square tương ứng. Hàm displayArea không cần phải có code riêng cho từng loại hình học!
  • Tương tự, hàm processAnimal(Animal& animal) nhận một tham chiếu tới Animal. Khi truyền đối tượng Dog vào, lời gọi animal.speak() sẽ thực thi Dog::speak().
  • Điều này cho thấy bạn có thể viết các hàm, lớp, hoặc container làm việc với một giao diện chung (lớp cha), và chúng sẽ tự động hoạt động với bất kỳ đối tượng cụ thể nào kế thừa và triển khai giao diện đó. Đây là một nguyên lý thiết kế OOP rất mạnh mẽ.

Những Lưu Ý Quan Trọng Khi Thực Hành

  • Từ khóa virtual: Bắt buộc phải có ở phương thức trong lớp cha nếu bạn muốn nó có hành vi đa hình qua con trỏ/tham chiếu lớp cha.
  • Từ khóa override: Nên dùng trong lớp con khi ghi đè phương thức ảo của lớp cha để trình biên dịch giúp bạn kiểm tra.
  • Con trỏ/Tham chiếu lớp cơ sở: Đa hình lúc chạy chỉ xảy ra khi bạn truy cập đối tượng của lớp kế thừa thông qua con trỏ hoặc tham chiếu tới lớp cơ sở của nó. Truy cập trực tiếp qua đối tượng lớp con sẽ luôn gọi phương thức của lớp con (hoặc lớp cha nếu không ghi đè).
  • Hàm hủy ảo virtual ~BaseClass(): Cực kỳ quan trọng nếu bạn cấp phát động các đối tượng kế thừa và lưu trữ chúng trong con trỏ/tham chiếu lớp cơ sở (như trong vector<Shape*>). Nó đảm bảo rằng khi bạn delete con trỏ lớp cơ sở trỏ đến đối tượng lớp con, hàm hủy đúng của lớp con sẽ được gọi trước, ngăn chặn rò rỉ bộ nhớ.
  • Lớp trừu tượng (Abstract Class): Một lớp có ít nhất một hàm ảo thuần túy (= 0). Bạn không thể tạo đối tượng của lớp trừu tượng. Chúng dùng để định nghĩa giao diện bắt buộc cho các lớp con.
  • Hàm ảo thuần túy (Pure Virtual Function): Một hàm ảo được khai báo với = 0. Lớp chứa nó là lớp trừu tượng. Các lớp con không trừu tượng bắt buộc phải triển khai hàm này.

Việc nắm vững và áp dụng kế thừađa hình sẽ mở ra rất nhiều khả năng trong việc thiết kế và xây dựng các hệ thống phần mềm phức tạp bằng C++. Hãy dành thời gian thực hành với các ví dụ này và thử nghiệm các kịch bản khác nhau để củng cố kiến thức của bạn!

Comments

There are no comments at the moment.