聊聊 Java 的面向对象以及接口和抽象类的区别?

Author Avatar
dev.liang 1月 21, 2019
  • 在其它设备中阅读本文章

Java 是非常典型的面向对象语言,掌握面向对象设计原则和技巧,是保证高质量代码的基础之一。面向对象提供的基本机制,对于提高开发、沟通等各方面效率也至关重要。
最近通过在极客时间里学习杨晓峰老师的课程,再回顾一下 java 方面的一些基础知识,在这里简单记录一下。

面向对象

谈到面向对象,我们一定要清楚面向对象的基本要素:封装、继承、多态。

面向对象的基本要素

封装
目的是隐藏事务内部的实现细节,以便提高安全性和简化编程。封装提供了合理的边界,避免外部调用者接触到内部的细节。我们在日常开发中,因为无意间暴露了细节导致的难缠 bug 太多了,比如在多线程环境暴露内部状态,导致的并发修改问题。从另外一个角度看,封装这种隐藏,也提供了简化的界面,避免太多无意义的细节浪费调用者的精力。

继承
是代码复用的基础机制.但要注意,继承可以看作是非常紧耦合的一种关系,父类代码修改,子类行为也会变动。在实践中,过度滥用继承,可能会起到反效果。

多态
说到多态,可能立即会想到重写(override)和重载(overload)、向上转型。
重写是父子类中相同名字和参数的方法,不同的实现;
重载则是相同名字的方法,但是不同的参数,本质上这些方法签名是不一样的,为了更好说明,请参考下面的样例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
public int doSomething() {
return 0;
}

/*输入参数不同,意味着方法签名不同,重载的体现*/
public int doSomething(List<String> strs) {
return 0;
}

/*return 类型不一样,编译不能通过;方法名称和参数一致,但是返回值不同,这种情况在 Java 代码中不算有效的重载*/
public short doSomething(List<String> strs) {
return 0;
}

面向对象设计原则(S.O.L.I.D)

英文简称 | 英文描述 | 中文名称
—|—
OCP | Open Closed Principle | 开放封闭原则
SRP | Single Responsibility Principle | 单一职责原则
LSP | Liskov Substitution Principle | 里氏替换原则
ISP | Interface Segregation Principle | 接口分离原则
DIP | Dependency Inversion Principle | 依赖倒置原则

下面通俗解释参考来源:CSDN xiong_it,感谢~

开关原则(开闭原则)(Open-Close, Open for extension, close for modification Principle)

开闭原则对扩展开放,对修改关闭,并不意味着不做任何修改,低层次模块的变化,必然要有高层模块进行耦合,否则就是一个孤立无意义的代码片段。在业务规则改变的情况下高层模块必须有部分改变以适应新业务,改变要尽量地少,防止变化风险的扩散。
—秦小波《设计模式之禅》

设计要对扩展开放,对修改关闭。换句话说,程序设计应保证平滑的扩展性,尽量避免因为新增同类功能而修改已有实现,这样可以少产出些回归(regression)问题。

通俗解释:
一个软件实体如类、模块和函数应该对扩展开放,对修改关闭。
OCP 是 6 大原则的最高纲领,所以才如此抽象,晦涩难懂。
用面向对象的语言来讲,OCP(开闭原则) 是一个最抽象的接口,而其余的5大原则只是OCP的子类接口,他们一起定义了 OOP 世界的开发标准,常用的 23 种设计模式更是只能算作这 6 大原则的实现抽象类,咱们开发的代码实践才是真正的具体子类。

单一职责原则(Single Responsibility Principle)

类或者对象最好是只有单一职责,在程序设计中如果发现某个类承担着多种义务,可以考虑进行拆分。

通俗解释:
应该有且只有一个原因引起类的变更。(一个类,一个方法只应该做一件事情)

这种方式是如何体现扩展性的呢?
拿一个Android中最常见的ImageLoader的设计来举例子,ImageLoader主要需要实现2个功能,下载图片,缓存图片。
假如,我们把所有的功能全部放在一个ImageLoader类中,假设下载要改方式呢?缓存要改策略呢?你通通要改ImageLoader,你如何保证修改某个功能的过程中另一个功能依旧完好,没被污染?拆分职责,使用ImageCache接口及其子类实现进行缓存,和ImageLoader建立关联,职责单一了,你再在每个单一的职责类里面去修改相关代码,这样其他功能代码被污染的概率大大降低。
当然,这里只是随意举的例子,划分单一职责这个度很难把握,每个人都需要根据自身情况和项目情况来进行判断。

里氏替换原则(Liskov Substitution Principle)

这是面向对象的基本要素之一,进行继承关系抽象时,凡是可以用父类或者基类的地方,都可以用子类替换。

通俗解释:
只要父类能出现的地方子类就可以出现,而且替换为子类也不产生任何异常错误,反之则不然。这主要体现在,我们经常使用抽象类/基类做为方法参数,具体使用哪个子类作为参数传入进去,由调用者决定。

这条原则包含以下几个方面:

  • 子类必须完全实现父类的方法;
  • 子类可以有自己的个性外观(属性)和行为(方法);
  • 覆盖或者实现父类方法时,参数可以被放大,即父类的某个方法参数为HashMap时,子类参数可以是HashMap,也可以是Map或者更大;
  • 覆盖或者实现父类的方法时,返回结果可以被缩小,即父类的某个方法返回类型是Map,子类可以是Map,也可以是HashMap或者更小。

接口分离原则(Interface Segregation Principle)

我们在进行类和接口设计时,如果在一个接口里定义了太多方法,其子类很可能面临两难,就是只有部分方法对它是有意义的,这就破坏了程序的内聚性。
对于这种情况,可以通过拆分成功能单一的多个接口,将行为进行解耦。在未来维护中,如果某个接口设计有变,不会对使用其他接口的子类构成影响。

通俗解释:
客户端不应该依赖它不需要的接口,类间的依赖应该建立在最小的接口上;
使用接口时应该建立单一接口,不要建立臃肿庞大的接口,尽量给调用者提供专门的接口,而非多功能接口。
这里我想举个 Android 中的事件处理 Listener 设计的例子,大家都知道,我们想给 button 添加点击或长按事件时,可以使用如下代码

1
2
button.setOnClickListener(clickListener); 
button.setOnLongClickListener(longClickListener);

还有其他比如 OnTouchListener 等等等事件接口,它为什么不直接提供一个通用的接口 IListener 呢?然后回调所有的事件给调用者处理,而要提供这么多独立的接口,这就是遵循了 ISP 原则的结果,每个接口最小化了,Activity/button 作为调用者,我可以选择性的去处理我想处理的事件,不关心的事件 Listener 我就不去处理和依赖。

依赖反转原则或依赖倒置原则(Dependency Inversion Principle)

实体应该依赖于抽象而不是实现。也就是说高层次模块,不应该依赖于低层次模块,而是应该基于抽象。实践这一原则是保证产品代码之间适当耦合度的法宝。

通俗解释:
抽象:Java中体现为基类,抽象类,接口,而不单指抽象类
细节:体现为子类,实现类

通俗点讲,该原则包含以下几点要素:

  • 模块间的依赖应该通过抽象发生,具体实现类之间不应该建立依赖关系
  • 接口或者抽象类不依赖于实现类,否则就失去了抽象的意义
  • 实现类依赖于接口或者抽象类

总结起来,一句话:”面向接口编程“。

以上为最通用的部分,另外还有一个原则:

迪米特法则(Demeter Principle):又称最少知识原则(Least Knowledge Principle, LKP)

一个对象应该对其他对象有最少的了解。
通俗点讲:一个类应该对自己需要耦合或者调用的类知道越少越好,被耦合或者调用的类内部和我没有关系,我不需要的东西你就别 public 了。
迪米特法则包含以下几点要素:
只和朋友类交流:只耦合该耦合的类
朋友间也是有距离的:减少不该public的方法,向外提供一个简洁的访问
自家的方法就自己创建:只要该方法不会增加内部的负担,也不会增加类间耦合

OOP 原则在面试题目中的分析

OOP 的全称是 Object Oriented Programming,即面向对象编程,它的设计原则对于指导开发有实际意义。
S.O.L.I.D 是面向对象设计(OOD)和面向对象编程(OOP)中的几个重要编码原则(Programming Priciple)的首字母缩写。

借用极客时间里的代码

1
2
3
4
5
6
7
8
9
10
public class VIPCenter {
void serviceVIP(T extend User user) {
if (user instanceof SlumDogVIP) {
// 穷 X VIP,活动抢的那种
// do somthing
} else if(user instanceof RealVIP) {
// do somthing
}
...
}

这段代码的一个问题是,业务逻辑集中在一起,当出现新的用户类型时,比如,大数据发现了我们是肥羊,需要去收获一下, 这就需要直接去修改服务方法代码实现,这可能会意外影响不相关的某个用户类型逻辑。

利用开关原则,尝试改造为下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class VIPCenter {
private Map<User.TYPE, ServiceProvider> mProviders;
void serviceVIP(T extend User user) {
mProviders.get(user.getType()).service(user);
}
}

interface ServiceProvider{
void service(T extend User user) ;
}

class SlumDogVIPServiceProvider implements ServiceProvider{
void service(T extend User user){
// do somthing
}
}

class RealVIPServiceProvider implements ServiceProvider{
void service(T extend User user) {
// do something
}
}

上面的示例,将不同对象分类的服务方法进行抽象,把业务逻辑的紧耦合关系拆开,实现代码的隔离保证了方便的扩展。

如何回答接口和抽象类两者的区别?

接口和抽象类是 Java 面向对象设计的两个基础机制。

接口

接口,不能实例化;
Java 类实现 interface 使用 implements 关键词;
接口是对行为的抽象,它是抽象方法的集合,利用接口可以达到 API 定义和实现分离的目的;
不能包含任何非常量成员,任何 field 都是隐含着 public static final 的意义;
同时,没有非静态方法实现,也就是说要么是抽象方法,要么是静态方法。
Java 标准类库中,定义了非常多的接口,比如 java.util.List,可以查看 list 的源码来了解。

抽象类

抽象类是不能实例化的类,用 abstract 关键字修饰 class,其目的主要是代码重用
除了不能实例化,形式上和一般的 Java 类并没有太大区别,可以有一个或者多个抽象方法,也可以没有抽象方法。
抽象类大多用于抽取相关 Java 类的共用方法实现或者是共同成员变量,然后通过继承的方式达到代码复用的目的。
Java 标准库中,比如 collection 框架,很多通用部分就被抽取成为抽象类,例如 java.util.AbstractList。
继承 abstract class 则是使用 extends 关键词,我们可以参考 Java 标准库中的 ArrayList。

1
2
3
4
public class ArrayList<E> extends AbstractList<E> 
implements List<E>, RandomAccess, Cloneable, java.io.Serializable{
...
}