文章目录
5. 了解 C++ 默默编写并调用了哪些函数
5.1 C++ 为 class 默认生成的四个函数
表面上写下了如下的代码:
class Empty();
但实际上,C++ 会生成这个类的默认构造函数、拷贝构造函数、析构函数、赋值运算符,如下:
class Empty {
public:
Empty() { ... } // 默认构造函数
Empty(const Empty& rhs) { ... } // 拷贝构造函数
~Empty() { ... } // 析构函数
Empty& operator=(const Empty& rhs) { ... } // 赋值运算符
};
如果某个类没有显式声明上述四种函数的某几种,那么 C++ 编译器就会创建对应的默认函数(如果这种函数被调用的话)。
而面对编写程序时各种各样的情况,可能是设计需求,也有可能是避免不可预期的错误,仅依靠 C++ 编译器创建的 4 个默认函数是远远不够的,通常需要程序员显式声明。
6. 若不想使用编译器自动生成的函数,就该明确拒绝
6.1 不允许拷贝构造的类
通常想要某个 class
没有某个功能,只要不声明这个函数就好了。可是拷贝构造函数和赋值运算符不一样,就算你不声明,C++ 编译器也会偷偷地声明。
这时就可以用 private
“关闭” 某个类的拷贝构造函数和赋值运算符。
class Uncopyable {
protected:
Uncopyable() {} // 允许派生类对象的构造和析构
~Uncopyable() {}
private:
Uncopyable(const Uncopyable&); // 禁止拷贝和赋值运算符(可以省略掉参数名)
Uncopyable& operator=(const Uncopyable&);
};
把 Uncopyable
当作基类,其派生类会有一些奇妙的性质,这种借助基类实现某些功能的设计方式很常见。
7. 为多态基类声明 virtual 析构函数
面试超级常见问题:“为什么基类的析构函数要声明为虚函数?”
7.1 基类的 non-virtual 析构函数会引发内存泄漏
class Fruit { // 基类
public:
Fruit();
~Fruit();
};
class Apple : public Fruit { ... }; // 派生类
class Banana : public Fruit { ... }; // 派生类
class Orange : public Fruit { ... }; // 派生类
Fruit* pFurit = new Apple(); // 基类指针指向派生类对象,为了实现多态,这种方式很常见
在上面代码中,如果析构函数 Fruit::~Fruit()
没有被声明为 virtual
,那么派生类的析构函数可能没有被调用。
做法很简单,任何 class
只要带有 virtual
函数,那么应该也有一个 virtual
析构函数。
(书上这里写的和平时遇到的情况不一样,因为析构时的调用顺序是派生类的析构函数,然后是成员对象的析构函数,最后才是基类的析构函数;不过考虑到某些意外的错误使用,书上并没有说错。)
值得注意的是,理论上 C++ 的 STL 容器 vector
、list
、set
等等都是可以被继承的,如下。但是由于这些容器的析构函数未声明为 virtual
函数,因此会导致不明确行为,所以不要继承 STL 容器!(C++ 没有像 Java 那样的 final
关键词。)
class SpecialString: public std::string { // 馊主意!写出这种代码的一定是个人才!
...
};
7.2 不要无端地的声明 virtual 析构函数
根据 virtual
函数的实现原理(虚函数表指针,虚函数表等),一旦一个类内含有 virtual
函数,那么其对象的占用内存空间就会相应增加(存放虚函数表和虚函数表指针)。
所以,不要无缘无故地把所有 class
的析构函数都声明为 virtual
函数。
7.3 小结
- 多态型的基类应该声明一个
virtual
析构函数。也就是说,如果一个class
存在virtual
函数,它就应该有一个virtual
析构函数。 - 不要无端地声明
virtual
函数,会带来没必要的内存开销,有些类class
并不是作为基类的,不具有多态性。
8. 别让异常逃离析构函数
因为 C++ 开发比较少用到异常处理,这里不详细介绍原因,只说需要注意的两点:
- 析构函数不要吐出异常,连续两次抛出异常未处理后,程序会结束执行或者导致不明确行为
- 如果客户需要对某个操作函数运行期抛出的异常作出反应,那么
class
应该专门提供一个普通函数执行该操作,而不是在析构函数中
9. 绝不在构造和析构过程调用 virtual 函数
又是个被问烂了的面试题,“C++ 为什么不要在构造和析构函数中调用虚函数?”
9.1 不要在构造函数调用虚函数
假设在 Class Base
的构造函数调用虚函数 void vFunc();
,并且有 Class Derived : public Base
继承关系。
// 基类
class Base {
public:
Base() { vFunc(); } // 构造函数内调用虚函数
virtual void vFunc() = 0; // 虚函数,声明为纯虚函数也是常见的情况
...
};
// 派生类
class Derived: public Base {
public:
virtual void vFunc(); // 虚函数
...
};
// 生成派生类对象
Derived dd;
那么在生成派生类对象时 Derived dd;
,一定是先调用基类的构造函数,然后调用派生类的构造函数。
问题来了,基类的构造函数调用了虚函数 void vFunc();
,本应该调用的是派生类的版本 Derived::vFunc();
,然而此时派生类的构造函数还未调用,派生类独有的成员变量也未初始化。
这是十分危险的,C++ 绝不允许使用未初始化的成员变量!
因此,你只能使用基类版本的 Base::vFunc();
,那么这也失去了虚函数的意义。并且如果 Base::vFunc();
是纯虚函数,简直完蛋。
9.2 不要在析构函数调用虚函数
道理和构造函数相同,先调用基类的析构函数,然后调用派生类的析构函数。
派生类完成析构后,再进入基类的析构函数,而此时原本是派生类的对象就会变成相当于基类的对象。
9.3 小结
在 构造函数
和 析构函数
内不要调用 虚函数
,因为这种调用不会下降到派生类,只会停留在当前基类。
10. 令赋值运算符返回 *this 的引用
赋值运算可以写成连锁形式:
int x, y, z;
x = y = z = 3;
为了让重载的赋值运算符函数 operator=
也能支持“连锁赋值”,函数应该返回 *this
的引用。
class Widget {
public:
...
Widget& operator=(const Widget& rhs) // 返回类型是引用,该引用指向操作符左侧的对象
{
...
return* this; // 返回操作符左侧的对象
}
}
不仅是 operator=
,operator+=
、operator-=
、operator*=
等等重载运算符都可以遵循此协议。
这份协议被所有内置类型和标准程序库提供的类型遵守( string
, vector
, complex
, tr1::shared_ptr
)!
11. 在赋值运算符中处理“自我赋值”
由于别名和基类指针指向派生类对象,在赋值运算符的左右两边可能是同一个对象。
11.1 法一(证同测试)
class Bitmap { ... };
class Widget {
...
private:
Bitmap* pb;
};
Widget& Widget::operator=(const Widget& rhs)
{
if (this == &rhs) return *this; // 证同测试,如果是自我赋值就不做任何事
delete pb; // 不具备异常安全性,如果 new Bitmap 异常
pb = new Bitmap(*rhs.pb) // pb 就会指向一块被删除的区域
return *this;
}
11.2 法二 (建立副本)
Widget& Widget::operator=(const Widget& rhs)
{
// if (this == &rhs) return *this; // 证同测试会让代码体积变大,并倒入新的控制流分支
// 估计“自我赋值”发生的可能性再决定是否加入证同测试
Bitmap* pOrig = pb; // 建立副本,在 new 之前建立原始 pb 的副本
pb = new Bitmap(*rhs.pb); // 可以确保异常安全和自我赋值安全
delete pOrig;
return *this;
}
11.3 法三(copy and swap)
Widget& Widget::operator=(const Widget& rhs)
{
Widget temp(rhs); // 手动创建被传对象的副本
swap(temp); // 用自定义的 swap() 函数实现赋值
return *this;
}
11.4 法四(传值方式)
Widget& Widget::operator=(Widget rhs) // 此处不是引用,rhs是被传对象的副本
// 牺牲代码清晰性巧妙地解决了问题,有时候会更高效!
{
swap(rhs); // swap() 函数实现数据交换
return *this;
}
11.5 小结
- 确保重载赋值运算符函数在自我赋值时仍有正确行为,可以用到的技术有:“证同测试”、“手动创建副本后再 new”、“copy-and-swap”。
- 确保任何函数如果操作一个以上的对象,而其中多个对象是同一个对象时,行为仍然正确。
12. 复制对象时勿忘其每个成分(Copy all parts of an object.)
不妨把拷贝构造函数和重载赋值运算符统称为“拷贝函数”。
假设在某个 class
内部添加了新的成员变量,那么也必须同时在拷贝函数添加这些成员变量的拷贝操作,不能遗漏。
12.1 派生类的拷贝函数
// 基类
class Tree {
public:
Tree(const Tree& rhs) : name(rhs.name) {} // 拷贝构造函数
Tree& operator=(const Tree& rhs) { // 重载赋值运算符
name = rhs.name;
return *this; // 前面提到的协议记得遵守
}
private:
std::string name; // 派生类无法直接访问
};
// 派生类
class AppleTree {
public:
AppleTree(const AppleTree& rhs) : apppleCount(rhs.appleCount) {} // 拷贝构造函数
AppleTree& operator=(const AppleTree& rhs) { // 重载赋值运算符
appleCount = rhs.appleCount; // 基类的拷贝函数并没有复制基类的成分!
return *this;
}
private:
int appleCount;
};
上面的例子中,派生类无法访问基类的 private
成分,并且拷贝函数也没有参数从基类传递给派生类,于是派生类无法复制基类的成分。
因此 C++ 会擅自使用基类 Tree
的默认拷贝构造函数来初始化派生类 对象 AppleTree
的基类成分。
本例中,默认拷贝构造函数会对 Tree::name
执行缺省的初始化动作。
所以必须“手动为派生类编写拷贝函数”!保证基类的成分也被完整拷贝。
// 手动为派生类编写拷贝函数
AppleTree::AppleTree(const AppleTree& rhs)
: Tree(rhs), // 调用基类的拷贝构造函数,这在Qt编程里很常见
appleCount(rhs.appleCount)
{
}
AppleTree& AppleTree::operator=(const AppleTree& rhs)
{
Tree::operator=(rhs); // 调用基类的重载赋值运算符函数
appleCount = rhs.appleCount;
return *this;
}
12.2 拷贝函数的互相调用
- 重载赋值运算符函数调用拷贝构造函数
不合理!调用拷贝构造函数创建一个已经存在的对象?没有意义。 - 拷贝构造函数调用重载赋值运算符函数
拷贝构造函数:初始化对象
赋值运算符:用于已经初始化的对象
对还未初始化的对象赋值,没有意义!
12.3 小结
- 拷贝函数应该保证复制「对象内的所有成员变量」以及「基类的所有成分」
- 不要让重载赋值运算符和拷贝构造函数互相调用,如果为了避免代码重复,请新建
init()
函数