第 21 章
虚函数与多态:动态绑定、切片与 override
virtual 让调用在运行时按真实对象选择版本;多态对象因虚表指针而变大;把派生对象按值存进基类容器会切片,引用不能存进容器,指针与智能指针可以;override 防止签名写错,重载与覆盖会互相遮蔽;多态可以在任意层级建立;静态成员不随继承各自独立;final 限制覆盖与继承;默认参数在编译期绑定,虚析构保证整条链被销毁。
Virtual Functions and Polymorphism: Dynamic Binding, Slicing and override
virtual makes the call choose its version at run time from the real object; polymorphic objects grow because of the vtable pointer; storing derived objects by value in base containers slices them, references cannot be stored, pointers and smart pointers can; override catches signature mistakes, overloading and overriding hide each other; polymorphism can be established at any level; static members are not duplicated by inheritance; final restricts overriding and inheritance; default arguments bind statically and virtual destructors keep the whole chain alive.
篇内目录
- What this chapter is about
- Timeline
- From static to dynamic binding
- One function for every shape
- Non-virtual functions do not participate
- The price: objects get larger
- Object slicing
- Three ways to store objects in a container
- override: let the compiler check the signature
- Overloading, overriding and hiding
- Polymorphism at different levels
- Static members and inheritance
- final: two restrictions
- Virtual functions and default arguments
- Virtual destructors
- Chapter summary
- Comprehension check
- Relation to neighbouring chapters
本章是 31 小时长视频的第 21 章,对应 26:55:10–29:35:36。上一章最后的反例说明:通过基类指针调用普通函数,只会执行基类版本。这一章用 virtual 把「编译期决定」换成「运行期决定」,并把它带来的所有副作用一次讲清。
这一章在讲什么
一条继承链 Shape → Oval → Circle 贯穿全章。核心问题只有一个:怎样让一个 Shape* 调用到它实际管理的那个对象的版本?
答案是给函数加上 virtual。但这一个关键字会牵出一连串后果:
- 对象为什么突然变大了?
- 把派生对象装进基类数组,为什么调用的还是基类版本?
- 为什么引用不能放进数组,指针却可以?
override与final各自防的是什么错?- 默认参数为什么不受多态影响?
- 为什么析构函数也必须是虚的?
这些问题构成了本章的全部内容。
时间轴
| 时间 | 内容 |
|---|---|
| 26:55 | 给函数加 virtual,基类指针/引用得到动态绑定 |
| 27:05 | 一个 draw_shape 函数处理所有形状;基类指针数组 |
| 27:09 | 非虚函数不参与多态:get_x_rad 编译失败 |
| 27:14 | 多态对象的大小:虚表指针使对象变大 |
| 27:17 | 对象切片:派生对象按值赋给基类对象 |
| 27:26 | 把多态对象放进容器:按值切片、引用不可存、指针可用 |
| 27:45 | override:签名写错时由编译器拦住 |
| 27:52 | 重载、覆盖、遮蔽三者的关系 |
| 28:07 | 在不同层级建立多态:Animal / Feline / Bird |
| 28:33 | 静态成员与继承:同名静态变量不自动独立 |
| 28:49 | final:禁止进一步覆盖,或禁止继承 |
| 29:07 | 虚函数与默认参数:参数在编译期绑定 |
| 29:23 | 虚析构函数:通过基类指针删除派生对象 |
从静态绑定到动态绑定
图:类层次、对象与虚函数表,以及一次调用的四步。
class Shape {
public:
virtual void draw() const { std::cout << "Shape draw\n"; }
};
class Oval : public Shape {
public:
void draw() const override { std::cout << "Oval draw\n"; }
};
class Circle : public Oval {
public:
void draw() const override { std::cout << "Circle draw\n"; }
};
Shape* p = new Circle();
p->draw(); // Circle draw
只加了一个 virtual,行为就完全不同:
- 没有 virtual:编译器看
p的静态类型Shape*,调用Shape::draw; - 有 virtual:编译器生成「运行时再查」的调用,看
p实际指向的对象类型,调用Circle::draw。
课程把后者称为动态绑定(也叫晚绑定、多态)。派生类里的 draw 建议同时写 override(见后文),它不是必需的,但能防止签名写错。
一个函数处理所有形状
有了动态绑定,就不必为每种形状写一个绘制函数:
void draw_shape(const Shape* s) {
s->draw();
}
// 也可以接受引用
void draw_shape_ref(const Shape& s) {
s.draw();
}
draw_shape(&shape1); // Shape draw
draw_shape(&oval1); // Oval draw
draw_shape(&circle1); // Circle draw
容器也一样:只要存的是基类指针,循环一次就能处理所有派生类型:
std::vector<Shape*> shapes{&shape1, &oval1, &circle1};
for (const Shape* s : shapes) {
s->draw();
}
这正是多态存在的理由:代码针对基类编写,行为随实际对象变化。没有它,一百种形状就要一百个函数、一百个容器。
非虚函数不参与多态
Shape* p = new Oval();
p->get_x_rad(); // 编译错误:Shape has no member named get_x_rad
get_x_rad 只在 Oval 里声明、且不是虚函数。编译器按 p 的静态类型 Shape* 查找,找不到就报错。只有基类中声明为 virtual 的函数,才能通过基类指针被多态调用。
多态的代价:对象变大
多态不是免费的。编译器要实现「运行时查表」,就得给每个多态对象塞一个指针,指向该类型的虚函数表(vtable)。课程在演示机上量到的结果是:
| 类型 | 关闭多态(无 virtual) | 开启多态(有 virtual) |
|---|---|---|
| Shape | 32 字节 | 40 字节 |
| Oval | 48 字节 | 56 字节 |
| Circle | — | 56 字节 |
(具体数值取决于编译器和标准库实现,但「多态对象更大」这条结论普遍成立。)
多出来的部分主要是虚表指针。对象越多、越小,这份额外开销的相对比例就越高。所以:只在确实需要运行时多态的地方使用 virtual,而不是给所有函数都加上。
对象切片
当派生对象被按值赋给基类对象时,多出来的派生部分会被切掉:
Circle circle1(10, "circle one");
Shape shape1 = circle1; // 切片:Circle、Oval 部分被丢弃
shape1.draw(); // Shape draw,不是 Circle draw
原因是空间:shape1 只有 Shape 那么大,装不下 Oval 与 Circle 的成员。编译器只能复制基类那一层,其余部分丢掉。这不是错误,编译器不会报错,但多态信息永久丢失。
切片发生在这些场合:
- 派生对象按值赋给基类对象;
- 派生对象按值传给「接受基类对象」的函数;
- 派生对象被复制进存放基类对象的容器。
容器里的三种存法
std::vector<Shape> shapes; // 存对象 → 切片,全部退化为 Shape
shapes.push_back(circle1); // 只复制 Shape 那一层
std::vector<Shape&> refs; // 编译错误:不能存引用
std::vector<Shape*> ptrs; // 可以,多态保留
std::vector<std::shared_ptr<Shape>> smart; // 也可以
三个结论:
- 存对象会切片。 数组/容器为元素分配的空间是基类的大小,派生信息放不下。
- 不能存引用。 容器要求元素类型可赋值(left-assignable),而引用一旦绑定就不能改指别处,所以对引用赋值等于改它指向的对象,而不是换一个引用。C++ 因此禁止引用数组。
- 存指针(裸指针或智能指针)可以。 指向派生对象的基类指针,恰好就是多态设计的目标形态。
切片之后即便再取地址也救不回来:
Shape sliced = circle1; // 已经丢掉派生部分
Shape* p = &sliced;
p->draw(); // 仍然是 Shape draw,数据已不存在
override:让编译器检查签名
假设手误把 draw 写成了 Draw:
class Oval : public Shape {
public:
void Draw() const { ... } // 新函数,不是覆盖
};
这段代码能编译。Oval 里多了一个叫 Draw 的普通函数,而 draw 仍是继承来的 Shape::draw。于是通过基类指针调用时,永远走不到这个「本意是覆盖」的函数,而且几乎无法从代码里看出来。
override 就是为此设计的:
class Oval : public Shape {
public:
void draw() const override { ... } // 正确
void Draw() const override { ... } // 编译错误
};
第二条会报:
error: 'void Oval::Draw() const' marked 'override' but does not override
建议:只要打算覆盖虚函数,就写上 override。 它不改变行为,只把「签名写错」从运行时 bug 变成编译错误。
重载、覆盖与遮蔽
三个概念容易混:
| 名称 | 发生位置 | 条件 | 效果 |
|---|---|---|---|
| 重载 overload | 同一作用域 | 同名、参数不同 | 按参数选择版本 |
| 覆盖 override | 派生类对基类 | 同名、同签名、基类函数为 virtual | 运行时按真实对象选择 |
| 遮蔽 hide | 派生类对基类 | 同名即可,参数无关 | 基类同名成员不可见 |
最反直觉的是「遮蔽压过重载」:
class Shape {
public:
virtual void draw() const { ... }
virtual void draw(int color_depth) const { ... } // 重载
};
class Oval : public Shape {
public:
void draw() const override { ... } // 只覆盖了无参版本
};
Oval o;
o.draw(44); // 编译错误:Oval 里只有一个 draw()
Oval 声明了 draw() 之后,基类的所有同名函数都被遮蔽,包括 draw(int)。要保留它们,必须:
- 在 Oval 中把
draw(int)也覆盖一遍;或 - 在 Oval 中写
using Shape::draw;把基类重载重新引入。
同理,在派生层新增的重载(比如 Oval::draw(int, std::string))并不在基类中声明,不参与 Shape 多态:通过 Shape* 调不到它,因为基类根本不知道这个函数存在。
规则:只有基类中声明、且为 virtual 的函数,才参与基类多态。
在不同层级建立多态
多态不要求一定从最顶层开始。设想:
Animal
├─ Feline ── Dog, Cat
└─ Bird ── Pigeon, Crow
可以在 Animal 层声明 breathe 虚函数,所有动物都覆盖它——这是 Animal 多态。也可以在 Feline 层声明 run 虚函数,只有猫科动物覆盖它——这是 Feline 多态。
Feline* f1 = new Dog();
Feline* f2 = new Cat();
f1->run(); // Dog::run
f2->run(); // Cat::run
Feline* bad = new Pigion(); // 编译错误:鸟不是猫科
Animal* a = new Animal(); // 编译错误(若 Animal 抽象)
Feline* bad2 = a; // 编译错误:不能把父类赋给子类指针
多态在哪个层级建立,就只能在该层级及其派生类中生效。 Feline 指针装不下 Bird;Animal 指针也调不到只在 Feline 声明的 run。这提供了很实用的设计自由度:不需要为整个体系找一个共同的顶层接口,只要在真正共享行为的层级建立多态即可。
静态成员与继承
静态成员属于类本身,不属于对象。派生类可以直接使用基类的静态成员,但不会自动获得一份独立的副本:
class Shape {
public:
Shape() { ++m_count; }
virtual int get_count() const { return m_count; }
static int m_count;
protected:
std::string m_description;
};
int Shape::m_count = 0;
class Ellipse : public Shape { /* 没有自己的 m_count */ };
Shape s1, s2, s3;
Ellipse e1;
std::cout << Shape::m_count; // 4
std::cout << Ellipse::m_count; // 4 —— 是同一个变量
如果希望每种类型单独计数,需要在派生类中声明同名的静态成员,并覆盖 get_count:
class Ellipse : public Shape {
public:
Ellipse() { ++m_count; } // 自己的计数
int get_count() const override { return m_count; }
static int m_count;
};
int Ellipse::m_count = 0;
此时 Shape::m_count 是 4,Ellipse::m_count 是 1。而通过基类指针调用 get_count() 会触发多态:指向 Shape 的对象返回 4,指向 Ellipse 的对象返回 1。
一个容易忽略的细节:派生类构造函数会先调用基类构造,所以创建一个 Ellipse 会让两个计数都加一。若要只统计派生类型,就不能在派生构造里重复自增,或需要在设计上分清两层语义。
final:两种限制
final 有两种用法。
一、禁止进一步覆盖某个虚函数:
class Feline : public Animal {
public:
virtual void run() const { ... }
};
class Dog : public Feline {
public:
void run() const final { ... } // 到此为止,不允许再覆盖
};
class Bulldog : public Dog {
public:
void run() const override; // 编译错误
};
二、禁止从某个类继承:
class Cat final : public Feline { ... };
class WildCat : public Cat { ... }; // 编译错误:cannot derive from final base
课程还演示了两个「合法但没意义」的写法,用来说明这条规则的边界:
- 在
final类里声明新的 virtual 函数——没人能继承,永远不可能被覆盖; - 同时写
virtual和final——virtual 表示「希望被覆盖」,final 表示「禁止覆盖」,final 生效,函数永远停在当前实现。
这些写法编译器不报错,但读代码的人会被误导。
虚函数与默认参数
这是本章最隐蔽的陷阱。默认参数在编译期按静态类型填入,函数体在运行期按动态类型选择。两者可能来自不同的类:
class Base {
public:
virtual double add(int a = 5, int b = 5) const { return a + b + 1; }
};
class Derived : public Base {
public:
double add(int a = 10, int b = 10) const override { return a + b + 2; }
};
Base* p = new Derived();
p->add(); // 12 = 5 + 5 + 2
Derived d;
d.add(); // 22 = 10 + 10 + 2
Base b;
b.add(); // 11 = 5 + 5 + 1
p->add() 是最容易看错的:函数体来自 Derived(+2),参数却来自 Base(5, 5)。因为编译器只看到 Base*,就在编译期把默认值填成 5, 5。
结论:默认参数永远走静态绑定。 混合使用虚函数与默认参数会得到难以解释的结果,课程的建议是直接避免这种组合。
虚析构函数
用基类指针删除派生对象时,析构函数必须是虚的:
class Animal {
public:
virtual ~Animal() { std::cout << "Animal destructor\n"; }
};
class Feline : public Animal {
public:
~Feline() override { std::cout << "Feline destructor\n"; }
};
class Dog : public Feline {
public:
~Dog() override { std::cout << "Dog destructor\n"; }
};
Animal* p = new Dog();
delete p; // Dog → Feline → Animal,全部被调用
如果析构函数不是虚的,delete p 只调用 Animal::~Animal。后果有两层:
- 资源泄漏:派生层在构造中分配的内存永远不会被释放;
- 未定义行为:C++ 标准规定,通过基类指针删除派生对象而基类析构非虚,属于未定义行为,不同编译器可能表现不同。
规则:只要类里有虚函数,就把析构函数也设为 virtual。 这是几乎无成本、却能避免严重问题的习惯。
本章结论
virtual启用动态绑定:调用按真实对象类型选择版本。- 只有基类中声明为 virtual 的函数才参与多态;非虚函数按静态类型解析。
- 多态对象因虚表指针而变大,多态不是零成本。
- 按值存入基类对象或容器会切片,派生信息永久丢失。
- 容器不能存引用(不可赋值),存指针或智能指针可以保留多态。
- 覆盖虚函数时写
override,把签名错误提前到编译期。 - 覆盖一个重载会遮蔽基类所有同名重载;需要
using或逐个覆盖。 - 多态可在任意层级建立,但只在该层级及其派生类中生效。
- 静态成员不会随继承自动独立;需要独立计数时在派生类声明同名静态成员。
final可禁止覆盖某函数,或禁止从某类继承。- 默认参数在编译期绑定,与虚函数混用会产生反直觉结果。
- 有虚函数的类必须使用虚析构函数。
理解检验
1. 加上 virtual 之后,编译器生成的调用具体做了什么改变?为什么这会让对象变大?
参考答案:没有 virtual 时,编译器在编译期就确定调用哪个函数,直接生成对该函数的调用指令。加上 virtual 后,编译器不再写死目标,而是通过对象内的虚表指针找到该对象所属类型的虚函数表,再按函数槽位取出地址并调用,因此目标在运行时才确定。虚表指针是每个多态对象额外存储的一个指针,所以 sizeof 增加(演示中是 32→40、48→56)。存储它正是「运行时查表」的前提。
2. 一个派生对象按值传进「参数为基类对象」的函数,会发生什么?如果参数改成 const Base& 呢?
参考答案:按值传参会在函数内部构造一个基类对象,只复制基类那一层,派生部分被切掉,函数内部无法得到派生类型的信息,虚调用也只会落在基类版本。改成 const Base& 后不产生新对象,引用直接绑定到原派生对象上,虚调用依然按真实类型解析,多态保留。这解释了为什么多态接口通常用引用或指针作参数。
3. 为什么 std::vector<Base&> 编译失败,而 std::vector<Base*> 可以?
参考答案:容器的元素类型必须是可赋值的(left-assignable),因为容器需要能对已有元素执行 elem = new_value 来替换内容。引用一旦绑定就不能改指其他对象,对引用赋值实际上修改的是被引用的对象,不是「替换这个引用」,所以引用不满足可赋值要求,语言禁止引用数组。指针本身是可赋值的变量:给指针赋新地址只是让它指向别处,因此指针容器合法,而且指向派生对象的基类指针正好保留多态。
4. override 与 virtual 分别解决什么问题?为什么建议两者都写?
参考答案:virtual 在基类中声明「这个函数允许在运行期被覆盖」。override 在派生类中声明「我确实打算覆盖基类的某个虚函数」,编译器会核对签名;若基类没有匹配的虚函数(例如手误写成 Draw、漏写 const、参数类型不符),直接报错。只写 virtual 能得到多态,但签名写错时会悄悄变成一个新函数,运行时表现为「多态没生效」,极难排查。两者都写,既启用多态,又让编译器守住签名。
5. 在 Oval 中只覆盖了 draw(),为什么 draw(int) 在 Oval 对象上也不可用了?怎样恢复?
参考答案:派生类一旦声明了名为 draw 的成员,就会遮蔽基类作用域中所有叫 draw 的成员,与参数列表无关。因此 Shape::draw(int) 在 Oval 中被隐藏,o.draw(44) 找不到匹配的重载。恢复方式有两种:在 Oval 中把 draw(int) 也覆盖一遍;或写 using Shape::draw; 把基类的全部 draw 重载重新引入 Oval 的作用域。注意,即使恢复了,通过 Shape* 调用 draw(int) 仍然只有基类版本参与多态——因为 Oval 没有覆盖它。
6. class Cat final : public Feline 中再声明一个 virtual void meow() 有意义吗?
参考答案:没有实际意义。把 Cat 标记为 final 意味着任何类都不能从 Cat 继承;而 virtual 的意义在于允许派生类覆盖。没有派生类,这个函数永远不会被覆盖,virtual 完全落空。这段代码能编译,但会误导读者以为存在覆盖的可能。同理,同时写 virtual 和 final 也是自相矛盾的:final 生效,函数停在当前实现。
7. 为什么 Base* p = new Derived(); p->add(); 会得到 12,而不是 22?
参考答案:add 是虚函数,函数体按动态类型选择,所以执行的是 Derived::add(+2)。但默认参数在编译期按静态类型填入:编译器只看到 Base*,于是用 Base::add 的默认参数 5, 5。两者相加得到 。直接用 Derived 对象调用时静态类型就是 Derived,默认参数取 10, 10,结果才是 22。这正说明默认参数不参与动态绑定。
8. 如果基类析构函数不是虚的,delete base_ptr 会发生什么?
参考答案:编译器按 base_ptr 的静态类型调用 Base::~Base,派生类的析构函数不会执行。后果有两层:派生层在构造时分配的资源(堆内存、文件句柄等)永远不会释放,造成泄漏;并且 C++ 标准把「通过基类指针删除派生对象而基类析构非虚」定义为未定义行为,实际表现依编译器而异,可能崩溃也可能看似正常。因此只要类中有虚函数,就应把析构函数也声明为 virtual。
与前后章的关系
本章建立了运行时多态,也把它的边界讲清楚了:非虚函数、按值存储、默认参数都不参与动态绑定。下一章继续在这套体系上扩展:用 dynamic_cast 把基类指针安全地转回派生类指针,用纯虚函数定义抽象类,再用「只有纯虚函数、没有成员变量」的类描述接口。
This is chapter 21 of the 31-hour course, covering 26:55:10–29:35:36. The previous chapter ended with a counterexample: calling an ordinary function through a base pointer runs the base version. This chapter replaces that compile-time decision with a run-time one using virtual, and works through every consequence.
What this chapter is about
One chain, Shape → Oval → Circle, runs through the whole chapter. The central question is: how does a Shape* call the version belonging to the object it actually manages?
The answer is one keyword, virtual. But that keyword drags in a chain of consequences:
- Why do objects suddenly get larger?
- Why does storing derived objects in a base array still call the base version?
- Why can references not be stored in a container while pointers can?
- What do
overrideandfinaleach protect against? - Why do default arguments ignore polymorphism?
- Why must the destructor be virtual too?
These questions are the chapter.
Timeline
| Time | Topic |
|---|---|
| 26:55 | virtual enables dynamic binding through base pointers and references |
| 27:05 | One draw_shape function for every shape; arrays of base pointers |
| 27:09 | Non-virtual functions do not participate: get_x_rad fails to compile |
| 27:14 | Size of polymorphic objects: the vtable pointer makes objects larger |
| 27:17 | Object slicing: assigning a derived object to a base object |
| 27:26 | Polymorphic objects in containers: slicing, references rejected, pointers work |
| 27:45 | override lets the compiler catch signature mistakes |
| 27:52 | Overloading, overriding and hiding |
| 28:07 | Polymorphism at different levels: Animal / Feline / Bird |
| 28:33 | Static members and inheritance: same-named statics are not independent |
| 28:49 | final: forbid further overriding, or forbid inheritance |
| 29:07 | Virtual functions with default arguments: arguments bind at compile time |
| 29:23 | Virtual destructors: deleting derived objects through base pointers |
From static to dynamic binding
Figure:Class hierarchy, object and vtable, and the four steps of one call。
class Shape {
public:
virtual void draw() const { std::cout << "Shape draw\n"; }
};
class Oval : public Shape {
public:
void draw() const override { std::cout << "Oval draw\n"; }
};
class Circle : public Oval {
public:
void draw() const override { std::cout << "Circle draw\n"; }
};
Shape* p = new Circle();
p->draw(); // Circle draw
One keyword changes everything:
- Without virtual, the compiler looks at the static type of
p(Shape*) and callsShape::draw. - With virtual, the compiler emits a call resolved at run time from the dynamic type of the object, so
Circle::drawruns.
The lecture calls this dynamic binding (also late binding, or polymorphism). Derived versions should also say override (below); it is optional but prevents signature mistakes.
One function for every shape
With dynamic binding there is no need for one drawing function per shape:
void draw_shape(const Shape* s) {
s->draw();
}
// A reference parameter works too
void draw_shape_ref(const Shape& s) {
s.draw();
}
draw_shape(&shape1); // Shape draw
draw_shape(&oval1); // Oval draw
draw_shape(&circle1); // Circle draw
Containers behave the same way: store base pointers and one loop handles every derived type:
std::vector<Shape*> shapes{&shape1, &oval1, &circle1};
for (const Shape* s : shapes) {
s->draw();
}
This is the reason polymorphism exists: write code against the base class, let behaviour follow the actual object. Without it, a hundred shapes would need a hundred functions and a hundred containers.
Non-virtual functions do not participate
Shape* p = new Oval();
p->get_x_rad(); // compile error: Shape has no member named get_x_rad
get_x_rad is declared only in Oval and is not virtual. The compiler resolves the call from the static type Shape*, finds nothing, and reports an error. Only functions declared virtual in the base class can be called polymorphically through a base pointer.
The price: objects get larger
Polymorphism is not free. To resolve calls at run time, the compiler gives every polymorphic object a pointer to its class's virtual function table (vtable). On the instructor's machine:
| Type | Polymorphism off | Polymorphism on |
|---|---|---|
| Shape | 32 bytes | 40 bytes |
| Oval | 48 bytes | 56 bytes |
| Circle | — | 56 bytes |
(The exact numbers depend on the compiler and standard library, but "polymorphic objects are larger" is universal.)
The extra bytes are mostly the vtable pointer. The more objects you create and the smaller they are, the larger the relative overhead. So: use virtual only where run-time polymorphism is genuinely needed, not on every function out of habit.
Object slicing
When a derived object is assigned by value to a base object, the derived part is cut off:
Circle circle1(10, "circle one");
Shape shape1 = circle1; // slicing: Circle and Oval parts discarded
shape1.draw(); // Shape draw, not Circle draw
The reason is space: shape1 is only as large as a Shape and cannot hold the Oval and Circle members. The compiler copies the base layer and discards the rest. Nothing is "wrong" syntactically and no diagnostic appears, but the polymorphic information is gone permanently.
Slicing happens when:
- a derived object is assigned by value to a base object;
- a derived object is passed by value to a function taking a base object;
- a derived object is copied into a container of base objects.
Three ways to store objects in a container
std::vector<Shape> shapes; // objects → slicing, all become Shape
shapes.push_back(circle1); // only the Shape layer is copied
std::vector<Shape&> refs; // compile error: references cannot be stored
std::vector<Shape*> ptrs; // fine, polymorphism preserved
std::vector<std::shared_ptr<Shape>> smart; // fine too
Three conclusions:
- Storing objects slices. The container allocates space for the base size; the derived data does not fit.
- References cannot be stored. Containers require element types to be assignable (left-assignable). A reference, once bound, cannot be rebound, so assigning to it modifies the referenced object rather than replacing the reference. C++ therefore forbids arrays of references.
- Pointers (raw or smart) work. A base pointer aimed at a derived object is exactly the shape polymorphism was designed for.
Taking the address afterwards does not help:
Shape sliced = circle1; // derived part already lost
Shape* p = &sliced;
p->draw(); // still Shape draw; the data no longer exists
override: let the compiler check the signature
Suppose draw is mistyped as Draw:
class Oval : public Shape {
public:
void Draw() const { ... } // a new function, not an override
};
This compiles. Oval gains an unrelated function called Draw, while draw remains the inherited Shape::draw. Every call through a base pointer skips the intended override, and nothing in the code looks wrong.
override exists for exactly this:
class Oval : public Shape {
public:
void draw() const override { ... } // correct
void Draw() const override { ... } // compile error
};
The second line reports:
error: 'void Oval::Draw() const' marked 'override' but does not override
Whenever you intend to override a virtual function, write override. It changes no behaviour; it turns a silent runtime bug into a compile error.
Overloading, overriding and hiding
| Term | Where | Condition | Effect |
|---|---|---|---|
| overload | same scope | same name, different parameters | pick by arguments |
| override | derived vs base | same name, same signature, base function virtual | pick at run time by real object |
| hide | derived vs base | same name only | base members of that name become invisible |
The counter-intuitive part is that hiding beats overloading:
class Shape {
public:
virtual void draw() const { ... }
virtual void draw(int color_depth) const { ... } // overload
};
class Oval : public Shape {
public:
void draw() const override { ... } // overrides only the no-argument version
};
Oval o;
o.draw(44); // compile error: Oval has only one draw()
Once Oval declares draw(), every same-named function in Shape is hidden, including draw(int). To keep them:
- override
draw(int)in Oval as well; or - write
using Shape::draw;inside Oval to reintroduce the base overloads.
Likewise, an overload introduced downstream, such as Oval::draw(int, std::string), does not participate in Shape polymorphism: a Shape* cannot call it, because the base class has never heard of it.
Rule: only functions declared in the base class and marked virtual take part in base-class polymorphism.
Polymorphism at different levels
Polymorphism need not start at the top. Consider:
Animal
├─ Feline ── Dog, Cat
└─ Bird ── Pigeon, Crow
breathe can be declared virtual at the Animal level and overridden by every animal — Animal polymorphism. run can be declared virtual at the Feline level and overridden only by felines — Feline polymorphism.
Feline* f1 = new Dog();
Feline* f2 = new Cat();
f1->run(); // Dog::run
f2->run(); // Cat::run
Feline* bad = new Pigeon(); // compile error: a bird is not a feline
Feline* bad2 = new Animal(); // compile error: base cannot be stored in a derived-typed handle
Polymorphism established at a level works at that level and below. A Feline pointer cannot hold a Bird, and an Animal pointer cannot call a run that exists only in Feline. This gives real design freedom: there is no need to invent one common top interface for everything; establish polymorphism wherever behaviour is genuinely shared.
Static members and inheritance
A static member belongs to the class, not to objects. Derived classes may use the base's static member, but they do not automatically get an independent copy:
class Shape {
public:
Shape() { ++m_count; }
virtual int get_count() const { return m_count; }
static int m_count;
protected:
std::string m_description;
};
int Shape::m_count = 0;
class Ellipse : public Shape { /* no m_count of its own */ };
Shape s1, s2, s3;
Ellipse e1;
std::cout << Shape::m_count; // 4
std::cout << Ellipse::m_count; // 4 — the same variable
To count each type separately, declare a same-named static member in the derived class and override get_count:
class Ellipse : public Shape {
public:
Ellipse() { ++m_count; }
int get_count() const override { return m_count; }
static int m_count;
};
int Ellipse::m_count = 0;
Now Shape::m_count is 4 and Ellipse::m_count is 1. Calling get_count() through a base pointer is polymorphic: an object of type Shape reports 4, an Ellipse reports 1.
One subtlety: constructing an Ellipse runs the base constructor first, so both counters increase. If only the derived type should be counted, either do not increment again in the derived constructor or separate the two meanings in the design.
final: two restrictions
One, forbid further overriding of a virtual function:
class Feline : public Animal {
public:
virtual void run() const { ... }
};
class Dog : public Feline {
public:
void run() const final { ... } // stops here
};
class Bulldog : public Dog {
public:
void run() const override; // compile error
};
Two, forbid inheritance from a class:
class Cat final : public Feline { ... };
class WildCat : public Cat { ... }; // compile error: cannot derive from a final base
The lecture also shows two "legal but pointless" forms to mark the boundary:
- declaring a new virtual function inside a final class — nobody can inherit, so it can never be overridden;
- writing
virtualandfinaltogether — virtual means "may be overridden", final means "must not be"; final wins and the function stays frozen.
Neither is rejected by the compiler, but both mislead readers.
Virtual functions and default arguments
This is the most subtle trap in the chapter. Default arguments are filled in at compile time from the static type, while the function body is chosen at run time from the dynamic type. The two can come from different classes:
class Base {
public:
virtual double add(int a = 5, int b = 5) const { return a + b + 1; }
};
class Derived : public Base {
public:
double add(int a = 10, int b = 10) const override { return a + b + 2; }
};
Base* p = new Derived();
p->add(); // 12 = 5 + 5 + 2
Derived d;
d.add(); // 22 = 10 + 10 + 2
Base b;
b.add(); // 11 = 5 + 5 + 1
p->add() is the surprise: the body comes from Derived (+2), the arguments from Base (5, 5). The compiler only saw Base* and inserted 5, 5 while compiling.
Conclusion: default arguments always bind statically. Mixing them with virtual functions produces results that are hard to explain; the lecture advises avoiding the combination entirely.
Virtual destructors
Deleting a derived object through a base pointer requires a virtual destructor:
class Animal {
public:
virtual ~Animal() { std::cout << "Animal destructor\n"; }
};
class Feline : public Animal {
public:
~Feline() override { std::cout << "Feline destructor\n"; }
};
class Dog : public Feline {
public:
~Dog() override { std::cout << "Dog destructor\n"; }
};
Animal* p = new Dog();
delete p; // Dog → Feline → Animal, all run
With a non-virtual destructor, delete p calls only Animal::~Animal. Two consequences:
- Resource leaks — memory allocated by the derived layers is never released;
- Undefined behaviour — deleting a derived object through a base pointer whose destructor is not virtual is undefined behaviour by the standard, and compilers may behave differently.
Rule: if a class has any virtual function, make its destructor virtual too. It costs almost nothing and prevents serious bugs.
Chapter summary
virtualenables dynamic binding: the call selects its version from the real object type.- Only functions declared virtual in the base class participate; non-virtual calls resolve statically.
- Polymorphic objects grow because of the vtable pointer; polymorphism is not free.
- Storing derived objects by value in base objects or containers slices them; derived data is lost permanently.
- Containers cannot hold references (not assignable); pointers and smart pointers preserve polymorphism.
- Write
overridewhen overriding; it moves signature mistakes to compile time. - Overriding one overload hides all base overloads of that name; use
usingor override each. - Polymorphism can be established at any level, but only works at that level and below.
- Static members are not duplicated by inheritance; declare a same-named static in the derived class when separate counts are wanted.
finalforbids further overriding of a function, or inheritance from a class.- Default arguments bind at compile time and interact badly with virtual functions.
- A class with virtual functions must have a virtual destructor.
Comprehension check
1. What changes in the generated call when virtual is added, and why does that make objects larger?
Reference answer: Without virtual, the compiler determines the target at compile time and emits a direct call. With virtual, it no longer hard-codes the target: it follows the object's vtable pointer to the virtual function table for the object's type, indexes the right slot and calls through it, so the target is chosen at run time. The vtable pointer is one extra pointer stored in every polymorphic object, which raises sizeof (32→40 and 48→56 in the demo). Storing it is precisely what makes run-time lookup possible.
2. What happens when a derived object is passed by value to a function taking a base object? What if the parameter becomes const Base&?
Reference answer: Passing by value constructs a base object inside the function and copies only the base layer; the derived part is sliced off, so the function has no derived information and virtual calls land on the base version. With const Base&, no new object is created: the reference binds to the original derived object, virtual calls still resolve from the real type, and polymorphism is preserved. This is why polymorphic interfaces take references or pointers.
3. Why does std::vector<Base&> fail to compile while std::vector<Base*> works?
Reference answer: Container element types must be assignable (left-assignable), because the container needs to replace an element with elem = new_value. A reference cannot be rebound once bound; assigning to it modifies the referenced object rather than replacing the reference. So references fail the requirement and arrays of references are forbidden. A pointer is an assignable variable: assigning a new address merely points it elsewhere. Hence pointer containers are legal, and a base pointer aimed at a derived object preserves polymorphism exactly as intended.
4. What problems do override and virtual solve, and why write both?
Reference answer: virtual in the base declares "this function may be overridden at run time". override in the derived declares "I intend to override a base virtual function", and makes the compiler verify the signature; if no matching base virtual exists — a typo such as Draw, a missing const, a wrong parameter type — it reports an error. virtual alone gives polymorphism, but a typo silently creates a new function, and the symptom at run time is "polymorphism does not work", which is very hard to diagnose. Writing both enables polymorphism and lets the compiler guard the signature.
5. In Oval only draw() is overridden. Why does draw(int) stop working on an Oval object, and how is it restored?
Reference answer: Once a derived class declares a member named draw, it hides every member of that name in the base scope, regardless of parameters. Shape::draw(int) therefore becomes invisible in Oval, and o.draw(44) finds no match. Restore it either by overriding draw(int) in Oval, or with using Shape::draw; to reintroduce all base draw overloads into Oval's scope. Note that even after restoration, a call through Shape* still resolves draw(int) to the base version, because Oval does not override it.
6. Is declaring virtual void meow() inside class Cat final : public Feline meaningful?
Reference answer: No. Marking Cat final means no class may inherit from it, while virtual only matters because derived classes may override. Without derived classes the function can never be overridden, so virtual accomplishes nothing. The code compiles but misleads readers into thinking overriding is possible. Writing virtual and final together is equally contradictory: final wins and the function stays frozen at its current implementation.
7. Why does Base* p = new Derived(); p->add(); produce 12 rather than 22?
Reference answer: add is virtual, so the function body is chosen from the dynamic type and Derived::add runs (+2). But default arguments are inserted at compile time from the static type: the compiler sees only Base* and uses Base::add's defaults 5, 5. The result is . Calling directly on a Derived object makes the static type Derived, so the defaults are 10, 10 and the result is 22. Default arguments simply do not participate in dynamic binding.
8. What happens if the base destructor is not virtual and delete base_ptr is executed?
Reference answer: The compiler resolves the call from the static type and runs Base::~Base only; the derived destructors never execute. Two consequences follow: resources acquired by the derived layers (heap memory, file handles) are never released, and the standard declares "deleting a derived object through a base pointer with a non-virtual destructor" to be undefined behaviour, so behaviour varies by compiler and may crash or appear to work. Any class with virtual functions should therefore declare a virtual destructor.
Relation to neighbouring chapters
This chapter established run-time polymorphism and its boundaries: non-virtual functions, by-value storage and default arguments all stay static. The next chapter extends the same machinery: dynamic_cast turns a base pointer back into a derived pointer safely, pure virtual functions define abstract classes, and a class with only pure virtual functions and no member variables describes an interface.