谈谈RAII惯用法-例子讨论
转发自公众号文章《谈谈RAII惯用法-例子讨论》
下面讨论一个使用RAII惯用法的具体例子,假设有一个类safety_data:
class safety_data {
int id;
string name;
};
它有两个数据成员id和name,分别是int型和string类型,该类有invariant要求,要求id和name必须是一一对应的,即它们之间的对应关系不能被破坏,读写它们必须是一个原子操作,不能发生只更新了其中一个时,就被别的线程读取的情况。
因此,可以考虑使用原子类来包装它:atomic<safe_data>,这样,在读写操作时能够保证id和name作为一个整体同时操作进行,这是原子操作,显然是线程安全的。但是atomic原子类不支持拷贝构造操作,在实际应用中,如果safe_data对象有许多复制操作的场合,使用起来并不方便。因此,没有使用原子类来包装它,而是由程序员来实现它的线程安全性。
因为id和name必须作为一个整体进行原子操作,就在类中定义了一个类型为mutex的数据成员mtx,来负责id和name的读写时进行互斥保护。同时还提供了更新它们值的modify()成员函数,在modify()成员函数中使用了lock_guard按照RAII机制对id和name的写操作进行了保护。
初步定义如下:
class safety_data {
int id;
string name;
mutex mtx;
public:
void modify(int id, string name) {
lock_guard<mutex> guard(mtx);
this->id = id;
this->name = move(name);
}
...// 其它成员
};
因为safety_data对象可以进行拷贝操作,当调用拷贝构造函数来复制对象时,需要对源对象的数据成员id和name的读操作进行保护,定义了拷贝构造函数的实现:
safety_data::safety_data(const safety_data &source) {
lock_guard<mutex> guard(source.mtx);
this->id = source.id;
this->name = source.name;
}
同样使用了RAII机制对源对象(source)进行保护,这样,在使用source作为源对象进行拷贝构造时,如果别的线程同时调用它的modify()成员函数进行更新操作,就会因为申请不到锁而被阻塞,保证了它的id和name之间的一致性不会被破坏。
我们可以看出,实现拷贝构造函数时,数据成员id和name的初始化都是在函数体内进行的,也就是说它们都是在进入构造函数体之前先进行缺省构造,然后再在函数体内使用赋值操作进行初始化。对于id成员来说,它是int类型,因为是基本类型的数据,这点开销并不大,而name成员,它是string类型,有的库在实现缺省构造时可能需要更多的耗时操作。
因此,一般对于像string类型的“大对象”来说,在构造时可能申请了内存资源,后来对它赋值时可能又需要释放旧资源并申请新资源,显然这些缺省构造的操作就属于无用功了。
可见,虽然上述拷贝构造函数的实现保证了线程安全,但是对于string类型的name成员来说,是先构造再赋值,有不必要的性能开销,为了优化初始化性能,一般提倡数据成员都在初始化列表中初始化。
因此,实现拷贝构造函数时,把数据成员id和name都放在初始化列表中初始化,那么拷贝构造函数就变成了:
safety_data::safety_data(const safety_data &source)
: id(source.id), name(source.name) {
// 只能保护函数体内的代码
lock_guard<mutex> guard(source.mtx);
}
于是,又出现了另一个问题,函数体内创建的RAII对象guard保护谁呢?谁也没有保护!guard的作用域在函数体内,而id和name的初始化发生在调用构造函数之前,可见初始化过程并不在guard的保护范围之内。因此,在id和name初始化时,如果有别的线程正在调用源对象source的modify()成员函数,可能会发生data race,导致id和name之间的一致性遭到破坏。
数据成员如果放在拷贝构造函数体内初始化,性能不好,如果放在初始化列表中初始化,却又无法保证线程安全。有没有两全其美的办法,既保证线程安全又保证初始化的性能?
下面就讨论一下方案:
想要保证对象拷贝构造时的性能,就必须把数据成员的初始化操作放在拷贝构造函数的初始化列表中进行:
safety_data::safety_data(const safety_data &source) :
id(source.id), name(source.name) {
}
那么,实现方案的关键点就放在如何使用互斥量mtx进行保护了。
最常见的办法是为safety_data类提供新的成员函数接口,可以对mtx成员进行加锁和解锁操作:
void safety_data::lock() {
mtx.lock();
}
void safety_data::unlock() {
mtx.unlock();
}
那么当进行拷贝对象时,在调用拷贝构造函数的前后位置,分别调用lock和unlock函数:
void scenario(safe_data &source) {
// ...其它代码
source.lock();
safe_data dup(source);
source.unlock();
// ...其它代码
}
虽然能够解决问题,但同时也为程序运行带来了异常风险:无法保证能够安全地解锁互斥量。如果在加锁和解锁之间,程序运行抛出了异常,就无法释放锁了,因此还必须要编写处理异常的代码逻辑,以防止把释放锁的操作丢失了。
因此,应该考虑使用RAII机制来自动管理加锁和解锁的操作,那就为safe_data类添加一个接口,用来返回互斥量成员mtx。即:
mutex &safety_data::mutex() {
return mtx;
}
使用RAII类std::lock_guard来管理互斥锁资源,因此,上述场景修改为:
void scenario0(safe_data &source) {
// ...其它代码
lock_guard guard(source.mutex());
safe_data dup(source);
// ...其它代码
}
RAII对象guard使用source的mtx成员对source对象的拷贝构造操作进行保护,不过,guard的生存期太长,可以使用“{}”来减小它的作用域范围:
void scenario1(safe_data &source) {
// ...其它代码
{
lock_guard guard(source.mutex());
safe_data dup(source);
// ...使用dup的代码
}
//...其它不使用dup的代码
}
可是,尽管显式使用“{}”缩小了guard的保护范围,因为它保护的是safe_data的初始化构造过程,就得需要把声明dup也放在“{}”之内,因为
后面还有使用dup的代码,因此,“{}”的作用域范围也就必须扩大到dup不再使用为止。
也就是说,尽管mtx仅用来保护拷贝构造函数的调用,按说创建完dup对象后也就无需再保护了,但是为了能够让dup对象离开作用域后不被销毁,guard保护的临界区却不得不延长到不再使用dup对象为止。RAII对象guard的生存期不得不和dup的生存期一样长,扩大了互斥锁的锁定范围,会导致锁不再使用时没有及时释放,显然这种方案缺陷很大。
那就想办法减少“{}”作用域的范围,首先,在safe_data类中添加缺省构造函数:
safety_data() = default;
应用场景修改为:
void scenario2(safe_data &source) {
// ...其它代码
safe_data dup; // 先缺省构造
{
lock_guard guard(source.mutex());
dup = safe_data(source); // 再赋值
}
// ...其它代码
}
显然这是一个初始化+赋值的方案:先缺省构造dup,然后在临界区{}作用域内创建一个safe_data临时对象,再把临时对象赋值给dup。在拷贝赋值操作时,参数source是在guard保护的临界区中访问的,保证了它的线程安全。虽然此时guard对象的作用域范围缩小了,但是dup对象的初始化要分两步走:先缺省构造,再拷贝赋值,也有额外的性能开销,并且safe_data必须具有缺省构造函数才可以。
虽然强行让dup和RAII对象guard的作用域分开了,仍然有多余的缺省初始化操作,为了去掉多余的初始化操作,可以让拷贝构造的副本dup为指针类型:
// 改值类型为指针类型,从堆中分配
void scenario3(safe_data &source) {
// ...其它代码
safe_data *dup = nullptr;
{
lock_guard guard(source.mutex());
dup = new safe_data(source);
}
// ...使用dup的代码
delete dup;
// ... 其它不使用dup的代码
}
dup指向的对象是在堆中创建的,程序中出现了内存资源,也就有了新的资源管理要求,既然是内存资源,那就按照惯例,使用RAII机制来管理在堆中创建的safe_data对象,显然要使用智能指针:
void scenario4(safe_data &source) {
// ...其它代码
unique_ptr<safe_data> dup(nullptr); // 先缺省构造
{
lock_guard guard(source.mutex());
dup.reset(new safe_data(source)); // 再设置
}
// ...其它代码
}
unique_ptr对象的缺省构造的开销并不大,如同裸指针赋值nullptr的开销。该方案让dup和RAII对象guard的作用域分开了,没有了多余的缺省初始化操作,不足之处是必须在堆中创建对象。
上述任何方案,都需要用户付出实现RAII机制的代码编写成本,在每个调用safe_data类拷贝构造函数的地方,都要使用相同的套路和重复的代码,有大量雷同的代码,增加了创建对象副本的代码编写成本。
那么,能否把管理互斥锁的RAII过程封装起来,实现一个线程安全的safe_data拷贝构造函数,让用户直接调用拷贝构造函数,来减轻编程时的心智负担呢?
既然是拷贝构造时的问题,属于创建对象的范畴,那就考虑使用创建型的设计模式来解决问题了,要控制对象的拷贝构造过程,那就把这个过程封装起来,设计一个工厂方法来实现这个拷贝构造功能。如下所示:
class safety_data {
private:
safety_data(const safety_data &source)
: id(source.id), name(source.name) {
}
public:
// 静态工厂方法函数
static safety_data clone(const safety_data &source) {
lock_guard guard(source.mtx);
return safety_data(source);
}
...
};
首先把拷贝构造函数设置为private访问属性,这样用户就无法直接调用拷贝构造函数来创建对象了,其次,定义了一个静态工厂方法函数clone(),它调用这个私有safety_data的拷贝构造函数来创建对象。
在工厂方法函数中,使用RAII机制来管理mtx互斥量的操作,再也不用担心因为发生意外导致互斥量无法解锁的错误,lock_guard保护的临界区也恰到好处,范围没有扩大。因为拷贝构造函数是private修饰的,用户无法直接调用,只能通过工厂方法函数创建,封闭了线程不安全的途径。
在创建对象副本时,编写代码也非常简单,直接调用工厂方法就行了:
void scenario5(safe_data &source) {
// ...其它代码
safe_data dup = safety_data::clone(source);
// ...其它代码
}
但是使用工厂方法也有不足,那就是safety_data类型在模板中的使用,如果使用模板参数来拷贝构造一个副本时,则无法直接使用safety_data的拷贝构造函数,也就无法使用统一的模板代码,只能针对safety_data类型编写一个特化模板类型,显然带来了不便或者无法使用safety_data作为模板参数。
此外,为了防止用户误用拷贝构造函数,它被使用private限制访问了,因此在使用这个工厂方法时,只能用于高于C++17版本,它支持copy elision优化,返回时不需要拷贝构造语义,如果是在C++17以下版本,则无法编译,此时只能把静态工厂方法的返回值改成safety_data指针类型,不再举例说明。
尽管上述方案都能解决问题,但是它们或多或少的都存在不足之处,谈不上完美。其实,如果仔细分析safe_data类的拷贝构造函数的实现,会发现它的初始化列表就是临界区中的代码,但初始化列表却不属于任何函数体内的代码,因此也就无法直接使用“{}”来指定RAII对象的作用域。显然这符合本系列文章中介绍的匿名对象表达式RAII的应用场景,可以使用前面文章中介绍的方法来实现拷贝构造函数,也就有了更为优雅的解决方案。
方案1,函数的匿名RAII对象参数
class safety_data {
...
safety_data(const safety_data &source, const lock_guard<mutex> &)
: id(source.id), name(source.name) {
}
public:
safety_data(const safety_data &source)
: safety_data(source, lock_guard(source.mtx)) {
}
};
即把id和name的构造过程封装在一个委托构造函数内,并添加一个const lock_guard &类型的哑参数,然后让拷贝构造函数在初始化列表中调用这个新封装的委托构造函数。根据前文介绍的知识,lock_guard(source.mtx)是匿名RAII对象,它保护了整个委托构造函数的执行过程,即初始化列表代码:
safety_data(source, lock_guard(source.mtx))
相当于:
lock_guard guard(source.mtx);
safety_data(source, guard);
guard.~lock_guard();
可见,通过利用临时对象的表达式生存期机制,实现了使用mutex的RAII机制保护初始化列表代码的目的。
当需要一个safe_data对象副本时,可以直接调用拷贝构造函数:
void scenario6(safe_data &source) {
// ...其它代码
safe_data dup(source);
// ...其它代码
}
方案2,逗号表达式+匿名RAII对象
class safety_data {
...
public:
safety_data(const safety_data &source)
: name((lock_guard(source.mtx), id = source.id, source.name)) {
}
};
id是在逗号表达式中进行初始化,而name是使用初始化列表的方式进行初始化。
根据前文介绍的知识,lock_guard(source.mtx)是一个匿名RAII对象,它保护了初始化列表的执行过程,source.id和source.name的访问都在互斥锁source.mtx的保护下,保证了对它们的原子读操作,即初始化列表代码:
name((lock_guard(source.mtx), id = source.id, source.name))
相当于:
lock_guard guard(source.mtx);
id = source.id;
name = source.name;
guard.~lock_guard();
可见,通过利用临时对象的表达式生存期机制,实现了使用mutex的RAII机制保护初始化列表代码的目的。
当需要一个safe_data对象副本时,直接调用拷贝构造函数:
void scenario7(safe_data &source) {
// ...其它代码
safe_data dup(source);
// ...其它代码
}
显然,上面两个方案使用了匿名对象表达式RAII机制,即使无法在初始化列表中显式使用{}来指定RAII对象的作用域,也把RAII对象的生存期限制在了构造函数的初始化列表中,并且在创建对象副本时,可以在任何场合直接调用拷贝构造函数,用户完全无感,不需要编写任何额外的代码。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)