设计模式:面向对象设计的六大原则
六大原则
这是我学习设计模式系列的第一篇文章,本文主要讲的是面向对象设计应该遵循的六大原则,掌握这些原则能帮助我们更好的理解面向对象的概念,也能更好的理解设计模式。这六大原则分别是:
- 单一职责原则——SRP
- 里式替换原则——LSP
- 依赖倒置原则——DIP
- 接口隔离原则——ISP
- 迪米特原则——LOD
- 开闭原则——OCP
单一职责原则
单一职责原则告诉我们:一个类不能太“累”!在软件系统中,一个类(大到模块,小到方法)承担的职责越多,它被复用的可能性就越小,而且一个类承担的职责过多,就相当于将这些职责耦合在一起,当其中一个职责变化时,可能会影响其他职责的运作,因此要将这些职责进行分离,将不同的职责封装在不同的类中,即将不同的变化原因封装在不同的类中,如果多个职责总是同时发生改变则可将它们封装在同一类中。
简单通俗的来说:一个类只负责一项职责,单一职责原则是实现高内聚、低耦合的指导方针。
举个例子,在创业公司里,由于人力成本控制和流程不够规范的原因,往往一个人需要担任N个职责,一个工程师可能不仅要出需求,还要写代码,甚至要面谈客户,光背的锅就好几种,简单用代码表达大概如此:
public class Engineer {
public void makeDemand(){}
public void writeCode(){}
public void meetClient(){}
}
代码看上去好像没什么问题,因为我们平时就是这么写的啊,但是细读一下就能发现,这种写法很明显不符合单一职责的原则,因为引起类的变化不只有一个,至少有三个方法都可以引起类的变化。比如有天因为业务需要,出需求的方法需要加个功能 (比如需求的成本分析),或者是见客户也需要个参数之类的,那样一来类的变化就会有多种可能性了,其他引用该类的类也需要相应的变化,如果引用类的数目很多的话,代码维护的成本可想而知会有多高。所以我们需要把这些方法拆分成独立的职责,可以让一个类只负责一个方法,每个类只专心处理自己的方法即可。
单一职责原则的优点:
- 类的复杂性降低,实现什么职责都有明确的定义;
- 逻辑变得简单,类的可读性提高了,而且,因为逻辑简单,代码的可维护性也提高了;
- 变更的风险降低,因为只会在单一的类中的修改。
里式替换原则
所有引用基类的地方必须能够透明地使用其子类的对象。
通俗点说,只要父类能出现的地方子类就可以出现,而且替换为子类也不会产生任何异常。 但是反过来就不行了,因为子类可以扩展父类没有的功能,同时子类还不能改变父类原有的功能。
例如有两个类,一个类为BaseClass,另一个是SubClass类,并且SubClass类是BaseClass类的子类,那么一个方法如果可以接受一个BaseClass类型的基类对象base的话,如:method1(base),那么它必然可以接受一个BaseClass类型的子类对象sub,method1(sub)能够正常运行。反过来的代换不成立,如一个方法method2接受BaseClass类型的子类对象sub为参数:method2(sub),那么一般而言不可以有method2(base),除非是重载方法。
我们都知道,面向对象的三大特征是封装、继承和多态,这三者缺一不可,但三者之间却并不 “和谐“。因为继承有很多缺点,当子类继承父类时,虽然可以复用父类的代码,但是父类的属性和方法对子类都是透明的,子类可以随意修改父类的成员。如果需求变更,子类对父类的方法进行了一些复写的时候,其他的子类可能就需要随之改变,这在一定程度上就违反了封装的原则,解决的方案就是引入里氏替换原则。
里氏替换原则为良好的继承定义了一个规范,它包含了4层含义:
1、子类可以实现父类的抽象方法,但是不能覆盖父类的非抽象方法。
2、子类可以有自己的个性,可以有自己的属性和方法。
3、子类覆盖或重载父类的方法时输入参数可以被放大。
比如父类有一个方法,参数是HashMap
public class Father {
public void test(HashMap map){
System.out.println("父类被执行。。。。。");
}
}
那么子类的同名方法输入参数的类型可以扩大,例如我们输入参数为Map,
public class Son extends Father{
public void test(Map map){
System.out.println("子类被执行。。。。");
}
}
我们写一个场景类测试一下父类的方法执行效果,
public class Client {
public static void main(String[] args) {
Father father = new Father();
HashMap map = new HashMap();
father.test(map);
}
}
结果输出:父类被执行。。。。。
因为里氏替换原则,只要父类能出现的地方子类就可以出现,而且替换为子类也不会产生任何异常。我们改下代码,调用子类的方法,
public class Client {
public static void main(String[] args) {
Son son = new Son();
HashMap map = new HashMap();
father.test(map);
}
}
运行结果是一样的,因为子类方法的输入参数类型范围扩大了,子类代替父类传递到调用者中,子类的方法永远不会被执行,这样的结果其实是正确的,如果想让子类方法执行,可以重写方法体。
反之,如果子类的输入参数类型范围比父类还小,比如父类中的参数是Map,而子类是HashMap,那么执行上述代码的结果就会是子类的方法体,有人说,这难道不对吗?子类显示自己的内容啊。其实这是不对的,因为子类没有复写父类的同名方法,方法就被执行了,这会引起逻辑的混乱,如果父类是抽象类,子类是实现类,你传递一个这样的实现类就违背了父类的意图了,容易引起逻辑混乱,所以子类覆盖或重载父类的方法时输入参数必定是相同或者放大的。
4、子类覆盖或重载父类的方法时输出结果可以被缩小,也就是说返回值要小于或等于父类的方法返回值。
确保程序遵循里氏替换原则可以要求我们的程序建立抽象,通过抽象去建立规范,然后用实现去扩展细节,所以,它跟开闭原则往往是相互依存的。
依赖倒置原则
依赖倒置原则,Dependence Inversion Principle,简称DIP,它的定义是:
高层模块不应该依赖底层模块,两者都应该依赖其抽象;
抽象不应该依赖细节;
细节应该依赖抽象;
什么是高层模块和底层模块呢?不可分割的原子逻辑就是底层模块,原子逻辑的再组装就是高层模块。
在Java语言中,抽象就是指接口或抽象类,两者都不能被实例化;而细节就是实现接口或继承抽象类产生的类,也就是可以被实例化的实现类。依赖倒置原则是指模块间的依赖是通过抽象来发生的,实现类之间不发生直接的依赖关系,其依赖关系是通过接口是来实现的,这就是俗称的面向接口编程。
依赖倒置原则的核心思想是面向接口编程,我们依旧用一个例子来说明面向接口编程比相对于面向实现编程好在什么地方。场景是这样的,母亲给孩子讲故事,只要给她一本书,她就可以照着书给孩子讲故事了。代码如下:
class Book{
public String getContent(){
return "很久很久以前有一个阿拉伯的故事……";
}
}
class Mother{
public void narrate(Book book){
System.out.println("妈妈开始讲故事");
System.out.println(book.getContent());
}
}
public class Client{
public static void main(String[] args){
Mother mother = new Mother();
mother.narrate(new Book());
}
}
运行结果:
妈妈开始讲故事
很久很久以前有一个阿拉伯的故事……
上述是面向实现的编程,即依赖的是Book这个具体的实现类;看起来功能都很OK,也没有什么问题。
运行良好,假如有一天,需求变成这样:不是给书而是给一份报纸,让这位母亲讲一下报纸上的故事,报纸的代码如下:
class Newspaper{
public String getContent(){
return "林书豪38+7领导尼克斯击败湖人……";
}
}
这位母亲却办不到,因为她居然不会读报纸上的故事,这太荒唐了,只是将书换成报纸,居然必须要修改Mother才能读。假如以后需求换成杂志呢?换成网页呢?还要不断地修改Mother,这显然不是好的设计。原因就是Mother与Book之间的耦合性太高了,必须降低他们之间的耦合度才行。
我们引入一个抽象的接口IReader。读物,只要是带字的都属于读物:
interface IReader{
public String getContent();
}
Mother类与接口IReader发生依赖关系,而Book和Newspaper都属于读物的范畴,他们各自都去实现IReader接口,这样就符合依赖倒置原则了,代码修改为:
class Newspaper implements IReader {
public String getContent(){
return "林书豪17+9助尼克斯击败老鹰……";
}
}
class Book implements IReader{
public String getContent(){
return "很久很久以前有一个阿拉伯的故事……";
}
}
class Mother{
public void narrate(IReader reader){
System.out.println("妈妈开始讲故事");
System.out.println(reader.getContent());
}
}
public class Client{
public static void main(String[] args){
Mother mother = new Mother();
mother.narrate(new Book());
mother.narrate(new Newspaper());
}
}
运行结果:
妈妈开始讲故事
很久很久以前有一个阿拉伯的故事……
妈妈开始讲故事
林书豪17+9助尼克斯击败老鹰……
这样修改后,无论以后怎样扩展Client类,都不需要再修改Mother类了。这只是一个简单的例子,实际情况中,代表高层模块的Mother类将负责完成主要的业务逻辑,一旦需要对它进行修改,引入错误的风险极大。所以遵循依赖倒置原则可以降低类之间的耦合性,提高系统的稳定性,降低修改程序造成的风险。
通过这样的面向接口编程,我们的代码就有了更好的扩展性,同时也降低了耦合,提高了系统的稳定性。
在实际编程中,我们一般需要做到如下3点:
- 低层模块尽量都要有抽象类或接口,或者两者都有。【可能会被人用到的】
- 变量的声明类型尽量是抽象类或接口。
- 使用继承时遵循里氏替换原则。
依赖倒置原则的核心就是要我们面向接口编程,理解了面向接口编程,也就理解了依赖倒置。
接口隔离原则
接口隔离原则,Interface Segregation Principle,简称ISP,其定义是:
客户端不应该依赖它不需要的接口
意思就是客户端需要什么接口就提供什么接口,把不需要的接口剔除掉,这就需要对接口进行细化,保证接口的纯洁性。换成另一种说法就是,类间的依赖关系应该建立在最小的接口上,也就是建立单一的接口。
你可能会疑惑,建立单一接口,这不是单一职责原则吗?其实不是,单一职责原则要求的是类和接口职责单一,注重的是职责,一个职责的接口是可以有多个方法的,而接口隔离原则要求的是接口的方法尽量少,模块尽量单一,如果需要提供给客户端很多的模块,那么就要相应的定义多个接口,不要把所有的模块功能都定义在一个接口中,那样会显得很臃肿。
举个例子,现在的智能手机非常的发达,几乎是人手一部的社会状态,在我们年轻人的观念里,好的智能手机应该是价格便宜,外观好看,功能丰富的,由此我们可以定义一个智能手机的抽象接口 ISmartPhone,代码如下所示:
public interface ISmartPhone {
public void cheapPrice();
public void goodLooking();
public void richFunction();
}
接着,我们定义一个手机接口的实现类,实现这三个抽象方法,
public class SmartPhone implements ISmartPhone{
public void cheapPrice() {
System.out.println("这手机便宜~~~~~");
}
public void goodLooking() {
System.out.println("这手机外观好看~~~~~");
}
public void richFunction() {
System.out.println("这手机功能真多~~~~~");
}
}
然后,定义一个用户的实体类 User,并定义一个构造方法,以ISmartPhone 作为参数传入,同时,我们也定义一个使用的方法usePhone 来调用接口的方法,
public class User {
private ISmartPhone phone;
public User(ISmartPhone phone){
this.phone = phone;
}
public void usePhone(){
phone.cheapPrice();
phone.goodLooking();
phone.richFunction();
}
}
可以看出,当我们实例化User类并调用其方法usePhone后,控制台上就会显示手机接口三个方法的方法体信息,这种设计看上去没什么大毛病,但是我们可以仔细想下,ISmartPhone这个接口的设计是否已经达到最优了呢?很遗憾,答案是没有,接口其实还可以再优化。
因为除了年轻人之外,中年商务人士也在用智能手机,在他们的观念里,智能手机并不需要丰富的功能,甚至不用考虑是否便宜 (有钱就是任性~~~~),因为成功人士都比较忙,对智能手机的要求大多是外观大气,功能简单即可,这才是他们心中好的智能手机的特征,这样一来,我们定义的 ISmartPhone 接口就无法适用了,因为我们的接口定义了智能手机必须满足三个特性,如果实现该接口就必须三个方法都实现,而对商务人员的标准来说,我们定义的方法只有外观符合且可以重用而已。你可能会说,我可以重写一个实现类啊,只实现外观的方法,另外两个方法置空,什么都不写,这不就行了吗?但是这也不行,因为 User 引用的是ISmartPhone 接口,它调用三个方法,你只实现了两个,那么打印信息就少了两条了,只靠外观的特性,使用者怎么知道智能手机是否符合自己的预期?
分析到这里,我们大概就明白了,其实ISmartPhone的设计是有缺陷的,过于臃肿了,按照接口隔离原则,我们可以根据不同的特性把智能手机的接口进行拆分,这样一来,每个接口的功能就会变得单一,保证了接口的纯洁性,也进一步提高了代码的灵活性和稳定性。
迪米特原则
迪米特原则,Law of Demeter,简称LoD,也被称为最少知识原则,它描述的规则是:
一个对象应该对其他对象有最少的了解
也就是说,一个类应该对自己需要耦合或调用的类知道的最少,类与类之间的关系越密切,耦合度越大,那么类的变化对其耦合的类的影响也会越大,这也是我们面向设计的核心原则:低耦合,高内聚。
迪米特法则还有一个解释:只与直接的朋友通信。
什么是直接的朋友呢?每个对象都必然与其他对象有耦合关系,两个对象的耦合就成为朋友关系,这种关系的类型很多,例如组合、聚合、依赖等。其中,我们称出现成员变量、方法参数、方法返回值中的类为直接的朋友,而出现在局部变量中的类则不是直接的朋友。也就是说,陌生的类最好不要作为局部变量的形式出现在类的内部。
举个例子,上体育课之前,老师让班长先去体务室拿20个篮球,等下上课的时候要用。根据这一场景,我们可以设计出三个类 Teacher(老师),Monitor (班长) 和 BasketBall (篮球),以及发布命令的方法command 和 拿篮球的方法takeBall,
public class Teacher {
// 命令班长去拿球
public void command(Monitor monitor) {
List<BasketBall> ballList = new ArrayList<BasketBall>();
// 初始化篮球数目
for (int i = 0;i<20;i++){
ballList.add(new BasketBall());
}
// 通知班长开始去拿球
monitor.takeBall(ballList);
}
}
public class BasketBall {
}
public class Monitor {
// 拿球
public void takeBall(List<BasketBall> balls) {
System.out.println("篮球数目:" + balls.size());
}
}
然后,我们写一个情景类进行测试:
public class Client {
public static void main(String[] args) {
Teacher teacher = new Teacher();
teacher.command(new Monitor());
}
}
结果显示如下:
篮球数目:20
虽然结果是正确的,但我们的程序其实还是存在问题,因为从场景来说,老师只需命令班长拿篮球即可,Teacher只需要一个朋友----Monitor,但在程序里,Teacher的方法体中却依赖了BasketBall类,也就是说,Teacher类与一个陌生的类有了交流,这样Teacher的健壮性就被破坏了,因为一旦BasketBall类做了修改,那么Teacher也需要做修改,这很明显违背了迪米特法则。
因此,我们需要对程序做些修改,在Teacher的方法中去掉对BasketBall类的依赖,只让Teacher类与朋友类Monitor产生依赖,修改后的代码如下:
public class Teacher {
// 命令班长去拿球
public void command(Monitor monitor) {
// 通知班长开始去拿球
monitor.takeBall();
}
}
public class Monitor {
// 拿球
public void takeBall() {
List<BasketBall> ballList = new ArrayList<BasketBall>();
// 初始化篮球数目
for (int i = 0;i<20;i++){
ballList.add(new BasketBall());
}
System.out.println("篮球数目:" + ballList.size());
}
}
这样一来,Teacher类就不会与BasketBall类产生依赖了,即时日后因为业务需要修改BasketBall也不会影响Teacher类。
开闭原则
开闭原则就是说对扩展开放,对修改关闭。在程序需要进行拓展的时候,不能去修改原有的代码,实现一个热插拔的效果。所以一句话概括就是:为了使程序的扩展性好,易于维护和升级。想要达到这样的效果,需要面向接口编程。
一个软件实体如类、模块和函数应该对扩展开放,对修改关闭。即软件实体应尽量在不修改原有代码的情况下进行扩展。
也就是说,一个软件实体应该通过扩展来实现变化,而不是通过修改已有的代码实现变化。这是为软件实体的未来事件而制定的对现行开发设计进行约束的一个原则。
在我们编码的过程中,需求变化是不断的发生的,当我们需要对代码进行修改时,我们应该尽量做到能不动原来的代码就不动,通过扩展的方式来满足需求。
开闭原则是面向对象设计中最基础的设计原则,它指导我们如何建立稳定灵活的系统。开闭原则可能是设计模式六项原则中定义最模糊的一个了,它只告诉我们对扩展开放,对修改关闭,可是到底如何才能做到对扩展开放,对修改关闭,并没有明确的告诉我们。以前,如果有人告诉我“你进行设计的时候一定要遵守开闭原则”,我会觉的他什么都没说,但貌似又什么都说了。因为开闭原则真的太虚了。
如果仔细思考以及仔细阅读很多设计模式的文章后,会发现其实,我们遵循设计模式前面5大原则,以及使用23种设计模式的目的就是遵循开闭原则。也就是说,只要我们对前面5项原则遵守的好了,设计出的软件自然是符合开闭原则的,这个开闭原则更像是前面五项原则遵守程度的“平均得分”,前面5项原则遵守的好,平均分自然就高,说明软件设计开闭原则遵守的好;如果前面5项原则遵守的不好,则说明开闭原则遵守的不好。
开闭原则无非就是想表达这样一层意思:用抽象构建框架,用实现扩展细节。因为抽象灵活性好,适应性广,只要抽象的合理,可以基本保持软件架构的稳定。而软件中易变的细节,我们用从抽象派生的实现类来进行扩展,当软件需要发生变化时,我们只需要根据需求重新派生一个实现类来扩展就可以了。当然前提是我们的抽象要合理,要对需求的变更有前瞻性和预见性才行。
说到这里,再回想一下前面说的5项原则,恰恰是告诉我们用抽象构建框架,用实现扩展细节的注意事项而已:
- 单一职责原则告诉我们实现类要职责单一;
- 里氏替换原则告诉我们不要破坏继承体系;
- 依赖倒置原则告诉我们要面向接口编程;
- 接口隔离原则告诉我们在设计接口的时候要精简单一;
- 迪米特法则告诉我们要降低耦合。
而开闭原则是总纲,他告诉我们要对扩展开放,对修改关闭。
总结
好了,面向对象的六大原则就介绍到这里了。其实,我们不难发现,六大原则虽说是原则,但它们并不是强制性的,更多的是建议。遵照这些原则固然能帮助我们更好的规范我们的系统设计和代码习惯,但并不是所有的场景都适用,就例如接口隔离原则,在现实系统开发中,我们很难完全遵守一个模块一个接口的设计,否则业务多了就会出现代码设计过度的情况,让整个系统变得过于庞大,增加了系统的复杂度,甚至影响自己的项目进度,得不偿失啊。
所以,还是那句话,在合适的场景选择合适的技术!
设计模式的六大原则主要如上,主要参考书籍有《设计模式》《设计模式之禅》《大话设计模式》、CSDN 的zhengzhb 博主以及网上一些零散的文章。下面将开始归纳总结下各个设计模式。

浙公网安备 33010602011771号