CSDN博客

img taodm

More Effective C++ Item M32:在未来时态下开发程序

发表于2002/3/31 15:44:00  778人阅读

2. 杂项
我们现在到了接近结束的部分了,这章讲述的是一些不属于前面任一章节的指导原则。开始两个是关于C++软件开发的,描述的是设计适应变化的系统。面向对象的一个强大之处是支持变化,这两个条款描述具体的步骤来增强你的软件对变化的抵抗能力。
然后,我们分析怎么在同一程序中进行C和C++混合编程。这必然导致考虑语言学之外的问题,但C++存在于真实世界中,所以有时我们必须面对这种事情。
最后,我将概述C++语言标准在《The Annotated C++ Reference Manual 》出版后发生的变化。尤其是,我将覆盖标准运行库中发生的广大变化(参见Item E49)。如果你没有紧跟标准化的过程,那么将可能很吃惊--很多变化都令人很开心。
 
2.1 Item M32:在未来时态下开发程序
事物在变化。
作为软件开发人员,我们也许知道得不多,但我们知道万物都会变化。我们没必要知道什么将发生变化,这么变化又怎么发生,以什么时候发生,在哪里发生,但我们知道:万物都会变化。
好的软件能够适应变化。它提供新的特性,适应到新的平台,满足新的需求,处理新的输入。软件的灵活性、健壮性、可靠性不是来自于意外。它是程序员们在满足了现在的需求并关注了将来的可能后设计和实现出来的。这样的软件(接受小改变的软件)是那些在未来时态下开发程序的人写出来的。
要在未来时态下开发程序,就必须接受事物会发生变化,并为此作了准备。这是应该考虑的:新的函数将被加入到函数库中,新的重载将发生,于是要注意那些含糊的函数调用行为的结果;新的类将会加入继承层次,现在的派生类将会是以后的基类,并已为此作好准备;将会编制新的应用软件,函数将在新的运行环境下被调用,它们应该被写得在新平台上运行正确;程序的维护人员通常不是原来编写它们的人,因此应该被设计得易于被别人理解、维护和扩充。
这么做的一种方法是:用C++语言自己来表达设计上的约束条件,而不是用注释或文档。例如,如果一个类被设计得不会被继承,不要只是在其头文件中加个注释,用C++的方法来阻止继承;Item M26显示了这个技巧。如果一个类需要其实例全部创建在堆中,不要只是对用户说了这么一句,用Item M27的方法来强迫这一点。如果拷贝构造和赋值对一个类是没有意义的,通过申明它们为私有来阻止这些操作(见Item E27)。C++提供了强大的功能、灵活度和表达力。用语言提供的这些特性来强迫程序符合设计。
因为万物都会变化,要写能承受软件发展过程中的混乱攻击的类。避免“demand-paged”(WQ:“用户要求型”之类的意思吧)的虚函数,凭什么你本没有写虚函数而直到有人来要求后你就更改为虚函数?应该判断一个函数的含意,以及它被派生类重定义的话是否有意义。如果是有意义的,申明它为虚,即使没有人立即重定义它。如果不是的话,申明它为非虚,并且不要在以后为了便于某人而更改;确保更改是对整个类的运行环境和类所表示的抽象是有意义的(见Item E36)。
处理每个类的赋值和拷贝构造函数,即使“从没人这样做过”。他们现在没有这么做并不意味着他们以后不这么做(见Item E18)。如果这些函数是难以实现的,那么申明它们为私有。这样,不会有人误调编译器提供的默认版本而做错事(这在默认赋值和拷贝构造函数上经常发生,见Item E11)。
基于最小惊讶法则:努力提供这样的类,它们的操作和函数有自然的语法和直观的语义。和内建数据类型的行为保持一致:拿不定主意时,仿照int来做。
要承认:只要是能被人做的,就有人这么做(WQ:莫菲法则)。他们会抛异常;会用自己给自己赋值;在没有赋初值前就使用对象;给对象赋了值而没有使用;会赋过大的值、过小的值或空值。一般而言,只要能编译通过,就有人会这么做。所以,要使得自己的类易于被正确使用而难以误用。要承认用户可能犯错误,所以要将你的类设计得可以防止、检测或修正这些错误(例子见Item M33和Item E46)。
努力于可移植的代码。写可移植的代码并不比不可移植的代码难太多,只有在性能极其重要时采用不可移植的结构才是可取的(见Item M16)。即使是为特定的硬件设计的程序也经常被移植,因为这些平台在几年内就会有一个数量级的性能提升。可移植的代码使得你在更换平台是比较容易,扩大你的用户基础,吹嘘支持开放平台。这也使得你赌错了操作系统时比较容易补救。
将你的代码设计得当需要变化时,影响是局部的。尽可能地封装;将实现细节申明为私有(例子见Item E20)。只要可能,使用无名的命名空间和文件内的静态对象或函数(见Item E31)。避免导致虚基类的设计,因为这种类需要每个派生类都直接初始化它--即使是那些间接派生类(见Item M4和Item E43)。避免需要RTTI的设计,它需要if...then...else型的瀑布结构(再次参见Item M31,然后看Item E39上的好方法)。每次,类的继承层次变了,每组if...then...else语句都需要更新,如果你忘掉了一个,你不会从编译器得到任何告警。
这是著名的老生常谈般的告戒,但大部分程序员仍然违背它。看这条一个著名C++专家提出忠告(很不幸,许多作者也这么说):
你需要虚析构函数,只要有人delete一个实际值向D的B *。
这里,B是基类,D是其派生类。换句话说,这位作者暗示,如果你的程序看起来是这样时,并不需要B有虚析构函数:
class B { ... };                   // no virtual dtor needed
class D: public B { ... };
B *pb = new D;
然而,当你加入这么一句时,情况就变了:
delete pb;                        // NOW you need the virtual
                                  // destructor in B
这意味着,用户代码中的一个小变化--增加了一个delete语句--实际上能导致需要修改B的定义。如果这发生了的话,所有B的用户都必须重编译。采纳了这个作者的建议的话,一条语句的增加将导致大量代码的重编译和重链接。这绝不是一个高效的设计。
就同一主题,另一个作者写道:
如果一个公有基类没有虚析构函数,所有的派生类基其成员函数都不应该有析构函数。
    也就是说,这是没问题的:
class string {                    // from the standard C++ library
public:
  ~string();
};
class B { ... };                  // no data members with dtors,
                                  // no virtual dtor needed
但从B继承一个新类后,事情就变了:
class D: public B {
  string name;                    // NOW ~B needs to be virtual
};
再一次,一个关于B的使用的小小的变化(这里是增加了一个包含有析构函数的成员对象的派生类)可能需要大量代码的重编译和重链接。但在系统中,小的变化应该只有小的影响。这个设计在这个测试上失败了。
同一作者写了:
如果多重继承体系有许多析构函数,每个基类都应该有应该虚析构函数。
所有这些引用,都在关注进行时态的考虑。用户现在在怎么操纵指针?当前类的什么成员有析构函数?继承系统中的什么类有析构函数?
未来时态的考虑完全不同。不是问一个类现在正被怎么使用,而是问这个类是被设计为怎么去使用的。未来时态的考虑认为:如果一个类被设计为作一个基类使用(即使现在还没有被这么使用),它就应该有一个虚析构函数(见Item E14)。这样的类在现在和将来都行为正确,并且当新类从它们派生时并不影响其它库用户。(至少,它们没有任何影响,直到其析构函数被使用。如果需要对类的额外修改,其它用户将受影响。)
有一个商业类库(在C++标准运行库申明string以前)包含了一个没有虚析构函数的string类。其生产商解释:
我们没有使用虚析构函数,因为我们不想这个string类有vtbl。我们甚至不期望想有一个string *,所以这不成为问题。我们很清楚这么做将造成的困难。
这是进行时态的考虑还是未来时态的考虑?
当然,vbtl有个技术上的问题(见Item M24和Item E14)。大部分string类的实现都是对象内部只有一个char *指针,所以增加一个vptr造成每个string类的大小翻倍。所以可以理解为什么不肯实现它,尤其是对于string这样频繁出现高密度使用的类。这样的类是属于影响程序性能的那20%的部分的(见Item M16)。
还有,一个string对象的全部内存--它自己加上所指向的在堆中的字符串--通常远大于保存一个char *指针的大小。从这个方面来说,为vptr增加的花费并不是那么重要的。不过,这仍然是个合法的设计。(的确,ISO/ANSI标准委员会似乎是这么想的:标准string类型有一个非虚的析构函数。)
更有问题的是生产商的注释:“我们甚至不期望有一个string *,所以这不成为问题”。这可能是正确的,但他们的这个string类是提供给数以千记的开发人员使用的类库的一部分。有如此多的开发人员,每个人对C++的掌握程度都不同,每个人做事的方法也都不同。这些人都了解string没有虚析构函数的后果吗?生产商确信他们的客户知道没有虚析构函数时,用string *指针delete对象时可能工作不正确,在string的指针和引用上使用RTTI操作可能得到不正确的信息吗?这个类是易于正确使用而不容易用错的吗?
这个生产商应该提供明确的文档以指出他的string类没有被设计得可被继承的,但如果程序员没注意到这个警告或未能读到这个文档时会发生什么?
一个可选方法是用C++自己来阻止继承。Item M26描述了怎么限制对象只生成于堆中,以及用auto_ptr对象来操作堆中的对象。构造string对象的接口将不符合传统也不方便,需要这样:
auto_ptr<String> ps(String::makeString("Future tense C++"));
...                                 // treat ps as a pointer to
                                    // a String object, but don't
                                    // worry about deleting it
来代替这个:
String s("Future tense C++");
但,多半,为了减少不正确的继承行为是值得换用不方便的语法的。(对string类,这未必很合算,但对其它类,这样的交换是完全值得的。)
当然,进行时态的考虑也是需要的。你开发的软件必须在现在的编译器下工作;你不能等到直到最新的语言特性(被编译器)实现了。它必须在现在支持的硬件上工作,也必须在你的用户已有的(软件)配置下工作;你不能强迫客户升级系统或更改操作环境。它必须现在就提供可接受的性能;承诺数年后更小而更快的程序完全不能吸引潜在用户。你所参与的软件必须“尽快”推出,通常意味着已经误期了(which often means some time in the recent past)。这些都是重要的约束条件。你不能忽略它们。
未来时态的考虑只是简单地增加了一些额外约束:
* 提供完备的类(见Item E18),即使某些部分现在还没有被使用。如果有了新的需求,你不用回过头去改它们。
* 将你的接口设计得便于常见操作并防止常见错误(见Item E46)。使得类容易正确使用而不易用错。例如,阻止拷贝构造和赋值操作,如果它们对这个类没有意义的话(见Item E27)。防止部分赋值(见Item M33)。
* 如果没有限制你不能通用化你的代码,那么通用化它。例如,如果在写树的遍历算法,考虑将它通用得可以处理任何有向不循环图。
未来时态的考虑增加了你的代码的可重用性、可维护性、健壮性,已及在环境发生改变时易于修改。它必须与进行时态的约束条件进行取舍。太多的程序员们只关注于现在的需要,然而这么做牺牲了其软件的长期生存能力。是与众不同的,是离经叛道的,在未来时态下开发程序。
2.2 Item M33:将非尾端类设计为抽象类
假设你正在从事一个软件项目,它处理动物。在这个软件里,大多数动物能被抽象得非常类似,但两种动物--晰蜴和小鸡--需要特别处理。显然,晰蜴和小鸡与动物类的联系是这样的:
        Animal
         | |
         / /
        /   /
       /     /
    Lizard Chicken
动物类处理所有动物共有的特性,晰蜴类和小鸡类特别化动物类以适用这两种动物的特有行为。
这是它们的简化定义:
class Animal {
public:
  Animal& operator=(const Animal& rhs);
  ...
};
class Lizard: public Animal {
public:
  Lizard& operator=(const Lizard& rhs);
  ...
};
class Chicken: public Animal {
public:
  Chicken& operator=(const Chicken& rhs);
  ...
};
这里只写出了赋值运算函数,但已经够我们忙乎一阵了。看这样的代码:
Lizard liz1;
Lizard liz2;
Animal *pAnimal1 = &liz1;
Animal *pAnimal2 = &liz2;
...
*pAnimal1 = *pAnimal2;
这里有两个问题。第一,最后一行的赋值运算调用的是Animal类的,虽然相关对象的类型是Lizard。结果,只有liz1的Animal部分被修改。这是部分赋值。在赋值后,liz1的Animal成员有了来自于liz2的值,但其Lizard成员部分没被改变。
第二个问题是真的有程序员把代码写成这样。用指针来给对象赋值并不少见,特别是那些对C有丰富经验而转移到C++的程序员。所以,我们应该将赋值设计得更合理的。如Item M32指出的,我们的类应该容易被正确适用而不容易被用错,而上面这个类层次是容易被用错。
一个解决方法是将赋值运算申明为虚函数。如果Animal::operator=是虚函数,那句赋值语句将调用Lizard的赋值操作(应该被调用的版本)。然而,看一下申明它为虚后会发生什么:
class Animal {
public:
  virtual Animal& operator=(const Animal& rhs);
  ...
};
class Lizard: public Animal {
public:
  virtual Lizard& operator=(const Animal& rhs);
  ...
};
class Chicken: public Animal {
public:
  virtual Chicken& operator=(const Animal& rhs);
  ...
};
基于C++语言最近作出的修改,我们可以修改返回值的类型(于是每个都返回正确的类的引用),但C++的规则强迫我们申明相同的参数类型。这意味着Lizard类和Chicken类的赋值操作必须准备接受任意类型的Animal对象。也就是说,这意味着我们必须面对这样的事实:下面的代码是合法的:
Lizard liz;
Chicken chick;
Animal *pAnimal1 = &liz;
Animal *pAnimal2 = &chick;
...
*pAnimal1 = *pAnimal2;                 // assign a chicken to
                                       // a lizard!
这是一个混合类型赋值:左边是一个Lizard,右边是一个Chicken。混合类型赋值在C++中通常不是问题,因为C++的强类型原则将评定它们非法。然而,通过将Animal的赋值操作设为虚函数,我们打开了混合类型操作的门。
这使得我们处境艰难。我们应该允许通过指针进行同类型赋值,而禁止通过同样的指针进行混合类型赋值。换句话说,我们想允许这样:
Animal *pAnimal1 = &liz1;
Animal *pAnimal2 = &liz2;
...
*pAnimal1 = *pAnimal2;                 // assign a lizard to a lizard
而想禁止这样:
Animal *pAnimal1 = &liz;
Animal *pAnimal2 = &chick;
...
*pAnimal1 = *pAnimal2;                 // assign a chicken to a lizard
只能在运行期区分它们,因为将*pAnimal2赋给*pAnimal1有时是正确的,有时不是。我们于是陷入了基类型运行期错误的黑暗世界中。尤其是,我们需要在混合类型赋值时指出在operator=内部发生了错误,而类型相同时,我们期望按通常的方式完成赋值。
我们可以使用dynamic_cast(见Item M2)来实现。下面是怎么实现Lizard的赋值操作:
Lizard& Lizard::operator=(const Animal& rhs)
{
  // make sure rhs is really a lizard
  const Lizard& rhs_liz = dynamic_cast<const Lizard&>(rhs);
  proceed with a normal assignment of rhs_liz to *this;
}
这个函数只在rhs确实是Lizard类型时将它赋给*this。如果rhs不是Lizard类型,函数传递出dynamic_cast转换失败时抛的bad_cast类型的异常。(实际上,异常的类型是std::bad_cast,因为标准运行库的组成部分,包括它们抛出的异常,都位于命名空间std中。对于标准运行库的概述,见Item E49和Item M35)。
即使不在乎有异常,这个函数看起来也是没必要的复杂和昂贵--dynamic_cast必要引用一个type_info结构;见Item M24--因为通常情况下都是一个Lizard对象赋给另一个:
Lizard liz1, liz2;
...
liz1 = liz2;                           // no need to perform a
                                       // dynamic_cast: this
                                       // assignment must be valid
我们可以处理这种情况而无需增加复杂度或花费dynamic_cast,只要在Lizard中增加一个通常形式的赋值操作:
class Lizard: public Animal {
public:
  virtual Lizard& operator=(const Animal& rhs);
  Lizard& operator=(const Lizard& rhs);           // add this
  ...
};
Lizard liz1, liz2;
...
liz1 = liz2;                                     // calls operator= taking
                                                 // a const Lizard&
Animal *pAnimal1 = &liz1;
Animal *pAnimal2 = &liz2;
...
*pAnimal1 = *pAnimal2;                          // calls operator= taking
                                                 // a const Animal&
    实际上,给出了后面那个的operator=,也就简化了前者的实现:
Lizard& Lizard::operator=(const Animal& rhs)
{
  return operator
}
现在这个函数试图将rhs转换为一个Lizard。如果转换成功,通常的赋值操作被调用;否则,一个bad_cast异常被抛出。
说实话,在运行期使用dynamic_cast进行类型检测,这令我很紧张。有一件事要注意,一些编译器仍然没有支持dynamic_cast,所以使用它的代码虽然理论上具有可移植性,实际上不一定。更重要的是,它要求使用Lizard和Chicken的用户必须在每次赋值操作时都准备好捕获bad_cast异常并作相应处理。如果他们没有这么做的话,那么不清楚我们得到的好处是否超过最初的方案。
指出了这个关于虚赋值操作的令人非常不满意的状态后,在最开始的地方重新整理以试图找到一个方法来阻止用户写出有问题的赋值语句是有必要的。如果这样的赋值语句在编译期被拒绝,我们就不用担心它们做错事了。
最容易的方法是在Animal中将operator=置为private。于是,Lizard对象可以赋值给Lizard对象,Chicken对象可以赋值给Chicken对象,但部分或混合类型赋值被禁止:
class Animal {
private:
  Animal& operator=(const Animal& rhs);               // this is now
  ...                                                 // private
};
class Lizard: public Animal {
public:
  Lizard& operator=(const Lizard& rhs);
  ...
};
class Chicken: public Animal {
public:
  Chicken& operator=(const Chicken& rhs);
  ...
};
Lizard liz1, liz2;
...
liz1 = liz2;                                    // fine
Chicken chick1, chick2;
...
chick1 = chick2;                                // also fine
Animal *pAnimal1 = &liz1;
Animal *pAnimal2 = &chick1;
...
*pAnimal1 = *pAnimal2;                          // error! attempt to call
                                                // private Animal::operator=
不幸的是,Animal也是实体类,这个方法同时将Animal对象间的赋值评定为非法了:
Animal animal1, animal2;
...
animal1 = animal2;                              // error! attempt to call
                                                // private Animal::operator=
而且,它也使得不可能正确实现Lizard和Chicken类的赋值操作,因为派生类的赋值操作函数有责任调用其基类的赋值操作函数:
Lizard& Lizard::operator=(const Lizard& rhs)
{
  if (this == &rhs) return *this;
  Animal::operator=(rhs);                       // error! attempt to call
                                                // private function. But
                                                // Lizard::operator= must
                                                // call this function to
  ...                                           // assign the Animal parts
}                                               // of *this!
后面这个问题可以通过将Animal::operator=申明为protected来解决,但“允许Animal对象间的赋值而阻止Lizard和Chicken对象通过Animal的指针进行部分赋值”的两难问题仍然存在。程序该怎么办?
最容易的事情是排除Animal对象间赋值的需求,其最容易的实现方法是将Animal设计为抽象类。作为抽象类,Animal不能被实例化,所以也就没有了Animal对象间赋值的需求了。当然,这导致了一个新问题,因为我们最初的设计表明Animal对象是必须的。有一个很容易的解决方法:不用将Animal设为抽象类,我们创一个新类--叫AbstractAnimal--来包含Animal、Lizard、Chikcen的共有属性,并把它设为抽象类。然后将每个实体类从AbstractAnimal继承。修改后的继承体系是这样的:
        AbstractAnimal
        |     |     |
        /     |     /
       /      |      /
      /       |       /
   Lizard  Animal   Chicken
类的定义是:
class AbstractAnimal {
protected:
  AbstractAnimal& operator=(const AbstractAnimal& rhs);
public:
  virtual ~AbstractAnimal() = 0;                     // see below
  ...
};
class Animal: public AbstractAnimal {
public:
  Animal& operator=(const Animal& rhs);
  ...
};
class Lizard: public AbstractAnimal {
public:
  Lizard& operator=(const Lizard& rhs);
  ...
};
class Chicken: public AbstractAnimal {
public:
  Chicken& operator=(const Chicken& rhs);
  ...
};
这个设计给你所以你需要的东西。同类型间的赋值被允许,部分赋值或不同类型间的赋值被禁止;派生类的赋值操作函数可以调用基类的赋值操作函数。此外,所有涉及Aniaml、Lizard或Chicken类的代码都不需要修改,因为这些类仍然操作,其行为与引入AbstractAnimal前保持了一致。肯定,这些代码需要重新编译,但这是为获得“确保了编译通过的赋值语句的行为是正确的而行为可能不正确的赋值语句不能编译通过”所付出的很小的代价。
要使得这一切工作,AbstractAnimal类必须是抽象类--它必须至少有一个纯虚函数。大部分情况下,带一个这样的函数是没问题的,但在极少见的情况下,你会发现需要创一个如AbstractAnimal这样的类,没有哪个成员函数是自然的纯虚函数。此时,传统方法是将析构函数申明为纯虚函数;这也是上面所采用的。为了支持多态,基类总需要虚析构函数(见Item 14),将它再多设为纯虚的唯一麻烦就是必须在类的定义之外实现它(例子见P195,Item M29)。
(如果实现一个纯虚函数的想法冲击了你,你只是知识不够开阔。申明一个函数为虚并不意味着它没有实现,它意味着:
* 当前类是抽象类
* 任何从此类派生的实体类必须将此函数申明为一个“普通”的虚函数(也就是说,不能带“= 0”)
是的,绝大部分纯虚函数都没有实现,但纯虚析构函数是个特例。它们必须被实现,因为它们在派生类析构函数被调用时也将被调用。而且,它们经常执行有用的任务,诸如释放资源(见Item M9)或纪录消息。实现纯虚函数一般不常见,但对纯虚析构函数,它不只是常见,它是必须。)
你可能已经注意到这里讨论的通过基类指针进行赋值的问题是基于假设实体类(如Animal)有数据成员。如果它们没有数据成员,你可能指出,那么就不会有问题,从一个无数据的实体类派生新的实体类是安全的。
无数据而可以成为实体类的基类会两种可能:在将来,或者它可能有数据成员,或者它仍然没有。如果它将来可能有数据成员,你现在做的只是推迟问题的发生(直到数据成员被加入),你在用短利换长痛(参见Item M32)。如果这个基类真的不会有数据成员,那么它现在就该是抽象类,没有数据的实体类有什么用处?
用如AbstractAnimal这样的抽象基类替换如Animal这样的实体基类,其好处远比简单地使得operator=的行为易于了解。它也减少了你试图对数组使用多态的可能,这种行为的令人不愉快的后果解释于Item M3。然而,这个技巧最大的好处发生在设计的层次上,因为这种替换强迫你明确地认可有用处的抽象行为的实体。也就是说,它使得你为有用的原型(concept)创造了新的抽象类,即使你并不知道这个有用的原型的存在。
如果你有两个实体类C1和C2并且你喜欢C2公有继承自C1,你应该将两个类的继承层次改为三个类的继承层次,通过创造一个新的抽象类A并将C1和C2都从它继承:
       C1             A
        |             //
        |            /  /
       C2           C1  C2
 你的最初想法    修改后的继承层次
这种修改的重要价值是强迫你确定抽象类A。很清楚,C1和C2有共性;这就是为什么它们用公有继承联系在一起的原因(见Item E35)。修改后,你必须确定这些共性到底是什么。而且,你必须用C++的类将这些共性组织起来,它将不再是模糊不清的东西了,它到达了一个抽象类型的层次,有明确定义的成员函数和明确定义的语义。
这一切导致了一些令人不安的思考。毕竟,每个类都完成了某些类型的抽象,我们不应该在此继承体系中创造两个类来针对每个原型吗(一个是抽象类来表示其抽象部分(to embody the abstract part of the abstraction) ,一个是实体类来表示对象生成部分(to embody the object-generation part of the abstraction))?不应该。如果你这么做了,将使得继承体系中有太多的类。这样的继承体系是难以理解的,难以维护的,编译的代价很昂贵。这不是面向对象设计的目的。
其目的是:确认有用的抽象,并强迫它们(并且只有它们)放入如抽象类这样的实体。但怎么确认有用的抽象?谁知道什么抽象在将来被证明有用?谁能预知他将来要从什么进行继承?
好了,我不知道怎么预知一个继承体系将来的用处,但我知道一件事:在一个地方需要的抽象可能只是凑巧,但多处地方需要的抽象通常是有意义的。那么,有用的抽象就是那些被多处需要的抽象。也就是说,它们相当于是这样的类:就它们自己而言是有用的(比如,有这种类型的对象是用处的),并且它们对于一个或多个派生类也是有用处的。
在一个原型第一次被需要时,我们无法证明同时创造一个抽象类(为了这个原型)和一个实体类(为了原型对应的对象)是正确的,但第二次需要时,我们就能够这么做是正确的。我描述过的修改简单地实现了这个过程,并且在这么做的过程中强迫设计着和程序员明确表达那些有用的抽象,即使他们不知道那些有用的原型。 这也碰巧使得构建正确的赋值行为很容易。
让我们看一下一个简单的例子。假设你正在编制一个程序以处理局域网上计算机间的移动信息,通过将它拆为数据包并根据某种协议进行传输。我们认为应该用类来表示这些数据数据包,并且这些数据包是程序的核心。
假设你处理的只有一种传输协议,也只有一种包。也许你听说了其它协议和数据包类型的存在,但还从未支持它们,也没有任何计划以在未来支持它们。你会为数据包(for the concept that a packet represents) 既设计一个抽象类吗,又设计一个你实际使用的实体类?如果你这么做了,你可以在以后增加新的数据包而不用改变基类。这使得你增加新的数据包类型时程序不用重新编译。但这种设计需要两个类,而你现在只需要一个(针对于你现在使用的特殊数据包类型)。这值得吗,增加设计的复杂度以允许扩充特性,而这种扩充可能从不发生?
这儿没有肯定正确的选择,但经验显示:为我们还不完全了解的原型设计优秀的类几乎是不可能的。如果你为数据包设计了抽象类,你怎么保证它正确,尤其是在你的经验只局限于这唯一的数据包类型时?记住,只有在设计出的类能被将来的类从它继承而不需要它作任何修改时,你才能从数据包的抽象类中获得好处。(如果它需要被修改,你不得不重新编译所有使用数据包类的代码,你没得到任何好处。)
看起来不太能够设计出一个领人满意的抽象设计包类,除非你精通各种数据包的区别以及它们相应的使用环境。鉴于你有限的经验,我建议不要定义抽象类,等到以后需要从实体类继承时再加。
我所说的转换方法是一个判断是否需要抽象类的方法,但不是唯有的方法。还有很多其它的好方法;讲述面向对象分析的书籍上满是这类方法。“当发现需求从一个实体类派生出另外一个实体类时”,这也不是唯一需要引入抽象类的地方。不管怎么说啦,需要通过公有继承将两个实体类联系起来,通常表示需要一个新的抽象类。
这种情况是如此常见,所以引起了我们的深思。第三方的C++类库越来越多,当发现你需要从类库中的实体类派生出一个新的实体类,而这个库你只有只读权时,你要怎么做?
你不能修改类库以加入一个新的抽象类,所以你的选择将很有限、很无趣:
* 从已存在的实体类派生出你的实体类,并容忍我们在本Item开始时说到的赋值问题。你还要注意在Item M3中说过的数组问题。
* 试图在类库的继承树的更高处找到一个完成了你所需的大部分功能的抽象类,从它进行继承。当然,可能没有合适的类;即使有,你可能不得不重复很多已经在(你试图扩展的)实体类中实现了的东西。
* 用包容你试图继承的类的方法来实现你的新类(见Item E40和Item E42)。例下例,你将一个类库中的类的对象为数据成员,并在你的类中重实现它的接口:
class Window {                      // this is the library class
public:
virtual void resize(int newWidth, int newHeight);
virtual void repaint() const;
int width() const;
int height() const;
};
class SpecialWindow {               // this is the class you
public:                             // wanted to have inherit
  ...                               // from Window
  // pass-through implementations of nonvirtual functions
  int width() const { return w.width(); }
  int height() const { return w.height(); }
  // new implementations of "inherited" virtual functions
  virtual void resize(int newWidth, int newHeight);
  virtual void repaint() const;
private:
    Window w;
};
这种方法需要你在类库每次升级时也要更新你自己的类。它还需要你放弃重定义类库中的类的虚函数的能力,因为你用的不是继承。
* 使用你得到。使用类库中的类,而将你自己的程序修改得那个类适用。用非成员函数来提供扩展功能(那些你想加入那个类而没有做到的)。结果,程序将不如你所期望中的清晰、高效、可维护、可扩展,但至少它完成了你所需要的功能。
这些选择都不怎么吸引人,所以你不得不作出判断并选择最轻的毒药。这不怎么有趣,但生活有时就是这样。想让事情在以后对你自己(和我们其它人)容易些,将问题反馈给类库生产商。靠运气(以及大量的用户反馈),随时间的流逝,那些设计可能被改进。
最后,一般的规则是:非尾端类应该是抽象类。在处理外来的类库时,你可能需要违背这个规则;但对于你能控制的代码,遵守它可以提高程序的可靠性、健壮性、可读性、可扩展性。
0 0

相关博文

我的热门文章

img
取 消
img