开闭原则

原创
2013/09/04 18:23
阅读数 387

开闭原则的定义

在哲学上,矛盾法则即对立统一法则,是唯物辨证法的最根本法则。开闭原则是Java世界里最基本的设计原则,它指导我们如何建立一个稳定的、灵活的系统,先来看开闭原则的定义:

Software entities like classes,modules and functions should be open for extension but closed for modifications. (一个软件实体如类、模块和函数应该对扩展开放,对修改关闭。)

初看这个定义可能会很疑惑,对扩展开放?开放什么?对修改关闭,怎么关闭?

我们做一件事,或者选择一个方向,一般需要经历三个步骤:What——是什么,why——为什么,How——怎么办(简称3W原则)。对于开闭原则,我们也采用这三步来分析,即什么是开闭原则,为什么要使用开闭原则,怎样使用开闭原则。

开闭原则的庐山真面目

开闭原则的定义已经非常明确地告诉我们:软件实体应该对扩展开发,对修改关闭,其含义是说一个软件实体应该通过扩展来实现变化,而不是通过修改已有代码来实现变化。那什么是软件实体?软件实体包括以下几个部分:

  1. 项目或软件产品中按照一定的逻辑规则划分的模块。
  2. 抽象和类。
  3. 方法。

一个软件产品只要在生命期内,都会有变化,既然变化是一个既定的事实,我们就应该在变化时尽量适应这些变化,以提高项目的稳定性和灵活性,真正实现“拥抱变化”。开闭原则告诉我们应尽量通过扩展软件实体的行为来实现变化,而不是通过修改已有的代码来实现变化,它是为软件实体的未来事件而制定的对现行开发设计进行约束的一个原则。我们举例来说明什么是开闭原则,以书店销售书籍为例,其类图如下:

书店售书类图

IBook定义了数据的三个属性:名称、价格和作者。小说NobelBook是一个具体的实现类,是所有小说书籍的总称,BookStore指的是书店。

代码清单1:IBook.java (书籍接口)

/**
 *
 * @author Barudisshu
 */
public interface IBook {

    //书籍有名称
    public String getName();

    //书籍有售价
    public int getPrice();

    //书籍有作者
    public String getAuthor();
}

代码清单2:NovelBook.java (小说类)

/**
 *
 * @author Barudisshu
 */
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;
    }

    @Override
    public String getName() {
        return name;
    }

    @Override
    public int getPrice() {
        return price;
    }

    @Override
    public String getAuthor() {
        return author;
    }
}

代码清单3:BookStore.java (书店售书类)

import java.text.NumberFormat;
import java.util.ArrayList;

/**
 *
 * @author Barudisshu
 */
public class BookStore {

    private final static ArrayList<IBook> bookList = new ArrayList();

    //static静态模块初始化数据,实际项目中一般由持久层完成
    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书籍价格:" + formatter.format(book.getPrice() / 100.0) + "元"
                    + "\t书籍作者:" + book.getAuthor());
        }
    }
}

在BookStore中声明了一个静态模块,实现了数据的初始化,这部分应该是从持久层产生的,由持久层框架进行管理,如下结果如下:

捕获

项目投产了,书籍正常销售出去,书店也盈利了。从2008年开始,全球经济开始下滑,对零售业影响比较大,书店为了生存开始打折销售;所有40元以上的书籍9折销售,其他的8折销售。对已经投产的项目来说,这就是一个变化,我们应该如何应对这样一个需求变化?有如下三个方法可以解决这个问题:

  1. 修改接口
    在IBook上新增一个方法getOffPrice(),专门用于进行打折处理,所有的实现类实现该方法。但是这样修改的后果是,实现类NovelBook要修改,BookStore中的maim方法也要修改,同时IBook作为接口,应该是稳定可靠的,不应该经常发生变化,否则接口作为契约的作用就失去了效能。因此,该方案否定。
  2. 修改实现类
    修改NovelBook类中的方法,直接在getPrice()中实现打折处理,好办法,相信大家在项目中经常使用的就是这样的办法,通过class文件替换的方式可以完成部分业务变化(或是缺陷修复)。该方法在项目有明确的章程(团队内约束)或优良的架构设计时,是一个非常优秀的方法,但是该方法还是有缺陷的。例如采购书籍人员也是要看价格的,由于该方法已经实现了打折处理价格,因此采购人员看到的也是打折后的价格,会因信息不对程而出现决策失误的情况。因此,该方案也不是一个最优的方案。
  3. 通过扩展实现变化
    增加一个子类OffNovelBook,覆盖getPrice()方法,高层次的模块(也就是static模块)通过OffNovelBook类产生新的对象,完成业务变化对系统的最小化开发。好办法,修改也小,风险也小。

OffNovelBook类继承了NovelBook,并覆写了getPrice方法,不修改原有的代码。

扩展后书店售书图

代码清单4:OffNovelBook.java (打折销售的小说类)

/**
 *
 * @author Barudisshu
 */
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;
        //原价大于40元,则打9折,否则打8折
        if (selfPrice > 4000) {
            offPrice = selfPrice * 90 / 100;
        } else {
            offPrice = selfPrice * 80 / 100;
        }
        return offPrice;
    }
}

代码清单5:BookStore.java (书店打折销售类)

import java.text.NumberFormat;
import java.util.ArrayList;

/**
 *
 * @author Barudisshu
 */
public class BookStore {

    private final static ArrayList<IBook> bookList = new ArrayList();

    //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书籍价格:" + formatter.format(book.getPrice() / 100.0) + "元"
                    + "\t书籍作者:" + book.getAuthor());
        }
    }
}

修改后运行结果如下:

捕获

OK,打折销售开发完成了。看到这里,各位可能会有想法:增加一个OffNovelBook类后,你的业务逻辑还是修改了,你修改了static静态模块区域。这部分确实修改了,该部分属于高层次的模块,是由持久层产生的,在业务规则改变的情况下高层模块必须有部分改变以适应新业务,改变要尽量地少,防止变化风险的扩散。

开闭原则对扩展开放,对修改关闭,并不意味着不做任何修改,低层模块的变更,必然要有高层模块进行耦合,否则就是一个孤立无意义的代码片段。

我们可以把变化归纳为以下三种类型:

  1. 逻辑变化
    只变化一个逻辑,而不涉及其他模块,比如原有一个算法是a*b+c,现在需要修改为a*b*c,可以通过修改原有类中的方法来完成,前提条件是所有依赖或关联类都按照相同的逻辑处理。
  2. 子模块变化
    一个模块变化,会对其他模块产生影响,特别是一个低层次的模块变化必然引起高层次模块的变化,因此在通过扩展完成变化时,高层次的模块修改是必然的,刚刚的书籍打折处理就是类似的处理模块,该部分的变化甚至会引起界面的变化。
  3. 可见视图变化
    可见视图是提供给客户端使用的界面,如JSP程序、Swing界面等,该部分的变化一般会引起连锁反应。如果仅仅是界面上按钮、文字的重新排布倒是简单,最司空见惯的是业务耦合变化,什么意思呢?一个展示数据的列表,按照原有的需求是6列,突然有一天要增加1列,而且这一列要跨N张表,处理N个逻辑才能排列出来,这样的变化是比较恐怖的,但还是可以通过扩展来完成变化,这就要看我们原有的设计是否灵活。

为什么要采用开闭原则

开闭原则是非常重要的,可通过以下几个方面来理解其重要性。

  1. 开闭原则对测试的影响
    所有已经投产的代码都是有意义的,并且都受系统规则的约束,这样的代码都要经过“千锤百炼”的测试过程,不仅保证逻辑是正确的,还要保证苛刻条件(高压力、异常、错误)下不产生“有毒代码”(Pisonous Code),因此有变化提出时,我们就需要考虑一下,原有的健壮代码是否可以不修改,仅仅通过扩展实现变化呢?否则,就需要把原有的测试过程回笼一遍,需要进行单元测试、功能测试、集成测试甚至是验收测试,现在虽然在大力提倡自动化测试工具,但是仍然代替不了人工测试。
    以上面提到的书店售书为例,IBook接口写完后,实现类NovelBook也写好了,我们需要写一个测试类进行测试。
    import junit.framework.TestCase;
    import ocp.section2.IBook;
    import ocp.section2.OffNovelBook;
    
    /**
     *
     * @author Barudisshu
     */
    public class OffNovelBookTest extends TestCase {
    
        private IBook below40NovelBook = new OffNovelBook("平凡的世界", 3000, "路遥");
        private IBook above40NovelBook = new OffNovelBook("平凡的世界", 6000, "路遥");
    
        //测试低于40元的数据是否打8折
        public void testGetPriceBelow40() {
            TestCase.assertEquals(2400, this.below40NovelBook.getPrice());
        }
    
        //测试大于40的书籍是否是打9折
        public void testGetPriceAbove40() {
            TestCase.assertEquals(5400, this.above40NovelBook.getPrice());
        }
    }

    新增加的类,新增加的测试方法,只要保证新增加类是正确的就可以了。
  2. 开闭原则可以提高复用性
    在面向对象的设计中,所有的逻辑都是从原子逻辑组合而来的,而不是在一个类中独立实现一个业务逻辑。只有这样代码才可以复用,粒度越小,被复用的可能性就越大。那为什么要复用呢?减少代码量,避免相同的逻辑分散在多个角落,避免日后的维护人员为了修改一个微小的缺陷或增加新功能而要在整一个项目中到处查找相关的代码,然后发出对开发人员“极度失望”的感慨。那怎样才能提高复用率呢?缩小逻辑粒度,直到一个逻辑不能再拆分为止。
  3. 开闭原则可以提高可维护性
    一款软件投产后,维护人员的工作不仅仅是对数据进行维护,还可能要对程序进行扩展,维护人员最愿意做的事情就是扩展一个类,而不是修改一个类,甭管原有的代码写得多么优秀还是多么糟糕,让维护人员读懂原有的代码,然后再修改,是一件很痛苦的事情,不要让他在所有的代码海洋里游弋完毕后再修改,那是对维护人员的一种折磨和摧残。
  4. 面向对象开发的要求
    万物皆对象,我们需要把所有的事物都抽象成对象,然后针对对象进行操作,但是万物皆是运动,有运动就有变化,有变化就要有策略去应对,怎么快速应对呢?这就要在设计之初考虑到所有可能变化的因素,然后留下接口, 等待“可能”转变为“现实”。

如何使用开闭原则

开闭原则是一个非常虚的原则,开闭原则并没有具体的解析,它“虚”得没有边界。

  1. 抽象约束
    抽象是对一组事物通用的描述,没有具体的实现,也就表示他有非常多的可能性,可以跟随需求的变化而变化。因此,通过接口或抽象类可以约束一组可能变化的行为,并且能够实现对扩展开放,其包含三层意义:第一,通过接口或抽象类约束扩展,对扩展进行边界限定,不允许出现在接口或抽象类中不存在的public方法;第二,参数类型、引用对象尽量使用接口或者抽象类,而不是实现类;第三,抽象层尽量保持稳定,一旦确定即不允许修改。
  2. 元数据(metadata)控制模块行为
    编程是一个很苦很累的活,那怎么才能减轻我们的压力呢?答案是尽量使用元数据来控制程序的行为,减少重复开发。什么是元数据?用来描述环境和数据的数据,通俗地将就是配置参数,参数可以从文件中获得,也可以从数据库中获得。
  3. 制定项目章程
    在一个项目中,制定项目章程是非常重要的,因为章程中指定了所有人员都必须遵循的约定,对项目来说,约定优于配置。
  4. 封装变化
    对变化封装包含两层含义:第一,将相同的变化封装到一个接口或抽象类中;第二,将不同的变化封装到不同接口或抽象类中,不应该有两个不同变化出现在同一个接口或抽象类中.封装变化,也就是受保护的变化(protectected variatious),找出预计有变化或不稳定的点,我们为这些变化点创建稳定的接口,准确地将就是封装可能发生的变化,一旦预测到或发觉有变化,就可以进行封装,23个设计模式都是从各个不同角度对变化进行封装的。
展开阅读全文
打赏
0
2 收藏
分享
加载中
更多评论
打赏
0 评论
2 收藏
0
分享
返回顶部
顶部