前面两篇文章分别介绍了临时对象的两种应用场景,一种是把临时对象作为一个临时的数据存放点,存放表示一个对象的信息数据,在需要时使用这些表示信息把目标对象创建出来,实现方式就是重载类型转换操作符,把临时对象转换为目标对象。另一种应用场景是利用临时对象的生存期短、即生即灭的特点,实现表达式RAII,即RAII所管理的资源的有效期,仅限于临时对象所在表达式的运行期间,一旦表达式运行结束,所管理的资源就会被销毁。
本文继续介绍一种临时对象的使用场景,基本上是前面二者特性的综合应用。它也让临时对象存放某种目标对象的表示信息,但是不再重载类型转换操作符了,而是重载了指针访问结构成员的操作符->,通过它可以做更多的逻辑处理,也就是把临时对象封装为一种“类指针”的对象,通过->操作符来访问目标对象的成员。其次,这个临时对象也实现了RAII机制,在构造函数中初始化资源,在析构函数中销毁资源,让->操作符在一个RAII的管理下访问结构成员。
考虑这样一个应用场景,我们知道C++ STL库中的容器类都是非线程安全的,如果用在多线程环境中,需要使用互斥锁保证线程安全。比如访问vector容器的成员函数时:
std::mutex mtx;
vector<int> shared;
void add(int x) {
lock_guard guard(mtx);
shared.push_back(x);
}
int get(int index) {
lock_guard guard(mtx);
return shared[index];
}
显然,每次调用vector的成员函数时,都要使用相同的套路来编写保证线程安全的代码,有没有更好的办法呢?既然vector对象要应用在多线程环境中,则对象肯定会以引用或者指针的形式在多个线程中共享,我们假设共享对象是以指针的形式在各个线程中使用,我们可以通过一个类似shared_ptr的对象来管理这个内存资源。下面封装一个类shared_ptr的管理类:
template<typename T>
class sync_shared_ptr : protected shared_ptr<T> {
public:
class raii {
public:
raii (T *v, mutex *me) : raw(v), m(me) {
puts("lock");
m->lock();
}
T *operator -> () { // ->操作符
return raw;
}
~raii () {
puts("unlock");
m->unlock();
}
private:
mutex *m; // 需要各个raii对象共享
T *raw;
};
sync_shared_ptr(T *p) : shared_ptr<T>(p) {
m = new mutex();
}
// 有指针的行为,重载->操作符
raii operator -> () { // 转换成raii对象
return raii(shared_ptr<T>::get(), m);
}
~sync_shared_ptr() {
if (shared_ptr<T>::use_count() == 1) {
delete m;
}
}
... // 其它拷贝/移动构造/赋值等成员函数
private:
mutex *m; // 类似于shared_ptr的控制块,各个副本的sync_shared_ptr也要共享
};
编写一个测试程序看看:
sync_shared_ptr ssp(new vector<int>());
ssp->push_back(24);
ssp->push_back(42);
根据输出的log信息,可以看到每次调用vector的成员函数时,在调用的开始和结束位置都会有互斥量的加锁和解锁操作。
分析一下工作原理。
每当调用sync_shared_ptr的operator->()操作符时,都会创建并返回一个内部类raii的临时对象,因为这个临时对象也重载了操作符->,所以继续调用它的operator->()操作符,最后返回了sync_shared_ptr所保存的裸指针,该指针指向一个vector对象,使用->操作符可以访问它所指向的vector对象的成员函数。同时又因为sync_shared_ptr类的->操作符调用返回的raii类型对象是临时对象,而临时对象的特点是具有完整表达式生存期,也就是一旦->操作符表达式运行结束,该临时对象就会被销毁。从raii类的实现可以看到,当构造raii时,对互斥量进行加锁操作,当析构时,对互斥量进行解锁操作,因此在调用sync_shared_ptr类的->操作符时,在vector的成员函数调用前后都会有一个加锁和解锁的操作,从而保证对vector的成员函数的调用是在互斥锁的保护下运行。
可见,上述设计符合设计预期要求。不过,因为临时对象生存期是基于完全表达式生存期的语义,如果在一个表达式中同时调用了多个vector的成员函数,会发生多个函数逐个申请锁,导致产生错误。比如,下面的代码片段:
copy(ssp->begin(), ssp->end(), ostream_iterator<int>(cout, "\n"));
ssp->begin()申请到的锁会在copy()算法运行结束时才释放,但是在此期间ssp->end()也要申请锁,前面的锁还没有释放,接着又开始申请同一把锁,会有未定义行为。当然了,可以简单地把mutex换成可重入互斥量recursive_mutex来解决,不过,在已获得锁的情况下,还要再次申请同一个锁,并不是一个好方案。下面介绍一种更为灵活的方案,该方案是来自吴咏炜著作《C++实战》一书中的例子。
template <typename T>
class synchronized_value {
public:
class locked_accessor {
public:
explicit locked_accessor(synchronized_value& obj) : ptr_(&obj) {
ptr_->mtx_.lock();
}
~locked_accessor() {
ptr_->mtx_.unlock();
}
T& operator*() {
return ptr_->value_;
}
T* operator->() {
return &ptr_->value_;
}
locked_accessor(const locked_accessor&) = delete;
locked_accessor& operator=(const locked_accessor&) = delete;
private:
synchronized_value* ptr_;
};
explicit synchronized_value(const T& value) : value_(value){}
explicit synchronized_value(T&& value): value_(std::move(value)){}
locked_accessor locked() {
return locked_accessor(*this);
}
private:
std::mutex mtx_;
T value_;
};
该方案好在既可以用来保护单个操作,也可以用来保护多个操作。例如,保护单个操作时:
synchronized_value<vector<int>> obj(vector<int>(10, 0));
obj.locked()->push_back(24);
obj.locked()->push_back(42);
可见,像前面sync_shared_ptr的例子一样,也是利用了临时变量的表达式生存期来自动管理互斥量的加锁和解锁操作。
如果想加锁执行多个操作,我们只需要把 synchronized_value::locked()的结果保存下来放到一个作用域里用就行,也就是有一个局部变量接收这个返回对象,然后通过它调用->操作符来访问结构成员。例如:
{
auto locked_ptr = obj.locked();
copy(locked_ptr->begin(), locked_ptr->end(), ostream_iterator<int>(cout, "\n"));
}
对象obj调用locked()函数返回的值被赋值给locked_ptr对象,此时就不再是临时对象了,而是一个具名对象,它的生存期是由作用域决定的。当离开作用域时,会被运行时自动进行析构操作,在这里就意味着释放锁,因此这个锁的范围扩大到整个{}作用域。
虽然前面举例的场景都是使用互斥量进行的RAII演示,实际上RAII应用场景并不止如此,只要是在一个函数前面、后面或者前后面都需要调用一个逻辑操作的地方,都可以使用此种机制。下面举一个AOP的例子,在调用一个对象的成员函数时,可以在调用前和调用后添加一些业务代码,比如可以计算函数的调用时间开销。
template<typename T>
class time_aop{
class around { // 内部类,作为RAII角色
public:
around(T *ptr) : ptr(ptr) {
// 记录开始时间,也可以调用提供的before回调函数
start = chrono::system_clock::now();
}
T *operator -> () { // 也提供->操作符
return ptr;
}
~around() {
// 计算结束时间,并打印输出,也可以调用提供的after回调函数
chrono::system_clock::time_point stop = chrono::system_clock::now();
cout << chrono::duration_cast<chrono::microseconds>(stop-start).count() << " us" << endl;
}
private:
T *ptr;
chrono::system_clock::time_point start;
};
public:
time_aop(T *v) : ptr(v) {}
around operator -> () { // 操作符->(),它转换成aound对象
return around(ptr.get());
}
private:
shared_ptr<T> ptr;
};
统计调用vector成员函数的时间开销:
int main() {
time_aop aop(new vector<int>());
aop->push_back(10);
aop->push_back(20);
}
同样原理,当它用在多个操作时,也会存在个别临时对象存活期比其他的要长,导致不准确的地方,如果有这样的需求,可以考虑使用和前面synchronized_value类似的解决方案。