Effective C++读书笔记(8)
条款12:复制对象勿忘其每一个成分
Copy all parts of an object
设计良好的面向对象系统中,封装了对象内部,仅留两个函数用于对象的拷贝:拷贝构造函数和拷贝赋值运算符,统称为拷贝函数。编译器生成版的copy函数会拷贝被拷贝对象的所以成员变量。
考虑一个表现顾客的类,这里的拷贝函数是手工写成的,以便将对它们的调用志记下来:
void logCall(const std::string&funcName); // 制造一个log entry
class Customer {
public:
...
Customer(const Customer& rhs);
Customer& operator=(const Customer& rhs);
...
private:
std::string name;
};
Customer::Customer(const Customer& rhs)
: name(rhs.name) // 复制rhs的数据
{logCall("Customer copy constructor");}
Customer& Customer::operator=(constCustomer& rhs)
{
logCall("Customer copy assignment operator");
name= rhs.name; //复制rhs的数据
return*this;
}
这里的每一件事看起来都不错,实际上也确实不错——直到Customer 中加入了另外的数据成员:
class Date { ... }; // 日期
class Customer {
public:
... // 同前 www.2cto.com
private:
std::string name;
Date lastTransaction;
};
在这里,已有的拷贝函数只进行了部分拷贝:它们拷贝了Customer 的name,但没有拷贝它的lastTransaction。然而,大部分编译器即使是在最高的警告级别也不出任何警告。结论显而易见:如果你为一个类增加了一个数据成员,你务必要做到更新拷贝函数,你还需要更新类中的全部的构造函数以及任何非标准形式的operator=。
一旦发生继承,可能会造成此主题最暗中肆虐的一个暗藏危机。考虑:
PriorityCustomer::PriorityCustomer(constPriorityCustomer& rhs)
: Customer(rhs), // 调用基类的copy构造函数
priority(rhs.priority)
{logCall("PriorityCustomer copy constructor");}
PriorityCustomer&
PriorityCustomer::operator=(const PriorityCustomer& rhs)
{
logCall("PriorityCustomer copy assignment operator");
Customer::operator=(rhs); // 对基类成分进行赋值动作
priority = rhs.priority;
return*this;
}
无论何时,你打算自己为一个派生类写拷贝函数时,必须注意同时拷贝基类部分。那些成分往往是private,所以你不能直接访问它们,应该让派生类的拷贝函数调用相应的基类函数。当你写一个拷贝函数,需要保证(1)拷贝所有本地数据成员以及(2)调用所有基类中的适当的拷贝函数。
· 拷贝函数应该保证拷贝一个对象的所有数据成员以及所有的基类部分。
在实际中,两个拷贝函数经常有相似的函数体,而这一点可能吸引你试图通过用一个函数调用另一个来避免代码重复。你希望避免代码重复的想法值得肯定,但是用一个拷贝函数调用另一个来做到这一点是错误的。
“用拷贝赋值运算符调用拷贝构造函数”和“用拷贝构造函数调用拷贝赋值运算符”都是没有意义的。如果发现你的拷贝构造函数和拷贝赋值运算符有相似的代码,通过创建第三个供两者调用的成员函数来消除重复。这样的函数当然是private 的,而且经常叫做init。这一策略可以消除拷贝构造函数和拷贝赋值运算符中的代码重复,安全且被证实过。
· 不要试图依据一个拷贝函数实现另一个。作为代替,将通用功能放入第三个供双方调用的函数。
条款13:以对象管理资源
Use objects to manage resources
假设我们使用一个用来塑模投资行为(例如股票、债券等)的程序库,各种各样的投资类型继承自root class Investment。进一步假设这个库使用了通过一个factory 函数为我们提供特定Investment 对象的方法:
class Investment { ... }; // “投资类型”继承体系中的root class
Investment* createInvestment(); /*返回指向Investment继承体系内的动态分配对象的指针。调用者有责任删除它。这里为了简化,刻意不写参数*/
当createInvestment 函数返回的对象不再使用时,由调用者负责删除它。下面的函数f 来履行以下职责:
void f()
{
Investment *pInv = createInvestment(); // 调用factory对象
...
delete pInv; // 释放pInv所指对象
}
以下几种情形会造成f 可能无法删除它得自createInvestment 的投资对象:
1. "..." 部分的某处有一个提前出现的return 语句,控制流就无法到达delete 语句;
2. 对createInvestment 的使用和删除在一个循环里,而这个循环以一个continue 或goto 语句提前退出;
3. "..." 中的一些语句可能抛出一个异常,控制流不会再到达那个delete。
单纯依赖“f总是会执行其delete语句”是行不通的。
为了确保createInvestment 返回的资源总能被释放,我们需要将资源放入对象中,当控制流离开f,这个对象的析构函数会自动释放那些资源。将资源放到对象内部,我们可以依赖C++ 的“析构函数自动调用机制”确保资源被释放。
许多资源都是动态分配到堆上的,并在单一区块或函数内使用,且应该在控制流离开那个块或函数的时候释放。标准库的auto_ptr 正是为这种情形而设计的。auto_ptr 是一个类似指针的对象(智能指针),它的析构函数自动对其所指对象调用delete。下面就是如何使用auto_ptr 来预防f 的潜在的资源泄漏:
void f()
{
std::auto_ptr<Investment> pInv(createInvestment()); // 调用工厂函数
... // 一如以往地使用pInv
} // 经由auto_ptr的析构函数自动删除pInv
这个简单的例子示范了“以对象管理资源”的两个关键想法:
· 获得资源后应该立即放进管理对象内。如上,createInvestment 返回的资源被用来初始化即将用来管理它的auto_ptr。实际上“以对象管理资源”的观念常被称为“资源取得时机便是初始化时机” (Resource Acquisition Is Initialization ;RAII),因为我们几乎总是在获得一笔资源后于同一语句内以它初始化某个管理对象。有时被获取的资源是被赋值给资源管理对象的(而不是初始化),但这两种方法都是在获取资源的同时就立即将它移交给资源管理对象。
· 管理对象使用它们的析构函数确保资源被释放。因为当一个对象被销毁时(例如,当一个对象离开其活动范围)会自动调用析构函数,无论控制流程是怎样离开一个块的,资源都会被正确释放。如果释放资源的动作会引起异常抛出,事情就会变得棘手。
当一个auto_ptr 被销毁的时候,会自动删除它所指向的东西,所以不要让超过一个的auto_ptr 指向同一个对象。如果发生了这种事情,那个对象就会被删除超过一次,而且会让你的程序进入不明确行为。为了防止这个问题,auto_ptrs 具有不同寻常的特性:拷贝它们(通过拷贝构造函数或者拷贝赋值运算符)就会将它们置为null,而复制所得的指针将取得资源的唯一拥有权!
std::auto_ptr<Investment>pInv1(createInvestment());
// pInv1指向createInvestment 返回物
std::auto_ptr<Investment> pInv2(pInv1);
// 现在pInv2指向对象,pInv1被设为null
pInv1 = pInv2; // 现在pInv1指向对象,pInv2被设为null
受auto_ptrs 管理的资源必须绝对没有超过一个以上的auto_ptr 同时指向它,这也就意味着auto_ptrs 不是管理所有动态分配资源的最好方法。例如,STL 容器要求其元素发挥正常的复制行为,因此这些容器容不得auto_ptrs。
auto_ptrs的替代方案是引用计数型智能指针(reference-counting smart pointer, RCSP)。RCSP能持续跟踪有多少对象指向一个特定的资源,并能够在不再有任何东西指向那个资源的时候删除它。就这一点而论,RCSP 提供的行为类似于垃圾收集(garbage collection)。不同的是,RCSP 不能打破循环引用(例如,两个没有其它使用者的对象互相指向对方)。TR1 的tr1::shared_ptr就是个RCSP:
void f()
{
...
std::tr1::shared_ptr<Investment> pInv(createInvestment());
// 调用factory 函数
... // 使用pInv一如既往
} // 经由shared_ptr析构函数自动删除pInv
这里的代码看上去和使用auto_ptr 的几乎相同,但是拷贝shared_ptrs 的行为却自然得多:
void f()
{
...
std::tr1::shared_ptr<Investment>pInv1(createInvestment());
// pInv指向createInvestment对象
std::tr1::shared_ptr<Investment>pInv2(pInv1);
//pInv1和pInv2指向同一个对象
pInv1= pInv2; // 同上,无任何改变
...
} // pInv1和pInv2被销毁,它们所指的对象也就被自动销毁
因为拷贝tr1::shared_ptrs 的行为“符合预期”,它们能被用于STL 容器以及其它和auto_ptr 的非正统的拷贝行为不相容的环境中。auto_ptr 和tr1::shared_ptr 都在它们的析构函数中使用delete,而不是delete []。这就意味着将auto_ptr 或tr1::shared_ptr 用于动态分配的数组是个馊主意。
C++ 中没有可用于动态分配数组的类似auto_ptr 或tr1::shared_ptr 这样的东西,甚至在TR1 中也没有。那是因为vector 和string 几乎总是能代替动态分配数组。你也可以去看看Boost,boost::scoped_array 和boost::shared_array 两个类提供了你在寻找的行为。
如果你手动释放资源(例如,使用delete,而不使用资源管理类),你就是在自找麻烦。像auto_ptr 和tr1::shared_ptr 这样的预制的资源管理类通常会使本条款的建议变得容易,但有时你所使用的资源是目前这些预制的类无法妥善管理的,你就需要精心打造自己的资源管理类。最后必须指出createInvestment 返回的“未加工指针”(raw pointer)是资源泄漏的请帖,因为调用者极易忘记在他们取回来的指针上调用delete。(即使他们使用一个auto_ptr 或tr1::shared_ptr 来完成delete,他们仍然必须记住将createInvestment 的返回值存储到智能指针对象中)。
· 为了防止资源泄漏,使用RAII 对象,在RAII 对象的构造函数中获得资源并在析构函数中释放它们。
· 两个通用的RAII 是tr1::shared_ptr 和auto_ptr。前者通常是更好的选择,因为其拷贝行为比较直观。若选择auto_ptr,复制动作会使被复制物指向null。
摘自 pandawuwyj的专栏