软件架构(软件体系结构)-设计原则篇(七大设计原则)
1、软件架构设计原则概述
1.1、软件的可维护性
1、软件的维护
一个好的软件设计可维护性较好的系统,必须能够允许新的设计要求很容易地加入到已有的系统中。
2、具有可维护性的设计目标
一个好的系统设计应该有如下所示的性质:
- 可扩展性:新的性能可以很容易的加入系统中。
- 灵活性:代码修改不会波及很多其他的模块。
- 可插入性:可以很容易地将一个类用另一个有同样接口的类代替。
1.2、系统的可复用性
1、传统的复用:
- 代码的剪贴复用:容易产生错误
- 算法的复用:将已经得到很好的研究的算法拿来复用
- 数据结构的复用:将研究透的数据结构拿来复用。
2、面向对象向设计的复用
面向对象的语言中(Java),数据的抽象化、继承、封装和多态性等语言特性使得一个系统可在更高层次上提供可复用性。
1.3、可维护性复用、设计原则和设计模式
在面向对象的设计中,可维护性复用是以设计原则和设计模式为基础的。
设计原则是在提高一个系统可维护性的同时,提高这个系统的可复用性的指导原则,依照这些设计原则进行系统设计,可以实现可维护性复用。
设计原则包含以下七种:
- 开-闭原则
- 里氏代换原则
- 依赖倒转原则
- 接口隔离原则
- 组合聚合复用原则
- 迪米特法则
- 接口隔离原则
- 单一职责原则
设计模式又分为以三大类别:
- 创建模式
- 结构模式
- 行为模式
考虑篇幅问题,本篇终点讲解设计原则,三大类别设计模式另外写博客进行讲解。
下面开始七大设计原则的讲解。
2、开- 闭原则
概念:对扩展开放,对修改关闭(精髓)。
在设计一个模块中,应当使这个模块在不被修改源代码的前提下被扩展——改变这个模块的行为。
所谓的开始,是用抽象构建框架,用实现扩展细节。可以提高软件系统的可维护性和可复用性。开闭原则是面向对象中最基础的原则,实现开闭原则的基本思想就是面向抽象编程。
与其它原则之间的关系
(不了解其它原则也别急,继续往下看,看完后再回过来看关系,说不定另有收获!)
- 里氏代换原则:任何父类可以出现的地方,子类一定可以出现。是对开-闭原则的补充,是实现抽象化的具体步骤的规范。
- 依赖倒转原则:要依赖于抽象,不要依赖于实现。依赖倒转原则与开-闭原则之间是目标和手段之间的关系:要想实现“开-闭原则”,就必须坚持依赖倒转原则,否则不可能达到开-闭原则的要求。
- 合成/聚合复用原则:要尽量使用合成/聚合实现复用,而不是继承。遵守合成/聚合复用原则是实现开-闭原则的必要条件。
- 接口隔离原则:应当为客户端提供尽可能小的单独的接口,而不要提供大的总接口。这是对一个软件实体与其它软件实体的通信的限制。
- 迪米特法则:一个软件实体应当与尽可能少的其它实体发生相互作用。当一个系统功能扩展时,模块如果是孤立的,就不会影响到其他的对象。遵守迪米特法则的系统在功能需要扩展时,会相对容易地做到对修改的关闭。
实例
下面看一个开-闭原则的java例子:
/**
* @name: Single_design_principle
* @date: 2020/12/30 20:19
* @author: nick_jackson
* @describtion: 单一设计原则
**/
/**
* 买商品接口
*/
interface SoldSth{
/**
* 获取卖出商品的杰哥
* @return
*/
double getSoldM();
/**
* 获取商品名称
* @return
*/
String getSoldSthName();
/**
* 获取商品编号
*/
String getSoldCode();
}
/**
* 卖冰箱实现类
*/
class SellingRefrigerators implements SoldSth{
//物品价格
private double soldM;
//物品名称
private String soldSthName;
//物品编码
private String soldCode;
public SellingRefrigerators(double soldM, String soldSthName, String soldCode) {
this.soldM = soldM;
this.soldSthName = soldSthName;
this.soldCode = soldCode;
}
@Override
public double getSoldM() {
return soldM;
}
@Override
public String getSoldSthName() {
return soldSthName;
}
@Override
public String getSoldCode() {
return soldCode;
}
}
//开-闭原则体现,再不修改源代码的前提下新增行为。
class SellingRefrigeratorsDiscount extends SellingRefrigerators{
public SellingRefrigeratorsDiscount(double soldM, String soldSthName, String soldCode) {
super(soldM, soldSthName, soldCode);
}
//获取打折后的价格
public double getDiscountSoldOutM(){
return getSoldM()*0.8;
}
}
//测试类
public class Single_design_principle {
public static void main(String[] args) {
SellingRefrigeratorsDiscount seller = new SellingRefrigeratorsDiscount(800, "冰箱", "GNS4321424");
System.out.println("打折前:"+seller.getSoldM());
System.out.println("打折后:"+seller.getDiscountSoldOutM());
}
}
3、里氏代换原则
概念
里氏代换原则是指所有引用基类(父类)的地方必须能透明的使用其子类的对象。即子类型必须能够替换掉它们的父类型。也就是说把父类都替换成它的子类,程序的行为没有发生变化。
里氏代换原则是继承复用的基础。只有当子类可以替换掉父类,软件单位的功能不受影响,父类才能真正被复用,而子类也能够在父类的基础上添加新的行为。
由于该原则子类型的可替换性使得父类类型的模块在无须修改的情况下就可扩展,所以满足里氏代换原则,餐恩公过满足开-闭原则。而依赖倒转原则中指出,依赖了抽象接口或抽象类,就不怕更改,原因也在于里氏代换原则。(这里可能听起来有点绕,但是多想想就能理顺了,或者看看下面的通俗说法)
里氏替换原则通俗的来讲就是:子类可以扩展父类的功能,但不能改变父类原有的功能。它包含以下4层含义:
- 子类可以实现父类的抽象方法,但不能覆盖父类的非抽象方法。
- 子类中可以增加自己特有的方法。
- 当子类的方法重载父类的方法时,方法的前置条件(即方法的形参)要比父类方法的输入参数更宽松。
- 当子类的方法实现父类的抽象方法时,方法的后置条件(即方法的返回值)要比父类更严格。
你可以直接这样想,凡是父类型存在的地方,子类替换掉父类,不会改变程序的行为。
例子
下面给出一个java例子。
/**
* @name: Richter_principle_of_substitution
* @date: 2020/12/30 21:41
* @author: nick_jackson
* @describtion: 里氏代换原则例子详解
**/
//测试类
public class Richter_principle_of_substitution {
//1、当子类型对父类型的方法进行了修改,就不满足里氏代换原则了。
// 因为里氏代换原则要求父类型存在的地方可以被子类型替换,而重载或重写父类方法都将不满足该条件。
public static void main(String[] args) {
Father father = new Father();
int sub = father.sub(100, 20);
System.out.println("输出:"+sub);
int sub1 = new Son1().sub(100,20);
System.out.println("输出1:"+sub1);
int sub2=new Son2().sub(100,20);
System.out.println("输出2:"+sub2);
}
}
//父类减法运算
class Father{
//减法方法
public int sub(int a, int b){
return a-b;
}
}
//子类1实现功能,减法,两数相加,此类已经重写了父类方法,可以加上@Override,不加也不太影响。重写了方法,导致使用该子类替换父类时会导致程序行为发生改变,因此不满足里氏代换原则。
class Son1 extends Father{
public int sub(int a, int b){
return a+b;
}
}
//子类2实现功能,减法,两数相加
class Son2 extends Father{
public int add(int a, int b){
return a+b;
}
}
4、依赖倒转原则
概念
要依赖于抽象,不要依赖于具体;高层模块不应该依赖于底层模块,两个都应该依赖抽象。
每一个逻辑实现都是由原子逻辑组成的,不可分割的原子逻辑即为底层模块,原子逻辑的再组装就是高层模块。
依赖倒转还可以表示为要针对接口编程,而非针对实现编程。
实例
下面给个例子理解一下。(结合例子理解更加印象深刻)
先看传统的例子:
/**
* @name: Dependence_Inversion_Principle
* @date: 2020/12/31 10:43
* @author: nick_jackson
* @describtion: 依赖倒转原则实例
**/
//测试类
public class Dependence_Inversion_Principle {
public static void main(String[] args) {
Phone phone = new Phone();
phone.openQQ();
phone.openWeixin();
}
}
//手机类
class Phone{
//打开QQ
public void openQQ(){
new QQ().openQQ();
}
//打开微信
public void openWeixin(){
new Weixin().openWeixin();
}
}
//QQ类
class QQ{
public void openQQ(){
System.out.println("打开QQ");
}
}
//微信类
class Weixin{
public void openWeixin(){
System.out.println("打开微信");
}
}
此时,如果想要新增一个打开淘宝app的行为,则必须要在高层模块Phone类中添加openTaobao()方法,在底层模块新增一个Taobao类,并写出openTaobao方法,这样做在软件系统中有很大的风险,因此就要使用依赖倒转原则进行优化,且看下面优化后的例子:
//抽象APP接口
interface App{
public void openApp();
}
//高层模块依赖于抽象,因为传入参数是一个抽象参数。
class Phone{
public void openApp(App app){
app.openApp();
}
}
//微信类实现APP接口,底层模块依赖于抽象
class Weixin implements App{
@Override
public void openApp() {
System.out.println("打开微信");
}
}
//QQ类实现APP接口,底层模块依赖于抽象
class QQ implements App{
@Override
public void openApp() {
System.out.println("打开QQ");
}
}
//测试类
public class Dependence_Inversion_Principle {
public static void main(String[] args) {
Phone phone = new Phone();
phone.openApp(new QQ());
phone.openApp(new Weixin());
}
}
此时,我们可以看到,如果再想增加一个TaobaoAPP,则只需要另写一个Taobao类实现抽象接口,然后就可以直接调用。这就是依赖倒转原则(好好体会一下)。
当然依赖倒转原则也有它的缺点,实现依赖倒转原则,对象的创建一般要使用对象工厂,不容易实现,且会导致产生大量的类。
5、合成聚合复用原则
理解合成聚合复用原则首先要理解合成、聚合的概念。
聚合
聚合用来表示“拥有”关系或者整体与部分的关系。代表部分的对象有可能会被多个代表整体的对象所共享,而且不一定会随着某个代表整体的对象被销毁或破坏而被销毁或破坏,部分的生命周期可以超越整体。
下面给个例子:
class Student {
public Student(){
System.out.println("Student has been created");
}
}
class Classes{
private Student student;
//在这里使用了聚合关联关系,当创建Classes的时候Student已经存在,不随着Classes的生命周期结束而结束。
public Classes(Student student){
System.out.println("classes has been created!");
this.student=student;
}
}
合成
//合成
class Room{
public Room createRoom(){
System.out.println("合成房间");
return new Room();
}
}
class House{
private Room room;
//这里采用合成关联关系,当House生命周期结束时,Room的生命周期同样结束。
public House(){
room=new Room();
}
public void createHouse(){
room.createRoom();
}
}
由于合成或聚合可以将已有对象纳入到新对象中,使之成为新对象的一部分,因此新对象可以调用已有对象的功能。这样做的好处有如下所示:
- 新对象存取成分对象的唯一方法是通过成分对象的接口。
- 这种复用是黑箱复用,因为成分对象的内部细节是新对象看不见的。
- 这种复用支持包装。
- 这种复用所需的依赖较少。
- 每一个新的类可以将焦点集中到一个任务上。
- 这种复用可以再运行时间内动态进行,新对象可以动态地引用与成分对象类型相同的对象。
当然,有优点同样有缺点,这中复用原则的主要缺点就是系统有较多的对象需要管理。
理解完合成聚合再来整体理解:
合成聚合复用原则
**概念:**在一个新的对象中使用已有的对象,使之成为新对象的一部分;新的对象可以调用已有对象的功能,从而达到复用已有功能的目的。
通俗讲:就是尽量使用合成/聚合,尽量不要使用继承。
继承复用
讲了合成聚合复用,再来说说继承复用,对比着理解会更加清楚明了。
概念:继承复用通过扩展一个已有对象的实现来得到新的功能,基类(父类)明显的捕获共同的属性和方法,而子类通过增加新的属性和方法来扩展超类的实现。继承是类型的复用。
优点:
- 新的实现较为容易,因为超类的大部分功能可以通过继承关系自动进入子类。
- 修改或扩展继承而来的实现较为容易。
缺点:
- 继承复用破坏封装,因为继承将超类的实现细节暴露给了子类。因为超类的内部细节常常对子类是透明的,因此这种复用是透明的复用,又叫“白箱”复用。
- 如果超类的实现改变了,那么子类的实现也不得不发生改变。因此,当一个基类发生了改变时,这种改变会传导到一级又一级的子类,使得设计师不得不相应的改变这些子类,以适应超类的变化。
- 从超类继承而来的实现是静态的,不可能在运行时间内发生变化,因此没有足够的灵活性。
6、耦合、解耦
在讲后面的设计原则之前需要普及一下基础知识(面向小白的知识博文,大佬可参考!)
想必之前讲了那么多原则,有人似乎或多或少都发现了一些特点,为什么要使用这些设计原则,就是为了满足系统的可维护性复用等等目标,而前面我们发现根据设计原则进行设计系统时,对象之间的依赖性相对较低,我们也称之为低耦合。什么是低耦合?耦合是什么?别急,往下看。
耦合
表示系统中对象之间的依赖关系。对象之间的耦合越高,维护成本越高。因此对象的设计应使类和构件之间的耦合最小。
耦合又可以分为一下几种,我将他们按耦合度又高到低从上往下进行排列:
- 内容耦合。当一个模块直接修改或操作另一个模块的数据时,或一个模块不通过正常入口而转入另一个模块时,这样的耦合被称为内容耦合。内容耦合是最高程度的耦合,应该避免使用之。
- 公共耦合。两个或两个以上的模块共同引用一个全局数据项,这种耦合被称为公共耦合。在具有大量公共耦合的结构中,确定究竟是哪个模块给全局变量赋了一个特定的值是十分困难的。
- 外部耦合。一组模块都访问同一全局简单变量而不是同一全局数据结构,而且不是通过参数表传递该全局变量的信息,则称之为外部耦合。
- 控制耦合。一个模块通过接口向另一个模块传递一个控制信号,接受信号的模块根据信号值而进行适当的动作,这种耦合被称为控制耦合。
- 标记耦合。若一个模块A通过接口向两个模块B和C传递一个公共参数,那么称模块B和C之间存在一个标记耦合。
- 数据耦合。模块之间通过参数来传递数据,那么被称为数据耦合。数据耦合是最低的一种耦合形式,系统中一般都存在这种类型的耦合,因为为了完成一些有意义的功能,往往需要将某些模块的输出数据作为另一些模块的输入数据。
- 非直接耦合。两个模块之间没有直接关系,它们之间的联系完全是通过主模块的控制和调用来实现的。
解耦
字面意思就是解除耦合关系。
在软件工程中,降低耦合度即可以理解为解耦,模块间有依赖关系必然存在耦合,理论上的绝对零耦合是做不到的,但可以通过一些现有的方法将耦合度降至最低。
设计的核心思想:尽可能减少代码耦合,如果发现代码耦合,就要采取解耦技术。让数据模型,业务逻辑和视图显示三层之间彼此降低耦合,把关联依赖降到最低,而不至于牵一发而动全身。原则就是A功能的代码不要写在B的功能代码中,如果两者之间需要交互,可以通过接口,通过消息,甚至可以引入框架,但总之就是不要直接交叉写。
7、迪米特法则(又称最少知识原则)
概念:
一个对象应该对其他对象保持最少的了解。
目的就是降低对象之间、类与类之间的耦合度。
实例
明星与经纪人的关系实例
明星由于全身心投入艺术,所以许多日常事务由经纪人负责处理,如与粉丝的见面会,与媒体公司的业务洽淡等。这里的经纪人是明星的朋友,而粉丝和媒体公司是陌生人,所以适合使用迪米特法则,其类图如图
下面照样给个例子:
注:一个中介,客户只要找中介要满足的楼盘 ,而不必跟每个楼盘发生联系。这个例子用C++写的。
#define _CRT_SCURE_NO_WARNINGS
#include<iostream>
using namespace std;
#include<string>
#include<vector>
class AbstractBuild
{
public:
AbstractBuild(){}
virtual void sale() = 0;
virtual string getqulity() = 0;
};
class BuildA :public AbstractBuild{
public:
BuildA(){
mqulity = "高品质";
}
virtual void sale(){
cout << mqulity << endl;
}
virtual string getqulity(){
return mqulity;
}
public:
string mqulity;
};
class BuildC :public AbstractBuild{
public:
BuildC(){
mqulity = "低品质";
}
virtual void sale(){
cout << mqulity << endl;
}
virtual string getqulity(){
return mqulity;
}
public:
string mqulity;
};
//中介类
class Mediator{
public:
Mediator(){
AbstractBuild *d1 = new BuildA;
Vbuild.push_back(d1);
AbstractBuild *d2 = new BuildC;
Vbuild.push_back(d2);
}
//对外提供接口
AbstractBuild * findmybuild(string p){
for (vector<AbstractBuild*>::iterator it1 = Vbuild.begin(); it1 != Vbuild.end(); it1++)
{
if ((*it1)->getqulity()==p)
{
return *it1;
}
}
return NULL;
}
~Mediator(){
for (vector<AbstractBuild*>::iterator it = Vbuild.begin(); it != Vbuild.end(); it++)
{
if (*it != NULL)
{
delete *it;
}
}
}
public:
vector<AbstractBuild*> Vbuild;
};
//客户端测试
void test(void)
{
Mediator *mediator = new Mediator;//实例化中介类
AbstractBuild *D = mediator->findmybuild("中高品质");//通过中介类查找房子
if (D != NULL)
{
D->sale();
}
else
{
cout << "楼盘没有找到" << endl;
}
}
//主函数
int main(void){
//迪米特原则 最小知识原则
test();
int m;
cout<<"回车退出"<<endl;
cin>>m;
// system("pause");
return 0;
}
8、单一职责原则
概念
功能要单一,一个对象应该只包含单一的职责,并且该职责被完整地封装在一个类中。
通俗的说,即一个类只负责一项职责。
个人观点:只有逻辑足够简单,才可以在代码级别上违反单一职责原则;只有类中方法数量足够少,才可以在方法级别上违反单一职责原则;
优点
- 可以降低类的复杂度,一个类只负责一项职责,其逻辑肯定要比负责多项职责简单的多;
- 提高类的可读性,提高系统的可维护性;
- 变更引起的风险降低,变更是必然的,如果单一职责原则遵守的好,当修改一个功能时,可以显著降低对其他功能的影响。
- 只要是模块化的程序设计,都适用单一职责原则。
实例
把动物行走拆分成陆生动物走路,水生动物游泳两个接口,并每个接口对应一个实现类.
public interface Animal{}
public interface WaterAnimal{}
public class load implements Animal{
System.out.println("动物走路");
}
public class water implements WaterAnimal{
System.out.println("水生动物动物游泳");
}
9、接口隔离原则
概念
简称ISP。客户端不应该依赖它不需要的接口;一个类对另一个类的依赖应该建立在最小的接口上。
一旦一个接口太大,则需要将它分割成一些更细小的接口,使用该接口的客户端仅需要知道与之相关联的方法即可。
理解:
- 一个类对另一个类的依赖应该建立在最小的接口上;
- 一个接口代表一个角色,不应该将不同的角色都交给一个接口,因为这样可能会形成一个臃肿的大接口;
- 不应该强迫客户依赖它们从来不用的方法。
实例
先来看看不适用接口隔离原则:
public class MadeFood {
interface MadeFoodInter{
void madeHot();
void madeCold();
}
//西红柿
class Tomatoes implements MadeFoodInter{
@Override
public void madeHot() {
Log.w("打印","热菜->打鸡蛋->翻炒->西红柿炒鸡蛋");
}
@Override
public void madeCold() {
Log.w("打印","冷菜->切片->加糖->搅拌->糖拌柿子");
}
}
//黄瓜
class Cucumber implements MadeFoodInter{
@Override
public void madeHot() {
Log.w("打印","热菜->切肉->翻炒->黄瓜炒肉");
}
@Override
public void madeCold() {
Log.w("打印","冷菜->拍碎->加调料->搅拌->拍黄瓜");
}
}
//芹菜
class Celery implements MadeFoodInter{
@Override
public void madeHot() {
Log.w("打印","热菜->切段->炒肉->加芹菜->芹菜炒肉");
}
@Override
public void madeCold() {
Log.w("打印","冷菜->切段->焯水->加调料->凉拌芹菜");
}
}
public MadeFoodInter getMade(String name){
MadeFoodInter madeFoodInter = null;
if(name.equals("西红柿")){
madeFoodInter = new Tomatoes();
}else if(name.equals("黄瓜")){
madeFoodInter = new Cucumber();
}else if(name.equals("芹菜")){
madeFoodInter = new Celery();
}
return madeFoodInter;
}
}
从上面的例子能看出来,把冷热菜需要的功能放到一个接口里之后,当热菜师傅调用接口之后,冷菜的制作方法也强制摆在热菜师傅面前,这显然是我们不应该设计出来的。
下面使用接口隔离原则进行调整:
public class MadeFood {
interface MadeFoodInter{
void madeHot();
void madeCold();
}
class Tomatoes implements MadeFoodInter{
@Override
public void madeHot() {
Log.w("打印","热菜->打鸡蛋->翻炒->西红柿炒鸡蛋");
}
@Override
public void madeCold() {
Log.w("打印","冷菜->切片->加糖->搅拌->糖拌柿子");
}
}
class Cucumber implements MadeFoodInter{
@Override
public void madeHot() {
Log.w("打印","热菜->切肉->翻炒->黄瓜炒肉");
}
@Override
public void madeCold() {
Log.w("打印","冷菜->拍碎->加调料->搅拌->拍黄瓜");
}
}
class Celery implements MadeFoodInter{
@Override
public void madeHot() {
Log.w("打印","热菜->切段->炒肉->加芹菜->芹菜炒肉");
}
@Override
public void madeCold() {
Log.w("打印","冷菜->切段->焯水->加调料->凉拌芹菜");
}
}
public MadeFoodInter getMade(String name){
MadeFoodInter madeFoodInter = null;
if(name.equals("西红柿")){
madeFoodInter = new Tomatoes();
}else if(name.equals("黄瓜")){
madeFoodInter = new Cucumber();
}else if(name.equals("芹菜")){
madeFoodInter = new Celery();
}
return madeFoodInter;
}
}
将冷热菜分为两个接口分别实现,就达到预期效果了。
优点:
使用接口隔离原则,意在设计一个短而小的接口和类,符合我们常说的高内聚低耦合的设计思想,从而使得类具有很好的可读性、可扩展性和可维护性。
接口隔离原则就是尽量让接口内的方法都是调用时必用的,原则上宁可接口多,也不要功能杂,当然具体业务实现要根据具体场景需求进行变动。
总结
本次博文主要讲解了七大设计原则,引用了许多例子,也感谢从上面看到这里的朋友们,加油!
通过本次学习,可以理解透彻软件体系架构的七大设计原则,那么你就对于软件编程也就更上一层楼了,加油!