1:尽量用new和delete而不用malloc和free
malloc和free(及其变体)会产生问题的原因在于它们太简单:他们不知道构造函数和析构函数。假设用两种方法给一个包含10个string对象的数组分配空间,一个用malloc,另一个用new:
string *stringarray1 =static_cast〈string*〉(malloc(10 * sizeof(string)));
string *stringarray2 = new string[10];
其结果是,stringarray1确实指向的是可以容纳10个string对象的足够空间,但内存里并没有创建这些对象。而且,如果你不从这种晦涩的语法怪圈里跳出来的话,你没有办法来初始化数组里的对象。换句话说,stringarray1其实一点用也没有。相反,stringarray2指向的是一个包含10个完全构造好的string对象的数组,每个对象可以在任何读取string的操作里安全使用。假设你想了个怪招对stringarray1数组里的对象进行了初始化,那么在你后面的程序里你一定会这么做:
free(stringarray1);
delete [] stringarray2;
调用free将会释放stringarray1指向的内存,但内存里的string对象不会调用析构函数。如果string对象象一般情况那样,自己已经分配了内存,那这些内存将会全部丢失。相反,当对stringarray2调用delete时,数组里的每个对象都会在内存释放前调用析构函数。既然new和delete可以这么有效地与构造函数和析构函数交互,选用它们是显然的。
2: 尽量用〈iostream〉而不用〈stdio.h〉
〈stdio.h〉 是C语言中格式输出、输入(prinft和scanf)的头文件,大家知道,他们的输出类型是有限制的,并且不可扩充,比如 %d 对应int 型等,书写很不方便。而在C++中,我们们引入“流”,并且通过操作符的重载,使得cout和cin 有很好的扩展性,再也不用像C中“%d 对应int 型”这样,因为通过函数的重载,它能自动匹配输出。而〈iostream〉正式C++中“流”的头文件。
在传递读和写的对象时采用的语法形式相同,所以不必象scanf那样死记一些规定,比如如果没有得到指针,必须加上地址符,而如果已经得到了指针,又要确定不要加上地址符。这些完全可以交给C++编译器去做。编译器没别的什么事好做的,而你却不一样。最后要注意的是,象int这样的固定类型和象Rational这样的自定义类型在读写时方式是一样的。而你用sacnf和printf试试看!
你所写的表示有理数的类的代码可能象下面这样:
class Rational {
public:
Rational(int numerator = 0, int denominator = 1);
..
private:
int n, d;// 分子,分母
friend ostream& operator〈〈(ostream& s, const Rational& );
};
ostream& operator〈〈(ostream& s, const Rational& r)
{
s〈〈 r.n 〈〈 ’/’ 〈〈 r.d;
return s;
}
3:如果你的Class中有任何指针,请为你的类戳写拷贝构造函数和“=”操作符
我先简单说明一下:假设你的类有指针变量,如果你不重写“=”,那么当你把一个对象a附给另一个对象b时,它会调用默认的“=”操作,而这个默认的“=”操作会逐项附值,导致的结果是两个对象的指针指向同一个区域,当你析构一个对象时,另一个对象的指针指向了一个不再存在的地址,导致错误。
看下面一个表示string对象的类:
// 一个很简单的string类
class string {
public:
string(const char *value);
~string();
... // 没有拷贝构造函数和operator=
private:
char *data;
};
string::string(const char *value)
{
if (value) {
data = new char[strlen(value) + 1];
strcpy(data, value);
}
else {
data = new char[1];
*data = ’\0’;
}
}
inline string::~string() { delete [] data; }
请注意这个类里没有声明赋值操作符和拷贝构造函数。这会带来一些不良后果。
如果这样定义两个对象:
string a(“hello“);
string b(“world“);
其结果就会如下所示:
a: data——〉 “hello\0“
b: data——〉 “world\0“
对象a的内部是一个指向包含字符串“hello“的内存的指针,对象b的内部是一个指向包含字符串“world“的内存的指针。如果进行下面的赋值:
b = a;
因为没有自定义的operator=可以调用,c++会生成并调用一个缺省的operator=操作符。这个缺省的赋值操作符会执行从a的成员到b的成员的逐个成员的赋值操作,对指针(a.data和b.data) 来说就是逐位拷贝。赋值的结果如下所示:
a: data --------〉 “hello\0“
/
b: data --/ “world\0“
这种情况下至少有两个问题。第一,b曾指向的内存永远不会被删除,因而会永远丢失。这是产生内存泄漏的典型例子。第二,现在a和b包含的指针指向同一个字符串,那么只要其中一个离开了它的生存空间,其析构函数就会删除掉另一个指针还指向的那块内存。
4:请让你的基类拥有虚析构函数
这种情况的错误多出现在“多态”中。在“多态”中,我们经常通过基类的指针指向其派生类的对象,并且在程序运行时,通过“动态绑定技术”调用实际对象的成员。但是,在谈到析构问题上,c++语言标准关于这个问题的阐述非常清楚:当通过基类的指针去删除派生类的对象,而基类又没有虚析构函数时,结果将是不可确定的。这意味着编译器生成的代码将会做任何它喜欢的事:重新格式化你的硬盘,给你的老板发电子邮件,把你的程序源代码传真给你的对手,无论什么事都可能发生。
不要问为什么,just do it, please!
另外,给大家一个技巧:有时,一个类想跟踪它有多少个对象存在。一个简单的方法是创建一个静态类成员来统计对象的个数。这个成员被初始化为0,在构造函数里加1,析构函数里减1。设想在一个军事应用程序里,有一个表示敌人目标的类:
class enemytarget {
public:
enemytarget() { ++numtargets; }
enemytarget(const enemytarget&) { ++numtargets; }
~enemytarget() { --numtargets; }
static size_t numberoftargets()
{ return numtargets; }
virtual bool destroy(); // 摧毁enemytarget对象后
// 返回成功
private:
static size_t numtargets; // 对象计数器
};
5:在operator=中检查给自己赋值的情况
偶废话不多说,给你个例子:
看看下面string对象的赋值,赋值运算符没有对给自己赋值的情况进行检查:
class string {
public:
string(const char *value);
~string();
...
string& operator=(const string& rhs);
private:
char *data;
};
// 忽略了给自己赋值的情况
// 的赋值运算符
string& string::operator=(const string& rhs)
{
delete [] data; // delete old memory
data = new char[strlen(rhs.data) + 1]; // 分配新内存,将rhs的值拷贝
strcpy(data, rhs.data);
return *this; // see item 15
}
看看下面这种情况将会发生什么:
string a = “hello“;
a = a; // same as a.operator=(a)
赋值运算符内部,*this和rhs好象是不同的对象,但在现在这种情况下它们却恰巧
是同一个对象的不同名字。可以这样来表示这种情况:
*this data ------------〉 “hello\0“
/
/
rhs data -----
赋值运算符做的第一件事是用delete删除data,其结果将如下所示:
*this data ------------〉 ???
/
/
rhs data -----
6:尽量用“传引用”而不用“传值”
大家知道,引用又称为“别名”,它其实是一个对象的两个名字。比如把“人”当作一个“类”,我本科时候的宿舍老大“闫书磊”(呵呵,现在在成都电子)当作由“人”派生出来的特定对象,那么他的外号“闫秃”就是“闫书磊”的引用。但传值就有所不同,需要在内存重新开辟空间,然后将被传得对象复制一份,这就涉及到类型的定义,进而有可能继续调用“拷贝构造函数”。
总而言之,从效率方面考虑,请用“传引用”。
7:必须返回一个对象时不要试图返回一个引用
据说爱因斯坦曾提出过这样的建议:尽可能地让事情简单,但不要过于简单。在c++语言中相似的说法应该是:尽可能地使程序高效,但不要过于高效。
一旦程序员抓住了“传值”在效率上的把柄,他们会变得十分极端,恨不得挖出每一个隐藏在程序中的传值操作。岂不知,在他们不懈地追求纯粹的“传引用”的过程中,他们会不可避免地犯另一个严重的错误:传递一个并不存在的对象的引用。这就不是好事了。
看一个表示有理数的类,其中包含一个友元函数,用于两个有理数相乘:
class rational {
public:
rational(int numerator = 0, int denominator = 1);
...
private:
int n, d; // 分子和分母
friend
const rational //
operator*(const rational& lhs, // 返回值是const
const rational& rhs)
};
inline const rational operator*(const rational& lhs,
const rational& rhs)
{
return rational(lhs.n * rhs.n, lhs.d * rhs.d);
}
很明显,这个版本的operator*是通过传值返回对象结果,如果不去考虑对象构造和析构时的开销,你就是在逃避作为一个程序员的责任。另外一件很明显的事实是,除非确实有必要,否则谁都不愿意承担这样一个临时对象的开销。那么,问题就归结于:确实有必要吗?
答案是,如果能返回一个引用,当然就没有必要。但请记住,引用只是一个名字,一个其它某个已经存在的对象的名字。无论何时看到一个引用的声明,就要立即问自己:它的另一个名字是什么呢?因为它必然还有另外一个什么名字。拿operator*来说,如果函数要返回一个引用,那它返回的必须是其它某个已经存在的rational对象的引用,这个对象包含了两个对象相乘的结果。但,期望在调用operator*之前有这样一个对象存在是没道理的。也就是说,如果有下面的代码:
rational a(1, 2); // a = 1/2
rational b(3, 5); // b = 3/5
rational c = a * b; // c 为 3/10
期望已经存在一个值为3/10的有理数是不现实的。如果operator* 一定要返回这样一个数的引用,就必须自己创建这个数的对象。
一个函数只能有两种方法创建一个新对象:在堆栈里或在堆上。在堆栈里创建对象时伴随着一个局部变量的定义,采用这种方法,就要这样写operator*:
// 写此函数的第一个错误方法
inline const rational& operator*(const rational& lhs,
const rational& rhs)
{
rational result(lhs.n * rhs.n, lhs.d * rhs.d);
return result;
}
这个方法应该被否决,因为我们的目标是避免构造函数被调用,但result必须要象其它对象一样被构造。另外,这个函数还有另外一个更严重的问题,它返回的是一个局部对象的引用,那么,在堆上创建一个对象然后返回它的引用呢?基于堆的对象是通过使用new产生的,所以应该这样写operator*:
// 写此函数的第二个错误方法
inline const rational& operator*(const rational& lhs,
const rational& rhs)
{
rational *result =
new rational(lhs.n * rhs.n, lhs.d * rhs.d);
return *result;
}
首先,你还是得负担构造函数调用的开销,因为new分配的内存是通过调用一个适当的构造函数来初始化的。另外,还有一个问题:谁将负责用delete来删除掉new生成的对象呢?实际上,这绝对是一个内存泄漏
8:有关Const的相关知识
对指针来说,可以指定指针本身为const,也可以指定指针所指的数据为const,或二者同时指定为const,还有,两者都不指定为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数据
语法并非看起来那么变化多端。一般来说,你可以在头脑里画一条垂直线穿过指针声明中的星号(*)位置,如果const出现在线的左边,指针指向的数据为常量;如果const出现在线的右边,指针本身为常量;如果const在线的两边都出现,二者都是常量。
在指针所指为常量的情况下,有些程序员喜欢把const放在类型名之前,有些程序员则喜欢把const放在类型名之后、星号之前。所以,下面的函数取的是同种参数类型:
class widget { ... };
void f1(const widget *pw); // f1取的是指向 widget常量对象的指针void f2(widget const *pw); // 同f2
因为两种表示形式在实际代码中都存在,所以要使自己对这两种形式都习惯。
const的一些强大的功能基于它在函数声明中的应用。在一个函数声明中,const可以指的是函数的返回值,或某个参数;对于成员函数,还可以指的是整个函数。
让函数返回一个常量值经常可以在不降低安全性和效率的情况下减少用户出错的几率。例如,看这个有理数的operator*函数的声明:
const rational operator*(const rational& lhs,const rational& rhs);
很多程序员第一眼看到它会纳闷:为什么operator*的返回结果是一个const对象?因为如果不是这样,用户就可以做下面这样的坏事:
rational a, b, c;
...
(a * b) = c; // 对a*b的结果赋值
我不知道为什么有些程序员会想到对两个数的运算结果直接赋值,但我却知道:如果a,b和c是固定类型,这样做显然是不合法的。一个好的用户自定义类型的特征是,它会避免那种没道理的与固定类型不兼容的行为。对我来说,对两个数的运算结果赋值是非常没道理的。声明operator*的返回值为const可以防止这种情况,所以这样做才是正确的。
还有一种情况下,通过类型转换消除const会既有用又安全。这就是:将一个const对象传递到一个取非const参数的函数中,同时你又知道参数不会在函数内部被修改的情况时。第二个条件很重要,因为对一个只会被读的对象(不会被写)消除const永远是安全的,即使那个对象最初曾被定义为const。
例如,已经知道有些库不正确地声明了象下面这样的strlen函数:
size_t strlen(char *s);
strlen当然不会去修改s所指的数据——至少我一辈子没看见过。但因为有了这个声明,对一个const char *类型的指针调用这个函数时就会不合法。为解决这个问题,可以在给strlen传参数时安全地把这个指针的const强制转换掉:
const char *klingongreeting = “nuqneh“; // “nuqneh“即“hello“
size_t length =strlen(const_cast〈char*〉(klingongreeting));
但不要滥用这个方法。只有在被调用的函数(比如本例中的strlen)不会修改它的参数所指的数据时,才能保证它可以正常工作。
我下面说一下有关const指针的用法(这一部分大家可参考C++Primer(3))
当我们写下下面的代码
Const double value=3.0;
Double *ptr=&value; (error)
编译器会报错,因为它把“试图通过该指针间接地改变对象值”的动作标记为错误。但是这并不意味着我们不能间接的指向一个const对象,只标明我们必须声明一个指向常量的指针来做这件事情。例如
Const double *cptr=&value; ( right)
此时,类似于*cptr=3.8等的改变原值的附值操作是错的,因为我们不能改变常量指针所指对象的值。
但是,常量指针也可以指向一个非常量对象,例如
Double meng=23.3; cptr=&meng; (right)
虽然meng 不是常量,但是,试图通过cptr改变它的值仍然会导致编译错误。
在实际的程序中,指向const 的指针经常用来做函数参数,它作为一个月定:被传给函数的实际对象在函数种不能被修改。例如
Int strcmp(const char *str1,const char *str2);
这样的好处是,我们可以把常量或者非常量传递给此函数,而且次函数保证不会对它做修改。
9:尽量使用初始化而不要在构造函数里赋值
看这样一个模板,它生成的类使得一个名字和一个t类型的对象的指针关联起来。
template〈class t〉
class namedptr {
public:
namedptr(const string& initname, t *initptr);
...
private:
string name;
t *ptr;
};
(因为有指针成员的对象在进行拷贝和赋值操作时可能会引起指针混乱,namedptr也必须实现这些函数)
在写namedptr构造函数时,必须将参数值传给相应的数据成员。有两种方法来实现。第一种方法是使用成员初始化列表:
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和引用数据成员只能用初始化,不能被赋值。所以,如果想让namedptr〈t〉对象不能改变它的名字或指针成员,就建议声明成员为const:
template〈class t〉
class namedptr {
public:
namedptr(const string& initname, t *initptr);
...
private:
const string name;
t * const ptr;
};
这个类的定义要求使用一个成员初始化列表,因为const成员只能被初始化,不能被赋值。
如果namedptr〈t〉对象包含一个现有名字的引用,情况会非常不同。但还是要在构造函数的初始化列表里对引用进行初始化。还可以对名字同时声明const和引用,这样就生成了一个其名字成员在类外可以被修改而在内部是只读的对象。
template〈class t〉
class namedptr {
public:
namedptr(const string& initname, t *initptr);
...
private:
const string& name; // 必须通过成员初始化列表
// 进行初始化
t * const ptr; // 必须通过成员初始化列表
// 进行初始化
};
然而前面最初的类模板不包含const和引用成员。即使这样,用成员初始化列表还是比在构造函数里赋值要好。
(2)这次的原因在于效率。
当使用成员初始化列表时,只有一个string成员函数被调用。而在构造函数里赋值时,将有两个被调用。为了理解为什么,请看在声明namedptr〈t〉对象时都发生了些什么。
对象的创建分两步:
1. 数据成员初始化。
2. 执行被调用构造函数体内的动作。
(对有基类的对象来说,基类的成员初始化和构造函数体的执行发生在派生类的成员初始化和构造函数体的执行之前)
对namedptr类来说,这意味着string对象name的构造函数总是在程序执行到namedptr的构造函数体之前就已经被调用了。问题只在于:string的哪个构造函数会被调用?
这取决于namedptr类的成员初始化列表。如果没有为name指定初始化参数,string的缺省构造函数会被调用。当在namedptr的构造函数里对name执行赋值时,会对name调用operator=函数。这样总共有两次对string的成员函数的调用:一次是缺省构造函数,另一次是赋值。
相反,如果用一个成员初始化列表来指定name必须用initname来初始化,name就会通过拷贝构造函数以仅一个函数调用的代价被初始化。
10:操作符重载时,注意成员函数,非成员函数和友元函数应用的不同
1:成员函数
成员函数和非成员函数最大的区别在于成员函数可以是虚拟的而非成员函数不行。所以,如果有个函数必须进行动态绑定,就要采用虚拟函数,而虚拟函数必定是某个类的成员函数。关于这一点就这么简单。如果函数不必是虚拟的,情况就稍微复杂一点。
看下面表示有理数的一个类:
class rational {
public:
rational(int numerator = 0, int denominator = 1);
int numerator() const;
int denominator() const;
private:
...
};
这是一个没有一点用处的类。所以,要对它增加加,减,乘等算术操作支持,但是,该用成员函数还是非成员函数,或者,非成员的友元函数来实现呢?
当拿不定主意的时候,用面向对象的方法来考虑!有理数的乘法是和rational类相联系的,所以,写一个成员函数把这个操作包到类中。
class rational {
public:
...
const rational operator*(const rational& rhs) const;
};
现在可以很容易地对有理数进行乘法操作:
rational oneeighth(1, 8);
rational onehalf(1, 2);
rational result = onehalf * oneeighth; // 运行良好
result = result * oneeighth; // 运行良好
但不要满足,还要支持混合类型操作,比如,rational要能和int相乘。但当写下下面的代码时,只有一半工作:
result = onehalf * 2; // 运行良好
result = 2 * onehalf; // 出错!
这是一个不好的苗头。记得吗?乘法要满足交换律。
如果用下面的等价函数形式重写上面的两个例子,问题的原因就很明显了:
result = onehalf.operator*(2); // 运行良好
result = 2.operator*(onehalf); // 出错!
对象onehalf是一个包含operator*函数的类的实例,所以编译器调用了那个函数。而整数2没有相应的类,所以没有operator*成员函数。秘密在于隐式类型转换。编译器知道传的值是int而函数需要的是rational,但它也同时知道调用rational的构造函数将int转换成一个合适的rational,所以才有上面成功的调用。换句话说,编译器处理这个调用时的情形类似下面这样:
const rational temp(2); // 从2产生一个临时
// rational对象
result = onehalf * temp; // 同onehalf.operator*(temp);
2:非成员函数:
你可能还是想支持混合型的算术操作,而实现的方法现在应该清楚了:使operator*成为一个非成员函数,从而允许编译器对所有的参数执行隐式类型转换:
class rational {
... // contains no operator*
};
// 在全局或某一名字空间声明,
const rational operator*(const rational& lhs,
const rational& rhs)
{
return rational(lhs.numerator() * rhs.numerator(),
lhs.denominator() * rhs.denominator());
}
rational onefourth(1, 4);
rational result;
result = onefourth * 2; // 工作良好
result = 2 * onefourth; // 万岁, 它也工作了!
这当然是一个完美的结局,但还有一个担心:operator*应该成为rational类的友元吗?
这种情况下,答案是不必要。因为operator*可以完全通过类的公有(public)接口来实现。上面的代码就是这么做的。只要能避免使用友元函数就要避免,因为,和现实生活中差不多,友元(朋友)带来的麻烦往往比它(他/她)对你的帮助多。
3:友元函数
然而,很多情况下,不是成员的函数从概念上说也可能是类接口的一部分,它们需要访问类的非公有成员的情况也不少。
让我们回头再来看看本书那个主要的例子,string类。如果想重载operator〉〉和operator〈〈来读写string对象,你会很快发现它们不能是成员函数。如果是成员函数的话,调用它们时就必须把string对象放在它们的左边:
// 一个不正确地将operator〉〉和
// operator〈〈作为成员函数的类
class string {
public:
string(const char *value);
...
istream& operator〉〉(istream& input);
ostream& operator〈〈(ostream& output);
private:
char *data;
};
string s;
s 〉〉 cin; // 合法, 但有违常规
s 〈〈 cout; // 同上
这会把别人弄糊涂。所以这些函数不能是成员函数。注意这种情况和前面的不同。这里的目标是自然的调用语法,前面关心的是隐式类型转换。
所以,如果来设计这些函数,就象这样:
istream& operator〉〉(istream& input, string& string)
{
delete [] string.data;
read from input into some memory, and make string.data
point to it
return input;
}
ostream& operator〈〈(ostream& output,const string& string)
{
return output 〈〈 string.data;
}
注意上面两个函数都要访问string类的data成员,而这个成员是私有(private)的。但我们已经知道,这个函数一定要是非成员函数。这样,就别无选择了:需要访问非公有成员的非成员函数只能是类的友元函数。
11:区分接口继承(纯虚拟函数和非纯虚拟函数)和实现继承(非虚拟函数)
(公有)继承的概念看起来很简单,进一步分析,会发现它由两个可分的部分组成:函数接口的继承和函数实现的继承。作为类的设计者,有时希望派生类只继承成员函数的接口(声明);有时希望派生类同时继承函数的接口和实现,但允许派生类改写实现;有时则希望同时继承接口和实现,并且不允许派生类改写任何东西。为了更好地体会这些选择间的区别,看下面这个类层次结构,它用来表示一个图形程序中的几何形状:
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 { ... };
纯虚函数draw使得Shape成为一个抽象类。所以,用户不能创建Shape类的实例,只能创建它的派生类的实例。但是,从Shape(公有)继承而来的所有类都受到Shape的巨大影响,因为:
1、 成员函数的接口总会被继承。
公有继承的含义是 “是一个“ ,所以对基类成立的所有事实也必须对派生类成立。因此,如果一个函数适用于某个类,也必将适用于它的子类。Shape类中声明了三个函数。第一个函数,draw,在某一画面上绘制当前对象。第二个函数,error,被其它成员函数调用,用于报告出错信息。第三个函数,objectID,返回当前对象的一个唯一整数标识符。每个函数以不同的方式声明:draw是一个纯虚函数;error是一个简单的(非纯?)虚函数;objectID是一个非虚函数。这些不同的声明各有什么含义呢?首先看纯虚函数draw。纯虚函数最显著的特征是:它们必须在继承了它们的任何具体类中重新声明,而且它们在抽象类中往往没有定义。把这两个特征放在一起,就会认识到:
2、定义纯虚函数的目的在于,使派生类仅仅只是继承函数的接口。
这对Shape::draw函数来说非常有意义,因为,让所有Shape对象都可以被绘制是很合理,但Shape类无法为Shape::draw提供一个合理的缺省实现。例如,绘制椭园的算法就和绘制矩形的算法大不一样。打个比方来说,上面Shape::draw的声明就象是在告诉子类的设计者,“你必须提供一个draw函数,但我不知道你会怎样实现它。“顺便说一句,为一个纯虚函数提供定义也是可能的。也就是说,你可以为Shape::draw提供实现,C++编译器也不会阻拦,但调用它的唯一方式是通过类名完整地指明是哪个调用:
Shape *ps = new Shape; // 错误! Shape是抽象的
Shape *ps1 = new Rectangle; // 正确
ps1-〉draw(); // 调用Rectangle::draw
Shape *ps2 = new Ellipse; // 正确
ps2-〉draw(); // 调用Ellipse::draw
ps1-〉Shape::draw(); // 调用Shape::draw
ps2-〉Shape::draw(); // 调用Shape::draw
一般来说,除了能让你在鸡尾酒会上给你的程序员同行留下深刻印象外,了解这种用法一般没大的作用。然而,正如后面将看到的,它可以被应用为一种机制,为简单的(非纯)虚函数提供 “比一般做法更安全“ 的缺省实现。
3、声明简单虚函数的目的在于,使派生类继承函数的接口和缺省实现
简单虚函数的情况和纯虚函数有点不一样。照例,派生类继承了函数的接口,但简单虚函数一般还提供了实现,派生类可以选择改写它们或不改写它们。思考片刻就可以认识到:具体到Shape::error,这个接口是在说,每个类必须提供一个出错时可以被调用的函数,但每个类可以按它们认为合适的任何方式处理错误。如果某个类不想做什么特别的事,可以借助于Shape类中提供的缺省出错处理函数。也就是说,Shape::error的声明是在告诉子类的设计者,“你必须支持error函数,但如果你不想写自己的版本,可以借助Shape类中的缺省版本。“
4、声明非虚函数的目的在于,使派生类继承函数的接口和强制性实现。
可以认为,Shape::objectID的声明就是在说,“每个Shape对象有一个函数用来产生对象的标识符,并且对象标识符的产生方式总是一样的。这种方式由Shape::objectID的定义决定,派生类不能改变它。“ 因为非虚函数表示一种特殊性上的不变性,所以它决不能在子类中重新定义。
理解了纯虚函数、简单虚函数和非虚函数在声明上的区别,就可以精确地指定你想让派生类继承什么:仅仅是接口,还是接口和一个缺省实现?或者,接口和一个强制实现?因为这些不同类型的声明指的是根本不同的事,所以在声明成员函数时一定要从中慎重选择。只有这样做,才可以避免没经验的程序员常犯的两个错误。
第一个错误是把所有的函数都声明为非虚函数。这就使得派生类没有特殊化的余地;非虚析构函数尤其会出问题。当然,设计出来的类不准备作为基类使用也是完全合理的。这种情况下,专门声明一组非虚成员函数是适当的。但是,把所有的函数都声明为非虚函数,大多数情况下是因为对虚函数和非虚函数之间区别的无知,或者是过分担心虚函数对程序性能的影响。而事实上是:几乎任何一个作为基类使用的类都有虚函数。
另一个常见的问题是将所有的函数都声明为虚函数。但是,这样做往往表现了类的设计者缺乏表明坚定立场的勇气。一些函数不能在派生类中重定义,只要是这种情况,就要旗帜鲜明地将它声明为非虚函数。不能让你的函数好象可以为任何人做任何事 ---- 只要他们花点时间重新定义所有的函数。记住,如果有一个基类B,一个派生类D,和一个成员函数mf,那么下面每个对mf的调用都必须工作正常:
D *pd = new D;
B *pb = pd;
pb-〉mf(); // 通过基类指针调用mf
pd-〉mf(); // 通过派生类指针调用mf
有时,必须将mf声明为非虚函数才能保证一切都以你所期望的方式工作。