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 và đ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.
1#include <iostream>2using namespace std;34class 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};1718int main() {19 Animal* a = new Dog();20 Animal* b = new Cat();21 a->speak();22 b->speak();23 delete a;24 delete b;25}
Ý tưởng cốt lõi
- Kế thừa
publicdiễn đạt quan hệ is-a: mộtDoglà mộtAnimal. - 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
virtualvà có thể bịdeletequa con trỏ cha, hủy tử phải là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 HinhChuNhat có datChieuRong(w) và
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.
- Quên
virtualở lớp cha. Không cóvirtualthì không có ghi đè, chỉ có che khuất (hiding). Lời gọi quaAnimal*sẽ chạy theo kiểu tĩnh. - Lệch chữ ký. Thiếu
const, sai kiểu tham số (intvớilong), hay sai tên do gõ nhầm đều biến "ghi đè" thành "hàm mới". Luôn viếtoverride. - Che khuất do quá tải. Nếu lớp cha có
f(int)vàf(double), mà lớp con chỉ định nghĩa lạif(int), thì cả hai bản của cha bị che khuất vàcon.f(3.14)sẽ gọif(int). Muốn kéo lại các bản của cha, viếtusing 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 a là Animal*, 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
intthì 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,vptrcòn đang trỏ tới bảng củaAnimal. Đó 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.
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 Cho14 p->keu();15 delete p;16}
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.
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)và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
virtualgọ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() là 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()
là 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() là 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á virtual ở Animal::speak(), kết quả in ra là gì?
Khi đó a->speak() và 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
- 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ì?
- 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ì?
- 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Ở 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. virtuallà 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ó.
Đ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.