Effective CPP(三):类的构造/析构/赋值运算


一、C++类中编译器默认创建的函数

当你在C++中创建一个空类的时候,编译器会默认为它创建下列内容:

class Empty {
public:
	Empty() { ... }                   // 默认构造函数
	Empty(const Empty&) { ... }       // 拷贝构造函数
	Empty(Empty&&) { ... }            // 移动构造函数(since cpp11)
	~Empty() { ... }                  // 析构函数
	Empty& operator=(const Empty&) { ... }    // 拷贝运算符 
	Empty& operator=(Empty&&) { ... }         // 移动运算符
}; 

当触发下列情景的时候,他们才会真正被编译器创建出来
Empty e1; //默认构造函数
Empty e2(e1); //拷贝构造函数
Empty e3 = std::move(e2); //移动构造函数
e2 = e1; //拷贝赋值运算符
e3 = std::move(e1); //移动赋值运算符

当然,如果条件不允许创建某一个构造函数或者运算符,编译器也不会创建的,比如对于一个只包含一个引用对象的类,由于引用无法指向不同的对象,所以编译器不会为他创建一个默认的拷贝赋值运算符和拷贝构造函数。 与此同时,如果类中包含有 const 的成员,或者基类中包含有 private 的拷贝赋值运算符,拷贝赋值运算符和拷贝构造函数也不会被创建。

当然,如果你不想创建的话,直接像单例模式那样声明为=delete 即可(since cpp11)

二、为多态基类声明一个虚析构函数

当派生类对象由一个基类指针被删除的时候,而该基类指针带着一个非虚析构函数的类对象,其结果就是未定义的。当通过基类指针删除派生类的对象的时候,如果基类析构函数不是虚的,那么只有基类的析构函数会被调用。 这意味着只有基类部分的资源会被释放。而派生类的析构函数就不会被调用,因此不会被释放。如果在基类中定义了虚析构函数,在删除对象释放资源的时候,会首先调用派生类的析构函数,再调用基类的析构函数。

一个好玩的用法是如果你想将基类当做一个抽象类,但是手头上没有其他的虚函数,那么将他的析构函数设置为纯虚函数也是一个不错的想法,但是请注意这种用法需要你写出析构函数的函数体:

class Base {
public:
	virtual ~Base() = 0 {} 
}; 

三、在析构函数中 “捕获” 异常

在 RAII 思想下,我们通常将释放资源的操作封装在析构函数中。例子如下:


class DBConn {
public: 
	~DBConn() {
		db.close(); //这一操作有可能会抛出异常
	}
private:
	DBConnection db;
};

为了在析构函数中完成对异常的处理,以下是几种常见的做法:
第一种,杀死程序:
DBConn::~DBConn() {
	try {
		db.close();
	} catch(...) {
		std::abort();
	}
}

第二种(推荐做法), 将有可能引起异常的操作暴露在普通函数中:
class DBConn {
public:
	void close() {
		db.close();
		closed = True;
	}
	
	~DBConn() {
		if(!closed) {
			try {
				db.close();
			} catch(...) { 
				//捕获异常
			}
		}
	}
}

在新的设计中,我们提供了 close 函数供客户手动调用,这样可会可以根据自己的意愿处理异常;如果客户忘记手动调用,析构函数才会自动调用 close 函数。 当一个操作可能会抛出需要客户处理的异常的时候,将其暴露在普通函数而非析构函数中是一个更好的选择。

四、不在构造函数和析构函数的过程中调用虚函数

在创建派生类对象的时候,基类的构造函数会早于派生类的构造函数调用, 基类的析构函数会晚于派生类的析构函数被调用。
假如第四点条款没有被遵守,在调用基类的构造函数以期望调用派生类中已经重写的虚函数的时候(比如使用基类的指针构造派生类),会意外执行到还没有被重写的虚函数。 当然一般不会有人傻到直接在构造函数中调用虚函数,但是很多时候我们可能不小心间接调用了虚函数:

class Transaction {
public:
    Transaction() { Init(); }
    virtual void LogTransaction() const = 0;

private:
    void Init(){
        ...
        LogTransaction();      // 此处间接调用了虚函数!
    }
};

如果想要基类在构造的时候就得知派生类的构造信息,推荐的做法是在派生类的构造函数中将必要的信息向上传递给基类的构造函数。

class Transaction {
public:
    explicit Transaction(const std::string& logInfo);
    void LogTransaction(const std::string& logInfo) const;
    ...
};

Transaction::Transaction(const std::string& logInfo) {
    LogTransaction(logInfo);                           // 更改为了非虚函数调用
}

class BuyTransaction : public Transaction {
public:
    BuyTransaction(...)
        : Transaction(CreateLogString(...)) { ... }    // 将信息传递给基类构造函数
    ...

private:
    static std::string CreateLogString(...);
}

请注意,在这里静态成员函数是必须的,因为 其在构造函数中被调用了,只有使用静态成员函数才不会使用还没有完成初始化的成员变量。

五. 重载赋值类运算符号的时候返回 this 指针

一个理想的重载 += 和 = 操作符的模板应该是这样的:

class Widget {
public:
    Widget& operator+=(const Widget& rhs) {    // 这个条款适用于
        ...                                    // +=, -=, *= 等等运算符
        return *this;
    }
    Widget& operator=(int rhs) {               // 即使参数类型不是 Widget& 也适用
        ...
        return *this;
    }
};

如果我们不使用*this而是返回一个临时创建的Widget对象,比如:

class Widget {
public:
	int value; 
	Widget(int val = 0) : value(val) {} 
    Widget& operator+=(const Widget& rhs) {
        // ...
        return someOtherWidget; // 返回另一个 Widget 的引用
    }
    // ...
};

Widget w1, w2 ,w3;
w1 += w2 += w3; 

六. 在 operator= 中处理“自我赋值”

自我赋值是合法的操作,但在一些情况下可能会导致意外的错误,例如在复制堆上的资源时:

请注意,在这里删除原来的 Resource 对象是必须的。否则会:

  1. 内存泄露 : *this 指向的原来的 Resource 对象没有被删除,导致内存泄露。
  2. 悬垂指针:如果 rhs 被销毁了或者更改其指向,则*this的 pRes 将会变成悬垂指针,指向无效的内存。
  3. 牵一发而动全身: 由于*this 和 rhs 共享同一个 Resource 对象。如果其中一个进行了修改这个对象,另外一个对象也会收到牵连。

Widget& operator+=(const Widget& rhs) {
delete pRes; // 删除当前持有的资源
pRes = new Resource(*rhs.pRes); // 复制传入的资源
return this;
}
但若rhs和
this指向的是相同的对象,就会导致访问到已删除的数据。

最简单的解决方法是在执行后续语句前先进行证同测试(Identity test):

Widget& operator=(const Widget& rhs) {
if (this == &rhs) return *this; // 若是自我赋值,则不做任何事

delete pRes;
pRes = new Resource(*rhs.pRes);
return *this;

}
另一个常见的做法是只关注异常安全性,而不关注是否自我赋值:

Widget& operator=(const Widget& rhs) {
Resource* pOrigin = pRes; // 先记住原来的pRes指针
pRes = new Resource(*rhs.pRes); // 复制传入的资源
delete pOrigin; // 删除原来的资源
return *this;
}
仅仅是适当安排语句的顺序,就可以做到使整个过程具有异常安全性。

还有一种取巧的做法是使用 copy and swap 技术,这种技术聪明地利用了栈空间会自动释放的特性,这样就可以通过析构函数来实现资源的释放:

Widget& operator=(const Widget& rhs) {
Widget temp(rhs);
std::swap(*this, temp);
return *this;
}
上述做法还可以写得更加巧妙,就是利用按值传参,自动调用构造函数:

Widget& operator=(Widget rhs) {
std::swap(*this, rhs);
return *this;
}
这种写法也被叫做拷贝交换技术。

七. 拷贝复制对象的时候应该考虑全面

当手动实现拷贝构造函数或者拷贝赋值运算符的时候,忘记赋值任何一个成员都可能会导致意外的错误.
当使用继承的时候,继承自基类的成员往往容易忘记在派生类中完成赋值,如果你的基类拥有拷贝构造函数和拷贝赋值运算符,应该记得及时调用他们.

class PriorityCustomer : public Customer { 
public:
	PriorityCustomer(const PriorityCustomer& rhs);
	PriorityCustomer& operator=(const PriorityCustomer& rhs);
	...
private:
	int priority;
};

PriorityCustomer::PriorityCustomer(const PriorityCustomer& rhs) : Customer(rhs), priority(rhs.priority) {
	...
}

PriorityCustomer::PriorityCustomer& operator=(const PriorityCustomer& rhs) { 
	Customer::operator=(rhs);
	priority = rhs.priority;
	return *this;
}

请注意,根据CPP 类型安全原则,将派生类赋值给基类是允许的,因为派生类包含比基类更多的信息,而反过来却不行。请注意在这里调用了Customer::operator=(rhs); 在这个函数内部修改了this指针,所以尽管没有用this来接收它,Customer也已经改变了。


  • 23
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值