定义:软件实体(类,模板,方法)可以扩展,但是不可修改。即对于扩展是开放的,对于更改是封闭的。面对需求,对程序的改动是通过增加新代码进行的,而不是更改现有的代码。
一个软件产品只要在生命期内, 都会发生变化, 既然变化是一个既定的事实, 我们就应该在设计时尽量适应这些变化, 以提高项目的稳定性和灵活性, 真正实现“拥抱变化”。
开闭原则告诉我们应尽量通过扩展软件实体的行为来实现变化, 而不是通过修改已有的代码来完成变化, 它是为软件实体的未来事件而制定的对现行开发设计进行约束的一个原则。
我们举例说明什么是开闭原则, 以书店销售书籍为例, 其类图如图所示:
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静态模块初始化数据, 实际项目中一般是由持久层完成
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书籍价格: "+book.getPrice());
}
}
}
项目投产了, 书籍正常销售出去, 书店也赢利了。 从2008年开始, 全球经济开始下滑,对零售业影响比较大, 书店为了生存开始打折销售: 所有40元以上的书籍9折销售, 其他的8折销售。 对已经投产的项目来说, 这就是一个变化, 我们应该如何应对这样一个需求变化?有如下三种方法可以解决这个问题:
1)修改接口
在IBook上新增加一个方法getOffPrice(), 专门用于进行打折处理, 所有的实现类实现该方法。 修改的后果是, 实现类ovelBook要修改, BookStore中的main方法也修改, 同时IBook作为接口应该是稳定且可靠的, 不应该经常发生变化, 否则接口作为契约的作用就失去了效能。 因此, 该方案否定。
2)修改实现类
修改NovelBook类中的方法, 直接在getPrice()中实现打折处理, 好办法, 我相信大家在项目中经常使用的就是这样的办法, 通过class文件替换的方式可以完成部分业务变化(或是缺陷修复) 。 该方法在项目有明确的章程(团队内约束) 或优良的架构设计时, 是一个非常优秀的方法, 但是该方法还是有缺陷的。 例如采购书籍人员也是要看价格的, 由于该方法已经实现了打折处理价格, 因此采购人员看到的也是打折后的价格, 会因信息不对称而出现决策失误的情况。 因此, 该方案也不是一个最优的方案。
3)通过扩展实现变化
增加一个子类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方法, 通过扩展完成了新增加的业务。
为什么要使用开闭原则
每个事物的诞生都有它存在的必要性, 存在即合理, 那开闭原则的存在也是合理的, 为什么这么说呢?
1. 开闭原则对测试的影响
所有已经投产的代码都是有意义的, 并且都受系统规则的约束, 这样的代码都要经过“千锤百炼”的测试过程, 不仅保证逻辑是正确的, 还要保证苛刻条件(高压力、 异常、 错误) 下不产生“有毒代码”(Poisonous Code) , 因此有变化提出时, 我们就需要考虑一下,原有的健壮代码是否可以不修改, 仅仅通过扩展实现变化呢? 否则, 就需要把原有的测试过程回笼一遍, 需要进行单元测试、 功能测试、 集成测试甚至是验收测试, 现在虽然在大力提倡自动化测试工具, 但是仍然代替不了人工的测试工作。
2. 开闭原则可以提高复用性
在面向对象的设计中, 所有的逻辑都是从原子逻辑组合而来的, 而不是在一个类中独立实现一个业务逻辑。 只有这样代码才可以复用,粒度越小, 被复用的可能性就越大。 那为什么要复用呢? 减少代码量, 避免相同的逻辑分散在多个角落, 避免日后的维护人员为了修改一个微小的缺陷或增加新功能而要在整个项目中到处查找相关的代码, 然后发出对开发人员“极度失望”的感慨。 那怎么才能提高复用率呢? 缩小逻辑粒度, 直到一个逻辑不可再拆分为止。
3. 开闭原则可以提高可维护性
一款软件投产后, 维护人员的工作不仅仅是对数据进行维护, 还可能要对程序进行扩展, 维护人员最乐意做的事情就是扩展一个类, 而不是修改一个类, 甭管原有的代码写得多么优秀还是多么糟糕, 让维护人员读懂原有的代码, 然后再修改, 是一件很痛苦的事情, 不要让他在原有的代码海洋里游弋完毕后再修改, 那是对维护人员的一种折磨和摧残。
4. 面向对象开发的要求
万物皆对象, 我们需要把所有的事物都抽象成对象, 然后针对对象进行操作, 但是万物皆运动, 有运动就有变化, 有变化就要有策略去应对, 怎么快速应对呢? 这就需要在设计之初考虑到所有可能变化的因素, 然后留下接口, 等待“可能”转变为“现实”。
怎么使用开闭原则
1. 抽象约束
抽象是对一组事物的通用描述, 没有具体的实现, 也就表示它可以有非常多的可能性,可以跟随需求的变化而变化。 因此, 通过接口或抽象类可以约束一组可能变化的行为, 并且能够实现对扩展开放, 其包含三层含义: 第一, 通过接口或抽象类约束扩展, 对扩展进行边界限定, 不允许出现在接口或抽象类中不存在的public方法; 第二, 参数类型、 引用对象尽量使用接口或者抽象类, 而不是实现类; 第三, 抽象层尽量保持稳定, 一旦确定即不允许修改。
2. 元数据(metadata) 控制模块行为
编程是一个很苦很累的活, 那怎么才能减轻我们的压力呢? 答案是尽量使用元数据来控制程序的行为, 减少重复开发。 什么是元数据? 用来描述环境和数据的数据, 通俗地说就是配置参数, 参数可以从文件中获得, 也可以从数据库中获得。典型的元数据控制模块行为的例子, 其中达到极致的就是控制反转(Inversion of Control) ,使用最多的就是Spring容器, 扩展一个子类, 修改SpringContext配置文件, 完成了业务变化, 这也是采用框架的好处。
3. 制定项目章程
在一个团队中, 建立项目章程是非常重要的, 因为章程中指定了所有人员都必须遵守的约定, 对项目来说, 约定优于配置。 相信大家都做过项目, 会发现一个项目会产生非常多的配置文件。举个简单的例子, 以SSH项目开发为例, 一个项目中的Bean配置文件就非常多,管理非常麻烦。 如果需要扩展, 就需要增加子类, 并修改SpringContext文件。 然而, 如果你在项目中指定这样一个章程: 所有的Bean都自动注入, 使用Annotation进行装配, 进行扩展时, 甚至只用写一个子类, 然后由持久层生成对象, 其他的都不需要修改, 这就需要项目内约束, 每个项目成员都必须遵守, 该方法需要一个团队有较高的自觉性, 需要一个较长时间的磨合, 一旦项目成员都熟悉这样的规则, 比通过接口或抽象类进行约束效率更高, 而且扩展性一点也没有减少。
4. 封装变化
对变化的封装包含两层含义: 第一, 将相同的变化封装到一个接口或抽象类中; 第二,将不同的变化封装到不同的接口或抽象类中, 不应该有两个不同的变化出现在同一个接口或抽象类中。 封装变化, 也就是受保护的变化(protected variations) , 找出预计有变化或不稳定的点, 我们为这些变化点创建稳定的接口, 准确地讲是封装可能发生的变化, 一旦预测到或“第六感”发觉有变化, 就可以进行封装。
总结:开放-封闭原则是面向对象设计的核心所在。遵循这个原则可以带来面向对象技术所声称的巨大好处,也就是可维护、可扩展、可复用、灵活性好。开发人员应该仅对程序中频繁出现变化的那些部分做出抽象,然而,对于程序中的每个部分都刻意地进行抽象同样不是一个好主意。拒绝不成熟的抽象和抽象本身一样重要。