几乎每个类都会有一个或多个构造函数(产出新对象并确保它被初始化)、一个析构函数(摆脱旧对象并确保它被适当清理)、一个copy assignment操作符(赋予对象新值)。这些函数定义了对对象的基本操作。
对于一个空类,当被C++处理过后它将不再是空类。
如果你没声明,编译器就会为它声明(编译器版本的)一个copy构造函数,一个copy assignment操作符和一个析构函数。如果你没有声明任何构造函数,编译器也会为你声明一个默认构造函数。这些函数都是public且inline。如下:
class Empty {
}; //你写下的,相当于下面的定义
class Empty {
public:
Empty() {
...} //默认构造函数
Empty(const Empty& rhs) {
...} //copy构造函数
~Empty() {
...} //析构函数
Empty& operator=(const Empty& rhs) {
...} //copy assignment操作符
};
只有这些函数被调用的时候它们才会被编译器创建出来。
其中默认构造函数和析构函数主要是给编译器一个地方放置“藏身幕后”的代码,比如 基类和非静态成员变量的构造函数和析构函数 。注意,编译器产生的析构函数是个非虚函数。除非 这个类的基类自身声明有析构虚函数 (这种情况下函数的虚属性主要来自于基类)。
至于copy构造函数和copy assignment操作符,编译器创建的版本指示单纯地将来源对象的,每一个non-static成员变量拷贝到目标对象。
template<typename T>
class NameObject {
public:
NamedObject(const char* name, const T& value);
NamedObject(const std::string& name, const T& vlaue);
...
private:
std::string nameValue;
T objectValue;
};
NamedObject<int> nol("Smallest Prime Number", 2);
NamedObject<int> no2(no1); //调用copy构造函数
上述代码中已经声明了一个构造函数,编译器于是不再为它创建默认构造函数。但是没有copy构造函数,也没有声明copy assignment操作符,所以编译器会创建那些函数。编译器生成的拷贝构造函数和拷贝运算符需要进行拷贝对象的变量类型一致,否则编译器将不会进行拷贝操作。如果类中包含引用成员,需要你自己定义拷贝操作。实际上就是编译器只能满足最低需求,类似直接复制粘贴,而如果在拷贝过程中间需要进行一些操作,那就是需要你自己定义的了。
编译器可以暗自为class创建default构造函数、copy构造函数、copy assignment操作符以及析构函数。
如果不想使用编译器自动生成的函数,比如类中的拷贝构造函数和拷贝赋值运算符,你可以自己进行声明。如果还不想使用他们还可以通过将其声明为private来实现。
原因是所有编译器产出的函数都是public的。所以明确声明一个成员函数,就阻止了编译器暗自创建其专属版本;而令这些函数为private,这样就可以成功阻止人们调用它。
一般而言这个做法并不绝对安全,因为member函数和friend函数还是可以调用private函数。当然也可以不定义它们,这样如果有人不小心调用任何一个,就会获取连接错误。
class HomeForSale {
public:
...
private:
...
HomeForSale(const HomeForSale&);//只有声明,函数名称不是必要的
HomeForSale operator=(const HomeForSale&);
};
上述代码实现了这个过程,当客户试图拷贝HomeForSale对象时,编译器会提示错误。如果不小心在member函数或friend函数之内那么做,连接器就会报警。
将连接期错误移至编译期是可能的(错误越早侦测出错误越好),只要在基类中将copy构造函数和copy assignment操作符声明为private就可以办到,这个基类是专门为了阻止copying动作而设计的。
class Uncopyable {
protected:
Uncopyable() {
}
~Uncopyable() {
}
private:
Uncopyable(const Uncopyable&);
Uncopyable operator=(const Uncopyable&);
};
class HomeForSale: private Uncopyable {
//为了阻止对象被拷贝,需要继承Uncopyable
...
} ;
为驳回编译器自动提供的机能(如拷贝构造函数),可将相应的成员函数声明为private并且不予以实现。使用像Uncopyable这样的基类也是一种做法。
在类的多态性使用过程中,如果碰到派生类的对象通过基类的析构函数释放时,而基类又没有实现虚析构函数,将会产生不好的后果。
class TimeKeeper {
public:
TimeKeeper();
~TimeKeeper();
...
};
class AtomicClock : public TimeKeeper {
...}; //原子钟
class WaterClock : public TimeKeeper {
...}; //水钟
class WristWatch : public TimeKeeper {
...}; //腕表
如上代码,如果只想在程序中使用时间,不关注具体实现细节,这时候我们可以设计如下的factory函数,返回指针指向一个计时对象。factory函数会返回一个基类的指针,指向新生成的派生类对象。
TimeKeeper *getTimeKeeper(); //返回一个指针,指向一个TimeKeeper派生类的动态分配对象
为了遵守factory函数的规矩,被getTimeKeeper函数返回的对象必须位于堆上。所以为了避免泄露内存和其他资源,就要在适当的时间将对象delete掉。
TimeKeeper *ptk = getTimeKeeper(); //从TimeKeeper继承体系获得一个动态分配对象
…
Delete ptk; //释放他,避免资源泄露
但是由于返回的是基类指针指向的是派生类的对象,而指针是由基类的析构函数删除,这就会导致派生类中声明的那部分成员没有被删除,造成一个“局部销毁”的对象。*从而产生资源泄露、败坏数据结构,在调试器上浪费许多时间的问题。*很简单,析构就是销毁垃圾,你自己产生的就只能你自己销毁,如果不是虚析构函数,你调用基类的析构函数,就只能销毁基类的垃圾,而子类的垃圾就不能被销毁了。而当你给基类实现了虚析构函数,当你执行析构时,编译器就会通过虚地址表(后面会介绍)寻找合适的析构函数,从而将垃圾全部清理掉。所以解决这种问题就是要给基类实现一个虚析构函数,这样删除对象时就会也将派生类声明中的部分也删除。
class TimeKeeper {
public:
TimeKeeper();
virtual ~TimeKeeper();
...
};
TimeKeeper *ptk = getTimeKeeper();
...
delete ptk;
注意,当一个类中含有虚函数,那么他应该也有一个虚析构函数。当一个类不含虚函数,他一般不会被当作基类;同理当一个类不是基类,他其中也不应该有虚析构函数。
当一个类不是基类却含有虚析构函数时,相比不含虚析构函数的类它实例化的对象包含更多的内容。因为实现虚函数需要对象携带虚指针,虚指针指向虚表,作用是确定运行期调用的虚函数。这样会导致对象的大小发生变化(占用更多的内存),对于不支持虚指针的语言(如C),包含虚析构函数的类也不再具有可移植性。这就是你定义虚函数之后,就要比不含虚函数的类的对象多上一些东西,占用自然更多。同样的对于不支持虚指针的语言,他们编译器并不知道类中虚函数包含着什么,自然就不可移植了。
含有纯虚函数的类叫做抽象类,不能实例化,总是作为基类实现。当你想实现一个抽象类,但没有成员函数可以被声明为纯虚函数,可以将类中的析构函数声明为纯虚函数。
class AWOV {
public:
virtual ~AWOV( ) = 0; //声明纯虚析构函数
};
AWOV::~AWOV() {
} //必须为纯虚析构函数提供定义
析构函数的调用顺序是,最深层派生类的析构函数最先调用,然后就是其每一个基类的析构函数被调用。所以你必须为上述的~AWOV提供定义,不然只有声明没有定义怎么调用。
1.带多态性质的基类应该声明一个虚析构函数。如果类中带有任何虚函数,它就应该拥有一个虚析构函数。
2.一个类如果不是被设计为基类使用,或不是为了具备多态性,就不应该声明虚析构函数。
C++并不禁止析构函数吐出异常,但并不鼓励这样做。因为析构函数吐出异常非常危险,总会带来“过早结束程序”或“发生不明确行为”的风险。
class Widegt {
public:
...
~Widegt( ) {
... }; //假设这个可能吐出异常
};
void doSomething()
{
std::vector<Widegt> v;
...
} //v在这里被自动销毁
当v被销毁,它会销毁其中含有的所有的widegts。假设v中含有十个widget,在析构第一个元素时有一个异常被抛出,但其他九个还是应该被销毁,因此v应该调用它们各个析构函数,假设在这些调用期间,又有一个异常被抛出。现在就有两个同时作用的异常,对于C++两个异常同时存在的情况下,程序不是结束执行就是导致不明确行为。同理,使用其他容器(如list,set)或数组(array)也会出现类似的情况。实际上只要析构函数吐出异常,不管是否使用容器或数组,程序都可能过早结束或出现不明确行为。所以尽量不要再析构函数中吐出异常。
但是如果你的析构函数必须执行一个工作,而该动作可能会在失败时抛出异常,这种情况又怎么办呢。举个例子,假如你使用一个类进行数据库连接:
class DBConnection {
public:
...
static DBConnection create(); //返回DBConnection对象
void close(); //关闭连接,失败则抛出异常
};
class DBConn {
//用来管理DBConnection对象,为了确保不忘
public: //记在DBConnection对象身上调用close()
...
~DBConn() {
//确保数据库链接总是被关闭
db.close();
}
private:
DBConnection db;
};
DBConn dbc(DBConnection::create()); //使用DBConn对象管理DBConnection对象
只要调用close成功,就没有问题。但是如果出现异常。DBConn析构函数就会传播异常,也就是允许这个异常离开析构函数,这样就会出现问题。有两个办法可以解决这个问题。
如果close抛出异常就结束程序,通常通过调用abort完成:
DBConn::~DBConn()
{
try {
db.close(); }
catch (...) {
制作运转记录,记下对close的调用失败;
std::abort();
}
}
如果程序遭遇一个“于析构期间发生的错误”后无法继续执行,强迫结束程序”是个合理选项。他可以阻止异常从析构函数中传播出去(避免出现不明确行为)。
吞下因调用close而发生的异常:
DBConn::~DBConn()
{
try {
db.close(); }
catch (...) {
制作运转记录,记下对close的调用失败;
}
}
一般来说,将异常吞掉是个坏主意,因为它压制了“某些动作失败”的重要信息。然而有时候吞下异常也比负担“草率结束程序”或“不明确行为带来的风险”好。这适用于即使在遭遇一个错误之后,程序必须能够继续可靠执行。
上面两种办法都不能对“导致close抛出异常”的情况做出反应。一个更好的方法是重新设计DBConn接口,能够有机会对可能出现的问题做出反应。例如DBConn可以自己提供一个close(),因而赋予用户一个机会得以处理“因该操作发生的异常”,并在答案为否的情况下由其析构函数关闭。这可防止遗失数据库连接。然而如果DBConn析构函数调用close失败,我们又只能“强迫结束程序”或“吞下异常”。
class DBConn {
public:
...
void close( ) {
//供用户使用的新函数
db.close();
closed = true;
}
~DBConn() {
if (!closed)
{
try {
//关闭连接(如果客户没有调用自定义close())
db.close();
}
catch (...) {
//如果关闭动作失败,记录下来并结束程序或吞下异常
制作运转记录,记下对close的调用失败;
...
}
}
}
private:
DBConnection db;
bool closed;
};
这样的做法是给用户一个自己处理异常的机会,能够对抛出异常的情况进行处理,从而改正错误。
1. 析构函数绝对不要吐出异常,会带来过早结束程序和发生不明确行为的风险。如果一个被析构函数调用的函数可能抛出异常,析构函数应该捕捉任何异常,然后吞下他们(不传播)或接触程序。
2. 如果客户需要对某个操作函数运行期间抛出的异常做出反应,那么类中应该提供一个普通函数(而非在析构函数中)执行该操作。
不应该在构造函数和析构函数期间调用虚函数。
假设你有一个类的继承体系,用来塑模股市交易,如买进、卖出的订单等。这样的交易一定要经过审计,所以每当创建一个交易对象,在审计日志中也需要创建一笔适当记录。
class Transaction {
//所有交易的积累性
public:
Transaction( );
virtual void logTransaction() const = 0; //做出一份因类型不同而不同的日志记录
...
};
Transaction::Transaction() //基类构造函数的实现
{
...
logTransaction(); //最后动作是志记这笔交易
}
class BuyTransaction : public Transaction {
//派生类,买入
public:
virtual void logTransaction() const = 0; //志记此型交易
...
};
class SellTransaction : public Transaction {
//派生类,卖出
public:
virtual void logTransaction() const = 0; //志记此型交易
...
};
BuyTransaction b; //实例化
上述代码实例化一个BuyTransaction对象。构造函数的调用顺序是最先调用基类的构造函数,然后按派生顺序依次调用构造函数。 所以首先调用的是Transaction构造函数,派生类的基类成分肯定会在派生类自身部分被构造之前先完成构造。 Transaction构造函数最后一行调用虚函数logTransaction,这调用的是Transaction内的版本,不是BuyTransaction内的版本。即使建立的对象是BuyTransaction。
基类构造期间虚函数绝不会下降到派生类阶层。这是由于基类构造函数的执行更早于派生类构造函数,当基类构造函数执行时派生类的成员变量还未初始化。如果此期间调用的虚函数下降到派生类阶层,这将直接导致不明确行为,C++不会允许这种情况的发生。更深层的原因是在派生类对象的基类构造期间,对象的类型是基类而不是派生类。不仅仅是虚函数会被编译器解析至基类,若使用运行期类型信息(如dynamic_cast和typeid)也会把对象视为基类类型。
相同的道理也适用于析构函数,一旦派生类析构函数开始执行,对象内的派生类成员变量便呈现未定义值,所以C++视它们仿佛不再存在。进入基类析构函数后对象就成为一个基类对象,而C++的任何部分包括虚函数、dynamic_cast等等都是如此。
在上述示例中,Transaction构造函数直接调用一个虚函数,直接违反了本条款。这个错误显而易见,某些编译器会为此发一个警告信息。即使没有这个警告,这个问题也会因为logTransaction是个纯虚函数没有被定义(极大可能,也有可能被定义)而出现连接错误。
但是判断构造函数和析构函数运行期间是否调用虚函数并不轻松 。
class Transaction {
public:
Transaction( ) {
init(); } //调用非虚函数
virtual void logTransaction() const = 0;
...
private:
void init() {
...
logTransaction(); //调用虚函数
}
};
上述代码不会引起任何编译器和连接器的警告和报错,但是由于logTransaction是Transaction内的一个纯虚函数,当纯虚函数被调用,大多数执行系统会中止程序。然而如果logTransaction是个虚函数而非纯虚函数并在Transaction中有实现代码,那将会被直接调用,只是最后派生类调用logTransaction时是错误版本。
避免此问题的唯一方法是:确定构造函数和析构函数中没有调用虚函数。实在需要在构造函数或析构函数中调用的函数,将之实现为非虚函数。
在构造和析构期间不要调用virtual函数,因为这类调用从不下降至派生类。换句话说,就是虚函数在构造和析构期间调用失去了virtual的效果,相当于普通函数,没有实现多态。
关于赋值,你可以将它们写成连锁形式:
int x, y, z;
x = y = z = 15; //赋值连锁形式
x = (y = (z = 15)); //赋值采用右结合律,解析后结果
所以为了实现连锁赋值,赋值操作符必须返回一个引用指向操作符左侧实参。这是为类实现赋值操作符时应该遵循的协议。
class Widget {
public:
...
Widget& operator=(const Widget& rhs) //返回类型是引用,指向当前对象
{
...
return* this; //返回左侧对象
}
...
};
这个协议适用于所有赋值相关运算。
class Widget {
public:
...
Widget& operator+=(const Widget& rhs) //适用于+=,-=,*=等等
{
...
return* this;
}
...
};
这是一个协议,并无强制性,不过如果没有必要的理由,还是不要违反它。
**令赋值操作符返回一个reference to *this **
“自我赋值”发生在对象备赋值给自己时:
class Widget {
... };
Widget w;
...
w = w; //赋值给自己,一般不会这么做
a[i] = a[j] //潜在的自我赋值,当i=j时
*px = *py //当px和py指向同一个东西时
对自我赋值的情况,如果是自行管理资源,可能会出现“在停止使用资源之前意外释放了它”的问题。
class Base {
... };
class Widget {
...
private:
Bitmap* pb; //指针,指向一个从堆中分配而得到的对象
};
Widget& Widget::operator=(const Widget& rhs) //不安全的赋值操作符版本
{
delete pb;
pb = new Bitmap(*rhs.pb);
return *this;
}
上面的自我赋值问题是赋值操作符两端的对象可能是同一个对象,这样会导致指针指向已被删除的对象。
Widget& Widget::operator=(const Widget& rhs)
{
if (this == &rhs) return *this;
delete pb;
pb = new Bitmap(*rhs.pb);
return *this;
}
上面的代码可以解决自我赋值安全性问题,不过还是不能解决new Bitmap导致异常的问题(分配时内存不足或拷贝构造函数抛出异常)。如果发生异常,指针还是会指向被删除的对象。
Widget& Widget::operator=(const Widget& rhs)
{
Bitmap* pOrig = pb;
pb = new Bitmap(*rhs.pb);
delete pOrig ;
return *this;
}
上面的代码可以保证赋值安全性和异常安全性问题,即使出现两种问题,pb也还是指向源地址。但是效率却不一定是最高的。
另一种较好的替代方案是copy and swap技术。
class Widget {
...
void swap(Widget& rhs); //交换*this和rhs的数据
...
};
Widget& Widget::operator=(const Widget& rhs)
{
Widget temp(ths); //为rhs数据制作一份复件
swap(temp); //将*this数据和上述复件进行交换
return *this;
}
Widget& Widget::operator=(Widget rhs) //另一种简化格式,不过不够清晰
{
swap(rhs);
return *this;
}
1. 确保当对象自我复制时operator有良好行为(注意不要出现自我赋值安全性和异常安全性问题)。其中技术包括比较“来源对象”和“目标对象”的地址、精心周到的语句、以及copy-and-swap。
2. 确定任何函数如果操作一个以上对象,而其中多个对象是同一对象时,其行为仍然正确。
自定义类的拷贝函数时,当你的实现代码必然出错时,编译器也不会提醒你。
我们用一个示例来说明问题。
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(this.name) //复制rhs的数据
{
logCall("Customer copy constructor");
}
Customer& Customer::operator=(const Customer& rhs) //复制rhs的数据
{
logCall("Customer copy assignment operator");
name = this.name;
return *this;
}
上面的类用来表示顾客,自定义拷贝函数实现他们的被调用信息会被日志记录下来。这时候的拷贝函数是没有问题的。
当Customer中新增成员变量。
class Date {
... }; //日期
class Customer {
public: ... //同前
private:
std::string name;
Date lastTransaction;
当碰到这种情况,类中新增了成员变量,相应的拷贝函数也需要进行修改。否则经过拷贝函数生成的对象对新增的成员变量以及相关的内容就不能进行操作。
class PriorityCustomer : public Customer {
public:
...
PriorityCustomer(const PriorityCustomer& rhs);
PriorityCustomer& operator=(const PriorityCustomer& rhs);
...
private:
int priority;
};
PriorityCustomer::PriorityCustomer(const PriorityCustomer& rhs) : priority(rhs.priority)
{
logCall("PriorityCustomer copy constructor");
}
PriorityCustomer& PriorityCustomer::operator=(const PriorityCustomer& rhs)
{
logCall("PriorityCustomer copy assignment operator");
priority = rhs.priority;
return *this;
}
PriorityCustomer的拷贝函数复制了自身中的所有东西,但由它继承的Customer却并未复制。缺少基类的复制,派生类的拷贝过程缺少基类成分是会出现问题的,为了防止报错编译器会自动调用基类的默认构造函数进行初始化,这样也会造成拷贝不成功。正确的应该是让派生类的拷贝函数调用相应的基类函数。
PriorityCustomer::PriorityCustomer(const PriorityCustomer& rhs) : Customer(rhs), 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;
}
两个拷贝函数往往有近似相同的实现本体,你可能会考虑直接用某一个函数调用另一个从而避免代码重复。这样的精益求精的态度是值得称赞的。但是实际上的情况却却是大相径庭。
令拷贝赋值操作符调用拷贝构造函数是不合理的。赋值操作本就是作用于已初始化的对象,这就像是构造一个已经存在的对象。反之,令拷贝构造函数调用拷贝复制操作符也是不合理的,对象还未进行初始化不能进行赋值操作。比较好的做法是将相同的部分实现一个成员函数,供两个函数调用。
1.拷贝函数应该确保复制“对象内的所有成员变量”及“所有基类成分”。
2.不要尝试以某个拷贝函数实现另一个拷贝函数,应该将共同技能放进第三个函数中,并由两个拷贝函数功能调用。