面向对象设计
面向对象基本概念
面向对象程序设计(
Object-oriented programming
即OOP
)是种具有对象概念的程序编程典范,同时也是一种程序开发的抽象方针。它可能包含数据、属性、代码与方法。
面向对象编程的两个重要概念是类和对象。
类:类是变量与作用这些变量的方法集合,事物都具有其自身的属性和方法,通过这些属性和方法可以将不同的物质区分开来。
对象:对象是类进行实例化后的产物,是一个实体。
面向对象基本特征
封装
封装是面向对象的特征之一,是对象和类概念的主要特性。
封装,也就是把客观事物封装成抽象的类,并且类可以把自己的数据和方法只让可信的类或者对象操作,对不可信的类进行信息隐藏。
继承
继承是指这样一种能力:它可以使用现有类的所有功能,并在无需重新编写原来的类的情况下对这些功能进行扩展。
- 通过继承创建的新类称为“子类”或“派生类”。
- 被继承的类称为“基类”、“父类”或“超类”。
- 继承的过程,就是从一般到特殊的过程。
继承概念的实现方式有三类:实现继承、接口继承和可视继承。
- 实现继承:使用基类的属性和方法而无需额外编码的能力;
- 接口继承:仅使用属性和方法的名称、但是子类必须提供实现的能力;
- 可视继承:子窗体(类)使用基窗体(类)的外观和实现代码的能力。
OO开发范式大致为:划分对象→抽象类→将类组织成为层次化结构(继承和合成) →用类与实例进行设计和实现几个阶段。
多态
所谓多态就是指一个类实例的相同方法在不同情形有不同表现形式。多态机制使具有不同内部结构的对象可以共享相同的外部接口。这意味着,虽然针对不同对象的具体操作不同,但通过一个公共的类,它们(那些操作)可以通过相同的方式予以调用。
最常见的多态就是将子类传入父类参数中,运行时调用父类方法时通过传入的子类决定具体的内部结构或行为。
实现多态,有两种方式,覆盖和重载。覆盖和重载的区别在于,覆盖在运行时决定,重载是在编译时决定。并且覆盖和重载的机制不同,例如在 Java 中,重载方法的签名(方法签名:方法名+形参列表
)必须不同于原先方法的,但对于覆盖签名必须相同。
面向对象设计原则
开闭原则
开闭原则(
Open Close Principle
即OCP
)是Java中最基础的设计原则,它可以帮助我们建立一个稳定、灵活的系统。
定义
软件中的对象(类、模块、函数等)应该对于扩展是开放的,但是对于修改是封闭的。
实例分析
我们假设当前有一个书籍销售的功能模块,程序原本的类图如下:
此时,因为书店打折活动,书籍价格会产生变化,我们需要修改程序的getPrice()方法,我们可以修改接口IBook
,也可以修改其实现类NovelBook
,但是这样的话都会在类中产生两个读取价格的方法,顾这两种方法都不是最优的解决办法。如下图所示,我们新建一个OffNovelBook
,让其继承NovelBook
并重写getPrice
方法,新建高层类,通过复写来改变业务逻辑,减少底层代码的修改,减少代码风险。
我们可以把变化归类为两种类型:
- 逻辑变化:只变化了一个逻辑,而不涉及到其他模块的变化,可以直接修改原有类中的方法来实现,但这有一个前提条件是所有依赖或关联都按照相同的逻辑处理。
- 子模块变化:一个模块的变化,会对其他模块产生影响,特别是一个低层次的模块变化必然引起高层模块的变化,因此诸如此类的变化应通过扩展来完成。
总结
- 抽象约束 :通过接口或抽象类约束扩展,对扩展进行边界限定 ;参数类型、引用对象尽量使用接口或抽象类,而不是具体的实现类 ;抽象层尽量保持稳定,一旦确定就不要修改 。
- 元数据(metadata)控制模块行为 :元数据就是用来描述环境和数据的数据。尽量使用元数据来控制程序的行为,减少重复开发 。
- 封装变化:将相同的变化封装到一个接口或抽象类中,不同的变化应封装在不同的接口或抽象类中,否则即违背了单一职责原则。
单一职责原则
单一职责原则(
Single Responsibility Principle
即SRP
),其核心的思想是: 一个类,最好只做一件事,只有一个引起它变化的原因。
定义
单一职责,强调的是职责的分离,在某种程度上对职责的理解,构成了不同类之间耦合关系的设计关键,因此单一职责原则或多或少成为设计过程中一个必须考虑的基础性原则。
实例分析
public void addProduct() {
if (!getCuurentUserPermission.contains("product.add")) {
//当前用户不具有add权限
return;
}
//业务逻辑
}
从以上代码,我们不难看出,如果该功能对应的权限规则发生了变化 ,那么我们需要对系统中所有诸如此类的类进行修改。这将大大降低程序的稳定性,同时也会大大的提高我们得工作量。
对于这种情况我们可以新建一个类(权限控制类,比如shiro
框架)来做处理。在shrio
框架中,可以通过注解的方式去配置权限代码来实现权限控制,同理我们也可自己写一个方法,例如:PermisssionUtils.haveRight(String permissionCode)
,在调用addProduct
之前去判断是否拥有权限,从而使业务逻辑与权限控制两个职责分离。
PermisssionUtils.haveRight("product.add");
addProduct();
总结
单一职责原则可以看作是低耦合、高内聚在面向对象原则上的引申,将职责定义为引起变化的原因,以提高内聚性来减少引起变化的原因。职责过多,可能引起它变化的原因就越多,这将导致职责依赖,相互之间就产生影响,从而极大的损伤其内聚性和耦合度。单一职责,通常意味着单一的功能,因此不要为类实现过多的功能点,以保证实体只有一个引起它变化的原因。
里氏替换原则
里氏替换原则(
Liskov Substitution Principle
,即LSP
):所有引用父类的地方必须能使用其子类的对象。
定义
所有引用父类的地方必须能使用其子类的对象:
在软件中将一个基类对象替换成它的子类对象,程序将不会产生任何错误和异常,反过来则不成立,如果一个软件实体使用的是一个子类对象的话,那么它不一定能够使用父类对象。
实例分析
在下文关于依赖倒置原则
的实例中:
public class Client {
public static void main(String[] args) {
People jim = new Jim();
Fruit apple = new Apple();
Fruit banana = new Banana();
jim.eat(apple);
jim.eat(banana);
}
}
我们将Banana
赋值给其父类Banana
,并且在执行jim.eat(Banana)
方法时得到了我们期望的结果。
总结
里氏替换原则是实现开闭原则的重要方式之一,由于使用父类对象的地方都可以使用子类对象,因此在程序中尽量使用父类类型来对对象进行定义,而在运行时再确定其子类类型,用子类对象来替换父类对象。通常我们会使用接口或者抽象方法定义基类,然后子类中实现父类的方法,并在运行时通过各种手段进行类型选择调用(比如反射)。
子类的所有方法必须在父类中声明,或子类必须实现父类中声明的所有方法。根据里氏替换原则,为了保证系统的扩展性,在程序中通常使用父类来进行定义,如果一个方法只存在子类中,在父类中不提供相应的声明,则无法在以父类定义的对象中使用该方法。
我们在运用里氏替换原则时,尽量把父类设计为抽象类或者接口,让子类继承父类或实现父接口,并实现在父类中声明的方法,运行时,子类实例替换父类实例,我们可以很方便地扩展系统的功能,同时无须修改原有子类的代码,增加新的功能可以通过增加一个新的子类来实现。里氏替换原则是开闭原则的具体实现手段之一。这也就是我们应该更多的依赖抽象,尽量少的依赖实现细节, 也就是依赖倒置原则。
依赖倒置原则
依赖倒置原则(
Dependency Inversion Principle
即DIP
)
定义
- 高层模块不应该依赖低层模块,两者都应该依赖其抽象
- 抽象不应该依赖细节
- 细节应该依赖抽象
抽象:抽象类或接口,两者是不能被实例化的
细节:抽象具体的实现类,实现接口或继承抽象类所产生的类(可以被实例化的类)
实例分析
//具体Jim人类
public class Jim {
public void eat(Apple apple){
System.out.println("Jim eat " + apple.getName());
}
}
//具体苹果类
public class Apple {
public String getName(){
return "apple";
}
}
public class Client {
public static void main(String[] args) {
Jim jim = new Jim();
Apple apple = new Apple();
jim.eat(apple);
}
}
从上述代码,我们不难看出,该程序所表示的人吃苹果。此时我们如果要加一条人吃香蕉,只能先定义一个Banana
类,然后在修改Jim
类,在其中加一个吃香蕉的方法。加一种尚且如此,那么加n种呢?并且修改Jim
类的操作会大大减少系统的稳健性,顾应根据依赖倒置原则对源码进行修改,修改如下:
//人接口
public interface People {
public void eat(Fruit fruit);//人都有吃的方法,不然都饿死了
}
//水果接口
public interface Fruit {
public String getName();//水果都是有名字的
}
//具体Jim人类
public class Jim implements People{
public void eat(Fruit fruit){
System.out.println("Jim eat " + fruit.getName());
}
}
//具体苹果类
public class Apple implements Fruit{
public String getName(){
return "apple";
}
}
//具体香蕉类
public class Banana implements Fruit{
public String getName(){
return "banana";
}
}
public class Client {
public static void main(String[] args) {
People jim = new Jim();
Fruit apple = new Apple();
Fruit banana = new Banana();
jim.eat(apple);
jim.eat(banana);
}
}
总结
总而言之,依赖倒置原则的核心就是面向接口编程,对于一些底层类都提取抽象类和公共接口,顶层类依赖抽象类或接口而不直接依赖具体实现。
依赖倒置原则的本质是通过抽象类或接口使各个类或模块的实现彼此独立,不相互影响,实现模块之间的松耦合。
在多人协作时,我们还可以遵循依赖倒置原则去设计程序,提取抽象,使得各模块相对独立,提升并行开发效率,提高开发速度。
接口隔离原则
接口隔离原则(
Interface Segregation Principle
, 即ISP
):使用多个专门的接口,而不使用单一的总接口,即客户端不应该依赖那些它不需要的接口。
定义
客户端不应该依赖它不需要的接口。
类间的依赖关系应该建立在最小的接口上。
接口隔离原则将非常庞大、臃肿的接口拆分成为更小的和更具体的接口,这样客户将会只知道他们感兴趣的方法。
实例分析
例如:客户端的用户需要登录、登出、修改密码等操作,而后台的管理员则可以修改用户资料、删除用户等操作。
public interface UserService{
public void login();
public void logout();
public void changePassword();
}
public interface AdminUserService extends UserService {
public void updateUser(User user);
public void deleteUser(User user);
}
总结
接口隔离原则的目的是系统解开耦合,从而容易重构、更改和重新部署。
接口隔离原则与前面的单一职责原则相辅相成。但单一职责原则并不保证客户程序只知道必要的信息,甚至在有些情况下接口隔离原则与单一职责原则会出现一定的冲突,设计时我们要根据用户界面,性能等因素决策.。
运用接口隔离原则,一定要适度,接口设计的过大或过小都不好。设计接口的时候,只有多花些时间去思考和筹划,才能准确地实践这一原则。
迪米特法则
迪米特法则(
Law of Demeter
, 即LOD
):一个软件实体应当尽可能少地与其他实体发生相互作用。迪米特法则又称为最少知识原则(
LeastKnowledge Principle
,即LKP
)
定义
不要和“陌生人”说话、只与你的直接朋友通信
在迪米特法则中,对于一个对象,其朋友包括以下几类:
- 当前对象本身(this)
- 以参数形式传入到当前对象方法中的对象
- 当前对象的成员对象
- 如果当前对象的成员对象是一个集合,那么集合中的元素也都是朋友
- 当前对象所创建的对象
任何一个对象,如果满足上面的条件之一,就是当前对象的“朋友”,否则就是“陌生人”。在应用迪米特法则时,一个对象只能与直接朋友发生交互,不要与“陌生人”发生直接交互,这样做可以降低系统的耦合度,一个对象的改变不会给太多其他对象带来影响 。
实例分析
例如:现在用户像好友发送消息,用户可以对单个好友发送消息,也可以发送群消息。
从上图不难看出,我们专门引入了一个MessageService
用与控制消息转发,同时降低UserService
与各系统之间的耦合度。当需要新增功能时,只需修改MessageSerice
即可。
总结
迪米特法则要求我们在设计系统时,应该尽量减少对象之间的交互,如果两个对象之间不必彼此直接通信,那么这两个对象就不应当发生任何直接的相互作用,如果其中的一个对象需要调用另一个对象的某一个方法的话,可以通过第三者转发这个调用。简言之,就是通过引入一个合理的第三者来降低现有对象之间的耦合度。
在运用迪米特恩法则时应注意以下几点:
- 在类的划分上,应当尽量创建松耦合的类,类之间的耦合度越低,就越有利于复用,一个处在松耦合中的类一旦被修改,不会对关联的类造成太大波及。
- 在类的结构设计上,每一个类都应当尽量降低其成员变量和成员函数的访问权限。
- 在类的设计上,只要有可能,一个类型应当设计成不变类;在对其他类的引用上,一个对象对其他对象的引用应当降到最低。
组合/聚合复用原则
组合/聚合复用原则(
Composite/Aggregate Reuse Principle
即CARP
)
定义
组合和聚合都是对象建模中关联(Association
)关系的一种.聚合表示整体与部分的关系,表示“含有”,整体由部分组合而成,部分可以脱离整体作为一个独立的个体存在。组合则是一种更强的聚合,部分组成整体,而且不可分割,部分不能脱离整体而单独存在。在合成关系中,部分和整体的生命周期一样,组合的新的对象完全支配其组成部分,包括他们的创建和销毁。一个合成关系中成分对象是不能与另外一个合成关系共享。
实例分析
组合/聚合和继承是实现复用的两个基本途径。合成复用原则是指尽量使用合成/聚合,而不是使用继承。 只有当以下的条件全部被满足时,才应当使用继承关系。
- 继承复用破坏包装,它把父类的实现细节直接暴露给了子类,这违背了信息隐藏的原则。
- 如果父类发生了改变,那么子类也要发生相应的改变,这就直接导致了类与类之间的高耦合,不利于类的扩展、复用、维护等,也带来了系统僵硬和脆弱的设计。
- 合成和聚合的时候新对象和已有对象的交互往往是通过接口或者抽象类进行的,就可以很好的避免上面的不足,而且这也可以让每一个新的类专注于实现自己的任务,符合单一职责原则。
判断方法:
1.使用“Has-A”和“Is-A”来判断:
“Has-A”:代表的是 对象和它的成员的从属关系。同一种类的对象,通过它们的属性的不同值来区别。比如一个人可以是医生、警察、教师等。此时可使用继承关系。
“Is-A”:代表的是类之间的继承关系,比如一个人可以是男人、女人。此时应使用组合/聚合。
2.使用里氏替换原则来判断
里氏代换原则是继承复用的基础。
总结
继承的缺点:
- 继承复用破坏数据封装性,将基类的实现细节全部暴露给了派生类,基类的内部细节常常对派生类是透明的,白箱复用。虽然简单,但不安全,不能在程序的运行过程中随便改变。
- 基类的实现发生了改变,派生类的实现也不得不改变。
- 从基类继承而来的派生类是静态的,不可能在运行时间内发生改变,因此没有足够的灵活性。
组合/聚合优点:
- 新对象存取
组成对象
的唯一方法是通过组成对象
的getter/setter
方法。 - 组合复用是黑箱复用,因为组成对象的内部细节是新对象所看不见的。
- 组合复用所需要的依赖较少。
- 每一个新的类可以将焦点集中到一个任务上。
- 组合复用可以在运行时间动态进行,新对象可以动态的引用与成分对象类型相同的对象。
组合/聚合缺点:组合复用建造的系统会有较多的对象需要管理。