站在新语言平台上再谈“组合”与“继承”

长久以来,OO编程思想的一个重要信条是:多用组合,少用继承,这被广为接受和认可。Scala引入Trait(特质)之后,这一点“似乎”受到了冲击,你可以看到,在很多Scala代码里出现了通过继承多个Trait为一个Class混入(追加)新功能的案例,而其中有不少案例是过去我们在传统OO语言(例如Java)中不会或不建议的做法,因为那样看上去很像是在滥用继承。

举一个简单的例子,在程序中记录日志是一个普遍需求,传统Java程序是以“组合”的方式为一个类添加这一能力的,也就是在类里引入一个logger实例作为字段,然后就可以在类的各处使用它。这在Java这种单继承语言里几乎是唯一的选择,很显然,你不可能让你的类仅仅为了一个日志功能就占用了唯一一个父类名额,即使你的类没任何父类,让一个类去继承一个Logger父类也是一件很怪异的事。

在Scala里,事情发生了一些变化,由于Trait的多继承特性让一个类去继承多个特质变得自然而普遍,这为程序员提供了一个新的“为一个类混入新功能”的途径。于是很多在Java里使用组合的地方都被基于Trait的“继承”替代了。同样是上面的日志功能,在Scala里的实现方式通常是这样的:

trait Logger {
  def log(msg: String): Unit = {
    println(msg)
  }
}

class DataAccess extends Logger{
    def query(in: String) = {
      log(in)
  }
}

new DataAccess().query("Test")

由于这个示例过于简单,以至于让DataAccess继承一个Logger看上去会有些突兀和怪异,如果DataAccess有其他更具实质意义的父类或特质,我们会用with语句把Logger放在继承列表的最后,这样看上去会自然很多。

回到主题,一个从Java刚刚转到Scala的程序员看到这样的代码会感到些许的“不适”,他过去接受的“信条”会劝诫他:在这个地方应该使用组合而不是继承。但是Scala社区却在普遍使用这种写法,这是一个值得思考的问题。

首先,我们需要搞清楚一个问题,在传统的OO语言里为什么要提倡多用组合少用继承,滥用继承的危害是什么?一个普遍的认同是滥用继承会破环封装,请记住我们的限定条件是“滥用”。由于继承一个类可以方便地获取它的属性和方法,而实现继承又是一件轻而易举的事情,所以在支持多继承的语言里,它很容易被滥用。滥用造成的直接后果就是“破坏了封装”,如果一个类从核心用意和设计初衷上天然是另一个类的子类时,并不存在破坏封装这种说法,真正出问题的场景是:一个类从用意和设计初衷上不应是某个类的子类,但又需要用到或依赖到这个类的部分功能,此时使用继承就会将父类全部的字段和方法暴露给子类,这会让它“背负”过多不应属于它的属性和行为,使其变得臃肿,定位模糊,以致成为了一个“难以自圆其说”的“怪物”。此类情形下应该使用“组合”来实现需求,组合是一种“克制”的手段,它能更好地维护类的独立性和纯粹性,也就是避免对封装的破坏。

上述分析梳理出了两个判定滥用继承的重要依据:

  1. 如果一个类从核心用意和设计初衷上天然是另一个类的子类,这种继承是天经地义的,并不存在破坏封装的说法。
  2. 在允许多继承的语言里,如果一个类需要使用到来自另一个父类(特质)的“全体”字段和方法,或着反过来说,当把某个父类(特质)的全部属性和行为赋予另一个类时,如果从这个类的设计用意和代表的概念上没有任何的违和感,那么,这时候使用继承也是正当的,没有破坏封装的嫌疑。

对于第二点实际上还有很多的潜台词,我们强调了“全体”字段和方法,这是体现“is-a”关系的一个重要标志,若不需要全体,那这种继承就值得怀疑,通常,这有两种可能:

  1. 你根本不应去继承
  2. 你的父类(特质)职责是否不够单一?是否需要重构成多个职责更单一的父类(特质),然后再从中选择合适的父类(特质)进行继承呢?

基于上述原则,我们分析两个案例,第一个就是前面示例代码中的Logger特质,可以说这是一个职责绝对单一的特质,所有想要继承它的类目的也很单一:获得日志输出的能力,在这种情况下使用继承没有任何副作用,尽管这对从单继承语言转过来的程序员而言会感觉有些“心里不踏实”,但是仔细分析一下就会发现这并没有触碰到什么红线,所以很快就会适应这种写法。

另一个则是与Logger极为类似但在我个人看来却是一个反面案例,就是在很多代码里看到的对Config类的继承:

trait Config {
  private val config = ConfigFactory.load()
  
  private val httpConfig = config.getConfig("http")
  private val databaseConfig = config.getConfig("database")

  val httpHost = httpConfig.getString("interface")
  val httpPort = httpConfig.getInt("port")

  val jdbcUrl = databaseConfig.getString("url")
  val dbUser = databaseConfig.getString("user")
  val dbPassword = databaseConfig.getString("password")
}

class HttpService extends Config {
    ...
}

class DatabaseService extends Config {
    ...
}

类似上面的代码在很多程序里出现过,设计者寄希望通过继承Config让一个类能方便的获取配置项的值,不同于Logger, Config包含了整个应用的所有配置项,没有哪一个类需要并应该继承它的所有字段,这与我们前文提及的第2原则相违背,也严重的破坏了Config维护的封装性。代码中的HttpService和DatabaseService也能从侧面说明这一点,它们各自关心的是Http和Database相关的配置,对于其他的配置项没有理由也暴露给它们。相对优雅的做法应该是把这些配置项封装到一个object中,在需要使用某个配置项时以变量的方式获取即可。

对于那些从传统单继承语言转到支持多继承语言的程序员来说,你应该“想开一点”,花开堪折直须折,如果一个类就是想要获得某方面的“特质”,多了不要,少了不行,那就放心大胆地去继承那个“特质”吧。除此以外,你还是要审慎地看待每一个继承,继承始终是一件需要警惕的事,特别是在允许多继承的语言里。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Laurence 

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值