谈谈RAII惯用法-匿名对象表达式RAII(下)
转发公众号文章:《谈谈RAII惯用法-匿名对象表达式RAII(下)》
2、逗号表达式+匿名RAII对象
如果逗号表达式中的第一个子表达式的值是一个临时对象,它的生存期涵盖了整个逗号表达式的执行过程。
结合前面的例子,假设函数的第一个参数不是string引用类型,而是string值类型,即原型:
string foo(string, const lock_guard<mutex>&);
根据前面的介绍,lock_guard匿名对象保护的是函数体中的代码和返回值赋值操作的线程安全。这里因为第一个参数类型是string值类型,在传参时需要调用string类的拷贝构造函数,假设实参对象shared_str_obj是一个共享对象,要求传参时保证它的安全,那么,在使用它作为参数调用foo函数时:
foo(shared_str_obj, lock_guard<mutex>(mtx));
就无法保证这个shared_str_obj参数对象的拷贝构造操作,也是在mutex锁的保护下了。
因为在调用函数传递参数时,无法保证两个参数的构造函数的调用顺序,不同编译器有不同的顺序:如果lock_guard匿名对象是最后创建的,显然它只能保护foo()的函数体的执行和返回赋值操作是线程安全的,无法保证在传递参数时,shared_str_obj对象的拷贝构造过程也是线程安全的。
这种场合可以使用逗号表达式来保证执行顺序,通过匿名RAII对象来保护整个逗号表达式。在C++中,逗号表达式的语义是由左到右依次执行每个表达式,并把最后一个表达式的值作为整个逗号表达式的值返回。例如逗号表达式:y= (lock_guard(mtx), x=42, “abc”),该表达式的执行过程是:先创建一个lock_guard对象,然后初始化x变量为42,最后返回"abc"为变量y赋值。
该表达式值得注意的地方是,lock_guard对象是一个临时对象,根据前面的介绍,临时对象在“完整表达式”结束时被销毁,即逗号表达式的y的赋值语句运行完毕之后,lock_guard临时对象就会被销毁。那么,该赋值语句加上隐含的临时对象的销毁操作,它的执行顺序就是:先创建lock_guard临时对象(同时对mtx加锁),接着初始化x,然后返回"abc",接着变量y赋值为“abc”,最后销毁lock_guard临时对象(同时释放mtx锁)。即相当于下面的代码逻辑:
lock_guard<mutex> tmp(mtx);
x = 42;
y = "abc";
tmp.~lock_guard();
可见,这个过程实际上是在互斥锁mtx的保护下进行的,而lock_guard创建的临时对象是在“完整表达式(full expression)”结束时销毁,即变量y被赋值完毕之后才销毁,此时对互斥量mtx解锁。
不过需要注意,逗号表达式的优先级很低,在使用时,通常需要使用“()”把逗号表达式括起来。因此上面foo()函数的调用语句可以这样实现:
string str = foo((lock_guard<mutex>(mtx), shared_str_obj));
相当于:
lock_guard<mutex> tmp(mtx);
string str = foo(shared_str_obj);
tmp.~lock_guard();
这样,就把shared_str_obj的拷贝构造、foo()函数的执行过程以及返回值赋值给str的操作,都在mtx互斥量的保护下,即临界区扩大到了实参对象的传递操作,保证了整个赋值表达式是线程安全的,并且str也不再受“{}”作用域的限制了。
按照C++语义,逗号表达式只能返回最后一个表达式的值。如果一个函数有多个参数时,比如:
void foo(string str_obj1, string str_obj2);
就无法按照上面的方法,使用逗号表达式了,此时可以考虑使用一个数据结构,比如std::tuple来封装它们,把多个参数合并成一个参数:
void foo(tuple<string, string> str_obj);
// 调用时
foo((lock_guard<mutex>(mtx), tuple(str_obj1, str_obj2)));
当然,这种逗号表达式传递参数的应用方式,在编程实践中也并不常用,因为更常见的方式是使用外围“{}”来指定作用域:
{
lock_guard<mutex> guard(mtx);
foo(str_obj);
}
或者:
{
lock_guard<mutex> guard(mtx);
foo(str_obj1, str_obj2);
}
同样,这种应用方式可以让RAII对象应用于无法在函数体内执行的代码,或者让RAII对象应用于不便使用“{}”的场合。
看一个例子:
假设有一个类widget,使用工厂方法创建它的一个对象时,一个线程间共享变量shared_obj是函数实参,在函数体内访问它时,需要保证它的线程安全。下面是代码片段:
widget make(shared shared_obj);
mutex mtx;
void test() {
{
//创建obj对象时要求保证参数的线程安全
lock_guard<mutex> guard(mtx);
widget obj = make(shared_obj);
//... 其它用到obj的代码
} // 在此处,销毁guard对象,释放mtx互斥锁
//... 其它不再使用obj的代码
}
显然,互斥量对象mtx保护的范围比较大,尽管使用“{}”缩小了mtx的保护范围,因为保护的是widget的初始化构造过程,就得需要把声明obj也放在“{}”之内,但是后面还有使用obj的代码,因此,“{}”的作用域范围也必须扩大到obj不再使用为止。
也就是说,尽管mtx仅用来保护创建obj对象时用到的实参变量shared_obj,按说创建完obj对象后也就无需再保护了,但是为了不让obj对象离开作用域后被销毁,mtx保护的临界区却不得不延长到使用obj对象的最后一刻为止。
前面分析过,当以逗号表达式+匿名RAII对象的形式来传递函数参数时,接收返回值的对象,可以不受“{}”作用域的限制,因此,我们可以把test()函数修改为:
void test() {
//...创建obj对象时要求保证参数的线程安全
widget obj = make((lock_guard<mutex>(mtx), shared_obj));
//...其它用到obj的逻辑
}
根据前面的分析,make()函数使用逗号表达式来传递参数时,它相当于:
lock_guard<mutex> tmp(mtx)
widget obj = make(shared_obj);
tmp.~lock_guard();
显然,保证了shared_obj参数传递时的线程安全。
同原方案相比,既保证了shared_obj共享变量的线程安全,又最小化了RAII对象lock_guard的生存期,因为不再使用{}来控制作用域,它的生存期不再和对象obj的生存期重叠,而且代码实现也非常简洁。
总之,从上面介绍的两种场景,可以见得,基于匿名对象的表达式RAII,编译器能够在匿名对象失去生存期的时候,会自动生成调用析构函数的代码,也是确定性的行为。但是在什么地方以及怎样创建匿名对象,也就是控制匿名对象何时销毁(因为匿名对象所在的完整达式结束后就销毁),却是程序员自己负责的事情,完全由程序员自己控制,这是不同于前面介绍的两种具名RAII对象的应用场景。
当无法使用“{}”来指定作用域,或者显式利用“{}”时无法高效的处理RAII对象的生存期管理时,可以考虑使用匿名RAII对象,在这种场合往往能够让RAII对象的生存期达到最小化。
因为RAII对象都是匿名的临时对象,也就没有变量来标记它,因此也就无法在程序中访问这个RAII对象,可见,它的使用方式仅限于RAII对象的构造和析构的自动调用,如使用lock_guard管理mutex的加锁和解锁操作。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)