为什么要“多用组合少用继承”,要回答这个问题我们需要先回顾分析继承的优缺点。
继承的优点是:代码复用,描述is-a关系,支持多态特性。
继承的缺点是:继承层次过深会降低可读性,父子类耦合不利于扩展性和维护性。
组合可以在保留继承的优点基础上克服继承的缺点。下面举一个例子具体说下为什么要“多用组合少用继承”,什么场景下用组合不用继承、以及组合怎么用。
假如我们要实现一系列的鸟类,包括麻雀、鸽子、鸵鸟、企鹅、杜鹃、蜂鸟等,需要实现他们的三个方法:叫、飞和孵蛋(tweet()、fly()和layEgg())。
要求具有这些能力的鸟必须实现他们相应能力的方法(例如麻雀是三种能力都有,因此必须实现这3个方法;鸵鸟只有 叫和孵蛋 两种能力但没有 飞 这种能力,因此必须实现这 tweet和layEgg方法,而不能实现fly()方法;蜂鸟则只有 孵蛋和飞的能力,没有叫的能力;杜鹃有 叫和飞的能力,没有孵蛋的能力,杜鹃只下蛋不孵蛋)。
1、使用继承的思路
实现方案1:使用AbstractBrid抽象类作为基类继承(PHP实现)
<?php
class FlyAbilityLackException extends Exception{}
class EggLayAbilityLackException extends Exception{}
class TweetAbilityLackException extends Exception{}
abstract class AbstractBird{
abstract public function fly();
abstract public function tweet();
abstract public function layEgg();
}
class MaQue extends AbstractBird{ // 麻雀
public function fly(){
// ...具体实现
}
public function tweet(){
// ...具体实现
}
public function layEgg(){
// ...具体实现
}
}
class TuoNiao extends AbstractBird{ // 鸵鸟
public function fly(){
throw new FlyAbilityLackException();
}
public function tweet(){
// ...具体实现
}
public function layEgg(){
// ...具体实现
}
}
class FengNiao extends AbstractBird{ // 蜂鸟
public function fly(){
// ...具体实现
}
public function tweet(){
throw new TweetAbilityLackException();
}
public function layEgg(){
// ...具体实现
}
}
这个方案在父类中声明全部的3个方法。由于鸵鸟不会飞,蜂鸟不会叫,但是由于继承了抽象类鸵鸟类和蜂鸟类不能不实现fly和tweet方法,所以只能在他们对应不会的方法上抛出异常。
看似没问题,能满足需求,但是仍有缺陷。
比如不会飞的鸟还有很多,比如企鹅。对于所有不会飞的鸟,我们都需要重写 fly() 方法,抛出异常。这会带来两个问题:
a. 徒增编码的工作量,复用性低;
b. 暴露了fly这个不该暴露的接口给外部,增加了类使用过程中被误用的概率(违反了最小知识原则)。
那为了解决上述的缺陷,用继承的思路继续优化下去可以这样做。
实现方案2:对AbstractBrid抽象类根据是否会飞做细分分成 AbstractFlyAbleBird 和 AbstractUnflyAbleBird类。AbstractBrid和AbstractUnflyAbleBird不实现fly()方法,AbstractFlyAbleBird实现fly()方法,并让麻雀、蜂鸟继承 AbstractFlyAbleBird ,让鸵鸟继承AbstractUnflyAbleBird。
但是这个方案有个致命问题,鸟类不仅在飞这个行为上有差异性,在叫和孵蛋行为上也有差异。如果结合 飞、叫和孵蛋这三种行为细分抽象基类,那么排列组合就会有很多个抽行类。
如果继续扩展更多行为,就会组合爆炸,扩展性几乎为0。
2、使用组合的思路
我们可以用组合、接口、委托这三个手段替代上面的继承方案。这三个手段合在一起我统称为组合(也就是上面所有提到的“组合”其实是指组合 + 接口 + 委托)。
其中,委托可以暂时简单的理解为 在一个类A的方法X中调用另一个类B的方法X,委托B来实现一部分方法X。
方案1:使用 接口类+组合 替代抽象+继承,但不使用委托
<?php
interface FlyAble{
public function fly();
}
interface TweetAble{
public function tweet();
}
interface EggLayAble{
public function layEgg();
}
class MaQue implements FlyAble,TweetAble,EggLayAble{ // 麻雀
public function fly(){
// ...具体实现
}
public function tweet(){
// ...具体实现
}
public function layEgg(){
// ...具体实现
}
}
class TuoNiao implements TweetAble,EggLayAble{ // 鸵鸟
public function tweet(){
// ...具体实现
}
public function layEgg(){
// ...具体实现
}
}
class FengNiao implements FlyAble,EggLayAble{ // 蜂鸟
public function fly(){
// ...具体实现
}
public function layEgg(){
// ...具体实现
}
}
这个方案的缺陷在于,如果fly()方法具有可复用的逻辑,那么所有会飞的鸟类的fly方法中都要重复实现fly中可复用的逻辑。
因此该方案虽然解决了继承的耦合和继承层级深的缺点,却遗失了继承的可复用性。
接下来我们看下一个方案。
方案2:使用 接口类+组合+委托 替代抽象+继承
具体来说,我们可以将 fly()、tweet()和layEgg() 中可复用的逻辑提出来放到3个类FlyAbility、TweetAbility和EggLayAbility中,在具体的鸟类的fly方法里可以调用Ability类的fly方法满足行为的公共逻辑,再实现该具体鸟类中fly的独有逻辑。
<?php
interface FlyAble{
public function fly();
}
interface TweetAble{
public function tweet();
}
interface EggLayAble{
public function layEgg();
}
class FlyAbility{ // 委托类
public function fly(){
// ...飞的公共逻辑
}
}
class TweetAbility{
public function tweet(){
// ...叫的公共逻辑
}
}
class EggLayAbility{
public function layEgg(){
// ...孵蛋的公共逻辑
}
}
class MaQue implements FlyAble,TweetAble,EggLayAble{ // 麻雀
protected $flyAbility;
protected $tweetAbility;
protected $eggLayAbility;
public function __construct()
{
$this->tweetAbility = new TweetAbility();
$this->flyAbility = new FlyAbility();
$this->eggLayAbility = new EggLayAbility();
}
public function fly(){
$this->flyAbility->fly(); // 委托
// ...具体实现
}
public function tweet(){
$this->tweetAbility->tweet();
// ...具体实现
}
public function layEgg(){
$this->eggLayAbility->layEgg();
// ...具体实现
}
}
class TuoNiao implements TweetAble,EggLayAble{ // 鸵鸟
protected $tweetAbility;
protected $eggLayAbility;
public function __construct()
{
$this->tweetAbility = new TweetAbility();
$this->eggLayAbility = new EggLayAbility();
}
public function tweet(){
$this->tweetAbility->tweet();
// ...具体实现
}
public function layEgg(){
$this->eggLayAbility->layEgg();
// ...具体实现
}
}
class FengNiao implements FlyAble,EggLayAble{ // 蜂鸟
protected $flyAbility;
protected $eggLayAbility;
public function __construct()
{
$this->flyAbility = new FlyAbility();
$this->eggLayAbility = new EggLayAbility();
}
public function fly(){
$this->flyAbility->fly();
// ...具体实现
}
public function layEgg(){
$this->eggLayAbility->layEgg();
// ...具体实现
}
}
该方案已经能够彻底弥补继承的缺点,有保持继承的优点。
但是不是说它就已经没有缺陷了呢,假如需要扩展更多的行为(例如我要再扩展一个“迁徙”行为),就意味着我要定义更多的Ability类和Able接口。并且在实现类中,我们还要在构造函数中实例化更多的Ability类。而类和接口的增多意味着代码的复杂程度和维护成本的提高。
此时我们应该考虑Ability类和Able接口类的拆分粒度。例如我们是不是可以将一些无可分割的方法放到一个Able接口和Ability类中,而不是为每个方法都定义一个Able接口和Ability类。例如read、write和execute方法,方法read和方法write放到一个 ReadWriteAble 接口和ReadWriteAbility类,execute 放到一个 ExecuteAble 接口和ExecuteAbility类。
至于具体的拆分粒度,取决于具体的业务场景。
在这个鸟类的案例中,我们的最佳方案其实是将3个方法整合到一个Ability类,而接口上需要拆分为3个Able接口类。
方案3:最终方案
<?php
interface FlyAble{
public function fly();
}
interface TweetAble{
public function tweet();
}
interface EggLayAble{
public function layEgg();
}
class BirdAbility{ // 委托类
public function fly(){
// ...飞的公共逻辑
}
public function tweet(){
// ...叫的公共逻辑
}
public function layEgg(){
// ...孵蛋的公共逻辑
}
}
class Bird{ // 子类继承自Bird类,将实例化Ability类的逻辑收归到Bird类中,进一步提升复用性
protected $birdAbility;
public function __construct()
{
$this->birdAbility = new BirdAbility();
}
// ...鸟类的其他方法
}
class MaQue extends Bird implements FlyAble,TweetAble,EggLayAble{ // 麻雀
public function fly(){
$this->birdAbility->fly(); // 委托
// ...具体实现
}
public function tweet(){
$this->birdAbility->tweet();
// ...具体实现
}
public function layEgg(){
$this->birdAbility->layEgg();
// ...具体实现
}
}
class TuoNiao extends Bird implements TweetAble,EggLayAble{ // 鸵鸟
public function tweet(){
$this->birdAbility->tweet();
// ...具体实现
}
public function layEgg(){
$this->birdAbility->layEgg();
// ...具体实现
}
}
class FengNiao extends Bird implements FlyAble,EggLayAble{ // 蜂鸟
public function fly(){
$this->birdAbility->fly();
// ...具体实现
}
public function layEgg(){
$this->birdAbility->layEgg();
// ...具体实现
}
}
处了上面的做法之外,对于PHP而言,我们可以用trait特性轻松的实现组合,具体做法是在新建一个BirdAblityTrait类,在trait类中定义 flyAblity() 方法实现飞的公共逻辑,然后再在具体的某个鸟类中调用flyAblity()方法,这样就可以无需定义BirdAblity类,具体的鸟类也无需依赖$this->birdAbility对象。如下所示:
<?php
interface FlyAble{
public function fly();
}
interface TweetAble{
public function tweet();
}
interface EggLayAble{
public function layEgg();
}
trait BirdAbilityTrait{
public function flyAbility(){
// ...飞的公共逻辑
}
public function tweetAbility(){
// ...叫的公共逻辑
}
public function layEggAbility(){
// ...孵蛋的公共逻辑
}
}
class Bird{
// ...鸟类的其他方法
}
class MaQue extends Bird implements FlyAble,TweetAble,EggLayAble{ // 麻雀
use BirdAbilityTrait; // 引入 trait 类
public function fly(){
$this->flyAbility(); // 调用trait类的方法
// ...具体实现
}
public function tweet(){
$this->tweetAbility(); // 调用trait类的方法
// ...具体实现
}
public function layEgg(){
$this->layEggAbility(); // 调用trait类的方法
// ...具体实现
}
}
class TuoNiao extends Bird implements TweetAble,EggLayAble{ // 鸵鸟
use BirdAbilityTrait;
public function tweet(){
$this->flyAbility(); // 调用trait类的方法
// ...具体实现
}
public function layEgg(){
$this->layEggAbility(); // 调用trait类的方法
// ...具体实现
}
}
class FengNiao extends Bird implements FlyAble,EggLayAble{ // 蜂鸟
use BirdAbilityTrait;
public function fly(){
$this->flyAbility(); // 调用trait类的方法
// ...具体实现
}
public function layEgg(){
$this->layEggAbility(); // 调用trait类的方法
// ...具体实现
}
}
综合上面的例子,我们得出结论,如果多个类具有is-a关系但是这些类之间的方法集合不尽相同,就需要尽量用到组合代替继承。
最后虽然我们说鼓励多用组合少用继承,但是具体情况要看具体业务场景,我们不能说为了设计原则而用设计原则,而是为了实现可读性、可复用性、可扩展性和可维护性,不忘初心。在不复杂的业务场景中,继承足够满足我们的需求且没有明显的扩展性缺陷,我们就可以不用组合(毕竟组合意味着要做更细粒度的类的拆分,类和接口的增多会增加代码的复杂程度和维护成本)。
类之间存在is-a关系的基础上,如果类之间的继承层次比较浅(比如,最多有两层继承关系),继承关系不复杂,我们就可以大胆地使用继承。反之则尽量使用组合来替代继承。
如果类之间不具备is-a关系,只具备has-a关系,则只能使用组合。
如果子类A不能够实现父类B的所有抽象方法(例如B中有些方法是A不需要的),那么建议A和B之间使用组合而不用继承,并且让A和B继承自一个更公共的父类C。