一、面向对象安全:继承与多态的安全问题
面向对象编程,说白了就是让代码更贴近现实世界的思维方式。但我在做安全审计时发现,很多人只看到了它的便利,却忽略了它带来的安全隐患。继承、多态、虚函数这些特性,用好了是利器,用不好就是定时炸弹。
我个人习惯把面向对象安全分成几个核心维度来审视。今天咱们就一个一个过,每个点我都会结合真实项目中的踩坑经历来讲。
1.1 继承的安全问题
继承本身不是问题,问题出在「不恰当的继承」上。我见过太多代码,为了复用几个字段就搞出三层继承,结果改一个基类,整个系统都跟着抖。
举个例子,你想想看:
class Base {
public:
void show() { std::cout << "Base\n"; }
};
class Derived : public Base {
public:
void show() { std::cout << "Derived\n"; }
};
int main() {
Base* p = new Derived();
p->show(); // 输出 "Base" —— 嗯,这里要注意,不是多态!
}
为什么会这样?因为 show() 不是虚函数。很多新手以为「子类重写了父类的方法」,其实只是隐藏了。我在项目中遇到过这种 bug,排查了整整一个下午,最后发现是少写了一个 virtual。
1.2 虚函数的安全使用
虚函数是实现多态的核心机制,但它的安全使用有几个关键点。
| 安全要点 | 说明 | 我的建议 |
|---|---|---|
| 基类析构函数必须为虚 | 否则派生类资源无法正确释放 | 养成习惯,有虚函数的类就把析构写成虚的 |
| 不要在构造/析构中调用虚函数 | 此时虚表尚未完全建立或已销毁 | 我吃过这个亏,后来一律禁止 |
| 虚函数默认参数不要重写 | 默认参数是静态绑定的,容易混淆 | 直接别用默认参数,或者统一在基类定义 |
1.3 对象切片
对象切片是个很隐蔽的问题。说白了,就是你把派生类对象按值传给基类时,派生部分被「切掉」了。
class Base {
public:
virtual void func() { std::cout << "Base\n"; }
};
class Derived : public Base {
int extra_data;
public:
void func() override { std::cout << "Derived\n"; }
};
void process(Base b) { // 按值传递!切片发生!
b.func(); // 输出 "Base"
}
int main() {
Derived d;
process(d); // extra_data 被切掉了
}
我个人习惯是:所有涉及多态的类,一律禁止按值传递。要么传指针,要么传引用。你想想看,如果 extra_data 里存的是动态分配的内存指针,切片后指针丢了,内存泄漏就来了。
1.4 友元的安全使用
友元这个特性,我一直觉得是 C++ 里的「特赦令」。它打破了封装,用的时候一定要想清楚。
我在项目中遇到过这样的代码:一个类有十几个友元函数和友元类,最后改一个私有成员,所有友元都得跟着改。维护成本极高。
安全使用友元的几个建议:
- 优先用 public 接口替代友元
- 如果必须用,只给最小权限(比如只友元一个函数,不要友元整个类)
- 友元声明放在类末尾,用注释标明原因
1.5 拷贝与移动语义的安全
拷贝和移动语义,是 C++11 之后每个开发者都必须掌握的。但安全使用它们,有很多细节。
先看一个经典问题:
class Resource {
int* data;
public:
Resource() : data(new int(42)) {}
~Resource() { delete data; }
// 没有拷贝构造函数和拷贝赋值运算符!
};
int main() {
Resource r1;
Resource r2 = r1; // 浅拷贝!两个对象指向同一块内存!
// 析构时 double free!
}
这就是「三/五法则」的由来。如果你定义了析构函数、拷贝构造函数、拷贝赋值运算符中的任何一个,通常需要定义全部三个(C++11 后是五个,加上移动构造和移动赋值)。
= default 和 = delete 来明确表达意图。如果类不允许拷贝,就显式 delete 掉。这样代码读起来一目了然,也避免了编译器生成你不想要的行为。
移动语义的安全使用,核心是「移动后对象必须处于可析构的有效状态」。说白了,移动不是「偷」,是「转移所有权」。移动后源对象应该像刚默认构造出来一样,能安全析构,也能重新赋值。
class SafeResource {
int* data;
public:
SafeResource() : data(nullptr) {}
SafeResource(SafeResource&& other) noexcept
: data(other.data) {
other.data = nullptr; // 源对象置空
}
~SafeResource() { delete data; }
};
这里有个细节:移动构造函数标记为 noexcept。为什么?因为标准库容器在重新分配内存时,如果移动构造不抛异常,就会优先用移动而不是拷贝。性能提升很明显。
知识体系总览
下面这张图,是我梳理的本章知识结构。你可以把它当作一个检查清单,写代码时对照着看。
这张图把五个核心安全维度都串起来了。你写代码时,可以对照着检查:继承有没有做好接口隔离?虚函数有没有注意构造析构陷阱?对象有没有被意外切片?友元是不是给多了?拷贝移动语义有没有遵循三/五法则?
嗯,这一章的内容就到这里。面向对象安全是个大话题,每个点展开都能讲很久。但核心思想其实就一句话:用语言特性之前,先想清楚它可能带来的安全隐患。
公众号:蓝海资料掘金营,微信deep3321