Bjarne Stroustrup的 C++ 风格与技术常见问题与答案(节译一)
最近CKER工作很忙,实在对不起关心我的朋友......
真诚致歉.....:)
本文中包含大家经常问到的关于C++ 风格与技术的问题。若您有更好的的问题与建议请发信到 bs@research.att.com。要知道我不可能将所有的时间花在更新我的网页上。
更普通的问题,参阅 general FAQ.
术语和概念,参阅 C++ glossary.
请记住这里只是些问题和答案。并非您在一本好书中可以见到的精挑细选的例子和解释。某些说明可能也没有参考手册和标准中那样精准。关于C++设计的问题您可以去The Design and Evolution of C++看看。关于C++和其标准库的使用问题可以参看The C++ Programming Language。
为什么我编译起来忒慢?
您的编译器或许有问题。也许太老了,或者安装的有问题,也可能您的计算机已经过时了。如果这样我也无能为力。
但是,这看起来更像是您要编译的程序设计的太差。因而在编译的时候,编译器不得不检查数以百计的头文件和成千上万行的代码。原则上,这是可以避免的。如果问题处在您的程序库开发商的设计上,您也干不了什么(除了选择一个更好的库),但您可以将您的代码结构化,来减少每次改动后重新编译所需的时间。一个能体现良好的对象关系分割的设计通常总是不错的,维护性也好。
考虑如下面向对象编程的经典例子: class Shape {
public:// Shapes的用户接口
virtual void draw() const;
virtual void rotate(int degrees);
// ...
protected:// 通用数据实现
Point center;
Color col;
// ...
};
class Circle : public Shape {
public:
void draw() const;
void rotate(int) { }
// ...
protected:
int radius;
// ...
};
class Triangle : public Shape {
public:
void draw() const;
void rotate(int);
// ...
protected:
Point a, b, c;
// ...
};
思路是用户通过公共接口来操纵shape对象,并且派生类的实现(比如Circle和Triangle)共享由保护成员所代表的特征。
定义对所有子类都有用的共享特征并不容易。原因在于,保护成员们似乎比公共接口变化得要快得多。比如,尽管可以论证“Center”对所有的shape来说都是存在的。但被迫维护一个▲的中心点实在很麻烦-对▲来说 ,计算其中心多半是毫无意义的,除非有人对此有特别的兴趣。
保护成员似乎更依赖于用户Shape的具体实现,但这种依赖关系不是必须存在的。比如,使用Shape的多数(绝大多数?)代码和 "Color"的定义在逻辑上是无关的,但Shape定义中Color的存在,使得通常需要编译描述操作系统对颜色定义的头文件。
当保护体中的某些部分发生变化的时候,用户的Shape不得不重新编译-尽管只有派生类的实现才能访问这些保护成员。
因此,这些在基类中"对派生类实现有用的信息"同时也充当了用户接口。这就是造成派生类实现的不稳定性;(在基类中改变信息时)不合逻辑的重新编译用户代码;以及在用户代码中过度包含头文件(因为"对派生类实现有用的信息"需要这些头文件)的根源。有时我们将这种现象称之为"brittle base class problem"("致命的基类问题")。
解决之道显然是在用作用户接口的类中去掉这些"对派生类实现有用的信息"。这就是说:只生成接口,纯接口。也就是代表接口的纯虚基类。 class Shape {
public:// Shape的用户接口
virtual void draw() const = 0;
virtual void rotate(int degrees) = 0;
virtual Point center() const = 0;
// ...
// 无数据
};
class Circle : public Shape {
public:
void draw() const;
void rotate(int) { }
Point center() const { return center; }
// ...
protected:
Point cent;
Color col;
int radius;
// ...
};
class Triangle : public Shape {
public:
void draw() const;
void rotate(int);
Point center() const;
// ...
protected:
Color col;
Point a, b, c;
// ...
};
用户现在从派生类实现的变化中隔离开来了。我已经发现这个技术使得编译时间有数量级的减少。
但的确在所有派生类(或者只是一部分派生类)中有需要某些信息的时候该怎么办?只需将这些信息封装在另一个类中,然后实现派生类时同时也继承此类: class Shape {
public:// interface to users of Shapes
virtual void draw() const = 0;
virtual void rotate(int degrees) = 0;
virtual Point center() const = 0;
// ...
// no data
};
struct Common {
Color col;
// ...
};
class Circle : public Shape, protected Common {
public:
void draw() const;
void rotate(int) { }
Point center() const { return center; }
// ...
protected:
Point cent;
int radius;
};
class Triangle : public Shape, protected Common { //译者注:呵呵,多继承。唉....BCB不支持啊...:(
public:
void draw() const;
void rotate(int);
Point center() const;
// ...
protected:
Point a, b, c;
};
为什么空类的大小不是零?
这是为了确保两个不同的对象拥有不同的地址。出于同样的原因,new总是返回一个唯一的对象指针。考虑如下代码: class Empty { };
void f()
{
Empty a, b;
if (&a == &b) cout << "impossible: report error to compiler supplier \n不可能:快向您的编译器厂商报错!";
Empty* p1 = new Empty;
Empty* p2 = new Empty;
if (p1 == p2) cout << "impossible: report error to compiler supplier \n不可能:快向您的编译器厂商报错!";
}
关于空基类有个有趣的规则,就是空基类无需单独用一个字节代表: struct X : Empty { //译者注:从Empty基类继承
int a;
// ...
};
void f(X* p)
{
void* p1 = p;
void* p2 = &p->a;
if (p1 == p2) cout << "nice: good optimizer\n很好:不错的优化";
}
这种优化是安全的,并且很有用。它允许程序员使用空类来描述十分简单的概念而无需重载。目前已经有一些编译器提供这种 叫做"empty base class optimization"的优化。『译者注:BCB提供对空基类的优化,DEV C++ 好像不行.......』
为什么我不得不将数据放在我的 类定义部分?
您不该这么做。如果您的接口不需要数据,不要将它们放在接口定义类中。应该放在派生类中。参见Why do my compiles take so long?.
有时,您不得不在类中描述数据。考虑complex类: template< class Scalar> class complex {
public:
complex() : re(0), im(0) { }
complex(Scalar r) : re(r), im(0) { }
complex(Scalar r, Scalar i) : re(r), im(i) { }
// ...
complex& operator+=(const complex& a)
{ re+=a.re; im+=a.im; return *this; }
// ...
private:
Scalar re, im;
};
复数类被设计为用作系统内置类型。此处要想创建真正的本地对象(比如:对象在栈中分配,而不是在堆中)在类声明中的描述是必须的。这样才能确保简单操作的正确内联。本地对象和内联对复数类取得与系统内置复数类型相近的性能来说是必须的。
为什么成员函数缺省不是虚的 (virtual) ?
因为很多类都不是用作基类的。例子请参阅class complex。
同时,带一个虚函数的类的对象需要额外的空间。这是虚函数调用机制所要求的-通常是一个对象一个word(字)大小。这种开支是有意义的,但也使规划来自其他语言的数据变得复杂起来。(比如:C和Fortan)
参阅 The Design and Evolution of C++ 可以得到关于合理设计的更多资讯。
为什么析构函数缺省不是虚的 (virtual)?
因为很多类不是设计来用作基类的。虚函数只在用作派生对象的接口类中才有意义(通常在堆中分配,并通过引用指针访问)。
因此,什么时候需要将虚析构函数呢?当类包含至少一个虚函数时。包含虚函数意味着这个类被用作派生类的接口,这时一个派生类对象就有可能由基类指针释放销毁。比如: class Base {
// ...
virtual ~Base();
};
class Derived : public Base {
// ...
~Derived();
};
void f()
{
Base* p = new Derived;
delete p;// 虚析构函数用来确保调用派生类析构函数~Derived
}
如果基类的析构函数不是虚的,派生类的析构函数不可能被调用-这个副作用很明显,由派生类分配的资源没有释放。『译者注:这也是BCB中所有的TObject类的析构函数都必须声明为虚的原因。』
为什么没有虚构造函数?
虚调用是一种在可以在只有部分信息的情况下工作的机制。特别是允许我们调用一个只知道接口而不知道其准确的对象类型的函数。但要创建一个对象您需要全部的信息,特别是必须要知道对象的准确类型。因此,构造函数不能是虚的。
但仍然有可以间接实现像"虚构造函数"那样来创建对象的技术。例子参见TC++PL3 15.6.2.
下面的例子就是一种使用抽象类来生成适当类型的对象的技术: struct F {// 对象创建函数的接口
virtual A* make_an_A() const = 0;
virtual B* make_a_B() const = 0;
};
void user(const F& fac)
{
A* p = fac.make_an_A();// 生成适当类型的对象A
B* q = fac.make_a_B();// 生成适当类型的对象B
// ...
}
struct FX : F {
A* make_an_A() const { return new AX();} // AX 从A继承而来
B* make_a_B() const { return new BX();} // BX 从B继承而来
};
struct FY : F {
A* make_an_A() const { return new AY();} // AY 从A继承而来
B* make_a_B() const { return new BY();} // BY 从B继承而来
};
int main()
{
user(FX());// 此用户生成了AX和BX
user(FY());// 此用户生成了AY和BY
// ...
}
这个变化通常称作"the factory pattern"工厂模式。关键在于user()将比如AX和AY这样的类信息完全隔离掉了。
为什么派生类没有重载?
这个问题(有很多变化)通常会像下面这样提出来: #include< iostream>
using namespace std;
class B {
public:
int f(int i) { cout << "f(int): "; return i+1; }
// ...
};
class D : public B {
public:
double f(double d) { cout << "f(double): "; return d+1.3; }
// ...
};
int main()
{
D* pd = new D;
B* pb = pd;
cout << pd->f(2) << '\n';
cout << pd->f(2.3) << '\n';
}
运行结果: f(double): 3.3
f(double): 3.6
而不是像某些人(错误)的想象: f(int): 3
f(double): 3.6
换句话说,在D和B之间没有发生重载解析。编译器在D的作用域中查找,并发现了唯一的函数"double f(double)"并调用它。它永远不会打扰B(封装)的作用域。在C++中,没有跨作用域的重载-派生类作用域也不会例外。(详见 D&E 或 TC++PL3)。
但我如何从我的基类和派生类对所有的f()进行重载?使用using声明很容易做到。 class D : public B {
public:
using B::f;// 使得所有来自于B的f都可用
double f(double d) { cout << "f(double): "; return d; }
// ...
};
此时的输出会是 f(int): 3
f(double): 3.6
这就是说,重载解析同时应用于B的f()和D的f(),并选择调用最合适的f()。
我能在构造函数中调用一个虚函数么?
可以,但必须小心。它可能不像您想象的那样运行。在构造函数中,虚调用机制被禁用。原因是来自于派生类的重载还没有发生。对象构造时首先调用基类的方法,"base before derived"(基类先于派生类)。
考虑如下代码: #include< string>
#include< iostream>
using namespace std;
class B {
public:
B(const string& ss) { cout << "B constructor\n"; f(ss); }
virtual void f(const string&) { cout << "B::f\n";}
};
class D : public B {
public:
D(const string & ss) :B(ss) { cout << "D constructor\n";}
void f(const string& ss) { cout << "D::f\n"; s = ss; }
private:
string s;
};
int main()
{
D d("Hello");
}
程序编译运行如下 B constructor
B::f
D constructor
注意,没有D::f。想想如果改变规则的话会发生什么。B::B()将调用 D::f()。因为构造函数D::D()还没有运行,D::f()试图将参数赋给没有初始化的字符串s。最有可能的结果是程序立刻崩溃。
析构的次序则遵照 "derived class before base class"(派生类先于基类)的次序。所以虚函数的行为跟构造函数相同。只有本地的定义被使用-不会调用重载的函数以避免接触(已经销毁的)派生类对象的部分。
详见 D&E 13.2.4.2 或 TC++PL3 15.4.3.
这条规则看起来好像是人为加上的。但不是这样。事实上,要让构造函数调用虚函数的行为与其他函数完全一致的规则很容易实现。可是,这样做同时也暗示了不能书写任何依赖于基类创建的常量的虚函数。实在是可怕的混乱。