《C++面向对象高效编程(第2版)》——4.10 “写时复制”的概念

4.10 “写时复制”的概念

C++面向对象高效编程(第2版)
通过以上的讨论可知,TString类相当易懂和易实现。如果经常使用该类的对象作为函数参数和按值返回的值,会出现什么情况?因为TString类使用了深复制语义,如果TString

图4-12

类对象中的字符数目很多,将花费很长的时间来复制字符和删除动态分配内存。这也意味着,创建对象和销毁对象的开销很大。我们设计TString类的初衷,就是希望客户在使用字符串的地方,都能使用TString类对象。但是,如果创建、复制、赋值和销毁这些对象的开销太大,难免客户避而远之。是否有办法可以优化实现,加快对象的复制速度?

的确,复制TString类对象时,也要复制对象中的所有字符。但是,这样做太浪费时间。我们可以尝试修改实现,使其在建立多个TString类对象副本时,让这些副本都共享原始字符串中的字符,并不真正复制它们。我们了解过如何实现这样的共享。重要的是,当某个副本企图修改(或甚至销毁)对象中的字符时,共享机制必须确保该副本(TString类对象)获得一份自己的字符副本,而不会影响其他仍然共享字符的对象。例如(为理解以下代码,见图4-12)。

TString one(“ABCDEFG”)
TString two(one);  // 进行复制
TString three;
three = one;```
现在,如果three对象企图通过如下代码修改它的字符:

`three.ToLower();  // 将字符改为小写`
而其他的对象(one和two)不会受到影响。最终,我们应该得到图4-13所示的结果。

如果我们可以确保如上所述的条件,便可达到加速复制的目标。在企图修改时才进行真正的复制,这样方案就是写时复制(copy-on-write)。这项原则已经在软件工程中使用了很长时间,特别是在系统软件中1。它的基本含义是:在对资源进行写入之前,资源(在该例中就是字符)是共享的。当某共享资源的对象试图在资源中写入时,就制作一个副本。

图4-13

这个概念要求具备三个基本条件:

(1)共享资源不应该导致太多额外的成本(内存和CPU)。

(2)应该能清楚地识别并控制所有可以修改资源的途径。

(3)设计的实现必须在任何情况下都能追踪共享资源的对象数目。

第一个条件意味着,为了共享字符串中的字符,我们不能过度地增加每个对象的大小。而且,为此实现的代码也不能太复杂。换言之,应该以最小的代价完成共享。否则,共享的开销便抵销了它的优势。

第二个条件意味着,共享资源的对象只能通过定义良好的途径(或函数)来修改共享资源。我们的实现必须能识别所有的这些途径,并完成正确的工作。

第三个条件建议,为进行正确的操作,在任何时候,设计的实现都必须确切地知道有多少个对象正在共享资源。如果无法满足此条件,会出现无用单元或悬挂引用的问题。毫无疑问,在多线程环境中,也必须满足以上所有条件。

正确执行“写时复制”语义的关键就是从实现中分离接口。当复制TString类对象时,它必须共享原始对象中已存在的实现,而不应该创建一个新的字符集。再者,实现必须记住共享实现(或资源)的对象数目,这称为引用计数(reference count)。它是对资源引用的数目,而且在任何时候都必须保持正确。鉴于此,这种方案有时也称为引用计数机制(reference counting mechanism)。但是,引用计数并不意味着“写时复制”。实际上,在后面的章节中我们将介绍在使用引用计数时,并未进行“写时复制”的情况。此外,引用计数也被称为使用计数(use count)。

接下来,我们将字符的实现(和存储区)移动至TString类内的StringRep嵌套结构中。在C++中,嵌套类并不意味着它的对象就是嵌套对象,只是在类的声明处反映其嵌套性质。进一步而言,StringRep的名称只能在TString类内部可见。以下是新的TString类声明:

include

include

include

include

class TString {
 public:
    // 构造函数
  TString();  // 创建一个空字符串对象
    // 创建一个字符串对象,该对象包含指向字符的s指针。
    // s所指向的字符串必须以NULL结尾,通过s复制字符。
  TString(const char* s);  
  TString(char aChar);  // 创建一个包含单个字符aChar的字符串
  TString(const TString& arg);  // 复制构造函数
  ~TString();  // 析构函数
   // 赋值操作符
  TString& operator=(const TString& arg);
   // 返回指向内部数据的指针,小心。
  const char* c_str() const { return _rp->_str; }
   // 这些方法将修改原始对象,将其他对象的字符附在 this后。
   // 在字符串中改变字符的情况
  TString& ToLower();  // 将大写字符转换成小写
  TString& ToUpper();  // 将小写字符转换成大写
    // 其他成员函数未显示
 private:
  struct StringRep {
   char
_str;  // 实际的字符
   unsigned _refCount;  // 对它引用的数目
   unsigned _length;   // 字符串中的字符数目
 };
 StringRep* _rp;  // 在TString中唯一的数据成员
};```
// 其他非成员函数未作改动--此处未显示
每个TString类对象都包含指向StringRep对象的指针。在复制TString类对象时,只需复制_rp指针,就这么简单。实际上,也可以将StrginRep设计成一个带有构造函数和析构函数的真正独立的类。但是在该例中,不用这样做。我们需要的只是一个字符指针和引用计数的占位符。参见图4-14理解以下代码:

TString one(“ABCDEFG”);
TString two(one);  // 进行复制
TString three;
three = one;```

图4-14

现在,我们来看看它的实现有何不同:

TString::TString()
{
 _rp = new StringRep;
 _rp->_refCount = 1;
 _rp->_length = 0;
 _rp->_str = 0;
}
TString::TString(const char* s)
{
 _rp = new StringRep;
 _rp->_refCount = 1;   // 这是使用StringRep的唯一对象
 _rp->_length = strlen(s);
 _rp->_str = new char[_rp->_length + 1];
 strcpy (_rp->_str, s);
}
TString::TString(char aChar)
{
 _rp = new StringRep;
 _rp->_length = 1;
 _rp->_str = new char[_rp->_length + 1];
 _rp->_str[0] = aChar;
 _rp->_str[1] = 0;
 _rp->_refCount = 1;  // 这是使用StringRep的唯一对象
}
TString::TString(const TString& other)
{
// 这是最重要的操作之一。
// 我们需要在other中,通过_rp所指向的对象递增引用计数。它又获得一个引用。
 other._rp->_refCount++;
  // 让它们共享资源
 this->_rp = other._rp;
}
TString& TString::operator=(const TString& other)
{
 if (this == &other)
  return * this;  // 自我赋值
/* 这是另一个重要的操作。我们需要在other中,通过_rp所指向的对象递增引用计数。
同时,需要通过“this”指向的对象递减引用计数。 /
other._rp->_refCount++;  // 它又获得一个引用
// 递减和测试,是否仍然在使用它?
if (--this->_rp->_refCount == 0) {
  delete [] this->_rp->_str;
  delete this->_rp;
}
this->_rp = other._rp; // 让它们共享资源
return * this;
}
// 这是一个重要的成员函数,需要应用“写时复制”方案
TString& TString::ToLower()
{
 char
p;
 if (_rp->_refCount > 1) {
  // 这是最困难的部分。分离TString 对象并提供它的StringRep对象。
  // 这是“写时复制”操作。
  unsigned len = this->_rp->_length; // 保存它
  p = new char[len + 1];
  strcpy(p, this->_rp->_str);
  this->_rp->_refCount--; // 因为 *this即将离开内存池
  this->_rp = new StringRep;
  this->_rp->_refCount = 1;
  this->_rp->_length = len;
  this->_rp->_str = p;  // p在前面已创建
}
// 继续,并改变字符
p = this->_rp->_str;
if (p != 0) {
  while (*p) {
   p = tolower(p); ++p;
  }
}
 return * this;
}
TString& TString::ToUpper() // 留给读者作为练习
{
 return *this;
}
TString::~TString()
{
 if (--_rp->_refCount == 0) {
   delete [] _rp->_str;
delete _rp;
}
}```
// 已省略其他成员函数的实现
下面的代码用于说明赋值操作符如何工作(见图4-15):

TString x(“1234ABCDXYZ”);
TString y(x);  // 通过x复制构造y
TString a (“PQRS”);
TString b(a);
a = x;````
到目前为止,这些操作是最重要的,因为它们控制着对象的复制。现在,为了完善代码,来看看ToLower成员函数的实现效果(见图4-16)。

假设有如下的代码:

TString x(“1234ABCXYZ”);
TString y(x);
TString z = x;
z.ToLower();```
现在,分析一下TString类的析构函数。当TString类对象离开作用域后,如果不再使用_rp所指向的内存,必须将其删除。否则,我们只是减少了引用计数的值,并未清理内存就匆忙前进。

线程安全可移植性:

必须记住,在以上讨论的示例中,所有修改_refCount数据成员的地方,都不是多线程安全的操作。在需要多线程安全的情况中,必须保证这样的递增和递减操作是多线程安全的。方法是:使用操作系统特定的同步工具(甚至是在汇编语言例程中);或者,由一个不同的类(将在下一章中介绍)来处理这种针对处理器的操作,而且客户必须使用这个类。重要的是识别线程安全,如何实现它只是细节问题。

思考:

在上面的代码中,很多地方都需要创建、删除和操控StringRep对象。很明显,这并不是最好的方法。尝试修改实现,以便StringRep有自己的构造函数、析构函数以及其他函数。这样,StringRep便可自我管理。另外,完成TString类的实现。

4.10.1 何时使用引用计数

共享资源是大多数应用程序中十分常见的功能,在需要共享资源(无论是否有“写时复制”)时,使用引用计数是一种整洁的方案。引用计数促使实现更高效、更简洁,而且

图4-15

使应用程序运行得更快。引用计数为客户分担了资源管理的负担,并让其成为实现的一部分(这是正确的处理方法)。

4.10.2 “写时复制”小结

上面使用的引用计数方案有一些与众不同的特点。

TSring类对象负责处理StringRep对象。可以把StringRep对象看成主对象(master object),它拥有存储区和引用计数。实际上,客户并不知道内部如何完成所有的工作,因为“写时复制”方案保证了她不会受到任何影响。客户总会认为自己拥有了TString类对象副本,其实真正的实现远比这复杂得多。“写时复制”这个概念,在禁止高开销复制、需要更高效复制操作的地方非常有用。

图4-16

在不适合使用“写时复制”方案(因为主对象并不允许复制),但却需要共享的地方,我们将使用无“写时复制”的引用计数语义。在所有情况中,都必须考虑是否允许客户修改主对象。我们将在后面的章节中介绍更多相关的示例。

1这频繁用于操作系统中进程之间的页面共享。mach微处理器将该原则用于虚拟内存系统。UNIX系统通过调用vfork()也是为了相同的目的。
本文仅用于学习和交流目的,不代表异步社区观点。非商业转载请注明作译者、出处,并保留本文的原始链接。

时间: 2024-09-26 10:47:10

《C++面向对象高效编程(第2版)》——4.10 “写时复制”的概念的相关文章

《C++面向对象高效编程(第2版)》——导读

前言 C++面向对象高效编程(第2版) 面向对象软件开发已逐渐成为开发软件的首选.优秀的面向对象软件开发人员.设计人员.系统架构师对其需求与日俱增.要想成为一名成功的面向对象编程(OOP)人员必须忘却(摈弃)多年来面向程序编程的习惯,从新的角度分析问题. 面向对象编程要求程序员和设计者非常熟悉一些基本范式或概念.理解这些范式是在面向对象软件领域打下牢固基础的基本要求.支持OOP的语言都必须支持这些基本范式.换言之,学习OOP,简单地说,就是学习许多语言(如C++,Eiffel,SmallTalk

《C++面向对象高效编程(第2版)》——4.2 无用单元收集问题

4.2 无用单元收集问题 C++面向对象高效编程(第2版) 在我们讨论无用单元收集1(garbage collection)之前,先了解一下何为无用单元(garbage),何为悬挂引用(dangling reference). 4.2.1 无用单元 所谓无用单元(garbage),是一块存储区(或资源),该存储区虽然是程序(或进程)的一部分,但是在程序中却不可再对其引用.按照C++的规定,我们可以说,无用单元是程序中没有指针指向的某些资源.以下是一个示例: main() { char* p =

《C++面向对象高效编程(第2版)》——4.6 对象赋值的语义

4.6 对象赋值的语义 C++面向对象高效编程(第2版) 赋值与复制的操作非常类似.在C++中,绝大多数的复制操作都由语言隐式调用(当对象按值传递或按值返回时).当通过现有对象创建新对象时,也进行了复制操作(但不是很频繁).与复制相反的是,赋值是必须由程序员显式调用的操作.然而,在Eiffel和Smalltalk中,赋值和复制操作都由程序员显式调用.这也是基于值的语言与基于引用的语言之间的区别. 在C++中,对于对象和基本类型赋值都具有相同的含义.把基本类型变量赋值给另一个(兼容的)基本类型变量

《C++面向对象高效编程(第2版)》——2.21 确保抽象的可靠性——类不变式和断言

2.21 确保抽象的可靠性--类不变式和断言 C++面向对象高效编程(第2版) 任何抽象都必须与客户履行它的契约(contract).当客户使用类时,他希望类的对象像其发布描述的那样运行正常.另一方面,类的实现者必须千方百计地确保对象运行正常.但是,类只有在客户履行自己那部分契约后,才能正确行使它的职责.例如,类的成员函数可能要求传入的参数为非零指针(non-zero pointer).只有满足此前提条件,成员函数才能保证它的行为.因此,客户必须履行一些义务.换言之,如果客户履行了她那部分契约,

《C++面向对象高效编程(第2版)》——2.30 has-a关系的重要性

2.30 has-a关系的重要性 C++面向对象高效编程(第2版) "has-a"关系(也称为关联.聚集.包含.组合)是在OOD(面向对象设计)中频繁使用的重要关系.但是,许多设计者和程序员都没有很好地理解其相关性,从而导致复杂僵化的设计和不必要的继承. 在OOD阶段,软件的复用主要通过两种方式完成:继承和包含.它们各有优缺点.优秀的设计者了解它们的局限性.优点和代价,可以灵活自如地应用它们.继承将在第5章.第6章以及第二部分的第12章中详细讨论. 包含是一项强大的设计技术,它比继承更

《C++面向对象高效编程(第2版)》——2.27 关联

2.27 关联 C++面向对象高效编程(第2版) 关联表示对象与不同类之间的结构关系(structual relationship),大多数关联都是二元关系(binary relation).类之间的多重关联(multiple association)和类本身的自关联(self association)都是合法的(见图2-19). 关联可以有一个名称,表明阅读方向的箭头为可选.注意,方向箭头为可选,但关联名必须显示.关联在不同的方向可以有不同的名称,但是,大多数情况下,没必要注明(特别是在已标出

《C++面向对象高效编程(第2版)》——3.2 类要素的细节

3.2 类要素的细节 C++面向对象高效编程(第2版) 3.2.1 访问区域 客户可以访问在类的public区域中声明的任何成员.我们可以把该区域看做是通用公共(general public)的接口,它没有任何保护,是类限制最少的区域.一个设计良好的类绝不会将数据成员包含在public区域,该区域只能包含成员函数.如果在public区域包含数据成员,那么无需类的实现者,仅通过编译器即可访问这些数据成员.这违反了数据抽象和封装原则.这也是我们为什么总将数据成员放在private或protected

《C++面向对象高效编程(第2版)》——第2章 什么是数据抽象

第2章 什么是数据抽象 C++面向对象高效编程(第2版) 面向对象编程的一项基本任务是创建带有适当功能的类,并隐藏不必要的细节(即抽象数据).下面,我们将用一个现实生活中的例子来解释数据抽象的概念. 绝大多数人都见过影碟播放机(laser disc player)(或LD播放机).现在,提出一个简单的问题:设计一个影碟播放机,要求易于使用和修改,可后续添加更多有用的功能. 注意: 如果难以理解影碟播放机,可以用CD播放机代替LD播放机,其设计原理类似.实际上,影碟播放机的功能是CD播放机功能的超

《C++面向对象高效编程(第2版)》——1.5 什么可以作为类

1.5 什么可以作为类 C++面向对象高效编程(第2版) 用简单的例子详细讨论类和对象非常容易,但是难点在于如何为给定的问题找出合适的类.我们必须理解类代表什么,何时将问题中的某些部分转化为类,而非数据,反之亦然.根据我们的定义,类拥有一组对象的共同属性(或者特性).怎样的共同才是共同?何时说这是一个类,而不是另一个类的对象?这些都是我们在学习OOP时会遇到的,和真正关心的问题. 当我们决定创建一个类时,第一个问题就是"是否确实需要这个类的多个实例?",如果答案为"是&quo