【编程思想】为什么rust和go语言都拥抱组合而舍弃了继承?

14,407 阅读7分钟

大家好,我是Coder哥,今天我们来聊一下继承和组合。

昨天刷到一个问题,有人问是什么原因让rust和go等新型语言拥抱组合舍弃继承?仔细一想确实是,之前一直在用Java,最近才开始用Go语言,在用Java的时候有一个准则是,如果没充分的理由不要用继承。但是在用Java开发的时候也一直很随意,习惯性的撸继承,但是Go就很不一样了,没得选,~_~!!,只能用组合。

那么现代语言为啥提倡用组合呢?其实不只是新的语言,包括Java这个老大哥,在 《Effective Java》(文末有电子书地址)的 第16条 中有这样一句话:复合优先于继承, 继承是实现代码重用的的有力手段,但它未必是最好的方法。

所以我们是该仔细的思考一下。我们不讨论具体代码,只谈思想,我觉得可以从以下几个方向来考虑一下:

  1. 继承与组合的特点。
  2. 在实际应用中继承会带来什么问题?
  3. 组合是怎么解决这个问题的?

让我们结合一个实际应用,通过抛弃继承,拥抱接口、拥抱组合的方式来一步步优化。相信看完这个文章你会对继承和组合有一个新的认识。

继承与组合的特点

这里就不说继承和组合的定义了,继承和组合是面向对象编程中两种常见的代码重用方式。它们都可以实现代码的复用,但是他们有各自的优缺点。这里先总体说一下。

继承:

优点:

  1. 它可以实现代码的重用,从父类继承的属性和方法可以在子类中直接使用。
  2. 继承链的扩展。通过继承可以构建继承链,使得子类可以继承祖先类的所有属性和方法,从而提高代码的可扩展性和可维护性。
  3. 继承和组合都可以实现多态,即同一个方法在不同的子类中表现出不同的行为。

缺点:

  1. 父类的改变会影响子类。如果父类的实现发生变化,所有继承自该父类的子类都需要相应地进行修改,这会增加代码的维护成本。
  2. 继承关系的耦合度高。子类和父类之间是紧密耦合的关系,这会影响代码的灵活性和可移植性。

那么组合呢,组合相对于继承有如下特点:

组合:

优点:

  1. 组合可以减少代码的耦合性,因为对象之间的关系是松散的,修改一个对象不会影响到其他对象。
  2. 组合可以实现更灵活的代码设计,因为可以根据需要组合不同的对象。
  3. 接口隔离。组合可以实现接口隔离,将不同的功能模块分别实现,提高代码的可复用性。

缺点:

  1. 代码量增加。相比于继承,组合需要增加更多的代码来实现不同的模块组合。
  2. 对象之间的交互复杂。组合关系下,对象之间的交互有时需要复杂的接口定义和实现,增加了代码的复杂度。

在实际应用中继承会带来什么问题以及我们怎么优化它?

1、初步问题

比如说我们要设计一个关于车的类。按照面向对象编程的思想,我们将“车类”这样一个事务抽象成一个BaseCar类,默认有run的行为。那么所有车类都可以继承这个抽象类。比如,汽车,卡车等。

public class BaseCar { 
 	//... 省略其他属性和方法... 
 	public void run() { //... }
}
// 汽车
public class Car extends AbstractCar { 
}

但是,基于对这个对象的理解和需求,车出了跑,还可以修轮胎,可以修引擎等。那么AbstractCar就变成如下的类,那么这个时候有个自行车的类需要实现,自行车不能修引擎,要怎么写呢

public class BaseCar { 
 	//... 省略其他属性和方法... 
 	public void run() { //跑... }
    
  public void repaireTire() { //修轮胎... }
    
  public void repaireEngine() { //修引擎... }
}

// 自行车
public class Bicycle extends BaseCar { 
 //... 省略其他属性和方法... 
 public void repaireEngine() { 
     throw new UnSupportedMethodException("我没有引擎!");  
 }
}

按照这个逻辑,上面的代码看似解决了问题,实则可能会堆成屎山,上面的设计有三个点有很大隐患:

第一个是,如果我们把基类的行为实现都放到基类里面,比如说,如果后面增加了自动驾驶功能全景功能,带天窗功能,那是不是都要堆到基类里面了,虽然能提高复用性,但是也会改变所有子类的功能,这也会导致代码的复杂性提升,这点是我们并不想看到的。

第二个点是,对于没有没有那些功能的对象,比如自行车,就不应该把修引擎的功能暴露到自行车类里面。

第三个点是,如果扩展到其他对象怎么办,比如说人也会跑,飞机也会跑。那么这个设计后面就不好扩展了,也不够灵活。

那么对于上面的问题我们要怎么解决呢。你是不是想到了接口,对,接口更多的是行为的定义,抽象类更多的是定义的某一类类型的基础通用行为的实现。其实抽象类的加入也提高了代码的复杂度。

2、接口优化

对于以上问题,我们无视具体对象,只看行为,比如,跑,修引擎,修轮胎,这些功能行为,我们可以定义成接口:IRun,IEngine,ITire,这三个接口:

public interface IRun {
  void run();
}
public interface IEngine {
  void repaireEngine();
}
public interface ITire {
  void repaireTire();
}

那么我们实现汽车这个类的时候可以,实现IRun、IEngine、ITire 这三个接口,我们实现自行车类的时候可以实现IRun这一个接口,那么如果想写个人类的对象的时候,也只需要实现IRun 这个接口就可以了。

public class Car implements IRun, IEngine, ITire {//汽车
  //... 省略其他属性和方法...
  @Override
  public void run() { //跑... }
  @Override
  public void repaireEngine() { //修引擎... }
  @Override
  public void repaireTire() { //修轮胎... }
}
public class Bicycle impelents IRun, ITire{//自行车
  //... 省略其他属性和方法...
  @Override
  public void run() { //跑... }
  @Override
  public void repaireTire() { //修轮胎... }
}
public class Person impelents IRun {//人
  //... 省略其他属性和方法...
  @Override
  public void run() { //跑... }
}

这样是不是灵活性就更好了,到这是不是理解了为啥Go、Rust等现代语言,踢出了继承,踢出了抽象类,保留了接口实现的原因了吧。

但是,只看上面代码好像还有问题,那每个对象都要写一遍run,repaireEngine,repaireTire等功能,这样岂不是很麻烦,说好的复用呢???别急,组合该登场了。

3、组合优化

对于上面的问题,我们可以通过先实现接口,然后通过组合、委托的方式来解决。代码如下:

public class CarRunEnable implements IRun {
  @Override
  public void run() { // 车跑... }
}
public class PersonRunEnable implements IRun {
  @Override
  public void run() { // 人跑... }
}
//省略其他实现 EngineEnable/TireEnable

public class Car implements IRun, IEngine, ITire {//汽车
  private CarRunEnable runEnable = new CarRunEnable(); //组合
  private EngineEnable engineEnable = new EngineEnable(); //组合
  private TireEnable tireEnable = new TireEnable(); //组合
  //... 省略其他属性和方法...
  @Override
  public void run() { //跑... 
    runEnable.run();
  }
  @Override
  public void repaireEngine() { //修引擎...
    engineEnable.repaireEngine();
  }
  @Override
  public void repaireTire() { //修轮胎... 
    tireEnable.repaireTire();
  }
}
public class Bicycle impelents IRun {//自行车
  private CarRunEnable runEnable = new CarRunEnable(); //组合
  private TireEnable tireEnable = new TireEnable(); //组合
  //... 省略其他属性和方法...
  @Override
  public void run() { //跑... 
    runEnable.run();
  }
  @Override
  public void repaireTire() { //修轮胎... 
    tireEnable.repaireTire();
  }
}
public class Person impelents IRun {//人
  private PersonRunEnable runEnable = new PersonRunEnable(); //组合
  //... 省略其他属性和方法...
  @Override
  public void run() { //跑... 
    runEnable.run();
  }
}

看上面的代码逻辑是不是就很爽快,加功能,随便加,不影响其他的类,耦合度降低了很多,但是内聚性也不必继承差,这就是所谓的高内聚低耦合。唯一的缺点是,代码量变多了。

那么我们回到最开始的那个问题,什么原因让rust和go等新型语言拥抱组合舍弃继承?,相信你心中已经有了答案。

到最后了,感谢各位能看到这里。

参考书籍:
《Effective Java》: www.todocoder.com/pdf/java/00…
《Java编程思想》www.todocoder.com/pdf/java/00…