六大设计原则浅析

设计在软件开发中的重要性

重要性

在上大学的时候我们总是不理解为什么要讲这么理论性的东西,当时就一个感觉就是没什么用,我们更想去学习一些可以看到结果的东西,当你毕业之后就会发现基础的知识是多么重要,而这些知识都有一个共性就是可以脱离具体的技术或者问题而存在,是一种可以长期指导我们学习和进步的重要思想,设计原则和模式就是软件开发中的这种思想。

设计原则

我们先来思考一个问题:

怎么样的软件才算一个好的软件或者说对于程序员我们如何评价他(她)的编码技术?

我们来假设一个项目是由某个程序员独立去完成的,他做的事情不仅仅编码这么简单,编写实现功能的代码只占整个项目的30%都不到,他首先做的就应该是对整个项目的技术选择和框架设计(需求的学习和理解暂且不考虑),接下来才是正式编写代码,在实现过程中又需要多次的测试和修改(重构),这样就够了吗?如果能做到这些是可以开发出一个完整的软件,但是还不够。软件工程和盖房子有所区别的地方就在这里,房子盖好就不需要拆了重盖了,软件开发中我们还需要考虑到日后的迭代和变更,所以我们要做到整个结构有一个好的可维护性,设计原则是什么?设计原则就是指导我们实现这种结构的理论基础(也可以说是思想)。

设计模式

好了,设计原则有了,我也知道了应该遵循什么原则了,接下来怎么办?我该如何去在实际工程中运用这样的原则?设计模式就是为了解决这些问题而出现的,说白了设计模式就是我们智慧的老前辈们总结出来一些遵循六大设计原则的面向对象的实际应用方式。我们学会了这些设计模式可以使我们更加理解到设计原则的重要性,而设计原则也能帮助我们记忆和灵活应用各种设计模式。

六大设计原则

单一职责原则

单一职责原则的英文名称Single Responsibility Principle,简称SRP,单一职责的定义是:There should never be more than one reason for a class to change.(应该有且仅有一个原因引起类的变更)。

这个原则就是知道我们如何去封装一个对象(或者说如何去划分和定义类),“万物皆对象”这句话表明,任何事物都是对象,如何定义一类事物,完全由我们自己的需要决定。单一职责原则就是说明我们在定义(或者叫划分)一个类的时候应该尽可能的让他做一件事情,看起来这个原则很简单,但是问题恰恰就出在这个简单的“一件事情”上,如何来区分是一件事情就是一个问题?举个例子来说

/**
 * 电话接口
 * @author PeggyTong
 *
 */
public interface IPhone {

	//拨打电话
	public void dial(String phoneNumber);
	
	//通话
	public void chat(Object obj);
	
	//挂断电话
	public void hangup();
}

上面这接口看起来没有什么问题,它就是做一件事情打电话(看成我们的手机),但是如果我们这里把一件事情定义成单纯的拨打电话,那么它又不是在做一件事情,所以说这里的单一职责要根据我们具体的业务逻辑来定,而不是越小越好的。 单一职责有什么作用呢?

1、类的复杂性降低,实现什么职责都有清晰明确的定义。 2、可读性提高,复杂性降低所以提高了可读性。 3、可维护性提高。 4、变更引起的风险降低,一个接口的修改只对相应的实现类有影响,对其他接口无影响。

其实在软件开发中单一职责不仅仅对于类和接口是这样,对于方法的定义也应该见名知其意(做一件事)。

里氏替换原则

(本文出自水寒的CSDN博客:http://blog.csdn.net/dawanganban)

第一种定义:If for each object o1 of type S there is an object o2 of type T such that for all programs P defined in terms of T, the behavior of P is unchanged when o1 is substituted for o2 then S is a subtype of T.(如果对每一个类型为S的对象o1,都有类型为T的对象o2,使得以T定义的所有程序P在所有的对象o1都代换成o2时,程序P的行为没有发生变化,那么类型S是类型T的子类型)

第二种定义:Functions that user pointers or references base classes must be able to use objects of derived classes without knowing it.(所有引用基类的地方必须能够透明地使用其子类的对象)

第二个定义是最清晰明确的,通俗点讲,只要父类能出现的地方子类就可以出现,而且替换为子类也不会产生任何错误或者异常,使用者可能根本不需要知道父类还是子类。但是反过来就不行了,有子类出现的地方,父类未必就能适应。

看到这个原则你可能会想,为什么要有这个原则,这个原则有什么用?单一职责原则的作用还能想明白,这个完全搞不懂有何用。

要理解这个原则的作用,首先你得知道面向对象的一个重要特征叫多态,多态的好处也可以说是里氏替换原则的好处,多态可以把不同的子类当作同一个父类来看待,这样就可以屏蔽子类之间的差异,写出通用的代码,做出通用的编程,以适应需求的不断变化。

里氏替换原则只是告诉我们不应该怎么做,并没有告诉我们一个可行的继承方式,于是,工程师们开始关注如何确保对象的行为。1988年,B. Meyer提出了Design by Contract(契约式设计)理论。DbC从形式化方法中借鉴了一套确保对象行为和自身状态的方法,其基本概念很简单:

Pre-condition:

每个方法调用之前,该方法应该校验传入参数的正确性,只有正确才能执行该方法,否则认为调用方违反契约,不予执行。这称为前置条件(Pre-condition)。

Post-Condition:

每个方法调用之前,该方法应该校验传入参数的正确性,只有正确才能执行该方法,否则认为调用方违反契约,不予执行。这称为前置条件(Pre-condition)。

Invariant:

对象本身有一套对自身状态进行校验的检查条件,以确保该对象的本质不发生改变,这称之为不变式(Invariant)。

我们在Java中很容易看到继承的这些特点,如果我们不符合这些特征,编译器会告诉我们错误或警告。

依赖倒置原则

依赖倒置原则(Dependence Inversion Principle, DIP),定义:High level modules should not depend upon low level modules. Both should depend upon abstractions. Abstractions should not depend upon details. Details should depend upon abstractions.

1、高层模块不应该依赖低层模块,两者都应该依赖其抽象。 2、抽象不应该依赖于细节。 3、细节应该依赖于抽象。

高层模块和低层模块容易理解,每一个逻辑的实现都是由原子逻辑组成的,不可分割的原子逻辑就是低层模块,原子逻辑的再组装就是高层模块。那么什么是抽象?什么又是细节呢?在Java语言中,抽象就是接口或者抽象类,两者都是不能直接被实例化的;细节就是实现类,实现接口或者抽象类而产生的类就是细节,其特点就是可以直接被实例化,也就是可以加上一个关键字new产生一个对象。依赖倒置原则在java语言中的表现是:

  1. 模块间的依赖通过抽象发送,实现类之间不发生直接的依赖关系,其依赖关系是通过接口或者抽象类产生的。
  2. 接口或者抽象类不依赖于实现类。
  3. 实现类依赖于接口或者抽象类。

采用依赖倒置原则可以减少类之间的耦合性,提供系统的稳定性,降低并行开发引起的风险,提高代码的可读性和可维护性。 依赖有三种方式:

1、构造函数传递依赖对象

package cn.org.sunhome.yldz;
//具体的司机
public class Driver implements IDriver{
	
	private ICar mCar;
	
	public Driver(ICar car){
		mCar = car;
	}
	
	@Override
	public void drive() {
		mCar.run();
	}
}

2、Setter方法传递依赖对象

package cn.org.sunhome.yldz;
//具体的司机
public class Driver implements IDriver{
	
	private ICar mCar;
	
	public void setCar(ICar car){
		mCar = car;
	}
	
	@Override
	public void drive() {
		if(mCar == null) return;
		mCar.run();
	}
}

3、接口声明依赖对象(也叫接口注入)也就是上面的方式

package cn.org.sunhome.yldz;
//司机接口
public interface IDriver {
	public void drive(ICar car);
}

依赖倒置原则的本身就是通过抽象(接口或者抽象类)使各个类或模块的实现彼此独立,不互相影响,实现模块间的耦合,我们怎么在项目中使用这个规则呢?只要遵循以下几个规则就可以:

  1. 每个类尽量都有接口或者抽象类,或者抽象类和接口两者都具备。
  2. 变量的表面类型尽量是接口或者抽象类。
  3. 任何类都不应该从具体类派生。
  4. 尽量不要覆写基类的方法。
  5. 结合里氏替换原则使用。

接口隔离原则

定义一:Client should not be forced to depend upon interface that they don’t use.(客户端不应该依赖它不需要的接口) 定义二:The dependency of one class to another one should depend on the smallest possible interface(类间的依赖关系应该建立在最小接口上)

上面两个定义其实可以这样理解,我们在建立接口的时候要尽量细化接口,同时接口中的方法要尽量的少。 接口隔离原则是对接口进行规范约束,其包含以下4个含义:

  1. 接口要尽量小。
  2. 接口要高内聚。
  3. 定制服务。有时候我们的接口需要为各个客户端定制,这个时候应该将接口拆分。
  4. 接口设计是有限度的。

接口的设计颗粒度越小,系统越灵活,这个是一个不用争论的事实,但是灵活的同时却容易带来结构上的复杂化,开发难度增加,可维护性降低,所以接口的设计一定要注意适度。

接口隔离原则是对接口的定义,同时也是对类的定义,接口和类尽可能使用原子接口或原子类来组装。但是,这个原子该怎么划分是一个设计模式中的难题,在实践中可以根据以下几个规则来衡量:

  1. 一个接口只服务于一个子模块或业务逻辑。
  2. 通过业务逻辑压缩接口中的public方法。
  3. 已经被污染的接口,尽量去修改,若变更风险较大,则采用适配器模式进行转化处理。
  4. 了解环境,拒绝盲从。每个项目或产品都有特定的环境因素,环境不同,接口拆分的标准就不同,最好的接口设计的前提是深入理解业务逻辑。

迪米特法则

迪米特法则(Law of Demeter, LoD)也称为最少知识原则(Leak Knowledge Principle, LKP),虽然名字不同,但描述的是同一个规则:一个对象应该对其他对象有最少的了解。通俗的讲,一个类应该对自己需要耦合或调用的类知道的最少。

如果一个系统符合迪米特法则,那么当其中某一个模块发生修改时,就会尽量少地影响其他模块,扩展会相对容易,这是对软件实体之间通信的限制,迪米特法则要求限制软件实体之间通信的宽度和深度。迪米特法则可降低系统的耦合度,使类与类之间保持松散的耦合关系。

迪米特法则还有几种定义形式,包括:不要和“陌生人”说话、只与你的直接朋友通信等,在迪米特法则中,对于一个对象,其朋友包括以下几类:

  1. 当前对象本身(this);
  2. 以参数形式传入到当前对象方法中的对象;
  3. 当前对象的成员对象;
  4. 如果当前对象的成员对象是一个集合,那么集合中的元素也都是朋友;
  5. 当前对象所创建的对象。

任何一个对象,如果满足上面的条件之一,就是当前对象的“朋友”,否则就是“陌生人”。在应用迪米特法则时,一个对象只能与直接朋友发生交互,不要与“陌生人”发生直接交互,这样做可以降低系统的耦合度,一个对象的改变不会给太多其他对象带来影响。

迪米特法则要求我们在设计系统时,应该尽量减少对象之间的交互,如果两个对象之间不必彼此直接通信,那么这两个对象就不应当发生任何直接的相互作用,如果其中的一个对象需要调用另一个对象的某一个方法的话,可以通过第三者转发这个调用。简言之,就是通过引入一个合理的第三者来降低现有对象之间的耦合度。

在将迪米特法则运用到系统设计中时,要注意下面的几点:在类的划分上,应当尽量创建松耦合的类,类之间的耦合度越低,就越有利于复用,一个处在松耦合中的类一旦被修改,不会对关联的类造成太大波及;在类的结构设计上,每一个类都应当尽量降低其成员变量和成员函数的访问权限;在类的设计上,只要有可能,一个类型应当设计成不变类;在对其他类的引用上,一个对象对其他对象的引用应当降到最低。

开闭原则

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

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

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

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

我们实现开闭原则的关键就在于“抽象”。把系统的所有可能的行为抽象成一个抽象底层,这个抽象底层规定出所有的具体实现必须提供的方法的特征。作为系统设计的抽象层,要预见所有可能的扩展,从而使得在任何扩展情况下,系统的抽象底层不需修改;同时,由于可以从抽象底层导出一个或多个新的具体实现,可以改变系统的行为,因此系统设计对扩展是开放的。 下面我们来看一个例子

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;
	}

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

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

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

//书店销售
public class BookStore {

	private final static ArrayList bookList = new ArrayList();
	
	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折销售,其他8折销售,对于这个变化,我们应该如何应对,有三种方法可以解决:

1、修改接口

在IBook上新增一个方法getOffPrice(),专门用于进行打折处理,所有实现类实现该方法。但是这样修改的后果就是,实现类NovelBook要修改,BookStroe中的main方法也修改,同时IBook作为接口应该是稳定且可靠的,不应该经常发生变化,否则接口作为契约的作用失去了效能,因此,该方案否定。

2、修改实现类

修改NovelBook类中的方法,直接在getPrice()中实现打折,但是这个不是好办法,因为如果采购人员要看实际价格就没办法查看了。

3、通过扩展实现变化

增加一个子类OffNovelBook,覆写getPrice()方法,高层次的模块通过OffNovelBook类产生对象,完成业务变化对系统的最小开发,好办法!