当前位置: 首页 > news >正文

巧用临时对象之五

“巧用临时对象”系列文章已经写了四篇了,如果总结它们解决问题的规律的话,除了第二篇之外,其余各篇基本上都是“代理模式+临时对象”的套路模式。把调用相关接口的返回值封装成一个代理对象,以临时对象的形式被创建出来,并且用户不可见,它所做的事情不外乎是下面中的几点:

1、重载类型转换操作符,负责把自己转成目标类型。
2、重载赋值操作操作符"=“,负责把目标类型转成自己。
3、重载重载”->“、”*"等操作符,负责访问被代理的对象结构成员。

然后根据这些操作符的特点和作用来实现不同的应用目的。不过,在实践中最好不要使用auto定义变量来接收那个代理对象,如果类型转换时,直接让一个具体类型的变量来接收临时代理对象,如果是调用其它操作符时如“->”,则直接调用,这样代理对象就会自动进行类型转换或者调用其它操作符,因为代理对象是匿名的临时对象,用户也就意识不到是在和一个代理对象打交道,实现的代码看起来也非常干净利索。

在日常编程实践中,如果遇到类似的问题,不妨按照上面的思路来思考一下方案。下面我们按照这样的思路看看是如何解决问题的:判断一个接口返回引用类型的值之后是用于读还是写操作的。

我们知道,在Java语言中提供了动态数组的写时拷贝操作,用于多线程环境读多写少的场景,即Java并发包中的CopyOnWriteArrayList类,用户直接使用就行了。而在C++标准库中没有提供线程安全的容器类,一些要求线程安全的容器类都得需要用户自己实现,那么,如果我们要实现一个写时拷贝的vector类,应该怎么做呢?

首先了解一下写时拷贝的实现原理:有一个全局指针指向一个vector对象,当进行读操作时,先定义一个本地局部指针初始化为全局指针,通过这个局部指针来调用实现读操作的成员函数就可以了;如果是写操作,先创建一个新vector对象,使用一个新指针指向它,然后把原vector对象中的数据全部复制到这个新vector对象中,再进行相关的写操作,写操作完成之后,最后把全局指针的值更新为新指针的值。这样,如果后续再有读者进行读操作时,通过这个已经更新的全局指针来访问vector就可以了,同样,如果后面再有新的写操作,继续创建一个新的vector对象并进行更新,然后让全局指针指向它。可见,这样的操作流程,读者和写者互不影响,只有写者之间需要使用互斥锁进行保护。

考虑到C++不像Java那样有内存垃圾回收,因此,当写操作完成后,使用新指针更新全局指针时,要保证全局指针指向的vector能够正常释放,否则会造成内存泄漏;此外,因为每个读者在读操作时都在占有这个全局指针指向的vector对象,不能简单的在更新全局指针时,直接把旧的全局指针delete掉,否则会发生悬挂指针。因此,这里使用共享型的智能指针shared_ptr让各个读者使用,因为shared_ptr对象在赋值时并不是原子操作,还得需要借助于atomic类型来操作这个shared_ptr对象。

因此,vector写时拷贝类的数据成员定义如下:

template<typename T>
class cow_vector {atomic<shared_ptr<vector<T>>> sptr; // 全局指针,读写时需要原子操作mutable mutex mtx; // 用于写操作之间的互斥保护
};

接下来封装成员函数就简单了,按照vector中的成员函数一一对照进行封装就行了。vector的成员函数不外乎是“增删改查”这四类操作接口,前三种属于写操作,最后一种是读操作,分析出每一个成员函数是用于读还是写,然后按照前面介绍的读写操作流程实现就行了。如下:

“增加”操作,比如push_back()成员函数:

    void cow_vector::push_back(const T& value) {lock_guard lg(mtx); // 写操作之间要互斥auto tmp = sptr.load(); // 获取全局指针vector<T> *ptr = new vector<T>(*tmp); // 分配新对象ptr->push_back(value);  // 写入新值tmp.reset(ptr); // 更新指针指向新vector对象sptr.store(tmp);  // 更新全局指针}

“删除”操作,比如clear()成员函数:

    void cow_vector::clear() {lock_guard lg(mtx);auto tmp = sptr.load();vector<T> *ptr = new vector<T>();tmp.reset(ptr);sptr.store(tmp);}

“修改”操作,比如assign()成员函数:

    void cow_vector::assign( size_t count, const T& value ) {lock_guard lg(mtx);auto tmp = sptr.load();vector<T> *ptr = new vector<T>(*tmp);m->assign(count, value);tmp.reset(ptr);sptr.store(tmp);}

“查找”操作,即读操作,比如at()成员函数,它有两个重载版本:

    // 返回const引用类型,只可用于读操作const T &cow_vector::at(size_t n) {auto tmp = sptr.load();return tmp->at(n);}// 返回的是非const引用类型T &cow_vector::at(size_t n) {auto tmp = sptr.load();return tmp->at(n);}

仿佛一切顺利。慢着!这里的第二个at()接口返回的类型可是引用类型T &,意味着这个接口不光可以用于读取元素,如:

int item  = vec_obj.at(0);

也可以用于更新元素,如:

vec_obj.at(0) = 42;

显然,at()接口是属于“读写型”的接口:既可以用于读操作,也可以用于写操作。因此,上面的实现是不正确的,如果用于更新元素的场景,会和别的线程在读写操作时发生data race,出现错误。

怎么处理这些“读写”类型的接口呢?既然有写的可能,那就悲观一点,假设调用at()时都是用于写操作,按照写操作的流程来处理:

    T &cow_vector::at(size_t n) {lock_guard lg(mtx);auto tmp = sptr.load();vector<T> *ptr = new vector<T>(*tmp);T &r = ptr->at(n);tmp.reset(ptr);sptr.store(tmp);return r;}

显然是不合理的。首先,读操作会和写操作竞争互斥量m,写操作之间不但需要竞争m,还要和读操作之间竞争m,增加了互斥量的竞争激烈程度;更为严重的是,每次读操作,都要进行一次拷贝,违背了初衷。本来是为读多写少的场合实现的,现在即使是读操作也要复制一个副本,相当于每次读操作先要创建一个快照,然后在这个快照上面读取,但并不进行任何修改,全是无用功,写时拷贝也就没了意义,与其这样,还不如直接使用读写锁甚至使用互斥锁呢,直接在原vector上面进行读写操作,起码不用无条件的复制副本了。

还有一种思路是,提供新的成员函数,把读和写分开分别独立进行。比如定义一个新的成员函数专门用于写操作:void cow_vector::assign_at( size_t n, T value ),但是这样,它的对外接口就和vector不一致了,接口不兼容,用户使用时就有学习成本,使用起来不方便,不能参照vector的接口形式进行使用。此外,如果用作模板类型参数时,因为接口不和vector兼容,需要特化专门的模板实例。

应该根据at()的最终用途来决定是使用写操作的流程还是读操作的流程实现。可是at()函数在调用时并不知道它的用途,而是在调用结束后之后才知道返回的值用作读还是写。怎么办呢?计算机科学中的每个问题都可以用一间接层解决。那就是借助于第三方中间层,也就是考虑一个中间代理对象来负责判断是用于读还是写的处理。

先分析一下at()接口用于读和写场景的代码片段:

  int item = vec_obj.at(4);     读操作vec_obj.at(4) = 42;            写操作

当at()用于读操作场景时,它处在赋值操作符的右边,当用于写操作场景时,处于赋值操作符的左边。如果能够解决问题的话,间接层的着眼点应该是在赋值操作符“=”上面,定义一个对象来代理这个赋值过程。

我们知道在C++中,赋值操作符所起的作用是:1、把相同类型的一个值传给另一个值,不需要类型转换;2、类型转换,即把一个不同的值转换成目标对象类型的值。显然在这儿应该是第2种情况的,是进行类型转换。

转换操作可以由源对象端负责,即"=“右边的操作数,也就是源对象端重载类型转换操作符,转换类型就是目的对象类型;转换操作也可以由目的对象端负责,即”="左边的操作数,也就是目的对象端重载赋值操作符“=”,参数类型是源对象端类型。显然,这里能进行人工控制的只有at()成员函数,那就把它返回的值封装成一个对象,即代理对象,当它最后用在读操作场景时,把它作为源操作数进行类型转换,此时应该重载一个类型转换操作符,负责把它转出去;当最后用在写操作场景时,那就重载一个赋值操作符,负责把赋值操作符“=”右边的值转换为代理对象,负责转进来。因此,方案就很明了了,让at()返回一个代理对象,该代理对象重载了赋值操作符“=”和类型转换操作符,在at()用于读操作时把它的值“转出去”,用于写操作时把一个值“转进来”。巧的是在前面“巧用临时对象”系列文章中,第1篇和第4篇文章正好介绍的是类型的“转进来”和“转出去”,如果感兴趣不妨阅读一下它们。

中间层代理类实现如下:

struct proxy {size_t n; // 写操作时,在哪个位置上T &value; // vector读写接口读到的原始值mutex &mtx; // 用于写操作时的互斥保护atomic<shared_ptr<vector<T>>> &atomic_sptr; proxy(atomic<shared_ptr<vector<T>>> &sptr, mutex &m, T& t, size_t n=-1): atomic_sptr(sptr), mtx(m), value(t), n(n) {}// read 类型转出去是读操作operator const T&() const { // 返回const引用,只能读操作puts("only read");return value;}// write 类型转进来是写操作void operator=(T value) {lock_guard guard(mtx);shared_ptr<vector<T>> data = atomic_sptr.load();data = std::make_shared<std::vector<int>>(*data);data->operator[](n) = std::move(value);atomic_sptr.store(data);puts("only write");}
};

此外,这个转换过程还应该不留痕迹,即应该是隐式类型转换,要让用户意识不到是一个处于中间层的代理对象悄悄地进行了类型转换,以为只是一个普通的赋值语句,这样还得要借助于临时对象。临时对象是没有名字的匿名对象,对用户不可见,虽然存活期很短,但是在这存活期间它也要担起它的职责,由它控制进行类型转换。因此proxy类最好用户不可见,不要用它创建具名对象,在调用cow_vector的可用于读写操作的成员函数时,也不要使用auto自动推导类型进行接收,而是使用明确的类型来接收返回值,这样这个proxy对象就以临时对象的形式出现,在赋值操作完之后就自动析构了。

因此,vector涉及到的读写操作都有可能的成员函数实现如下:

    proxy cow_vector::operator[](size_t n) {auto tmp = sptr.load();return proxy(sptr, mtx, tmp->operator[](n), n); // 使用读取到的值创建代理对象}proxy cow_vector::at(size_t n) {auto tmp = sptr.load();return proxy(sptr, mtx, tmp->at(n), n); // 使用读取到的值创建代理对象}...其它读写都有可能的操作接口

对于下面的代码:

    int &y = cow[0];cow[0] = 42;

对于int &y = cow[0];,调用cow[0]时返回代理对象proxy,当赋值给int
&y时,因为类型不一样,proxy对象要进行隐式类型转换,调用 proxy::operator const int &() const接口,它直接返回所保存的数据。

对于cow[0] = 42;,调用cow[0]时返回代理对象proxy,当42赋值给它时,因为proxy重载了赋值操作符“=”,可以把一个int类型的值赋值给proxy,调用 void proxy::operator=(int value)接口,按照写操作的流程把参数赋值给proxy所保存的数据。

在调用cow[0]时,是读操作,也就是无需进行加锁保护,只有在proxy对象判断出所读出的引用值要被修改时,才进入写操作的流程,也就是说这是一个乐观的算法,在调用接口时先假定读出的值仅用于读操作,直到给它赋值时才转为写操作的流程,进行加锁保护。

代理对象在这里是匿名临时对象,目的就是让赋值操作在形式上和普通的赋值操作没有区别,看不到中间过程,如果是具名的代理对象,这个赋值操作在形式上就得必须通过这个代理对象中转,和原始的vector接口操作使用方式就不一致了。它的生存期很短,当赋值语句结束时它就自动销毁了,它的使命就是负责把vector读写接口的值保存起来,等到后面进行读或者写的时候,进行相应的类型转换,让外界读取或者更新这个值。

另外,也可以孤零零的调用cow[0],显然它读出0位置处的元素之后,什么也没有做,创建的临时对象紧接着就销毁了,没有任何影响。


http://www.mrgr.cn/news/96839.html

相关文章:

  • element-ui自制树形穿梭框
  • C++ 编程指南33 - 使用模板来表达适用于多种参数类型的算法
  • 基于React + Antd + Java的OFD文件上传预览实现方案(OFD文件转图片)
  • 对象和面向对象三大特征:封装
  • 黑马 C++ 学习笔记
  • Android 10.0 通过广播控制systemui状态栏动态显示和隐藏功能实现
  • Java 递归全解析:从原理到优化的实战指南
  • 持续集成与Jenkins安装使用教程
  • 模拟集成电路设计与仿真 : Mismatch
  • cmake(11):list 选项 排序 SORT,定义宏 add_definitions,cmake 里预定义的 8 个宏
  • 二叉树 —— 数据结构基础刷题路程
  • Linux内核中ARP协议的实现与dev_addr字段的作用
  • 基于Python的医院信息管理系统的设计与实现
  • Windows家庭版如何开启Hyper-V与关闭Hyper-V
  • 山东大学《多核平台下的并行计算》实验笔记
  • 相机的曝光和增益
  • Linux中的权限管理(附加详细实验示例)
  • JavaFX基础- Button 的基本使用
  • 基于 docker 的 LLaMA-Factory 全流程部署指南
  • Kubernetes 入门篇之Master节点部署与安装