设计模式

目前经典的设计模式有22个

关键术语

面向对象基础

老四样:

  • 抽象
  • 封装
  • 多态
  • 继承

抽象

根据真实世界对象来设计程序中的对象。程序中的对象并不需要能够百分之百准确地反映其原型 (极少情况下才需要做到这一点)。 实际上, 「对象只需模拟真实对象的特定属性和行为即可」

封装

细节都被隐藏起来,能够交互的只有提供的「接口」

封装:一个对象对其他对象隐藏部分状态和行为,而仅向程序其他部分暴露有限的借口的能力

比如private protected

封装_1

继承

根据已有类创建新类的能力,具体作用在于代码复用

“使用继承后, 子类将拥有与其父类相同的接口。 如果父类中声明了某个方法, 那么你将无法在子类中隐藏该方法。 你还必须实现所有的抽象方法, 即使它们对于你的子类而言没有意义。 ”

继承_1

多态

声明为抽象。 这让我们得以忽略父类中该方法的所有默认实现, 从而强制要求所有子类自行提供该方法的实现。子类重写超类的方法

多态_1

多态的特殊机制使得程序可以追踪对象的子类并调用其方法, 从而执行恰当的行为。
多态是指程序能够检测对象所属的实际类, 并在当前上下文不知道其真实类型的情况下调用其实现的能力。
可将多态看作是一个对象 “假扮” 为其他东西 (通常是其扩展的类或实现的接口) 的能力。 在我们的示例中, 袋中的狗和猫就相当于是假扮成了一般的动物。”

关系

关系分为:

  • 依赖
  • 关联
  • 聚合
  • 组合

依赖

是「最基础,最微弱」的关系类型

如果修改一个类的定义可能会造成另一个类的变化。那么就说这两个类之间有依赖关系。

通过让代码依赖「接口」或「抽象类」从而降低依赖程度

UML 不会展示所有依赖,仅展示对于沟通想法来说的重要的依赖关系

虚线箭头 来表示

关系_依赖1

关联

一个对象使用另一个对象 或 与另一个对象进行交互的关系

关联 是一种特殊的依赖,一个对象总有访问与其交互对象的权限

一般使用 关联 来表示类似于 成员变量 的东西,这个关系将一直存在。

关系_关联1

依赖 vs 关联

1
2
3
4
5
6
class Professor is
  field Student student
  // ...
  method teach(Course c) is
    // ...
    this.student.remember(c.getKnowledge())

而Course 作为 一个参数,Course 是作为 依赖

Student 是 Professor 的一个成员变量,Student 类 是 Professor 的 不仅是 依赖 而上升为 关联

聚合

一般表示 一对多 多对多 整体对部分

一个对象拥有 一组其他对象,扮演容器和集合的角色。

组建可以独立于容器,也可以同时链接多个容器。

表示:

关系_聚合1

组合

组合是一种特殊的聚合,对象由 一个或多个其他对象实例构成。组件仅能作为容器的一部分存在

关系_组合1

聚合 vs 组合

比如 大学 嘎 则 院系 消失 因为 大学管理院系的生命周期

但是 院系 嘎 教授 不一定会 嘎,因为 是聚合关系

组合使得两个 tight 的更紧了

总结

关系总结

  • 依赖: 对类 B 进行修改会影响到类 A 。
  • 关联: 对象 A 知道对象 B。 类 A 依赖于类 B。
  • 聚合: 对象 A 知道对象 B 且由 B 构成。 类 A 依赖于类 B。
  • 组合: 对象 A 知道对象 B、 由 B 构成而且管理着 B 的生命周期。 类 A 依赖于类 B。

设计模式

算法 vs 设计模式

算法:明确定义达成特定目标的所需一系列步骤。像一道菜谱:提供达成目标的「明确」步骤

模式:解决方案的更高层次描述。像蓝图:知道最终结果和功能,自己确定如何实现的步骤

分类

  • 创建型

    • “提供创建对象的机制, 增加已有代码的灵活性和可复用性”
  • 结构型

    • “对象和类组装成较大的结构, 并同时保持结构的灵活和高效。”
  • 行为

    • “行为模式负责对象间的高效沟通和职责委派。”

软件设计原则

代码复用

从 类 转向 模式 再到 框架,复用程度不断增加

模式提供的复用方式比框架风险小。

扩展性

需要应对未来可能发生的变化来提供具有可扩展性的代码

设计原则

从而保证灵活的设计

封装变化的内容

定位程序中的变化内容并与不变的内容分开从而使得将变更造成的影响最小化

封装掉变化的内容从而可以保护其他代码不受负面影响。

封装方法

方法封装

将计算税金的逻辑抽取到一个单独的方法中, 并对原始方法隐藏该逻辑

方法封装2

隔离后就可以只修改getTaxrate的逻辑,并且如果税率计算逻辑变得过于复杂, 也能更方便地将其移动到独立的类中。

封装类

新增行为通常还会带来助手成员变量和方法,将这些内容抽出来到一个新类中

类封装

类封装2

面向接口进行开发

这样的好处是,我们依赖的是抽象类型而不是具体类

  • 确定一个对象对另一对象的确切需求: 它需执行哪些方法?
  • 在一个新的接口或抽象类中描述这些方法。
  • 让被依赖的类实现该接口。
  • 现在让有需求的类依赖于这个接口, 而不依赖于具体的类。 你仍可与原始类中的对象进行互动, 但现在其连接将会灵活得多。

面向接口开发

组合优于继承

继承有比较多的缺点:

  • 子类不能减少超类的接口。 你必须实现父类中所有的抽象方法, 即使它们没什么用。

  • 在重写方法时, 你需要确保新行为与其基类中的版本兼容。 这一点很重要, 因为子类的所有对象都可能被传递给以超类对象为参数的任何代码, 相信你不会希望这些代码崩溃的。

  • 继承打破了超类的封装, 因为子类拥有访问父类内部详细内容的权限。 此外还可能会有相反的情况出现, 那就是程序员为了进一步扩展的方便而让超类知晓子类的内部详细内容。

  • 子类与超类紧密耦合。 超类中的任何修改都可能会破坏子类的功能。

  • 通过继承复用代码可能导致平行继承体系的产生。 继承通常仅发生在一个维度中。 只要出现了两个以上的维度, 你就必须创建数量巨大的类组合, 从而使类层次结构膨胀到不可思议的程度

总结:复写所有抽象方法 + 兼容性 + 打破封装 + 增加耦合 + 类层次膨胀

组合是代替继承的一种方法。 继承代表类之间的 “是” 关系 (汽车是交通工具), 而组合则代表 “有” 关系 (汽车有一个引擎)。

这个原则也能应用于聚合 (一种更松弛的组合变体, 一个对象可引用另一个对象, 但并不管理其生命周期)。 例如: 一辆汽车上有司机, 但是司机也可能会使用另一辆汽车, 或者选择步行而不使用汽车。

例子

组合优于继承1

类越来越多

可以通过进行 组/聚 合优化:

组合优于继承2

SOLID

SOLID 五原则 让软件设计更易于理解、 更加灵活和更易于维护的五个原则的简称。

「S」ingle Responsibility Principle 单一职责原则

每一个类应当只负责软件中的一个功能,并将其完全封装在该类中。

使用场景:“开始感觉在同时关注程序特定方面的内容时有些困难的话, 请回忆单一职责原则并考虑现在是否应将某些类分割为几个部分。”

例子:

单一职责原则1

单一职责原则2

「O」pen/closed Principle 开闭原则

对于扩展,类应当 「开放」,对于修改,类应该「封闭」

实现新功能时保持已有代码不变

开放:对一个类扩展时可以创建它的子类并对其做任何事情,比如新增方法,重写方法。

封闭:比如字段设置了 final 从而限制了扩展,那么就不是开放,并且如果一个类做好了充分的准备来使得其他类能够使用,即接口明确定义不会修改,那么该类就是封闭/完整

开闭原则1

开闭原则2

现在当需要实现新的运输方式那么扩展 Shipping接口创建新类即可,订单在这里就不会被破坏,属于封闭。

「L」iskov Substitution Principle 里氏替换原则

扩展类时,应该要能在不修改客户端代码的情况下子类的对象作为父类对象进行传递。其意味着子类需要和父类行为兼容,重写方法时,对基类进行 扩展 而不是进行替换

这个原则是用于预测子类是否与代码兼容。是否能与其超类对象协作的一组检查。主要服务于开发程序库和框架时。

举个例子,假设你有一个父类 Bird 和一个继承自 Bird 的子类 Sparrow

1
2
3
4
5
6
7
class Bird:
def fly(self):
print("Bird is flying")

class Sparrow(Bird):
def fly(self):
print("Sparrow is flying")

在这个例子中,SparrowBird 的子类,并且重写了 fly 方法。根据里氏替换原则,任何期望 Bird 类型对象的代码都应该能够接受 Sparrow 对象,而不需要做任何改变。

1
2
3
4
5
def let_it_fly(bird: Bird):
bird.fly()

sparrow = Sparrow()
let_it_fly(sparrow) # 这里传递了一个 Sparrow 对象

在这段代码中,let_it_fly 函数接受一个 Bird 类型的对象,并调用它的 fly 方法。由于 SparrowBird 的子类,我们可以创建一个 Sparrow 对象并将其传递给 let_it_fly 函数。根据里氏替换原则,这应该能够正常工作,因为 Sparrow 完全兼容 Bird 类的接口。

需要有一些对于子类的形式要求:

  • “子类方法的参数类型必须与其超类的参数类型相匹配或更加抽象”

    • “假设某个类有个方法用于给猫咪喂食: feed­(Cat c) 。 客户端代码总是会将 “猫 (cat)” 对象传递给该方法。
    • 好的方式: 假如你创建了一个子类并重写了前面的方法, 使其能够给任何 “动物 (ani­mal, 即 ‘猫’ 的超类)” 喂食: feed­(Animal c) 。 如果现在你将一个子类对象而非超类对象传递给客户端代码, 程序仍将正常工作。 该方法可用于给任何动物喂食, 因此它仍然可以用于给传递给客户端的任何 “猫” 喂食。
    • 不好的方式: 你创建了另一个子类且限制喂食方法仅接受 “孟加拉猫 (Ben­gal­Cat, 一个 ‘猫’ 的子类)”: feed­(Bengal­Cat c) 。 如果你用它来替代链接在某个对象中的原始类,客户端中会发生什么呢? 由于该方法只能对特殊种类的猫进行喂食, 因此无法为传递给客户端的普通猫提供服务, 从而将破坏所有相关的功能。
  • “子类方法的返回值类型必须与超类方法的返回值类型或是其子类别相匹配。 正如你所看到的, 对于返回值类型的要求与对于参数类型的要求相反。”

    return 越具体越好
    parameters 越抽象越好

    • 假设buyCat() : Cat, 返回猫
    • 好:重写为 buyCat(): BengalCat
    • 不好:重写为 buyCat(): Animal
  • 异常类型必须与基础方法的异常或者子类相匹配

  • 子类不应该加强前置条件,也不能削弱后置条件

  • 超类的不变量需要保留

  • 子类不能修改超类中私有变量的值

「I」terface Segregation Principle 接口隔离原则

客户端不应该强迫依赖于其不使用的方法。

应该提升细粒度将导致臃肿的方法分割给其他接口:

接口隔离原则1

「D」ependency Inversion Principle 依赖倒置原则

高层次的类不应该依赖于低层次的类。 两者都应该依赖于抽象接口。 抽象接口不应依赖于具体实现。 具体实现应该依赖于抽象接口。

  • 低层次的类实现基础操作 (例如磁盘操作、 传输网络数据和连接数据库等)。
  • 高层次类包含复杂业务逻辑以指导低层次类执行特定操作。

依赖倒置原则建议改变这种依赖方式。

作为初学者, 你最好使用业务术语来对高层次类依赖的低层次操作接口进行描述。 例如, 业务逻辑应该调用名为 open­Report­(file)的方法, 而不是 open­File­(x) 、 read­Bytes­(n)和 close­File­(x)等一系列方法。 这些接口被视为是高层次的。
现在你可基于这些接口创建高层次类, 而不是基于低层次的具体类。 这要比原始的依赖关系灵活很多。
一旦低层次的类实现了这些接口, 它们将依赖于业务逻辑层, 从而倒置了原始的依赖关系。

依赖倒置原则通常和开闭原则共同发挥作用: 你无需修改已有类就能用不同的业务逻辑类扩展低层次的类。

修改前,高层次 BudgetReport 依赖于 低层次的 数据库操作方法,如果数据库的版本或者功能发生改变,那么BudgeReport就可能不能工作:

依赖倒置原则