案例
C/C++把内存管理交给程序员,由于对象生命周期的长短不一,需要记住内存在真正不需要它的时候显示释放,让程序员承担很大的心智负担,不经意间就出现内存泄露。通常正常的业务流程是不会出现内存泄露的,因为跑用例时会挂上内存检测工具,但一些异常分支,用例难以覆盖的地方,是内存泄露的重灾区。若一旦出现,都难以定位。前一段时间走读某一老产品的代码,是C/C++混合代码,发现一些潜在内存泄露问题。
问题1,每个异常分支需要手动释放方法内申请的内存,代码脱敏如下:
pBuf1 = malloc(sizeof(XX1)); // 申请一块内存
if (cond1) { // 异常分支1
free(pBuf1);
return RET_ERROR;
}
// 省略其它代码
pBuf2 = malloc(sizeof(XX2)); // 申请另一块内存
if (cond2) { // 异常分支2
free(pBuf1); // 每个分支,都要释放前面所有代码
free(pBuf2);
return RET_ERROR;
}
// 省略其它代码
ptr->buf1 = pBuf1; // 把多块内存的指针赋值给结构体指针ptr的成员变量,ptr是出参,ptr内存以及它的成员指针内存释放是在其它地方
ptr->buf2 = pBuf2;
return RET_SUCCESS
上述代码是典型的内存申请与最终释放不在同一地方,即使整体代码采用c++编译,大都是c的用法,没有封装类,在其析构方法中释放其的成员变量所指内存,内存申请分散在代码逻辑流程中,任一异常分支都可能导致某个前面申请的内存被遗漏而造成内存泄露。
问题2,宏可能会遗漏了前面的内存块释放:
pBuf2 = malloc(sizeof(XX2)); // 申请一块内存
SECURE_RETURN_WHEN_ERROR(memset_s(pbuf2, ....)); // 潜在内存泄露问题
公司要求对于memset,memcopy等函数采用带_s
结尾的安全函数。而这些函数都有返回值,同时公司要求不能忽略函数的返回值。如果每个安全函数返回值都加if来判断,代码雷同,所以通常考虑采用定义一个宏(如上面的的SECURE_RETURN_WHEN_ERROR)来简化代码,在函数返回失败时,直接return。当使用宏时,可能并没有考虑上下文信息,没有释放此行代码前面申请的多个内存块,如问题1代码中异常分支2需要同时释放两个指针的内存。
问题3,指针置空了,其指向的内存不再受管理
do {
// 省略大段其它代码
pTmpBuf = filed->data;
filed->data = malloc(filed->def.size * 2); // 对filed->data扩容
if (NULL == filed->data) {
done = RET_SUCESS; // 潜在内存泄露问题
break;
}
// 省略大段其它代码
free(pTmpBuf); // 正常逻辑会释放内存
} while(...);
上面的代码比较乍一看,比较难以发现出现问题,当malloc失败时,已把filed->data置空了,其它地方对filed->data释放代码失效,pTmpBuf在最后正常场景才释放内存,未在异常场景释放,造成内存泄露。
三个案例中异常都是内存操作有关,如malloc与memset等,我们可能认为他们出现失败的机率很低,风险不高。但他们并不是不会出现,如SECURE_RETURN_WHEN_ERROR宏在函数返回失败时打印日志,从某一现网历史日志来看,它一共出现了4次。
RAII
回过头来看上面的案例代码的问题,虽是C++程序,但还是C的写法,根本没有使用C++的RAII(Resource Acquistion Is Initialization,资源获取即初始化)机制。RAII本质是利用栈对象当它离开作用域之后自动析构,可把其它资源的生命周期绑定到该对象,让该对象在析构时对其它资源自动释放。
RAII的应用如下:
struct ScopeGuard {
ScopeGuard() {
data_ = new int[50];
}
~ScopeGuard{} {
if (data_ != nullptr) { delete[] data_;}
}
int *data_;
}
从C+11开始,在标准库中已提供对RAII的应用:
- 内存管理智能指针:unique_ptr, shared_ptr, weak_ptr
- 锁管理守卫类:lock_guard, unique_guard, shard_guard
RAII可以说已是C++的基础,它有很多用法,有点像Java的面向切面编程:
- 封装一个日志类,打印其它函数进入与退出日志,在其构建方法中打印函数入口信息,在其析构时打印函数的退出行,返回码等
- 封装一个作用域退出类,析构时调用其它函数或lambda,做一些清理工作
- 封装一个计时器类,在其构造方法中记录开始时间点,在其析构时统计花费的时长
合理地使用智能指针,可以更加方便的管理内存,降低内存泄露的风险,但也要注意他们一些使用约束:
- 不要把一个裸指针给多个智能指针管理,会出现Double Free
- 智能指针get出来裸指针不能delete
- shared_ptr可能存在循环引用,导致内存泄露,需要结合weak_ptr使用
- 不是new出来内存(如内存池申请的内存),需要自定义删除器
- 不建议使用new对象再传给智能指针,而是直接使用make_unqiue与make_shared
- 不能shared_ptr(this),它会Double Free,而是调用shared_from_this()返回shared_ptr
所有权
智能指针早期有一个auto_ptr,auto_ptr是C++98提供的解决方案,C++11已摒弃、C++17已删除。为什么会被摒弃?因为它没有很好地解决所有权的问题,缺点是在转移所有权后会使运行期不安全。
什么是所有权?所有权(Ownership):有一个决定其生命周期的唯一的所有者(owner)。对于一个特定的对象A,只有另一个对象B对它持有所有权,那在对象B释放时,则可以在它的生命周期结束时,也对它管理的对象进行释放,这样就不会出现内存泄露与Double Free。这个道理非常简单,映射到现实世界,一个物体,只有一个Owner才不会出现纠纷,比如你家的房产。
C++规范并没有像Rust语言定义所有权,借用Rust语言对所有权的规则,我们同样可以运用到C++中:
- 每一个值都有其所有者变量,如通过unique_ptr,shared_ptr持有
- 同一时间所有者变量只能有一个,如尽可能地使用unique_ptr
- 所有者离开作用域,值被丢弃,如智能指针在析构时释放内存
作为所有者,也有如下权利:
- 控制资源的释放
- 出借所有权,如在其生命周期内,使用Const引用
- 转移所有权,如使用move语义
为什么要转移所有权?一个变量指向的对象值总会函数内的表达式中进行操作再赋值给其它变量,在函数间传递。为了性能,期望传递的变量指向的内存区域给其它变量,如果这块内存区域比较大,复制内存数据到给新的变量则开销很大,所以需要把所有权转移给新的Owner,同时当前的变量放弃所有权。
C++真是太复杂,什么都交给你控制,搞了很多语法:
- 拷贝语义:拷贝构造,拷贝赋值
- 移动语义:移动构造,移动赋值
有如下代码,再结合左值右值,是不是会直接把人搞晕,有点孔乙己回字有几种写法的感觉:
class A {
public:
explict A(int n);
A(const A &that); // 拷贝构造
A &operator=(const A &that); // 拷贝赋值
// 传递右值引用,转移右值中对象所有权
A(A&& that); // 移动构造
A &operator=(A&& that); // 移动赋值
// 传递左值引用,也可以实现移动语义,对that对象中数据修改,但非常不建议使用如下
// A(A &that); //重载非const版本,可以移动构造
// A &operator=(A &that); //重载非const版本,可以移动赋值
private:
int n_;
int* p_;
}
A a1(4); // 调用A::A(int n)
A b1(a1); // 调用A::A(const A &that),若实现A::A(A &that),则会是调用它
b1 = a1; // 调用A::operator=(const A &that)
A c1(std::move(a1)) // 调用A::A(A&& that)
c1 = std::move(a1); // 调用A::operator=(A&& that)
小结:
- 拷贝构造或拷贝赋值,对于成员变量指针内容,可以是浅拷贝,也可以是深拷贝
- 深拷贝涉及到对象的分配与释放,如果是浅拷贝,则指针的拥有者存在多个,管理变得更复杂
- 移动语义目的是把对象的所有权移交,不需要再构造与析构
- 移动语义必有使用引用,而不是指针或普通值
- 非const引用传递左值,则函数内部可以修改目标数据对象
- 为了区分左值引用,实现移动语义必须采用右值引用
- 移动语义,为了保证能够修改修改目标数据对象,实现函数是必须将右值引用当作左值引用使用
对所有权,拷贝语义,移动语义有了初步的了解,再回头看auto_ptr的问题,它实现如下:
auto_ptr(auto_ptr &__a);
auto_ptr &operator=(auto_ptr &__a);
auto_ptr采用拷贝语义的函数来转移指针资源,转移指针资源的所有权的同时将原指针置为NULL,这跟通常理解的拷贝行为是不一致的。在C++98时,没有右值引用移动语义,只好在拷贝构造与赋值中实现移动语义。使用auto_ptr不当容易造成内存问题,所以不建议作为函数的返回值和函数的参数,也不建议在容器中保存auto_ptr,一旦当拷贝却移动了,指针为NULL了。
在C++11中,支持右值以及移动语义,此时可以完全匹配auto_ptr的所有权管理,但不能单纯的为该auto_ptr加入右值和移动语义的支持,因为还是禁不掉已在拷贝时发生了移动的事实。所以C++11只能搞一个新的unique_ptr,它不仅加入了移动语义的支持,同时也关闭了左值拷贝构造和左值赋值功能。但也没有完善的方案,填坑的同时,也挖了个大坑,unique_ptr加入vector中要小心,没有拷贝语义不符合容器Copy Constructible要求,把已有的一个unique_ptr装进vector需要使用move()。
std::unique_ptr<A> p = std::make_unique<A>();
std::vector<std::unique_ptr<A>> v;
v.push_back(std::make_unique<A>());
v.push_back(std::move(p));
v.emplace_back(new A);
没有了拷贝语义,同样对于普通函数传递unique_ptr,也只能采用引用方式:
void func(std::unique_ptr<A> &ptr) { }
RVO
RVO是Return Value Optimization的缩写,即返回值优化,NRVO是Named Return Value Optimization的缩写,即具名返回值优化。RVO是C++在处理一个函数返回类对象并将返回值赋给另一个对象时,为了减少拷贝构造次数以及析构次数而采用的一种编译器优化技术。NRVO是RVO一个变种,起初RVO技术仅支持匿名变量的优化,后期才支持NRVO。返回值优化此特征在C++11已写入标准,不过在一些编译器早就支持了。
A getA() {
return A(); // NVO
}
A getAWhitName() {
A a;
return a; // NRVO
}
有了RVO与NRVO,对于返回对象,没有必要在函数中new出对象,通过指针返回,或者是通过指针引用出参传递。减少祼指针的使用,可以降低内存泄露的机会。
A *getA() {
return new A;
}
int get(A *&out_ptr) {
out_ptr = new A;
return EXIT_SUCCESS;
}
C++11引入移动语义之后,对NRO稍有一些变化,总结如下:
- 应该按照正常写法返回Local变量,通过RVO或NRVO优化
- 编译器会决定要么使用RVO,要么使用移动语义来优化返回语句
- 如果使用条件语句返回,会抑制编译器使用RVO
- 不要将返回的值包装在另一个函数中返回,而应该仅仅返回local变量
- 如果要返回一个local变量内的成员变量,需要显式的使用move
- 如果使用移动语义,需要返回的类型有转移构造,否则只会进行复制
- RVO效率比使用转移构造要高, 转移构造也是一样需要对象创建
A getAWithMove() {
A a;
return std::move(a); // 非必要加move
}
std::unique_ptr<A> getAWhitUniquePtr() {
return std::make_unique<A>(param1, param2); // unique_ptr支持move
}
解题
前面对RAII,所有权,智能指针,移动语义,以及RVO点到为止,没有展开来一一深入讲解,大家可以借助搜索来深入学习。提到这些知识点,只为了更好理解与使用智能指针,达到减少内存泄露目标作为铺垫。现在C++在语义上已经可以实现半自动化的内存回收,unique_ptr+move:
- 内部实现尽可能不要使用祼指针,而是使用智能指针管理对象
- 存在指针成员变量对象尽可能不要支持拷贝,尤其是浅拷贝祼指针
- unique_ptr是"独占"所指对象,更符合所有权要求,能使用unique_ptr就不要使用shared_ptr
- unique_ptr可以做为返回值,可以引用传递参数,可以是类的成员变量
- 尽可能不要显示调用unique_ptr的release与get方法
- 要使用move来移动unique_ptr所有权
好了,该是结束时候了,布置课后作业,如果要使用unique_ptr+move,前面三个案例的代码你会怎么改呢?