概览介绍
写在前面
我们尚未正式开始学习,让我们先理智地认识到,设计模式 (Design Patterns)
并非解决所有问题的银弹。确切来说,它们只是一套工具,帮助我们更好地总结经验和最佳实践,以实现优雅且易读的代码。
尽管设计模式具有其重要性,但这并不能补充深入学习和理解数据结构与算法的必要性。在提升代码效率和性能方面,掌握数据结构和算法才是核心关键。这是每一个称职的程序员都必须不断提升和深化的领域。
在实际应用中,设计模式和数据结构、算法是相辅相成的。我们需要结合运用这些工具和知识,才能解决问题并编写出高效、优秀的代码。
此外,持续重构是一个极其重要的过程。通过改善代码的结构和设计,我们方能使代码更加稳健并且易于维护。
因此,在学习设计模式的同时,我们也需要密切关注数据结构和算法的学习。通过不断的实战操作和深化理解,我们会成长为更出色的软件开发者,有能力编写高质量的代码。
学习网站
在线学习网站: Refactoring.Guru
GOF 模式
"GOF" 是指《设计模式:可复用面向对象软件的基础》(Design Patterns: Elements of Reusable Object-Oriented Software)这本经典的设计模式书籍的作者首字母缩写,也被称为 "Gang of Four"。该书由 Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides 四位作者合著。
GOF
模式(23
种设计模式)可以按照一些常见的分类方式进行组织,例如 "创建型模式"、"结构型模式" 和 "行为型模式"。这种分类方式可以帮助我们理解和组织这些模式的概念和应用场景,但并不意味着你必须按照特定的顺序学习或使用它们。具体如下:
类型 | 模式 | 简单描述 |
---|---|---|
创建型模式 | 单例模式 | 确保类只有一个实例,并提供一个全局访问点。 |
创建型模式 | 工厂方法模式 | 定义一个创建对象的接口,但由子类决定要实例化的类是哪一个。 |
创建型模式 | 抽象工厂模式 | 提供一个接口,用于创建相关或依赖对象的家族,而不需要明确指定具体类。 |
创建型模式 | 建造者模式 | 使用简单对象和逐步构建的方式复杂对象进行构建的创建型模式。 |
创建型模式 | 原型模式 | 用于创建对象的种类,并通过拷贝这些原型创建新的对象。 |
结构型模式 | 适配器模式 | 允许对象使用不同的接口进行交互。 |
结构型模式 | 桥接模式 | 将抽象化与实现化解耦,使得二者可以独立变化。 |
结构型模式 | 组合模式 | 将对象组成树形结构以表示"部分-整体"的层次结构。 |
结构型模式 | 装饰模式 | 动态地给一个对象添加一些额外的职责,就增加功能来说,比生成子类更为灵活。 |
结构型模式 | 外观模式 | 为子系统中的一组接口提供一个一致的界面。 |
结构型模式 | 享元模式 | 通过与其他相似对象共享数据来减小内存使用量,或者计算或网络负载。 |
结构型模式 | 代理模式 | 为其他对象提供一个代理以控制对这个对象的访问。 |
行为型模式 | 解释器模式 | 定义语法的一种方式,用于解释一个表达式。 |
行为型模式 | 模板方法模式 | 在不改变模板结构的前提下,重新定义模板的某些特定步骤。 |
行为型模式 | 观察者模式 | 对象之间存在一对多的依赖关系,当一个对象状态改变时,所有依赖于它的对象都会收到通知。 |
行为型模式 | 迭代器模式 | 提供一种方法顺序访问一个聚合对象中各个元素,而又不暴露其内部的表示。 |
行为型模式 | 策略模式 | 定义了一系列的算法,并将每一个算法封装起来,使它们可以互相替换。 |
行为型模式 | 命令模式 | 将一个请求封装为一个对象,使发出请求的责任和执行请求的责任分割开。 |
行为型模式 | 备忘录模式 | 在不破坏对象的封装性的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态,以便将来恢复。 |
行为型模式 | 访问者模式 | 主要将数据结构与数据操作分离,解决数据结构和操作之间的耦合性。 |
行为型模式 | 中介者模式 | 用一个中介对象来封装一系列的对象交互,中介者使各对象不需要显式地互相引用,从而使其耦合松散。 |
行为型模式 | 责任链模式 | 为请求创建了一个接收者对象的链,这些接收者中包含有请求的处理代码。 |
行为型模式 | 状态模式 | 对象的行为依赖于它的状态(即,它的属性),并且可以根据它的状态改变而改变其行为。 |
创建型模式
单例模式
代码示例
创建了一个Logger
类型的单例,并用它来写日志。
go
// gof/singleton.go
package gof
import (
"fmt"
"sync"
"time"
)
type Logger struct {
mu sync.Mutex
}
var instance *Logger
var once sync.Once
func GetInstance() *Logger {
once.Do(func() {
instance = &Logger{}
})
return instance
}
func (l *Logger) Log(message string, index ...int) {
l.mu.Lock()
defer l.mu.Unlock()
fmt.Printf("[%s] %s %d\n", time.Now().Format("2006-01-02 15:04:05"), message, index[0])
}
这个函数返回的始终是同一个Logger
的实例。由sync.Once
类型变量once
保证的,无论多少协程去调用这个函数,实例都只创建一次。Logger
结构体内有一个互斥锁mu
,这把锁就是用来保护Log
方法的并发访问的。
go
// main.go
package main
import (
"sync"
"codebase/gof"
)
func main() {
wg := sync.WaitGroup{}
for i := 0; i < 100000; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
logger := gof.GetInstance()
logger.Log("this is a message", i) // 让每一个协程都去写日志
}(i)
}
wg.Wait()
}
客观分析
外界有些言论会说单例模式是一种过时的设计模式。为什么会有这样的观点呢?其实单例模式确实有一些潜在的问题,我们看下面的优劣分析:
类别 | 内容 |
---|---|
优势 | 1. 全局资源控制:当网络应用需要创建一些全局的对象,其中经常包括数据库连接池、线程池、系统中的配置对象、应用缓存和日志对象等,使用单例模式能够方便地控制其数量,确保系统中只有一个实例存在。 2. 对象创建成本高:如果对象创建的成本较高,例如,它需要消耗大量的系统资源,或者从系统其他部分获取数据,或者进行耗时的计算。在这种情况下,可以通过单例模式防止多次创建相同的对象,节省系统资源。 3. 保持全局一致性:存在一些全局状态需要持续维护,而状态的一致性对系统的运行至关重要。在这种情况下,单例模式可以帮助我们确保全局的一致性。 |
劣势 | 1. 违背了"单一职责原则":单一职责原则意味着一个类应该只有一个引起它变化的原因。但在单例模式中,此类既管理对象的创建,又处理业务逻辑,因此当业务逻辑需要修改时,有可能对类的实例化部分造成影响。 2. "隐藏"了类之间的依赖关系:使用单例模式可能会使得依赖关系不再明确,并可能会产生隐藏的依赖关系,这可能会导致代码维护和理解上的困难。 3. 处理并发问题:虽然Java和Go等语言提供了处理并发问题的机制,但在其他不支持这些机制的语言中,需要特别注意并发环境下单例的创建和使用。 4. 难以进行单元测试:由于单例模式在全局只有一个实例,因此当单例对象的状态发生改变时可能影响到其他的测试用例。 |
作为软件工程师,我们无疑会遇到各种各样关于编程范式和设计模式,包括开篇的单例模式在内的各种评论和观点。陈述有所不同,观点各异,这是必然的。然而,面对各种声音,我们并非要完全放弃使用单例模式。关键在于,我们需要根据特定的应用场景和特殊需求,实施审慎的判断和权衡。每一种设计模式都有其独特的用途和优势,只有明确了这一点,我们才能做出最适宜的决定,从而高效地解决遇到的问题。
工厂方法模式
代码示例
让我们用一个餐馆制作食物的例子来阐释工厂方法模式:
假设你去了一个西餐厅,这个餐厅可以提供各种类型的食物,包括汉堡、比萨、三明治等。当你点单时,你并不知道厨师是如何烹饪这些食物的,你只需告诉服务员你的点单信息。
这里的餐厅相当于工厂,提供的食物相当于产品,点菜动作相当于调用工厂方法。
在代码中,我们可以将其抽象成接口,例如:
go
// gof/factory_method.go
package gof
import (
"fmt"
)
// Food 接口定义了烹饪的共同行为
type Food interface {
Cook() string
}
// Burger 是汉堡的具体类型
type Burger struct{}
func (b Burger) Cook() string {
return "Cooking a burger."
}
// Pizza 是披萨的具体类型
type Pizza struct{}
func (p Pizza) Cook() string {
return "Cooking a pizza."
}
// Kitchen 接口定义了厨房的行为
type Kitchen interface {
CookFood(order string) Food
}
// Restaurant 实现了 Kitchen 接口
type Restaurant struct{}
func (r Restaurant) CookFood(order string) Food {
switch order {
case "burger":
return &Burger{}
case "pizza":
return &Pizza{}
default:
fmt.Println("Guest ordered a food that doesn't exist.")
return nil
}
}
我们只要知道想吃何种食物,餐厅就会为顾客烹饪相应的食物,顾客并不需要知道烹饪食物的具体细节。
因此,餐厅烹饪食物的过程就是一个典型的工厂方法模式,它把制作食物的过程(创建产品的过程)和顾客消费食物的过程(使用产品的过程)进行了分离。
go
// main.go
package main
import (
"fmt"
"codebase/gof"
)
func main() {
restaurant := &gof.Restaurant{}
food1 := restaurant.CookFood("burger")
fmt.Println(food1.Cook())
food2 := restaurant.CookFood("pizza")
fmt.Println(food2.Cook())
food3 := restaurant.CookFood("sandwich")
if food3 != nil {
fmt.Println(food3.Cook()) // 无效的食物
}
}
客观分析
工厂方法模式在很多情况下都很有用,但它并不是完美的。以下是一些工厂方法模式的优点和缺点:
类别 | 内容 |
---|---|
优势 | 1. 代码解耦:客户端代码不需要知道具体的产品类,只需要知道对应的工厂即可。这降低了客户端代码与具体产品类之间的耦合度。 2. 代码可扩展性高:当增加新的产品时,只需要增加新的具体产品类和对应的工厂类,无需修改既有的代码,满足了"开放封闭原则"。 3. 将对象创建集中管理,可以一定程度上控制创建对象的数量和申请的资源,例如连接池、线程池等。 |
劣势 | 1. 代码复杂性增加:为每一个产品都要提供一个工厂类,如果产品数量非常多,会导致工厂类的数量也非常多,增加了代码的复杂性。 2. 增加了系统的抽象性和理解难度,对于简单的对象创建,使用工厂方法可能会显得过于复杂。 3. 当产品族中产品类增加的时候,所有的工厂类都需要进行修改,可能会引入错误。 |
总的来说,工厂方法模式在某些情景下能大大提升代码的可维护性和可扩展性,但是如若应用不当,也可能会导致代码复杂性增加。因此,是否使用工厂方法模式,需要根据具体情况进行选择。
抽象工厂模式
代码示例
让我们再次使用餐馆的例子帮助理解升级版的抽象工厂模式:
假设你去了一个大型综合餐厅,这个餐厅既有西餐厅也有中餐厅,分别提供各类西餐和中餐。当你坐在餐桌旁,你不关心菜品是如何准备的,你只需要点单并享用。这里的各类餐厅就是抽象工厂,菜品就是产品。
在代码层面,你可以抽象出一个食品接口和一个餐厅接口:
go
// gof/abstract_factory.go
package gof
// 食物接口
type Food interface {
Cook() string
}
// 具体的食物:汉堡和披萨
type Burger struct{}
func (b Burger) Cook() string {
return "Making a burger."
}
type Pizza struct{}
func (p Pizza) Cook() string {
return "Making a pizza."
}
// 具体的食物:饺子和面条
type Dumpling struct{}
func (b Dumpling) Cook() string {
return "Making dumplings."
}
type Noodle struct{}
func (p Noodle) Cook() string {
return "Making noodles."
}
// 餐厅接口
type Restaurant interface {
MakeMainDish() Food
MakeSideDish() Food
}
// 西餐厅餐厅(抽象工厂)
type WesternRestaurant struct{}
func (w WesternRestaurant) MakeMainDish() Food {
return &Burger{}
}
func (w WesternRestaurant) MakeSideDish() Food {
return &Pizza{}
}
// 中餐厅餐厅(抽象工厂)
type ChineseRestaurant struct{}
func (c ChineseRestaurant) MakeMainDish() Food {
return &Dumpling{}
}
func (c ChineseRestaurant) MakeSideDish() Food {
return &Noodle{}
}
这样可以保证当你在中餐厅用餐时,你只能得到中餐,当你在西餐厅用餐时,你只能得到西餐。即,餐厅(抽象工厂)保证给你提供的始终是正确类别的食物(产品)。所以,无论你进入的是哪个类型的餐厅,你都会收到对的主菜和副菜,这就是抽象工厂模式的魅力所在。
go
// main.go
package main
import (
"fmt"
"codebase/gof"
)
func main() {
chinese := &gof.ChineseRestaurant{}
chineseFoodMain := chinese.MakeMainDish().Cook()
chineseFoodSide := chinese.MakeSideDish().Cook()
fmt.Println(chineseFoodMain, chineseFoodSide)
western := &gof.WesternRestaurant{}
westernFoodMain := western.MakeMainDish().Cook()
westernFoodSide := western.MakeSideDish().Cook()
fmt.Println(westernFoodMain, westernFoodSide)
}
客观分析
抽象工厂模式就像是工厂方法模式的进一步延伸和泛化,它的优劣性也更加显著:
类别 | 内容 |
---|---|
优点 | 1. 分离接口和实现:客户端通过工厂接口创建需要的对象,而不需要关心对象的创建细节。 2. 让更换产品族变得容易:由于抽象工厂类固定提供所有产品类,每个具体工厂类只需确定产品即可,便于系统扩展。 3. 提高灵活性和抗变异性:抽象工厂模式提供的高度抽象性使得系统更具有灵活性和抗变异性,便于应对变动。 4. 它避免了"具体产品"(也就是被创建的对象)与客户代码的耦合。 |
劣势 | 1. 不太容易支持新种类的产品:如果需新增不属于现有的产品族,要修改的地方会非常多。 2. 由于涉及到多个工厂类和产品类,所以代码的复杂性会提高。 3. 采用了高级别的抽象,设计和理解起来可能较复杂。 |
使用抽象工厂模式是需要权衡的,它提供了很大的灵活性,而且可以提高代码的可复用性,但它也可能使你的代码更加复杂,更难以调试和维护。所以选择时需要判断当前的系统架构是否真的需要抽象工厂模式。
非 GoF 模式
GoF
列出了 23
种经典的设计模式,但实际上在编程实践中,有其他一些模式也被程序员广泛接受和使用,主要包括:
设计模式 | 描述 |
---|---|
简单工厂模式(Simple Factory) | 并未出现在 GoF 的 23 个设计模式中,但是它的思想非常简单,主要用于创建某类对象,封装了创建对象的逻辑。 |
空对象模式(Null Object) | 空对象替换 NULL 对象实例的检查。一个空对象不是检查空值,而是反应一个不做任何动作的关系。这样的空对象也可以在数据不可用的时候提供默认的行为。 |
线程池模式(Thread Pool) | 这是一种在设计多线程应用程序时常用的模式。一个线程池是一组闲置的线程,这些线程处于等待状态,预备在程序中的其他线程分配给它们的任务去执行。 |
依赖注入模式(Dependency Injection) | 它是一种实现控制反转 IoC (Inversion of Control) 的技术,使得代码更加解耦合。在这种模式中,一个类获得它依赖的对象的引用,而不是自己去构造或找到这些对象。 |
MVC 模式(Model View Controller) | 是一种架构模式,广泛应用于 Web 开发中。 |
MVVM 模式(Model View ViewModel) | 框架模式,用于解决UI开发中的分离问题,如 WPF 和 Angular。 |
简单工厂模式
"简单工厂模式" 或 "静态工厂模式" (static factory method)
,是后来由社区提出的一种创建型模式,主要用于创建某一种或某一类对象。在简单工厂模式里,你可以想象,所有的产品都是从同一间工厂流水线上生产出来的,不同的产品只是工厂生产流程的不同分支。
从某些角度来看,简单工厂模式和工厂方法模式是有点相似,因为它们都提供了一种方式来封装对象的创建逻辑。然而,这两种模式的用途和结构存在明显的差异。
我们还是使用餐馆举例,便于理解:
go
package main
import "fmt"
// Food 是我们的基本产品接口
type Food interface {
Cook() string
}
// Burger 是一种具体的食物
type Burger struct {}
func (b Burger) Cook() string {
return "Making a burger."
}
// Dumpling 也是一种具体的食物
type Dumpling struct {}
func (d Dumpling) Cook() string {
return "Making dumplings."
}
// FoodFactory 是我们的简单工厂,用来创建食物
func FoodFactory(foodType string) Food {
switch foodType {
case "Burger":
return &Burger{}
case "Dumpling":
return &Dumpling{}
default:
return nil
}
}
func main() {
food1 := FoodFactory("Burger")
fmt.Println(food1.Cook())
food2 := FoodFactory("Dumpling")
fmt.Println(food2.Cook())
}
在 Go
语言中,常会用函数创建并初始化结构体的方式来实现简单工厂模式,这类函数通常会有 "New" 开头的函数名。其实这和简单工厂模式的原理是一样的,Simple Factory
不一定是一个类,很多情况下,它只是一段代码,或者一个函数。
这种 NewXXX
的方式在 Go
语言编程中非常常见。原因有二:其一,Go
语言没有构造函数;其二,结构体的零值不一定是我们想要的初始状态。
go
package main
import (
"fmt"
)
type User struct {
name string
age int
}
func NewUser(name string, age int) *User {
return &User{name: name, age: age}
}
func main() {
user := NewUser("John", 20)
fmt.Println(user)
}
函数选项模式
函数选项模式(Functional Options),在 Go 社区中普遍得到了独特的喜爱。这个模式为构造函数的调用者提供了极大的灵活性,使得用户可以在保留默认行为的基础上进行任意自定义的配置。
为何 Go + 函数选项才算是绝配?
函数选项模式在 Go
社区的流行,主要归功于其优雅地解决了在 Go
编程中常见的一些问题。
首先,Go
是一门静态类型的语言,且不具备类似 Java
中的构造器重载或 C#
中的命名参数等特性。因此,当一个函数(比如结构体的构造函数)具有许多可选参数时,我们通常需要提供一个包含多字段的配置结构体作为输入。然后,这个配置结构体需要在创建时初始化其所有字段,然而这就带来了一些额外的工作,且使得调用者在理解每个字段具体作用上有所困难。而函数选项模式则提供了一个清晰且简洁的方式来处理这个问题。
其次,Go
的函数是一等公民,并且支持闭包,这使我们可以自然而然地实现函数选项模式。与需要编写大量样板代码的 Builder
模式相比,函数选项模式更加简洁,灵活且易于理解。
最后,函数选项模式由于其灵活性质,也有助于维护 API
的向后兼容性。当你需要增加新的选项时,只需要添加一个新的函数选项即可,无需改变现有的函数签名或是调用代码。
当然,元年后诞生的编程语言基本都支持多编程范式。譬如 Rust
,其实也可将函数视为一等公民,并且支持闭包,但由于 Rust
的所有权和生命周期规则,使得在 Rust
中实现函数选项模式可能会相比 Go
中更复杂一些。
至于动态语言,如 Python
和 JavaScript
。Python
有独特的 *args/**kwargs
机制,JS
同样也有 Rest parameters
和解构等方式。这些情况都显示出了,不同的语言,都有着属于自己独特的编程实现方式。
确实可以写,但也属实没必要系列
Python 初始化:
python
# Python 版函数选项
class DatabaseConfig:
def __init__(self, *options):
self.options = options
self.host = 'localhost'
self.port = 3306
self.user = 'root'
self.password = 'root'
self.database = 'test_db'
for option in options:
option(self)
def set_host(host):
def inner(config):
config.host = host
return inner
def set_port(port):
def inner(config):
config.port = port
return inner
def set_user(user):
def inner(config):
config.user = user
return inner
def set_password(password):
def inner(config):
config.password = password
return inner
def set_database(database):
def inner(config):
config.database = database
return inner
config = DatabaseConfig(set_host('192.168.1.1'), set_port(8888), set_user('admin'), set_password('admin123'), set_database('my_database'))
print(config.host) # 输出 192.168.1.1
print(config.port) # 输出 8888
print(config.user) # 输出 admin
print(config.password) # 输出 admin123
print(config.database) # 输出 my_database
python
# 经典的 Python 风格
class DatabaseConfig:
def __init__(self, host='localhost', port=3306, user='root', password=None, database=None):
self.host = host
self.port = port
self.user = user
self.password = password
self.database = database
config = DatabaseConfig(host='192.168.1.1', port=8888, user='admin', password='admin123', database='my_database')
print(config.host) # 输出 192.168.1.1
print(config.port) # 输出 8888
print(config.user) # 输出 admin
print(config.password) # 输出 admin123
print(config.database) # 输出 my_database
JavaScript 初始化:
js
// Nodejs 版函数选项
class DatabaseConfig {
constructor(...options) {
this.options = options;
this.host = 'localhost';
this.port = 3306;
this.user = 'root';
this.password = 'root';
this.database = 'test_db';
options.forEach(option => option(this));
}
}
const setHost = (host) => (config) => { config.host = host; };
const setPort = (port) => (config) => { config.port = port; };
const setUser = (user) => (config) => { config.user = user; };
const setPassword = (password) => (config) => { config.password = password; };
const setDatabase = (database) => (config) => { config.database = database; };
const config = new DatabaseConfig(
setHost('192.168.1.1'),
setPort(8888),
setUser('admin'),
setPassword('admin123'),
setDatabase('my_database')
);
console.log(config.host); // Outputs: 192.168.1.1
console.log(config.port); // Outputs: 8888
console.log(config.user); // Outputs: admin
console.log(config.password); // Outputs: admin123
console.log(config.database); // Outputs: my_database
js
// 经典的 ES6 风格
class DatabaseConfig {
constructor({ host = 'localhost', port = 3306, user = 'root', password = 'root', database = 'test_db' } = {}) {
this.host = host;
this.port = port;
this.user = user;
this.password = password;
this.database = database;
}
}
const config = new DatabaseConfig({
host: '192.168.1.1',
port: 8888,
user: 'admin',
password: 'admin123',
database: 'my_database'
});
console.log(config.host); // Outputs: 192.168.1.1
console.log(config.port); // Outputs: 8888
console.log(config.user); // Outputs: admin
console.log(config.password); // Outputs: admin123
console.log(config.database); // Outputs: my_database
最后,来看下最正统的 Go 版本吧
作为一名 Gopher
,对于函数选项模式我们有着较深的感情。因此,下面对这个模式进行特别介绍!
go
// Golang 函数选项模式
package main
import (
"fmt"
)
type DatabaseConfig struct {
host string
port int
user string
password string
database string
}
type Option func(*DatabaseConfig)
type Chain []Option
func WithHost(host string) Option {
return func(c *DatabaseConfig) {
c.host = host
}
}
func WithPort(port int) Option {
return func(c *DatabaseConfig) {
c.port = port
}
}
func WithUser(user string) Option {
return func(c *DatabaseConfig) {
c.user = user
}
}
func WithPassword(password string) Option {
return func(c *DatabaseConfig) {
c.password = password
}
}
func WithDatabase(database string) Option {
return func(c *DatabaseConfig) {
c.database = database
}
}
func (chain Chain) apply(config *DatabaseConfig) {
for _, option := range chain {
option(config)
}
}
func NewDatabaseConfig(opts ...Option) *DatabaseConfig {
cfg := &DatabaseConfig{
host: "localhost",
port: 3306,
}
Chain(opts).apply(cfg)
return cfg
}
func main() {
config := NewDatabaseConfig(
WithHost("192.168.1.1"),
WithPort(8888),
WithUser("admin"),
WithPassword("admin123"),
WithDatabase("my_database"),
)
fmt.Printf("Host: %s\nPort: %d\nUser: %s\nPassword: %s\nDatabase: %s\n", config.host, config.port, config.user, config.password, config.database)
}