概述
设计模式六大原则之--开闭原则(OCP)
- 前言
- 1 描述
- 2 理解:
- 3 问题由来:
- 4 使用LoD的好处:
- 5 难点:
- 6 最佳实践:
- 7 范例:
前言
The Open - Closed Principle, OCP
软件实体(类、模块、方法等)应该对扩展开放,但是对修改关闭。
优点
1.保持软件产品的稳定性
开闭原则要求我们通过保持原有代码不变,添加新代码来实现软件的变化,因为不涉及原代码的改动,这样可以避免为实现新功能而改坏线上功能的情况,避免老用户的流失。
2.不影响原有测试代码的运行
软件开发规范性好的团队都会写单元测试,如果原有的某个功能发生了变化,则单元测试代码也应做相应的变更,否则就有可能导致测试出错。如果每次软件的变化,除了变更功能代码之外,还得变更测试代码,书写测试代码同样需要消耗工时,这样在项目中引入单元测试就成了累赘。开闭原则可以让单元测试充分发挥作用而又不会成为后期软件开发的累赘。
3.使代码更具模块化,易于维护
开闭原则可以让代码中的各功能,以及新旧功能独立存在于不同的单元模块中,一旦某个功能出现问题,可以很快地锁定代码位置作出修改,由于模块间代码独立不相互调用,更改一个功能的代码也不会引起其他功能的崩溃。
4.提高开发效率
在项目开发过程中,有时候阅读前人的代码是件很头疼的事,尤其项目开发周期比较长,可能三五年,再加上公司人员流动性大,原有代码的开发人员早就另谋高就,而代码写的更是一团糟,自带混淆,能走弯路不走直路。而现在需要在原有功能的基础上开发新功能,如果开闭原则使用得当的话,我们是不需要看懂原有代码实现细节便可以添加新代码实现新功能,毕竟有时候阅读一个功能的代码,比自己重新实现这个功能用的时间还要长。
1 描述
遵循开放-关闭原则设计出来的模块具有两个主要的特征。
“对扩展开放”(Open for extension)
这意味着模块的行为是可以扩展的。 当应用的需求改变是,我们可以对模块扩展,让它可以具备满足那些改变的新行为。换句话说,我们可以改变模块的功能。
“对更改关闭”(Closed for modification)
对模块的行为进行扩展时,不必改变模块的源代码或者二进制代码。像模块的二进制可执行版本,无论是可连接的库、DLL或者Java的.jar文件,都无须改变。
这两个特征看似矛盾。扩展模块的行为的常用方式是修改模块的源代码,不允许修改的模块通常都被认为具有固定的行为。
怎么可能在不改变模块源代码的情况下更改模块的行为呢?怎样才能在无需改变模块的情况下就改变他的功能呢?
关键是抽象
在C++、Java或者其他任何OOPL面向对象编程语言中,可以创建出稳固却能够描述无限种可能行为的抽象。这种抽象就是抽象类,而无限种可能行为则是所有可能的派生类。
2 理解:
2.1 软件实体应该对扩展开放,对修改关闭,其含义是说一个软件实体应该通过扩展来实现变化,而不是通过修改已有的代码来实现变化。
软件实体包括以下几个部分:
项目或软件产品中按照一定的逻辑规则划分的模块;
抽象和类;
方法。
2.2 修改:
可以分为两个层次来分析。一个层次是对抽象定义的修改,如对象公开的接口,包括方法的名称、参数与返回类型。
***我们必须保证一个接口,尤其要保证被其他对象调用的接口的稳定;否则,就会导致修改蔓延,牵一发而动全身。从某种程度上讲,接口就是标准,要保障接口的稳定,就应该对对象进行合理的封装。一般的设计原则之所以强调方法参数尽量避免基本类型,原因正在于此。***比较如下两个方法定义:
1. //定义1
2. bool Connect(string userName, string password, string ftpAddress, int port);
3. //定义2
4. bool Connect(Account account);
5. public class Account
6. {
7. public string UserName { get; set; }
8. public string Password { get; set; }
9. public string FtpAddress { get; set; }
10. public string int Port { get; set; }
11. }
相比较前者,后者虽然多了一个Account类的定义,但Connect()方法却明显更加稳定。倘若需要为Connect()方法提供一个Ftp服务器的主目录名,定义1必须修改该方法的接口,对应的,所有调用Connect()方法的对象都会受到影响;而定义2只需要修改Account类,由于Connect()方法的接口保持不变,只要Connect()方法的调用者并不需要主目录名,这样的修改就完全不会影响调用者。即使需要主目录名,我们也可以在Account类的构造函数中为主目录名提供默认的实现,从而降低需求变化带来的影响。我认为,这样的设计对修改就是封闭的。定义2 良好!
另一个层次是指对具体实现的修改。"对修改封闭"是开放封闭原则的两个要素之一。原则上,要做到避免对源代码的修改,即使仅修改具体实现,也需要慎之又慎。这是因为具体实现的修改,可能会给调用者带来意想不到的结果,这一结果并非我们预期的,甚至可能与预期相反。如果确实需要修改具体的实现,就需要做好达到测试覆盖率要求的单元测试。根据我的经验,设计要做到完全对修改封闭,几乎是不可能完成的任务。我们只能尽量将代码修改的影响降到最低,其核心指导原则就是封装与充分的测试。
2.3 扩展
“对扩展开放"的关键是"抽象”,而对象的多态则保证了这种扩展的开放性。开放原则首先意味着我们可以自由地增加功能,而不会影响原有系统。这就要求我们能够通过继承完成功能的扩展。其次,开放原则还意味着实现是可替换的。只有利用抽象,才可以为定义提供不同的实现,然后根据不同的需求实例化不同的实现子类。例如排序算法的调用,对照图1与图2之间的区别。
2.4 开放封闭原则还可以统一起来理解。
由于我们对扩展实现了开放,才能够保证对修改是封闭的。开放利用了对象的抽象,封闭则在一定程度上利用了封装。最佳的做法仍然是要做到分离对象的变与不变,将对象不变的部分封装起来,并遵循良好的设计原则以保障接口的稳定;至于对象中可能变的部分,则需要进行抽象,以建立松散的耦合关系。
回忆前面的5个原则,OCP恰恰告诉我们:用抽象构建框架,用实现扩展细节的注意事项而已:
单一职责原则告诉我们实现类要职责单一;
里氏替换原则告诉我们不要破坏继承体系;
依赖倒置原则告诉我们要面向接口编程;
接口隔离原则告诉我们要在设计接口时要精简单一;
迪米特法则告诉我们要降低耦合。
而开闭原则是总纲,他告诉我们要对扩展开放,对修改关闭。
3 问题由来:
在软件的生命周期内,因为变化、升级和维护等原因需要对软件原有代码进行修改时,可能会给旧代码中引入错误,也可能会使我们不得不对整个功能进行重构,并且需要原有代码经过重新测试。[解决方案]当软件需要变化时,尽量通过扩展软件实体的行为来实现变化,而不是通过修改已有的代码来实现变化。
4 使用LoD的好处:
使单元测试也能够OCP;
帮助缩小逻辑粒度,以提高可复用性;
可以使维护人员只扩展一个类,而非修改一个类,从而提高可维护性;
在设计之初考虑所有可能变化的因素,留下接口,从而符合面向对象开发的要求;
5 难点:
如何遵循抽象约束:
a) 通过接口或抽象类约束扩展,对扩展进行边界限定,不允许出现在接口或抽象类中的不存在的public方法;
b) 参数类型、引用对象尽量使用接口或者抽象类,而不是实现类;
c) 抽象层尽量保持稳定,一旦确定即不允许修改。
封装变化:
a) 将相同的变化封装到一个接口或抽象类中;
b) 将不同的变化封装到不同的接口或抽象类中,不应该有两个不同的变化出现在同一接口或抽象类中。(23设计模式也是从各个不同的角度对变化进行封装的)
6 最佳实践:
封装变化:按可能变化的不同去封装变化;
抽象约束:抽象层尽量保持稳定,一旦确定即不允许修改。
7 范例:
7.1扩展实现(书店售书例,下为其类图)
代码清单如下:
public interface IBook {
//书籍有名称
public String getName();
//书籍有售价
public int getPrice();
//书籍有作者
public String getAuthor();
}
小说书籍的源代码如下:
public class NovelBook implements IBook {
//书籍名称
private String name;
//书籍的价格
private int price;
//书籍的作者
private String author;
//通过构造函数传递书籍数据
public NovelBook(String _name,int _price,String _author){
this.name = _name;
this.price = _price;
this.author = _author;
}
//获得作者是谁
public String getAuthor() {
return this.author;
}
//书籍叫什么名字
public String getName() {
return this.name;
}
//获得书籍的价格
public int getPrice() {
return this.price;
}
}
//售书
public class BookStore {
private final static ArrayList<IBook> bookList = new ArrayList<IBook>();
//静态模块初始化,项目中一般是从持久层初始化产
static{
bookList.add(new NovelBook("天龙八部",3200,"金庸"));
bookList.add(new NovelBook("巴黎圣母院",5600,"雨果"));
bookList.add(new NovelBook("悲惨世界",3500,"雨果"));
bookList.add(new NovelBook("金瓶梅",4300,"兰陵笑笑生"));
}
//模拟书店买书
public static void main(String[] args) {
NumberFormat formatter = NumberFormat.getCurrencyInstance();
formatter.setMaximumFractionDigits(2);
System.out.println("------------书店买出去的书籍记录如下:---------------------");
for(IBook book:bookList){
System.out.println("书籍名称:" + book.getName()+"t书籍作者:" +
book.getAuthor()+ "t书籍价格:" + formatter.format(book.getPrice()/100.0)+"元");
}
}
}
项目投产,书店盈利,但为扩大市场,书店决定,40元以上打9折,40元以下打8 折。如何解决这个问题呢?
修改接口。在IBook上新增加一个方法getOffPrice(),专门进行打折,所有实现类实现这个方法。但是这样修改的后果就是实现类NovelBook要修改,BookStore中的main方法也修改,同时Ibook作为接口应该是稳定且可靠的,不应该经常发生变化,否则接口做为契约的作用就失去了效能,因此该方案被否定。
修改实现类。修改NovelBook 类中的方法,直接在getPrice()中实现打折处理,这个应该是大家在项目中经常使用的就是这样办法,通过class文件替换的方式可以完成部分业务(或是缺陷修复)变化,该方法在项目有明确的章程(团队内约束)或优良的架构设计时,是一个非常优秀的方法,但是该方法还是有缺陷的,例如采购书籍人员也是要看价格的,由于该方法已经实现了打折处理价格,因此采购人员看到的也是打折后的价格,这就产生了信息的蒙蔽效果,导致信息不对称而出现决策失误的情况。该方案也不是一个最优的方案。
通过扩展实现变化。增加一个子类 OffNovelBook,覆写getPrice方法,高层次的模块(也就是static静态模块区)通过OffNovelBook类产生新的对象,完成对业务变化开发任务。好办法,风险也小,我们来看类图:
OffNovelBook类继承了NovelBook,并覆写了getPrice方法,不修改原有的代码。我们来看看新增加的子类OffNovelBook:
public class OffNovelBook extends NovelBook {
public OffNovelBook(String _name,int _price,String _author){
super(_name,_price,_author);
}
//覆写销售价格
@Override
public int getPrice(){
//原价
int selfPrice = super.getPrice();
int offPrice=0;
if(selfPrice>4000){ //原价大于40元,则打9折
offPrice = selfPrice * 90 /100;
}else{
offPrice = selfPrice * 80 /100;
}
return offPrice;
}
}
很简单,仅仅覆写了getPrice方法,通过扩展完成了新增加的业务。 然后我们来看BookStore类的修改:
public class BookStore {
private final static ArrayList<IBook> bookList = new ArrayList<IBook>();
//静态模块初始化,项目中一般是从持久层初始化产
static{
bookList.add(new OffNovelBook("天龙八部",3200,"金庸"));
bookList.add(new OffNovelBook("巴黎圣母院",5600,"雨果"));
bookList.add(new OffNovelBook("悲惨世界",3500,"雨果"));
bookList.add(new OffNovelBook("金瓶梅",4300,"兰陵笑笑生"));
}
//模拟书店买书
public static void main(String[] args) {
NumberFormat formatter = NumberFormat.getCurrencyInstance();
formatter.setMaximumFractionDigits(2);
System.out.println("------------书店买出去的书籍记录如下:---------------------");
for(IBook book:bookList){
System.out.println("书籍名称:" + book.getName()+"t书籍作者:" +
book.getAuthor()+ "t书籍价格:" + formatter.format(book.getPrice()/100.0)+"元");
}
}
}
归纳变化:
逻辑变化。只变化一个逻辑,而不涉及到其他模块,比如原有的一个算法是ab+c,现在要求ab*c,可能通过修改原有类中的方法方式来完成,前提条件是所有依赖或关联类都按此相同逻辑处理。
子模块变化。一个模块变化,会对其他模块产生影响,特别是一个低层次的模块变化必然引起高层模块的变化,因此在通过扩展完成变化时,高层次的模块修改是必然的,刚刚的书籍打折处理就是类似的处理模块,该部分的变化甚至引起界面的变化。
可见视图变化。可见视图是提供给客户使用的界面,该部分的变化一般会引起连锁反应(特别是在国内做项目,做欧美的外包项目一般不会影响太大),如果仅仅是界面上按钮、文字的重新排布倒是简单,最司空见惯的是业务耦合变化,什么意思呢?一个展示数据的列表,按照原有的需求是六列,突然有一天要增加一列,而且这一列要跨度N张表,处理M个逻辑才能展现出来,这样的变化是比较恐怖的,但是我们还是可以通过扩展来完成变化,这就依赖我们原有的设计是否灵活。
7.2扩展接口再扩展实现
在上例中,书店又增加了计算机类书籍,该类书还有一个独特特性:面向的是什么领域,修改后的类图如下:
增加了一个接口IcomputerBook和实现类ComputerBook,而BookStore不用做任何修改就可以完成书店销售计算机书籍的业务,我们来看源代码:
public interface IComputerBook extends IBook{
//计算机书籍是有一个范围
public String getScope();
}
很简单,计算机数据增加了一个方法,就是获得该书籍的范围,同时继承IBook接口,毕竟计算机书籍也是书籍。其实现类如下:
public class ComputerBook implements IComputerBook {
private String name;
private String scope;
private String author;
private int price;
public ComputerBook(String _name,int _price,String _author,String _scope){
this.name=_name;
this.price = _price
this.author = _author;
this.scope = _scope;
}
public String getScope() {
return this.scope;
}
public String getAuthor() {
return this.author;
}
public String getName() {
return this.name;
}
public int getPrice() {
return this.price;
}
}
也很简单,实现IcomputerBook就可以,而BookStore类没有做任何的修改,只是在static静态模块中增加一条数据,代码如下:
public class BookStore {
private final static ArrayList<IBook> bookList = new ArrayList<IBook>();
//静态模块初始化,项目中一般是从持久层初始化产
static{
bookList.add(new OffNovelBook("天龙八部",3200,"金庸"));
bookList.add(new OffNovelBook("巴黎圣母院",5600,"雨果"));
bookList.add(new OffNovelBook("悲惨世界",3500,"雨果"));
bookList.add(new OffNovelBook("金瓶梅",4300,"兰陵笑笑生"));
//增加计算机书籍
bookList.add(new ComputerBook("Think in Java",4300,"Bruce Eckel","编程语言"));
}
//模拟书店买书
public static void main(String[] args) {
NumberFormat formatter = NumberFormat.getCurrencyInstance();
formatter.setMaximumFractionDigits(2);
System.out.println("------------书店买出去的书籍记录如下:---------------------");
for(IBook book:bookList){
System.out.println("书籍名称:" + book.getName()+"t书籍作者:" +
book.getAuthor()+ "t书籍价格:" + formatter.format(book.getPrice()/100.0)+"元");
}
}
}
定义:一个软件实体如类、模块和函数应该对扩展开放,对修改关闭。
问题由来:在软件的生命周期内,因为变化、升级和维护等原因需要对软件原有代码进行修改时,可能会给旧代码中引入错误,也可能会使我们不得不对整个功能进行重构,并且需要原有代码经过重新测试。
解决方案:当软件需要变化时,尽量通过扩展软件实体的行为来实现变化,而不是通过修改已有的代码来实现变化。
开闭原则是面向对象设计中最基础的设计原则,它指导我们如何建立稳定灵活的系统。开闭原则可能是设计模式六项原则中定义最模糊的一个了,它只告诉我们对扩展开放,对修改关闭,可是到底如何才能做到对扩展开放,对修改关闭,并没有明确的告诉我们。以前,如果有人告诉我“你进行设计的时候一定要遵守开闭原则”,我会觉的他什么都没说,但貌似又什么都说了。因为开闭原则真的太虚了。
在仔细思考以及仔细阅读很多设计模式的文章后,终于对开闭原则有了一点认识。其实,我们遵循设计模式前面5大原则,以及使用23种设计模式的目的就是遵循开闭原则。也就是说,只要我们对前面5项原则遵守的好了,设计出的软件自然是符合开闭原则的,这个开闭原则更像是前面五项原则遵守程度的“平均得分”,前面5项原则遵守的好,平均分自然就高,说明软件设计开闭原则遵守的好;如果前面5项原则遵守的不好,则说明开闭原则遵守的不好。
其实笔者认为,开闭原则无非就是想表达这样一层意思:用抽象构建框架,用实现扩展细节。因为抽象灵活性好,适应性广,只要抽象的合理,可以基本保持软件架构的稳定。 而软件中易变的细节,我们用从抽象派生的实现类来进行扩展,当软件需要发生变化时,我们只需要根据需求重新派生一个实现类来扩展就可以了。当然前提是我们的抽象要合理,要对需求的变更有前瞻性和预见性才行。
说到这里,再回想一下前面说的5项原则,恰恰是告诉我们用抽象构建框架,用实现扩展细节的注意事项而已:单一职责原则告诉我们实现类要职责单一;里氏替换原则告诉我们不要破坏继承体系;依赖倒置原则告诉我们要面向接口编程;接口隔离原则告诉我们在设计接口的时候要精简单一;迪米特法则告诉我们要降低耦合。而开闭原则是总纲,他告诉我们要对扩展开放,对修改关闭。
最后说明一下如何去遵守这六个原则。对这六个原则的遵守并不是是和否的问题,而是多和少的问题,也就是说,我们一般不会说有没有遵守,而是说遵守程度的多少。任何事都是过犹不及,设计模式的六个设计原则也是一样,制定这六个原则的目的并不是要我们刻板的遵守他们,而需要根据实际情况灵活运用。对他们的遵守程度只要在一个合理的范围内,就算是良好的设计。我们用一幅图来说明一下。
部分转载:
https://blog.csdn.net/yuanlong_zheng/article/details/7443585
https://blog.csdn.net/zhengzhb/article/details/7296944
最后
以上就是孤独老虎为你收集整理的设计模式六大原则之--开闭原则(OCP)前言的全部内容,希望文章能够帮你解决设计模式六大原则之--开闭原则(OCP)前言所遇到的程序开发问题。
如果觉得靠谱客网站的内容还不错,欢迎将靠谱客网站推荐给程序员好友。
发表评论 取消回复