问题引出
软件开发过程中,需要设计大量的类,使他们交互以实现特定的功能性需求。但是不同的设计方式,对程序的非功能性需求(可扩展性,稳定性,可维护性等)的实现程度则完全不同。
有没有一种统一的设计方式既实现功能性需求又能满足非功能性需求?没有。开发中药遵循现有的设计原则如GRASP达到相对较好的程序开发质量。
什么是GRASP
GRASP是General Responsibility Assignment Software Principle,通用职责分配软件原则。核心思想是“职责分配”。GRASP将在通用代码层面知道以下编程过程:
- 某个方法要交给哪个类来实现比较合适(方法给哪个类)
- 某个类由哪个类来创建合适(类由哪个类创建)
- 某个类包含哪些成员和方法(类应该有哪些方法和成员)
- 两个类交互时,采用哪种方式?
- 某个类在某些情况下应该转变为另一个类
- 类的哪些成员和方法可以被哪些类访问
1 信息专家原则
1.1 问题引出及原则
问题:流水类越级操作了Item类。导致了级联修改。
打八折应该是流水类应该负责的事情,但是产生级联修改。小票类也要进行修改。
Sale小票类应该是存放所有流水的总价。而各个流水的价格(subTotal)应该交给流水类来计算,这样在某商品打折时,可以在流水类中进行优惠计算。
把职责分配给具有完成该职责所需信息的那个类,这个类就是信息专家。
即:如果某个对象拥有完成某个职责所需要的所有信息,那么这个职责就分配给这个对象实现。这个时候,这个类就是相对于这个职责的信息专家。
/**
* 小票
*/
class Sale {
saleItemMap: SalesLineItem[]
}
/**
* 流水
*/
class SalesLineItem {
// 销量
quantity: number;
desprition: ProductDesprition;
}
/**
* 商品详情
*/
class Item{
des: string;
id: string;
price: number;
}
1.2 总结
优点:
- 信息的封装性得以维持
- 对象充分利用自身的信息来完成任务。
- 支持低耦合,形成更健壮、可维护的系统。
- 系统行为分布到不同的类
- 形成内聚性更强的轻量类,易于维护和理解。
2 创建者原则
2.1 总结
算是信息专家原则的一个特化。
优点:
支持低耦合:创建者模式不会增加耦合性,因为所创建的类与创建者之间本身已经存在关联。
有利于类的重用
3 低耦合原则
对于程序设计,耦合一般是指代码块、类、模块、系统间的互相调用或引用。
一般的,程序设计的耦合可以分为:间接耦合,数据耦合,对象耦合,控制耦合,公共耦合,内容耦合
3.1 数据耦合
两个模块之间有调用|用关系,传递的是数据值(int,double,float,string),相当于值模型传递。
3.2 对象耦合
两个模块之间有调用门|用关系,传递的是数据对象,相当于引用模型传递。
3.3 控制耦合
指一个模块调用另一个模块时,传递的是控制变量(如开关、标志等),被调模块通过该控制变量的值有选择地执行块内某一功能。
3.4 公共耦合
指通过一个公共数据环境相互作用的那些模块间的耦合。公共耦合的复杂程度随耦合模块的个数增加而增加。
3.5 内容耦合
当一个模块通过非正常入口而转入另一个模块内部,或者直接使用另一个模块的内部数据,则会产生内容耦合。
3.6 总结
- 低耦合是在制定设计决策期间需要牢记的原则,是评估所有设计结果时要运用的评估原则。
- 低耦合不能脱离专家和高内聚模式孤立地考虑,应该作为影响职责分配的原则之一。
- 没有绝对的度量标准来衡量耦合程度的高低(高低是一个相对的概念)。重要的是能够估测当前的耦合程度,评估增加耦合是否会导致问题。
优点:
- 不受其他构件变化的影响
- 易于单独理解
- 便于复用
4 高内聚原则
内聚性较低的类,会执行很多互不相关的操作。这将导致系统:
- 难以理解
- 难以复用
- 难以维护
- 脆弱,容易受到变化的影响
所以,需要对类的职责进行拆分,以达到高内聚。分解后的类,应当具有独立的职责,一个类只完成与它高度相关的工作。如果要实现在多个类中重复使用的方法,或者其他功能需要的方法,则把该方法封装在其他类中。类与类之间彼此协作,完成复杂的任务。
class Main {
getSum(...branches) {
let sum = 0;
branches.forEach(item => {
sum += item.income - item.spending;
});
return sum;
}
}
class Branch {
income: number;
spending: number;
constructor(income: number, spending: number) {
this.income = income;
this.spending = spending;
}
}
/*
Main类直接访问Branch类的数据,造成内容耦合。
如果Branch类的数据字段变动,Main类也需要随之进行迭代,不利于引入新的变化。
Main的求和函数 getSum 中,不应该计算某一个Branch的利润。
因为这属于Branch的业务逻辑。
*/
class Main {
getSum(...branches) {
let sum = 0;
branches.forEach(item => {
sum += item.getProfit();
});
return sum;
}
}
class Branch {
private income: number;
private spending: number;
constructor(income: number, spending: number) {
this.income = income;
this.spending = spending;
}
getProfit() {
return this.income - this.spending;
}
}
/*
总店与分店解耦:总店不用直接访问分店的内部数据
避免了例如分店数据发生变动时,总店模块也要随之升级的情况。
总店的求和函数 getSum 只完成与之高度相关的一件事情,使得内聚性提高。
*/
4.1 总结
1、具有高度相关功能的模块或类,可以采用相对较高耦合度的交互方式进连接(如继承、对象引用)
2、而功能差异较大的模块或类,应该采用耦合度较低的交互方式进行连接(如接口调用,抽象类调用)优点:
- 能够更加轻松、清楚地理解设计。
- 降低类的复杂性,降低代码的维护和改进成本。
- 通常支持低耦合。
- 由于内聚的类可以用于某个特定的目的,因此细粒度、相关性强的功能,可复用性增强。
5 控制器原则
当控制器负担过多的职责,且没有重点时,该控制器就是一个臃肿的控制器。这样的控制器违背高内聚原则,不利于模块复用和维护。继续以上面的控制器 LoginController 为例,它负责处理登录场景的事件协调。现在系统中出现一些新的用例场景,并新增事件处理器 Other、OtherOne和 OtherTwo。如果新增的事件继续让 LoginController 负责,如下所示,则会造成控制器的职责过多且没有重点。代码的可读性和可复用性都会降低。
// Login控制器
class LoginController {
private buttonModel: Button;
private closeModel: Close;
private otherModel: Other;
private otherOneModel: OtherOne;
private otherTwoModel: OtherTwo;
constructor() {
this.buttonModel = new Button();
this.closeModel = new Close();
this.otherModel = new Other();
this.otherOneModel = new OtherOne();
this.otherTwoModel = new OtherTwo();
}
// 事件协调/分发函数
dispathEvent(event: string) {
this.buttonModel.handle(event);
this.closeModel.handle(event);
this.otherModel.handle(event);
this.otherOneModel.handle(event);
this.otherTwoModel.handle(event);
}
}
/*
我们对上述控制器进行改进。拆分 Login 控制器的职责
增加一种控制器 Other,使得每个控制器只负责处理一种用例场景。
这样代码变得更清晰了,且每个控制器的内聚性提高。改进后代码如下:
*/
class LoginController {
private buttonModel: Button;
private closeModel: Close;
constructor() {
this.buttonModel = new Button();
this.closeModel = new Close();
}
dispathEvent(event: string) {
this.buttonModel.handle(event);
this.closeModel.handle(event);
}
}
// Other 控制器
class OtherController {
private otherModel: Other;
private otherOneModel: OtherOne;
private otherTwoModel: OtherTwo;
constructor() {
this.otherModel = new Other();
this.otherOneModel = new OtherOne();
this.otherTwoModel = new OtherTwo();
}
dispathEvent(event: string) {
this.otherModel.handle(event);
this.otherOneModel.handle(event);
this.otherTwoModel.handle(event);
}
}
解决方法:
- 增加控制器:在存在很多系统事件的系统中,增加控制器,每个控制器负责不同的场景。
- 设计控制器:良好地设计控制器,使它把处理系统事件的任务分发出去。
6 多态原则
如何处理基于类型的选择?如何创建可插拔的软件组件?
接收的都是Action,但是实现的功能不一样。同样的方法调用得到的是不同效果。
多态实现:
- 方式一:接口
- 方式二:重载
- 方式三:抽象类和抽象方法
当相关选择或行为随类型而变化时,使用多态操作来为变化的行为分配职责。
推论:不要测试对象的类型,也不要使用条件逻辑来执行基于类型的不同选择。如果我们使用 if-else 或 switch 语句来执行不同类型的分支,当出现新的变化时,往往需要修改散落在各处的的if语句,让软件难以维护,也容易出现缺陷。
function demo(animals) {
for(const animal of animals) {
if (animal instanceof Duck) {
console.log('Duck Duck Duck');
// do Duck's thing
} else if (animal instanceof Cattle) {
console.log('mou mou mou');
// do Cattle's thing
}
}
}
/*
以上代码的缺陷:
当需要新增 animal 类型时,就需要增加 if/else 逻辑。
每个 animal 类型的行为逻辑无法在其他地方复用。
*/
/*
接下来使用多态模式,对该示例进行改进。
新增 Animal 接口以及 makeSound 方法。
Duck 类和 Cattle 类代表两种不同的动物,它们都实现了 makeSound 方法。
这样一来,在 demo 函数中,只需要遍历 animals 列表,
并执行 makeSound 方法即可。改进后的代码如下:
*/
interface Animal {
makeSound(): void;
}
class Duck implements Animal {
makeSound() {
console.log('Duck Duck Duck');
}
}
class Cattle implements Animal {
makeSound() {
console.log('mou mou mou');
}
}
function demo(animals: Animal[]) {
for(const animal of animals) {
// 不同的 animal 发出不同的声音
animal.makeSound();
}
}
/*
改进后的优点:
该功能模块更容易引入新变化。
每个类的逻辑可复用。
*/
6.1 总结
多态:同种行为(方法)有不同的实现。 反过来,不同的类型,统一为同种类型,隐藏了多余的方法。
多态和继承的关系:继承可以实现多态;多态的实现方法不局限于继承,还有接口实现、组合实现、代理实现等。
优点:
- 符合多态原则的对象,易于增加新变化所需的扩展。
- 无需影响客户,就能够引入新的实现。
7 间接原则
interface RowingBoat {
row(): void;
}
class Captain {
private rowingBoat: RowingBoat;
constructor(rowingBoat: RowingBoat) {
this.rowingBoat = rowingBoat;
}
row() {
this.rowingBoat.row();
}
}
class FishingBoat {
sail() {
// do something
}
}
/*
这里 FishingBoat 已经被其他对象使用了,它本身不想把 sail 方法改成 row。
而 Captain 也已经被其他业务使用了,所以它本身的接口也不能修改。
怎样协调 Captain 和 FishingBoat,使 Captain 可以调用 FishingBoat?
*/
//根据间接性建议,可以增加一个中介对象,
//避免 Captain 和 FishingBoat 直接耦合。实现如下:
class FishingBoatAdapter implements RowingBoat {
private boat: FishingBoat;
constructor() {
this.boat = new FishingBoat();
}
row() {
this.boat.sail();
}
}
//Captain调用
var captain = new Captain(new FishingBoatAdapter());
captain.row();
//Captain 和 FishingBoat 之间低耦合
//Captain 既能调用 FishingBoat
//又不影响 FishingBoat 复用到其他业务。
优点:实现构件之间的低耦合。
8 纯虚构
当信息专家原则无法实现低耦合和高内聚时,那可以引入纯虚构方案。
对人为制造的类分配一组高内聚的职责,该类并不代表问题领域的概念——虚构的事物,用以支持高内聚、低耦合和复用。这种类是凭空虚构的。理想情况下,分配给这种虚构物的职责支持高内聚和低耦合,使这种虚构物清晰或纯粹——因此称为纯虚构。
有一个图形库,其中 DrawShapes 类接收所有图形数据,输出所有图形。根据信息专家,DrawShapes 拥有所有图形的数据,所以 DrawShapes 应该负责每个图形的输出。
interface ShapeData {
name: string;
data: any;
}
class DrawShapes {
constructor(data: ShapeData[]) {
data.forEach(item => {
if (item.name === 'circle') {
this.drawCircle(item.data);
}
if (item.name === 'square') {
this.drawSquare(item.data);
}
});
}
private drawCircle(data: CricleData) {
// draw circle
}
private drawSquare(data: SquareData) {
// draw square
}
}
- 高耦合:DrawShapes 与所有图形耦合在一起。
- 不易扩展:新增一种图形,都需要新增 if 。
- 不易复用:drawCircle这些方法无法单独复用到其他业务。
新增一个类 ShapeController,由这个类负责调度所有图形,连接 DarwShapes 和 所有图形。这个类在图形业务领域并没有相关的概念,它是我们虚构出来的。
interface IController {
draw(data: any): void;
}
class ShapeController {
ctrl: IController;
root: '/';
constructor (root: string) {
this.root = root;
}
draw(name: string, data: Record<string, any>) {
// 根据 name 来查找图形
let fullpath = path.resolve(this.root, name);
const ShapeClass = require(fullpath);
// 实例化图形并绘制
const shape = new ShapeClass();
shape.draw(data);
}
}
//每个图像单独模块
// cricle
class Circle implements IController {
draw(data: CricleData) {
// draw circle
}
}
// rectangle
class Rectangle implements IController {
draw(data: RectangleData) {
// draw rectangle
}
}
// DrawShape
class DrawShape {
constructor(data: ShapeData[]) {
let shapeController = new ShapeController('/shape');
data.forEach(item => {
shapeController.draw(item.name, item.data);
});
}
}
- 纯虚构通常会接纳本来基于“专家模式”所分配给领域类的职责,这里要特别注意防止纯虚构的滥用。
- 基本所有的设计模式都是纯虚构,比如控制器,适配器,观察者。
- 不必纠结一个类是否为纯虚构,纯虚构是基于相关的功能性进行划分,是一种以功能或者行为为中心的对象。
9 防变异
有A、B两个元素,A内部的变化不会对B造成影响,B内部的变化也不会对A造成影响。识别预计变化或不稳定之处,分配职责以在这些变化之外创建稳定接口。
指在面向对象设计中,应当预见到系统中哪些部分可能会发生变化,并将这些部分封装起来,从而保护系统的其他部分不受这些变化的影响。通过定义稳定的接口,系统中的各个部分可以与这些接口交互,而不是直接与变化的部分交互。这样,当变化发生时,只需修改接口的实现,而不需要修改依赖于这些接口的其他代码。
防变异原则的目的是隔离变化,使得系统的其他部分在变化发生时不需要做修改,从而提高系统的可维护性和灵活性。
在这个反例中,
DrawingClient
类直接依赖于Circle
和Square
类。如果未来需要添加新的图形,就需要修改DrawingClient
类,这违反了防变异原则 。
// 客户端代码直接依赖于具体类
class DrawingClient {
public void drawCircle() {
new Circle().draw();
}
public void drawSquare() {
new Square().draw();
}
}
// 定义一个稳定的接口
interface Shape {
void draw();
}
// 实现接口的具体类
class Circle implements Shape {
@Override
public void draw() {
// 绘制圆形
}
}
class Square implements Shape {
@Override
public void draw() {
// 绘制正方形
}
}
// 客户端代码,依赖于稳定的接口
class DrawingClient {
public void drawShape(Shape shape) {
shape.draw();
}
}