Effective C++读书笔记
条款1:尽量用const和inline而不用#define
1. Const成员变量和static成员变量的使用:
class MyTestClass
{
public:
MyTestClass() : m_ciInt(1), m_csStr("MyStr") // const成员变量,在ctor参数列表中初始化
{}
public:
const int m_ciInt;
const String m_csStr;
static int m_siInt;
static String m_ssStr;
const static int m_csiInt;
const static String m_cssStr;
};
int MyTestClass::m_siInt = 1; // static成员变量,在外部定义
String MyTestClass::m_ssStr = "MyStr"; // static成员变量,在外部定义
const int MyTestClass::m_csiInt = 1; // const static/static const成员变量,在外部定义
const String MyTestClass::m_cssStr = "MyStr"; // const static/static const成员变量,在外部定义
2. 尽量避免使用define 宏定义
#define max(a,b) ((a) > (b) ? (a) : (b))
可以使用下边函数模板代替:
template<class T>
inline const T& max(const T& a, const T& b)
{ return a > b ? a : b; }
条款2:尽量用<iostream>而不用<stdio.h>
1. iostream库的类和函数所提供的类型安全和可扩展性。
2. 对不同类型的读和写采用的语法形式相同,避免死记规定。
3. 如果编译器同时支持 <iostream>和<iostream.h>,那头文件名的使用会很微妙。如果使用了#include <iostream>, 得到的是置于名字空间std(见条款28)下的iostream库的元素;如果使用#include <iostream.h>,得到的是置于全局空间的同样的元素。
条款3:尽量用new和delete而不用malloc和free
1. malloc和free(及其变体)会产生问题的原因在于它们太简单,他们不知道构造函数和析构函数。Malloc是只会创建足够的空间,但是不会创建对象。Free时只会释放内存,不会调用析构函数。
2. new/delete和malloc/free的不兼容性常常会导致一些严重的复杂性问题。
条款5:对应的new和delete要采用相同的形式
1. 用new的时候会发生两件事。首先,内存被分配(通过operator new 函数,详见条款7-10和条款m8),然后,为被分配的内存调用一个或多个构造函数。用delete的时候,也有两件事发生:首先,为将被释放的内存调用一个或多个析构函数,然后,释放内存。
需要注意:说
typedef string addresslines[4]; //一个人的地址,共4行,每行一个string
//因为addresslines是个数组,使用new:
string *pal = new addresslines; // 注意"new addresslines"返回string*, 和
// "new string[4]"返回的一样
delete时必须以数组形式与之对应:
delete pal;// 错误!
delete [] pal;// 正确
条款6:析构函数里对指针成员调用delete
·在每个构造函数里对指针进行初始化。对于一些构造函数,如果没有内存要分配给指针的话,指针要被初始化为0(即空指针)。
·删除现有的内存,通过赋值操作符分配给指针新的内存。
·在析构函数里删除指针。
条款7:预先准备好内存不够的情况
当内存分配请求不能满足时,调用你预先指定的一个出错处理函数。这个方法基于一个常规,即当operator new不能满足请求时,会在抛出异常之前调用客户指定的一个出错处理函数——一般称为new-handler函数。
typedef void (*new_handler)();
new_handler set_new_handler(new_handler p) throw();
可以看到,new_handler是一个自定义的函数指针类型,它指向一个没有输入参数也没有返回值的函数。set_new_handler则是一个输入并返回new_handler类型的函数。
set_new_handler的输入参数是operator new分配内存失败时要调用的出错处理函数的指针,返回值是set_new_handler没调用之前就已经在起作用的旧的出错处理函数的指针。
可以象下面这样使用set_new_handler:
// function to call if operator new can't allocate enough memory
void nomorememory()
{
cerr << "unable to satisfy request for memory/n";
abort();
}
int main()
{
set_new_handler(nomorememory);
int *pbigdataarray = new int[100000000];
...
}
一个设计得好的new-handler函数必须实现下面功能中的一种。
·产生更多的可用内存。这将使operator new下一次分配内存的尝试有可能获得成功。实施这一策略的一个方法是:在程序启动时分配一个大的内存块,然后在第一次调用new-handler时释放。释放时伴随着一些对用户的警告信息,如内存数量太少,下次请求可能会失败,除非又有更多的可用空间。
·安装另一个不同的new-handler函数。如果当前的new-handler函数不能产生更多的可用内存,可能它会知道另一个new-handler函数可以提供更多的资源。这样的话,当前的new-handler可以安装另一个new-handler来取代它(通过调用set_new_handler)。下一次operator new调用new-handler时,会使用最近安装的那个。(这一策略的另一个变通办法是让new-handler可以改变它自己的运行行为,那么下次调用时,它将做不同的事。方法是使new-handler可以修改那些影响它自身行为的静态或全局数据。)
·卸除new-handler。也就是传递空指针给set_new_handler。没有安装new-handler,operator new分配内存不成功时就会抛出一个标准的std::bad_alloc类型的异常。
·抛出std::bad_alloc或从std::bad_alloc继承的其他类型的异常。这样的异常不会被operator new捕捉,所以它们会被送到最初进行内存请求的地方。(抛出别的不同类型的异常会违反operator new异常规范。规范中的缺省行为是调用abort,所以new-handler要抛出一个异常时,一定要确信它是从std::bad_alloc继承来的。想更多地了解异常规范,参见条款m14。)
·没有返回。典型做法是调用abort或exit。abort/exit可以在标准c库中找到。
条款8: 写operator new和operator delete时要遵循常规
operator new伪代码:
非类成员形式:
void * operator new(size_t size) // operator new还可能有其它参数
{
if (size == 0) { // 处理0字节请求时,
size = 1; // 把它当作1个字节请求来处理
}
while (1) {
分配size字节内存;
if (分配成功)
return (指向内存的指针);
// 分配不成功,找出当前出错处理函数
new_handler globalhandler = set_new_handler(0);
set_new_handler(globalhandler);
if (globalhandler) (*globalhandler)();
else throw std::bad_alloc();
}
}
类成员形式:
class base {
public:
static void * operator new(size_t size);
...
};
void * base::operator new(size_t size)
{
if (size != sizeof(base)) // 如果数量“错误”,让标准operator new
return ::operator new(size); // 去处理这个请求
//
... // 否则处理这个请求
}
operator delete的伪代码:
非类成员形式:
void operator delete(void *rawmemory)
{
if (rawmemory == 0) return; file://如/果指针为空,返回
//
释放rawmemory指向的内存;
return;
}
类成员形式:
class base { // 和前面一样,只是这里声明了
public: // operator delete
static void * operator new(size_t size);
static void operator delete(void *rawmemory, size_t size);
...
};
void base::operator delete(void *rawmemory, size_t size)
{
if (rawmemory == 0) return; // 检查空指针
if (size != sizeof(base)) { // 如果size"错误",
::operator delete(rawmemory); // 让标准operator来处理请求
return;
}
释放指向rawmemory的内存;
return;
}
条款9: 避免隐藏标准形式的new
办法避免这个问题的2中方法:
1. 一个办法是在类里写一个支持标准new调用方式的operator new,它和标准new做同样的事
class x {
public:
void f();
static void * operator new(size_t size, new_handler p);
static void * operator new(size_t size)
{ return ::operator new(size); }
};
x *px1 =
new (specialerrorhandler) x; // 调用 x::operator
// new(size_t, new_handler)
x* px2 = new x; // 调用 x::operator
// new(size_t)
2. 另一种方法是为每一个增加到operator new的参数提供缺省值
class x {
public:
void f();
static
void * operator new(size_t size, // p缺省值为0
new_handler p = 0); //
};
x *px1 = new (specialerrorhandler) x; // 正确
x* px2 = new x; // 也正确
条款10: 如果写了operator new就要同时写operator delete
airplane *pa = new airplane;
你不会得到一块看起来象这样的内存块:
pa——> airplane对象的内存
而是得到象这样的内存块:
pa——> 内存块大小数据 + airplane对象的内存
条款11: 为需要动态分配内存的类声明一个拷贝构造函数和一个赋值操作符
class String
{
public:
String(const char * value);
Stirng(const String& s1);
String & operator = (const String & s1);
~String();
private:
char * str;
};
String::String(const char * value)
{
if(value)
{
str = new char[strlen(value)+1];
strcpy( str, value );
}
else
{
str = new char[1];
*str = '/0';
}
}
String::Stirng(const String& s1)
{
str = new char(strlen(s1.str));
strcpy( str, s1.str);
}
String& String::operator = (const String & s1)
{
if( this == &s1 )
{
return *this;
}
delete [] str;
str = new char[ strlen(s1.str) + 1 ];
strcpy( str, s1.str );
return * this;
}
String::~String()
{
delete [] str;
}
条款12: 尽量使用初始化而不要在构造函数里赋值
1. 构造函数的定义
第一种方法是使用成员初始化列表:
template<class t>
namedptr<t>::namedptr(const string& initname, t *initptr )
: name(initname), ptr(initptr)
{}
第二种方法是在构造函数体内赋值:
template<class t>
namedptr<t>::namedptr(const string& initname, t *initptr)
{
name = initname;
ptr = initptr;
}
两种方法有重大的不同:
1.特别是const和引用数据成员只能用初始化,不能被赋值。
template<class t>
class namedptr {
public:
namedptr(const string& initname, t *initptr);
...
private:
const string& name; // 必须通过成员初始化列表
// 进行初始化
t * const ptr; // 必须通过成员初始化列表
// 进行初始化
};
2. 如果用一个成员初始化列表来指定name必须用initname来初始化,name就会通过拷贝构造函数以仅一个函数调用的代价被初始化。
如果没有为name指定初始化参数,string的缺省构造函数会被调用。当在namedptr的构造函数里对name执行赋值时,会对name调用operator=函数。这样总共有两次对string的成员函数的调用:一次是缺省构造函数,另一次是赋值。
3. 这就是当有大量的固定类型的数据成员要在每个构造函数里以相同的方式初始化的时候,对类的数据成员用赋值比用初始化更合理。
条款13: 初始化列表中成员列出的顺序和它们在类中声明的顺序相同
类成员是按照它们在类里被声明的顺序进行初始化的,和它们在成员初始化列表中列出的顺序没一点关系。
条款14: 确定基类有虚析构函数
1. 当通过基类的指针去删除派生类的对象,而基类又没有虚析构函数时,结果将是不可确定的。
2. 当一个类不准备作为基类使用时,使用虚函数是个坏主意。定义虚函数会对对象附带一些额外信息,具体形式是一个称为vptr(虚函数表指针)的指针。
3. 当且仅当类里包含至少一个虚函数的时候才去声明虚析构函数。
4. 如果声明虚析构函数为inline,将会避免调用它们时产生的开销,但编译器还是必然会在什么地方产生一个此函数的拷贝。
条款15: 让operator=返回*this的引用
返回*this的引用的目的是:连续(链式)赋值操作。
为什么返回*this的引用?
1. 如果返回void,不能支持w = x = y = z = "hello" 这样的连续(链式)赋值操作。
2. 如果返回const对象的引用,不能支持(w1 = w2) = w3; 这样的操作。
4. 如果返回赋值赋右边的对象的引用:
string& string::operator=(const string& rhs)
{
...
return rhs; // 返回右边的对象
}
由于因为rhs是一个const string的引用,而operator=要返回的是一个string的引用,编译不能通过。
其次也不能修改为string& string::operator=(string& rhs) { ... }
因为编译器为产生如下代码:
const string temp("hello"); // 产生临时string
x = temp; // 临时string传给operator=
对于没有声明相应参数为const的函数来说,传递一个const对象是非法的。这是一个关于const的很简单的规定。
条款16: 在operator=中对所有数据成员赋值
class base {
public:
base(int initialvalue = 0): x(initialvalue) {}
private:
int x;
};
class derived: public base {
public:
derived(int initialvalue)
: base(initialvalue), y(initialvalue) {}
derived& operator=(const derived& rhs);
private:
int y;
};
// 正确的赋值运算符
derived& derived::operator=(const derived& rhs)
{
if (this == &rhs) return *this;
base::operator=(rhs); // 调用this->base::operator=
y = rhs.y;
return *this;
}
如果基类赋值运算符是编译器生成的,有些编译器会拒绝这种对于基类赋值运算符的调用)。为了适应这种编译器,必须这样实现derived::operator=:
derived& derived::operator=(const derived& rhs)
{
if (this == &rhs) return *this;
static_cast<base&>(*this) = rhs; // 对*this的base部分
// 调用operator=
y = rhs.y;
return *this;
}
强制转换为base&,而不转换为base的对象。
条款17: 在operator=中检查给自己赋值的情况
在赋值运算符中要特别注意可能出现别名的情况,其理由基于两点。
其中之一是效率。如果可以在赋值运算符函数体的首部检测到是给自己赋值,就可以立即返回,从而可以节省大量的工作,否则必须去实现整个赋值操作。
另一个更重要的原因是保证正确性。一个赋值运算符必须首先释放掉一个对象的资源(去掉旧值),然后根据新值分配新的资源。
条款18: 争取使类的接口完整并且最小
类接口的目标是完整且最小。
条款19: 分清成员函数,非成员函数和友元函数
成员函数和非成员函数最大的区别在于成员函数可以是虚拟的而非成员函数不行。
explicit构造函数不能用于隐式转换,这正是explicit的含义。
它只对函数参数表中列出的参数进行转换,决不会对成员函数所在的对象(即,成员函数中的*this指针所对应的对象)进行转换。
result = onehalf * 2; // 运行良好
result = 2 * onehalf; // 出错!
本条款得出的结论如下。假设f是想正确声明的函数,c是和它相关的类:
·虚函数必须是成员函数。如果f必须是虚函数,就让它成为c的成员函数。
· operator>>和operator<<决不能是成员函数。如果f是operator>>或operator<<,让f成为非成员函数。如果f还需要访问c的非公有成员,让f成为c的友元函数。
· 只有非成员函数对最左边的参数进行类型转换。如果f需要对最左边的参数进行类型转换,让f成为非成员函数。如果f还需要访问c的非公有成员,让f成为c的友元函数。
例如:
const rational operator*(const rational& lhs,
const rational& rhs)
{
return rational(lhs.numerator() * rhs.numerator(),
lhs.denominator() * rhs.denominator());
}
·其它情况下都声明为成员函数。如果以上情况都不是,让f成为c的成员函数。
条款20: 避免public接口出现数据成员
如果使数据成员为public,每个人都可以对它读写;如果用函数来获取或设定它的值,就可以实现禁止访问、只读访问和读写访问等多种控制。
class accesslevels {
public:
int getreadonly() const{ return readonly; }
void setreadwrite(int value) { readwrite = value; }
int getreadwrite() const { return readwrite; }
void setwriteonly(int value) { writeonly = value; }
private:
int noaccess; // 禁止访问这个int
int readonly; // 可以只读这个int
int readwrite; // 可以读/写这个int
int writeonly; // 可以只写这个int
};
功能分离:
如果用函数来实现对数据成员的访问,以后就有可能用一段计算来取代这个数据成员,而使用这个类的用户却一无所知。
条款21: 尽可能使用const
char *p = "hello"; // 非const指针,
// 非const数据
const char *p = "hello"; // 非const指针,
// const数据
char * const p = "hello"; // const指针,
// 非const数据
const char * const p = "hello"; // const指针,
// const数据
void f1(const widget *pw); // f1取的是指向
// widget常量对象的指针
void f2(widget const *pw); // 同f2
const的一些强大的功能基于它在函数声明中的应用。在一个函数声明中,const可以指的是函数的返回值,或某个参数;对于成员函数,还可以指的是整个函数。
const成员函数的目的当然是为了指明哪个成员函数可以在const对象上被调用。
通过重载operator[]并给不同版本不同的返回值,就可以对const和非const string进行不同的处理:
string s = "hello"; // 非const string对象
cout << s[0]; // 正确——读一个
// 非const string
s[0] = 'x'; // 正确——写一个
// 非const string
const string cs = "world"; // const string 对象
cout << cs[0]; // 正确——读一个
// const string
cs[0] = 'x'; // 错误!——写一个
// const string
另外注意,这里的错误只和调用operator[]的返回值有关;operator[]调用本身没问题。
还要注意,非const operator[]的返回类型必须是一个char的引用——char本身则不行。
有两种主要的看法:数据意义上的const(bitwise constness)和概念意义上的const(conceptual constness)。
bitwise constness的坚持者认为,当且仅当成员函数不修改对象的任何数据成员(静态数据成员除外)时,即不修改对象中任何一个比特(bit)时,这个成员函数才是const的。
conceptual constness观点的坚持者认为,一个const成员函数可以修改它所在对象的一些数据(bits) ,但只有在用户不会发觉的情况下。
使用了关键字mutable,当对非静态数据成员运用mutable时,这些成员的“bitwise constness”限制就被解除:
class string {
public:
... // same as above
private:
char *data;
mutable size_t datalength; // 这些数据成员现在
// 为mutable;他们可以在
mutable bool lengthisvalid; // 任何地方被修改,即使
// 在const成员函数里
};
size_t string::length() const
{
if (!lengthisvalid) {
datalength = strlen(data); // 现在合法
lengthisvalid = true; // 同样合法
}
return datalength;
}
类c的一个成员函数中,this指针就好象经过如下的声明:
c * const this; // 非const成员函数中
const c * const this; // const成员函数中
size_t string::length() const
{
// 定义一个不指向const对象的
// 局部版本的this指针
string * const localthis =
const_cast<string * const>(this);
if (!lengthisvalid) {
localthis->datalength = strlen(data);
localthis->lengthisvalid = true;
}
return datalength;
}
条款22: 尽量用“传引用”而不用“传值”
1. class person {
public:
person(); // 为简化,省略参数
//
~person();
...
private:
string name, address;
};
class student: public person {
public:
student(); // 为简化,省略参数
//
~student();
...
private:
string schoolname, schooladdress;
};
returnstudent(plato); // 调用returnstudent
因为returnstudent函数使用了两次传值(一次对参数,一次对返回值),这个函数总共调用了十二个构造函数和十二个析构函数!
使用传值的好处:
1. 高效:没有构造函数或析构函数被调用。
2. 解决“切割问题”问题。
条款23: 必须返回一个对象时不要试图返回一个引用
传递一个并不存在的对象的引用
写一个必须返回一个新对象的函数的正确方法就是让这个函数返回一个新对象。的确,这会导致“operator*的返回值构造和析构时带来的开销”,但归根结底它只是用小的代价换来正确的程序运行行为而已。
条款24: 在函数重载和设定参数缺省值间慎重选择
什么时候选择重载和参数缺省?
一般来说,如果可以选择一个合适的缺省值并且只是用到一种算法,就使用缺省参数。否则,就使用函数重载。
标准c++库(见条款49)在头文件<limits> 中定义了一个类模板numeric_limits,这个类模板本身也定义了一些静态成员函数。每个函数返回的是“实例化这个模板的类型”的信息。也就是说,numeric_limits<int>中的函数返回的信息是关于类型int的,numeric_limits<double> 中的函数返回的信息是关于类型double的。numeric_limits中有一个函数叫min,min返回可表示为“实例化类型”的最小值,所以numeric_limits<int>::min()返回的是代表整数类型的最小值。
条款25: 避免对指针和数字类型重载
const // 这是一个const对象...
class {
public:
template<class t> // 可以转换任何类型
operator t*() const // 的null非成员指针
{ return 0; } //
template<class c, class t> // 可以转换任何类型
operator t c::*() const // 的null成员指针
{ return 0; }
private:
void operator&() const; // 不能取其地址
// (见条款27)
} null; // 名字为null
此null只是一个涉及方案,他不能强迫你的调用者去使用它。
所以,作为重载函数的设计者,归根结底最基本的一条是,只要有可能,就要避免对一个数字和一个指针类型重载。
条款26: 当心潜在的二义性
潜在的二义性:
例子1:
这是潜在二义性的一个例子:
class B; // 对类B提前声明
//
class A {
public:
A(const B&); // 可以从B构造而来的类A
};
class B {
public:
operator A() const; // 可以从A转换而来的类B
};
void f(const A&);
B b;
f(b); // 错误!——二义
例子2:
void f(int);
void f(char);
double d = 6.02;
f(d); // 错误!——二义
f(static_cast<int>(d)); // 正确, 调用f(int)
f(static_cast<char>(d)); // 正确, 调用f(char)
例子3:
class Base1 {
public:
int doIt();
};
class Base2 {
public:
void doIt();
};
class Derived: public Base1, // Derived没有声明
public Base2 { // 一个叫做doIt的函数
...
};
Derived d;
d.doIt(); // 错误!——二义
d.Base1::doIt(); // 正确, 调用Base1::doIt
d.Base2::doIt(); // 正确, 调用Base2::doIt
条款27: 如果不想使用隐式生成的函数就要显式地禁止它
由于编译器会自动的自动生成的一些成员函数,因此当不想调用这些函数时要显示的禁止它。例如:
template<class T>
class Array {
private:
// 不要定义这个函数!
Array& operator=(const Array& rhs);
...
};
条款28: 划分全局名字空间
名字空间带来的最大的好处之一在于:潜在的二义不会造成错误。
可以用struct来近似实现namespace。它在很多方面很欠缺,其中很明显的一点是对运算符的处理。
条款29: 避免返回内部数据的句柄
对于const成员函数来说,返回句柄是不明智的,因为它会破坏数据抽象。对于非const成员函数来说,返回句柄会带来麻烦,特别是涉及到临时对象时。
string somefamousauthor() // 随机选择一个作家名
{ // 并返回之
switch (rand() % 3) { // rand()在<stdlib.h>中
// (还有<cstdlib>。参见条款49)
case 0:
return "margaret mitchell"; // 此作家曾写了 "飘",
// 一部绝对经典的作品
case 1:
return "stephen king"; // 他的小说使得许多人
// 彻夜不眠
case 2:
return "scott meyers"; // 嗯...滥竽充数的一个
}
return ""; // 程序不会执行到这儿,
// 但对于一个有返回值的函数来说,
// 任何执行途径上都要有返回值
}
const char *pc = somefamousauthor();
cout << pc;
不论你是否相信,谁也不能预测这段代码将会做些什么,至少不能确定它会做些什么。因为当你想打印pc所指的字符串时,字符串的值是不确定的。造成这一结果的原因在于pc初始化时发生了下面这些事件:
1. 产生一个临时string对象用以保存somefamousauthor的返回值。
2. 通过string的operator const char*成员函数将临时string对象转换为const char*指针,并用这个指针初始化pc。
3. 临时string对象被销毁,其析构函数被调用。析构函数中,data指针被删除(代码详见条款11)。然而,data和pc所指的是同一块内存,所以现在pc指向的是被删除的内存--------其内容是不可确定的。
条款30: 避免这样的成员函数:其返回值是指向成员的非const指针或引用,但成员的访问级比这个函数要低
条款31: 千万不要返回局部对象的引用,也不要返回函数内部用new初始化的指针的引用
1. 返回一个局部对象的引用:临时对象的引用在函数退出前消失
2. 使用的new:容易引起内存泄漏。
条款32: 尽可能地推迟变量的定义
不仅要将变量的定义推迟到必须使用它的时候,还要尽量推迟到可以为它提供一个初始化参数为止。
条款33: 明智地使用内联
内联函数的基本思想在于将每个函数调用以它的代码体来替换。
一个给定的内联函数是否真的被内联取决于所用的编译器的具体实现。
假设写了某个函数f并声明为inline,如果出于什么原因,编译器决定不对它内联,那将会发生些什么呢?
新的规范:最明显的一个回答是将f作为一个非内联函数来处理。
被多个源文件include在头文件中的定义的内联函数没有内联,会导致编译时的连接错误。
旧的硅粉:对于未被内联的内联函数,编译器把它当成被声明为static那样处理。
但带来了开销:每个包含f的定义(以及调用f)的被编译单元都包含自己的f的静态拷贝。
内联函数中的静态对象常常表现出违反直觉的行为。所以,如果函数中包含静态对象,通常要避免将它声明为内联函数。
条款34: 将文件间的编译依赖性降至最低
// 编译器还是要知道这些类型名,
// 因为Person的构造函数要用到它们
class string; // 对标准string来说这样做不对,
// 原因参见条款49
class Date;
class Address;
class Country;
// 类PersonImpl将包含Person对象的实
// 现细节,此处只是类名的提前声明
class PersonImpl;
class Person {
public:
Person(const string& name, const Date& birthday,
const Address& addr, const Country& country);
virtual ~Person();
... // 拷贝构造函数, operator=
string name() const;
string birthDate() const;
string address() const;
string nationality() const;
private:
PersonImpl *impl; // 指向具体的实现类
};
分离的关键在于,"对类定义的依赖" 被 "对类声明的依赖" 取代了。所以,为了降低编译依赖性,我们只要知道这么一条就足够了:只要有可能,尽量让头文件不要依赖于别的文件;如果不可能,就借助于类的声明,不要依靠类的定义。其它一切方法都源于这一简单的设计思想。
下面就是这一思想直接深化后的含义:
· 如果可以使用对象的引用和指针,就要避免使用对象本身。定义某个类型的引用和指针只会涉及到这个类型的声明。定义此类型的对象则需要类型定义的参与。
· 尽可能使用类的声明,而不使用类的定义。因为在声明一个函数时,如果用到某个类,是绝对不需要这个类的定义的,即使函数是通过传值来传递和返回这个类:
class Date; // 类的声明
Date returnADate(); // 正确 ---- 不需要Date的定义
void takeADate(Date d);
· 不要在头文件中再(通过#include指令)包含其它头文件,除非缺少了它们就不能编译。相反,要一个一个地声明所需要的类,让使用这个头文件的用户自己(通过#include指令)去包含其它的头文件,以使用户代码最终得以通过编译。一些用户会抱怨这样做对他们来说很不方便,但实际上你为他们避免了许多你曾饱受的痛苦。事实上,这种技术很受推崇,并被运用到C++标准库(参见条款49)中;头文件<iosfwd>就包含了iostream库中的类型声明(而且仅仅是类型声明)。
Person类仅仅用一个指针来指向某个不确定的实现,这样的类常常被称为句炳类(Handle class)或信封类(Envelope class)。
#include "Person.h" // 因为是在实现Person类,
// 所以必须包含类的定义
#include "PersonImpl.h" // 也必须包含PersonImpl类的定义,
// 否则不能调用它的成员函数。
// 注意PersonImpl和Person含有一样的
// 成员函数,它们的接口完全相同
Person::Person(const string& name, const Date& birthday,
const Address& addr, const Country& country)
{
impl = new PersonImpl(name, birthday, addr, country);
}
string Person::name() const
{
return impl->name();
}
除了句柄类,另一选择是使Person成为一种特殊类型的抽象基类,称为协议类(Protocol class)。根据定义,协议类没有实现;它存在的目的是为派生类确定一个接口(参见条款36)。所以,它一般没有数据成员,没有构造函数;有一个虚析构函数(见条款14),还有一套纯虚函数,用于制定接口。Person的协议类看起来会象下面这样:
class Person {
public:
virtual ~Person();
virtual string name() const = 0;
virtual string birthDate() const = 0;
virtual string address() const = 0;
virtual string nationality() const = 0;
};
class RealPerson: public Person {
public:
RealPerson(const string& name, const Date& birthday,
const Address& addr, const Country& country)
: name_(name), birthday_(birthday),
address_(addr), country_(country)
{}
virtual ~RealPerson() {}
string name() const; // 函数的具体实现没有
string birthDate() const; // 在这里给出,但它们
string address() const; // 都很容易实现
string nationality() const;
private:
string name_;
Date birthday_;
Address address_;
Country country_;
有了RealPerson,写Person::makePerson就是小菜一碟:
Person * Person::makePerson(const string& name,
const Date& birthday,
const Address& addr,
const Country& country)
{
return new RealPerson(name, birthday, addr, country);
}
条款35: 使公有继承体现 "是一个" 的含义
C++面向对象编程中一条重要的规则是:公有继承意味着 "是一个" 。一定要牢牢记住这条规则。
当写下类D("Derived" )从类B("Base")公有继承时,类型D的每一个对象也是类型B的一个对象,但反之不成立。
当然,"是一个" 的关系不是存在于类之间的唯一关系。类之间常见的另两个关系是 "有一个" 和 "用...来实现"。
条款36: 区分接口继承和实现继承
(公有)继承的概念看起来很简单,进一步分析,会发现它由两个可分的部分组成:函数接口的继承和函数实现的继承。
class Shape {
public:
virtual void draw() const = 0;
virtual void error(const string& msg);
int objectID() const;
...
};
class Rectangle: public Shape { ... };
class Ellipse: public Shape { ... };
· 成员函数的接口总会被继承。正如条款35所说明的,公有继承的含义是 "是一个" ,所以对基类成立的所有事实也必须对派生类成立。因此,如果一个函数适用于某个类,也必将适用于它的子类。
首先看纯虚函数draw。纯虚函数最显著的特征是:它们必须在继承了它们的任何具体类中重新声明,而且它们在抽象类中往往没有定义。把这两个特征放在一起,就会认识到:
· 定义纯虚函数的目的在于,使派生类仅仅只是继承函数的接口。
简单虚函数的情况和纯虚函数有点不一样。照例,派生类继承了函数的接口,但简单虚函数一般还提供了实现,派生类可以选择改写它们或不改写它们。思考片刻就可以认识到:
· 声明简单虚函数的目的在于,使派生类继承函数的接口和缺省实现。
实际上,为简单虚函数同时提供函数声明和缺省实现是很危险的。
切断虚函数的接口和它的缺省实现之间的联系。
使用纯虚函数。
最后,来谈谈Shape的非虚函数,objectID。当一个成员函数为非虚函数时,它在派生类中的行为就不应该不同。实际上,非虚成员函数表明了一种特殊性上的不变性,因为它表示的是不会改变的行为 ---- 不管一个派生类有多特殊。所以,
· 声明非虚函数的目的在于,使派生类继承函数的接口和强制性实现。
条款37: 决不要重新定义继承而来的非虚函数
class B {
public:
void mf();
};
class D: public B {
public:
void mf();
};
void B::mf()
{
cout << "B::mf()" << endl;
}
void D::mf()
{
cout << "D::mf()" << endl;
}
pB->mf(); // 调用B::mf
pD->mf(); // 调用D::mf
行为的两面性产生的原因在于,象B::mf和D::mf这样的非虚函数是静态绑定的。
条款38: 决不要重新定义继承而来的缺省参数值
继承一个有缺省参数值的虚函数:
虚函数是动态绑定而缺省参数值是静态绑定的。这意味着你最终可能调用的是一个定义在派生类,但使用了基类中的缺省参数值的虚函数。
对象的静态类型是指你声明的存在于程序代码文本中的类型。
对象的动态类型是由它当前所指的对象的类型决定的。
条款39: 避免 "向下转换" 继承层次
这种类型的转换 ---- 从一个基类指针到一个派生类指针 ---- 被称为 "向下转换",因为它向下转换了继承的层次结构。
class BankAccount {
public:
virtual void creditInterest() {}
...
};
class SavingsAccount: public BankAccount { ... };
class CheckingAccount: public BankAccount { ... };
list<BankAccount*> allAccounts;
// 看啊,没有转换!
for (list<BankAccount*>::iterator p = allAccounts.begin();
p != allAccounts.end();
++p) {
(*p)->creditInterest();
}
条款40: 通过分层来体现 "有一个" 或 "用...来实现"
有一个:
class Address { ... }; // 某人居住之处
class PhoneNumber { ... };
class Person {
public:
...
private:
string name; // 下层对象
Address address; // 同上
PhoneNumber voiceNumber; // 同上
PhoneNumber faxNumber; // 同上
};
用…..来实现:
template<class T>
class Set {
public:
bool member(const T& item) const;
void insert(const T& item);
void remove(const T& item);
int cardinality() const;
private:
list<T> rep; // 表示一个Set
};
条款41: 区分继承和模板
“类的行为" 和 "类所操作的对象的类型"之间的关系导致设计的模式。
模板类的特点:行为不依赖于类型。
· 当对象的类型不影响类中函数的行为时,就要使用模板来生成这样一组类。
· 当对象的类型影响类中函数的行为时,就要使用继承来得到这样一组类。
条款42: 明智地使用私有继承
私有继承的第一个规则正如你现在所看到的:和公有继承相反,如果两个类之间的继承关系为私有,编译器一般不会将派生类对象(如Student)转换成基类对象(如Person)。
第二个规则是,从私有基类继承而来的成员都成为了派生类的私有成员,即使它们在基类中是保护或公有成员。行为特征就这些。
私有继承意味着 "用...来实现"。"分层" 也具有相同的含义。怎么在二者之间进行选择呢?答案很简单:尽可能地使用分层,必须时才使用私有继承。
class GenericStack {
protected:
GenericStack();
~GenericStack();
void push(void *object);
void * pop();
bool empty() const;
private:
... // 同上
};
GenericStack s; // 错误! 构造函数被保护
class IntStack: private GenericStack {
public:
void push(int *intPtr) { GenericStack::push(intPtr); }
int * pop() { return static_cast<int*>(GenericStack::pop()); }
bool empty() const { return GenericStack::empty(); }
};
class CatStack: private GenericStack {
public:
void push(Cat *catPtr) { GenericStack::push(catPtr); }
Cat * pop() { return static_cast<Cat*>(GenericStack::pop()); }
bool empty() const { return GenericStack::empty(); }
};
IntStack is; // 正确
CatStack cs; // 也正确
使用模板类代替上边2个私有继承:
template<class T>
class Stack: private GenericStack {
public:
void push(T *objectPtr) { GenericStack::push(objectPtr); }
T * pop() { return static_cast<T*>(GenericStack::pop()); }
bool empty() const { return GenericStack::empty(); }
};
条款43: 明智地使用多继承
多继承多带来的二义性:
· 向虚基类传递构造函数参数。非虚继承时,基类构造函数的参数是由紧临的派生类的成员初始化列表指定的。因为单继承的层次结构只需要非虚基类,继承层次结构中参数的向上传递采用的是一种很自然的方式:第n层的类将参数传给第n-1层的类。但是,虚基类的构造函数则不同,它的参数是由继承结构中最底层派生类的成员初始化列表指定的。这就造成,负责初始化虚基类的那个类可能在继承图中和它相距很远;如果有新类增加到继承结构中,执行初始化的类还可能改变。(避免这个问题的一个好办法是:消除对虚基类传递构造函数参数的需要。最简单的做法是避免在这样的类中放入数据成员。这本质上是Java的解决之道:Java中的虚基类(即,"接口")禁止包含数据)
· 虚函数的优先度。就在你自认为弄清了所有的二义之时,它们却又在你面前摇身一变。再次看看关于类A,B,C和D的钻石形状的继承图。假设A定义了一个虚成员函数mf,C重定义了它;B和D则没有重定义mf:
A virtual void mf();
//
/ /
/ /
B C virtual void mf();
/ /
/ /
//
D
根据以前的讨论,你会认为下面有二义:
D *pd = new D;
pd->mf(); // A::mf或者C::mf?
条款44: 说你想说的;理解你所说的
公有继承和 "是一个" 的等价性,以及非虚成员函数和 "特殊性上的不变性" 的等价性,是C++构件如何和设计思想相对应的例子。
· 共同的基类意味着共同的特性。如果类D1和类D2都把类B声明为基类,D1和D2将从B继承共同的数据成员和/或共同的成员函数。见条款43。
· 公有继承意味着 "是一个"。如果类D公有继承于类B,类型D的每一个对象也是一个类型B的对象,但反过来不成立。见条款35。
· 私有继承意味着 "用...来实现"。如果类D私有继承于类B,类型D的对象只不过是用类型B的对象来实现而已;类型B和类型D的对象之间不存在概念上的关系。见条款42。
· 分层意味着 "有一个" 或 "用...来实现"。如果类A包含一个类型B的数据成员,类型A的对象要么具有一个类型为B的部件,要么在实现中使用了类型B的对象。见条款40。
下面的对应关系只适用于公有继承的情况:
· 纯虚函数意味着仅仅继承函数的接口。如果类C声明了一个纯虚函数mf,C的子类必须继承mf的接口,C的具体子类必须为之提供它们自己的实现。见条款36。
· 简单虚函数意味着继承函数的接口加上一个缺省实现。如果类C声明了一个简单(非纯)虚函数mf,C的子类必须继承mf的接口;如果需要的话,还可以继承一个缺省实现。见条款36。
· 非虚函数意味着继承函数的接口加上一个强制实现。如果类C声明了一个非虚函数mf,C的子类必须同时继承mf的接口和实现。实际上,mf定义了C的 "特殊性上的不变性"。见条款36。
条款45: 弄清C++在幕后为你所写、所调用的函数
编译器会提供自己函数有:
这些函数是:一个拷贝构造函数,一个赋值运算符,一个析构函数,一对取址运算符。另外,如果你没有声明任何构造函数,它也将为你声明一个缺省构造函数。
class Empty{};
和你这么写是一样的:
class Empty {
public:
Empty(); // 缺省构造函数
Empty(const Empty& rhs); // 拷贝构造函数
~Empty(); // 析构函数 ---- 是否
//
Empty&
operator=(const Empty& rhs); // 赋值运算符
Empty* operator&(); // 取址运算符
const Empty* operator&() const;
};
注意,生成的析构函数一般是非虚拟的,除非它所在的类是从一个声明了虚析构函数的基类继承而来。
如果想让一个包含引用成员的类支持赋值,你就得自己定义赋值运算符。
对于包含const成员的类,你就得自己定义赋值运算符。
如果派生类的基类将标准赋值运算符声明为private, 编译器也将拒绝为这个派生类生成赋值运算符。
条款46: 宁可编译和链接时出错,也不要运行时出错
这种方法带来的好处不仅仅在于程序的大小和速度,还有可靠性。
条款47: 确保非局部静态对象在使用前被初始化
FileSystem& theFileSystem() // 这个函数代替了
{ // theFileSystem对象
static FileSystem tfs; // 定义和初始化
// 局部静态对象
// (tfs = "the file system")
return tfs; // 返回它的引用
}
这种返回引用的函数虽然采用了上面所讨论的技术,但函数本身总是很简单:第一行定义并初始化一个局部静态对象,第二行返回它,仅此而已。
条款48: 重视编译器警告
条款49: 熟悉标准库
· 旧的C++头文件名如<iostream.h>将会继续被支持,尽管它们不在官方标准中。这些头文件的内容不在名字空间std中。
· 新的C++头文件如<iostream>包含的基本功能和对应的旧头文件相同,但头文件的内容在名字空间std中。(在标准化的过程中,库中有些部分的细节被修改了,所以旧头文件和新头文件中的实体不一定完全对应。)
· 标准C头文件如<stdio.h>继续被支持。头文件的内容不在std中。
· 具有C库功能的新C++头文件具有如<cstdio>这样的名字。它们提供的内容和相应的旧C头文件相同,只是内容在std中。
C++库中有哪些主要组件:
· 标准C库。它还在,你还可以用它。虽然有些地方有点小的修修补补,但无论怎么说,还是那个用了多年的C库。
· Iostream。和 "传统" Iostream的实现相比,它已经被模板化了,继承层次结构也做了修改,增强了抛出异常的能力,可以支持string(通过stringstream类)和国际化(通过locales ---- 见下文)。当然,你期望Iostream库所具有的东西几乎全都继续存在。也就是说,它还是支持流缓冲区,格式化标识符,操作子和文件,还有cin,cout,cerr和clog对象。这意味着可以把string和文件当做流,还可以对流的行为进行更广泛的控制,包括缓冲和格式化。
· String。string对象在大多数应用中被用来消除对char*指针的使用。它们支持你所期望的那些操作(例如,字符串连接,通过operator[]对单个字符进行常量时间级的访问,等等),它们可以转换成char*,以保持和现有代码的兼容性,它们还自动处理内存管理。一些string的实现采用了引用计数(参见条款M29),这会带来比基于char*的字符串更佳的性能(时间和空间上)。
· 容器。不要再写你自己的基本容器类!标准库提供了下列高效的实现:vector(就象动态可扩充的数组),list(双链表),queue, stack,deque,map,set和bitset。唉,竟然没有hash table(虽然很多制造商作为扩充提供),但多少可以作为补偿的一点是, string是容器。这很重要,因为它意味着对容器所做的任何操作(见下文)对string也适用。
· 算法。标准容器当然好,如果存在易于使用它们的方法就更好。标准库就提供了大量简易的方法(即,预定义函数,官方称为算法(algorithm) ---- 实际上是函数模板),其中的大多数适用于库中所有的容器 ---- 以及内建数组(built-in arrays)!
· 对国际化的支持。不同的文化以不同的方式行事。和C库一样,C++库提供了很多特性有助于开发出国际化的软件。但虽然从概念上来说和C类似,其实C++的方法还是有所不同。例如,C++为支持国际化广泛使用了模板,还利用了继承和虚函数,这些一定不会让你感到奇怪。
· 对数字处理的支持。FORTRAN的末日可能就快到了。C++库为复数类(实数和虚数部分的精度可以是float,double或long double)和专门针对数值编程而设计的特殊数组提供了模板。例如,valarray类型的对象可用来保存可以任意混叠(aliasing)的元素。这使得编译器可以更充分地进行优化,尤其是对矢量计算机来说。标准库还对两种不同类型的数组片提供了支持,并提供了算法计算内积(inner product),部分和(partial sum),临差(adjacent difference)等。
· 诊断支持。标准库支持三种报错方式:C的断言(参见条款7),错误号,例外。为了有助于为例外类型提供某种结构,标准库定义了下面的例外类(exception class)层次结构:
|---domain_error
|----- logic_error<---- |---invalid_argument
| |---length_error
| |---out_of_range
exception<--|
| |--- range_error
|-----runtime_error<--|---underflow_error
|---overflow_error
条款50: 提高对C++的认识
The Annotated C++ Reference Manual
The Design and Evolution of C++