C++继承与组合的区别

时间:2024-06-17 10:36:26

C++程序开发中,设计孤立的类比较容易,设计相互关联的类却比较难,这其中会涉及到两个概念,一个是继承(Inheritance),一个是组合(Composition)。因为二者有一定的相似性,往往令程序员混淆不清。类的组合和继承一样,是软件重用的重要方式。组合和继承都是有效地利用已有类的资源。但二者的概念和用法不同。

如果类B 有必要使用A 的功能,则要分两种情况考虑:

1.继承

若在逻辑上B 是一种A (is a kind of),则允许B 继承A 的功能,它们之间就是Is-A 关系。如男人(Man)是人(Human)的一种,女人(Woman)是人的一种。那么类Man 可以从类Human 派生,类Woman也可以从类Human 派生。示例程序如下:

class Human
{
  …
};
class Man : public Human
{
  …
};
class Woman : public Man
{
  …
};

在UML的术语中,继承关系被称为泛化(Generalization),类Man和Woman与类Human的UML关系图可描述如下:

C++继承与组合的区别

继承在逻辑上看起来比较简单,但在实际应用上可能遭遇意外。比如在OO界中著名的“鸵鸟不是鸟”和“圆不是椭圆”的问题。这样的问题说明了程序设计和现实世界存在逻辑差异。从生物学的角度,鸵鸟(Ostrich)是鸟(Bird)的一种,既然是Is-A的关系,类COstrich应该可以从类CBird派生。但是鸵鸟不会飞,但从CBird那里继承了接口函数fly,如下所示:

class CBird{
public:
    virtual void fly(){}
};

class COstrich{
public:
    ...
};

“圆不是椭圆”同样存在类似的问题,圆从椭圆类继承了无用的长短轴数据成员。所以更加严格的继承应该是:若在逻辑上B是A的一种,并且A的所有功能和属性对B都有意义,则允许B继承A的所有功能和属性。

类继承允许我们根据自己的实现来覆盖重写父类的实现细节,父类的实现对于子类是可见的,所以我们一般称之为白盒复用。继承易于修改或扩展那些被复用的实现,但它这种白盒复用却容易破坏封装性。因为这会将父类的实现细节暴露给子类。

2.组合

若在逻辑上A 是B 的“一部分”(a part of),则不允许B 继承A 的功能,而是要用A和其它东西组合出B,它们之间就是“Has-A关系”。例如眼(Eye)、鼻(Nose)、口(Mouth)、耳(Ear)是头(Head)的一部分,所以类Head 应该由类Eye、Nose、Mouth、Ear 组合而成,不是派生而成。示例程序如下:

class Eye
{
public:
  void Look(void);
};
class Nose
{
public:
  void Smell(void);
};
class Mouth
{
public:
  void Eat(void);
};
class Ear
{
public:
  void Listen(void);
};
// 正确的设计,冗长的程序
class Head
{
public:
  void Look(void) { m_eye.Look(); }
  void Smell(void) { m_nose.Smell(); }
  void Eat(void) { m_mouth.Eat(); }
  void Listen(void) { m_ear.Listen(); }
private:
  Eye m_eye;
  Nose m_nose;
  Mouth m_mouth;
Ear m_ear;
};

如果允许Head 从Eye、Nose、Mouth、Ear 派生而成,那么Head 将自动具有Look、Smell、Eat、Listen 这些功能:

// 错误的设计
class Head : public Eye, public Nose, public Mouth, public Ear
{
};

上述程序十分简短并且运行正确,但是这种设计却是错误的。所以我们要经的起“继承”的诱惑,避免犯下设计错误。

在UML中,上面类的UML关系图可描述如下:

C++继承与组合的区别

实心菱形代表了一种坚固的关系,被包含类的生命周期受包含类控制,被包含类会随着包含类创建而创建,消亡而消亡。组合属于黑盒复用,被包含对象的内部细节对外是不可见的,所以它的封装性相对较好,实现上相互依赖比较小,并且可以通过获取其它具有相同类型的对象引用或指针,在运行期间动态的定义组合。而缺点就是致使系统中的对象过多。

综上所述,Is-A关系用继承表示,Has-A关系用组合表示,GoF在《设计模式》中指出OO设计的一大原则就是:优先使用对象组合,而不是类继承。

3.解决“圆不是椭圆”继承问题,杜绝不良继承

封装、继承、多态是面向对象技术的三大机制,封装是基础、继承是关键、多态是延伸。继承是作为关键的一部分,如果我们理解不够深刻,则容易造成程序设计中的不良继承,影响程序质量。

上文中“圆不是椭圆”这一著名问题,实际上在数学上圆是一种特殊的椭圆,于是会出现下面的继承:

class CEllipse{
public:
    void setSize(float x,float y){}
};

class CCircle:public CEllipse{};

椭圆存在一个设置长短轴的成员函数setSize,而圆则不需要。椭圆能做某些圆不能做的事,所以圆继承自椭圆是不合理的类设计。那么面对“圆是/不是一种椭圆”这个两难的问题,我们如何解决。主要有几下几种方法:

(1)使用代码技巧来弥补设计缺陷。在子类CCircle中重新定义setSize抛出异常,或终止程序,或做其他的异常处理,但这些技巧会让用户吃惊不已,违背了接口设计的“最小惊讶原则”;

(2)改变观点,人为圆是不对称的。这对于我们思维严谨的程序员来说,有点不可接受;

(3)将基类的成员函数setSize删除。但这回影响椭圆对象的正常使用。

(4)去掉它们之间的继承关系。推荐做法,既然圆继承椭圆是一种不良类设计,我们就应该杜绝。去掉继承关系,并不代表圆与椭圆就没有关系,两个类可以继承自同一个类COvalShape,不过该类不能执行不对称的setSize计算,如下图所示:

class COvalShape{
public:
    void setSize(float x);
};

class  CEllipse:public COvalShape{
public:
    void setSize(float x,float y);
};

class CCircle:public COvalShape{
};

其中,椭圆增加了特有的setSize(float x,float y)运算。

不良继承出现的根本原因在于对继承的理解不够深刻,错把直觉中的“是一种(Is-A)”当成了学术中的“子类型(subtype)”概念。在继承体系中,派生类对象是可以取代基类对象的。而在椭圆和圆的问题上,椭圆类中的成员函数setSize(x,y)违背了这个可置换性,即Liskov替换原则。

所有不良继承都可以归结为“圆不是椭圆”这一著名具有代表性的问题上。在不良继承中,基类总会有一些额外能力,而派生类却无法满足它。这些额外的能力通常表现为一个或多个成员函数提供的功能。要解决这一问题,要么使基类弱化,要么消除继承关系,需要根据具体情形来选择。


参考文献

[1]C++中继承和组合区别与使用

[2]李健.编写高质量代码:改善C++程序的150个建议.第一版.北京:机械工业出版社,2012.1:303-310