类
如果仅有数据成员,使用结构体,其他使用类
构造,拷贝构造,赋值和析构函数
构造,拷贝,移动和析构函数提供了对象的生命周期管理方法:
- 构造函数(constructor):
X()
- 拷贝构造函数(copy constructor):
X(const X&)
- 拷贝赋值操作符(copy assignment):
operator=(const X&)
- 移动构造函数(move constructor):
X(X&&)
C++11以后提供 - 移动赋值操作符(move assignment):
operator=(X&&)
C++11以后提供 - 析构函数(destructor):
~X()
规则7.1.1 类的成员变量必须显式初始化
说明:如果类有成员变量,没有定义构造函数,又没有定义默认构造函数,编译器将自动生成一个构造函数,但编译器生成的构造函数并不会对成员变量进行初始化,对象状态处于一种不确定性。
例外:
- 如果类的成员变量具有默认构造函数,那么可以不需要显式初始化。示例:如下代码没有构造函数,私有数据成员无法初始化:
class Message {
public:
void ProcessOutMsg() {
//…
}
private:
unsigned int msgID;
unsigned int msgLength;
unsigned char* msgBuffer;
std::string someIdentifier;
};
Message message; // message成员变量没有初始化
message.ProcessOutMsg(); // 后续使用存在隐患
// 因此,有必要定义默认构造函数,如下:
class Message {
public:
Message() : msgID(0), msgLength(0) {
}
void ProcessOutMsg() {
// …
}
private:
unsigned int msgID;
unsigned int msgLength;
unsigned char* msgBuffer;
std::string someIdentifier; //具有默认构造函数,不需要显式初始化
};
建议7.1.1 成员变量优先使用声明时初始化(C++11)和构造函数初始化列表初始化
说明:C++11的声明时初始化可以一目了然的看出成员初始值,应当优先使用。如果成员初始化值和构造函数相关,或者不支持C++11,则应当优先使用构造函数初始化列表来初始化成员。相比起在构造函数体中对成员赋值,初始化列表的代码更简洁,执行性能更好,而且可以对const成员和引用成员初始化。
class Message {
public:
Message() : msgLength(0) { // Good,优先使用初始化列表
msgBuffer = NULL; // Bad,不推荐在构造函数中赋值
}
private:
unsigned int msgID{0}; // Good,C++11中使用
unsigned int msgLength;
unsigned char* msgBuffer;
};
规则7.1.2 为避免隐式转换,将单参数构造函数声明为explicit
说明:单参数构造函数如果没有用explicit声明,则会成为隐式转换函数。示例:
class Foo {
public:
explicit Foo(const string& name): name(name) {
}
private:
string name;
};
void ProcessFoo(const Foo& foo){}
int main(void) {
std::string test = "test";
ProcessFoo(test); // 编译不通过
return 0;
}
上面的代码编译不通过,因为ProcessFoo
需要的参数是Foo类型,传入的string类型不匹配。
如果将Foo构造函数的explicit关键字移除,那么调用ProcessFoo
传入的string就会触发隐式转换,生成一个临时的Foo对象。往往这种隐式转换是让人迷惑的,并且容易隐藏Bug,得到了一个不期望的类型转换。所以对于单参数的构造函数是要求explicit声明。
规则7.1.3 如果不需要拷贝构造函数、赋值操作符 / 移动构造函数、赋值操作符,请明确禁止
说明:如果用户不定义,编译器默认会生成拷贝构造函数和拷贝赋值操作符, 移动构造和移动赋值操作符(移动语义的函数C++11以后才有)。如果我们不要使用拷贝构造函数,或者赋值操作符,请明确拒绝:
- 将拷贝构造函数或者赋值操作符设置为private,并且不实现:
class Foo {
private:
Foo(const Foo&);
Foo& operator=(const Foo&);
};
- 使用C++11提供的delete, 请参见后面现代C++的相关章节。
规则7.1.4 拷贝构造和拷贝赋值操作符应该是成对出现或者禁止
拷贝构造函数和拷贝赋值操作符都是具有拷贝语义的,应该同时出现或者禁止。
// 同时出现
class Foo {
public:
...
Foo(const Foo&);
Foo& operator=(const Foo&);
...
};
// 同时default, C++11支持
class Foo {
public:
Foo(const Foo&) = default;
Foo& operator=(const Foo&) = default;
};
// 同时禁止, C++11可以使用delete
class Foo {
private:
Foo(const Foo&);
Foo& operator=(const Foo&);
};
规则7.1.5 移动构造和移动赋值操作符应该是成对出现或者禁止
在C++11中增加了move操作,如果需要某个类支持移动操作,那么需要实现移动构造和移动赋值操作符。
移动构造函数和移动赋值操作符都是具有移动语义的,应该同时出现或者禁止。
// 同时出现
class Foo {
public:
...
Foo(Foo&&);
Foo& operator=(Foo&&);
...
};
// 同时default, C++11支持
class Foo {
public:
Foo(Foo&&) = default;
Foo& operator=(Foo&&) = default;
};
// 同时禁止, 使用C++11的delete
class Foo {
public:
Foo(Foo&&) = delete;
Foo& operator=(Foo&&) = delete;
};
规则7.1.6 禁止在构造函数和析构函数中调用虚函数
说明:在构造函数和析构函数中调用当前对象的虚函数,会导致未实现多态的行为。在C++中,一个基类一次只构造一个完整的对象。
示例:类Base是基类,Sub是派生类
class Base {
public:
Base();
virtual void Log() = 0; // 不同的派生类调用不同的日志文件
};
Base::Base() { // 基类构造函数
Log(); // 调用虚函数Log
}
class Sub : public Base {
public:
virtual void Log();
};
当执行如下语句:Sub sub;
会先执行Sub的构造函数,但首先调用Base的构造函数,由于Base的构造函数调用虚函数Log,此时Log还是基类的版本,只有基类构造完成后,才会完成派生类的构造,从而导致未实现多态的行为。同样的道理也适用于析构函数。
继承
规则7.2.1 基类的析构函数应该声明为virtual
说明:只有基类析构函数是virtual,通过多态调用的时候才能保证派生类的析构函数被调用。
示例:基类的析构函数没有声明为virtual导致了内存泄漏。
class Base {
public:
virtual std::string getVersion() = 0;
~Base() {
std::cout << "~Base" << std::endl;
}
};
class Sub : public Base {
public:
Sub() : numbers(NULL) {
}
~Sub() {
delete[] numbers;
std::cout << "~Sub" << std::endl;
}
int Init() {
const size_t numberCount = 100;
numbers = new (std::nothrow) int[numberCount];
if (numbers == NULL) {
return -1;
}
...
}
std::string getVersion() {
return std::string("hello!");
}
private:
int* numbers;
};
int main(int argc, char* args[]) {
Base* b = new Sub();
delete b;
return 0;
}
由于基类Base的析构函数没有声明为virtual,当对象被销毁时,只会调用基类的析构函数,不会调用派生类Sub的析构函数,导致内存泄漏。
规则7.2.2 禁止虚函数使用缺省参数值
说明:在C++中,虚函数是动态绑定的,但函数的缺省参数却是在编译时就静态绑定的。这意味着你最终执行的函数是一个定义在派生类,但使用了基类中的缺省参数值的虚函数。为了避免虚函数重载时,因参数声明不一致给使用者带来的困惑和由此导致的问题,规定所有虚函数均不允许声明缺省参数值。示例:虚函数display缺省参数值text是由编译时刻决定的,而非运行时刻,没有达到多态的目的:
class Base {
public:
virtual void Display(const std::string& text = "Base!") {
std::cout << text << std::endl;
}
virtual ~Base(){}
};
class Sub : public Base {
public:
virtual void Display(const std::string& text = "Sub!") {
std::cout << text << std::endl;
}
virtual ~Sub(){}
};
int main() {
Base* base = new Sub();
Sub* sub = new Sub();
...
base->Display(); // 程序输出结果: Base! 而期望输出:Sub!
sub->Display(); // 程序输出结果: Sub!
delete base;
delete sub;
return 0;
};
规则7.2.3 禁止重新定义继承而来的非虚函数
说明:因为非虚函数无法实现动态绑定,只有虚函数才能实现动态绑定:只要操作基类的指针,即可获得正确的结果。
示例:
class Base {
public:
void Fun();
};
class Sub : public Base {
public:
void Fun();
};
Sub* sub = new Sub();
Base* base = sub;
sub->Fun(); // 调用子类的Fun
base->Fun(); // 调用父类的Fun
//...
多重继承
在实际开发过程中使用多重继承的场景是比较少的,因为多重继承使用过程中有下面的典型问题:
- 菱形继承所带来的数据重复,以及名字二义性。因此,C++引入了virtual继承来解决这类问题;
- 即便不是菱形继承,多个父类之间的名字也可能存在冲突,从而导致的二义性;
- 如果子类需要扩展或改写多个父类的方法时,造成子类的职责不明,语义混乱;
- 相对于委托,继承是一种白盒复用,即子类可以访问父类的protected成员, 这会导致更强的耦合。而多重继承,由于耦合了多个父类,相对于单根继承,这会产生更强的耦合关系。多重继承具有下面的优点:多重继承提供了一种更简单的组合来实现多种接口或者类的组装与复用。
所以,对于多重继承的只有下面几种情况下面才允许使用多重继承。
建议7.3.1 使用多重继承来实现接口分离与多角色组合
如果某个类需要实现多重接口,可以通过多重继承把多个分离的接口组合起来,类似 scala 语言的 traits 混入。
class Role1 {};
class Role2 {};
class Role3 {};
class Object1 : public Role1, public Role2 {
// ...
};
class Object2 : public Role2, public Role3 {
// ...
};
在C++标准库中也有类似的实现样例:
class basic_istream {};
class basic_ostream {};
class basic_iostream : public basic_istream, public basic_ostream {
};
重载
重载操作符要有充分理由,而且不要改变操作符原有语义,例如不要使用 ‘+’ 操作符来做减运算。操作符重载令代码更加直观,但也有一些不足:
- 混淆直觉,误以为该操作和内建类型一样是高性能的,忽略了性能降低的可能;
- 问题定位时不够直观,按函数名查找比按操作符显然更方便。
- 重载操作符如果行为定义不直观(例如将‘+’ 操作符来做减运算),会让代码产生混淆。
- 赋值操作符的重载引入的隐式转换会隐藏很深的bug。可以定义类似Equals()、CopyFrom()等函数来替代=,==操作符。