如何在 C++ 中安全地按引用传递参数 - 未解决的问题
使用后释放 bug、新的智能指针和安全 C++ 编程的新状态
请注意,本文有点像是介绍“注册指针”的先前文章的续篇,并且可能包含一些冗余。
快速摘要
虽然通过“原生”指针或引用按参数传递的传统方式通常是安全的,但为了确保参数引用不会被用于访问无效内存,您必须使用智能指针(或智能引用)。特别是,我们建议使用mse::TRefCountingFixedPointer、mse::TScopeFixedPointer 和/或mse::TRegisteredFixedPointer。如果您编写的是一个供更广泛使用的函数,并且不想限制调用者使用特定类型的智能指针,您可以将函数“模板化”,使其能够接受调用者希望使用的任何类型的指针。
#include "mseregistered.h"
#include "mserefcounting.h"
class H {
public:
/* Just an example of a templated member function. You might consider templating pointer parameter
types to give the caller some flexibility as to which kind of (smart/safe) pointer they want to
use. */
template<typename _Tpointer>
static int foo1(_Tpointer A_ptr) { return A_ptr->b; }
protected:
~H() {}
};
int main(int argc, char* argv[]) {
class A {
public:
A() {}
int b = 3;
};
A a_obj;
A* a_ptr = &a_obj;
int res1 = H::foo1(a_ptr);
mse::TRegisteredObj<A> a_robj;
mse::TRegisteredFixedPointer<A> a_rfptr = &a_robj; // safe smart pointer
int res2 = H::foo1(a_rfptr);
mse::TRefCountingFixedPointer<A>
a_refcfptr = mse::make_refcounting<A>(); // another safe smart pointer
int res3 = H::foo1(a_refcfptr);
}
看?很简单。
如果您因为某些原因无法或不想将函数模板化,但仍希望为调用者提供指针引用参数的灵活性,那么您可能需要考虑使用“多态指针”。多态指针可以根据需要充当强/拥有指针或弱/非拥有指针。当从强/拥有指针(即引用计数指针或 std::shared_ptr)构造时,多态指针将获得并保持目标对象的(共享)所有权。
这里我们将演示三种不同的多态指针的使用 - mse::TRefCountingOrXScopeFixedPointer、mse::TRefCountingOrXScopeOrRawFixedPointer 和 mse::TSharedOrRawFixedPointer。
#include "msepoly.h"
class A {
public:
A() {}
int b = 3;
};
class H {
public:
static int foo2(mse::TRefCountingOrXScopeFixedPointer<A> A_ptr) { return A_ptr->b; }
static int foo3(mse::TRefCountingOrXScopeOrRawFixedPointer<A> A_ptr) { return A_ptr->b; }
static int foo4(mse::TSharedOrRawFixedPointer<A> A_ptr) { return A_ptr->b; }
protected:
~H() {}
};
int main(int argc, char* argv[]) {
A a_obj;
A* a_ptr = &a_obj;
mse::TXScopeObj<A> a_xscpobj;
mse::TXScopeFixedPointer<A> a_xscpptr = &a_xscpobj;//a "smart" pointer for stack allocated objects
mse::TRefCountingFixedPointer<A> a_refcfptr = mse::make_refcounting<A>();
int res1 = H::foo2(a_xscpptr);
int res2 = H::foo2(a_refcfptr);
int res3 = H::foo3(a_ptr);
int res4 = H::foo3(a_xscpptr);
int res5 = H::foo3(a_refcfptr);
std::shared_ptr<A> a_shptr = std::make_shared<A>();
int res6 = H::foo4(a_ptr);
int res7 = H::foo4(a_shptr);
}
当然,多态指针会有一些小的运行时成本,因此将函数“模板化”是首选选项。
讨论
那么,我们所说的“安全地按引用传递参数”是什么意思呢?嗯,让我们来看一个例子
#include <string>
#include <vector>
class CProgram {
public:
void add_instruction(const std::string& instruction_cref) {
if ("!clear instructions" == instruction_cref) {
instructions.clear();
instructions.shrink_to_fit();
} else {
instructions.push_back(instruction_cref);
}
}
void add_two_instructions(const std::string&
instruction1_cref, const std::string& instruction2_cref) {
add_instruction(instruction1_cref);
add_instruction(instruction2_cref);
}
std::vector<std::string> instructions;
};
void main(int argc, char* argv[]) {
CProgram program1;
program1.add_two_instructions(std::string("add 1"),
std::string("multiply by 2"));
program1.add_two_instructions(program1.instructions.front(),
std::string("multiply by 3"));
program1.add_two_instructions(std::string("!clear instructions"),
program1.instructions.front());
}
假设 shrink_to_fit()
函数完成了它的工作,那么程序的最后一行最终会造成无效内存访问,对吧?add_two_instructions()
函数将收到两个完全有效的引用参数,但在完成使用之前,它将无意中导致第二个参数变得无效。然后它会将该无效参数传递给 add_instruction()
函数,后者将尝试使用该无效参数。这是不好的。
那么,这种 bug 在实践中有多大的问题呢?嗯,很难说。有些人似乎并不太担心这种危险,而其他人则对这种缺乏担忧感到有些担忧。
我们给出的例子中的 bug 类型称为“使用后释放” bug。例如,如果我们查看流行的 C++ 开源 Chromium 浏览器项目最近的“严重”安全 bug,我们会注意到,在撰写本文时,其中 20 个最近的 bug 在其描述中表明它们属于“使用后释放”类型。例如,如果我们查看今年 Pwn2Own(2016)第一天的结果,我们会注意到所有五次成功的利用都至少使用了一次使用后释放漏洞。
在现代语言中,内存使用后释放 bug 主要是 C++ 的现象。C# 和 Java 等语言使用强制垃圾回收等机制来避免此类 bug。在 C++ 中,我们可以选择使用垃圾回收来解决这个问题,但从 C++ 的角度来看,垃圾回收有一些不吸引人的特性。例如,非确定性销毁、更高的内存使用、“GC 暂停”等。幸运的是,通过安全智能指针有替代解决方案。让我们快速看一下三种旨在尽可能高效地解决使用后释放问题的安全智能指针 - mse::TRefCountingPointer
、mse::TScopeFixedPointer
和 mse::TRegisteredPointer
。
mse::TRefCountingPointer 是一个像 std::shared_ptr
一样的引用计数智能指针。但 mse::TRefCountingPointer
在某些方面不如 std::shared_ptr
灵活,这使其效率更高(更小、更快),也许更适合通用用途。例如,mse::TRefCountingPointer
省去了 std::shared_ptr
(昂贵)的线程安全机制。并且 mse::TRefCountingPointer
有“非空”和“固定”(不可重定向)版本,可以安全地假定它们始终指向一个有效分配的对象。
mse::TScopeFixedPointer 指向在栈上分配的对象,或者其“拥有”指针在栈上分配的对象。“作用域”指针的目的是基本上识别一组足够简单和确定性的情况,使得不需要(运行时)安全机制。
mse::TRegisteredPointer 本质上是原生指针的一个安全直接替代品。默认情况下,它会在任何尝试访问无效内存的操作时抛出异常。
因此,有了这些安全智能指针,让我们来修复我们之前的例子
#include "mserefcounting.h"
#include "msescope.h"
#include <string>
#include <vector>
class CProgramV2 {
public:
template<typename _TStringPointer>
void add_instruction(_TStringPointer instruction_ptr) { // now a template function
if ("!clear instructions" == *instruction_ptr) {
instruction_ptrs.clear();
instruction_ptrs.shrink_to_fit();
} else {
instruction_ptrs.push_back(mse::make_refcounting<std::string>(*instruction_ptr));
}
}
template<typename _TStringPointer1, typename _TStringPointer2>
void add_two_instructions(_TStringPointer1 instruction1_ptr, _TStringPointer2 instruction2_ptr) {
add_instruction(instruction1_ptr);
add_instruction(instruction2_ptr);
}
/* We need to make references to stored items safe. One way to do this is to use reference counting
pointers. */
std::vector<mse::TRefCountingFixedPointer<std::string>> instruction_ptrs;
};
void main(int argc, char* argv[]) {
CProgramV2 program1;
mse::TScopeObj<std::string> add_one_scpobj("add 1");
/* We explicitly declare an mse::TScopeFixedPointer here just to show what's going on. */
mse::TScopeFixedPointer<std::string> add_one_scpfptr = &add_one_scpobj;
program1.add_two_instructions(add_one_scpfptr,
&mse::TScopeObj<std::string>("multiply by 2"));
program1.add_two_instructions(program1.instruction_ptrs.front(),
&mse::TScopeObj<std::string>("multiply by 3"));
program1.add_two_instructions(&mse::TScopeObj<std::string>
("!clear instructions"), program1.instruction_ptrs.front());
}
首先要注意的是,add_instruction()
和 add_two_instructions()
成员函数已被转换为模板函数,允许它们接受任何类型的指针(智能指针或否则)作为参数。
我们选择在这里解决使用后释放问题的途径是大致模仿垃圾回收语言的内存管理,使用引用计数指针来管理堆分配的对象。栈分配的对象在示例中并未导致使用后释放 bug,但为了演示目的,我们在此将其声明为“scope
”对象,以增强(编译时)安全性。
事实上,实现指针安全的一个通用、直接的方法是仅将允许的指针类型限制为这些引用计数和作用域指针。
话虽如此,让我们考虑另一个解决方案。mse::mstd::vector 是一个具有安全迭代器的 std::vector 的安全实现,可以在我们使用上一个解决方案中的引用计数指针的地方使用。
#include "msescope.h"
#include <string>
#include "msemstdvector.h"
class CProgramV3 {
public:
template<typename _TStringPointer>
void add_instruction(_TStringPointer instruction_ptr) {
if ("!clear instructions" == *instruction_ptr) {
instructions.clear();
instructions.shrink_to_fit();
}
else {
instructions.push_back(*instruction_ptr);
}
}
template<typename _TStringPointer1, typename _TStringPointer2>
void add_two_instructions(_TStringPointer1 instruction1_ptr, _TStringPointer2 instruction2_ptr) {
add_instruction(instruction1_ptr);
add_instruction(instruction2_ptr);
}
/* mse::mstd::vector is just a safe implementation of std::vector. */
mse::mstd::vector<std::string> instructions;
};
void main(int argc, char* argv[]) {
CProgramV3 program1;
program1.add_two_instructions(&mse::TScopeObj<std::string>("add 1"),
&mse::TScopeObj<std::string>("multiply by 2"));
program1.add_two_instructions(program1.instructions.cbegin(),
&mse::TScopeObj<std::string>("multiply by 3"));
bool expected_exception = false;
try {
program1.add_two_instructions(&mse::TScopeObj<std::string>("!clear instructions"),
program1.instructions.cbegin());
}
catch (...) {
expected_exception = true;
/* The iterator returned by program1.instructions.cbegin() is going to become invalid when
the vector is cleared, and will throw an exception when the program tries to dereference it. */
}
}
首先,请注意模板函数接受迭代器作为指针参数没有问题。其次,请注意,虽然引用计数指针通过延长目标对象的生命周期来确保所有对其的引用都保持有效,但 mse::mstd::vector
迭代器通过在尝试访问不再有效的项时抛出异常来保证其安全性。哪种行为更可取是值得商榷的,但在 C++ 中您可以选择。而在强制垃圾回收的语言中,您则不能。
我们还应该注意到,std::vector
在其标准实现中是一个不安全的容器,因为它允许通过其迭代器和“[]
”运算符访问无效内存(未经检查)。更安全的编程实践会让我们使用像 mse::mstd::vector
这样的更安全实现。
快速地,让我们再看一个使用注册指针的解决方案。
#include "mseregistered.h"
#include <string>
#include "msemstdvector.h"
class CProgramV4 {
public:
template<typename _TStringPointer>
void add_instruction(_TStringPointer instruction_ptr) {
if ("!clear instructions" == *instruction_ptr) {
instructions.clear();
instructions.shrink_to_fit();
}
else {
instructions.push_back(*instruction_ptr);
}
}
template<typename _TStringPointer1, typename _TStringPointer2>
void add_two_instructions(_TStringPointer1 instruction1_ptr, _TStringPointer2 instruction2_ptr) {
add_instruction(instruction1_ptr);
add_instruction(instruction2_ptr);
}
/* mse::TRegisteredObj<std::string> is meant to behave just like an std::string. */
mse::mstd::vector<mse::TRegisteredObj<std::string>> instructions;
};
void main(int argc, char* argv[]) {
CProgramV4 program1;
program1.add_two_instructions(&mse::TRegisteredObj<std::string>
("add 1"), &mse::TRegisteredObj<std::string>("multiply by 2"));
/* We explicitly declare an mse::TRegisteredFixedPointer here just to show what's going on. */
mse::TRegisteredFixedPointer<std::string> first_instruction_rfptr = &(program1.instructions.front());
program1.add_two_instructions(first_instruction_rfptr,
&mse::TRegisteredObj<std::string>("multiply by 3"));
bool expected_exception = false;
try {
program1.add_two_instructions(&mse::TRegisteredObj<std::string>
("!clear instructions"), &(program1.instructions.front()));
}
catch (...) {
expected_exception = true;
/* The registered pointer returned by &(program1.instructions.front()) is going to become
invalid when the vector is cleared, and will throw an exception when the program tries to
dereference it. */
}
}
因此,实现指针安全的另一个通用、直接的方法是用它们的“注册”对应物替换所有类和指针。对于堆分配的对象,注册指针比引用计数指针有更多的开销,当然比作用域指针(默认情况下没有运行时开销)对于栈分配的对象有更多的开销。您可能更喜欢使用注册指针的一个原因,除了它们的简单性之外,是因为当指向有效对象时,它们的行为与其对应的原生指针相同。这允许它们通过编译时指令“禁用”(自动替换为它们的原生对应物),使您能够生成安全和(高性能)不太安全的应用程序版本。而且由于安全版本会在任何尝试访问无效内存的操作时抛出异常,因此它可以在测试期间用于查找 bug。
顺便说一句,如果所有这些安全指针代码看起来有点冗长,可以使用更短的别名。(在头文件中查找“更短的别名”。)或者更好的是,您当然可以自己创建。
总而言之,对于安全编码,我们默认建议将意图为通用用途的引用参数函数转换为模板函数,使其能够接受任何类型的指针引用。并且当您需要使用指针或引用时,通常我们建议使用 mse::TRefCountingFixedPointer
来处理堆分配的对象,使用 mse::TScopeFixedPointer
来处理栈分配的对象。如果某些情况下这些指针不理想,mse::TRegisteredFixedPointer
也是一种推荐的安全指针类型。
基准测试
“那么”,您可能会问,“这些安全指针会给我带来多少性能损失?”就像任何事情一样,这取决于您的具体用例,但为了给您一个非常粗略的想法,这里是一些简单的微基准测试结果
分配、释放、指针复制和赋值
指针类型 | 时间 |
---|---|
mse::TRegisteredPointer (栈) |
0.0317188 秒 |
原生指针 (堆) | 0.0394826 秒 |
mse::TRefCountingPointer (堆) |
0.0493629 秒 |
mse::TRegisteredPointer (堆) |
0.0573699 秒 |
std::shared_ptr (堆) |
0.0692405 秒 |
mse::TRelaxedRegisteredPointer (堆) |
0.14475 秒 |
解引用
指针类型 | 时间 |
---|---|
原生指针 | 0.0105804 秒 |
mse::TRelaxedRegisteredPointer 未检查 |
0.0136354 秒 |
mse::TRefCountingPointer (已检查) |
0.0258107 秒 |
mse::TRelaxedRegisteredPointer (已检查) |
0.0308289 秒 |
std::weak_ptr |
0.179833 秒 |
基准测试环境:msvc2015/x64/Window 7/Haswell
请注意,mse::TRefCountingFixedPointer
始终指向一个有效分配的对象,因此其解引用不需要检查。mse::TRegisteredPointer
的安全机制与基准测试中用于隔离解引用性能的技术不兼容,但 mse::TRegisteredPointer
的解引用性能预计与 mse::TRelaxedRegisteredPointer
基本相同。默认情况下,作用域指针的性能与原生指针相同。
您不应该过分夸大这些结果,它们可能无法反映实际性能。而且它们似乎在一定程度上依赖于编译器。即便如此,从这些结果来看,我们可以感觉到存在性能成本,但可能以百分比衡量,而不是倍数。
这种成本是否值得?当然这取决于您的具体情况,但同样,考虑到像使用后释放漏洞在关键互联网基础设施中的频率,社会正在为我们当前的 UNSAFE 编码实践付出代价。
采纳或不采纳
现在,您可能会对采纳这种安全编码方式感到有些不安。它不熟悉,而且可能显得有些冗长。而且由于它是新的,目前还没有值得信赖的已安装基础。那么,让我们看看在决定是否采纳一种新的编码技术时,您需要考虑的四个因素 - 效用与成本、合理性/风险性、依赖性和兼容性。
代码安全性的价值将是特定于情况的,成本也是如此。不仅特定于应用程序,而且通常特定于应用程序内部。即使在性能关键型应用程序中,通常也只有一小部分代码实际上是性能关键的。因此,您可能有一些代码部分的安全是次要的,而另一些部分则不是。
思考这种编码合理性的一种好方法是将其与 C# 和 Java 等语言的底层运作进行比较。从这个角度来看,像 mse::TRefCountingFixedPointer
和 mse::TRegisteredFixedPointer
这样的安全智能指针的开销和复杂性,与这些语言中垃圾回收的底层机制相比,并不显得不利。在我们的例子中,机制只是暴露得更多。如果您对所有模板函数感到不安,那么 STL 本身就很好地证明了编译器处理大量模板的能力。
这种安全编码风格的一个好处是它确实不引入任何依赖风险。事实上,函数参数的模板化使我们摆脱了对传统不安全参数传递接口的长期事实上的依赖。至于安全智能指针本身,它们每个都包含在一个或两个文件中,没有其他依赖项。而且,如果您将来选择恢复到标准指针类型,那将非常容易。它们有一个内置的编译时功能可以自动完成这一点。或者,您可以简单地将它们别名为相应的标准类型,并完全删除它们的实现文件。mse::mstd::vector
也是如此。
最后,这种安全编码风格与传统的(不安全)编码风格非常兼容,允许它们在代码中混合使用。将函数转换为函数模板并不会阻止它们接受标准指针类型,反之,安全智能指针目标对象要么与原始对象类型相同,要么与其兼容。
...
/* just demonstrating compatibility with traditional interfaces */
CProgramV2 program1;
program1.add_two_instructions(&std::string("add 1"), &std::string("multiply by 2"));
class B {
public:
static int foo3(const std::string& string1_cref) { return (int)(string1_cref.size()); }
};
mse::TScopeObj<std::string> add_one_scpobj("add 1");
int res1 = B::foo3(add_one_scpobj);
mse::TRegisteredObj<std::string> add_one_regobj("add 1");
int res2 = B::foo3(add_one_regobj);
mse::TRefCountingFixedPointer<std::string> add_one_refcfptr =
mse::make_refcounting<std::string>("add 1");
int res3= B::foo3(*add_one_refcfptr);
...
安全 C++ 编程的现状
所以,基本上我们在这里演示了在 C++ 中实现垃圾回收的指针/引用安全性,而没有一些相关的缺点。
但 C++ 仍然无法媲美其他现代语言的安全性,因为它没有编译时限制使用不安全代码,并且没有一个合理完整、最新的标准库安全实现。
C++ 确实比 Java 和 C# 等语言具有一些安全优势,包括 const
引用和通过析构函数(RAII)进行的确定性、自动资源释放。但可能更重要的是,C++ 的全面重载功能和强大的预处理器允许您构建具有您想要添加的任何安全功能的有效语言元素。例如,“安全数值”库允许您创建具有自定义范围限制的整数。或者,例如,您可以想象一个跟踪和分析其解引用以查找可疑使用模式的智能指针。
虽然今天安全编程的花园可能在于当前的垃圾回收语言,但安全编程的未来可能是更强大、更高效、更确定性语言的领域。谁知道呢,C++ 可能就是其中之一。