Chuyển tới nội dung chính

Kế thừa và đa hình trong C++

Bạn đang viết một chương trình vẽ. Có hình tròn, hình chữ nhật, hình tam giác. Cách viết ngây thơ là một mảng riêng cho mỗi loại, và ở chỗ vẽ thì if phân loại: nếu là hình tròn thì gọi hàm này, nếu là hình chữ nhật thì gọi hàm kia. Nó chạy được. Nhưng hôm sau khách hàng xin thêm hình ngôi sao, bạn phải đi tìm mọi chỗ có cái if đó (chỗ vẽ, chỗ tính diện tích, chỗ lưu file, chỗ in) và sửa từng chỗ. Bỏ sót một chỗ là có lỗi. Chương trình càng lớn thì cái danh sách if này càng là nơi sinh ra lỗi.

Kế thừađa hình là câu trả lời của lập trình hướng đối tượng cho đúng vấn đề đó. Kế thừa cho phép một lớp con dùng lại và mở rộng lớp cha. Đa hình cho phép ta viết một dòng duy nhất, hinh->ve(), mà dòng ấy tự chạy đúng hành vi của kiểu thực sự của đối tượng lúc chương trình đang chạy. Thêm hình ngôi sao lúc này chỉ là thêm một lớp mới, không phải sửa bất kỳ chỗ nào đã có. Đó là lý do người ta hay nói: đa hình biến các câu lệnh rẽ nhánh thành các lớp.

Có một cách rất gợi hình để hiểu chuyện gì diễn ra. Con trỏ Animal* giống như tấm biển tên dán ngoài chiếc hộp, còn đối tượng thật bên trong hộp có thể là Dog hoặc Cat. Trình biên dịch chỉ đọc được tấm biển (kiểu tĩnh). Nhưng nếu ta đánh dấu phương thức là virtual, C++ sẽ mở hộp ra lúc chạy để xem bên trong thật sự là gì (kiểu động), rồi mới quyết định gọi hàm nào. Chính bước "mở hộp lúc chạy" ấy là toàn bộ phép màu.

Thử ngay: xem đa hình chạy từng bước

Bấm ▶ Chạy (hoặc kéo thanh trượt) để theo dõi: con trỏ trên ngăn xếp, đối tượng trên bộ nhớ động, và lời gọi virtual được phân giải về đúng lớp nào. Chưa cần nhớ cú pháp, cứ nhìn hiện tượng trước: hai dòng lệnh trông giống hệt nhau lại chạy ra hai kết quả khác nhau. Các mục bên dưới sẽ giải thích vì sao.

Đa hình qua con trỏ lớp cha C++
1#include <iostream>
2using namespace std;
3
4class Animal {
5public:
6 virtual void speak() { cout << "..."; }
7 virtual ~Animal() {}
8};
9class Dog : public Animal {
10public:
11 void speak() override { cout << "Gau gau\n"; }
12};
13class Cat : public Animal {
14public:
15 void speak() override { cout << "Meo meo\n"; }
16};
17
18int main() {
19 Animal* a = new Dog();
20 Animal* b = new Cat();
21 a->speak();
22 b->speak();
23 delete a;
24 delete b;
25}
Ngăn xếp stack
main()
Bộ nhớ động heap
(trống)
Bắt đầu hàm main(). Chưa có biến nào, bộ nhớ động (heap) còn trống.
1/7

Ý tưởng cốt lõi

  • Kế thừa public diễn đạt quan hệ is-a: một Dog là một Animal.
  • Nhờ quan hệ đó, con trỏ lớp cha (Animal*) có thể trỏ tới đối tượng lớp con (Dog, Cat).
  • Kiểu tĩnh (static type) của con trỏ là Animal, cố định lúc biên dịch.
  • Kiểu động (dynamic type) là kiểu đối tượng thật trên heap, chỉ biết lúc chạy.
  • Khi hàm là virtual, C++ gọi theo kiểu động (qua vtable). Đó là đa hình.
  • Nếu lớp có hàm virtual và có thể bị delete qua con trỏ cha, hủy tử phải là virtual.
Vì sao cần virtual?

Nếu bỏ virtual, lời gọi a->speak() sẽ chạy theo kiểu tĩnh Animal (gọi nhầm Animal::speak). Có virtual, nó chạy theo kiểu động Dog. Hủy tử cũng phải virtual để delete qua con trỏ cha giải phóng đúng phần của lớp con.

Mã nguồn

#include <iostream>
using namespace std;

class Animal {
public:
virtual void speak() { cout << "..."; }
virtual ~Animal() {}
};
class Dog : public Animal {
public:
void speak() override { cout << "Gau gau\n"; }
};
class Cat : public Animal {
public:
void speak() override { cout << "Meo meo\n"; }
};

int main() {
Animal* a = new Dog();
Animal* b = new Cat();
a->speak();
b->speak();
delete a;
delete b;
}

Kế thừa is-a: kiểm tra trước khi viết dấu hai chấm

Kế thừa public là một lời khẳng định rất mạnh: ở bất kỳ đâu mã nguồn cần một Animal, ta có thể đưa một Dog vào và mọi thứ vẫn đúng. Trước khi cho lớp B kế thừa lớp A, hãy tự hỏi: mỗi B là một A phải không? Nếu câu trả lời gượng gạo thì đó không phải chỗ dùng kế thừa.

Ví dụ kinh điển về việc trả lời sai: cho HinhVuong kế thừa HinhChuNhat vì hình vuông là một hình chữ nhật theo toán học. Nhưng nếu HinhChuNhatdatChieuRong(w)datChieuCao(h) độc lập, một hàm nhận HinhChuNhat& có quyền đặt rộng 5, cao 3 rồi mong diện tích là 15. Đưa HinhVuong vào, nó buộc hai cạnh bằng nhau và kết quả sai. Quan hệ toán học không tự động là quan hệ thay thế được trong lập trình. Khi nghi ngờ, hãy dùng thành phần (giữ một đối tượng của lớp kia làm thành viên) thay vì kế thừa.

Ghi đè phương thức: điều kiện và cạm bẫy

Để ghi đè (override) một phương thức của lớp cha, chữ ký ở lớp con phải trùng khớp hoàn toàn: cùng tên, cùng danh sách tham số, cùng tính const. Chỉ cần lệch một chi tiết nhỏ, C++ không báo lỗi mà lặng lẽ coi đó là một hàm mới, chỉ tình cờ trùng tên. Chương trình vẫn biên dịch, vẫn chạy, và gọi nhầm hàm của lớp cha.

Từ khóa override sinh ra để chặn đúng cái bẫy này: nó bảo trình biên dịch rằng ta có ý ghi đè, và nếu không có hàm ảo nào ở lớp cha khớp thì hãy báo lỗi.

#include <iostream>
using namespace std;

class Base {
public:
virtual void f(int x) const { cout << "Base::f\n"; }
virtual ~Base() {}
};

class Sai : public Base {
public:
// No const: this is a NEW function, it does not override anything.
// The compiler stays silent. Calling through Base* runs Base::f.
void f(int x) { cout << "Sai::f\n"; }
};

class Dung : public Base {
public:
// 'override' asks the compiler to verify the signature really matches.
void f(int x) const override { cout << "Dung::f\n"; }
};

int main() {
const Base* a = new Sai();
const Base* b = new Dung();
a->f(1); // prints "Base::f": the bug, silently
b->f(1); // prints "Dung::f": correct override
delete a;
delete b;
return 0;
}

Cạnh override còn có final. Ghi void f(int) const override final; nghĩa là "không lớp con nào được ghi đè tiếp hàm này nữa". Ghi class Dog final : public Animal nghĩa là "không ai được kế thừa Dog". Đây là cách khóa lại một nhánh thiết kế mà bạn không muốn người khác mở rộng.

Lỗi thường gặp khi ghi đè
  • Quên virtual ở lớp cha. Không có virtual thì không có ghi đè, chỉ có che khuất (hiding). Lời gọi qua Animal* sẽ chạy theo kiểu tĩnh.
  • Lệch chữ ký. Thiếu const, sai kiểu tham số (int với long), hay sai tên do gõ nhầm đều biến "ghi đè" thành "hàm mới". Luôn viết override.
  • Che khuất do quá tải. Nếu lớp cha có f(int)f(double), mà lớp con chỉ định nghĩa lại f(int), thì cả hai bản của cha bị che khuất và con.f(3.14) sẽ gọi f(int). Muốn kéo lại các bản của cha, viết using Base::f; trong lớp con.
  • Gọi hàm ảo trong hàm dựng hoặc hủy tử. Lúc đó phần lớp con chưa được dựng (hoặc đã bị hủy), nên lời gọi ảo không phân giải xuống lớp con. Đừng trông cậy vào đa hình ở hai chỗ này.

virtual hoạt động ra sao: bảng ảo

Cơ chế bên dưới đơn giản đến bất ngờ. Khi một lớp có ít nhất một hàm virtual, trình biên dịch tạo cho lớp đó một bảng tĩnh gọi là bảng ảo (vtable), chứa địa chỉ các hàm ảo phiên bản của lớp ấy. Mỗi đối tượng của lớp đó được thêm một con trỏ ẩn (vptr) trỏ tới bảng ảo của lớp mình. Hàm dựng là nơi đặt vptr này.

Khi ta viết a->speak() với aAnimal*, trình biên dịch không nhảy thẳng tới địa chỉ Animal::speak. Nó sinh mã đại ý: "đọc vptr trong đối tượng mà a trỏ tới, vào bảng ảo, lấy ô số 0, nhảy tới địa chỉ ở đó". Nếu đối tượng thật là Dog, vptr của nó trỏ tới bảng của Dog, ô số 0 chứa Dog::speak. Thế là xong, không cần if nào cả.

Từ mô hình này rút ra vài hệ quả thực tế đáng nhớ:

  • Đối tượng của lớp có hàm ảo to hơn một chút (thêm một con trỏ). Với lớp chỉ có vài int thì tỉ lệ tăng không nhỏ.
  • Lời gọi ảo tốn thêm một lần đọc bộ nhớ và một lần nhảy gián tiếp, nên hơi chậm hơn lời gọi thường và thường không được nội tuyến (inline). Trong mã bình thường sự khác biệt này không đáng kể, chỉ đáng cân nhắc ở vòng lặp nóng.
  • Trong hàm dựng của Animal, vptr còn đang trỏ tới bảng của Animal. Đó chính là lý do hàm ảo gọi trong hàm dựng không xuống được lớp con.

Xem chạy từng bước: điều phối động qua một con trỏ lớp cha

Bản trace ở đầu bài có hai con trỏ trỏ tới hai kiểu khác nhau. Lần này chỉ một con trỏ DongVat* trỏ tới một đối tượng Cho, để bạn nhìn kỹ đúng cơ chế bảng ảo vừa mô tả: chương trình đọc vptr trong đối tượng, vào bảng ảo của lớp thật, rồi mới quyết định nhảy tới hàm nào. Đó chính là bước "mở hộp lúc chạy" nói ở đầu bài, lần này nhìn từ bên dưới. Chú ý trường call (điều phối động) và nhãn kiểu tĩnh trên đối tượng heap.

Con trỏ lớp cha, kiểu động quyết định hàm được gọi C++
1#include <iostream>
2using namespace std;
3class DongVat {
4public:
5 virtual void keu() { cout << "..."; }
6 virtual ~DongVat() {}
7};
8class Cho : public DongVat {
9public:
10 void keu() override { cout << "Gau gau"; }
11};
12int main() {
13 DongVat* p = new Cho(); // static type DongVat, dynamic type Cho
14 p->keu();
15 delete p;
16}
Ngăn xếp stack
main()
Bộ nhớ động heap
(trống)
Bắt đầu main(). Chưa có biến, bộ nhớ động còn trống.
1/6

Hủy tử ảo: điều tuyệt đối không được quên

Đây là quy tắc quan trọng nhất của cả bài. Nếu một lớp có hàm ảo, hoặc dự định bị kế thừa và bị delete qua con trỏ lớp cha, thì hủy tử của nó phải là virtual.

Vì sao? Vì delete a với a kiểu Animal* cũng là một lời gọi hàm: lời gọi hủy tử. Nếu hủy tử không ảo, lời gọi này phân giải theo kiểu tĩnh, tức là chỉ ~Animal() chạy. Phần riêng của Dog không bao giờ được hủy. Nếu Dog giữ một vector hay một unique_ptr hay một file đang mở, tất cả đều rò rỉ. Về mặt chuẩn ngôn ngữ, đây thậm chí là hành vi không xác định.

#include <iostream>
#include <vector>
using namespace std;

class Xau {
public:
virtual void chay() { cout << "Xau::chay\n"; }
virtual ~Xau() { cout << "~Xau\n"; } // WITHOUT virtual: ~Con never runs
};

class Con : public Xau {
vector<int> duLieu; // a real resource to release
public:
Con() : duLieu(1000000) {} // holds a lot of memory
void chay() override { cout << "Con::chay\n"; }
~Con() override { cout << "~Con: giai phong " << duLieu.size() << " phan tu\n"; }
};

int main() {
Xau* p = new Con();
p->chay(); // Con::chay
delete p; // virtual destructor -> ~Con runs first, then ~Xau
return 0;
}

Hãy thử bỏ chữ virtual~Xau() rồi chạy lại. Dòng ~Con biến mất khỏi kết quả, và cả triệu phần tử của vector rò rỉ trong im lặng. Chương trình vẫn "chạy đúng", vẫn in ra Con::chay, không hề báo lỗi. Đó là lý do lỗi này sống rất lâu trong mã nguồn thật.

Quy tắc thực hành

Lớp cha trong một cây kế thừa đa hình nên có: hủy tử virtual (hoặc protected và không ảo, nếu bạn cố ý cấm delete qua con trỏ cha), các hàm cần đa hình đánh dấu virtual, và các lớp con đánh dấu override. Ba việc này đi cùng nhau.

Cắt lát đối tượng: vì sao đa hình cần con trỏ hoặc tham chiếu

Đa hình chỉ hoạt động qua con trỏ hoặc tham chiếu tới lớp cha. Nếu ta gán một đối tượng lớp con vào một biến giá trị kiểu lớp cha, C++ sẽ sao chép chỉ phần lớp cha và vứt bỏ phần riêng của lớp con. Hiện tượng này gọi là cắt lát đối tượng (object slicing).

#include <iostream>
using namespace std;

class Animal {
public:
virtual void speak() const { cout << "...\n"; }
virtual ~Animal() {}
};
class Dog : public Animal {
public:
void speak() const override { cout << "Gau gau\n"; }
};

void theoGiaTri(Animal a) { a.speak(); } // SLICING: only the Animal part arrives
void theoThamChieu(const Animal& a) { a.speak(); } // polymorphism works

int main() {
Dog d;
theoGiaTri(d); // prints "..." : the Dog part was sliced away
theoThamChieu(d); // prints "Gau gau" : correct
return 0;
}

Kết luận rất thực dụng: hàm nhận đối tượng đa hình thì luôn nhận const Animal& hoặc Animal*, đừng bao giờ nhận Animal theo giá trị. Cũng vì lý do này mà một vector<Animal> sẽ cắt lát mọi thứ bỏ vào; muốn chứa đa hình phải là vector<Animal*> hoặc tốt hơn là vector<unique_ptr<Animal>>.

Lớp trừu tượng và hàm thuần ảo

Trong ví dụ đầu bài, Animal::speak() in ra ..., một hành vi mặc định khá vô nghĩa: thực ra không tồn tại "tiếng kêu của động vật nói chung". Khi lớp cha không có cách cài đặt hợp lý cho một hành vi, hãy khai báo nó là thuần ảo bằng cú pháp = 0. Lớp có ít nhất một hàm thuần ảo trở thành lớp trừu tượng: không tạo được đối tượng của nó, và mọi lớp con buộc phải cài đặt hàm đó, nếu không chúng cũng trừu tượng theo.

#include <iostream>
#include <memory>
#include <vector>
using namespace std;

class Hinh {
public:
virtual double dienTich() const = 0; // pure virtual: no default answer exists
virtual ~Hinh() = default;
};

class HinhTron : public Hinh {
double r;
public:
HinhTron(double r) : r(r) {}
double dienTich() const override { return 3.14159 * r * r; }
};

class HinhChuNhat : public Hinh {
double a, b;
public:
HinhChuNhat(double a, double b) : a(a), b(b) {}
double dienTich() const override { return a * b; }
};

int main() {
// Hinh h; // error: Hinh is abstract, cannot be instantiated
vector<unique_ptr<Hinh>> ds;
ds.push_back(make_unique<HinhTron>(1.0));
ds.push_back(make_unique<HinhChuNhat>(2.0, 3.0));

double tong = 0;
for (const auto& h : ds) {
tong += h->dienTich(); // one loop, many behaviours
}
cout << "Tong dien tich = " << tong << "\n"; // 3.14159 + 6 = 9.14159
return 0;
}

Đoạn mã này gói gọn tinh thần của bài. Vòng lặp tính tổng diện tích không hề biết có bao nhiêu loại hình và chúng tính diện tích ra sao. Thêm hình tam giác ngày mai chỉ cần viết một lớp mới, vòng lặp giữ nguyên. Chú ý ta dùng vector<unique_ptr<Hinh>>: vừa có đa hình (qua con trỏ), vừa không phải nhớ delete (nhờ RAII).

Đa hình động và đa hình tĩnh

Từ "đa hình" trong C++ thật ra chỉ hai cơ chế khác nhau, và người mới học rất hay nhầm.

  • Đa hình tĩnh (compile time): quá tải hàm (f(int)f(double)) và khuôn mẫu (template). Trình biên dịch nhìn kiểu ở nơi gọi rồi chọn sẵn hàm nào, xong xuôi trước khi chương trình chạy. Nhanh, nội tuyến được, nhưng tập kiểu phải biết trước lúc biên dịch.
  • Đa hình động (runtime): hàm virtual gọi qua con trỏ hoặc tham chiếu lớp cha. Quyết định được hoãn tới lúc chạy, dựa vào kiểu động của đối tượng. Chậm hơn một chút, nhưng cho phép xử lý một tập kiểu mà tại nơi viết mã ta chưa hề biết, kể cả các lớp con do người khác thêm vào sau này.

Chọn cái nào? Nếu tại chỗ viết mã bạn đã biết chắc kiểu, dùng đa hình tĩnh. Nếu bạn cần một danh sách trộn lẫn nhiều kiểu, hoặc muốn người khác mở rộng hệ thống của bạn mà không sửa mã của bạn, đó chính là chỗ của virtual.

Bài tập thực hành

Bài tập 1: thêm lớp con thứ ba

Thêm một lớp Cow (con bò) kế thừa từ Animal, ghi đè speak() để in ra Bo bo. Trong main(), tạo một Animal* trỏ tới đối tượng Cow rồi gọi speak() để kiểm chứng đa hình vẫn hoạt động với lớp con mới.

Lời giải chi tiết

Bước 1: khai báo lớp Cow với : public Animal để upcast hợp lệ. Bước 2: ghi đè speak() kèm override cho an toàn. Bước 3: trong main(), gán new Cow() cho một con trỏ kiểu Animal*. Vì speak()virtual, lời gọi c->speak() phân giải theo kiểu động Cow, in ra Bo bo. Nhớ delete để tránh rò rỉ.

#include <iostream>
using namespace std;

class Animal {
public:
virtual void speak() { cout << "..."; }
virtual ~Animal() {}
};
class Cow : public Animal {
public:
void speak() override { cout << "Bo bo\n"; }
};

int main() {
Animal* c = new Cow(); // static type Animal, dynamic type Cow
c->speak(); // virtual dispatch picks Cow::speak()
delete c;
}

Ý chính: thêm hành vi mới chỉ cần một lớp con mới ghi đè virtual, phần main() gọi qua con trỏ lớp cha không phải sửa gì.

Bài tập 2: duyệt mảng con trỏ lớp cha

Tạo một vector<Animal*> chứa lẫn lộn các đối tượng Dog, Cat, Cow. Dùng một vòng lặp duy nhất để gọi speak() cho từng phần tử, sau đó giải phóng toàn bộ.

Lời giải chi tiết

Bước 1: khai báo vector<Animal*> rồi push_back các con trỏ tới những lớp con khác nhau. Bước 2: một vòng for duyệt qua từng Animal* và gọi a->speak(). Vì speak()virtual, mỗi phần tử tự chạy đúng hành vi của kiểu động của nó (đây là sức mạnh của đa hình: một vòng lặp xử lý nhiều kiểu). Bước 3: một vòng for thứ hai để delete từng con trỏ, nhờ ~Animal()virtual nên phần riêng của lớp con được hủy đúng.

#include <iostream>
#include <vector>
using namespace std;

class Animal {
public:
virtual void speak() { cout << "...\n"; }
virtual ~Animal() {}
};
class Dog : public Animal {
public:
void speak() override { cout << "Gau gau\n"; }
};
class Cat : public Animal {
public:
void speak() override { cout << "Meo meo\n"; }
};
class Cow : public Animal {
public:
void speak() override { cout << "Bo bo\n"; }
};

int main() {
vector<Animal*> ds;
ds.push_back(new Dog());
ds.push_back(new Cat());
ds.push_back(new Cow());

for (Animal* a : ds) {
a->speak(); // virtual dispatch per element
}
for (Animal* a : ds) {
delete a; // virtual destructor frees the right type
}
}

Ý chính: gom các đối tượng khác kiểu vào một mảng con trỏ lớp cha cho phép xử lý đồng nhất bằng một vòng lặp, mỗi phần tử vẫn giữ hành vi riêng nhờ virtual.

Bài tập 3: gọi lại phương thức lớp cha và dùng protected

Cho Animal một thành phần protected là tên loài và một speak() in tên đó. Lớp Dog ghi đè speak() nhưng gọi lại Animal::speak() để in tên trước, rồi in thêm tiếng kêu riêng.

Lời giải chi tiết

Bước 1: đặt biến name ở mục protected để lớp con truy cập được nhưng bên ngoài thì không. Bước 2: khởi tạo name qua hàm dựng của Animal, lớp Dog chuyển giá trị lên bằng danh sách khởi tạo : Animal("Dog"). Bước 3: trong Dog::speak(), gọi tường minh Animal::speak() để chạy phần dùng chung của lớp cha (in tên), sau đó in tiếng kêu riêng. Cú pháp Base::method() bỏ qua cơ chế virtual và gọi đúng bản của lớp cha.

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

class Animal {
protected:
string name; // accessible to derived classes, not to outsiders
public:
Animal(string n) : name(n) {}
virtual void speak() { cout << "Toi la " << name << ". "; }
virtual ~Animal() {}
};
class Dog : public Animal {
public:
Dog() : Animal("Dog") {}
void speak() override {
Animal::speak(); // reuse parent behavior first
cout << "Gau gau\n"; // then add own behavior
}
};

int main() {
Animal* a = new Dog();
a->speak(); // prints: Toi la Dog. Gau gau
delete a;
}

Ý chính: protected chia sẻ dữ liệu cho lớp con mà vẫn che với bên ngoài, còn Base::method() cho phép lớp con tái dùng rồi mở rộng hành vi của lớp cha.

Tự kiểm tra

1) Nếu xóa từ khoá virtualAnimal::speak(), kết quả in ra là gì?

Khi đó a->speak()b->speak() đều chạy theo kiểu tĩnh Animal → in ra ...... (gọi Animal::speak() hai lần). Mất tính đa hình.

2) Vì sao hủy tử ~Animal() nên là virtual?

Vì ta delete đối tượng qua con trỏ lớp cha Animal*. Nếu hủy tử không virtual, chỉ phần Animal được hủy, phần riêng của Dog/Cat bị bỏ sót → rò rỉ tài nguyên. Có virtual, hủy tử lớp con được gọi đúng.

Câu hỏi tự kiểm

Kiểm tra nhanh: kế thừa và đa hình0/4 đúngchưa trả lời
  1. 1Nếu bỏ virtual ở Animal::speak() thì a->speak() với a là Animal* đang trỏ tới một Dog sẽ chạy gì?
  2. 2Truyền một đối tượng đa hình theo giá trị, ví dụ void f(Animal a), gây ra chuyện gì?
  3. 3Vì sao lớp cơ sở trong một cây kế thừa đa hình phải có hủy tử ảo?
  4. 4Ở lớp con viết void f(int x) (thiếu const so với hàm ảo const của lớp cha) và không ghi override thì kết quả là gì?

Tóm tắt

  • Chỉ kế thừa khi quan hệ thật sự là "là một". Nếu chỉ là "có một", hãy dùng thành phần thay vì kế thừa.
  • Ghi đè cần cùng tên, cùng tham số và cùng tính hằng. Viết override để trình biên dịch bắt lỗi giúp bạn khi bạn ghi sai.
  • virtual là thứ bật đa hình động. Không có nó, lời gọi bám theo kiểu tĩnh của con trỏ chứ không theo đối tượng thật.
  • Lớp cha có hàm ảo thì hủy tử phải ảo. Quên điều này là rò rỉ tài nguyên khi xóa qua con trỏ lớp cha.
  • Đa hình chỉ hoạt động qua con trỏ hoặc tham chiếu. Truyền theo giá trị sẽ cắt lát đối tượng, mất phần riêng của lớp con.
  • Hàm thuần ảo tạo ra lớp trừu tượng: một hợp đồng buộc lớp con phải cài đặt, và không tạo được đối tượng trực tiếp từ nó.
Ghi nhớ

Đa hình = một giao diện (Animal*, speak()) cho nhiều hành vi (Dog, Cat). Chìa khoá là virtual + con trỏ/tham chiếu lớp cha trỏ tới đối tượng lớp con.