The Virtues Of Bastard

Blood is inherited and virtue is acquired.
-- Venezuelan Proverb

引子

在刚刚结束的《权力的游戏》第六季里,最让人热血沸腾的是第九集《The War of Bastards》;而在第十集,Jon Snow,这个具有公正,勇敢,富有荣誉感等多种美德的bastard,正式获得继承权,成为临冬城主,北境之王!

Jon Snow

这让我想起了C++语言中的一个有趣特性:私有继承

私有继承或许是C++语言特有的一种特性。所以你在各种面向对象教材中,很难看到针对这种用法的讨论。在现实项目中,也很难看到它的踪影。

它究竟有何用途?

匿名访问

我们知道继承意味着子类父类之间存在IS-A的关系。所以,子类可自动被当作父类来使用(当然要符合里氏替换原则)。

下面的代码,可以非常顺利的编译通过。

struct Base {};
struct Derived : public Base {}; 

void f(Base* base); 
// ... 

Derived derived; 

f(&derived); // 编译正常 

但如果DerivedBase之间是私有继承的关系,在Derived的外部客户看来, 它和Base之间并不存在IS-A的关系。事实上,在客户眼里,它们什么关系也没有。Base作为 Derived的一种内部实现细节,从逻辑上被彻底隐藏了。比如:

struct Derived : private Base {}; // 私有继承 

// ... 

Derived derived; 

f(&derived); // 不能编译!! 

而在类的设计者看来,DerivedBase的任何外部客户一样,可以自由访问Base的所有public成员,但对其非public成员却毫无权限。也就是说,Base相当于Derived的一个成员变量。所以它们之间的关系相当于委托

但它比委托要更方便。因为在委托方式下,Derived必须定义一个 Base 类型的成员变量,并通过变量访问Base的成员。而私有继承则无须这么做。其差别如下所示:

struct Base 
{ 
  void f(); 
};

// 委托
struct Object 
{ 
  void f1() 
  { 
    base.f(); // 需通过成员变量访问 
  } 
  
private:  
  Base base; 
}; 

// 私有继承
struct Derived : private Base 
{ 
  void f1() 
  { 
    f(); // 可直接访问
  } 
};

避免"中间人"

另外,编程实践中最让人厌烦的活动之一,是编写中间人Middle Man)式的转调代码。(关于中间人,参见Martin Fowler《重构》

比如,我们原来有一个类Foo,存在着多个public成员函数接口。现在,系统中另一处需要一个类BarBar只需要提供一个接口int f(),其功能需求和Foo提供的同名接口完全一致。所以,我们想通过委托关系复用Foo的实现。

经典委托的实现方式,是直接转调其接口。比如:

struct Foo 
{ 
  int f();
  int g(); 
  int h(); 
  // ... 
};
 
struct Bar 
{ 
  int f() 
  { 
    return foo.f(); 
  } 

private: 
  Foo foo; 
};

如果使用私有继承,则可以通过部分暴露的方式来简化你的工作:

struct Bar : private Foo // 私有继承 
{ 
  // 部分暴露 Foo 的接口。 
  using Foo::f; 
};

我们知道,实现继承本质上是一种扩展关系。而这种通过私有继承进行部分暴露接口的用法是一种反扩展

避免额外的派生类

公有继承一样,私有继承的子类可以实现父类的虚函数。

所以,如果简单的以委托的方式来实现组合,程序员们则不得不先通过派生类给出实现,然后再组合派生类。如下图所示:

组合派生类
组合派生类

但这样的实现方式,不仅需要定义一个新的类,更重要的是,派生类的实现方式很可能需要使用客户类的内容,而客户类并不想将这些内容公开。两个类该如何配合,就变成了设计者一个非常棘手的问题。

但通过私有继承,则可以完美的解决这中问题。如下图所示:

通过私有继承避免额外派生类
通过私有继承避免额外派生类

对遗留系统结构体的封装

在遗留系统中,会存在一些只有数据没有行为的结构体。而这样的结构体经常作为参数,在模块之间到处传递。不同模块在获取数据之后,会根据这些数据进行一系列的计算。

比如, 我们又一个名为Rectangle的数据结构:

struct Rectangle
{
   int width;
   int height;
};

对于这样的数据结构,我们当然想进行封装,以享用封装所带来好处。而如果这一切都发生在可控的单个子系统内部,毫无疑问,你应该这么做。

但如果Rectangle跨多个子系统,或者一个子系统过大,你可能就会面临下列问题:

  1. 每个子系统对于Rectangle的行为定义都是不一样的,也就是说,它们唯一共享的就是数据;
  2. 不同子系统由不同的团队维护,你无权修改它们的代码,也无权修改共享的头文件;
  3. 直接进行封装,会造成大面积的代码的修改;
  4. 其它子系统的开发语言仍然是C
  5. ...

总而言之,你不能修改原有的结构体Rectangle。 这种情况下,你依然想在正在重构的代码中对Rectangle进行封装,那该怎么
办?

私有继承,是解决这类问题的不错选择。


struct MyRectangle : private Rectangle 
{ 
  int getArea() const 
  { 
    return width * height; 
  }
   
  int getPerimeter() const 
  { 
    return 2 * (width + height); 
  } 
};

为什么是私有继承?因为我们想进行封装,让子系统内部的代码没有人可以自由的访问数据,以享用封装带来的好处。

为什么不使用委托?因为如果没有之前所说的那些约束,这些数据和行为本来就应该属于一个类。而委托关系,会造成我们访问每个数据成员时,都必须通过成员变量进行间接访问,这毫无疑问给我们带来了不必要的负担。

私有继承也意味着,在客户代码那里IS-A关系的丧失。好在我们还有强制转换的武器,我们只需要在子系统边界对其进行类型强换,在子系统内部均使用MyRectangle即可。

尽管看起来让人有些不安,由于这种继承完全没有修改任何内存布局,所以这种强转是绝对安全的。

// 本子系统边界函数
void s1_boundary(Rectangle* rect) 
{ 
  // ...  
  // 强制转换 
  s1_internal1((MyRectangle&)*rect);
  // ...
}

// 子系统内部函数,使用 MyRectangle 
void s1_internal1(MyRectangle& rect) 
{ 
  int perimeter = rect.getPerimeter(); 
  // ...  
  s1_internal2(rect);  
  // ... 
} 

// 子系统内部函数,使用 MyRectangle 
void s1_internal2(MyRectangle& rect) 
{ 
  int area = rect.getArea();  
  // ... 
  s2_boundary((Rectangle*)&rect); 
} 

// 其它子系统的边界函数,仍然使用 Rectangle 
void s2_boundary(Rectangle* rect);

这种方法,在一些以消息作为进程间通信手段的嵌入式系统中,是一种非常有效的封装手段。

私有继承也是继承

Scott Meyers在其著作《Effective C++》中,将私有继承定义为和组合一样的关系(is-implemented-in-terms- of)。

尽管他也提到私有继承的一个优势是可以实现父类的虚函数,但他没有明确的指出,私有继承也是一种继承

只是这种继承关系通过private关键字对外界隐藏了真相;但是,在类的内部, 子类和基类IS-A关系依然成立。我将这种关系称之为:私有继承的子类是父类的私生子

这就意味着,当我们想利用继承关系来完成一些特定的设计,但又不想让这种关系被外部利用时,私有继承就是绝佳的选择。

比如:在一颗二叉树上,正常情况下,每个结点都有存在一个父结点和两个子节点。但存在一些例外情况:根节点没有父节点,而叶子节点则没有子节点。

所以,我们用这样的数据结构来表现一个节点:

struct Node 
{ 
  // ... 
private:  
  Node* parent; 
  Node* leftChild, rightChild; 
}; 

在这个树上,有时候一个节点的状态变化必须通知给其所有父子节点,而其父子也会进一步将此事件向上下传播。如下:

void Node::notifyEvent() 
{ 
  notifyParent();
  notifyChildren();
}

void Node::notifyParent() 
{ 
  if(parent != 0) parent->onChildStateChange(); 
}
 
void Node::notifyChildren() 
{ 
  if(leftChild != 0)
  { 
    leftChild->onParentStateChange();
  }
  
  if(rightChild != 0) 
  {
    rightChild->onParentStateChange(); 
  }
}

可以看到,代码中存在一些空指针判断,如果这些空指针只有少数的几个,也没有什么大问题。但如果这些的事件很多,每个事件的处理手段也不一样,可能就会早就很多的空指针判断语句,这让我们的代码很不干净。

当然解决空指针问题的常用手段是空对象模式Null Object)。所以,我们将设计修改为:

struct NodeEventListener
{
  virtual void onParentStateChange() = 0; 
  virtual void onChildStateChange() = 0;
  // ... 
  virtual ~NodeEventListener(){} 
};

struct Node : NodeEventListener 
{ 
  Node(Node* parent, Node* leftChild, Node* rightChild); 
  
  void onParentStateChange(); 
  void onChildStateChange(); 
  // ... 

private:  
  NodeEventListener* parent; 
  NodeEventListener* leftChild; 
  NodeEventListener* rightChild; 
};
 
namespace 
{  
  struct NullNode : NodeEventListener 
  { 
    void onParentStateChange() {} 
    void onChildStateChange() {}
 
    // ... 
    static NullNode* getInstance() 
    { 
      static NullNode instance;
      return &instance; 
    } 
  }; 

  NodeEventListener* getListener(NodeEventListener* node) 
  { 
    return node == 0 ? NullNode::getInstance() : node; 
  } 
} 

Node::Node
  ( Node* parent
  , Node* leftChild
  , Node* rightChild) 
  : parent(getListener(parent))  
  , leftChild(getListener(leftChild))
  , rightChild(getListener(rightChild)) 
{ } 

void Node::notifyParent() 
{ 
   parent->onChildStateChange();
}

void Node::notifyChildren() 
{ 
  leftChild->onParentStateChange();
  rightChild->onParentStateChange();
}

void Node::onParentStateChange() 
{ 
  // 真正的事件处理代码  
} 

void Node::onChildStateChange() 
{ 
  // 真正的事件处理代码 
}

作为一个C++的标准空对象模式的实现,上述设计解决了空指针判断的问题。并且Node类和NodeEventListener之间从概念层面的 IS-A也是成立的。所以,我们理应为这样的结果感到欣慰。

但如果你再仔细审视一下,就会产生这样的疑问:这个IS-A的关系,需要被Node外部的用户知道吗?你希望他们可以通过NodeEventListener类型来调用NodeonParentStateChange()onChildStateChange()等方法吗?

答案是否定的,因为这是一个内部设计。

所以我们需要将这些接口隐藏起来。方法很简单:将继承关系改为 private, 并将继承自NodeEventListener的函数也设为 private 即可:

// ... 

struct Node : private NodeEventListener 
{ 
  Node(Node* parent, Node* leftChild, Node* rightChild); 
  // ... 

  // 来自于 NodeEventListener 的函数被设为私有 

private: 
  void onParentStateChange(); 
  void onChildStateChange(); 
  // ... 

private:  
  NodeEventListener* parent; 
  NodeEventListener* leftChild; 
  NodeEventListener* rightChild; 
};

尽管我们将继承关系改为了私有,但请注意,parent等成员变量仍然是NodeEventListener类型,而构造函数的参数类型却是Node类型(想想为什么?)。 从NodeNodeEventListener的自动类型转换在构造函数的实现代码里完成,这说明NodeNodeEventListener 之间的IS-A关系,在Node看来,依然是成立的。

越狱

我们现在知道,私有继承会对外屏蔽子类和父类的继承关系。子类的外部客户不能将其看作父类类型,更不可能调用子类继承自父类的函数。

因此,在下面的代码中,Derived似乎永远也无法被当作 Foo::f(Base*) 的参数,以利用Foo所提供的服务。

struct Base 
{ 
  virtual void f() = 0; 
  virtual ~Base() {} 
}; 

struct Foo 
{ 
  void f(Base* base) 
  { 
    base->f(); 
  } 
};
 
struct Derived : private Base 
{
  // ... 
private:  
  void f() {} 
}; 

Really?你不妨尝试编译一下下面的代码,然后就会惊讶的发现,竟然是通过的。

// ... 
struct Derived : private Base 
{ 
  // ... 
  void doSth(Foo* foo) 
  { 
    foo->f(this); 
  } 

private:  
  void f() {} 
};

其实不必惊讶,这是因为:这种继承关系只是对外界进行了隐藏,但其并没有消失,只是作为Derived的一个私人秘密不为外人所知罢了。

在需要的时候,Derived就可以将这种关系摆出来,让别人看在其父类的面子上为其提供服务。只要这种基于IS-A 关系的类型转换得以成功,别人才不管你是不是私生子。

这样的发现,可以在很多场合给我们的设计带来便利。比如,很多嵌入式设备都提供了Timer服务。而Timer模块要求:用户必须通过注册一个接口,以便于当指定的时间到期后,可以进行回调。比如:

struct TimerEventHandler 
{ 
  virtual void onTimeout() = 0; 
  virtual ~TimerEventHandler() {} 
};
 
struct Timer 
{ 
  static void registerHandler
    ( unsigned int timeout 
    , TimerEventHandler* handler);
}; 

然后,一个类NetworkClient需要Timer服务。但当Timer过期之后,其需要进行的操作都是自己的私有成员。如果创建一个专门的类以实现TimerEventHandler,就要么需要使用友元关系,要么就必须把相关的成员改为public。无论怎样,都会破坏NetworkClient的封装性。

这种情况下,让 NetworkClient直接继承(实现)TimerEventHandler,就是一个最恰当的选择。同样,由于我们不希望这层关系为外界所知,从而产生不必要的依赖,我们应选择私有继承

struct NetworkClient : private TimerEventHandler 
{ 
  void connect() 
  { 
    server.connect(); 
    Timer::registerHandler(30, this); 
    // ...
  } 

private:  
  // 对 TimerEventHandler 的实现 
  void onTimeout() 
  { 
    state = STATE_DISCONNECTED;
    sendAlarm(CONNECT_TIMEOUT);
  }

  void sendAlarm(AlarmType alarmType); 

private: 
   State state; 
  // ... 
};

结论

C++私有继承是一种非常有趣的关系。我们在之前几个大型C++项目里,对于上述场景下,均经常性的使用私有继承。(估计我们是C++社区里使用这个特性最多的团队:D)。

善用它,可以帮助我们在设计的便利性信息隐藏组合方面带来诸多好处。私有继承的美德可以让它成为软件设计一方之王。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,015评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,262评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,727评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,986评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,363评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,610评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,871评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,582评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,297评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,551评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,053评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,385评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,035评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,079评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,841评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,648评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,550评论 2 270

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 170,569评论 25 707
  • 1. Java基础部分 基础部分的顺序:基本语法,类相关的语法,内部类的语法,继承相关的语法,异常的语法,线程的语...
    子非鱼_t_阅读 31,295评论 18 399
  • 写在之前 因为简书字数限制,完整版地址:https://www.zybuluo.com/hainingwyx/no...
    hainingwyx阅读 13,748评论 0 41
  • 1.面向对象的主要特征 抽象:抽象就是忽略一个主题中与当前目标无关的那些方面,以便更充分地注意与当前目标有关的方面...
    A_Coder阅读 357评论 0 1
  • “今天又看见他从窗外走过,依旧和那几个朋友待在一起,还是一样,不会笑。” “其实他笑起来很好看。咦,为什么要用好看...
    老街木阅读 207评论 0 4