文章目录
-
- 一、多态两大分类:结果什么时候定下来
-
- [1.1 静态多态:编译期就分好工](#1.1 静态多态:编译期就分好工)
- [1.2 动态多态:把决定权交给对象](#1.2 动态多态:把决定权交给对象)
- 二、动态多态三要素:三个开关全开才通电
- 三、虚函数语法规则:接口继承与实现继承
- [四、虚析构:父类指针 delete 子类对象,不加 virtual 必漏](#四、虚析构:父类指针 delete 子类对象,不加 virtual 必漏)
- [五、vtable 与 vptr:动态绑定的底层账本](#五、vtable 与 vptr:动态绑定的底层账本)
- [六、overload / override / hiding:三兄弟一份判决书](#六、overload / override / hiding:三兄弟一份判决书)
- 七、构造与析构期间调虚函数:多态暂时下岗
- 八、对象切片与上下转型
- 九、协变返回值:重写时返回值可以"降级"
- 十、难点拆解
-
- [10.1 "虚看对象、非虚看指针"到底换了什么](#10.1 “虚看对象、非虚看指针”到底换了什么)
- [102 虚析构的泄漏为什么恰好是子类那块](#102 虚析构的泄漏为什么恰好是子类那块)
- [10.3 假重写为什么不报错只警告](#10.3 假重写为什么不报错只警告)
- [10.4 抽象类连按值传参都不行,为什么指针引用可以](#10.4 抽象类连按值传参都不行,为什么指针引用可以)
- 十一、易踩坑汇总
- 十二、闭环复盘
一、多态两大分类:结果什么时候定下来
多态的字面意思是"同一个调用,不同的结果"。C++ 里达成这个效果有两条完全不同的路,分界线只有一个问题:结果在什么时候确定。
多态两大分支
1.1 静态多态:编译期就分好工
最熟悉的静态多态是函数重载。
cpp
// 静态多态:重载决议在编译期完成,符号表里就是三个不同的函数
#include <iostream>
namespace byte
{
void Print(int x) { std::cout << "Print(int): " << x << std::endl; }
void Print(double x) { std::cout << "Print(double): " << x << std::endl; }
void Print(const char* x) { std::cout << "Print(const char*): " << x << std::endl; }
}
int main()
{
byte::Print(1);
byte::Print(3.14);
byte::Print("hello");
return 0;
}
text
Print(int): 1
Print(double): 3.14
Print(const char*): hello
三个 Print 在编译期就按实参类型各自确定了调用目标------符号表里是三个独立的函数,运行期没有任何选择动作。这就是静态绑定(早绑定):绑定完成得越早,运行开销越小,但灵活性也锁死在编译那一刻。
1.2 动态多态:把决定权交给对象
动态多态反过来:代码里只写"调这个函数",具体执行谁的版本,等运行时看对象是谁 。同一行 p->Speak(),指向猫就是喵、指向狗就是汪。这个能力靠虚函数机制撑起来,接下来三节把它拆开。
二、动态多态三要素:三个开关全开才通电
动态多态成立需要同时满足三个条件:① 公有继承;② 基类虚函数被子类重写(签名完全一致);③ 通过基类指针或引用调用。
cpp
// 动态多态三要素对照:继承、虚函数重写、父类指针或引用------缺一个就失效
#include <iostream>
namespace byte
{
class Animal
{
public:
void Speak1() const { std::cout << "Animal::Speak1(非虚)" << std::endl; }
virtual void Speak2() const { std::cout << "Animal::Speak2(虚)" << std::endl; }
virtual ~Animal() {}
};
class Cat : public Animal
{
public:
void Speak1() const { std::cout << "Cat::Speak1(重定义,非虚)" << std::endl; }
virtual void Speak2() const { std::cout << "Cat::Speak2(重写)" << std::endl; }
};
}
// 按值传参:发生切片,三要素缺了"指针或引用"
void feed_by_value(byte::Animal a) { a.Speak2(); }
// 引用:三要素齐全,多态生效
void feed_by_ref(const byte::Animal& a) { a.Speak2(); }
// 指针:同上
void feed_by_ptr(const byte::Animal* p) { p->Speak2(); }
int main()
{
byte::Cat c;
byte::Animal* p = &c;
std::cout << "== 要素二(虚函数)开关对比 ==" << std::endl;
p->Speak1(); // 非虚:按指针类型走,Animal 版本
p->Speak2(); // 虚:按对象类型走,Cat 版本
std::cout << "== 要素三(指针/引用)开关对比 ==" << std::endl;
feed_by_value(c); // 切片,永远 Animal 版本
feed_by_ref(c); // Cat 版本
feed_by_ptr(&c); // Cat 版本
return 0;
}
text
== 要素二(虚函数)开关对比 ==
Animal::Speak1(非虚)
Cat::Speak2(重写)
== 要素三(指针/引用)开关对比 ==
Animal::Speak2(虚)
Cat::Speak2(重写)
Cat::Speak2(重写)
输出分两组对照着读。第一组验证要素②:p->Speak1() 没写 virtual,走指针的类型 (Animal 版本);p->Speak2() 是虚函数,走对象的类型 (Cat 版本)------虚函数把绑定依据从"指针类型"换成了"对象类型",这一句是全部机制的核心。第二组验证要素③:同一个虚函数,按值传参走 Animal 版本(切片把 Cat 部分切掉了),引用和指针走 Cat 版本。
第三要素的"公有"也不是摆设。private 继承下父类指针根本指不了子类对象:
text
clang++: error: no such file or directory: 'e4_private_base_poly.cpp'
动态多态三要素
三、虚函数语法规则:接口继承与实现继承
virtual 写在基类成员函数声明前,派生类重写时 virtual 可写可不写(写了更醒目)。真正的重点是虚函数家族里混着两种继承:
- 接口继承 :纯虚函数(
=0),只承诺"必须能做这件事",不给做法,第五节展开。 - 实现继承:普通虚函数给了默认实现,子类不重写就沿用;重写了就换。
判断能不能多态,看签名是否逐字一致 :函数名、参数列表、const 修饰,一个都不能差。C++11 补了 override 关键字替人把关:
cpp
#include <iostream>
namespace byte
{
class Base
{
public:
virtual void greet() const { std::cout << "Base::greet" << std::endl; }
virtual void run() { std::cout << "Base::run" << std::endl; }
virtual ~Base() {}
};
class Derived : public Base
{
public:
// override:签名对不上直接编译报错,把"假重写"拦在编译期
virtual void greet() const override { std::cout << "Derived::greet" << std::endl; }
// final:封死 further 重写,再往下重写就是编译错
virtual void run() final { std::cout << "Derived::run" << std::endl; }
virtual ~Derived() {}
};
}
int main()
{
byte::Derived d;
byte::Base* p = &d;
p->greet();
p->run();
return 0;
}
text
Derived::greet
Derived::run
override 的价值在编译期拦截笔误:签名对不上直接报错,而不是安静地变成一个新函数。final 则封死 further 重写,按需使用。
四、虚析构:父类指针 delete 子类对象,不加 virtual 必漏
多态体系里最危险的一条规则:用基类指针 delete 一个子类对象时,如果基类析构不是虚函数,只会执行基类析构------子类自己申请的资源原地泄漏。`
cpp
// 虚析构问题现场:父类指针 delete 子类对象,只跑了父类析构
#include <iostream>
namespace byte
{
class Job
{
public:
Job() : _buf(new char[100])
{
std::cout << " Job 构造:分配 100 字节" << std::endl;
}
// 注意:这里故意不写 virtual
~Job()
{
delete[] _buf;
std::cout << " ~Job:释放 100 字节" << std::endl;
}
char* _buf;
};
class OnlineJob : public Job
{
public:
OnlineJob() : _ext(new char[200])
{
std::cout << " OnlineJob 构造:分配 200 字节" << std::endl;
}
~OnlineJob()
{
delete[] _ext;
std::cout << " ~OnlineJob:释放 200 字节" << std::endl;
}
char* _ext;
};
}
int main()
{
byte::Job* p = new byte::OnlineJob;
std::cout << "== delete 父类指针 ==" << std::endl;
delete p;
std::cout << "== 结束(观察 ~OnlineJob 是否出现)==" << std::endl;
return 0;
}
text
Job 构造:分配 100 字节
OnlineJob 构造:分配 200 字节
== delete 父类指针 ==
~Job:释放 100 字节
== 结束(观察 ~OnlineJob 是否出现)==
看输出:~OnlineJob 那行根本没出现 ------子类析构没跑,_ext 那 200 字节悬空。
text
Process 20834: 1 leak for 224 total leaked bytes.
STACK OF 1 INSTANCE OF 'ROOT LEAK: <malloc in byte::OnlineJob::OnlineJob()>':
leaks 点名泄漏发生在 byte::OnlineJob::OnlineJob() 里------正是构造函数中 new char[200] 的那一块。修复只要一个词,给基类析构加上 virtual:
cpp
// 虚析构修复:析构函数加 virtual,delete 父类指针先跑子类析构
#include <iostream>
namespace byte
{
class Job
{
public:
Job() : _buf(new char[100])
{
std::cout << " Job 构造:分配 100 字节" << std::endl;
}
virtual ~Job() // 唯一的改动:加 virtual
{
delete[] _buf;
std::cout << " ~Job:释放 100 字节" << std::endl;
}
char* _buf;
};
class OnlineJob : public Job
{
public:
OnlineJob() : _ext(new char[200])
{
std::cout << " OnlineJob 构造:分配 200 字节" << std::endl;
}
~OnlineJob()
{
delete[] _ext;
std::cout << " ~OnlineJob:释放 200 字节" << std::endl;
}
char* _ext;
};
}
int main()
{
byte::Job* p = new byte::OnlineJob;
std::cout << "== delete 父类指针 ==" << std::endl;
delete p;
std::cout << "== 结束(~OnlineJob 先跑,~Job 后跑)==" << std::endl;
return 0;
}
text
Job 构造:分配 100 字节
OnlineJob 构造:分配 200 字节
== delete 父类指针 ==
~OnlineJob:释放 200 字节
~Job:释放 100 字节
== 结束(~OnlineJob 先跑,~Job 后跑)==
text
Process 20839: 0 leaks for 0 total leaked bytes.
析构顺序恢复正确(~OnlineJob 先、~Job 后),leaks 报告归零:0 leaks for 0 total leaked bytes。结论写成纪律:只要一个类会被继承、会经基类指针 delete,析构函数就必须是 virtual。成本是对象多一根 vptr------对有虚函数的类来说这根指针本来就存在,几乎白赚。
五、vtable 与 vptr:动态绑定的底层账本
前几节的现象需要一个底层解释:运行期凭什么知道对象是谁 。答案是每个多态类一张虚表(vtable) 、每个对象一根虚表指针(vptr)。
cpp
// vtable 与 vptr:观察对象里多出来的指针和虚表的分工
#include <iostream>
namespace byte
{
// 对照组:没有虚函数的空类
class NoVirtual
{
public:
void h() { std::cout << "NoVirtual::h" << std::endl; }
};
class Base
{
public:
virtual void f() { std::cout << "Base::f" << std::endl; }
virtual void g() { std::cout << "Base::g" << std::endl; }
void h() { std::cout << "Base::h(非虚,不进虚表)" << std::endl; }
virtual ~Base() {}
};
class Derived : public Base
{
public:
virtual void f() { std::cout << "Derived::f" << std::endl; } // 只重写 f
// g 不重写,虚表里沿用 Base::g
};
}
// 观察辅助:读对象前 8 字节(vptr),再从虚表取第 i 项调用。
typedef void (*VF)();
static void* vptr_of(void* obj) { return *reinterpret_cast<void**>(obj); }
static VF entry_of(void* obj, int i)
{
// 函数指针与对象指针的搬运借 union 完成,绕开类型系统只为观察
union Slot
{
void* addr;
VF fn;
};
Slot s;
s.addr = reinterpret_cast<void**>(vptr_of(obj))[i];
return s.fn;
}
int main()
{
std::cout << "sizeof(NoVirtual) = " << sizeof(byte::NoVirtual) << std::endl;
std::cout << "sizeof(Base) = " << sizeof(byte::Base)
<< "(多出来的就是 vptr)" << std::endl;
byte::Base b1, b2;
byte::Derived d;
void* v1 = vptr_of(&b1);
void* v2 = vptr_of(&b2);
void* vd = vptr_of(&d);
std::cout << std::boolalpha;
std::cout << "b1 与 b2 的 vptr 相同 ? " << (v1 == v2)
<< "(同一张虚表)" << std::endl;
std::cout << "b1 与 d 的 vptr 相同 ? " << (v1 == vd)
<< "(Derived 重写了 f,虚表是独立一份)" << std::endl;
std::cout << "== 从虚表第 0 项调用(对应 f)==" << std::endl;
entry_of(&b1, 0)(); // Base::f
entry_of(&d, 0)(); // Derived::f ------ 重写只换了这一格
std::cout << "== 从虚表第 1 项调用(对应 g)==" << std::endl;
entry_of(&b1, 1)(); // Base::g
entry_of(&d, 1)(); // Derived 没重写,还是 Base::g
return 0;
}
text
sizeof(NoVirtual) = 1
sizeof(Base) = 8(多出来的就是 vptr)
b1 与 b2 的 vptr 相同 ? true(同一张虚表)
b1 与 d 的 vptr 相同 ? false(Derived 重写了 f,虚表是独立一份)
== 从虚表第 0 项调用(对应 f)==
Base::f
Derived::f
== 从虚表第 1 项调用(对应 g)==
Base::g
Base::g
四个数据点连成完整逻辑。第一 ,sizeof(NoVirtual) = 1(空类占位字节),sizeof(Base) = 8------多出来的 8 字节就是 vptr,有虚函数的对象头部藏着一根指针 。第二 ,b1 与 b2 的 vptr 相同------虚表属于类,同类对象共享一张。第三 ,b1 与 d 的 vptr 不同------Derived 重写了 f,编译器为它生成了独立的虚表。第四 ,从虚表槽位直接调用:第 0 项 b 走 Base::f、d 走 Derived::f;第 1 项两边都是 Base::g------重写的本质就是"把子类虚表对应槽位的函数地址换掉",没重写的槽位原样沿用基类条目。
p->f() 的运行期三步由此清晰:取对象头部的 vptr → 找到该类的虚表 → 按槽位取地址调用。这三步就是"动态绑定"的全部成本,也是"虚看对象、非虚看指针"的物理解释------非虚函数的地址编译期就焊死在指令里,根本不查表。
配图 4|单继承下虚表与虚指针布局
六、overload / override / hiding:三兄弟一份判决书
笔试最爱把这三个概念搅在一起。判定只要两问:先问两个函数在不在同一层作用域,再问基类函数是不是虚函数:
cpp
// 重载、重写、隐藏三者辨析:一份代码看清三种关系
#include <iostream>
namespace byte
{
class Base
{
public:
// 重载:同一作用域、同名、参数不同------编译期就选定
void fun() { std::cout << "Base::fun()" << std::endl; }
void fun(int x) { std::cout << "Base::fun(int) " << x << std::endl; }
virtual void greet() { std::cout << "Base::greet()" << std::endl; }
virtual void show() const { std::cout << "Base::show() const" << std::endl; }
virtual ~Base() {}
};
class Derived : public Base
{
public:
// 重写(override):签名与基类虚函数完全一致
virtual void greet() { std::cout << "Derived::greet()" << std::endl; }
// 隐藏(hiding):与 Base::fun 同名不同作用域,基类两个 fun 全被挡住
void fun(int x) { std::cout << "Derived::fun(int) " << x << std::endl; }
};
}
int main()
{
byte::Derived d;
byte::Base* p = &d;
std::cout << "== 重载:同一作用域内,编译期选定 ==" << std::endl;
d.Base::fun();
d.Base::fun(10);
std::cout << "== 隐藏:派生类 fun(int) 挡住基类两个 fun ==" << std::endl;
d.fun(10); // Derived::fun(int)
std::cout << "== 重写:签名一致,多态生效 ==" << std::endl;
p->greet(); // Derived::greet()
return 0;
}
text
== 重载:同一作用域内,编译期选定 ==
Base::fun()
Base::fun(int) 10
== 隐藏:派生类 fun(int) 挡住基类两个 fun ==
Derived::fun(int) 10
== 重写:签名一致,多态生效 ==
Derived::greet()
- 重载 :同一个作用域内、同名、参数不同,
Base::fun()与Base::fun(int)并存,编译期按实参选定。 - 重写 :跨作用域,基类函数是 virtual、子类签名逐字一致,
greet()经基类指针调用走子类版本。 - 隐藏 :跨作用域、其余全部情况。
Derived::fun(int)与基类fun同名,基类的两个 fun 整组被挡------继承篇的"名字查找内层命中即停"在这里再次生效。
隐藏里埋着笔试第一大坑:假重写。签名差一个 const 就不算重写,
p->show() 走的还是基类版本------虚表槽位根本没被换 ,子类那个 show() 是另一个独立的新虚函数。
overload / override / hiding 对照
七、构造与析构期间调虚函数:多态暂时下岗
在构造函数或析构函数里调用虚函数,永远不会落到子类版本------这不是 bug,是语言规则。
cpp
// 构造与析构期间调用虚函数:多态失效,永远走当前正在构造/析构的那一层的版本
#include <iostream>
namespace byte
{
class Base
{
public:
Base()
{
init(); // 构造期间调虚函数
std::cout << " Base 构造完成" << std::endl;
}
virtual void init()
{
std::cout << " Base::init 被调用" << std::endl;
}
virtual ~Base()
{
release(); // 析构期间调虚函数
std::cout << " Base 析构完成" << std::endl;
}
virtual void release()
{
std::cout << " Base::release 被调用" << std::endl;
}
};
class Derived : public Base
{
public:
Derived()
{
std::cout << " Derived 构造完成" << std::endl;
}
virtual void init()
{
std::cout << " Derived::init 被调用" << std::endl;
}
virtual void release()
{
std::cout << " Derived::release 被调用" << std::endl;
}
virtual ~Derived()
{
std::cout << " Derived 析构完成" << std::endl;
}
};
}
int main()
{
std::cout << "== 构造 Derived ==" << std::endl;
byte::Derived d;
std::cout << "== main 作用域结束,自动析构 ==" << std::endl;
return 0;
}
text
== 构造 Derived ==
Base::init 被调用
Base 构造完成
Derived 构造完成
== main 作用域结束,自动析构 ==
Derived 析构完成
Base::release 被调用
Base 析构完成
构造 Derived 时 init() 输出的是 Base::init,析构时 release() 输出的是 Base::release。vptr 是构造过程中一步一步换指向的 ------进入 Base::Base() 时 vptr 指向 Base 虚表,此刻的虚函数调用自然落在 Base 层;进入 Derived::Derived() 后 vptr 才换过去。析构反向同理,一进入 ~Base,vptr 就换回 Base 虚表。语言这么设计是在保护程序:Derived 还没构造完时它的成员不存在,真把调用派下去等于操作半成品对象。
构造析构期间的虚函数行为
八、对象切片与上下转型
切片在继承篇算过 sizeof 账本,这次补上多态视角的后果:按值传参不仅丢数据,连多态也一起丢------切片拷走的是基类子对象,vptr 所在的那份对象头会按基类重新安放,函数体内看到的彻头彻尾是个基类对象:
cpp
// 对象切片:按值传参丢掉派生部分;向下转型要自己担保安全
#include <iostream>
namespace byte
{
class Animal
{
public:
virtual void kind() const { std::cout << "Animal(基类版本)" << std::endl; }
virtual ~Animal() {}
};
class Dog : public Animal
{
public:
virtual void kind() const { std::cout << "Dog(子类版本)" << std::endl; }
void fetch() { std::cout << "Dog::fetch:子类专属能力" << std::endl; }
};
}
void by_value(byte::Animal a) { a.kind(); } // 按值:切片
void by_ref(const byte::Animal& a) { a.kind(); } // 引用:不切片
int main()
{
byte::Dog d;
std::cout << "== 按值传参 vs 引用传参 ==" << std::endl;
by_value(d); // Animal ------ 切片,派生部分没进函数
by_ref(d); // Dog
std::cout << "== 向上转型:隐式、安全 ==" << std::endl;
byte::Animal* pa = &d;
pa->kind(); // Dog,多态生效
std::cout << "== 向下转型:static_cast,安全性自己担保 ==" << std::endl;
byte::Dog* pd = static_cast<byte::Dog*>(pa); // pa 确实指向 Dog,没问题
pd->kind();
pd->fetch();
return 0;
}
text
== 按值传参 vs 引用传参 ==
Animal(基类版本)
Dog(子类版本)
== 向上转型:隐式、安全 ==
Dog(子类版本)
== 向下转型:static_cast,安全性自己担保 ==
Dog(子类版本)
Dog::fetch:子类专属能力
by_value(d) 输出基类版本、by_ref(d) 输出子类版本,同一只狗,两种命运。想保住多态,指针或引用,别无选择 。向上转型隐式安全;向下转型 C++98 里用 static_cast,安全性完全自己担保------pa 确实指向 Dog 时没问题,指向真 Animal 时行为未定义。标准库提供了运行期检查的 dynamic_cast
九、协变返回值:重写时返回值可以"降级"
一般规则说重写要求签名逐字一致,返回值却有一个合法的例外:基类虚函数返回基类指针/引用,子类重写时可以返回子类指针/引用,这叫协变:
cpp
// 协变返回值:重写时返回值可以换成子类指针/引用
#include <iostream>
namespace byte
{
class Base
{
public:
virtual Base* clone() const
{
std::cout << " Base::clone" << std::endl;
return new Base(*this);
}
virtual ~Base() {}
};
class Derived : public Base
{
public:
// 协变:返回 Derived* 也算重写(必须是基类返回值的子类指针/引用)
virtual Derived* clone() const
{
std::cout << " Derived::clone" << std::endl;
return new Derived(*this);
}
};
}
int main()
{
byte::Derived d;
byte::Base* p = &d;
std::cout << "== 经基类指针调用 clone ==" << std::endl;
byte::Base* c1 = p->clone(); // 多态:走 Derived::clone,返回值自动收窄为 Base*
std::cout << "== 直接对对象调用 clone ==" << std::endl;
byte::Derived* c2 = d.clone(); // 拿到的直接就是 Derived*,不用再向下转
delete c1;
delete c2;
return 0;
}
text
== 经基类指针调用 clone ==
Derived::clone
== 直接对对象调用 clone ==
Derived::clone
p->clone() 走的是 Derived::clone(重写生效),返回的 Derived* 自动收窄成 Base*;而 d.clone() 直接拿到 Derived*,省掉一次向下转型。工厂方法、克隆接口常用这招。限制条件要记牢:只有指针和引用允许协变,按值返回不行;子类返回类型必须是基类返回类型的子类。
十、难点拆解
10.1 "虚看对象、非虚看指针"到底换了什么
非虚函数的调用目标在编译期由静态类型 (指针/引用声明类型)决定;虚函数运行期由动态类型 (对象真实身份)决定,载体就是 vptr。同一根指针,静态类型永远是 Animal*,动态类型随指向的对象变------虚函数只是把"看谁"从前者换成后者。理解了这层,切片为什么丢多态(对象都换了,动态类型成了 Animal)、构造析构为什么失效(vptr 尚未/已经换回基层)全部自动成立。
102 虚析构的泄漏为什么恰好是子类那块
delete p 编译器只看静态类型 Job*:非虚析构 → 静态绑定调 ~Job → 子类析构不在调用链上 → 子类构造时分配的资源没人释放。虚析构 → 查表调到 ~OnlineJob → 子类析构跑完后按继承篇的镜像律自动接 ~Job。泄漏的永远是"子类多出来的那一部分" ,leaks 报告把泄漏点定位在 OnlineJob 构造函数,与此完全吻合。
10.3 假重写为什么不报错只警告
C++98 的名字查找规则:派生类声明了同名函数,基类同名集整个被隐藏,这是合法语言行为(隐藏机制本身正当)。至于"这个同名函数本意是不是重写",编译器只能猜------它选择用 -Woverloaded-virtual 提醒,但语言层面放行。C++11 的 override 把猜测变成契约,写上 override 又对不上签名就直接报错。C++98 时代的替代纪律:重写前把基类声明原样复制。
10.4 抽象类连按值传参都不行,为什么指针引用可以
按值传参需要完整拷贝一个对象,而抽象类的纯虚函数没有实现,它的虚表里有"空槽",对象造不出来也拷不出来。指针和引用不产生新对象,只是透过它调用子类提供的实现------抽象类把自己"不能实例化"的限制,精确地转化成了"只能以多态方式使用"的设计意图。
十一、易踩坑汇总
| 编号 | 坑 | 现象 | 根因 | 处理 |
|---|---|---|---|---|
| 1 | 该加 virtual 没加 | 父类指针调用永远走基类版本 | 非虚函数静态绑定 | 预期被重写的函数一律 virtual |
| 2 | 基类析构不写 virtual | 子类析构不执行,leaks 点名泄漏 | delete 按静态类型绑定 | 会被继承的类析构写 virtual |
| 3 | 假重写(少 const / 参数不同) | 编译警告 hides overloaded virtual function,多态失效 | 签名不逐字一致 | 复制基类声明再改函数体 |
| 4 | 构造/析构里调虚函数期待多态 | 永远走当前层版本 | vptr 指向正在构造/析构层的虚表 | 两段式 init 或非虚下沉 |
| 5 | 按值传递多态对象 | 切片 + 多态失效 | 拷贝只搬基类子对象 | 指针或引用传参 |
| 6 | 派生类重写函数不写 virtual 且以为没生效 | 实际生效了(基类有 virtual 即可) | virtual 属性会被继承 | 知道规则即可,写了更醒目 |
| 7 | 抽象类试图实例化/按值传参 | is an abstract class 报错 |
纯虚函数无实现,虚表有空槽 | 用指针或引用 |
| 8 | 子类未实现全部纯虚函数就实例化子类 | 子类也报 abstract | 纯虚函数被继承下来 | 补实现,或子类继续当抽象类 |
| 9 | 隐藏当重载用 | 基类同名整组函数调不到 | 内层作用域命中即停 | 类名限定 d.Base::fun() |
| 10 | static_cast 向下转错对象 | 行为未定义,不报错 | 转换安全性自己担保 | 只在确知动态类型时使用 |
| 11 | 协变写成按值返回 | 不算重写,编译报错或变隐藏 | 协变仅限指针/引用 | 返回类型保持指针/引用形态 |
| 12 | 虚函数太多担心对象变大 | 对象不变大,只多一根 vptr | vptr 每对象一根,虚表在类侧 | 放心设计接口 |
第 2 条的泄漏报告、第 3 条的警告文本、第 5 条的切片输出全部本机实测,见正文各节。
十二、闭环复盘
这篇串一遍,落成六条。
第一,先分静态动态再谈机制。 重载编译期定结果,虚函数运行期定结果;分界问题是"要不要看对象身份"。
第二,三要素是"与"关系。 公有继承、签名逐字一致的虚函数重写、父类指针或引用,缺一即失效;按值传参从根上断送多态。
第三,虚析构是纪律不是可选项。 家族有虚函数,基类析构就 virtual;实测泄漏报告与归零报告就是这句话的成本收益表。
第四,虚表虚指针是全篇的物理解释。 一类一表、一对象一指针、重写换槽位、调用走三步------三要素、虚析构、构造失效、切片全是它的推论。
第五,overload / override / hiding 用两问判定。 先问作用域层级,再问虚函数与签名;假重写靠 -Wall 警告和复制声明的纪律防。
第六,构造析构期间虚函数不可靠。 vptr 指向正在出生或死亡的那一层,别在这两处依赖多态。
最后附笔试简答背诵精简版:
- 多态分类:静态多态(重载/模板,编译期绑定,零运行开销);动态多态(虚函数,运行期绑定,一次查表)。
- 动态多态三条件:公有继承;基类虚函数被子类重写(签名一致);基类指针或引用调用子类对象。
- virtual 作用:把函数绑定依据从静态类型换成动态类型,实现上经 vptr 查虚表。
- 虚析构必要性:基类指针 delete 子类对象时保证完整析构链,否则子类资源泄漏。
- 纯虚函数 :
virtual 返回类型 函数名(参数) = 0;;含纯虚函数的类是抽象类,不能实例化,强制派生类实现接口。 - 虚表虚指针:每个多态类一张虚表存虚函数地址;每个对象一根 vptr 指向所属类的虚表;重写即替换子类虚表槽位。
- 重载/重写/隐藏:重载同作用域同名不同参、编译期绑定;重写跨作用域、基类虚函数、签名一致、运行期绑定;隐藏是同名即遮蔽基类整组函数。
- 构造析构中调虚函数:不发生多态,调用落在当前正在构造/析构的层,因为 vptr 指向该层虚表。
- 切片:子类对象按值赋给/传给基类时丢掉派生部分,多态随之失效;指针引用不切片。
- 协变返回值:重写时返回值可为基类返回类型的子类指针/引用,其余情况签名必须逐字一致。




