谈谈RAII惯用法-匿名对象表达式RAII(上)
转发公众号文章:
《谈谈RAII惯用法-匿名对象表达式RAII(上)》
前面介绍的几种场景所用到的RAII对象都是具名的,下面介绍RAII对象是匿名对象的应用场景。
我们知道,在C++中,匿名对象都是临时对象,而且它们是在栈上创建的,是自动对象,也是属于自动管理生存期的。但是它们有自己独有的自动生存期管理,那就是full-expression生存期管理,也叫表达式生存期,意思是当临时对象所在的完整表达式全部执行结束后,临时对象的生存期也就结束了。
下面看几个产生临时对象的场景:
string make(); //工厂方法,返回值对象
void consume(const string&);//常量引用参数
void temporary() {
make(); // 返回值对象没有接收,产生一个匿名对象
string("42"); // 创建一个匿名对象,没有对象名
consume(string("24")); // 传递匿名对象
}
编译器编译完后,等同于下面的代码:
void temporary() {
string &tmp = make();
tmp.~string(); //返回后立即销毁
string tmp("42");
tmp.~string(); //创建后立即销毁
const string &tmp = string("24");
consume(tmp);
tmp.~string(); // 函数调用结束后立即销毁
}
这段程序在运行过程中产生的匿名对象都不是在遇到“}”符号或者函数返回时再销毁,而是所在的表达式运行结束后它们就立即销毁了,也就是它们的生存期只有一行代码,代码运行完即可销毁。
因此,如果RAII对象是匿名对象的话,它的生存期就非常短,若使用不当可能会发生错误,比如:
// mtx是一个互斥量mutex对象
void foo() {
lock_guard<mutex>{mtx};
...
}
因为语句lock_guard{mtx}创建的对象没有名称,是一个匿名对象,创建后就立即销毁了,也就是还没有等到foo()函数运行结束就销毁了,显然没有起到锁保护的作用。
可见,不同于具名的自动对象,它们是在程序到达“}”离开作用域时,或者离开if/swicth语句块时(如果使用带初始化的变量的特性),或者遇到流程跳转时,才对它进行析构处理,而匿名的自动对象,即临时对象是在所在的完整表达式运行结束时,就对它进行析构处理,显然这也是确定性的行为,因此也可以完全由编译器进行处理。
注意,临时对象具有全表达式生存期,不妨看一个例子:
string long_life = foo(make_shared<int>(42), string("abc"), vector<int>{1,2,3});
这是一个函赋值语句,foo()是一个函数,参数类型是shared_ptr、string和vector,返回类型是string。在调用它时,传递了三个匿名对象作为参数,那么它们的生存期不是分别在创建shared_ptr、string、vector对象并传递后立即销毁,foo()调用完后返回的匿名对象也不是立即被销毁,而是在整个函数调用执行完,long_life被赋值之后才销毁它们,也就是编译完之后相当于下面的代码段(为了便于说明,假设编译器按照它们出现的先后顺序进行构造对象,函数的返回值也没有进行copy-ellision优化,即foo()返回的匿名对象没有被优化掉):
shared_ptr<int> tmp0 = make_shared(42);
string tmp1("abc");
vector<int> tmp2{1,2,3,4};
// 函数返回临时对象tmp3
string tmp3 = foo(move(tmp0), move(tmp1), move(tmp2));
// 临时对象tmp3作为参数构造局部变量long_life
string long_life(move(tmp3));
// 销毁所有临时对象
tmp3.~string();
tmp2.~vector<int>();
tmp1.~string();
tmp0.~shared_ptr();
...
// long_life离开作用域时被销毁
long_life.~string();
即整个赋值表达式全部执行结束之后,所有在运行这个表达式时产生的临时对象才被销毁。
如果RAII对象是匿名对象的话,在某些场合会很有用,能够解决一些C++语法不支持的情况,比如使用“{}”限定作用域不方便的场合,或者根本就无法使用“{}”限定作用域的场合。
下面介绍两种匿名RAII对象的应用场景:
1、函数匿名RAII对象参数
函数调用时,如果实参是一个临时对象,它的生存期涵盖了整个函数的调用过程,包括返回值的接收。
假设有一个函数,原型为:string foo(string&),调用它要保证线程安全,下面的代码片段是通过斥量mtx使用RAII机制来保证线程安全:
{
lock_guard<mutex> guard(mtx);
string str = foo(str_obj);
// ...其它后续代码
}
即在调用foo()的外围函数体中,使用一个{}作用域来控制lock_guard自动对象guard的生存期。
如果我们把函数原型改成:string foo(string&, const lock_guard&)。因为常量引用类型的参数可以接收匿名对象作为参数,那么,对于函数调用语句:string str = foo(str_obj, lock_guard(mtx)),根据前面介绍的完整表达式生存期,匿名RAII对象lock_guard(mtx)在foo()函数调用前创建,在调用结束并且将返回值赋值给str后就被销毁,也就是该调用过程相当于:
lock_guard<mutex> tmp(mtx);
// guard是个哑元参数
string str = foo(str_obj, tmp);
tmp.~lock_guard();
也就是说,虽然是一个单行的函数调用,但是它所起的作用是把整个函数的调用过程,以及返回结果的赋值操作,都在互斥量mtx的保护范围内,如同把它们都放在了一个“{}”作用域内一样。只使用一条简单的函数调用语句,就能做到不需要在外围使用“{}”来指定作用域,这样对象str的生存期也就不再受“{}”作用域的限制了,这是它的特殊之处。
实际上,这种应用方式,在编程实践中并不常用,更常见的方式是上面说的使用外围“{}”来指定作用域,或者把mutex的保护放在在函数体内:
string foo(string &str) {
lock_guard<mutex> guard(mtx);
...// 其它代码
}
不过,这种应用方式可以让RAII对象应用于无法在函数体内执行的代码:既然代码无法在函数体内执行,也就意味着无法使用“{}”来限定作用域,或者让RAII对象应用于不便使用“{}”的场合。
比如类构造函数的初始化列表,它就不在任何函数中执行:
template<typename T, typename U>
class widget {
T x;
U y;
public:
widget(const T &xx, const U &yy)
: x(xx), y(yy) {}
};
假设类型T和U有自己的拷贝构造函数,初始化列表“x(xx), y(yy)”要对x和y进行拷贝构造。我们知道,初始化列表“x(xx), y(yy)”不是在一个函数体内执行的,如果要对它使用mutex互斥锁进行保护。
显然,首先,无法在初始化列表中使用mutex创建lock_guard对象;其次,也无法使用“{}”把它围起来指定作用域,也就是无法使用常规的RAII应用方式,此时可以考虑上面介绍的方案了。
封装一个委托构造函数,它带有一个哑元参数:
widget(const T &xx, const U &yy, const lock_guard<mutex>&)
: x(xx), y(yy) {}
原构造函数修改为调用委托构造函数:
widget(const T &xx, const U &yy)
: widget(xx, yy, lock_guard<mutex>(mtx)) {}
根据前面的分析,在初始化列表中调用委托构造函数时:
widget(xx, yy, lock_guard<mutex>(mtx)) {}
相当于:
lock_guard<mutex> tmp(mtx)
widget(xx, yy, tmp);
tmp.~lock_guard();
显然,保证了调用委托构造函数的线程安全。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)