手头上有一本 Scott Meyes 的 Effective C++(3rd Edition),虽然中文的出版时间是感人的2011年(也就是说C++11的那些新特性都没讨论了),但看网上的一些评论,此书还是值得一读的。(PS:作者针对C++11和14的新特性有本新书 Effective Modern C++。。
Day 1:
Item 1: View c++ as a federation of languages
我果然还是喜欢在开头说一点总结性的东西。这里对条款1做一点记录。。
C++是一个多重范型编程语言,一个简单理解C++的方法是把C++视为多个次语言(sublanguage)组成的联邦,次语言之间的守则可能会不一致。次语言共有4个:C,面向对象(Object-Oriented C++),模版 (Template C++),STL。
一个例子:对内置类型(C-like)而言,pass-by-value通常比pass-by-reference高效;对用户自定义的对象,由于构造和析构函数的存在,pass-by-reference-to-const往往更好;而在传递STL迭代器(iterator)和函数对象(function object)时,pass-by-value则更好,因为它们都是基于C指针实现的。
Item 2: Prefer consts, enums and inlines to #define
#define只是预处理阶段的文本替换,我们要尽量利用编译器提供更多信息。比较常用的就是const代替#define的常量,用inline函数代替#define的宏函数。这两个平时都用的很多了。enum只是在下面很特定的例子中有用:
// 1.h
class A {
public:
static const int x = 5;
int y[x];
};
这里希望声明一个常量x,它以类成员形式出现,而且所有对象只保存它的一个副本。于是就有了这么一个static const的成员变量,但是旧编译器并不允许你在static变量声明时赋值,#define又无法体现x的作用域,这时就可以使用enum:
// new_1.h
class A {
public:
enum { x = 5 };
int y[x];
};
很无语是吧。。是个挺过时的故事。值得一说的是,你如果想要取1.h中x的地址(其实也是挺奇怪的,相当于取常量地址),是需要重新声明一下这个成员变量的:
// 1.cpp
#include "1.h"
#include<iostream>
const int A::x; // already set value to 5, if not, assign a value here
int main() {
A a;
std::cout << a.x << std::endl; // ok with or without the redeclaration
std::cout << &(a.x) << std::endl; // must redeclare A::x
return 0;
}
Day 2:
Item 3: Use const whenever possible
const是一个神奇的关键字,它是C++语法的一部分,却允许你对程序的语义做出限制。应该尽可能在程序中使用const关键字,这样编译器能够帮你做更多。
class Rational {
const Rational operator * (const Rational &lhs, const Rational &rhs) {...}
};
int main() {
Rational a, b, c;
// should be "==", but will pass compilation if "const" missed
if((a * b) = c) {...}
}
const在指针上有两种语义:1)指针本身不能指向其他地址;2)指针所指地址的值不能变动。分别对应:
char greeting[] = "hello";
char* const p1 = greeting;
const char* p2 = greeting;
Item 1中提到了,STL的迭代器是基于指针的。在STL中这两类迭代器分别对应:
std::vector<int> vec;
const std::vector<int>::iterator iter1 = vec.begin();
std::vector<int>::const_iterator iter2 = vec.begin();
const在C++中如此重要的另一个因素是在用户自定义类型上,pass-by-reference-to-const比pass-by-value高效。因此,应当尽可能多用const关键词修饰成员函数,已方便在const对象上的操作。
关于一个成员函数是否能用const关键字,编译器关心的是bitwise constness,即函数内部没有对成员变量的赋值;而程序员更应当关心程序事实运行中的logical constness,即有些变量可能会被修改,但需要保持const的变量事实上是不会变的,两个例子:
// a program to "beat" bitwise constness
// we only apply const function on const object, but the string value changed
#include<iostream>
#include<cstring>
class Text {
char *content;
public:
// ...
char& operator [] (std::size_t idx) const {
return content[idx];
}
};
int main() {
char greeting[] = "hello";
const Text text = Text(greeting);
char *p = &text[0];
*p = 'j';
std::cout << text[0] << std::endl;
return 0;
}
注意,这个例子并不是说编译器错了,事实上它的确满足bitwise constness原则,content指针的值并没有发生变化。但我们明显是希望content所指向的字符串也保持不变。事实上,这是使用指针的问题,在重载[]
时返回const char&或者用string类型作为成员变量,编译器都能发现这段程序的错误行为。
// a snippet explaining "logical constness"
// the const function getLength() updates two variables
// and they are declared as mutable
class Text {
char *content;
mutable std::size_t length;
mutable bool lengthIsValid;
public:
// ...
std::size_t getLength() const {
if(!lengthIsValid) {
length = strlen(content);
lengthIsValid = true;
}
return length;
}
};
最后,很多时候我们会为const对象实现一个const成员函数,为非const对象实现一个非const成员函数。他们行为几乎完全一样,如上述重载[]
前要做一系列合法性检查。那么为了减少代码冗余,一个常见的操作是用非const函数调用const函数,这里展示一下C++风格的类型转换写法:
const char& operator [] (std::size_t idx) const {
// many lines of code
return content[idx];
}
char& operator [] (std::size_t idx) {
return const_cast<char&>(static_cast<const Text&>(*this)[idx]);
}
Day 3:
Item 4: Make sure objects are initialized before they're used
C++中来自C语言的数据类型是不保证初始化的,因此请手动初始化所有的内置类型。
对于用户定义类型,请使用成员初值列(member initialization list)代替赋值操作,这是出于效率上的考虑:
// constructor in assignment style
A::A(const string &s, const std::list<int> l) {
str = s; lst = l;
}
// a more efficient constructor
A::A(const string &s, const std::list<int> l): str(s), lst(l) {}
具体来说,对于A类的成员变量str和lst(它们都不是内置类型),它们的初始化时间在调用A构造函数之前,也就是说在上面的写法中,str和lst先用各自的默认构造函数初始化后,又进行了一次赋值;而在下面的写法中,它们就是用参数值初始化的。
对于跨文件的static变量,请用local-static对象替换non-local static对象。因为跨文件的static变量初始化顺序是不确定的,而有时我们必须指定特定顺序,举例而言:
// fs.h
class FileSystem {
public:
// lines of code
void foo() {}
};
FileSystem& fs() {
static FileSystem fsObj;
return fsObj;
}
// fs.cpp
tfs().foo();
调用全局变量就不太好了:
// fs.cpp
extern FileSystem fs;
fs.foo();
当有多个跨文件的全局变量时,上面写法中FileSystem对象的创建时间是可控的,下面则可能造成多个对象创建顺序的混乱。
Day 4:
Item 5: Know what functions C++ silently writes and calls
你写了一个空的类(只声明了一些成员变量),C++编译器会按需为你生成default构造函数,copy构造函数,析构函数以及重载赋值符号。而一旦你声明了对应的函数,C++就不会再默默为你做这些事。
class Empty {};
// the above code equals below
class Empty {
Empty() { ... }
Empty(const Empty &rhs) { ... }
~Empty() { ... }
Empty& operator = (const Empty &rhs) { ... }
};
这些默认函数的行为都是naive的,对于基础类型,copy构造函数和赋值只做简单的bits拷贝。对于string这样定义了copy构造函数和赋值的类型,则会调用对应函数。
这种naive方法当然有失效的时候,比如你定义了一个const成员变量,或者你的成员变量是一个引用,那么很明显bits拷贝的方法是不行的,你必须手动初始化这两类变量。编译器会提示你显式地编写构造函数,copy赋值函数,重载赋值符。
Day 5:
Item 6: Explicitly disallow the use of compiler-generated functions you do not want
有些类不应当存在拷贝操作,让编译器帮助你的方法是将这个类的copy构造函数和赋值重载声明为private
class UnCopyable {
UnCopyable(const UnCopyable&); // parameter name can be skipped
UnCopyable& operator = (const UnCopyable&);
public:
// lines of code
};
为了进一步防止成员函数和友元函数调用赋值,你需要使这两个private函数的函数体为空。这样如果你在成员函数或友元函数中使用了赋值,连接器(linker)会报错。
Item 7: Declare destructors virtual in polymorphic base classes
先看一份代码:
#include <iostream>
using namespace std;
class A {
public:
int x;
virtual ~A() {
cout << "Destructor A is called" << endl;
}
};
class B: public A {
public:
int y;
~B() {
cout << "Destructor B is called" << endl;
}
};
class C {
public:
int x;
~C() {
cout << "Destructor C is called" << endl;
}
};
int main() {
A *ptr = new B();
delete ptr;
cout << "size A: " << sizeof(A) << endl;
cout << "size C: " << sizeof(C) << endl;
return 0;
}
运行结果:
Compiler: GNU G++14 6.4.0
Ouput:
Destructor B is called
Destructor A is called
size A: 8
size C: 4
这份代码告诉我们两件事:
1)不要无端的将一个类的某些函数声明为virtual,这会使你的类体积变大,其原因是有虚函数的类会有一个虚指针(virtual table pointer),用于指向运行时实际调用的函数。
2)如果一个类是作为基类使用,并且会设计多态,那么一定要将构造函数声明为virtual,否则示例代码中只会调用A类的析构函数,从而B类在堆中为y申请的内存会发生泄漏。
Day 6:
Item 8: Prevent exceptions from leaving destructors
C++中当有多个异常被抛出时,程序不是结束执行就是导致不明确行为。而如果在析构函数中抛出异常,那么这样的情况非常容易发生,比如销毁一个vector!因此,绝对不应该让析构函数抛出异常,而是应该在析构函数中就对其捕捉并处理(考虑刚才提到的危害,在析构函数中发现异常后直接退出程序也是可以接受的)。
另一个做法是将可能抛出异常的部分代码独立出来作为一个成员函数,让用户手动去调用并处理可能带来的异常。类本身也可以设置一个变量记录进入析构函数时这个可能抛出异常的程序是否已经被调用。如果没有,那么捕获并处理掉这个异常,如果已经被调用,那么就可以摆脱异常处理这一烦恼了!这相当于是将部分风险责任分担给了用户从而获得的“双保险”做法。
Item 9: Never call virtual functions during constructor or destructor
虚函数的作用是在多态环境下调用正确类的函数。而在构造函数和析构函数中使用虚函数则会无法保证这一点,这可能会带来困惑。究其原因是一个派生类在调用自己的构造函数前,会先调用基类的构造函数,此时这个派生类对象的运行时类型(runtime type information)就会是基类!如果基类的构造函数中调用了虚函数,这个虚函数是没有办法指向派生类中的实现的!析构函数的道理类似。看完这段,不如猜一猜下面代码的输出吧!
#include <iostream>
class A {
int x;
public:
A() {
std::cout << "Construct A" << std::endl;
foo();
}
virtual void foo() {
std::cout << "A::foo() invocation" << std::endl;
}
~A() {
std::cout << "Destruct A" << std::endl;
foo();
}
};
class B: A {
int y;
public:
B() {
std::cout << "Construct B" << std::endl;
}
void foo() {
std::cout << "B::foo() invocation" << std::endl;
}
~B() {
std::cout << "Destruct B" << std::endl;
}
};
int main() {
B b;
return 0;
}
正确答案:
Construct A
A::foo() invocation
Construct B
Destruct B
Destruct A
A::foo() invocation