05 了解C++默默编写并调用哪些函数
- 写下一个空类,编译器会为它声明一个copy构造函数。一个copy assignment操作符和一个析构函数
class A {};
// 相当于写了下列代码
class A {
public:
A() { ... }
A(const A& rhs) { ... }
~A() { ... }
A& operator=(const A& rhs) { ... }
};
// 只有当这些函数被调用才会被编译器创建
A a1; // default构造函数
A a2(a1); // copy构造函数
a2 = a1; // copy assignment操作符
- 如果声明了构造函数,编译器就不会创建default构造函数,此时如果自己没有写default构造函数编译器会报错。编译器创建的copy构造函数和copy assignment操作符只是单纯将来源对象的每个non-static成员变量拷贝到目标对象
template<typename T>
class NameObject {
public:
NameObject(const char* name, const T& value);
NameObject(const std::string& name, const T& value);
...
private:
std::string nameValue;
T objectValue;
};
NameObject<int> no1("Smallest Prime Number", 2);
NameObject no2(no1); // copy构造函数
- 编译器生成的copy构造函数中,nameValue类型为string类,所以会调用string的copy构造函数,而objectValue则是内置类型,会通过拷贝no1.objectValue内的每一个bits完成初始化。编译器生成的copy assignment操作符行为和copy构造函数类似,但如果class中含有引用或const成员编译器会拒绝生成operator=(但用VS2017测试仍会生成operator=)
template<typename T>
class NameObject {
public:
// 构造函数不再接受const,因为nameValue变成了reference to non-const string
NameObject(std::string& name, const T& value);
...
private:
std::string& nameValue; // 如今是reference
const T objectValue; // 如今是const
};
std::string newDog("Persephone");
std::string oldDog("Satch");
NameObject<int> p(newDog, 2);
NameObject<int> s(oldDog, 36);
p = s; // 这步编译器会拒绝生成
- 原因在于,用s.nameValue赋给p.nameValue之后,导致了p.nameValue绑定的对象本身改变了(根据测试并没有改变绑定的对象),其他指向该string的pointer或reference都会被影响,所以在内含reference或const成员的class中要自己定义copy assignment操作符。还有一种情况是,如果base class将copy assignment操作符声明为private,编译器也会拒绝为deriverd class生成copy assignment操作符(声明为private成员是原始的阻止拷贝的手法,C++11中的手法是将函数声明为=delete),因为即使生成了也无权处理base class的成员
// 思考下列代码就明白了
string s1("hello");
string s2("world");
string& rs1 = s1;
string& rs2 = s2;
string* ps = &s1;
rs1 = rs2;
cout << *ps; // 打印出"world",s1此时变成了s2,ps指的内容被影响了
06 若不想使用编译器自动生成的函数,就该明确拒绝
- 如果想阻止copy,但调用时编译器会自动生成怎么办?一个办法在private中只声明不定义,即使member函数和friend函数可以调用private函数。但没有定义会得到一个连接错误
class A {
public:
...
private:
...
A(const A&); // 只有声明
A& operator=(const A&);
};
- 不过如果希望一个class阻止copy,并不是自身这样写,而是写在一个专门用来阻止拷贝的base class中然后继承它
class Uncopyable{
protected:
Uncopyable() {}
~Uncopyable() {}
private:
Uncopyable(const Uncopyable&);
Uncopyable& operator=(const Uncopyable&);
};
class A : private Uncopyable { // class不再声明copy构造函数或copy assignment操作符
...
};
- C++11通过将拷贝构造函数和拷贝赋值运算符定义为删除的函数(deleted function)来阻止拷贝,在函数参数列表后加上=delete指明。虽然删除的函数被声明了,但是不能以任何方式使用。不能删除析构函数,因为如果析构函数被删除,就无法销毁此类型的对象
struct NoCopy
{
NoCopy() = default;
// 和=default不同,=delete必须出现在函数第一次声明的时候
NoCopy(const NoCopy&) = delete; // 阻止拷贝
NoCopy& operator=(const NoCopy&) = delete; // 阻止赋值
~NoCopy() = default;
// ...
}
07 为多态基类声明virtual析构函数
- 若基类指针指向派生类,基类中的析构函数不是虚函数,delete该指针只能销毁基类的部分,派生类的部分并未被销毁
class TimerKeeper {
public:
TimeKeeper();
~TimeKeeper();
...
};
// 设计一个计时器,派生一些不同计时方法的类,如原子钟、水钟、腕表
// 然后设置一个factory函数返回指向一个派生类对象的基类指针
TimeKeeper* getTimeKeeper(); // 为了遵守factory函数的规矩,返回对象应该在heap上
TimeKeeper* ptk = getTimeKeeper();
// 避免内存泄漏,delete
delete ptk; // 只能局部销毁
class TimerKeeper {
public:
TimeKeeper();
virtual ~TimeKeeper();
...
};
TimeKeeper* ptk = getTimeKeeper();
delete ptk;
- 标准string也不含virtual函数,不要错误地把它当基类用,所有的STL容器同理
- 也不要无端声明虚函数,虚函数的原理是通过一个vptr(virtual table pointer)指向一张由函数指针构成的数组表,即vtbl(virtual table),对象调用virtual函数时实际调用的函数取决于vptr所指的vtbl。假设一个class仅有两个int成员,此时加一个vptr相当于增加了50%-100%大小(32位系统2个int占64bits,加上vptr要96bits,64位则可能vptr占64bits,要128bit),这样导致对象塞不进一个64bit缓存器,而C++的类对象也不再和其他语言(如C,没有vptr)的相同声明有一样的结构,就失去了移植性
- 如果类的设计目的不是作为基类使用,或不是为了具备多态性,就不该声明virtual析构函数,较好的做法是只有当class内含至少一个virtual函数才为它声明virtual析构函数
08 别让异常逃离析构函数
class A {
public:
...
~A() { ... } // 假设这里可能吐出一个异常
};
void dosomething()
{
std::vector<A> v;
...
} // v在这里被自动销毁
- 当v被销毁,必须负责销毁内含的所有的A,因此会调用各个析构函数,假设第一个和第二个都抛出相同的异常,在两个异常同时存在的情况下,程序若不结束执行就会导致不明确行为。这很容易理解,如果析构函数必须执行一个可能在失败时抛出异常的动作怎么办?
// 假设用一个class负责数据库连接
class DBConnection {
public:
...
static DBConnection create(); // 为求简化暂略参数
void close();
};
// 为了保证客户不忘记调用close,创建一个管理DBConnection的class
class DBConn {
public:
...
~DBConn { db.close(); }
private:
DBConnection db;
};
// 这允许客户写出下面的代码
{
DBConn dbc(DBConnection::create());
...
}
- 如果最后的class调用异常,DBConn析构函数会传播异常,允许它离开这个析构函数,就会造成最初的问题。有两个办法来避免此问题,一是如果抛出异常就结束程序
// 一个方法是调用abort,如果抛出异常就结束程序
DBConn::~DBConn()
{
try { db.close(); }
catch(...) {
// 制作运转记录,记录对close的调用失败
std::abort();
}
}
// 另一个是吞下异常,但这通常也意味着吞掉了发生错误的信息
DBConn::~DBConn()
{
try { db.close(); }
catch(...) {
// 制作运转记录,记录对close的调用失败
}
}
- 但这两个方法都没有对抛出异常的情况做出反应,一个较好的办法是重新设计DBConn接口让客户能对可能出现的问题作出反应,比如自己提供一个close函数
class DBConn {
public:
...
void close()
{
db.close();
closed = true;
}
~DBConn()
{
if(!closed) {
try { // 关闭连接,如果客户不那么做的话
db.close();
}
catch(...) {
//制作运转记录,记录对close的调用失败
// ...
}
}
}
private:
DBConnection db;
bool closed;
};
- 析构函数绝对不要吐出异常,如果析构函数调用的函数可能抛出异常,析构函数应该捕捉任何异常,然后吞掉或结束程序。如果客户需要对某个操作函数运行期间抛出的异常做出反应,那么class应该提供一个普通函数而非在析构函数中执行该操作
09 绝不在构造和析构过程中调用virtual函数
class A {
public:
A(...);
virtual void f() const = 0;
...
};
A::A(...)
{
...
f(...);
}
class B : public A {
public:
virtual void f(...) const;
...
};
B b; // 此步会发生问题
- 上述代码最后一句的问题是,B的构造函数被调用前,先调用A的构造函数构造基类部分,而A::A()中调用的是A::f(),因为基类构造期间virtual函数不会下降到派生类,也可以理解为基类构造期间,virtual函数不是virtual函数。基类的构造函数先执行,如果此期间调用的virtual函数下降到派生类,派生类的函数可能取用local变量,而此时这些成员变量尚未初始化,这样会造成不明确的行为
- 更根本的原因是,派生类对象的base class构造期间,对象类型是base class,不只virtual函数会被编译器解析至base class,若用RTTI(dynamic_cast和typeid)也会把对象视为base class类型。析构函数同理,派生类析构函数执行,对象内的派生类成员就会变为未定义,进入基类析构函数后对象就成为了一个基类对象
- 既然无法使virtual函数下降,可以在构造期间令派生类通过一个辅助函数将构造信息上递至基类构造函数,令此函数为static就不可能意外指向B对象内未初始化部分
class A {
public:
A(...);
void f(...) const; // 现在不是virtual函数
...
};
A::A(...)
{
...
f(...);
}
class B : public A {
public:
B(...) : A(f2(...)) {}
...
private:
static ... f2(...);
};
10 令operator=返回一个reference to *this
class A {
public:
...
A& operator=(const A& rhs)
{
...
return *this;
}
...
};
11 在operator=中处理“自我赋值”
a[i] = a[j]; // 潜在的自我赋值
*px = *py;
class A { ... };
class B {
...
private:
A* p;
};
B& B::operator=(const B& rhs)
{
delete p;
p = new A(*rhs.p); // *this和rhs可能是同一对象
return *this;
}
- 为了防止这种错误,传统做法是在开始加一句identity test,不过这样仍然不具备异常安全性,如果new A异常,B最终指向一块被删除的A
B& B::operator=(const B& rhs)
{
if(this == &rhs) return *this;
delete p;
p = new A(*rhs.p); // *this和rhs可能是同一对象
return *this;
}
- 为了异常安全性,更好的做法是不去验证,只需要在复制p所指对象前不要删除p,这样如果new A抛出异常,p保持原状
B& B::operator=(const B& rhs)
{
A* orip = p;
p = new A(*rhs.p); // *this和rhs可能是同一对象
delete orip;
return *this;
}
B& B::operator=(const B& rhs)
{
A* newp = new A(*rhs.p);
delete p;
p = newp;
return *this;
}
- 在operator=函数中手工排列语句的一个替代方案是使用copy and swap技术
class B {
...
void swap(B& rhs);
...
};
void B::swap(B& rhs)
{
using std::swap;
swap(p, rhs.p);
}
B& B::operator=(const B& rhs)
{
B tmp(rhs); // 调用拷贝构造函数
swap(tmp); // 将*this数据和上述复件的数据交换
return *this;
} // 析构tmp
- 不过这里仍要注意自赋值的问题,构造的tmp对象在离开operator=时被析构,因此其中的成员p也会被析构,若是自赋值,则在operator=结束时,赋值后的p被析构。为了避免此问题,如果不使用临时量而直接swap,则会导致用来赋值的对象被交换掉
B& B::operator=(const B& rhs)
{
swap(rhs); // rhs绑定的对象现在被交换了
return *this;
}
12 复制对象时勿忘其每一个成分
- 通常在修改代码时,如果添加成员,容易忘记在拷贝函数中添加此成员,而编译器不会报错,所以在编写copying函数时要确保复制所有local成员变量,调用所有base class中适当的copying函数
- 两个copying函数往往有近似相同的实现本体,但不要通过令某个copying函数调用另一个来避免代码重复,消除重复的做法是,建立一个新的成员函数给两者调用,一般是private且常命名为init
网友评论