More Effective C++ Item M33:将非尾端类设计为抽象类

类别:编程语言 点击:0 评论:0 推荐:
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;
};
这种方法需要你在类库每次升级时也要更新你自己的类。它还需要你放弃重定义类库中的类的虚函数的能力,因为你用的不是继承。
* 使用你得到。使用类库中的类,而将你自己的程序修改得那个类适用。用非成员函数来提供扩展功能(那些你想加入那个类而没有做到的)。结果,程序将不如你所期望中的清晰、高效、可维护、可扩展,但至少它完成了你所需要的功能。
这些选择都不怎么吸引人,所以你不得不作出判断并选择最轻的毒药。这不怎么有趣,但生活有时就是这样。想让事情在以后对你自己(和我们其它人)容易些,将问题反馈给类库生产商。靠运气(以及大量的用户反馈),随时间的流逝,那些设计可能被改进。
最后,一般的规则是:非尾端类应该是抽象类。在处理外来的类库时,你可能需要违背这个规则;但对于你能控制的代码,遵守它可以提高程序的可靠性、健壮性、可读性、可扩展性。

本文地址:http://com.8s8s.com/it/it29682.htm