软件设计原则---六大原则
软件设计原则
在软件开发中,程序员应尽量遵守这六条软件设计原则,这六条原则可以帮助我们提高软件系统的可维护性和可复用性,增加软件的可拓展性和灵活性。
软件设计六大原则:
- 开闭原则
- 里氏代换原则
- 依赖倒转原则
- 接口隔离原则
- 迪米特法则
- 合成复用原则
1、开闭原则
对拓展开放,对修改关闭
在程序需要拓展原有功能时,不能对原有代码进行修改,而要实现一个热插拔的效果:需要什么就添加上去,不要影响原来的程序功能。其目的在于使得程序可拓展性好,易于维护与升级。
要想达到这样的效果,我们需要使用接口和抽象类。
为什么呢?其实本质上接口和抽象类定义的就是规范,只要我们合理的抽象,它可以覆盖很大的一块功能实现,从而维持软件架构的稳定。
而那些易变的细节,则可以交给具体的实现类来完成,当软件需求发生变化,只需要再派生一个实现类完成功能即可。
这里某种程度上其实暗合了依赖倒转原则。
实现开闭原则简单实例:我们创建一个代表皮肤展示的接口,然后通过多个类实现该接口来完成皮肤的实现,最后通过一个测试类来进行测试。
//接口,表示皮肤展示的抽象意义
public interface Skin {
void showSkin();
}
//实现类一,实现了第一种皮肤的展示
public class ShowSkin01 implements Skin {
@Override
public void showSkin() {
System.out.println("Skin01");
}
}
//实现类二,实现了第二种皮肤的展示
public class ShowSkin02 implements Skin {
@Override
public void showSkin() {
System.out.println("Skin02");
}
}
//IoC简单实现,将选择何种皮肤的权利交给用户
public class Shower {
private Skin skin;
public void setSkin(Skin skin) {
this.skin = skin;
}
public void show(){
skin.showSkin();
}
}
//客户端,如果输入1,则展示皮肤1;如果输入2,则展示皮肤2;其他输入会显示无效输入
public class Client {
public static void main(String[] args) {
Shower shower = new Shower();
Scanner scanner = new Scanner(System.in);
int i = scanner.nextInt();
switch (i){
case 1:
shower.setSkin(new ShowSkin01());
shower.show();
break;
case 2:
shower.setSkin(new ShowSkin02());
shower.show();
break;
default:
System.out.println("input no sense!");
}
}
}
2、里氏代换原则
任何父类出现的地方,子类一定也可以出现
通俗理解就是,子类可以拓展父类的功能,补充原来没有的功能,但是,不能改变父类原有的功能。
从编程的角度来理解的话,那就是在子类继承父类时,尽量不要重写父类已经实现了的方法,而应该转而拓展出父类本来不具有的方法。
如果重写了父类已经实现了的方法,不仅会造成父类对该方法的定义浪费,而且在使用多态的时候会非常容易发生错误。
下面我们来实现一个经典反例:正方形不是长方形
从数学知识来讲,显然,正方形是长方形的一个特例,它特殊在正方形的长和宽永远是相等的,所以从逻辑上讲,当然我们应该将正方形作为长方形的子类进行编程。
//非常大众化定义的长方形,有宽和长
public class Rectangle {
private int length;
private int width;
public int getLength() {
return length;
}
public void setLength(int length) {
this.length = length;
}
public int getWidth() {
return width;
}
public void setWidth(int width) {
this.width = width;
}
}
//继承自长方形的正方形,由于正方形的特殊性,这里我们重写set方法,因为我们要保证正方形的长和宽永远相等(这一步违背了里氏代换原则)
public class Square extends Rectangle{
@Override
public void setLength(int length) {
super.setLength(length);
super.setWidth(length);
}
@Override
public void setWidth(int width) {
super.setWidth(width);
super.setLength(width);
}
}
/*
这里我们做一个应用,该应用旨在测试长方形的宽是否大于等于长,如果满足,那么长的值加一直到长比宽大为止。但是这里我们就会明显发现,虽然这
个地方可以放长方形对象,但一旦使用于正方形,就会形成死循环,因为我们重写了set方法,而正方形的set方法使得正方形的长和宽永远不可能不同,
也就无法跳出循环,从而造成死机
*/
public class RectangleTest {
public static void main(String[] args) {
Rectangle rectangle = new Rectangle();
rectangle.setLength(10);
rectangle.setWidth(100);
resize(rectangle);
System.out.println(rectangle.getLength()+" : "+rectangle.getWidth());
Rectangle square = new Square();
square.setLength(10);
resize(square);//程序永远无法走出这里
System.out.println(square.getLength()+" : "+square.getWidth());
}
public static void resize(Rectangle rectangle){
while(rectangle.getWidth()>= rectangle.getLength()){
rectangle.setLength(rectangle.getLength()+1);
}
}
}
以上这个例子我们发现,由于正方形的特殊性,导致我们在设计resize这种应用时,正方形无法适用于长方形能适用的方法,这违背了继承的基本原则,所以从程序的角度来审视,正方形其实并不是长方形。
所以这里如果我们要满足里氏代换原则,显然我们就不能让正方形继承自长方形,我们应该设计一个更抽象的类,使得长方形和正方形都继承自它。
所以改进方案是,我们设计一个平行四边形抽象类,该类拥有获得长和宽的两个get抽象方法。
然后我们的正方形可以继承平行四边形,引入属性边长,实现边长的set方法和两个get方法。
长方形则引入长和宽两个属性,分别实现二者的get和set。
如此一来,我们针对长方形实现的方法resize()
就不会对正方形生效,也就避免出现上面的错误。
3、依赖倒转原则
高层模块不应该依赖低层模块,二者都应该依赖其抽象。抽象不应该依赖细节,细节应该依赖抽象。
通俗理解就是应当对抽象进行编程,不要对实现进行编程,这样就降低了客户和模块之间的耦合。是不是还是不明白?
下面我们给出一个非常通俗易懂的例子:
假设:
我们的客户要求我们为他的电脑定制一套驱动程序,于是给了我们他的电脑配置
具体配置是:因特尔的CPU,金士顿的内存条,英伟达的显卡
于是我们给这台电脑以及三个部件编写驱动程序,如果我们编写如下:
class Computer{
private intelCPU cpu;
private NVIDIAGPU gpu;
private KinstonMemory memory;
/*
各种驱动的实现省略
*/
}
class intelCPU{
/*
各种驱动省略
*/
}
class NVIDIAGPU{
/*
各种驱动省略
*/
}
class KinstonMemory{
/*
各种内存省略
*/
}
显然,在正确的安装下,这台电脑配上这些驱动程序,是可以正常运行的。客户满意的收下了。
结果第二天,客户说他想换新的显卡,英伟达的显卡太贵了他买不起,于是换了AMD的显卡,结果电脑就开不了机了,要求我们重新编写一套驱动程序。
这下我们傻眼了,为什么?
因为我们的电脑类已经定死了只能使用英伟达的显卡,如果需要修改,除了重新写一个AMDGPU
类的驱动程序以外,还必须把整个电脑类翻新重写,我们得一点一点在这个类中找出当初写的所有和NVIDIAGPU
类有关的东西,然后把他们全部重新写一遍,替换成我们刚刚写好了的AMDGPU
,而如果我们真的这么做了,交付给客户以后。结果交付的第二天客户又说他换了条三星的内存,结果又打不开机了。。。那简直是吐血
显然,这种设计方法写出来的程序既是在***难用户,亦是在***难自己。
再重读一遍什么叫依赖倒转原则:高层模块不应该依赖低层模块,二者都应该依赖其抽象。抽象不应该依赖细节,细节应该依赖抽象。
其意思就很明显了:
- 我们的高级模块中所依赖的低级模块不能是真正的低级模块而应该是低级模块的抽象,可以是抽象类,可以是接口。
- 同样的,低级模块中的那些依赖其他类的地方也不能是直接的依赖,而应该是对其的抽象的依赖。
- 抽象不能依赖与任何已存在的实现,否则就不足以称为抽象了
- 已存在的实现都应当依赖于抽象
听起来就像绕口令,但说白了就是对类的依赖应尽量依赖其最抽象,最核心的部分,因为无论以后我们会用到该类的任何一个子类,都可以轻易的适配进去。比如之前的例子,如果我们改为如下情况
class Computer{
private CPU cpu;
private GPU gpu;
private Memory memory;
/*
各种驱动的抽象,省略
*/
}
//抽象类
abstract class CPU{
/*
各种驱动的抽象,省略
*/
}
abstract class GPU{
/*
各种驱动的抽象,省略
*/
}
abstract class Memory{
/*
各种驱动的抽象,省略
*/
}
//实现
class intelCPU extends CPU{
/*
各种驱动实现,省略
*/
}
class NVIDIAGPU extends GPU{
/*
各种驱动实现,省略
*/
}
class KinstonMemory extends Memory{
/*
各种内存实现,省略
*/
}
如此一来,无论我们的用户换了怎么样的硬件,我们都不会再像刚刚那样需要反复修改代码了。这就是依赖倒转原则的用意之所在。
4、接口隔离原则
客户端不应该被迫依赖于它不使用的方法,一个类对于另一个类的依赖应该建立于最小的接口上
也就是说,在进行面向对象编程的时候,我们要尽量将接口功能的实现类拆分开,不要让某一个实现类耦合了过多的接口功能,造成浪费。
下面来看个例子:假设我们有一个接口,它表示一个防盗门的功能,包括:防火,防水,防盗。
正好,客户交来的先进防盗门就能满足这三种功能,现在我们来实现一下看看。
interface safetyDoor{
void fireProof();
void waterProof();
void thiefProof();
}
class advanceSafetyDoor implements safetyDoor{
public void fireProof();
public void waterProof();
public void thiefProof();
}
class test{
public static void main(String args[]){
safetyDoor door = new advanceSafetyDoor();
door.fireProof();
door.waterProof();
door.thiefProof();
}
}
非常不错,先进防盗门完美的实现了防盗门的所有功能,在测试中也表现良好。另一家客户听说了这事,把他们的防盗门也给我们看了:
简约防盗门,功能:防盗。
那么现在显然,问题大了,我们的"防盗门"接口要求,一扇防盗门必须要能够防火,防水,防盗,但是客户的这家防盗门仅仅只能防盗,我们如果令该实现类就这样继承防盗门接口,显然有两种方法我们就无法实现,从而陷入两难的境地。
所以结论很明显了,我们不能将过多的功能耦合在一个接口里,需要对其进行最小化的拆分,方便后面复用继承
interface antiFire{
void fireProof();
}
interface antiWater{
void waterProof();
}
interface antiThief{
void thiefProof();
}
class advanceSafetyDoor implements antiFire, antiWater, antiThief{
public void fireProof();
public void waterProof();
public void thiefProof();
}
class simpleSafetyDoor implements antiThief{
public void antiThief();
}
class test{
public static void main(String args[]){
advanceSafetyDoor door = new advanceSafetyDoor();
door.fireProof();
door.waterProof();
door.thiefProof();
simpleSafetyDoor door_ = new simpleSafetyDoor();
door_.thiefProof();
}
}
如此一来,我们就可以成功的实现先进安全门和简约安全门的类了,二者都可以安全,顺利的运行。
这里再思考一个问题:先进安全门和简约安全门可不可以继承自同一个类?这样我们就能像之前几个原则中一样通过一个高度抽象的类概括所 有子类
答案是可以,在这里,我们只需要使用
antiThief
就可以创建两种类了。但是显然这么做不符合生活常识,因为如果从常识上判断,两种安全门肯定都属于安全门,而安全门都应该是门
但是!在编程中,抽象的方式一定不要是从实体上想,要尽量往方法上想,换个角度来说,安全门的基础功能是什么?
就是防盗
那么防盗的可不可以是其他东西?也可以,比如保安。那么如果我实现一个antiThief
,我就可以把这个接口带到门,安保,保险箱等等类中去,而不仅仅是局限在所谓的 "门" 这个实际概念上。这就是接口隔离原则的意义,因为现实中程序都是由许多功能组成的,而这些功能也同样可能可以应用到其他程序中,把这些功能一个个抽象出来,其意义远大于抽象出一个 "安全门"。那么答案就显而易见了,为了拓宽程序的可复用性,我们的抽象要尽量脱离实体的束缚,多从功能上考虑
5、迪米特法则
又称最小知识原则:即只跟你的直接朋友说话,不要跟陌生人说话,即便他是朋友的朋友
从程序角度上来说就是,如果两个软件实体无需直接通信,那么就不应当发生直接的相互调用,可以通过第三方转发该调用。简言之就是最小化类与类之间的依赖关系,其目的是降低模块之间的耦合度,提高模块的相对独立性。
迪米特法则中的直接朋友指的是:
- 当前类本身的对象
- 该对象的成员对象
- 该对象所创建的对象
- 当前类对象的方法参数
- 各个与当前对象存在聚合,组合,关联关系的对象
举一个经典的例子:
一个开发公司,有许多个软件工程师,显然,工程师与客户并不是朋友,而公司是客户的朋友,所以当客户需要开发某个软件的时候,他们无需去找工程师,而应该去找开发公司,再由开发公司把具体任务下发给这些工程师,这样才能保证各个部分都独立自主的处理自己的事务,降低各个部分的耦合性。
class Engineer{//工程师仅仅和公司是朋友
Company company;
String name;
public void develope();
}
class Company{//公司和工程师,客户是朋友
List<Engineer> engineers;
List<Mission> mission;
public void workOnMission(engineer,mission){
Mission mission = customer.getMission();
engineer.develope();
}
}
class Customer{
Mission mission;
public Mission getMission(){
return mission;
}
}
思考,这里为什么要这么做?可不可以让工程师直接与客户交朋友?
假设我们让工程师与客户交上了朋友,比如说令
develope()
方法需要一个参数develope(Customer customer)
,这会带来什么影响呢?就上面这个程序而言,你完全可以这么做,但是假设有一天,不是客户,而是老板突发奇想,需要工程师来做一个公司管理系统的开发任务。老板虽然也有
Mission
,但是老板并不是Customer
那么要怎么办?你当然可以再在engineer
类中写一个参数是boss
的方法,但其中的绝大多数代码都会是冗余的,因为实际上工程师只关心Mission
,不关心给的人是谁,如果在company
中添加一个workOnBoss()
方法显然就要简单的多了。这就是降低耦合性的意义
6、合成复用原则
尽量使用聚合或组合的方式来进行复用,其次才考虑使用继承来进行复用
类的复用分两种:
- 继承复用
- 合成复用
继承复用虽然简单且易于实现,但也有如下缺点:
- 继承复用破坏了类的封装性,父类的一切属性都会暴露给子类,因而这种复用亦被称为"白箱复用"
- 子类与父类耦合度极高,父类的任何变化都会导致子类一同变化,不利于维护
- 限制了复用的灵活性,继承而来的实现是静态的,一经继承即确定,无法在运行中变化
与之对应,采用合成复用也就会有如下优点了:
- 合成复用维持了类的封装性,即便一个类是另一个类的成员,也必须通过该成员类自己的方法才能访问其内部的组成,亦称"黑箱复用"
- 对象间的耦合度低,我们可以在成员中声明抽象,即便成员对象改变,对于其他类来说,这些对象也是一个"黑盒子",无需关心其内部的实现细节
- 复用灵活性高,这种复用可以在运行时动态地进行,对于该类的不同对象,可以引用同类的不同对象作为成员对象
这里可以用一个很好的例子来诠释这两种复用的巨大差别:
假设我们需要实现一个汽车类,它有两个成员对象:汽车颜色,能源种类
其中颜色有红,蓝两种,能源有汽油,电力两种。
那么,如果我们仅仅采用继承复用不使用组合复用来实现汽车类:
class car{
}
class redCar extends car{
private Red red;
}
class blueCar extends car{
private Blue blue;
}
class redElecCar extends redCar{
private Electronic elec;
}
class redGasCar extends redCar{
private Gas gas;
}
class blueElecCar extends blueCar{
private Electronic elec;
}
class blueGasCar extends blueCar{
private Gas gas;
}
显然,这么做是很蠢的,本来我们只需要组合Color和Energy两个抽象类就能做到控制汽车的这两个成员对象,现在我们创建了如此多的子类才做到相同的事情。假设哪天客户突然说,他们又开发了四种颜色,两种新能源,那我们的软件工程师估计当天就要全部辞职了。
而且这么做的耦合度极高,一旦Car类中某个方法做出了修改,我们会额外影响整整六个类,在项目中,这样的做法是十分危险的。
而如果换成合成复用呢?
class car{
private Energy energy;
private Color color;
car(Energy energy,Color color){
this.energy = energy;
this.color = color
}
}
interface Energy{
void energize();
}
interface Color{
void showColor();
}
class red implements Color{
public void showColor(){
showRed;
}
}
class blue implements Color{
public void showColor(){
showblue;
}
}
class gasoline implements Energy{
public void energize(){
gasoEnergize();
}
}
class elecity implements Energy{
public void energize(){
elecEnergize();
}
}
显然,这么做的话,我们如果需要一辆蓝色电力车,我们只需要Car car = new car(new elecity,new blue);
这么一句话就足够了,而如果我们需要开发新的能源和颜色,只需要实现我们的Energy
和Color
接口,就可以完成添加,非常方便快捷。这就是合成复用的好处。