Bài 38.3: Bài tập thực hành đa hình trong C++

Chào mừng các bạn quay trở lại với chuỗi bài học C++ chuyên sâu của FullhouseDev!

Sau khi đã cùng nhau khám phá về đa hình (polymorphism) - một trong những trụ cột quan trọng nhất của lập trình hướng đối tượng (OOP) trong C++, hôm nay chúng ta sẽ đi sâu vào thực hành để củng cố kiến thức này. Lý thuyết là cần thiết, nhưng chỉ có thông qua các bài tập và ví dụ thực tế, chúng ta mới có thể nắm vững và áp dụng hiệu quả khái niệm đa hình vào các dự án của mình.

Đa hình, nói một cách đơn giản, là khả năng các đối tượng thuộc các lớp khác nhau nhưng có quan hệ kế thừa có thể được xử lý thông qua một giao diện chung, thường là con trỏ hoặc tham chiếu tới lớp cơ sở. Điều kỳ diệu xảy ra khi chúng ta gọi một phương thức virtual thông qua con trỏ/tham chiếu đó - C++ sẽ tự động xác định và thực thi phiên bản phương thức phù hợp với loại đối tượng thực tế, chứ không phải loại của con trỏ/tham chiếu. Đây chính là đa hình lúc chạy (runtime polymorphism).

Hãy cùng bắt tay vào các bài tập thực hành để thấy rõ sức mạnh và sự linh hoạt mà đa hình mang lại!


Bài tập 1: Hình học đơn giản với Đa hình

Một ví dụ kinh điển để bắt đầu. Chúng ta sẽ tạo một lớp cơ sở Shape và các lớp dẫn xuất như CircleSquare. Mục tiêu là có thể vẽ (hoặc mô phỏng việc vẽ) các hình này một cách thống nhất.

Bước 1: Định nghĩa Lớp Cơ sở và các Lớp Dẫn xuất

Chúng ta cần một lớp cơ sở Shape với một phương thức draw()virtual, và các lớp dẫn xuất Circle, Square ghi đè (override) phương thức này.

#include <iostream>
#include <vector> // Sẽ dùng vector để quản lý các hình

// Lớp cơ sở
class Shape {
public:
    // Phương thức virtual - cho phép đa hình
    virtual void draw() const {
        cout << "Drawing a generic shape." << endl;
    }

    // Rất quan trọng: destructor phải là virtual khi dùng đa hình với con trỏ
    virtual ~Shape() {
        cout << "Shape destructor called." << endl;
    }
};

// Lớp dẫn xuất
class Circle : public Shape {
public:
    // Ghi đè phương thức draw
    void draw() const override {
        cout << "Drawing a Circle." << endl;
    }

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

// Lớp dẫn xuất khác
class Square : public Shape {
public:
    // Ghi đè phương thức draw
    void draw() const override {
        cout << "Drawing a Square." << endl;
    }

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

Giải thích:

  • Lớp Shape là lớp cơ sở. Phương thức draw() được đánh dấu là virtual. Điều này báo hiệu cho trình biên dịch rằng các lớp dẫn xuất có thể cung cấp phiên bản riêng của phương thức này, và khi gọi thông qua con trỏ/tham chiếu Shape, hành vi sẽ được xác định lúc chạy.
  • Các lớp CircleSquare kế thừa từ Shape (public Shape).
  • Chúng ghi đè phương thức draw(). Từ khóa override (trong C++11 trở lên) là tùy chọn nhưng rất nên dùng vì nó 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 virtual của lớp cơ sở hay không.
  • Điểm cực kỳ quan trọng: Destructor (~Shape()) cũng được đánh dấu là virtual. Chúng ta sẽ thảo luận chi tiết hơn về điều này sau, nhưng về cơ bản, nó đảm bảo rằng khi bạn xóa một đối tượng dẫn xuất thông qua con trỏ lớp cơ sở (delete ptr_to_base;), destructor thực tế của lớp dẫn xuất sẽ được gọi, tránh rò rỉ bộ nhớ hoặc hành vi không mong muốn.

Bước 2: Thực hành Đa hình

Bây giờ, hãy xem làm thế nào chúng ta có thể sử dụng đa hình để xử lý các đối tượng CircleSquare một cách đồng nhất.

int main() {
    // Sử dụng vector để lưu trữ con trỏ tới các đối tượng Shape
    // Vector này có thể chứa cả Circle và Square
    vector<Shape*> shapes;

    // Tạo các đối tượng dẫn xuất và lưu trữ con trỏ của chúng
    shapes.push_back(new Circle());
    shapes.push_back(new Square());
    shapes.push_back(new Circle()); // Thêm nhiều loại khác nhau

    cout << "Drawing all shapes in the list:" << endl;
    // Lặp qua vector và gọi phương thức draw()
    // Mặc dù vector chứa con trỏ kiểu Shape*,
    // nhờ đa hình, phương thức draw() đúng của đối tượng thực tế sẽ được gọi.
    for (const auto& shape_ptr : shapes) {
        shape_ptr->draw(); // Đây chính là điểm mấu chốt của đa hình
    }

    // Dọn dẹp bộ nhớ: Rất quan trọng khi sử dụng con trỏ 'new'
    // Nhờ destructor virtual, destructor đúng của Circle và Square sẽ được gọi.
    cout << "\nCleaning up memory:" << endl;
    for (const auto& shape_ptr : shapes) {
        delete shape_ptr; // Gọi destructor của đối tượng thực tế
    }

    return 0;
}

Giải thích:

  • Chúng ta tạo một vector<Shape*>, tức là một vector chứa các con trỏ tới đối tượng Shape.
  • Chúng ta tạo các đối tượng CircleSquare bằng new và lưu trữ con trỏ của chúng vào vector shapes. Điều này hoàn toàn hợp lệ vì CircleSquare một loại Shape (do kế thừa).
  • Khi lặp qua vector và gọi shape_ptr->draw(), C++ nhìn vào loại đối tượng thực tếshape_ptr đang trỏ tới lúc chạy. Nếu đó là đối tượng Circle, nó gọi Circle::draw(). Nếu là Square, nó gọi Square::draw(). Nếu là Shape (nếu chúng ta có tạo một đối tượng Shape trực tiếp), nó sẽ gọi Shape::draw(). Đây chính là đa hình lúc chạy.
  • Cuối cùng, chúng ta lặp lại một lần nữa để delete các con trỏ. Nhờ có destructor virtual trong lớp Shape, khi delete shape_ptr được gọi, C++ biết được loại đối tượng thực tế là gì (Circle hay Square) và gọi destructor tương ứng của lớp dẫn xuất trước, sau đó mới gọi destructor của lớp cơ sở. Nếu destructor của Shape không phải virtual, chỉ destructor của Shape được gọi, dẫn đến rò rỉ bộ nhớ cho các thành viên của lớp dẫn xuất (nếu có).

Kết quả chạy chương trình sẽ là:

Drawing all shapes in the list:
Drawing a Circle.
Drawing a Square.
Drawing a Circle.

Cleaning up memory:
Circle destructor called.
Shape destructor called.
Square destructor called.
Shape destructor called.
Circle destructor called.
Shape destructor called.

Bạn có thể thấy rõ ràng destructor của lớp dẫn xuất được gọi trước khi destructor của lớp cơ sở được gọi cho mỗi đối tượng.


Bài tập 2: Đa hình với Tham chiếu và Hàm xử lý chung

Thay vì dùng con trỏ, chúng ta cũng có thể sử dụng tham chiếu để thể hiện đa hình. Kỹ thuật này thường được dùng khi bạn truyền đối tượng vào một hàm để xử lý.

Chúng ta sẽ sử dụng lại các lớp Shape, Circle, Square từ ví dụ trước.

Bước 1: Tạo một hàm xử lý sử dụng tham chiếu lớp cơ sở

// Hàm này nhận một tham chiếu tới Shape
// Nhờ đa hình, nó có thể xử lý bất kỳ đối tượng dẫn xuất nào của Shape
void processShape(const Shape& shape_ref) {
    cout << "Processing a shape via reference: ";
    shape_ref.draw(); // Gọi phương thức draw() thông qua tham chiếu
}

Giải thích:

  • Hàm processShape nhận đối số là một tham chiếu hằng (const Shape&) tới Shape.
  • Bên trong hàm, chúng ta gọi shape_ref.draw(). Vì shape_ref thực sự đang tham chiếu đến một đối tượng Circle hoặc Square, và draw() là phương thức virtual, C++ sẽ gọi phiên bản draw() phù hợp với đối tượng thực tế.

Bước 2: Sử dụng hàm với các đối tượng khác nhau

int main() {
    Circle myCircle;
    Square mySquare;

    cout << "Using processShape with different objects:" << endl;

    // Truyền đối tượng Circle vào hàm
    processShape(myCircle);

    // Truyền đối tượng Square vào hàm
    processShape(mySquare);

    // Không cần delete ở đây vì chúng ta dùng đối tượng trên stack, không dùng 'new'

    return 0;
}

Giải thích:

  • Chúng ta tạo các đối tượng myCirclemySquare trên stack.
  • Chúng ta gọi processShape và truyền myCircle rồi mySquare vào.
  • Mặc dù hàm processShape mong đợi const Shape&, việc truyền myCircle (kiểu Circle) và mySquare (kiểu Square) là hoàn toàn hợp lệ do mối quan hệ kế thừa. Bên trong hàm, đa hình đảm bảo rằng hành vi (draw()) là chính xác cho loại đối tượng được truyền vào.
  • Vì các đối tượng được tạo trên stack, chúng tự động được giải phóng khi ra khỏi phạm vi (main), nên không cần dùng delete.

Kết quả chạy chương trình sẽ là:

Using processShape with different objects:
Processing a shape via reference: Drawing a Circle.
Processing a shape via reference: Drawing a Square.

Đây là một cách thể hiện đa hình rất phổ biến, đặc biệt khi truyền đối tượng vào các hàm để xử lý chung.


Bài tập 3: Đa hình trong Hệ thống Nhân viên

Hãy xây dựng một hệ thống đơn giản quản lý các loại nhân viên khác nhau, ví dụ như nhân viên lương cứng và nhân viên lương theo giờ. Cả hai loại này đều có cách tính lương khác nhau, nhưng chúng ta muốn xử lý chúng một cách thống nhất để tính tổng lương cho toàn bộ công ty.

Bước 1: Định nghĩa Lớp Cơ sở và các Lớp Dẫn xuất

#include <iostream>
#include <vector>

// Lớp cơ sở
class Employee {
protected:
    string name;
public:
    Employee(const string& n) : name(n) {}

    // Phương thức virtual để tính lương
    virtual double calculateSalary() const = 0; // Pure virtual function

    // Phương thức virtual thông thường
    virtual string getName() const {
        return name;
    }

    // Destructor virtual
    virtual ~Employee() {
        cout << "Employee destructor called for " << name << endl;
    }
};

// Lớp dẫn xuất: Nhân viên lương cứng
class SalariedEmployee : public Employee {
private:
    double annualSalary;
public:
    SalariedEmployee(const string& n, double salary)
        : Employee(n), annualSalary(salary) {}

    // Ghi đè phương thức tính lương
    double calculateSalary() const override {
        // Ví dụ: Lương tháng = lương năm / 12
        return annualSalary / 12.0;
    }

    ~SalariedEmployee() override {
        cout << "SalariedEmployee destructor called for " << name << endl;
    }
};

// Lớp dẫn xuất: Nhân viên lương theo giờ
class HourlyEmployee : public Employee {
private:
    double hourlyRate;
    int hoursWorked;
public:
    HourlyEmployee(const string& n, double rate, int hours)
        : Employee(n), hourlyRate(rate), hoursWorked(hours) {}

    // Ghi đè phương thức tính lương
    double calculateSalary() const override {
        // Lương = tỷ lệ giờ * số giờ làm
        return hourlyRate * hoursWorked;
    }

    ~HourlyEmployee() override {
        cout << "HourlyEmployee destructor called for " << name << endl;
    }
};

Giải thích:

  • Lớp Employee là lớp cơ sở. Nó có một thành viên name và constructor để khởi tạo tên.
  • Phương thức calculateSalary() được khai báo là virtual double calculateSalary() const = 0;. Dấu = 0 ở cuối biến nó thành một phương thức ảo thuần túy (pure virtual function). Một lớp có ít nhất một phương thức ảo thuần túy được gọi là lớp trừu tượng (abstract class). Bạn không thể tạo đối tượng trực tiếp từ một lớp trừu tượng. Mục đích của nó là định nghĩa một giao diện mà các lớp dẫn xuất phải thực hiện. Bất kỳ lớp dẫn xuất nào không ghi đè tất cả các phương thức ảo thuần túy của lớp cơ sở cũng sẽ là lớp trừu tượng.
  • Phương thức getName()virtual thông thường, các lớp dẫn xuất có thể ghi đè hoặc sử dụng phiên bản mặc định của lớp cơ sở.
  • Destructor ~Employee()virtual (cũng như các destructor của lớp dẫn xuất) vì chúng ta sẽ dùng con trỏ và delete.
  • SalariedEmployeeHourlyEmployee kế thừa từ Employee. Chúng cung cấp các thành viên và constructor riêng để lưu trữ thông tin lương.
  • Cả hai lớp dẫn xuất bắt buộc phải ghi đè phương thức calculateSalary() để cung cấp logic tính lương cụ thể của từng loại.

Bước 2: Sử dụng Đa hình để Tính Tổng Lương

Bây giờ, chúng ta có thể tạo một danh sách các nhân viên thuộc các loại khác nhau và tính tổng lương của họ một cách dễ dàng nhờ đa hình.

int main() {
    // Sử dụng vector để lưu trữ con trỏ tới các đối tượng Employee
    vector<Employee*> employees;

    // Thêm các loại nhân viên khác nhau vào danh sách
    employees.push_back(new SalariedEmployee("Alice", 60000.0));
    employees.push_back(new HourlyEmployee("Bob", 15.0, 160)); // 160 giờ làm/tháng
    employees.push_back(new SalariedEmployee("Charlie", 72000.0));
    employees.push_back(new HourlyEmployee("David", 18.0, 140));

    double totalSalary = 0.0;

    cout << "Calculating salaries for all employees:" << endl;

    // Lặp qua danh sách và tính lương cho từng nhân viên
    // Mặc dù là con trỏ Employee*, đa hình đảm bảo calculateSalary() đúng được gọi.
    for (const auto& emp_ptr : employees) {
        double monthlySalary = emp_ptr->calculateSalary(); // Đa hình hoạt động ở đây!
        cout << emp_ptr->getName() << ": $" << monthlySalary << endl;
        totalSalary += monthlySalary;
    }

    cout << "\nTotal monthly payroll: $" << totalSalary << endl;

    // Dọn dẹp bộ nhớ
    cout << "\nCleaning up employee objects:" << endl;
    for (const auto& emp_ptr : employees) {
        delete emp_ptr; // Đảm bảo destructor đúng được gọi nhờ 'virtual'
    }

    return 0;
}

Giải thích:

  • Chúng ta tạo một vector<Employee*>. Vector này có thể chứa con trỏ tới bất kỳ đối tượng nào là dẫn xuất của Employee.
  • Chúng ta tạo các đối tượng SalariedEmployeeHourlyEmployee bằng new và thêm con trỏ của chúng vào vector.
  • Khi lặp qua vector, chúng ta gọi emp_ptr->calculateSalary(). Tại đây, đa hình phát huy tác dụng:
    • Nếu emp_ptr trỏ tới một SalariedEmployee, C++ gọi SalariedEmployee::calculateSalary().
    • Nếu emp_ptr trỏ tới một HourlyEmployee, C++ gọi HourlyEmployee::calculateSalary().
  • Chúng ta có thể xử lý tất cả các loại nhân viên khác nhau thông qua cùng một vòng lặp và cùng một lời gọi phương thức calculateSalary(), làm cho code linh hoạtdễ mở rộng. Nếu sau này bạn có thêm loại nhân viên mới (ví dụ: nhân viên theo dự án), bạn chỉ cần tạo lớp mới kế thừa từ Employee, ghi đè calculateSalary(), và thêm đối tượng của lớp mới vào vector này mà không cần thay đổi logic tính tổng lương.
  • Cuối cùng, chúng ta dọn dẹp bộ nhớ bằng cách delete từng con trỏ. Destructor virtual đảm bảo quá trình giải phóng bộ nhớ diễn ra đúng đắn.

Kết quả chạy chương trình sẽ là (các số có thể khác tùy dữ liệu đầu vào):

Calculating salaries for all employees:
Alice: $5000
Bob: $2400
Charlie: $6000
David: $2520

Total monthly payroll: $15920

Cleaning up employee objects:
SalariedEmployee destructor called for Alice
Employee destructor called for Alice
HourlyEmployee destructor called for Bob
Employee destructor called for Bob
SalariedEmployee destructor called for Charlie
Employee destructor called for Charlie
HourlyEmployee destructor called for David
Employee destructor called for David

Các điểm quan trọng cần ghi nhớ khi thực hành Đa hình:

  1. Kế Thừa: Đa hình chỉ hoạt động giữa các lớp có quan hệ kế thừa.
  2. Phương thức virtual: Chỉ các phương thức được đánh dấu là virtual trong lớp cơ sở mới có thể được gọi đa hình thông qua con trỏ/tham chiếu lớp cơ sở.
  3. Ghi đè (override): Các lớp dẫn xuất cung cấp phiên bản riêng của phương thức virtual bằng cách ghi đè nó. Sử dụng từ khóa override giúp tránh lỗi chính tả hoặc sai chữ ký phương thức.
  4. Con trỏ/Tham chiếu Lớp Cơ sở: Bạn cần sử dụng con trỏ hoặc tham chiếu tới lớp cơ sở để lưu trữ hoặc thao tác với các đối tượng dẫn xuất và gọi phương thức virtual một cách đa hình.
  5. Destructor virtual: Luôn luôn khai báo destructor của lớp cơ sở là virtual nếu lớp đó có bất kỳ phương thức virtual nào và bạn dự định xóa các đối tượng dẫn xuất thông qua con trỏ/tham chiếu lớp cơ sở. Điều này là cực kỳ quan trọng để ngăn chặn rò rỉ bộ nhớ và đảm bảo giải phóng tài nguyên đúng cách.
  6. Lớp Trừu tượng và Phương thức ảo thuần túy (= 0): Sử dụng phương thức ảo thuần túy để định nghĩa các hành vi mà bắt buộc các lớp dẫn xuất phải triển khai. Lớp chứa phương thức ảo thuần túy trở thành lớp trừu tượng và không thể tạo đối tượng trực tiếp.

Comments

There are no comments at the moment.