最近项目中需要对C++智能指针做一些代码合入前检查,特别的,发现我们使用的Cppcheck, PCLint+都只支持对一般指针的检查,不支持智能指针,在网上看到这个SPrinter, 发现它比较全面的支持了常见的智能指针错误检查,基于其论文,在这里对智能指针错误类型及SPrinter工具的使用方法做一个简要介绍。
背景介绍
C++中动态分配的内存需要程序员自己管理,稍有不慎就会造成内存错误(Memory errors: ME): 常见的ME有如下几种:
内存泄漏(Memory Leak: ML)
int main() {
// OK
int *p = new int;
delete p;
// Memory leak below
int *q = new int;
// no delete for q
}
双重释放(Double Free: DF)
int main() {
// OK
int * p = new int;
delete p;
...
// Double free below
delete p;
}
访问已释放的内存(Use after Free: UaF)
int* n = new int{1};
delete n;
...
// Use after free below
std::cout << *n << std::endl;
针对这些问题,通过采用RAII技术,C++中引入了智能指针(Smart Pointers: SP)来自动释放不再需要的内存。SP可以减少内存错误(ME),但是也会引入更难以发现的ME,这些错误可以称为智能指针错误(Smart Pointer Error: SPE)。
智能指针对对象的管理也可以称之为对对象所有权的管理。违反RAII原则,可能导致bug出现。常见的违反RAII原则的情形有:
- 未察觉的所有权转换(Unconscious Ownership Transfer)
- 所有权丢失(leaked ownership)
- 所有权分叉(forked ownership)
- 无效的内存所有权(invalid memory ownership)
违反这些原则,可能导致的内存错误的对应关系如下:
表格来自SPrinter论文,其中:FMnH:freed memory not on heap,
NDTM:new and delete operator type mismatch
使用样例
下面分别说明于Clang-Tidy的SPrinter工具如何检测上面表中的问题,所有示例均来自SPrinter论文:
未察觉的所有权转换(Unconscious Ownership Transfer)
由于std::auto_ptr的拷贝构造函数会转移被拷贝的ptr的所有权, std::unique_ptr没有可被调用的public的拷贝构造函数,因而不能将它们放在STL Containers中, 如果存入,就可能会有Unconscious Ownership Transfer发生,比如下面的例子:
存在问题的地方有:
-
第6行:将auto_ptr对象作为类的成员变量,且该类没有提供拷贝构造函数(意味着使用默认拷贝构造),那么auto_ptr成员变量在进行默认拷贝构造时将会转移被拷贝的对象的成员的ptr的所有权,SPrinter报错如下:
warning: Class declares a field of type `auto_ptr' with default copy constructor and/or copy assignment operator. [smartpointersafety-private-autoptr-in-class] std::auto_ptr<Group1> self; ^
-
第10行:auto_ptr放入STL container中(如果vector扩容,就会发生意想不到的结果):
warning: Put `auto_ptr' in STL containers may cause unexpected behaviour under some circumstances. [smartpointersafety-autoptr-in-container] std::vector<std::auto_ptr<Group1>> obj(2); ^
-
第13行:拷贝构造传值后ap已经置为NULL,14行解引用ap将会导致程序crash,SPrinter报错如下:
warning: Ownership transfer of `auto_ptr' happens here: [smartpointersafety-autoptr-ownership-transfer] obj[1]->foo(ap); ^
所有权丢失(leaked ownership)
std::unique_ptr的release()方法的返回值是指向原对象的指针,同时释放原对象的所有权,但是并不释放内存(析构对象),因此假如有如下代码:
std::unique_ptr<int> ptr(new int{3});
int* p = ptr.release();
if (ptr == NULL) std::cout << "ptr is NULL now" << std::endl; // ptr == ptr.get() == NULL
std::cout <<*p << std::endl; // *p is 3
// Must delete p, or memory leak
delete p;
后面的delete p;是必须的,否则就会造成内存泄漏;同时,调用完release后,原来的ptr就会变为NULL;
std::unique_ptr的get()方法的返回值是指向原对象的指针,但是不释放原对象的所有权。因此如果像下面这样,最后delete p; p指向的内存将会在ptr析构时再次释放,造成double free。
void foo() {
std::unique_ptr<int> ptr(new int{3});
int* p = ptr.get();
std::cout <<*p << std::endl; // *p is 3
// do not delete p, or double free
delete p;
}
std::unique_ptr的reset()方法默认参数是NULL,返回值是空,它释放原对象的所有权同时释放内存,并将ptr置为NULL。
std::shared_ptr没有release()方法,因为可能有多个shared pointer指向同一个对象,所以不可能通过release()来达到std::unique_ptr的release()的效果:比如下面来自boost shared_ptr FAQ的例子:
shared_ptr<int> a(new int);
shared_ptr<int> b(a); // a.use_count() == b.use_count() == 2
int * p = a.release();
如果release()减少了user_count, 那么b和a析构的时候最后的user_count是多少?另外如果外部调用delete p释放对象内存,shared_ptr是否还需要释放内存?基于这些考虑,std::shared_ptr没有release()方法。
比如下面的例子:
这段程序用SPrinter检查会提示下面的warning:
warning: Undeallocated released pointer may cause memory leaked. [smartpointersafety-undeallocated-released-pointer]
return *p.release();
^
但这个程序本身没有内存方面的问题:
- 第16行通过get()方法将指针的所有权存在了vector中,但p本身还拥有对象的所有权
- 第18行通过release()方法释放了对象的所有权,因此第7行的delete不会造成double free
但是这段代码是不易维护,不易扩展的,它很容易出bug:
- 如果第18行没有调用release()方法,就会造成double free
- 如果第14行定义的是shared_ptr, 因为shared_ptr没有release()方法,因此不可能有18行存在,必然会造成double free
- 如果第18行返回的是release()获得的指向对象的指针,并在外部调用处delete,也会造成double free
最佳的实践守则是:
- 不要调用delete释放通过get()获得的指针所指的内存;
- 必须通过delete等机制释放release()获得的指向对象的指针所指的内存;
推荐的写法如下:
class Container {
public:
// Acquire ownership:
void take(int* p) {
v.push_back(p);
}
// Destruct memory:
Container() {
for (auto &i : v) delete i;
}
private:
std::vector<int*> v;
} C;
int foo() {
std::unique_ptr<int> p = std::make_unique<int>(42);
int ret = *p;
C.take(p.release()); //Transfer ownership from p to C.v
return ret;
}
所有权分叉(forked ownership)
所有权分叉是指同一份内存交给两个完全独立的智能指针进行管理,如下:
由于不同的智能指针拥有独立的析构函数,上面的函数势必会造成Double free; 如果想让不同的智能指针管理,比如将第4行改为sp2是通过sp1拷贝构造的,这虽然可以避免Double free的问题,但是还是有潜在的风险,如果foo()函数后续有人添加代码,不可避免有人会写出第3行类似的代码来初始化新的sp3指针。正确的原则是:不要使用raw pointer来初始化智能指针。
采用Sprinter来检查上面的代码片段,给出了非常有用的提示信息:
warning: Raw pointer used for initiating different smart pointers for twice. [smartpointersafety-raw-pointer-initiation]
int* p = new int(42);
^
warning: Initiating smart pointer with raw pointer. [smartpointersafety-raw-pointer-initiation]
std::shared_ptr<int> sp1(p);
^
无效的内存所有权(invalid memory ownership)
无效的内存所有权主要有三种情形:
-
通过智能指针管理栈上(非堆)内存(智能指针只能管理堆上创建的对象)
-
通过智能指针管理堆上创建的数组:
shared_ptr<int> sp(new int[10]);
上面的代码,由于shared_ptr指向的是一个int 数组,但是释放内存时仍然会调用delete ptr; 而不是delete [] ptr, 因此不推荐使用智能指针管理堆上创建的数组,SPrinter在上面情况也会报错。
但是如果提供自定义的deleter, 如下面这样,也是可以实现采用智能指针管理堆上数组的:template< typename T > struct array_deleter { void operator ()(T const * p) { delete [] p; } }; std::shared_ptr<int> sp(new int[10], array_deleter<int>()); // or as below std::shared_ptr<int> sp(new int[10], std::default_delete<int[]>()); // or use lambda expression std::shared_ptr<int> sp(new int[10], [](int *p) { delete[] p; });
在C++17中,不需要提供自定义的deleter,只需要声明智能指针时就直接支持了这种用法:
std::shared_ptr<int[]> sp(new int[10]);
-
访问释放了的内存,必然会造成crash或者undefined behavior
下面的示例代码:
第3行对应上面的第1种情形,第4行对应第2种情形,第7行对应第三种情形:由于tp过了作用域后自动释放,解引用wp.lock()返回的内存地址时非法的,推荐的用法是对于任意的weak_ptr,解引用通过lock()返回的内存地址均必须先检查其是否为NULL。
采用SPrinter工具检查上面的代码,得到的warning分别是:
warning: Initiating smart pointer with non-allocated memory. [smartpointersafety-non-allocated-memory-initiation]
std::unique_ptr<int> up(&v);
^
warning: Initiation CXXNewExpr type dose not match its declaration type `int'. [smartpointersafety-type-mismatch-initiation]
std::auto_ptr<int> ap(new int[42]);
^
warning: Validity of locked weak_ptr is unchecked, may have been expired. [smartpointersafety-unchecked-locked-weak-pointer]
*wp.lock() = 0;
^
SPrinter的实现机制
SPrinter是基于Clang-Tidy框架:Clang-Tidy当检查一个源文件时,源文件的代码会被转换为抽象语法树(AST),Clang-Tidy内部会注册一些语法检查的matcher来遍历AST,当某个matcher匹配时,就会提示相应的warning或error信息,SPrinter就是提供了一些这样的matcher来检查智能指针中潜在的使用风险。
SPrinter的不足
基于SPrinter的实现机制,SPrinter只能通过分析AST来查找某种模式是否存在,如下基于数据流的NULL check不能检查:
-
情形1:
std::unique_ptr<int> ptr; // ... // ... *ptr;
-
情形2:
std::unique_ptr<int> ptr(new int(3)); // ... std::unique_ptr<int> ptr2 = ptr; // ... *ptr;
SPrinter的优点
SPrinter通过分析AST的方法来实现检查, 准确率较高,使用起来独立方便,其命令行使用方法如下:
python ${llvm_path}/share/clang/run-clang-tidy.py -p ${repo_path} -clang-tidy-binary ${llvm_path}/bin/clang-tidy -quiet -checks='smartpointersafety-*' -j 8 -extra-arg=-ferror-limit=0 ${directory_or_file_to_check}
注意点:需要结合compile database(compile_commands.json)等使用,${repo_path}目录下应有compile database文件