为什么说要慎重使用继承

原文链接:https://www.cnblogs.com/xz816111/archive/2018/05/24/9080173.html

这篇文章的主题并非鼓励不使用继承,而是仅从使用继承带来的问题出发,讨论继承机制不太好的地方,从而在使用时慎重选择,避开可能遇到的坑。

JAVA中使用到继承就会有两个无法回避的缺点:

  1. 打破了封装性,子类依赖于超类的实现细节,和超类耦合。
  2. 超类更新后可能会导致错误。

继承打破了封装性

关于这一点,下面是一个详细的例子(来源于Effective Java第16条)

public class MyHashSet<E> extends HashSet<E> {
    private int addCount = 0;
<span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">int</span> <span class="hljs-title">getAddCount</span><span class="hljs-params">()</span> </span>{
    <span class="hljs-keyword">return</span> addCount;
}

<span class="hljs-meta">@Override</span>
<span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">boolean</span> <span class="hljs-title">add</span><span class="hljs-params">(E e)</span> </span>{
    addCount++;
    <span class="hljs-keyword">return</span> <span class="hljs-keyword">super</span>.add(e);
}

<span class="hljs-meta">@Override</span>
<span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">boolean</span> <span class="hljs-title">addAll</span><span class="hljs-params">(Collection&lt;? extends E&gt; c)</span> </span>{
    addCount += c.size();
    <span class="hljs-keyword">return</span> <span class="hljs-keyword">super</span>.addAll(c);
}

}

这里自定义了一个HashSet,重写了两个方法,它和超类唯一的区别是加入了一个计数器,用来统计添加过多少个元素。

写一个测试来测试这个新增的功能是否工作:

public class MyHashSetTest {
    private MyHashSet<Integer> myHashSet = new MyHashSet<Integer>();
<span class="hljs-meta">@Test</span>
<span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title">test</span><span class="hljs-params">()</span> </span>{
    myHashSet.addAll(Arrays.asList(<span class="hljs-number">1</span>,<span class="hljs-number">2</span>,<span class="hljs-number">3</span>));
    
    System.out.println(myHashSet.getAddCount());
}

}

运行后会发现,加入了3个元素之后,计数器输出的值是6。

进入到超类中的addAll()方法就会发现出错的原因:它内部调用的是add()方法。所以在这个测试里,进入子类的addAll()方法时,数器加3,然后调用超类的addAll(),超类的addAll()又会调用子类的add()三次,这时计数器又会再加三。

问题的根源

将这种情况抽象一下,可以发现出错是因为超类的可覆盖的方法存在自用性(即超类里可覆盖的方法调用了别的可覆盖的方法),这时候如果子类覆盖了其中的一些方法,就可能导致错误。

比如上图这种情况,Father类里有可覆盖的方法A和方法B,并且A调用了B。子类Son重写了方法B,这时候如果子类调用继承来的方法A,那么方法A调用的就不再是Father.B(),而是子类中的方法Son.B()。如果程序的正确性依赖于Father.B()中的一些操作,而Son.B()重写了这些操作,那么就很可能导致错误产生。

关键在于,子类的写法很可能从表面上看来没有问题,但是却会出错,这就迫使开发者去了解超类的实现细节,从而打破了面向对象的封装性,因为封装性是要求隐藏实现细节的。更危险的是,错误不一定能轻易地被测出来,如果开发者不了解超类的实现细节就进行重写,那么可能就埋下了隐患。

超类更新时可能产生错误

这一点比较好理解,主要有以下几种可能:

  • 超类更改了已有方法的签名。会导致编译错误。
  • 超类新增了方法:
    • 和子类已有方法的签名相同但返回类型不同,会导致编译错误。
    • 和子类的已有方法签名相同,会导致子类无意中复写,回到了第一种情况。
    • 和子类无冲突,但可能会影响程序的正确性。比如子类中元素加入集合必须要满足特定条件,这时候如果超类加入了一个无需检测就可以直接将元素插入的方法,程序的正确性就受到了威胁。

设计可继承的类

设计可以用来继承的类时,应该注意:

  • 对于存在自用性的可覆盖方法,应该用文档精确描述调用细节。
  • 尽可能少的暴露受保护成员,否则会暴露太多实现细节。
  • 构造器不应该调用任何可覆盖的方法。

详细解释下第三点。它实际上和 继承打破了封装性 里讨论的问题很相似,假设有以下代码:

public class Father {
    public Father() {
        someMethod();
    }
<span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title">someMethod</span><span class="hljs-params">()</span> </span>{
}

}

public class Son extends Father {
    private Date date;

    public Son() {
        this.date = new Date();
    }

    @Override
    public void someMethod() {
        System.out.println("Time = " + date.getTime());
    }
}

上述代码在运行测试时就会抛出NullPointerException

public class SonTest {
    private Son     son = new Son();
<span class="hljs-meta">@Test</span>
<span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title">test</span><span class="hljs-params">()</span> </span>{
    son.someMethod();
}

}

因为超类的构造函数会在子类的构造函数之前先运行,这里超类的构造函数对someMethod()有依赖,同时someMethod()被重写,所以超类的构造函数里调用到的将是Son.someMethod(),而这时候子类还没被初始化,于是在运行到date.getTime()时便抛出了空指针异常。

因此,如果在超类的构造函数里对可覆盖的方法有依赖,那么在继承时就可能会出错。

结论

继承有很多优点,但使用继承时应该慎重并多加考虑。同样用来实现代码复用的还有复合,如果使用继承和复合皆可(这是前提),那么应该优先使用复合,因为复合可以保持超类对实现细节的屏蔽,上述关于继承的缺点都可以用复合来避免。这也是所谓的复合优先于继承

如果使用继承,那么应该留意重写超类中存在自用性的可覆盖方法可能会出错,即使不进行重写,超类更新时也可能会引入错误。同时也应该精心设计超类,对任何相互调用的可覆盖方法提供详细文档。

另一篇

1.继承和组合都是可以复用代码。继承is a的关系,组合是has a的关系

2.继承是高耦合的,主要体现就是牵一发动全身。

3.如果想代码复用先考虑使用组合。

4.组合就是被扩展的类拥有一个扩展类的对象。这样拥有类就可以使用这个类的所有方法和属性。扩展类之间如果是需要联络的使用protocol进行。

5.使用继承的场景要满足以下三点:①父类只是给子类提供服务,并不涉及子类的业务逻辑;②层级关系明显,功能划分清晰,父类和子类各做各的;③如果有,父类的所有变化,都需要在子类中体现,这种需求。

6.父类只需要给子类提供一些共用的方法就可以了,不需要放子类的逻辑,比如View继承NSObject

7.当继承超过两层的时候,你就要好好考虑是否使用这个继承方案。第三层继承正是滥用的开始。

8.可以使用代理(delegate)去实现继承的需求,扩展类只要实现协议的方法,被扩展类用代理调用不同扩展类的实现,就可以达到继承效果(苹果推荐)。

9.可以使用类别(category)去实现继承的扩展功能。

10.是使用组合还是继承:要遵循里氏代换原则(有两个类A和B,B是A的子类,那么一个方法如果可以接受一个基类对象a的话:method(a),那么它必然可以接受一个子类对象b,:method(b))。

Casa的例子:

http://casatwy.com/tiao-chu-mian-xiang-dui-xiang-si-xiang-yi-ji-cheng.html

总结:根据里氏代换原则对Casa的例子进行分析:假设在一个Controller中使用到了HOME_SEARCH_BAR,比如addSubView(HOME_SEARCH_BAR)。这里是不能用add(LOCAL_SEARCH_BAR)代换的。因为HOME_SEARCH_BAR与LOCAL_SEARCH_BAR拥有不同的属性与行为,将他们添加到一个view上会得到截然不同的结果。

华丽的分割线,下方是网上摘抄的继承和组合的优缺点,帮助理解。


一:继承

继承是Is a的关系,比如说Student继承Person,则说明Student is a Person。继承的优点是子类可以重写父类的方法来方便地实现对父类的扩展。

继承的缺点有以下几点:

①:父类的内部细节对子类是可见的。

②:子类从父类继承的方法在编译时就确定下来了,所以无法在运行期间改变从父类继承的方法的行为。

③:如果对父类的方法做了修改的话(比如增加了一个参数),则子类的方法必须做出相应的修改。所以说子类与父类是一种高耦合,违背了面向对象思想。

二:组合

组合也就是设计类的时候把要组合的类的对象加入到该类中作为自己的成员变量。

组合的优点:

①:当前对象只能通过所包含的那个对象去调用其方法,所以所包含的对象的内部细节对当前对象时不可见的。

②:当前对象与包含的对象是一个低耦合关系,如果修改包含对象的类中代码不需要修改当前对象类的代码。

③:当前对象可以在运行时动态的绑定所包含的对象。可以通过set方法给所包含对象赋值。

组合的缺点:①:容易产生过多的对象。②:为了能组合多个对象,必须仔细对接口进行定义。

由此可见,组合比继承更具灵活性和稳定性,所以在设计的时候优先使用组合。只有当下列条件满足时才考虑使用继承:

子类是一种特殊的类型,而不只是父类的一个角色

子类的实例不需要变成另一个类的对象

子类扩展,而不是覆盖或者使父类的功能失效

作者:IvanRunning
链接:https://www.jianshu.com/p/5f95a3e6ac4b
来源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值