学习设计原则,学习设计模式的基础。
在实际开发过程中,并不是一定要求所有代码都遵循设计原则,我们要考虑人力、时间、成本、质量,不是刻意追求完美,要在适当的场景遵循设计原则,体现的是一种平衡取舍,帮助我们设计出更加优雅的代码结构。
六大设计原则:
- 另外还有一个 合成复合原则 也会进行介绍.
1. 开闭原则
开闭原则,其在理解上比较简单,而且也是其他设计原则的基石,是面向对象设计中最基础的设计原则。
定义:Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.
即:一个软件实体如类、模块和函数应该对扩展开放,对修改关闭。
概述:
- 用抽象构建框架,用实现扩展细节。
- 不以改动原有类的方式来实现新需求,而是应该以实现事先抽象出来的接口(或具体类继承抽象类)的方式来实现。
所谓的开闭,也正是对 扩展 和 修改 两个行为的一个原则。强调的是 用抽象构建框架,用 实现扩展细节。可以提高软件系统的可复用性及可维护性。
优点:
实践开闭原则的优点在于可以在不改动原有代码的前提下给程序扩展功能。增加了程序的可扩展性,同时也降低了程序的维护成本。
开闭原则 指导我们如何建立稳定灵活的系统。
例如:我们版本更新,我尽可能不修改源代码,但是可以增加新功能。在现实生活中对于开闭原则也有体现。比如,很多互联网公司都实行弹性制作息时间,规定每天工作 8 小时。
意思就是说,对于每天工作 8 小时这个规定是关闭的,但是你什么时候来,什么时候走是开放的。早来早走,晚来晚走。实现开闭原则的核心思想就是面向抽象编程。
代码举例了解:
设计一个在线课程类:
由于教学资源有限,开始的时候只有类似于博客的,通过文字讲解的课程。
但是随着教学资源的增多,后来增加了视频课程,音频课程以及直播课程。
先来看一下不好的设计:
不好的设计
在课程类原有的数据信息基础上,直接增加 视频、音频、直播课程 等数据:
public class Course{
private String courseTitle; // 课程名称
private String courseIntroduction; // 课程介绍
private String teacherName; // 讲师姓名
private String content; // 文字内容
private String videoUrl; // 视频地址
private String audioUrl; // 音频地址
private String liveUrl; // 直播地址
}
三种新增的课程都在原 Course类中添加了对应的url。也就是每次添加一个新的类型的课程,都在原有 Course类里面修改:新增这种课程需要的数据。
这就导致:我们从 Course类实例化的视频课程对象会包含并不属于自己的数据:audioUrl和liveUrl:这样就造成了冗余,视频课程对象并不是纯粹的视频课程对象,它包含了音频地址,直播地址等成员。
很显然,这个设计不是一个好的设计,因为(对应上面两段叙述):
- 随着需求的增加,需要反复修改之前创建的类。
- 给新增的类造成了不必要的冗余。
之所以会造成上述两个缺陷,是因为该设计 没有遵循对修改关闭,对扩展开放的开闭原则,而是反其道而行之:开放修改,而且不给扩展提供便利。
难么怎么做可以遵循开闭原则呢?下面看一下遵循开闭原则的较好的设计:
较好的设计:
最开始建一个课程接口Course,声明了最初的在线课程所需要包含的数据(课程名称、课程介绍、讲师姓名):
public class Course {
private String courseTitle; // 课程名称
private String courseIntroduction; // 课程介绍
private String getCourseTeacherName; // 讲师姓名
public String getCourseTitle() {
return this.courseTitle;
}
public String getCourseIntroduction() {
return this.courseIntroduction;
}
public String getCourseTeacherName() {
return this.courseTeacherName;
}
}
接着,针对文字课程,视频课程,音频课程,直播课程这三种新型的课程采用继承Course类的方式。而且继承后,添加自己独有的数据:
// 文字课程
public class TextCourse implements Course{
private String content; // 文字内容
public TextCourse(String courseTitle, String courseIntroduction, String courseTeacherName, String content) {
this.courseTitle = courseTitle;
this.courseIntroduction = courseIntroduction;
this.courseTeacherName = courseTeacherName;
this.content = content;
}
public String getContent() {
return this.content;
}
}
// 视频课程
public class VideoCourse extends Course{
private String videoUrl; // 视频地址
public VideoCourse(String courseTitle, String courseIntroduction, String courseTeacherName, String videoUrl) {
this.courseTitle = courseTitle;
this.courseIntroduction = courseIntroduction;
this.courseTeacherName = courseTeacherName;
this.videoUrl = videoUrl;
}
public String getVideoUrl() {
return this.videoUrl;
}
}
// 音频课程
public class AudioCourse implements Course{
private String audioUrl; // 音频地址
public AudioCourse(String courseTitle, String courseIntroduction, String courseTeacherName, String audioUrl) {
this.courseTitle = courseTitle;
this.courseIntroduction = courseIntroduction;
this.courseTeacherName = courseTeacherName;
this.audioUrl = audioUrl;
}
public String getAudioUrl() {
return this.audioUrl;
}
}
// 直播课程
public class LiveCourse implements Course{
private String liveUrl; // 直播地址
public LiveCourse(String courseTitle, String courseIntroduction, String courseTeacherName, String liveUrl) {
this.courseTitle = courseTitle;
this.courseIntroduction = courseIntroduction;
this.courseTeacherName = courseTeacherName;
this.liveUrl = liveUrl;
}
public String getLiveUrl() {
return this.liveUrl;
}
}
这样一来,上面的两个问题都得到了解决:
- 随着课程类型的增加,不需要反复修改最初的父类(Course),只需要新建一个继承于它的子类并在子类中添加仅属于该子类的数据(或行为)即可。
- 因为各种课程独有的数据(或行为)都被分散到了不同的课程子类里,所以每个子类的数据(或行为)没有任何冗余。
而且对于第二点:或许今后的视频课程可以有高清地址,视频加速功能。而这些功能只需要在VideoCourse类里添加即可,因为它们都是视频课程所独有的。同样地,直播课程后面还可以支持在线问答功能,也可以仅加在LiveCourse里面。
我们可以看到,正是由于最初程序设计合理,所以对后面需求的增加才会处理得很好。
下面来看一下这两个设计的UML 类图,可以更形象地看出两种设计上的区别:
在实践了开闭原则的 UML 类图中,四个课程类继承了Course类并添加了自己独有的属性。(在 UML 类图中:实线空心三角箭头代表继承关系:由子类指向其父类)
如何实践:
为了更好地实践开闭原则,在设计之初就要想清楚在该场景里哪些数据(或行为)是一定不变(或很难再改变)的,哪些是很容易变动的。将后者抽象成接口或抽象方法,以便于在将来通过创造具体的实现应对不同的需求。
2. 单一职责原则
定义:A class should have a single responsibility, where a responsibility is nothing but a reason to change.
即:一个类只允许有一个职责,即只有一个导致该类变更的原因。
概述:
- 类职责的变化往往就是导致类变化的原因:也就是说如果一个类具有多种职责,就会有多种导致这个类变化的原因,从而导致这个类的维护变得困难。
- 往往在软件开发中随着需求的不断增加,可能会给原来的类添加一些本来不属于它的一些职责,从而违反了单一职责原则。如果我们发现当前类的职责不仅仅有一个,就应该将本来不属于该类真正的职责分离出去。
- 不仅仅是类,函数(方法)也要遵循单一职责原则,即:一个函数(方法)只做一件事情。如果发现一个函数(方法)里面有不同的任务,则需要将不同的任务以另一个函数(方法)的形式分离出去。
优点:
如果类与方法的职责划分得很清晰,不但可以提高代码的可读性,更实际性地更降低了程序出错的风险,因为清晰的代码会让bug无处藏身,也有利于bug的追踪,也就是降低了程序的维护成本。
代码举例了解:
初始需求:需要创造一个员工类,这个类有员工的一些基本信息。
新需求:增加两个方法:
-
判定员工在今年是否升职
-
计算员工的薪水
先来看一下不好的设计:
不好的设计:
public class Employee{
private String name; // 员工姓名
private String address; // 员工住址
private String employeeID; // 员工ID
public double calculateSalary(); // 计算薪水
public boolean willPromotionThisYear(); // 今年是否晋升
}
由上面的代码可以看出:
- 在初始需求下,我们创建了 Employee这个员工类,并声明了3个员工信息的属性:员工姓名,地址,员工ID。
- 在新需求下,两个方法直接加到了员工类里面。
新需求的做法看似没有问题,因为都是和员工有关的,但却违反了单一职责原则:因为这两个方法并不是员工本身的职责。
- calculateSalary() 这个方法的职责是属于会计部门的:薪水的计算是会计部门负责。
- willPromotionThisYear() 这个方法的职责是属于人事部门的:考核与晋升机制是人事部门负责。
而上面的设计将本来不属于员工自己的职责强加进了员工类里面,而这个类的设计初衷(原始职责)就是单纯地保留员工的一些信息而已。因此这么做就是给这个类引入了新的职责,故此设计违反了单一职责原则。
我们可以简单想象一下这么做的后果是什么:如果员工的晋升机制变了,或者税收政策等影响员工工资的因素变了,我们还需要修改当前这个类。
那么怎么做才能不违反单一职责原则呢?- 我们需要将这两个方法(责任)分离出去,让本应该处理这类任务的类来处理。
较好的设计
我们保留员工类的基本信息:
public class Employee{
private String name; // 员工姓名
private String address; // 员工住址
private String employeeID; // 员工ID
}
接着创建新的会计部门类:
public class FinancialApartment {
public double calculateSalary(Employee employee); // 计算薪水
}
和人事部门类:
public class HRApartment {
public boolean willPromotionThisYear(Employee employee); // 今年是否晋升
}
通过创建了两个分别专门处理薪水和晋升的部门,会计部门和人事部门的类:FinancialApartment 和 HRApartment,把两个任务(责任)分离了出去,让本该处理这些职责的类来处理这些职责。
这样一来,不仅仅在此次新需求中满足了单一职责原则,以后如果还要增加人事部门和会计部门处理的任务,就可以直接在这两个类里面添加即可。
下面来看一下这两个设计的UML 类图,可以更形象地看出两种设计上的区别:
UML 类图对比
可以看到,在实践了单一职责原则的 UML 类图中,不属于Employee的两个职责被分类了FinancialApartment类 和 HRApartment类。(在 UML 类图中,虚线箭头表示依赖关系,常用在方法参数等,由依赖方指向被依赖方)
上面说过除了类要遵循单一职责设计原则之外,在函数(方法)的设计上也要遵循单一职责的设计原则。因函数(方法)的单一职责原则理解起来比较容易,故在这里就不提供Demo和UML 类图了。
可以简单举一个例子:
APP的默认导航栏的样式是这样的:
- 白色底
- 黑色标题
- 底部有阴影
那么创建默认导航栏的伪代码可能是这样子的:
public void createDefaultNavigationBarWithTitle(String title){
//create white color background view
//create black color title
//create shadow bottom
}
现在我们可以用这个方法统一创建默认的导航栏了。
但是过不久又有新的需求来了,有的页面的导航栏需要做成透明的,因此需要一个透明样式的导航栏:
- 透明底
- 白色标题
- 底部无阴影
针对这个需求,我们可以新增一个方法:
public void createTransParentNavigationBarWithTitle(String title){
//create transparent color background view
//create white color title
}
看出问题来了么?在这两个方法里面,创造background view和 title color title的方法的差别仅仅是颜色不同而已,而其他部分的代码是重复的。
因此我们应该将这两个方法抽出来:
//根据传入的颜色参数设置导航栏的背景色
public void createBackgroundViewWithColor(String color){...}
//根据传入的标题字符串和颜色参数设置标题
public void createTitlewWithColorWithTitle(String title, String color){...}
而且上面的制造阴影的部分也可以作为方法抽出来:
public void createShadowBottom(){...}
这样一来,原来的两个方法可以写成:
public void createDefaultNavigationBarWithTitle(String title){
// 设置白色背景
createBackgroundViewWithColor(String whiteColor);
// 设置黑色标题
createTitlewWithColorWithTitle(String title, String blackColor);
// 设置底部阴影
createShadowBottom();
}
而且我们也可以将里面的方法拿出来在外面调用也可以:
设置默认样式的导航栏:
// 设置白色背景
navigationBar.createBackgroundViewWithColor(String whiteColor);
// 设置黑色标题
navigationBar.createTitlewWithColorWithTitle(String title, String blackColor);
// 设置底部阴影
navigationBar.createShadowBottom();
设置透明样式的导航栏:
// 设置透明色背景
navigationBar.createBackgroundViewWithColor(String clearColor);
// 设置白色标题
navigationBar.createTitlewWithColorWithTitle(String title, String whiteColor);
这样一来,无论写在一个大方法里面调用或是分别在外面调用,都能很清楚地看到导航栏的每个元素是如何生成的,因为每个职责都分配到了一个单独的方法里面。而且还有一个好处是,透明导航栏如果遇到浅色背景的话,使用白色字体不如使用黑色字体好,所以遇到这种情况我们可以在createTitlewWithColorWithTitle:color:方法里面传入黑色色值。
而且今后可能还会有更多的导航栏样式,那么我们只需要分别改变传入的色值即可,不需要有大量的重复代码了,修改起来也很方便。
如何实践:
对于上面的员工类的例子,或许是因为我们先入为主,知道一个公司的合理组织架构,觉得这么设计理所当然。但是在实际开发中,我们很容易会将不同的责任揉在一起,这点还是需要开发者注意的。
3. 依赖倒置原则
定义:
- Depend upon Abstractions. Do not depend upon concretions.
- Abstractions should not depend upon details. Details should depend upon abstractions
- High-level modules should not depend on low-level modules. Both should depend on abstractions.
即:
- 依赖抽象,而不是依赖实现。
- 抽象不应该依赖细节;细节应该依赖抽象。
- 高层模块不能依赖低层模块,二者都应该依赖抽象。
概述:
- 针对接口编程,而不是针对实现编程。
- 尽量不要从具体的类派生,而是以继承抽象类或实现接口来实现。
- 关于高层模块与低层模块的划分可以按照决策能力的高低进行划分。业务层自然就处于上层模块,逻辑层和数据层自然就归类为底层。
优点:
通过抽象来搭建框架,建立类和类的关联,以减少类间的耦合性。而且以抽象搭建的系统要比以具体实现搭建的系统更加稳定,扩展性更高,提高代码的可读性和可维护性,并能够降低修改程序所造成的风险。
代码举例了解:
实现下面这样的需求:
用代码模拟一个实际项目开发的场景:前端和后端开发人员开发同一个项目。
不好的设计:
首先生成两个类,分别对应前端和后端开发者:
前端开发者:
public class FrondEndDeveloper{
public void writeJavaScriptCode(){
System.out.println("Write JavaScript code");
}
}
后端开发者:
public class BackEndDeveloper{
public void writeJavaCode(){
System.out.println("Write Java code");
}
}
这两个开发者分别对外提供了自己开发的方法:writeJavaScriptCode和writeJavaCode。
接着创建一个Project类:
public class Project{
private String[] _developers;
public Project(String[] developers){
this._developers=developers;
}
public void startDeveloping(){
for(String developer:_developers){
if(developer.class.getSimpleName()=="FrondEndDeveloper"){
developer.writeJavaScriptCode();
}else if(developer.class.getSimpleName()=="BackEndDeveloper"){
developer.writeJavaCode();
}else{
//no such developer
}
}
}
}
在Project类中,我们首先通过一个构造方法,将开发者的数组传入project的实例对象。然后在开始开发的方法 startDeveloping里面,遍历数组并判断元素类型的方式让不同类型的开发者调用和自己对应的函数。
思考一下,这样的设计有什么问题?
问题一:
假如后台的开发语言改成了GO语言,那么上述代码需要改动两个地方:
- BackEndDeveloper:需要向外提供一个writeGolangCode方法。
- Project类的startDeveloping方法里面需要将BackEndDeveloper类的writeJavaCode改成writeGolangCode。
问题二:
假如后期老板要求做移动端的APP(需要iOS和安卓的开发者),那么上述代码仍然需要改动两个地方:
- 还需要给Project类的构造器方法里面传入IOSDeveloper和AndroidDeveloper两个类。而且按照现有的设计,还要分别向外部提供writeSwiftCode和writeKotlinCode。
- Project类的startDeveloping方法里面需要再多两个elseif判断,专门判断IOSDeveloper和AndroidDeveloper这两个类。
开发安卓的代码也可以用Java,但是为了和后台的开发代码区分一下,这里用了同样可以开发安卓的Kotlin语言。
很显然,在这两种假设的场景下,高层模块(Project)都依赖了低层模块(BackEndDeveloper)的改动,因此上述设计不符合依赖倒置原则。
那么该如何设计才可以符合依赖倒置原则呢?
答案是将开发者写代码的方法抽象出来,让Project类不再依赖所有低层的开发者类的具体实现,而是依赖抽象。而且从下至上,所有底层的开发者类也都依赖这个抽象,通过实现这个抽象来做自己的任务。
这个抽象可以用接口,也可以用抽象类的方式来做,在这里使用接口的方式进行讲解:
较好的设计:
首先,创建一个接口,接口里面有一个写代码的方法writeCode:
public interface DeveloperProtocol{
public void writeCode();
}
然后,让前端程序员和后端程序员类实现这个接口(遵循这个协议)并按照自己的方式实现:
前端程序员类:
public class FrondEndDeveloper implements DeveloperProtocol{
public void writeCode(){
System.out.println("Write JavaScript code");
}
}
后端程序员:
public class BackEndDeveloper implements DeveloperProtocol{
public void writeCode(){
System.out.println("Write Java code");
}
}
最后我们看一下新设计后的Project类:
public class Project{
private String[] _developers;
public Project(String[] developers){
this._developers=developers;
}
public void startDeveloping(){
for(String developer:_developers){
developer.writeCode();
}
}
}
新的Project的构造方法只需传入遵循DeveloperProtocol协议的对象构成的数组即可。这样也比较符合现实中的需求:只需要会写代码就可以加入到项目中。
而新的startDeveloping方法里:每次循环,直接向当前对象发送writeCode方法即可,不需要对程序员的类型做判断。因为这个对象一定是遵循DeveloperProtocol接口的,而遵循该接口的对象一定会实现writeCode方法(就算不实现也不会引起重大错误)。
现在新的设计接受完了,我们通过上面假设的两个情况来和之前的设计做个对比:
假设1:后台的开发语言改成了GO语言
在这种情况下,只需更改BackEndDeveloper类里面对于DeveloperProtocol接口的writeCode方法的实现即可:
public class BackEndDeveloper implements DeveloperProtocol{
public void writeCode(){
// 原本:
// System.out.println("Write Java code");
// 修改后:
System.out.println("Write Golang code");
}
}
而在Project里面不需要修改任何代码,因为Project类只依赖了接口方法WriteCode,没有依赖其具体的实现。
我们接着看一下第二个假设:
假设2:后期老板要求做移动端的APP(需要iOS和安卓的开发者)
在这个新场景下,我们只需要将新创建的两个开发者类:IOSDeveloper和AndroidDeveloper分别实现DeveloperProtocol接口的writeCode方法即可。
同样,Project的接口和实现代码都不用修改:客户端只需要在Project的构建方法的数组参数里面添加这两个新类的实例即可,不需要在startDeveloping方法里面添加类型判断,原因同上。
我们可以看到,新设计很好地在高层类(Project)与低层类(各种developer类)中间加了一层抽象,解除了二者在旧设计中的耦合,使得在低层类中的改动没有影响到高层类。
同样是抽象,新设计同样也可以用抽象类的方式:创建一个Developer的抽象类并提供一个writeCode方法,让不同的开发者类继承与它并按照自己的方式实现writeCode方法。这样一来,在Project类的构造方法就是传入已Developer类型为元素的数组了。有兴趣的小伙伴可以自己实现一下~
下面来看一下这两个设计的UML 类图,可以更形象地看出两种设计上的区别:
UML 类图对比:
在实践了依赖倒置原则的 UML 类图中,我们可以看到Project仅仅依赖于新的接口;而且低层的FrondEndDevelope和BackEndDevelope类按照自己的方式实现了这个接口:通过接口解除了原有的依赖。(在 UML 类图中,虚线三角箭头表示接口实线,由实现方指向接口)
如何实践:
今后在处理高低层模块(类)交互的情景时,尽量将二者的依赖通过抽象的方式解除掉,实现方式可以是通过接口也可以是抽象类的方式。
4. 接口分离原则
定义:Many client specific interfaces are better than one general purpose interface.
即:多个特定的客户端接口要好于一个通用性的总接口。
指用多个专门的接口,而不使用单一的总接口,客户端不应该依赖它不需要的接口。
概述:
- 客户端不应该依赖它不需要实现的接口。
- 一个类对一类的依赖应该建立在最小的接口之上。
- 建立单一接口,不要建立庞大臃肿的接口,应尽量细化接口,接口中的方法应该尽量少(不是越少越好,一定要适度)。
需要注意的是:接口的粒度也不能太小。如果过小,则会造成接口数量过多,使设计复杂化。
接口隔离原则 符合 高内聚低耦合 的设计思想,从而使得类具有很好的可读性、可扩展性和可维护性。我们在设计接口的时候,要多花时间去思考,要考虑业务模型,包括以后有可能发生变更的地方还要做一些预判。
所以,对于抽象,对业务模型的理解是非常重要的。
代码举例了解:
现在的餐厅除了提供传统的店内服务,多数也都支持网上下单,网上支付功能。写一些接口方法来涵盖餐厅的所有的下单及支付功能。
不好的设计:
public interface RestaurantProtocol{
public void placeOnlineOrder(); //下订单:online
public void placeTelephoneOrder(); //下订单:通过电话
public void placeWalkInCustomerOrder(); //下订单:在店里
public void payOnline(); //支付订单:online
public void payInPerson(); //支付订单:在店里支付
}
在这里声明了一个接口,它包含了下单和支付的几种方式:
下单:
- online下单
- 电话下单
- 店里下单(店内服务)
支付
- online支付(适用于online下单和电话下单的顾客)
- 店里支付(店内服务)
这里先不讨论电话下单的顾客是用online支付还是店内支付。
对应的,我们有三种下单方式的顾客:
1.online下单,online支付的顾客
public class OnlineClient implements RestaurantProtocol{
public void placeOnlineOrder{
System.out.println("place on line order");
}
public void placeTelephoneOrder{
//not necessarily
}
public void placeWalkInCustomerOrder{
//not necessarily
}
public void payOnline{
System.out.println("pay on line");
}
public void payInPerson{
//not necessarily
}
}
2.电话下单,online支付的顾客
public class TelephoneClient implements RestaurantProtocol{
public void placeOnlineOrder{
//not necessarily
}
public void placeTelephoneOrder{
System.out.println("pay telephone order");
}
public void placeWalkInCustomerOrder{
//not necessarily
}
public void payOnline{
System.out.println("pay on line");
}
public void payInPerson{
//not necessarily
}
}
3.在店里下单并支付的顾客:
public class WalkinClient implements RestaurantProtocol{
public void placeOnlineOrder{
//not necessarily
}
public void placeTelephoneOrder{
//not necessarily
}
public void placeWalkInCustomerOrder{
System.out.println("place walk in customer order");
}
public void payOnline{
//not necessarily
}
public void payInPerson{
System.out.println("pay on person");
}
}
我们发现,并不是所有顾客都必须要实现RestaurantProtocol里面的所有方法。由于接口方法的设计造成了冗余,因此该设计不符合接口隔离原则。
那么如何做才符合接口隔离原则呢?我们来看一下较好的设计。
较好的设计:
要符合接口隔离原则,只需要将不同类型的接口分离出来即可。我们将原来的RestaurantProtocol接口拆分成两个接口:下单接口和支付接口。
下单接口:
public interface RestaurantPlaceOrderProtocol{
public void placeOrder();
}
支付接口:
public interface RestaurantPaymentProtocol {
public void payOrder();
}
现在有了下单接口和支付接口,我们就可以让不同的客户来以自己的方式实现下单和支付操作了:
首先创建一个所有客户的父类,来遵循这个两个接口:
public class Client implements RestaurantPlaceOrderProtocol,RestaurantPaymentProtocol{
}
接着另online下单,电话下单,店内下单的顾客继承这个父类,分别实现这两个接口的方法:
1.online下单,online支付的顾客
public class OnlineClient extend Client{
public void placeOrder(){
System.out.println("place on line order");
}
public void payOrder(){
System.out.println("pay on line");
}
}
2.电话下单,online支付的顾客
public class TelephoneClient extend Client{
public void placeOrder(){
System.out.println("place telephone order");
}
public void payOrder(){
System.out.println("pay on line");
}
}
3.在店里下单并支付顾客:
public class WalkinClient extend Client{
public void placeOrder(){
System.out.println("place walk in customer order");
}
public void payOrder(){
System.out.println("pay on person");
}
}
因为我们把不同职责的接口拆开,使得接口的责任更加清晰,简洁明了。不同的客户端可以根据自己的需求遵循所需要的接口来以自己的方式实现。
而且今后如果还有和下单或者支付相关的方法,也可以分别加入到各自的接口中,避免了接口的臃肿,同时也提高了程序的内聚性。
下面来看一下这两个设计的UML 类图,可以更形象地看出两种设计上的区别:
UML 类图对比:
通过遵守接口分离原则,接口的设计变得更加简洁,而且各种客户类不需要实现自己不需要实现的接口。
如何实践:
在设计接口时,尤其是在向现有的接口添加方法时,我们需要仔细斟酌这些方法是否是处理同一类任务的:如果是则可以放在一起;如果不是则需要做拆分。
5. 迪米特法则 (最少知道原则)
定义:You only ask for objects which you directly need.
即:一个对象应该对尽可能少的对象有接触,也就是只接触那些真正需要接触的对象。
指一个对象应该对其他对象保持最少的了解,又叫最少知道原则(Least Knowledge Principle,LKP),尽量降低类与类之间的耦合。
概述:
迪米特法则也叫做最少知道原则(Least Know Principle), 一个类应该只和它的成员变量,方法的输入,返回参数中的类作交流,而不应该引入其他的类(间接交流)。
迪米特原则主要强调只和朋友交流,不和陌生人说话。出现在成员变量、方法的输入、输出参数中的类都可以称之为成员朋友类,而出现在方法体内部的类不属于朋友类。
优点:
实践迪米特法则可以良好地降低类与类之间的耦合,减少类与类之间的关联程度,让类与类之间的协作更加直接。
代码举例了解:
设计一个汽车类,包含汽车的品牌名称,引擎等成员变量。提供一个方法返回引擎的品牌名称。
不好的设计:
Car类:
public class Car {
private GasEngine engine;
public Car(GasEngine engine){
this.engine=engine;
}
public GasEngine usingEngineBrandName() {
return this.engine;
}
}
从上面可以看出,Car的构造方法需要传入一个引擎的实例对象。而且因为引擎的实例对象被赋到了Car对象的私有成员变量里面。所以Car类给外部提供了一个返回引擎对象的方法:GetEngine。
而这个引擎类GasEngine有一个品牌名称的成员变量brandName:
public class GasEngine {
private String brandName;
public GasEngine(String brandName){
this.brandName=brandName;
}
public String getBrandName() {
return brandName;
}
}
这样一来,客户端就可以拿到引擎的品牌名称了:
public String findCarEngineBrandName(Car car) {
GasEngine engine=car.GetEngine();
String engineBrandName=engine.getBrandName(); //获取到了引擎的品牌名称
return engineBrandName;
}
上面的设计完成了需求,但是却违反了迪米特法则。原因是在客户端的findCarEngineBrandName:中引入了和入参(Car)和返回值(NSString)无关的GasEngine对象。增加了客户端与
GasEngine的耦合。而这个耦合显然是不必要更是可以避免的。
接下来我们看一下如何设计可以避免这种耦合:
较好的设计:
同样是Car这个类,我们去掉原有的返回引擎对象的方法,而是增加一个直接返回引擎品牌名称的方法:
public class Car {
private GasEngine engine;
public Car(GasEngine engine){
this.engine=engine;
}
//public GasEngine GetEngine() {
// return this.engine;
//}
public String usingEngineBrandName() {
return this.engine.getBrandName();
}
}
因为 GetEngineBrandName方法直接返回了引擎的品牌名称,所以在客户端里面就可以直接拿到这个值,而不需要间接地通过原来的GasEngine实例来获取。
我们看一下客户端操作的变化:
public String findCarEngineBrandName(Car car) {
String engineBrandName=car.usingEngineBrandName(); // 直接获取到了引擎的品牌名称
return engineBrandName;
}
与之前的设计不同,在客户端里面,没有引入GasEngine类,而是直接通过Car实例获取到了需要的数据。
这样设计的好处是,如果这辆车的引擎换成了电动引擎(原来的GasEngine类换成了ElectricEngine类),客户端代码可以不做任何修改!因为它没有引入任何引擎类,而是直接获取了引擎的品牌名称。
所以在这种情况下我们只需要修改Car类的usingEngineBrandName方法实现,将新引擎的品牌名称返回即可。
下面来看一下这两个设计的UML 类图,可以更形象地看出两种设计上的区别:
UML 类图对比:
如何实践:
今后在做对象与对象之间交互的设计时,应该极力避免引出中间对象的情况(需要导入其他对象的类):需要什么对象直接返回即可,降低类之间的耦合度。
6. 里氏替换原则
定义:In a computer program, if S is a subtype of T, then objects of type T may be replaced with objects of type S (i.e. an object of type T may be substituted with any object of a subtype S) without altering any of the desirable properties of the program (correctness, task performed, etc.)
即:所有引用基类的地方必须能透明地使用其子类的对象,也就是说子类对象可以替换其父类对象,而程序执行效果不变。
概述:
在继承体系中,子类中可以增加自己特有的方法,也可以实现父类的抽象方法,但是不能重写父类的非抽象方法,否则该继承关系就不是一个正确的继承关系
抽象理解一下:
一个软件实体如果适用一个父类的话,那一定是适用于其子类,所有引用父类的地方必须能透明地使用其子类的对象,子类对象能够替换父类对象,而程序逻辑不变。
根据这个理解,我们总结一下:
引申含义:子类可以扩展父类的功能,但不能改变父类原有的功能。
- 子类可以实现父类的抽象方法,但不能覆盖父类的非抽象方法。
- 子类中可以增加自己特有的方法。
- 当子类的方法重载父类的方法时,方法的前置条件(即方法的输入/入参)要比父类方法的输入参数更宽松。
- 当子类的方法实现父类的方法时(重写/重载或实现抽象方法),方法的后置条件(即 方法的输出/返回值)要比父类更严格或相等。
优点:
- 约束继承泛滥,开闭原则的一种体现。
- 加强程序的健壮性,同时变更时也可以做到非常好的兼容性,提高程序的维护性、扩 展性。降低需求变更时引入的风险。
代码举例了解:
现在来描述一个经典的业务场景,用正方形、矩形和四边形的关系说明里氏替换原则,我们都知道正方形是一个特殊的长方形,那么就可以创建一个长方形父类Rectangle 类:
public class Rectangle {
private long height;
private long width;
@Override
public long getWidth() {
return width;
}
@Override
public long getLength() {
return length;
}
public void setLength(long length) {
this.length = length;
}
public void setWidth(long width) {
this.width = width;
}
}
创建正方形 Square 类继承长方形:
public class Square extends Rectangle {
private long length;
public long getLength() {
return length;
}
public void setLength(long length) {
this.length = length;
}
@Override
public long getWidth() {
return getLength();
}
@Override
public long getHeight() {
return getLength();
}
@Override
public void setHeight(long height) {
setLength(height);
}
@Override
public void setWidth(long width) {
setLength(width);
}
}
在测试类中创建 resize()方法,根据逻辑长方形的宽应该大于等于高,我们让高一直自增, 直到高等于宽变成正方形:
public static void resize(Rectangle rectangle){
while (rectangle.getWidth() >= rectangle.getHeight()){
rectangle.setHeight(rectangle.getHeight() + 1);
System.out.println("width:"+rectangle.getWidth() + ",height:"+rectangle.getHeight());
}
System.out.println("resize 方法结束" +"\nwidth:"+rectangle.getWidth() + ",height:"+rectangle.getHeight());
}
测试代码:
public static void main(String[] args) {
Rectangle rectangle = new Rectangle();
rectangle.setWidth(20);
rectangle.setHeight(10);
resize(rectangle);
}
运行结果:
发现高比宽还大了,在长方形中是一种非常正常的情况。现在我们再来看下面的代码,把长方形 Rectangle 替换成它的子类正方形 Square,修改测试代码:
public static void main(String[] args) {
Square square = new Square();
square.setLength(10);
resize(square);
}
这时候我们运行的时候就出现了死循环,违背了里氏替换原则,将父类替换为子类后,程序运行结果没有达到预期。因此,我们的代码设计是存在一定风险的。里氏替换原则只存在父类与子类之间,约束继承泛滥。
既然正方形不能继承于长方形,那么是否可以让二者都继承于其他的父类呢?答案是可以的。
既然要继承于其他的父类,它们这个父类肯定具备这两种形状共同的特点:有4个边。那么我们创建一个基于长方形与正方形共同的抽象四边形 Quadrangle 接口:
public interface Quadrangle {
long getWidth();
long getHeight();
}
修改长方形 Rectangle 类:
public class Rectangle implements Quadrangle {
private long height;
private long width;
@Override
public long getWidth() {
return width;
}
public long getHeight() {
return height;
}
public void setHeight(long height) {
this.height = height;
}
public void setWidth(long width) {
this.width = width;
}
}
修改正方形类 Square 类:
public class Square implements Quadrangle {
private long length;
public long getLength() {
return length;
}
public void setLength(long length) {
this.length = length;
}
@Override
public long getWidth() {
return length;
}
@Override
public long getHeight() {
return length;
}
}
此时,如果我们把 resize()方法的参数换成四边形 Quadrangle 类,方法内部就会报错。因为正方形 Square 已经没有了 setWidth()和setHeight()方法了。因此,为了约束继承 泛滥,resize()的方法参数只能用 Rectangle 长方形。
如何实践:
里氏替换原则是对继承关系的一种检验:检验是否真正符合继承关系,以避免继承的滥用。因此,在使用继承之前,需要反复思考和确认该继承关系是否正确,或者当前的继承体系是否还可以支持后续的需求变更,如果无法支持,则需要及时重构,采用更好的方式来设计程序。
7. 合成复合原则
合成复用原则(Composite/Aggregate Reuse Principle,CARP):指尽量使用对象组合(has-a)/聚合(contanis-a),而不是继承关系达到软件复用的目的。可以使系统更加灵活,降低类与类之间的耦合度,一个类的变化对其他类造成的影响相对较少。
-
继承:白箱复用,相当于把所有的实现细节暴露给子类。
-
组合/聚合:黑箱
-
复用:对类以外的对象是无法获取到实现细节的。要根据具体的业务场景来做代码设计,其实也都需要遵循 OOP 模型。
这篇博客的内容大部分知识点是参照这篇文章:http://www.cocoachina.com/articles/24916
而我的代码举例用java改写,可能会有一些问题。