Chương 7: Tính Đa Hình (Polymorphism)


1. Giới thiệu về Tính Đa Hình

Tính đa hình (Polymorphism) là một trong bốn tính chất cốt lõi của lập trình hướng đối tượng (OOP), bên cạnh tính đóng gói, kế thừa và trừu tượng.

Định nghĩa: Đa hình là hiện tượng các đối tượng thuộc các lớp khác nhau có khả năng hiểu cùng một thông điệp theo các cách khác nhau.

Tính đa hình xuất hiện khi có sự kế thừa giữa các lớp. Có những phương thức mang tính tổng quát cho mọi lớp dẫn xuất nên phải có mặt ở lớp cơ sở, nhưng nội dung cụ thể của nó chỉ được xác định ở các lớp dẫn xuất.

Ví dụ thực tế: Khi nhận cùng một thông điệp “nhảy”, một con kangaroo và một con cóc nhảy theo hai kiểu hoàn toàn khác nhau — chúng cùng có hành vi “nhảy” nhưng cách thực hiện khác nhau. Tương tự, phương thức tinhDienTich() ở lớp hình tam giác và lớp hình tứ giác có cùng tên nhưng thuật toán tính hoàn toàn khác nhau.


2. Bài Toán Đặt Ra

Giả sử cần quản lý một danh sách các đối tượng có kiểu khác nhau (Người, Sinh viên, Công nhân). Có hai vấn đề cần giải quyết:

  • Lưu trữ: Dùng mảng con trỏ kiểu lớp cơ sở, danh sách liên kết, v.v.
  • Thao tác xử lý: Phải thỏa mãn yêu cầu đa hình — thao tác hoạt động khác nhau ứng với từng loại đối tượng.

hai cách để giải quyết:

  1. Vùng chọn kiểu (Type selector field)
  2. Phương thức ảo (Virtual method)

3. Ví Dụ Cơ Sở — Hệ Thống Phân Cấp Lớp

Trước khi đi vào hai cách giải quyết, hãy xem cấu trúc phân cấp lớp được dùng xuyên suốt chương:

classDiagram class Nguoi { #char* HoTen #int NamSinh +Nguoi(ht, ns) +~Nguoi() +An() +Xuat() } class SinhVien { #char* MaSo +SinhVien(n, ms, ns) +~SinhVien() +Xuat() } class NuSinh { +NuSinh(ht, ms, ns) +An() } class CongNhan { #double MucLuong +CongNhan(n, ml, ns) +Xuat() } Nguoi <|-- SinhVien Nguoi <|-- CongNhan SinhVien <|-- NuSinh

Khai báo các lớp:

class Nguoi {
protected:
    char *HoTen;
    int NamSinh;
public:
    Nguoi(char *ht, int ns) : NamSinh(ns) { HoTen = strdup(ht); }
    ~Nguoi() { delete[] HoTen; }
    void An() const { cout << HoTen << " an 3 chen com"; }
    void Xuat() const {
        cout << "Nguoi, ho ten: " << HoTen << " sinh " << NamSinh;
    }
};

class SinhVien : public Nguoi {
protected:
    char *MaSo;
public:
    SinhVien(char *n, char *ms, int ns) : Nguoi(n, ns) {
        MaSo = strdup(ms);
    }
    ~SinhVien() { delete[] MaSo; }
    void Xuat() const {
        cout << "Sinh vien " << HoTen << ", ma so " << MaSo;
    }
};

class NuSinh : public SinhVien {
public:
    NuSinh(char *ht, char *ms, int ns) : SinhVien(ht, ms, ns) {}
    void An() const {
        cout << HoTen << " ma so " << MaSo << " an 2 to pho";
    }
};

class CongNhan : public Nguoi {
protected:
    double MucLuong;
public:
    CongNhan(char *n, double ml, int ns) : Nguoi(n, ns), MucLuong(ml) {}
    void Xuat() const {
        cout << "Cong nhan, ten " << HoTen << " muc luong: " << MucLuong;
    }
};

Vấn đề xảy ra khi gọi hàm in danh sách qua con trỏ lớp cơ sở:

void XuatDs(int n, Nguoi *an[]) {
    for (int i = 0; i < n; i++) {
        an[i]->Xuat();  // Luon goi Xuat() cua lop Nguoi!
        cout << "\n";
    }
}

void main() {
    Nguoi *a[4];
    a[0] = new SinhVien("Vien Van Sinh", "200001234", 1982);
    a[1] = new NuSinh("Le Thi Ha Dong", "200001235", 1984);
    a[2] = new CongNhan("Tran Nhan Cong", 1000000, 1984);
    a[3] = new Nguoi("Nguyen Thanh Nhan", 1960);
    XuatDs(4, a);
}

Kết quả (sai — tất cả đều gọi Xuat() của Nguoi):

Nguoi, ho ten: Vien Van Sinh sinh 1982
Nguoi, ho ten: Le Thi Ha Dong sinh 1984
Nguoi, ho ten: Tran Nhan Cong sinh 1984
Nguoi, ho ten: Nguyen Thanh Nhan sinh 1960

Nguyên nhân: Không có đa hình — khi truy xuất qua con trỏ lớp cơ sở Nguoi*, trình biên dịch chỉ gọi phiên bản Xuat() của Nguoi, bất kể đối tượng thực sự thuộc lớp nào. Đây là kết nối tĩnh (static binding).


4. Cách 1 — Vùng Chọn Kiểu (Type Selector)

4.1 Ý tưởng

Thêm một trường dữ liệu đặc biệt vào lớp cơ sở để nhận diện kiểu thực sự của đối tượng. Dùng enum để biểu diễn các kiểu có thể có.

class Nguoi {
public:
    enum LOAI { NGUOI, SV, CN };
protected:
    char *HoTen;
    int NamSinh;
public:
    LOAI pl;   // <-- Vung chon kieu

    Nguoi(char *ht, int ns) : NamSinh(ns), pl(NGUOI) {
        HoTen = strdup(ht);
    }
    ~Nguoi() { delete[] HoTen; }
    void An() const { cout << HoTen << " an 3 chen com"; }
    void Xuat() const {
        cout << "Nguoi, ho ten: " << HoTen << " sinh " << NamSinh;
    }
};

class SinhVien : public Nguoi {
protected:
    char *MaSo;
public:
    SinhVien(char *n, char *ms, int ns) : Nguoi(n, ns) {
        MaSo = strdup(ms);
        pl = SV;   // <-- Dat gia tri vung chon kieu
    }
    ~SinhVien() { delete[] MaSo; }
    void Xuat() const {
        cout << "Sinh vien " << HoTen << ", ma so " << MaSo;
    }
};

class CongNhan : public Nguoi {
protected:
    double MucLuong;
public:
    CongNhan(char *n, double ml, int ns) : Nguoi(n, ns), MucLuong(ml) {
        pl = CN;   // <-- Dat gia tri vung chon kieu
    }
    void Xuat() const {
        cout << "Cong nhan, ten " << HoTen << " muc luong: " << MucLuong;
    }
};

4.2 Hàm xử lý dùng switch-case

void XuatDs(int n, Nguoi *an[]) {
    for (int i = 0; i < n; i++) {
        switch (an[i]->pl) {
            case Nguoi::SV:
                ((SinhVien*)an[i])->Xuat();  // Ep kieu ve SinhVien*
                break;
            case Nguoi::CN:
                ((CongNhan*)an[i])->Xuat();  // Ep kieu ve CongNhan*
                break;
            default:
                an[i]->Xuat();
                break;
        }
        cout << "\n";
    }
}

Kết quả (đúng):

Sinh vien Vien Van Sinh, ma so 200001234
Sinh vien Le Thi Ha Dong, ma so 200001235
Cong nhan, ten Tran Nhan Cong muc luong: 1000000
Nguoi, ho ten: Nguyen Thanh Nhan sinh 1960

4.3 Nhược điểm của vùng chọn kiểu


5. Cách 2 — Phương Thức Ảo (Virtual Method)

5.1 Khái niệm

Phương thức ảo là cơ chế C++ dùng để hiện thực hóa tính đa hình. Khai báo một hàm thành phần là phương thức ảo bằng từ khóa virtual.

Khi đó, trình biên dịch sẽ tự động gọi đúng phiên bản phương thức tương ứng với kiểu thực sự của đối tượng lúc chạy — dù đang truy xuất qua con trỏ lớp cơ sở. Đây là kết nối động (dynamic/late binding).

class Nguoi {
protected:
    char *HoTen;
    int NamSinh;
public:
    Nguoi(char *ht, int ns) : NamSinh(ns) { HoTen = strdup(ht); }
    ~Nguoi() { delete[] HoTen; }
    void An() const { cout << HoTen << " an 3 chen com"; }

    virtual void Xuat() const {   // <-- Tu khoa virtual
        cout << "Nguoi, ho ten: " << HoTen << " sinh " << NamSinh;
    }
};

Các lớp SinhVien, CongNhan giữ nguyên — chỉ cần thêm virtual ở lớp cơ sở là đủ (các lớp con tự động kế thừa tính ảo).

void XuatDs(int n, Nguoi *an[]) {
    for (int i = 0; i < n; i++) {
        an[i]->Xuat();   // Tu dong goi dung phien ban cua tung lop!
        cout << "\n";
    }
}

Kết quả đúng, không cần switch-case:

Sinh vien Vien Van Sinh, ma so 200001234
Sinh vien Le Thi Ha Dong, ma so 200001235
Cong nhan, ten Tran Nhan Cong muc luong: 1000000
Nguoi, ho ten: Nguyen Thanh Nhan sinh 1960

5.2 So sánh: Kết nối tĩnh vs Kết nối động

flowchart TD A[Goi an_i Xuat] --> B{Xuat co la virtual?} B -- Khong --> C[Static Binding\nGoi Nguoi::Xuat\nluc bien dich] B -- Co --> D[Dynamic Binding\nTra bang vtable\nluc chay] D --> E{Kieu thuc su cua doi tuong?} E --> F[Nguoi -> Nguoi::Xuat] E --> G[SinhVien -> SinhVien::Xuat] E --> H[CongNhan -> CongNhan::Xuat]

5.3 Cơ chế hoạt động — Bảng Phương Thức Ảo (vtable)

Khi một lớp có phương thức ảo, trình biên dịch tự động tạo ra một bảng phương thức ảo (virtual method table — vtable) cho mỗi lớp. Mỗi đối tượng lưu một con trỏ ẩn (vptr) trỏ đến vtable của lớp tương ứng.

flowchart LR subgraph Objects["Doi tuong trong bo nho"] n["doi tuong Nguoi\nvptr ->"] sv["doi tuong SinhVien\nvptr ->"] cn["doi tuong CongNhan\nvptr ->"] end subgraph vtables["Bang phuong thuc ao vtable"] vt_n["vtable Nguoi\nXuat -> Nguoi::Xuat"] vt_sv["vtable SinhVien\nXuat -> SinhVien::Xuat"] vt_cn["vtable CongNhan\nXuat -> CongNhan::Xuat"] end n --> vt_n sv --> vt_sv cn --> vt_cn

Khi gọi an[i]->Xuat():

  1. Truy cập vptr trong đối tượng an[i]
  2. Lấy địa chỉ hàm Xuat từ vtable tương ứng
  3. Gọi đúng phiên bản hàm

5.4 Dễ dàng mở rộng — Thêm lớp mới

Ưu điểm nổi bật của phương thức ảo: không cần sửa code cũ khi thêm lớp mới.

class CaSi : public Nguoi {
protected:
    double CatXe;
public:
    CaSi(char *ht, double cx, int ns) : Nguoi(ht, ns), CatXe(cx) {}
    void Xuat() const {
        cout << "Ca si, " << HoTen << " co cat xe " << CatXe;
    }
};

Hàm XuatDs không thay đổi một dòng nào, nhưng vẫn xử lý đúng đối tượng CaSi mới tạo ra. Đây chính là biểu hiện của nguyên tắc Open/Closed.


6. Các Lưu Ý Quan Trọng Khi Dùng Phương Thức Ảo


7. Phương Thức Hủy Bỏ Ảo (Virtual Destructor)

Khi dọn dẹp mảng con trỏ lớp cơ sở, nếu destructor không phải ảo, chỉ destructor của lớp cơ sở được gọi — gây rò rỉ bộ nhớ.

for (int i = 0; i < 4; i++)
    delete a[i];  // Neu ~Nguoi() khong la virtual -> chi goi ~Nguoi()
                  // -> Bo nho cua MaSo (SinhVien) bi ro ri!

Giải pháp: Khai báo destructor là ảo ở lớp cơ sở:

class Nguoi {
protected:
    char *HoTen;
    int NamSinh;
public:
    Nguoi(char *ht, int ns) : NamSinh(ns) { HoTen = strdup(ht); }

    virtual ~Nguoi() {       // <-- Destructor ao
        delete[] HoTen;
    }

    virtual void Xuat(ostream &os) const { /* ... */ }
};

8. Phương Thức Thuần Ảo và Lớp Cơ Sở Trừu Tượng

8.1 Vấn đề

Đôi khi lớp cơ sở không có nội dung có nghĩa cho một phương thức — ví dụ Shape (Hình) không biết cách vẽ cụ thể, chỉ các lớp Circle, Rectangle mới biết. Nhưng ta vẫn muốn ép buộc các lớp con phải cài đặt phương thức đó.

8.2 Phương Thức Thuần Ảo (Pure Virtual Function)

Phương thức thuần ảo là phương thức ảo không có thân hàm, được khai báo bằng cú pháp = 0:

class Shape {   // Lop co so truu tuong
public:
    virtual void draw() = 0;   // <-- Phuong thuc thuan ao
};

Khi lớp có ít nhất một phương thức thuần ảo, lớp đó trở thành lớp cơ sở trừu tượng (Abstract Base Class — ABC). Không thể tạo đối tượng trực tiếp từ lớp trừu tượng.

Shape s;      // LOI bien dich! Khong the tao doi tuong lop truu tuong
Shape *ps;    // OK - con tro den lop truu tuong la hop le

8.3 Ví dụ đầy đủ

class Shape {    // Abstract Base Class
public:
    virtual void draw() = 0;    // Thuan ao - ep buo cac lop con phai cai dat
};

class Circle : public Shape {
public:
    void print() {
        cout << "I am a circle" << endl;
    }
    // CHUA override draw() -> Circle van la abstract class!
};

class Rectangle : public Shape {
public:
    void draw() {    // Override phuong thuc thuan ao
        cout << "Drawing Rectangle" << endl;
    }
};
classDiagram class Shape { <> +draw()* } class Circle { +print() } class Rectangle { +draw() } class Triangle { +draw() } Shape <|-- Circle Shape <|-- Rectangle Shape <|-- Triangle

8.4 Vai trò của lớp cơ sở trừu tượng


9. Bài Tập — Hệ Thống Tính Lương Công Ty ABC

Đề bài

Công ty ABC sản xuất kinh doanh thú nhồi bông, có nhân viên ở ba bộ phận:

  • Nhân viên văn phòng: Lương = Lương Cơ Bản + Số ngày làm việc × 200.000 + Trợ Cấp
  • Nhân viên sản xuất: Lương = Lương Cơ Bản + Số Sản Phẩm × 2.000
  • Nhân viên quản lý: Lương = Lương Cơ Bản × Hệ số chức vụ + Thưởng

Yêu cầu:

  1. Nhập thông tin nhân viên
  2. Tính lương từng nhân viên
  3. Xuất thông tin nhân viên
  4. Tính tổng lương công ty
  5. Tìm kiếm nhân viên theo họ tên

Thiết kế hệ thống lớp

classDiagram class NhanVien { <> #char* HoTen #int NgaySinh #double LuongCoBan +NhanVien(ht, ns, lcb) +virtual ~NhanVien() +virtual double TinhLuong()* +virtual void Nhap()* +virtual void Xuat()* } class NVVanPhong { -int SoNgayLamViec -double TroCap +TinhLuong() double +Nhap() +Xuat() } class NVSanXuat { -int SoSanPham +TinhLuong() double +Nhap() +Xuat() } class NVQuanLy { -double HeSoChucVu -double Thuong +TinhLuong() double +Nhap() +Xuat() } NhanVien <|-- NVVanPhong NhanVien <|-- NVSanXuat NhanVien <|-- NVQuanLy

Lời giải

#include <iostream>
#include <cstring>
#include <string>
using namespace std;

// ===================== LOP CO SO TRUU TUONG =====================
class NhanVien {
protected:
    char *HoTen;
    int NgaySinh;
    double LuongCoBan;
public:
    NhanVien(const char *ht, int ns, double lcb)
        : NgaySinh(ns), LuongCoBan(lcb) {
        HoTen = strdup(ht);
    }
    virtual ~NhanVien() { delete[] HoTen; }

    virtual double TinhLuong() const = 0;   // Thuan ao
    virtual void Nhap() = 0;                // Thuan ao
    virtual void Xuat() const = 0;          // Thuan ao

    const char* GetHoTen() const { return HoTen; }
};

// ===================== NHAN VIEN VAN PHONG =====================
class NVVanPhong : public NhanVien {
private:
    int SoNgayLamViec;
    double TroCap;
public:
    NVVanPhong() : NhanVien("", 0, 0), SoNgayLamViec(0), TroCap(0) {}

    void Nhap() override {
        char ht[100];
        cout << "Ho ten: "; cin.getline(ht, 100);
        HoTen = strdup(ht);
        cout << "Nam sinh: "; cin >> NgaySinh;
        cout << "Luong co ban: "; cin >> LuongCoBan;
        cout << "So ngay lam viec: "; cin >> SoNgayLamViec;
        cout << "Tro cap: "; cin >> TroCap;
        cin.ignore();
    }

    double TinhLuong() const override {
        return LuongCoBan + SoNgayLamViec * 200000.0 + TroCap;
    }

    void Xuat() const override {
        cout << "[Van Phong] " << HoTen
             << " | Nam sinh: " << NgaySinh
             << " | Luong: " << TinhLuong() << " VND\n";
    }
};

// ===================== NHAN VIEN SAN XUAT =====================
class NVSanXuat : public NhanVien {
private:
    int SoSanPham;
public:
    NVSanXuat() : NhanVien("", 0, 0), SoSanPham(0) {}

    void Nhap() override {
        char ht[100];
        cout << "Ho ten: "; cin.getline(ht, 100);
        HoTen = strdup(ht);
        cout << "Nam sinh: "; cin >> NgaySinh;
        cout << "Luong co ban: "; cin >> LuongCoBan;
        cout << "So san pham: "; cin >> SoSanPham;
        cin.ignore();
    }

    double TinhLuong() const override {
        return LuongCoBan + SoSanPham * 2000.0;
    }

    void Xuat() const override {
        cout << "[San Xuat] " << HoTen
             << " | Nam sinh: " << NgaySinh
             << " | Luong: " << TinhLuong() << " VND\n";
    }
};

// ===================== NHAN VIEN QUAN LY =====================
class NVQuanLy : public NhanVien {
private:
    double HeSoChucVu;
    double Thuong;
public:
    NVQuanLy() : NhanVien("", 0, 0), HeSoChucVu(1.0), Thuong(0) {}

    void Nhap() override {
        char ht[100];
        cout << "Ho ten: "; cin.getline(ht, 100);
        HoTen = strdup(ht);
        cout << "Nam sinh: "; cin >> NgaySinh;
        cout << "Luong co ban: "; cin >> LuongCoBan;
        cout << "He so chuc vu: "; cin >> HeSoChucVu;
        cout << "Thuong: "; cin >> Thuong;
        cin.ignore();
    }

    double TinhLuong() const override {
        return LuongCoBan * HeSoChucVu + Thuong;
    }

    void Xuat() const override {
        cout << "[Quan Ly] " << HoTen
             << " | Nam sinh: " << NgaySinh
             << " | Luong: " << TinhLuong() << " VND\n";
    }
};

// ===================== HAM TIEN ICH =====================
void XuatDanhSach(int n, NhanVien *ds[]) {
    cout << "\n===== DANH SACH NHAN VIEN =====\n";
    for (int i = 0; i < n; i++)
        ds[i]->Xuat();
}

double TongLuong(int n, NhanVien *ds[]) {
    double tong = 0;
    for (int i = 0; i < n; i++)
        tong += ds[i]->TinhLuong();
    return tong;
}

NhanVien* TimKiem(int n, NhanVien *ds[], const char *ten) {
    for (int i = 0; i < n; i++)
        if (strcmp(ds[i]->GetHoTen(), ten) == 0)
            return ds[i];
    return nullptr;
}

10. Tổng Kết

flowchart TD A[Can da hinh?] --> B{Chon giai phap} B --> C[Vung chon kieu\nswitch-case] B --> D[Phuong thuc ao\nvirtual] C --> C1[Uu: Don gian\nde hieu] C --> C2[Nhuoc: Code dai, kho bao tri\nde sai sot khi mo rong] D --> D1[Uu: Linh hoat, de mo rong\nkhong can sua code cu] D --> D2[Nhuoc: Overhead nho\ndo vtable] D --> E{Co phuong thuc\nthuan ao?} E -- Co --> F[Lop co so truu tuong\nKhong tao duoc doi tuong truc tiep\nEp buoc lop con cai dat] E -- Khong --> G[Lop co so binh thuong\nCo the tao doi tuong]
Đặc điểmVùng chọn kiểuPhương thức ảo
Thêm lớp mớiPhải sửa switch ở mọi nơiChỉ cần tạo lớp mới
Độ an toànDễ quên cập nhậtTrình biên dịch tự xử lý
Hiệu năngTốt hơn đôi chútOverhead nhỏ do tra vtable
Đọc hiểu codeRõ luồng điTrừu tượng hơn
Ứng dụng thực tếHiếm dùngChuẩn OOP hiện đại