软件设计原则
在软件开发过程中,为了提高软件系统的可维护性和可复用性,增加软件的可扩展性和灵活性,程序员要尽量根据6条原则来开发程序,从而提高软件开发效率、节约软件开发成本和维护成本。
6种原则
6种原则分别为:开闭原则、里氏代换原则、依赖倒转原则、接口隔离原则、迪米特法则、合成复用原则。我将逐个进行分析,学习,理解。
开闭原则
对扩展开放,对修改关闭。
在程序需要拓展时,不能直接修改原有的代码,实现热插拔的效果(比如U盘、鼠标等),这样做可以使程序的扩展性好且易于维护和升级。
这时候我们就要用到接口和抽象类。抽象类灵活性好,适应性广,只要抽象合理,就能基本保证软件架构的稳定,而软件中经常改变的细节可以从抽象派生的实现类来进行扩展,当软件需要发生变化时,只需要根据需求重新派生一个实现类来扩展就可以完成需求。
举例: 王者某某中每个英雄都有自己的皮肤,皮肤是每个role的组成部分,用户可以自己选择喜欢的皮肤进行切换,不管是原皮还是伴生皮肤都有共同的特点,可以为其定义一个抽象类(AbstractSkin),而每个具体的皮肤(DefaultSkin)和(CompanySkin)是其子类。玩家可以根据自己的喜好选择皮肤而不需要修改源代码,所以它满足开闭原则。
部分代码如下:
//抽象皮肤类
public abstract class AbstractSkin {
public abstract void display();
}
//默认皮肤类
public class DefaultSkin extends AbstractSkin{
public void display(){
System.out.println("默认皮肤");
}
}
//伴生皮肤类
public class CompanySkin extends AbstractSkin{
public void display(){
System.out.println("伴生皮肤");
}
}
//角色类
public class role {
public AbstractSkin skin;
public void setSkin(AbstractSkin skin) {
this.skin = skin;
}
public void display(){
skin.display();
}
}
//测试类
public class test {
public static void main(String[] args) {
//创建角色对象
role roles = new role();
//创建皮肤对象,这里可以切换不同的皮肤。
//DefaultSkin skin = new DefaultSkin();
CompanySkin skin = new CompanySkin();
//将皮肤设置给角色
roles.setSkin(skin);
//显示皮肤
roles.display();
}
}
当用户更换默认皮肤时,系统程序输出为默认皮肤。
当更换为伴生皮肤时,输出为伴生皮肤。
总结:当系统想要增加皮肤时,不需要修改原有的抽象类及其他皮肤的代码,只需要新建一个类去继承AbstractSkin抽象类即可添加,而换皮肤则是在客户端更改代码。这就是开闭原则,对扩展开放,对修改关闭。
里氏代换原则
里氏代换原则是面向对象设计的基本原则之一。
里氏代换原则:任何基类可以出现的地方,子类一定可以出现。即:子类可以扩展父类的功能,但不能改变父类原有的功能。子类继承父类时,除添加新的方法完成新增功能外,尽量不要重写父类的方法。
如果通过重写父类方法来完成新的功能,虽然写起来简单,但是整体继承体系的可复用性会比较差,特别是运用多态比较频繁时,程序运行出错的概率会非常大。
举例:正方形是长方形不满足里氏代换原则。
在数学领域中,正方形是长宽相等的长方形。所以,我们在编写程序时可以让正方形继承长方形。
Rectangle类中有长宽两个成员变量以及它们get和set方法。
Square类中重写父类设置长和宽的两个方法。
RectangleDemo中的resize方法是当长方形的长大于宽时,对宽进行增加,使得宽能够大于长。还有一个printLengthAndWidth()方法是打印长和宽。
部分代码如下:
public class Rectangle {
//长方形类
public double length;
public double width;
public double getLength() {
return length;
}
public void setLength(double length) {
this.length = length;
}
public double getWidth() {
return width;
}
public void setWidth(double width) {
this.width = width;
}
}
//正方形类
public class Square extends Rectangle {
public void setLength(double length){
super.setLength(length);
super.setWidth(length);
}
public void setWidth(double Width){
super.setLength(Width);
super.setWidth(Width);
}
}
//测试类
public class RectangleDemo {
public static void main(String[] args) {
//创建长方形对象
Rectangle r =new Rectangle();
//设置长和宽
r.setLength(50);
r.setWidth(45);
//调用resize方法进行扩宽
resize(r);
printLengthAndWidth(r);
}
//扩宽方法
public static void resize(Rectangle rectangle){
//判断宽如果比长小,进行扩宽操作
while(rectangle.getWidth() <= rectangle.getLength()){
rectangle.setWidth(rectangle.getWidth()+1);
}
}
//打印长宽
public static void printLengthAndWidth(Rectangle rectangle) {
System.out.println(rectangle.getLength());
System.out.println(rectangle.getWidth());
}
}
运行代码后的预期效果和实际效果一致。
从语法上来说,此时我给resize传递一个Square也是成立的。
//创建正方形对象
Square s=new Square();
//设置长和宽
s.setLength(50);
//使用resize方法进行扩宽
resize(s);
printLengthAndWidth(s);
当我们运行代码之后可以得到
并没有输出,程序一直运行,这是因为当我们传入一个正方形时,他的长和宽都在一直增长,因为正方形长宽相等,只有当系统产生溢出错误的时候,程序才会停止。所以,普通的长方形是适合这段代码的,但正方形不适合。
**结论:**在resize方法中,Rectangle类型的参数是不能被Square类型的参数所替代的,如果进行了替换就得不到预期的效果,所以Square和Rectangle类之间的继承关系违反了里氏代换原则,它们之间的继承关系不成立。
修改:
此时我们可以重新设计它们之间的关系。抽象出来一个四边形接口(Quadriliateral),让Rectangle类和Square类实现Quadriliateral接口。
改进后的类图:
将Square和Rectangle抽象出一个四边形接口,接口类中包含getLength()和getWidth()两个方法,Square类实现Quadrilateral接口,其中包含side成员变量以及相应的get和set方法和重写了接口中的两个方法。Rectangle类中有length和width两个成员变量以及相应的get和set方法。RectangleDemo类中包含resize()扩宽方法以及printLengthAndWidth(Quadrilateral quadrilateral)打印方法。
部分代码如下:
//Rectangle类
public class Rectangle implements Quadrilateral{
public double length;
public double width;
public double getLength() {
return length;
}
public void setLength(double length) {
this.length = length;
}
public double getWidth() {
return width;
}
public void setWidth(double width) {
this.width = width;
}
}
//Square类
public class Square implements Quadrilateral{
public double side;
public double getSide() {
return side;
}
public void setSide(double side) {
this.side = side;
}
public double getLength() {
return side;
}
public double getWidth() {
return side;
}
}
//RectangleDemo类
public class RectangleDemo {
public static void main(String[] args) {
//创建长方形对象
Rectangle r = new Rectangle();
r.setLength(50);
r.setWidth(45);
//调用方法进行扩宽操作
resize(r);
printLengthAnhdWidth(r);
}
//扩宽方法
public static void resize(Rectangle rectangle){
while(rectangle.getWidth() <= rectangle.getLength()){
rectangle.setWidth(rectangle.getWidth()+1);
}
}
//打印长和宽
public static void printLengthAnhdWidth(Quadrilateral quadrilateral) {
System.out.println(quadrilateral.getLength());
System.out.println(quadrilateral.getWidth());
}
}
//interface
public interface Quadrilateral {
//获取长
double getLength();
//获取宽
double getWidth();
}
运行后得到的结果为:
此时,Square类的对象就不能使用resize方法。
总结: 子类可以扩展父类的功能,但不能改变父类原有的功能。子类继承父类时,除添加新的方法完成新增功能外,尽量不要重写父类的方法。
依赖倒转原则
高层模块不应该依赖底层模块,两者都应该依赖其抽象;抽象不应该依赖细节,细节应该依赖抽象,即要求对抽象进行编程,不要对实现进行编程,可以降低客户与实现模块间的耦合。
例:电脑组装
组装电脑需要配件cpu,硬盘,内存条等,只有配件都有,计算机才能正常运行。cpu有intel,amd等,硬盘可以选择希捷,西数等,内存条可以选择金士顿,海盗船等。
类图:
Computer由XiJieHardDisk、IntelCpu、KingstonMemory组成。Computer类中包含三个成员变量和他们对应的get、set方法和一个运行方法。XiJieHardDisk类中包含保存数据save和获取数据get方法。IntelCpu类中包含运行方法,KingstonMemory类中包含保存数据方法。
部分代码如下:
//硬盘类
public class XiJieHardDisk {
//存储数据的方法
public void save(String data){
System.out.println("使用希捷硬盘存储数据为:" + data);
}
//获取数据的方法
public String get(){
System.out.println("使用希捷硬盘取数据");
return "数据";
}
}
//cpu类
public class IntelCpu {
public void run(){
System.out.println("使用Intel处理器");
}
}
//内存条类
public class KingStonMemory {
public void save(){
System.out.println("使用金士顿内存条");
}
}
//电脑类
public class Computer {
public XiJieHardDisk hardDisk;
public IntelCpu cpu;
public KingStonMemory memory;
public XiJieHardDisk getHardDisk() {
return hardDisk;
}
public void setHardDisk(XiJieHardDisk hardDisk) {
this.hardDisk = hardDisk;
}
public IntelCpu getCpu() {
return cpu;
}
public void setCpu(IntelCpu cpu) {
this.cpu = cpu;
}
public KingStonMemory getMemory() {
return memory;
}
public void setMemory(KingStonMemory memory) {
this.memory = memory;
}
public void run() {
System.out.println("运行计算机");
String data = hardDisk.get();
System.out.println("从硬盘上获取的数据是:" + data);
cpu.run();
memory.save();
}
}
//test类
public class test {
public static void main(String[] args) {
//创建组件对象
XiJieHardDisk hardDisk = new XiJieHardDisk();
IntelCpu cpu = new IntelCpu();
KingStonMemory memory = new KingStonMemory();
//创建计算机对象
Computer c = new Computer();
//组装计算机
c.setCpu(cpu);
c.setHardDisk(hardDisk);
c.setMemory(memory);
//运行计算机
c.run();
}
}
代码运行结果:
由以上我们可以看出对于此程序而言,cpu,硬盘,内存条的品牌是固定的,用户不能根据自己的想法进行更换,这违背了开闭原则,所以我们将对此程序根据依赖倒转原则进行改进:
改进后的类图为:
对XiJieHardDisk、IntelCpu和KingstonMemory抽取出他们的父接口,他们实现了父接口,而Computer组合时不使用具体的实现类,而是组合抽象接口。如果后期有其他的品牌,我们只需要创建该品牌的实现类就可以了。
部分代码如下:
//cpu接口
public interface Cpu {
//运行cpu
public void run();
}
//内存条接口
public interface Memory {
public void save();
}
//硬盘接口
public interface HardDisk {
//存储数据
public void save(String data);
//获取数据
public String get();
}
//根据上面的代码把之前的三个类去实现这三个接口,然后进行组装
public class Computer {
public HardDisk hardDisk;
public Cpu cpu;
public Memory memory;
public HardDisk getHardDisk() {
return hardDisk;
}
public void setHardDisk(HardDisk hardDisk) {
this.hardDisk = hardDisk;
}
public Cpu getCpu() {
return cpu;
}
public void setCpu(Cpu cpu) {
this.cpu = cpu;
}
public Memory getMemory() {
return memory;
}
public void setMemory(Memory memory) {
this.memory = memory;
}
//运行计算机
public void run(){
System.out.println("运行计算机");
String data = hardDisk.get();
System.out.println("从硬盘上获取的数据是:" + data);
cpu.run();
memory.save();
}
}
//测试
public class test {
public static void main(String[] args) {
//创建计算机组件
HardDisk hardDisk = new XiJieHardDisk();
Cpu cpu = new IntelCpu();
Memory memory = new KingStonMemory();
//创建计算机对象
Computer c = new Computer();
//组装计算机
c.setHardDisk(hardDisk);
c.setCpu(cpu);
c.setMemory(memory);
//运行计算机
c.run();
}
}
程序运行后的结果为:
此时,当用户需要更换cpu或硬盘等,只需要创建实现类去实现接口而不需要修改原本的接口代码,这就符合了开闭原则。
总结: 对抽象进行编程,不要对实现进行编程。
接口隔离原则
客户端不应该被迫依赖于它不使用的方法;一个类对另一个类的依赖应该建立在最小的接口上。
例如:A类中有两个方法,B类需要用A类中的b()方法。
这里可以看出,B类被迫依赖于它不使用的方法c(),一个类对另一个类的依赖应该建立在最小的接口上。此时我们可以将A类中的两个方法声明为两个接口,让B类去实现A接口,这样就满足接口隔离原则,如果B类需要方法c(),则可以实现C接口。
举例:安全门
现在创建一个A品牌的安全门,该门具有防火、防水功能,我们可以将这两个功能提取成一个接口。
部分代码如下:
//安全门接口
public interface SafetyDoor {
//防火
void fireproof();
//防水
void waterproof();
}
//A类
public class ADoor implements SafetyDoor{
public void fireproof() {
System.out.println("防火");
}
public void waterproof() {
System.out.println("防水");
}
}
//test
public class test {
public static void main(String[] args) {
ADoor door = new ADoor();
door.fireproof();
door.waterproof();
}
}
程序结果为:
如果现在有一种B类门只防火不防水,我们就不能直接实现安全门接口,这样会违反接口隔离原则,所以我们对其进行改进。
改进后的类图:
部分代码如下:
//防火
public interface fireproof {
void fireproof();
}
//防水
public interface waterproof {
void waterproof();
}
//A类门
public class ADoor implements fireproof,waterproof{
public void fireproof() {
System.out.println("防火");
}
public void waterproof() {
System.out.println("防水");
}
}
//B类门
public class BDoor implements fireproof{
public void fireproof() {
System.out.println("防火");
}
}
//test
public class test {
public static void main(String[] args) {
//创建A类门对象
ADoor door = new ADoor();
//调用功能
door.waterproof();
door.fireproof();
System.out.println("----------");
//创建B门类对象
BDoor door1 = new BDoor();
door1.fireproof();
}
}
程序运行结果为:
总结: 客户端不应该被迫依赖于它不使用的方法;一个类对另一个类的依赖应该建立在最小的接口上。有人会问:那不是会有很多的接口?毫无疑问,但要注意java是不允许继承多个类的但可以继承多个接口。
迪米特法则
迪米特法则又叫最少知道原则。
只和你的直接朋友交谈,不跟陌生人说话。(Talk only to your immediate friends and not to stranges)。
含义:如果两个软件实体无须直接通信,那么就不应该发生直接的相互调用,可以通过第三方转发该调用。其目的是降低类之间的耦合度,提高模块的相对独立性,保护隐私信息。
迪米特法则中的朋友是指:当前对象本身、当前对象的成员对象、当前对象所创建的对象、当前对象的方法参数等,这些对象同当前对象存在关联、聚合或组合关系,可以直接访问这些对象的方法。
举例:明星的日常事务有经纪人负责处理,比如和粉丝见面,和媒体公司洽谈,这里明星的朋友是经纪人,而和粉丝和公司是陌生人,所以适合使用迪米特法则。
类图如下:
Star类中包含私有的name以及有参构造和获取名字的方法,Fans和Company同上,Agent包含3个成员变量以及对应的set方法和见面meeting方法、洽谈业务bussiness方法,为聚合关系。
部分代码如下:
//Star类
public class Star {
private String name;
public Star(String name) {
this.name = name;
}
public String getName() {
return name;
}
}
//Fans类
public class Fans {
private String name;
public Fans(String name) {
this.name = name;
}
public String getName() {
return name;
}
}
//Company类
public class Company {
private String name;
public Company(String name) {
this.name = name;
}
public String getName() {
return name;
}
}
//Agent类
public class Agent {
private Star star;
private Fans fans;
private Company company;
public void setStar(Star star) {
this.star = star;
}
public void setFans(Fans fans) {
this.fans = fans;
}
public void setCompany(Company company) {
this.company = company;
}
//和粉丝见面的方法
public void meeting(){
System.out.println(star.getName()+"和粉丝"+fans.getName()+"见面");
}
//和媒体公司洽谈的方法
public void business(){
System.out.println(star.getName()+"和"+company.getName()+"洽谈");
}
}
//test类
public class test {
public static void main(String[] args) {
//创建经纪人对象
Agent agent = new Agent();
//创建明星对象
Star star = new Star("ZCG");
agent.setStar(star);
//创建粉丝对象
Fans fans = new Fans("张三");
agent.setFans(fans);
//创建公司对象
Company company = new Company("学校");
agent.setCompany(company);
agent.meeting();//和粉丝见面
agent.business();//和公司洽谈
}
}
程序运行结果如下:
总结: 由本例子可知迪米特法则主要是为了降低明星与粉丝和公司之间的耦合度。迪米特法则目的是降低类之间的耦合度,提高模块的相对独立性,保护隐私信息。
合成复用原则
合成复用原则是指:尽量先使用组合或者聚合等关联关系来实现,其次才考虑使用继承关系来实现。
通常类的复用分为继承复用和合成复用两种。
接下来对比一下两者的优缺点:
继承复用优点:
简单,容易实现。
缺点:
1、继承复用破坏了类的封装性,因为继承会将父类的实现细节暴露给子类,父类对子类是透明的,所以这种复用又称“白箱”复用。
2、子类与父类的耦合度高。父类的实现的任何改变都会导致子类的实现发生变化,这不利于类的扩展和维护。
3、它限制了复用的灵活性。从父类继承而来的实现时静态的,在编译时已经定义,所以运行时不可能发生变化。
组合或聚合复用,可以将已有对象纳入新对象中,使之成为新对象的一部分,新对象可以调用已有对象的功能,它有以下优点:
1、它维持了类的封装性,因为成员对象的内部细节是新对象看不见的,所以这种复用又称为“黑箱”复用。
2、对象间的耦合度低,可以在类的成员位置声明抽象。
3、复用的灵活性高,这种复用可以在运行时动态进行,新对象可以动态地引用与成员对象类型相同的对象。
举例:汽车的分类
汽车按动力源可分为汽油汽车,电动汽车,光能汽车等,按颜色分为白色、红色等。如果同时考虑两种因素进行分类就会有很多种组合。
类图如下:
从上图可以看出,继承复用产生了很多子类,如果需要在定义新的能源或颜色(新的类),就会产生更多的子类,我们将把继承复用修改为聚合复用。
类图如下:
进过改进后,当添加光能汽车不需要添加对应颜色的子类。
总结: 尽量先使用组合或者聚合等关联关系来实现,其次才考虑使用继承关系来实现。
讲到这里六种软件设计原则已全部讲解完毕,下一章我将正式开始学习二十三种设计模式。有什么问题可以指出,互相学习。