转发公众号文章:谈谈RAII惯用法-具名对象表达式RAII

从本篇开始介绍一些特殊场景的RAII应用方式。

所谓表达式RAII,是指RAII对象是在一个表达式语句中创建的,不同于上篇文章中介绍的通过作用域{}来管理RAII对象的生存期,它们有自己独特的生存期管理,在某些场景下为程序代码实现带来了方便性。
先看一个例子:

if (条件) {
    raii_ptr raii(42);
    ...其它用到raii的代码
}

在if语句块中用到了raii对象,它在语句块结束的地方,即“}”位置,会自动调用它的析构函数,显然这是RAII机制的最常见的用法。

但是,如果else语句块中也用到了raii对象,或者在if语句的条件表达式中用到了raii对象,raii对象只好在if语句的前面进行创建,即类似下面的代码片段:

raii_ptr raii(42);
if (条件) {
    ...用到raii的代码
} else {
    ...用到raii的代码
}

raii_ptr raii(42);
if (用到raii的条件表达式) {
    ...用到raii的代码
}

尽管raii对象在if语句运行结束后就不再使用了,但是它却要等到离开生存期结束时,比如遇到“}”或者函数返回时才被销毁,此时可能距离if语句已经很远了,显然raii对象没有及时销毁,所管理的资源也就不会及时释放。

因此,程序员为了在raii对象不再使用时,能够尽快地销毁它,就使用{}对raii对象所在的作用域进行了限制:

{
    raii_ptr raii(42);
    if (条件) {
        ...用到raii的代码
    } else {
        ...用到raii的代码
    }
} // A

{
    raii_ptr raii(42);
    if (用到raii的条件表达式) {
        ...用到raii的代码
    }
} // B

这样,就不用等到函数返回,而是在A和B处就对raii对象进行了销毁操作。当然,不足之处是需要程序员手动添加"{}"代码来把raii对象和if语句限定在指定的作用域中,在编程时稍有不便。

C++17标准中提供了带初始化的if和switch语句的新特性,或者说是编译器语法糖,可以利用该特性使用更为简洁的方式来实现上述RAII对象生存期的管理。

1、if语句作用域RAII

先看标准“9.4.1 The if statement”章节中的描述:

An if statement of the form if constexpropt ( init-statement condition
) statement is equivalent to {
init-statement
if constexpropt ( condition ) statement }

And an if statement of the form if constexpropt ( init-statement
condition ) statement else statement is equivalent to {
init-statement
if constexpropt ( condition ) statement else statement }

由此可见,可以在if语句的条件表达式中声明并初始化变量,等同于它是在if语句之前定义的,并且使用“{}”限定了作用域,因此这个变量的生存期:

1、在整个if语句中有效,包括条件表达式部分、then部分和else部分。
2、仅在整个if语句中有效,因为等同于在if语句的外围添加了“{}”。

因此根据该特性,如果把初始化变量换成初始化一个RAII对象,则可以把上述场景改成下面的形式:

void demo(int flag) {
    // ... 其它代码
    if (raii_ptr raii(42); flag > 0) {
        // ...使用raii对象的代码
    } else {
        // ...使用raii对象的代码
    } // C
    // ...其它不使用raii对象的代码
}

直接在if语句的表达式中创建raii对象,这个对象在if-else语句块中都是可见的,并且在C处离开}时,编译器会生成调用析构函数的代码。即RAII的作用域会覆盖所有的选择分支,相当于编译器生成了下面的代码:

void demo(int flag) {
    // ... 其它代码
    raii_ptr raii(42);
    if (flag > 0) {
        // ...使用raii对象的代码
    } else {
        // ...使用raii对象的代码
    }
    raii.~raii_ptr(); // 编译器生成调用析构函数代码
    // ...其它不使用raii对象的代码
}

显然,这种方式更为简洁和方便:编写代码时,不再手动编程使用“{}”来限定raii对象的生存期作用域;阅读代码时,也无需去查找“{”和“}”对应的匹配位置。

2、switch语句作用域RAII

下面是标准在“9.4.2 The switch statement”章节中的相关描述:

A switch statement of the form switch ( init-statement condition )
statement is equivalent to {
init-statement
switch ( condition ) statement }

可见,switch语句也有类似的用法,例如下面的代码:

void demo(int x) {
    // ... 其它代码
    switch (raii_ptr raii(42); x) {
        case 1:
            // ... 使用raii对象的代码
            break;
        case -1:
            // ... 使用raii对象的代码
            break;
        default:
            // ... 使用raii对象的代码
            break;
    }
    // ... 其它不使用raii对象的代码
}

raii对象的作用域会覆盖所有的case选择分支,相当于编译器生成了下面的代码:

void demo(int x) {
    // ... 其它代码
    raii_ptr raii(42);
    switch (x) {
        case 1:
            // ... 使用raii对象的代码
            break;
        case -1:
            // ... 使用raii对象的代码
            break;
        default:
            // ... 使用raii对象的代码
            break;
    }
    raii.~raii_ptr(); // 编译器生成调用析构函数的代码
    // ... 其它不使用raii对象的代码
}

3、循环语句没有这样的特性

我们知道,在下面C++中的for循环语句块中:

for (int i=0, j=0; i<10; i++) {
    // ...可以访问变量i,j
}

变量i和j都是在for循环表达式的初始化部分中定义的,它们也可以在for循环语句块中被访问,而且作用域仅限于for循环的内部,貌似也可以像if和switch那样,在for循环语句块中使用RAII对象。但是,如果有多个变量需要在初始化部分中初始化,它们只能在是相同类型的变量时才可以这样做,RAII对象和循环变量的类型往往是不相同的。因此,如果在for的初始化部分中直接定义RAII对象,会因为与循环变量类型不同而无法编译。

当然,可以使用别的方式来变通一下,比如使用std::pair来封装RAII对象和循环变量:

for (std::pair<raii_ptr, int> p(42, 0); p.second < 10; p.second++) {
    ...其它使用p.first(即raii_ptr对象)的代码
}

显然,这样用起来也并不方便,程序晦涩聱牙,为评审和维护代码带来了心智负担,并不建议这样使用,在实际应用时,应该使用“{}”来限定RAII对象的作用域:

{
    raii_ptr raii(42);
    for (int i=0; i<10; i++) {    
        // ... 其它使用raii对象的代码
    }
}

同样while循环语句,也没有这样的语法特性。如果RAII对象的作用域仅限于while语句块,不妨使用“{}”来限定RAII的作用域:

{
    raii_ptr raii(42);
    while (i++ < 10) {
        // ... 其它使用raii对象的代码
    }
}

4、RAII对象必须是具名对象
最后,需要注意的是,无论是带初始化的if还是switch,初始化的RAII对象都不能是匿名的,它们都必须是具名的。假设在if语句中使用mutex来保护整个if-else语句块,如果这样来创建lock_guard类型的RAII对象:

mutex mtx; // 互斥对象
void demo(int flag) {
    // ... 其它代码
    if (lock_guard(mtx); flag > 0) {
        // ...mtx保护的代码
    } else {
        // ...mtx保护的代码
    } //
    // ...其它不使用raii对象的代码
}

编译器编译后,它相当于是这样的:

void demo(int flag) {
    // ... 其它代码
    lock_guard tmp(mtx);
    tmp.~lock_guard(); // 创建后紧接着就销毁了
    if (flag > 0) {
        // ...其它代码
    } else {
        // ...其它代码
    } //
    // ...其它代码
}

可见,创建的RAII对象,生存期并不在if-else语句块的作用域范围,在程序还没有进入if-else语句块时,它就被销毁了,因此,这里的mutex对象并没有能够保护if-else语句块的线程安全。

不过,这种匿名RAII对象,有着特殊的生存期语义,在一些场合能非常方便的解决一些常规方法不能解决的问题,下一篇文章来介绍。

Logo

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

更多推荐