当前位置: 首页 > 工具软件 > CodeGuide > 使用案例 >

Code Style Guide之正交设计浅析

莫逸仙
2023-12-01

前提:模块化设计

  • 为什么需要模块化设计?

    理论上可以只使用一个函数完成全部功能,但是太过复杂,超过人的掌控极限。因此必须要划分开,对问题进行分解。(面向过程->面向对象)

  • 模块化设计遇到的两个问题

    1. 如何划分模块?
    2. 模块之间如何连接?

软件设计

  • 为何要做软件设计?

    软件设计是为了让软件在长期范围内容易应对变化。即:尽量降低变化对软件的影响。否则维护成本太大。

  • HOW?

高内聚、低耦合原则
- 内聚:一个单位内部事物之间关联的紧密程度。
- 高内聚:将同类事物放在一起,只做一件事(单一职责)。
- 耦合:不同单位之间关联的紧密程度。
- 低耦合:不同单位之间尽量不要互相影响。

什么是好的模块化设计?

模块本身高内聚,不同模块间低耦合。

正交

什么是正交?

  • 二维空间:两条直线正交、正交分解
  • 多维空间:向量正交
  • 特点:正交的两个单位互不影响。

模块化设计 & 正交设计

  • 模块之间互不影响(正交)
  • 模块的划分实际上是功能职责的划分(如何划分模块)
  • 每个模块保证自己功能的实现,不关心其他模块如何实现
  • 模块之间联系的桥梁是接口(API),接口是模块之间的分界线(模块之间如何连接)

通俗的说,只要接口不变,一个模块对另一个模块的影响就是0,无论提供接口的模块内部实现如何变动,对调用接口的模块来说都是透明的。

举例:

例1:
1. 模块A负责排序,提供接口sort
2. 模块B在功能实现逻辑中需要得到排好序的数据,调用了模块A的sort接口,实现了自身的功能
- 模块A不关心谁来调用sort接口,不论是模块B还是模块C。外部的变动对模块自身无影响。
- 模块B只需要知道调用模块A的sort接口可以对数据进行排序,而不关心模块A内部使用了什么排序算法。即使有一天模块A的排序算法从冒泡排序变成了归并排序,模块B的实现代码也不需要有任何改动。

例2:设计一个结账计算器

public Class Goods {
    private double price;

    public double getPrice() {return price;}
    public void setPrice(double price) {this.price = price;}
}
  1. 简单粗暴第一版:
public double checkout (List<Goods> shoppingGoods) {
    double sum = 0d;
    for (Goods good : shoppingGoods) {
        sum += good.getPrice();
    }
    return sum;
}
  1. 打五折第二版:
public double checkout (List<Goods> shoppingGoods) {
    double sum = 0d;
    for (Goods good : shoppingGoods) {
        sum += good.getPrice();
    }
    return sum * 0.5;
}
  1. 打九折第三版:
public double checkout (List<Goods> shoppingGoods) {
    double sum = 0d;
    for (Goods good : shoppingGoods) {
        sum += good.getPrice();
    }
    return sum * 0.9;
}
  1. 灵活折扣第四版:
public Class Goods {
    private double price;
    private double discount;

    //getter setter...
}

public double checkout (List<Goods> shoppingGoods) {
    double sum = 0d;
    for (Goods good : shoppingGoods) {
        sum += good.getPrice() * good.getDiscount();
    }
    return sum;
}
  1. 忍无可忍终极版(?):
public interface Saleable {
    double checkout();
}

public abstract Class Goods implements Saleable {
    private double price;

    //getter setter...

    @Override
    public double checkout() {
        ...
    }
}

public Class GoodsA {
    ...
}

public double checkout (List<Goods> shoppingGoods) {
    double sum = 0d;
    for (Goods good : shoppingGoods) {
        sum += good.checkout();
    }
    return sum;
}

设计的问题:不应当把物品自己的算账细节与购物车的算账细节放到一起,模块之间耦合度太高。

四个策略

策略一:消除重复

重复的代码意味着相同的事物没有被放到一起,即低内聚。

举例:有多个地方都需要对数据排序,应当写一个工具类进行数据排序,而不是在每个地方自己写重复的排序代码。

有重复的代码意味着违反单一职责原则,他们做了不止一件事。

所以,每当发现自己在写重复的代码时,应当将重复的部分剥离出来。

策略二:分离不同的变化方向

如果总是因为一些类似的原因修改模块内同一部分代码,而其他部分的代码不变,则应该将变化的方向抽离出来。

举例:一件商品有时候打五折,有时候打九折,有时候不打折,那么就应当将折扣抽离出来,为商品增加“折扣”这个属性。

策略三:缩小依赖范围

两个模块之间依靠API进行关联,因此API决定了模块间的耦合度。

API设计的要点:
1. API应包含尽量少的知识,因为任何一项知识的变化都会导致双方的变化;
2. API也应该高内聚,而不应该强迫API的调用者依赖它不需要的东西。

举例:

public interface Saleable {
    double checkout();
}

public abstract Class Goods implements Saleable {
    private double price;

    //getter setter...

    @Override
    public double checkout() {
        ...
    }
}

public Class GoodsA {
    ...
}

public double checkout (List<Goods> shoppingGoods) {
    double sum = 0d;
    for (Goods good : shoppingGoods) {
        sum += good.checkout();
    }
    return sum;
}

对于checkout方法来说,声明了传入的参数类型是List<Goods>,但是在方法内部只调用了good.checkout()方法,而这个方法实际上是Saleable接口所声明的。因此,这个函数的声明应当是:

public double checkout (List<Saleable> saleableList) {
    double sum = 0d;
    for (Saleable saleable : saleableList) {
        sum += saleable.checkout();
    }
    return sum;
}

之前的设计中,API包含了多余的知识,要求参数必须是List<Goods>,然而在实现细节中没有用到Goods类的任何东西,它强迫这个API的调用者必须传入List<Goods>类型的参数。然而实际上,如果有另一个实现了Saleable接口但没有继承Goods类的类,那么这个类是无法使用这个API的,即API的内聚程度不够高,它强迫API的调用者依赖它不需要的东西。

策略四:向着稳定的方向依赖

两个模块之间如果有API的调用关系,那么这两个模块必然有一定程度的耦合。因此我们只能尽量降低耦合度而无法在存在API调用关系的情况下消除耦合。为了提高依赖方(API调用者)的稳定性,我们应当努力使API稳定。

如何使API稳定?在设计API时应当站在API调用者而不是API提供者的角度,思考API的调用者需要什么,不关心什么,在这个原则上进行封装/信息隐藏。

总结

  • 模块化的正交设计:高内聚、低耦合。
  • 四个策略:前两个策略解决如何划分模块的问题,后两个策略解决模块之间如何连接的问题。
 类似资料: