转发公众号文章:《谈谈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的加锁和解锁操作。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐