C++ primer 4 第十三章 复制控制

来源:互联网 发布:mac itunes不显示铃声 编辑:程序博客网 时间:2024/05/22 08:03
 

第十三章 复制控制

         复制构造函数、赋值操作符和析构函数总称为复制控制。编译器自动实现这些操作,但类也可以定义自己的版本。

         当定义一个新类型的时候,需要显式或隐式地指定复制、赋值和撤销该类型的对象时会发生什么——这是通过定义特殊成员:复制构造函数、赋值操作符和析构函数来达到的。如果没有显式定义复制构造函数或赋值操作符,编译器(通常)会为我们定义。

         复制构造函数是一种特殊构造函数,具有单个形参,该形参(常用 const 修饰)是对该类类型的引用。当定义一个新对象并用一个同类型的对象对它进行初始化时,将显式使用复制构造函数。当将该类型的对象传递给函数或函数返回该类型的对象时,将隐式使用复制构造函数。

         析构函数是构造函数的互补:当对象超出作用域或动态分配的对象被删除时,将自动应用析构函数。析构函数可用于释放对象时构造或在对象的生命期中所获取的资源。不管类是否定义了自己的析构函数,编译器都自动执行类中非 static 数据成员的析构函数。

         与构造函数一样,赋值操作符可以通过指定不同类型的右操作数而重载。右操作数为类类型的版本比较特殊:如果我们没有编写这种版本,编译器将为我们合成一个。

1. 复制构造函数

         只有单个形参,而且该形参是对本类类型对象的引用(常用 const 修饰),这样的构造函数称为复制构造函数。与默认构造函数一样,复制构造函数可由编译器隐式调用。复制构造函数可用于:

         1)根据另一个同类型的对象显式或隐式初始化一个对象。

         2)复制一个对象,将它作为实参传给一个函数。

         3)从函数返回时复制一个对象。

         4)初始化顺序容器中的元素。

         5)根据元素初始化式列表初始化数组元素。

         初始化的复制形式和直接形式有所不同:直接初始化直接调用与实参匹配的构造函数,复制初始化总是调用复制构造函数。复制初始化首先使用指定构造函数创建一个临时对象,然后用复制构造函数将那个临时对象复制到正在创建的对象:

     string null_book = "9-999-99999-9"; // copy-initialization

     string dots(10, '.');               // direct-initialization

     string empty_copy = string();       // copy-initialization

     string empty_direct;                // direct-initialization

         empty_copy 和 empty_direct 的初始化都调用默认构造函数。对前者初始化时,默认构造函数函数创建一个临时对象,然后复制构造函数用该对象初始化 empty_copy。对后者初始化时,直接运行 empty_direct 的默认构造函数。

         对于类类型对象,只有指定单个实参或显式创建一个临时对象用于复制时,才使用复制初始化。

         通常直接初始化和复制初始化仅在低级别上存在差异。然而,对于不支持复制的类型,或者使用非 explicit 构造函数的情形,它们有本质区别:

     ifstream file1("filename"); // ok: direct initialization

     ifstream file2 = "filename"; // error: copy constructor is private

     // This initialization is okay only if

     // the Sales_item(const string&) constructor is not explicit

     Sales_item item = string("9-999-99999-9");

         看上去等效的 file2 初始化使用复制初始化,但该定义不正确。由于不能复制 IO 类型的对象,所以不能对那些类型的对象使用复制初始化。

         item 的初始化是否正确,取决于正在使用哪个版本的 Sales_item 类。某些版本将参数为一个 string 的构造函数定义为 explicit。如果构造函数是显式的,则初始化失败;如果构造函数不是显式的,则初始化成功。

         当形参为非引用类型的时候,将复制实参的值。类似地,以非引用类型作返回值时,将返回 return 语句 中的值的副本。当形参或返回值为类类型时,由复制构造函数进行复制。

         复制构造函数可用于初始化顺序容器中的元素。例如,可以用表示容量的单个形参来初始化容器。容器的这种构造方式使用默认构造函数和复制构造函数:

     // default string constructor and five string copy constructors invoked

     vector<string> svec(5);

         编译器首先使用 string 默认构造函数创建一个临时值来初始化 svec,然后使用复制构造函数将临时值复制到 svec 的每个元素

         如果没有为类类型数组提供元素初始化式,则将用默认构造函数初始化每个元素。然而,如果使用常规的花括号括住的数组初始化列表来提供显式元素初始化式,则使用复制初始化来初始化每个元素。根据指定值创建适当类型的元素,然后用复制构造函数将该值复制到相应元素:

     Sales_item primer_eds[] = { string("0-201-16487-6"),

                                 string("0-201-54848-8"),

                                 string("0-201-82470-1"),

                                 Sales_item()

                               };

如果我们没有定义复制构造函数,编译器就会为我们合成一个。与合成的默认构造函数不同,即使我们定义了其他构造函数,也会合成复制构造函数。合成复制构造函数的行为是,执行逐个成员初始化,将新对象初始化为原对象的副本。所谓“逐个成员”,指的是编译器将现在对象的每个非 static 成员,依次复制到正创建的对象。合成复制构造函数直接复制内置类型成员的值,类类型成员使用该类的复制构造函数进行复制。数组成员的复制是个例外。虽然一般不能复制数组,但如果一个类具有数组成员,则合成复制构造函数将复制数组。复制数组时合成复制构造函数将复制数组的每一个元素。

定义自己的复制构造函数:

         复制构造函数就是接受单个类类型引用形参(通常用 const 修饰)的构造函数:

     class Foo {

     public:

        Foo();           // default constructor

        Foo(const Foo&); // copy constructor

        // ...

     };

         因为用于向函数传递对象和从函数返回对象,该构造函数一般不应设置为 explicit。

有些类必须对复制对象时发生的事情加以控制。这样的类经常有一个数据成员是指针,或者有成员表示在构造函数中分配的其他资源。而另一些类在创建新对象时必须做一些特定工作。这两种情况下,都必须定义复制构造函数。通常,定义复制构造函数最困难的部分在于认识到需要复制构造函数。只要能认识到需要复制构造函数,定义构造函数一般非常简单。

有些类需要完全禁止复制。例如,iostream 类就不允许复制。为了防止复制,类必须显式声明其复制构造函数为 private。如果复制构造函数是私有的,将不允许用户代码复制该类类型的对象,编译器将拒绝任何进行复制的尝试。然而,类的友元和成员仍可以进行复制。如果想要连友元和成员中的复制也禁止,就可以声明一个(private)复制构造函数但不对其定义:声明而不定义成员函数是合法的,但是,使用未定义成员的任何尝试将导致链接失败。通过声明(但不定义)private 复制构造函数,可以禁止任何复制类类型对象的尝试:用户代码中复制尝试将在编译时标记为错误,而成员函数和友元中的复制尝试将在链接时导致错误。

不定义复制构造函数和/或默认构造函数,会严重局限类的使用。不允许复制的类对象只能作为引用传递给函数或从函数返回,它们也不能用作容器的元素。

2. 赋值操作符

与复制构造函数一样,如果类没有定义自己的赋值操作符,则编译器会合成一个。

大多数操作符可以定义为成员函数或非成员函数。当操作符为成员函数时,它的第一个操作数隐式绑定到 this 指针。有些操作符(包括赋值操作符)必须是定义自己的类的成员。因为赋值必须是类的成员,所以 this 绑定到指向左操作数的指针。因此,赋值操作符接受单个形参,且该形参是同一类类型的对象。右操作数一般作为 const 引用传递。

赋值操作符的返回类型与内置类型赋值运算返回的类型相同。内置类型的赋值运算返回对右操作数的引用,因此,赋值操作符也返回对同一类类型的引用:

         Sales_item& operator=(const Sales_item &);

成赋值操作符与合成复制构造函数的操作类似。它会执行逐个成员赋值:右操作数对象的每个成员赋值给左操作数对象的对应成员。除数组之外,每个成员用所属类型的常规方式进行赋值。对于数组,给每个数组元素赋值。

合成赋值操作符根据成员类型使用适合的内置或类定义的赋值操作符,依次给每个成员赋值,该操作符返回 *this,它是对左操作数对象的引用。

3. 析构函数

变量(如 item)在超出作用域时应该自动撤销。动态分配的对象只有在指向该对象的指针被删除时才撤销。如果没有删除指向动态对象的指针,则不会运行该对象的析构函数,对象就一直存在,从而导致内存泄漏,而且,对象内部使用的任何资源也不会释放。当对象的引用或指针超出作用域时,不会运行析构函数。只有删除指向动态分配对象的指针或实际对象(而不是对象的引用)超出作用域时,才会运行析构函数。

撤销一个容器(不管是标准库容器还是内置数组)时,也会运行容器中的类类型元素的析构函数。

                   {

         Sales_item *p = new Sales_item[10]; // dynamically allocated

         vector<Sales_item> vec(p, p + 10);  // local object

         // ...

         delete [] p; // array is freed; destructor run on each element

             }   // vec goes out of scope; destructor run on each element

         容器中的元素总是按逆序撤销:首先撤销下标为 size() - 1 的元素,然后是下标为 size() - 2 的元素……直到最后撤销下标为 [0] 的元素。

         许多类不需要显式析构函数,如果类需要析构函数,则它也需要赋值操作符和复制构造函数,这是一个有用的经验法则。这个规则常称为三法则,指的是如果需要析构函数,则需要所有这三个复制控制成员。

         与复制构造函数或赋值操作符不同,编译器总是会为我们合成一个析构函数。合成析构函数按对象创建时的逆序撤销每个非 static 成员,因此,它按成员在类中声明次序的逆序撤销成员。对于类类型的每个成员,合成析构函数调用该成员的析构函数来撤销对象。合成析构函数并不删除指针成员所指向的对象。

         分配了资源的类一般需要定义析构函数以释放那些资源。析构函数是个成员函数,它的名字是在类名字之前加上一个代字号(~),它没有返回值,没有形参。因为不能指定任何形参,所以不能重载析构函数。虽然可以为一个类定义多个构造函数,但只能提供一个析构函数,应用于类的所有对象。

         析构函数与复制构造函数或赋值操作符之间的一个重要区别是,即使我们编写了自己的析构函数,合成析构函数仍然运行:

         为 Sales_item: 类编写如下的空析构函数:

     class Sales_item {

     public:

        // empty; no work to do other than destroying the members,

        // which happens automatically

         ~Sales_item() { }

        // other members as before

     };

         撤销 Sales_item 类型的对象时,将运行这个什么也不做的析构函数,它执行完毕后,将运行合成析构函数以撤销类的成员。合成析构函数调用 string 析构函数来撤销 string 成员,string 析构函数释放了保存 isbn 的内存。units_sold 和 revenue 成员是内置类型,所以合成析构函数撤销它们不需要做什么。

4. 消息处理示例

5. 管理指针成员

         包含指针的类需要特别注意复制控制,原因是复制指针时只复制指针中的地址,而不会复制指针指向的对象。

管理指针成员的三种不同方法:

         1)指针成员采取常规指针型行为。这样的类具有指针的所有缺陷但无需特殊的复制控制。

         2)类可以实现所谓的“智能指针”行为。指针所指向的对象是共享的,但类能够防止悬垂指针。

         3)类采取值型行为。指针所指向的对象是唯一的,由每个类对象独立管理。

一个带指针成员的简单类:

         class HasPtr {

     public:

         HasPtr(int *p, int i): ptr(p), val(i) { }

         int *get_ptr() const { return ptr; }

         int get_int() const { return val; }

         void set_ptr(int *p) { ptr = p; }

         void set_int(int i) { val = i; }

         int get_ptr_val() const { return *ptr; }

         void set_ptr_val(int val) const { *ptr = val; }

     private:

         int *ptr;

         int val;

     };

默认复制/赋值与指针成员:因为 HasPtr 类没有定义复制构造函数,所以复制一个 HasPtr 对象将复制两个成员:

     int obj = 0;

     HasPtr ptr1(&obj, 42); // int* member points to obj, val is 42

     HasPtr ptr2(ptr1);     // int* member points to obj, val is 42

         复制之后,ptr1 和 ptr2 中的指针指向同一对象且两个对象中的 int 值相同。但是,因为指针的值不同于它所指对象的值,这两个成员的行为看来非常不同。复制之后,int 值是清楚和独立的,而指针则纠缠在一起,可能出现悬垂指针:HasPtr 保存着给定指针。用户必须保证只要 HasPtr 对象存在,该指针指向的对象就存在:

     int *ip = new int(42); // dynamically allocated int initialized to 42

     HasPtr ptr(ip, 10);    // Has Ptr points to same object as ip does

     delete ip;             // object pointed to by ip is freed

     ptr.set_ptr_val(0); // disaster: The object to which Has Ptr points was freed!

         ip 和 ptr 中的指针指向同一对象。删除了该对象时,ptr 中的指针不再指向有效对象。然而,没有办法得知对象已经不存在了。

定义智能指针类:智能指针除了增加功能外,其行为像普通指针一样。本例中让智能指针负责删除共享对象。用户将动态分配一个对象并将该对象的地址传给新的 HasPtr 类。用户仍然可以通过普通指针访问对象,但绝不能删除指针。HasPtr 类将保证在撤销指向对象的最后一个 HasPtr 对象时删除对象。HasPtr 在其他方面的行为与普通指针一样。具体而言,复制对象时,副本和原对象将指向同一基础对象,如果通过一个副本改变基础对象,则通过另一对象访问的值也会改变。新的 HasPtr 类需要一个析构函数来删除指针,但是,析构函数不能无条件地删除指针。如果两个 HasPtr 对象指向同一基础对象,那么,在两个对象都撤销之前,我们并不希望删除基础对象。为了编写析构函数,需要知道这个 HasPtr 对象是否为指向给定对象的最后一个。

         定义智能指针的通用技术是采用一个使用计数。智能指针类将一个计数器与类指向的对象相关联。使用计数跟踪该类有多少个对象共享同一指针。使用计数为 0 时,删除对象。使用计数有时也称为引用计数。每次创建类的新对象时,初始化指针并将使用计数置为 1。当对象作为另一对象的副本而创建时,复制构造函数复制指针并增加与之相应的使用计数的值。对一个对象进行赋值时,赋值操作符减少左操作数所指对象的使用计数的值(如果使用计数减至 0,则删除对象),并增加右操作数所指对象的使用计数的值。最后,调用析构函数时,析构函数减少使用计数的值,如果计数减至 0,则删除基础对象。

计数器不能直接放在 HasPtr 对象中:定义一个单独的具体类用以封闭使用计数和相关指针:

     // private class for use by HasPtr only

     class U_Ptr {

         friend class HasPtr;

         int *ip;

         size_t use;

         U_Ptr(int *p): ip(p), use(1) { }

         ~U_Ptr() { delete ip; }

     };

         U_Ptr 类保存指针和使用计数,每个 HasPtr 对象将指向一个 U_Ptr 对象,使用计数将跟踪指向每个 U_Ptr 对象的 HasPtr 对象的数目。U_Ptr 定义的仅有函数是构造函数和析构函数,构造函数复制指针,而析构函数删除它。构造函数还将使用计数置为 1,表示一个 HasPtr 对象指向这个 U_Ptr 对象。新的 HasPtr 类保存一个指向 U_Ptr 对象的指针,U_Ptr 对象指向实际的 int 基础对象。必须改变每个成员以说明的 HasPtr 类指向一个 U_Ptr 对象而不是一个 int。

         class HasPtr {

     public:

         HasPtr(int *p, int i): ptr(new U_Ptr(p)), val(i) { }

         HasPtr(const HasPtr &orig):

            ptr(orig.ptr), val(orig.val) { ++ptr->use; }

         HasPtr& operator=(const HasPtr&);

         ~HasPtr() { if (--ptr->use == 0) delete ptr; }

     private:

         U_Ptr *ptr;        // points to use-counted U_Ptr class

         int val;

     };

赋值与使用计数:

         HasPtr& HasPtr::operator=(const HasPtr &rhs)

     {

         ++rhs.ptr->use;     // increment use count on rhs first

         if (--ptr->use == 0)

              delete ptr;    // if use count goes to 0 on this object, delete it

         ptr = rhs.ptr;      // copy the U_Ptr object

         val = rhs.val;      // copy the int member

         return *this;

     }

         首先将右操作数中的使用计数加 1,然后将左操作数对象的使用计数减 1 并检查这个使用计数。像析构函数中那样,如果这是指向 U_Ptr 对象的最后一个对象,就删除该对象,这会依次撤销 int 基础对象。将左操作数中的当前值减 1(可能撤销该对象)之后,再将指针从 rhs 复制到这个对象。赋值照常返回对这个对象的引用。

         改变访问 int* 的其他成员,以便通过 U_Ptr 指针间接获取 int:

     class HasPtr {

     public:

         int *get_ptr() const { return ptr->ip; }

         int get_int() const { return val; }

         void set_ptr(int *p) { ptr->ip = p; }

         void set_int(int i) { val = i; }

         int get_ptr_val() const { return *ptr->ip; }

         void set_ptr_val(int i) { *ptr->ip = i; }

     private:

         U_Ptr *ptr;        // points to use-counted U_Ptr class

         int val;

     };

为了管理具有指针成员的类,必须定义三个复制控制成员:复制构造函数、赋值操作符和析构函数。这些成员可以定义指针成员的指针型行为或值型行为。值型类将指针成员所指基础值的副本给每个对象。复制构造函数分配新元素并从被复制对象处复制值,赋值操作符撤销所保存的原对象并从右操作数向左操作数复制值,析构函数撤销对象。

定义值型类:要使指针成员表现得像一个值,复制 HasPtr 对象时必须复制指针所指向的对象:

     class HasPtr {

     public:

         HasPtr(const int &p, int i): ptr(new int(p)), val(i) {}

         HasPtr(const HasPtr &orig):

            ptr(new int (*orig.ptr)), val(orig.val) { }

         HasPtr& operator=(const HasPtr&);

         ~HasPtr() { delete ptr; }

         int get_ptr_val() const { return *ptr; }

         int get_int() const { return val; }

         void set_ptr(int *p) { ptr = p; }

         void set_int(int i) { val = i; }

         int *get_ptr() const { return ptr; }

         void set_ptr_val(int p) const { *ptr = p; }

     private:

         int *ptr;        // points to an int

         int val;

     };

         复制构造函数不再复制指针,它将分配一个新的 int 对象,并初始化该对象以保存与被复制对象相同的值。每个对象都保存属于自己的 int 值的不同副本。因为每个对象保存自己的副本,所以析构函数将无条件删除指针。

         赋值操作符不需要分配新对象,它只是必须记得给其指针所指向的对象赋新值,而不是给指针本身赋值:

     HasPtr& HasPtr::operator=(const HasPtr &rhs)

     {

         *ptr = *rhs.ptr;       // copy the value pointed to

         val = rhs.val;         // copy the int

         return *this;

     }

         即使要将一个对象赋值给它本身,赋值操作符也必须总是保证正确。本例中,即使左右操作数相同,操作本质上也是安全的,因此,不需要显式检查自身赋值。

 

原创粉丝点击