《C++面向对象高效编程(第2版)》——4.5 对象复制的语义

    xiaoxiao2024-07-06  109

    本节书摘来自异步社区出版社《C++面向对象高效编程(第2版)》一书中的第4章,第4.5节,作者: 【美】Kayshav Dattatri,更多章节内容可以访问云栖社区“异步社区”公众号查看。

    4.5 对象复制的语义

    C++面向对象高效编程(第2版)复制对象是OOP中的一个很普通的操作。既然在我们的世界中,一切皆是对象,我们肯定会遇到需要某个对象的多个副本的情况。

    如第3章所述,在许多不同的情况中都需要复制对象。例如,当按值传递(和按值返回)参数给函数时,就需要制作对象的副本。当函数被调用时,复制操作由语言(编译器)发起,这是一个隐式进行的操作。

    当然,对象的副本也可由程序员通过声明显式创建。我们可以编写如下代码:

    TPerson p(“John Doe”, “Anytown”, 618552567, “12-22-78”); TPerson q = p;``` 此时,q是p的副本。换言之,q由p创建而来。 复制对象不是一项简单的操作。因为对象不是基本类型,它们是带有关键状态信息的复杂实体。进一步而言,对象中可能包含其他对象,而这些其他对象又可能包含另外的对象,诸如此类。如何复制这些对象?当然,语言可以执行复制操作,但可能并不满足实现者的要求。实现者可能希望在复制期间共享对象内部的某些对象,而编译器无法进行这样的逻辑判断,这就是为什么需要程序员干预的原因。 复制对象不是OOP领域的新问题。不同的语言遵循不同的复制对象方案。接下来,我们介绍C++中的情况,并和其他语言进行对比。 ###4.5.1 简单复制操作的语义 复制对象的一种简单的方法是,不管数据成员的类型,只复制数据成员的值。只需将源对象(source object)数据成员中包含的值复制到目的对象(destination object)相应的数据成员中1。我们再次用TPerson类举例说明。

    void foo(TPerson thePerson)   // foo函数按值接受一个TPerson参数{   // 此处代码不重要,已略去。}main(){   TPerson bar(“Foo Bar”, “Unknown”, 414235056, “6-6-99”);   // ...   foo(bar); //调用以对象“bar”为参数的foo函数}`调用foo()时,将制作对象bar的副本。在C++中,用复制构造函数完成这样的复制操作,即通过现有对象制作一个该对象的副本。我们在TPerson类中尚未实现复制构造函数,所以,编译器会生成默认复制构造函数(default copy constructor):`TPerson::TPerson(const TPerson& source)`该复制构造函数将执行数据成员的逐个成员复制(memberwise copy)。复制构造函数中的代码类似图4-8。

    TPerson::TPerson(const TPerson& source)   : _birthDate(source._birthDate), // 调用TDate的复制构造函数   _name(source._name), _ssn(source._ssn), _address(source._address) { }``` 源对象中的每个成员只是被盲目地复制到目的对象中,这称为浅复制(shallow copy)。如图4-8所示,相应的元素被复制。就像复制整数那样,复制_name数据成员(包含地址234876)中的地址。除了_name和_address数据成员外,这种复制不会有问题。对于TDate类对象,将调用TDate的复制构造函数来制作_birthDate对象的副本。当要离开foo()函数时,局部对象thePerson即将离开作用域,因此语言将通过该对象调用它的析构函数。该析构函数将释放_name和_address所指向的内存。但是,当返回至main()时,源对象bar仍在使用,而且它的数据成员_name仍持有已被析构函数删除的内存地址。现在,bar中的_name(以及_address) ![image](https://yqfile.alicdn.com/455951ec0a419fa132b833fe13c18bd857a99edd.png) 图4-8 便成为悬挂引用。这是使用默认构造函数(或浅复制)存在的问题。 注意: 术语浅复制和深复制(稍后讨论)源自Smalltalk,这些术语通常用于描述复制语义。一般而言,深复制操作意味着递归地复制整个对象,而浅复制则意味着在复制对象的过程中,源对象和副本之间只共享状态。 如果对象不包含任何指针和引用,浅复制完全满足需要。如下所示,TPoint2D类代表2维图形系统中的1个点:

    class TPoint2D {  public:    TPoint2D(double x = 0.0, double y = 0.0);    DistanceTo(const TPoint2D& otherPoint);    // 复制构造函数和赋值操作符省略 – 由编译器提供    // ... 其他细节省略  private:    double _xcoordinate;    double _ycoordinate;};`编译器生成的简单复制构造函数可以完全满足需要。该类的对象包含两个双精度数,只需复制它们的值即可。

    回到TPerson类,我们并不希望复制_name和_address数据成员的值,因为它们是指针。我们希望为它们所指向的内容分配足够的内存,然后复制那些内容。在复制操作完成后,源对象和目的对象之间不会共享任何东西。这就是深复制。

    Smalltalk:

    Smalltalk为所有类都提供了两种复制对象的方法shallowCopy和deepCopy。在Smalltalk系统中,所有对象都可以使用这些方法。shallowCopy方法创建一个新对象,但该对象与原始对象共享状态;deepCopy方法复制对象及其状态,而且这种复制将为对象内所包含的所有对象进行递归复制。因此,deepCopy的结果是生成一个与源对象完全一样,但互相独立的对象,生成的对象与源对象不共享任何东西。在Smalltalk中,每个类都获得copy方法,而且copy的默认实现就是shallowCopy。需要组合使用shallowCopy和deepCopy的类(很多情况都需要这样)应该实现自己的copy方法。Smalltalk按值传递对象的语义和C++中传递引用几乎等价。然而需要注意的是,一些最新的Smalltalk实现(如VisualWorks)使用了略为不同的复制方案。

    Eiffel:

    Eiffel在复制对象时,遵循组合的引用—值(reference-value)语义。复制对象的方法称为Clone(),在默认情况下,所有类都可以使用(与Create类似)。该成员函数对基本类型(如整数和字符)进行真正地复制,即复制它们的值。然而,如果原始对象包含对另一个对象的引用,则只复制引用,而不是复制被引用的对象,这与浅复制十分类似。在本书其他章节提到过,在Eiffel中,对象只能包含基本类型或对其他对象的引用。对象不能按值包含另一个对象它只能包含对其他对象的引用2(为了方便共享)。如果类的实现者需要一个不同的复制语义,必须在类中为其他成员函数也提供相应的复制语义。Eiffel并不真正支持按值调用。在Eiffel中,调用函数(或过程)将把形参与实参相关联,这和C++中的引用类似。但不同的是,被调函数不能直接修改实参。换言之,对于任何arg参数,被调函数都不能修改它的值。如果arg是一个基本类型参数,则被调函数不能修改arg的值(它所引用的内容);如果arg是类类型,则被调函数不能将arg与新对象相关联,或将其与void引用。但是,Eiffel允许函数通过arg调用方法来修改arg。(不能把这种策略和C++的const成员函数混淆。在任何情况下,无论是直接还是间接,都不能修改const成员函数的对象。)鉴于Eiffel的这个特性,函数只可通过预定义的保留名称Result,才能向主调函数返回值,Result可以在函数内部(而不是在过程中)使用。

    理解复制对象的另一种方法是将一个对象想象为树的根节点。该对象(节点)可以包含任意数量的对其他对象(节点)的引用(在C++中,也包括任意数量的指向其他对象(节点)的指针)。当我们以树的形式描绘对象图时,它是一棵非常大(深)的树。深复

    图4-9

    制从树的根节点开始,然后递归遍历整棵树,复制每一个节点。复制操作结束后,我们获得一棵新树,它和原树完全一样。而浅复制只能复制根节点,其他所有节点在原树和副本树之间共享,复制的结果只是一棵带共享节点的树(见图4-9)。

    现在,我们来看看具有深复制语义的复制构造函数的实现:

    TPerson::TPerson(const TPerson& source)   // 初始化适当的数据成员   : _ssn(source._ssn),  // 社会安全号码    _birthDate(source._birthDate) // ① 参见图4-10 {     // 如果不是NULL,复制_name和_address。     // 需要检查源是否包含非零指针,然后为其分配内存和复制数据。     // 我们已为此编写好Strdup()(参见p.131),现在可以直接使用它。    this->_name = source._name ? Strdup(source._name) : 0;     // 以类似方式再次复制_address    this->_address = source._address ? Strdup(source._address) : 0; }``` 复制的语义和语言设计 在语言中,复制对象的语义和语言的设计原则密切相关。C++是一种基于值的语言,它采用统一的方式处理对象和基本类型。进行复制时,它把一切都当作值,仅复制值。另外,语言本身无法决定指针和引用用法的语义。这些决定权掌握在实现者手中,他们应该提供复制所需要的所有额外功能。除此之外,实现者还可以提供浅复制和深复制的组合语义。而语言则通过允许实现者提供复制构造函数,来实现各种复制的语义。 Smalltalk是一种基于引用的语言,因此复制对象非常容易。该语言同样以统一的方式处理所有的对象。在Smalltalk中,一切都是对象,因此非常容易遵循统一原则。与C++相比,Smalltalk在复制方面提供更多的功能,它为每个类都提供浅复制和深复制。以上提到的这些功能都很容易完成,因为Smalltalk是基于引用的语言。而且,无用单元收集也内置在Smalltalk系统中。 Eiffel以不同的方式对待基本类型和对象。在该语言中,对象只能包含对其他对象的引用。与Smalltalk类似,无用单元收集是语言本身的一部分。这就是为什么Eiffel只支持浅复制的原因。唯一的缺点是,需要为定义深复制语义的方法另外命名,Clone本身不能修改或覆盖。 ![image](https://yqfile.alicdn.com/64d0cabaee22d189ec148156eda8d4d2553bd32d.png) 图4-10 由于目的对象的_name和_address数据成员都未指向任何资源(因为它们刚由编译器创建),因此我们要为其分配动态内存,并复制所有字符。注意,这里将调用TDate的复制构造函数用于复制_birthDate对象。TPerson类并不知道如何复制TDate类对象,这应该由TDate的复制构造函数负责。在p.140的代码中,①通过_birthDate对象调用了复制构造函数(见图4-10)。 记住: 总而言之,当类包含任何指针、引用或其他对象时,使用默认复制操作都很不安全。不要依赖编译器生成的复制构造函数,应当编写自己的复制构造函数,以提供正确的复制操作。 1这就是在C中复制结构的方法。 2除非类明确指出它需要基于值的对象,而不是引用。
    最新回复(0)