1、单一职责原则(single responsibility principle )
There should never be more than one reason for a class to change.
所谓单一职责原则,就是对一个类而言,应该仅有一个引起它变化的原因。换句话说,一个类的功能要单一,只做与它相关的事情。在类的设计过程中要按职责进行设计,彼此保持正交,互不干涉。
什么是职责?
在SRP 中,职责定义为“变化的原因”。如果你能够想到多于一个的动机去改变一个类,那么该类就具有多于一个的职责。
为什么要采用单一职责原则?
因为每一个职责都是变化的一个轴线,当需求变化时,该变化会反映为类的职责的变化。如果一个类承担了多于一个的职责,那么就意味着引起它的变化的原因会有多个。如果一个类承担的职责过多,那么就等同于把这些职责耦合在了一起。一个职责的变化可能会抑制到该类完成其他职责的能力,这样的耦合会导致脆弱的设计。当变化发生时,设计会受到意想不到的破坏。单一职责原则正是实现高内聚低耦合需要遵守的一个原则。
注意: 单一职责原则简单而直观,但在实际应用中很难实现。只有变化的轴线仅当实际发生时才具有真正的意义。如果没有预兆,那么去应用SRP或者其他任何的原则都是不明智的。
下面就Modem接口为例,说明其原则。
大多数会认为看起来非常合理,该接口声明的4个函数确实是调制解调器的功能。 然而,该接口中却显示出两个职责。第一个职责连接管理,第二个职责是数据通信。dial和hangup函数进行调制解调器的连接管理,而send和recv负责进行数据通信。这两个职责应该被分开吗?这依赖于应用程序变化的方式。如果应用程序的变化会影响到连接函数的签名,那么这个设计就是僵硬的设计,因为调用send和 recv的类必须重新编译、部署的次数会超过我们预想的情况。在这种情况下,这两个职责必须被分离,我们分别实现这两个职责于:
高内聚、低耦合解释:
这是软件工程中的概念,是判断设计好坏的标准,主要是面向OO的设计,主要是看类的内聚性是否高,偶合度是否低
首先要知道一个软件是由多个子程序组装而成, 而一个程序由多个模块(方法)构成!
“高内聚,低耦合”主要是阐述的面向对象系统中,各个类需要职责分离的思想。
每一个类完成特定的独立的功能,这个就是高内聚。耦合就是类之间的互相调用关系,如果耦合很强,互相牵扯调用很多,那么会牵一发而动全身,不利于维护和扩展。
类之间的设置应该要低耦合,但是每个类应该要高内聚.耦合是类之间相互依赖的尺度.如果每个对象都有引用其它所有的对象,那么就有高耦合,这是不合乎要求的,因为在两个对象之间,潜在性地流动了太多信息.低耦合是合乎要求的:它意味着对象彼此之间更独立的工作.低耦合最小化了修改一个类而导致也要修改其它类的"连锁反应". 内聚是一个类中变量与方法连接强度的尺度.高内聚是值得要的,因为它意味着类可以更好地执行一项工作.低内聚是不好的,因为它表明类中的元素之间很少相关.成分之间相互有关联的模块是合乎要求的.每个方法也应该高内聚.大多数的方法只执行一个功能.不要在方法中添加'额外'的指令,这样会导致方法执行更多的函数。
推广开来说,这个思想并不限于类与类之间的关系。模块和模块,子系统之间也都要遵守这个原则,才可以设计出延展性比较强的系统。
开闭原则(Open-Closed Principle,OCP)
1、“开-闭”原则的定义及优点
1)定义:一个软件实体应当对扩展开放,对修改关闭( Software entities should be open for extension,but closed for modification.)。即在设计一个模块的时候,应当使这个模块可以在不被修改的前提下被扩展。
2)满足“开-闭”原则的系统的优点
a)通过扩展已有的软件系统,可以提供新的行为,以满足对软件的新需求,使变化中的软件系统有一定的适应性和灵活性。
b)已有的软件模块,特别是最重要的抽象层模块不能再修改,这就使变化中的软件系统有一定的稳定性和延续性。
c)这样的系统同时满足了可复用性与可维护性。
2、如何实现“开-闭”原则
在面向对象设计中,不允许更改的是系统的抽象层,而允许扩展的是系统的实现层。换言之,定义一个一劳永逸的抽象设计层,允许尽可能多的行为在实现层被实现。
解决问题关键在于抽象化,抽象化是面向对象设计的第一个核心本质。
对一个事物抽象化,实质上是在概括归纳总结它的本质。抽象让我们抓住最最重要的东西,从更高一层去思考。这降低了思考的复杂度,我们不用同时考虑那么多的东西。换言之,我们封装了事物的本质,看不到任何细节。
在面向对象编程中,通过抽象类及接口,规定了具体类的特征作为抽象层,相对稳定,不需更改,从而满足“对修改关闭”;而从抽象类导出的具体类可以改变系统的行为,从而满足“对扩展开放”。
对实体进行扩展时,不必改动软件的源代码或者二进制代码。关键在于抽象。
3、对可变性的封装原则
“开-闭”原则也就是“对可变性的封装原则”(Principle of Encapsulation of Variation ,EVP)。即找到一个系统的可变因素,将之封装起来。换言之,在你的设计中什么可能会发生变化,应使之成为抽象层而封装,而不是什么会导致设计改变才封装。
“对可变性的封装原则”意味着:
a)一种可变性不应当散落在代码的许多角落,而应当被封装到一个对象里面。同一可变性的不同表象意味着同一个继承等级结构中的具体子类。因此,此处可以期待继承关系的出现。继承是封装变化的方法,而不仅仅是从一般的对象生成特殊的对象。
b)一种可变性不应当与另一种可变性混合在一起。作者认为类图的继承结构如果超过两层,很可能意味着两种不同的可变性混合在了一起。
使用“可变性封装原则”来进行设计可以使系统遵守“开-闭”原则。即使无法百分之百的做到“开-闭”原则,但朝这个方向努力,可以显著改善一个系统的结构。
里氏代换原则(Liskov Substitution Principle, LSP)
1、里氏代换原则定义
若对于每一个类型S的对象o1,都存在一个类型T的对象o2,使得在所有针对T编写的程序P中,用o1替换o2后,程序P的行为功能不变,则S是T的子类型。
What is wanted here is something like the following substitution property: 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. (意思就是程序P满足以下特性:是针对更高的抽象类型来编程。以上为例,这个抽象类型指的就是T。)
即,一个软件实体如果使用的是一个基类的话,那么一定适用于其子类。而且它觉察不出基类对象和子类对象的区别。也就是说,在软件里面,把基类都替换成它的子类,程序的行为没有变化。反过来的代换不成立,如果一个软件实体使用的是一个子类的话,那么它不一定适用于基类。
2、里氏代换原则与“开-闭”原则的关系
实现“开-闭”原则的关键步骤是抽象化。基类与子类之间的继承关系就是抽象化的体现。因此里氏代换原则是对实现抽象化的具体步骤的规范。违反里氏代换原则意味着违反了“开-闭”原则,反之未必。
3、里氏代换原则的四层含义
1)子类必须完全实现父类的方法。在类中调用其他类是务必要使用父类或接口,如果不能使用父类或接口,则说明类的设计已经违背了LSP原则。
2)子类可以有自己的个性。子类当然可以有自己的行为和外观了,也就是方法和属性
3)覆盖或实现父类的方法时输入参数可以被放大。即子类可以重载父类的方法,但输入参数应比父类方法中的大,这样在子类代替父类的时候,调用的仍然是父类的方法。即以子类中方法的前置条件必须与超类中被覆盖的方法的前置条件相同或者更宽松。
4)覆盖或实现父类的方法时输出结果可以被缩小。
4、里氏代换原则在设计模式中的体现
策略模式(Strategy)
如果有一组算法,那么就将算法封装起来,使得它们可以互换。客户端依赖于基类类型,而变量的真实类型则是具体策略类。这是具体策略焦色可以“即插即用”的关键。
合成模式(Composite)
合成模式通过使用树结构描述整体与部分的关系,从而可以将单纯元素与符合元素同等看待。由于单纯元素和符合元素都是抽象元素角色的子类,因此两者都可以替代抽象元素出现在任何地方。里氏代换原则是合成模式能够成立的基础。
代理模式(Proxy)
代理模式给某一个对象提供一个代理对象,并由代理对象控制对原对象的引用。代理模式能够成立的关键,就在于代理模式与真实主题模式都是抽象主题角色的子类。客户端只知道抽象主题,而代理主题可以替代抽象主题出现在任何需要的地方,而将真实主题隐藏在幕后。里氏代换原则是代理模式能够成立的基础。
5、总结
里氏代换原则是对开闭原则的扩展,它表明我们在创建基类的新的子类时,不应该改变基类的行为。也就是不要消弱基类的行为。
面向对象的设计关注的是对象的行为,它是使用“行为”来对对象进行分类的,只有行为一致的对象才能抽象出一个类来。我经常说类的继承关系就是一种“Is-A”关系,实际上指的是行为上的“Is-A”关系,可以把它描述为“Act-As”。
如果父类仅仅声明了抽象方法,而各派生类分别实现了该方法,那么就如同实现接口一样,可达到多态的目的;
如果父类实现了某方法,那么它对外所描述的行为也就确定了,派生类如果重写这个方法,那么就修改了这个行为,当派生类实例被父类型的引用使用时,表现出的行为与父类本身的实例不相符,即违反了Liskov substitution principle。
现实中,正方形是矩形的一种特殊形式。
现在先有Rectangle作为父类,具有height和width两个property(有邪恶的getter/setter);再有Square继承Rectangle做子类,由于Square长与高相等,所以重写height和width的set方法为同时设置长与高。至此似乎是设计与现实完全相符,接着有人这样调用了:
1
2
3
4
5
6
7
8
9
10
11
|
void
tryAreaCalculation(Rectangle rectangle) {
rectangle.setHeight(
4
);
rectangle.setWidth(
5
);
assertEquals(
20
, rectangle.area());
}
....
tryAreaCalculation(
new
Square());
|
结果assertion失败,“expect: 20, but was 25.” 也许我们会说,明知道传入的是Square对象还写那样的assertion是不可能出现的,但不清楚程序的人如果只看tryAreaCalculation方法,就会认为“矩形面积=长×高=4×5=20“是理所当然的。这显然不是我们想看到的事。
当然,这个例子并不是Liskov替换原则所针对问题的典型例子,在以“不择手段地复用代码”为目的的继承例子中(class Students extends List<Student>),大家更能明白Liskov替换原则的意义,以及对组合和继承的选择应首先去考虑面向对象的模型。
是不是觉得我把类继承妖魔化了呢?有没有因为担心人类被官员类继承就不敢给人类添加“说话”的方法呢?不必这样。我们在用面向对象编程时要清楚,什么是从面向对象建模角度认为对的事,什么是所使用的编程语言能够做到的事。
如果你手上的是Java语言,发现官员会说话,人也会说话,官员又是人的一种,那么就在人类中实现“说话”这个方法,再让官员类继承人类(即便在未来可能会在官员类中重写说话方法,加入说空话的语句),这一切已是你能做到的合理的事了。但请心里清楚,这并不是使用OOP对待此模型的最正确方案,因为Java语言的限制。
如果编程语言有了如Mixin的特性(例如Ruby中的Mixin、Scala中的trait,下面以Ruby为例),那么你会发现在这个问题上你能有更好的解决方案。为了官员、教授等也是人的事实,我们依旧让这些类继承人类,但除了固有属性如身高、性别,以及固有行为如睡觉,不继承任何可变的因素;把可能共用的因素都单另放在Module中,之后在需要的类中Mixin进来,如为程序员、司机、中学生等类Mixin普通“说话”方法所在的Module,而在官员类中定义含说空话逻辑的“说话”方法。
类的继承原则:如果一个继承类的对象可能会在基类出现的地方出现运行错误,则该子类不应该从该基类继承,或者说,应该重新设计它们之间的关系。
动作正确性保证:符合里氏代换原则的类扩展不会给已有的系统引入新的错误。
接口隔离原则(Interface Segregation Principle)
1、接口隔离原则的定义:
第一种定义: Clients should not be forced to depend upon interfaces that they don't use.客户端不应该依赖它不需用的接口。
第二种定义:The dependency of one class to another one should depend on the smallest possible interface。一个类对另外一个类的依赖性应当是建立在最小的接口上的。
换句话说,使用多个专门的接口比使用单一的总接口总要好,建立单一接口,不要建立臃肿庞大的接口。一个接口代表一个角色,不应当将不同的角色都交给一个接口。没有关系的接口合并在一起,形成一个臃肿的大接口,这是对角色和接口的污染。不应当将几个不同的角色都交给同一个接口,而应当交给不同的接口。
2、接口污染定义:
所谓接口污染就是为接口添加了不必要的职责。在接口中加一个新方法只是为了给实现类带来好处,以减少类的数目。持续这样做,接口就被不断污染,变胖。实际上,类的数目根本不是什么问题,接口污染会带来维护和重用方面的问题。最常见的问题是我们为了重用被污染的接口,被迫实现并维护不必要的方法。因此,我们必须分离客户程序,分离客户程序就是分离接口。
3、分离接口的实现方法:
分离接口的方式一般分为两种:
1) 使用委托分离接口。(Separation through Delegation)
就把请求委托给别的接口的实现类来完成需要的职责,就是适配器模式(Adapter)。
2) 使用多重继承分离接口。(Separation through Multiple Inheritance。)
该方法通过实现多个接口来完成需要的职责。
两种方式各有优缺点,通常我们应该先考虑后一个方案,如果涉及到类型转换时则选择前一个方案。
4、实例
假如有一个Door,有lock,unlock功能,另外,可以在Door上安装一个Alarm而使其具有报警功能。用户可以选择一般的Door,也可以选择具有报警功能的Door。
要遵循ISP设计原则,方案如下:
1、在IAlarm接口定义alarm方法,在IDoor接口定义lock,unlock方法。接口之间无继承关系。CommonDoor实现IDoor接口。
public interface IDoor {
public void lock();
public void unlock();
}
public interface IAlarm {
public void alarm();
}
public class CommonDoor implements IDoor {
public void lock() {
System.out.println("CommonDoor is lock!");
}
public void unlock() {
System.out.println("CommonDoor is unlock!");
}
}
AlarmDoor有2种实现方案:
1)同时实现IDoor和IAlarm接口。
public class AlarmDoor implements IDoor, IAlarm {
public void lock() {
System.out.println("AlarmDoor is lock!");
}
public void unlock() {
System.out.println("AlarmDoor is unlock!");
}
public void alarm() {
System.out.println("AlarmDoor is alarm!");
}
}
2)继承CommonDoor,并实现Alarm接口。该方案是继承方式的Adapter设计模式的实现。
此种方案更具有实用性。
public class AlarmDoor extends CommonDoor implements IAlarm {
public void lock() {
super.lock();
}
public void unlock() {
super.unlock();
}
public void alarm() {
System.out.println("AlarmDoor is alarm!");
}
}
2、采用委让实现
public interface IDoor {
public void lock();
public void unlock();
}
public interface IAlarm {
public void lock();
public void unlock();
public void alarm();
}
public class CommonDoor implements IDoor {
public void lock() {
System.out.println("CommonDoor is lock!");
}
public void unlock() {
System.out.println("CommonDoor is unlock!");
}
}
采用委托的方式即采用对象适配器的方式
public class AlarmDoor implements IAlarm {
private CommonDoor commdoor=new CommonDoor();
public void lock() {
commdoor.lock();
}
public void unlock() {
commdoor.unlock();
}
public void alarm() {
System.out.println("AlarmDoor is alarm!");
}
}
5、小结
如果已经设计成了胖接口,可以使用适配器模式隔离它。像其他设计原则一样,接口隔离原则需要额外的时间和努力,并且会增加代码的复杂性,但是可以产生更灵活的设计。如果我们过度的使用它将会产生大量的包含单一方法的接口,所以需要根据经验并且识别出那些将来需要扩展的代码来使用它。
依赖倒置原则(Dependence Inversion Principle)
1、依赖倒置原则的定义
1)上层模块不应该依赖于底层模块,它们都应该依赖于抽象。
2)抽象不应该依赖于细节,细节应该依赖于抽象,要针对接口编程,不要针对实现编程。
Abstractions should not depend upon details,Details should depend upon abstractions.Program to an interface, not an implementation.
也就是说应当使用接口和抽象类进行变量类型声明、参数类型声明、方法返还类型说明,以及数据类型的转换等。而不要用具体类进行变量的类型声明、参数类型声明、方法返还类型说明,以及数据类型的转换等。要保证做到这一点,一个具体类应当只实现接口和抽象类中声明过的方法,而不要给出多余的方法。
基于这个原则,设计类结构的方式应该是从上层模块到底层模块遵循这样的结构:上层类--->抽象层--->底层类。
2、依赖倒置原则与开闭原则的关系
“开-闭”原则与依赖倒转原则是目标和手段的关系。如果说开闭原则是目标,依赖倒转原则是到达"开闭"原则的手段。如果要达到最好的"开闭"原则,就要尽量的遵守依赖倒转原则,依赖倒转原则是对"抽象化"的最好规范。里氏代换原则是依赖倒转原则的基础,依赖倒转原则是里氏代换原则的重要补充。
3、实例
下面是一个违反了依赖倒转原则的例子。我们有一个上层类Manager和底层类Worker。我们需要在程序中添加一个新模块,因为有新的特殊的工作者被雇用。为此,我们创建一个新的类SuperWorker。
假设Manager是一个包含非常复杂的逻辑的类,现在为了引入新的SuperWorker,我们需要修改它。让我们看一下这有哪些缺点:
(1)我们需要修改Manager类(记住,它是一个非常复杂的类,这需要一些时间和努力)。
(2)Manager类的一些现有功能可能会受到影响。
(3)需要重做单元测试。
所有的这些问题需要大量的时间去解决。但是如果程序的设计符合依赖倒转原则将会非常简单。意思是我们设计Manager类和一个IWorker接口以及一些实现了该接口的Worker类。当需要添加SuperWorker类时我们只需要让它实现IWorker接口。
下面是支持依赖倒转原则的代码。在这个新的设计中,我们增加了一个新的抽象层IWork接口。现在,上面的问题得到了解决:
不需要修改Manager类。
使对Manager类现有功能的影响最小化。
不需要对Manager类重新进行单元测试。
4、总结
应用该原则意味着上层类不直接使用底层类,他们使用接口作为抽象层。这种情况下上层类中创建底层类的对象的代码不能直接使用new操作符。可以使用一些创建型设计模式,例如工厂方法,抽象工厂和原型模式。
模版设计模式是应用依赖倒转原则的一个例子。
当然,使用该模式需要额外的努力和更复杂的代码,不过可以带来更灵活的设计。不应该随意使用该原则,如果我们有一个类的功能很有可能在将来不会改变,那么我们就不需要使用该原则。
迪米特原则(Law of Demeter)
1、迪米特原则的定义
迪米特原则也叫最少知识原则(Least Knowledge Principle, LKP)简单的说,就是如果两个类不必彼此直接通信,那么这两个类就不应当发生直接的相互作用。如果一个类需要调用类另一个类的某个方法的话,应当通过第三个类来转发调用。迪米特法则可以简单说成:talk only to your immediate friends。
2、迪米特原则解释
一个软件实体应当尽可能少的与其他实体发生相互作用。每一个软件单位对其他的单位都只有最少的知识,而且局限于那些与本单位密切相关的软件单位。迪米特法则减少耦合的问题,类之间的耦合越弱,越有利于复用,一个处在弱耦合的类被修改,不会对有关系的类造成波及。也就是说,信息的隐藏促进了软件的复用。
3、迪米特原则含义
(1)、talk only to your immediate friends.只和直接朋友通信。在一个类中,出现在成员变量、方法的输入输出参数中的类被称为成员朋友类。
实例:张三李四是朋友关系,李四王五是朋友关系.张三和王五是陌生人关系,为了保证张三和王五不认识,而张三又能调用王五中的方法,通过一个李四作为中继,把张三和王五隔离开来。
(2)、迪米特法则就要求类“小气”一点,尽量不要对外公布太多的public 方法和非静态的public 变量,尽量内敛,
多使用private,package-private、protected 等访问权限。
实例:在软件的安装中,一个类中定义了多个步骤,同时一个类要调用此类中的方法完成安装,此时可以在客户端的类中随即地调用这些步骤,这样前边的类就定义多个public的方法,我们转换一种思路,把这些步骤方法都设置为private,同时,引入一个总的方法,在总的方法中调用安装步骤方法,避免了客户类和此类中多个方法的接触。
(3)、如果一个方法放在本类中,即不增加类间关系,也对本类不产生负面影响,就放置在本类中。组合/聚合与之相似。
(4)、谨慎使用Serializable。
4、迪米特原则优缺点
由于狭义迪米特法则要求当两个类不必直接通信时,进行方法调用应当由第三个类来转发调用,这样就会在系统里制造出大量的小方法,是的系统显的凌乱。
迪米特法则的知识最小化,可以使得局部的模块得到简化,但是同时也会造成模块间的通信效率降低,使模块间不容易协调。
迪米特法则讨论的是对象之间信息的流量,方向以及信息影响的控制。其主要意图是控制信息过载,这就要求一个模块一个应当尽可能将自己的内部数据和实现细节隐藏起来,也就是要求有良好的封装。
因此,在类设计上的应用优先考虑将一个类设计成不变类,即使这个类是可变的在给她的属性设置赋值方法时,也应当尽可能的吝啬,除非确实需要,否则不要对该属性增加赋值方法。尽量降低一个类的访问权限,谨慎使用Serializable,尽量降低成员的访问权限。
设计模式的门面模式(Facade)和中介模式(Mediator),都是迪米特法则应用的例子。