Appearance
设计模式与 Go 语言
设计模式(Design Patterns)这个词自 1994 年 GoF(Gang of Four)出版《设计模式:可复用面向对象软件的基础》以来,几乎成了软件工程的「必修课」。然而,设计模式并不是一套放之四海而皆准的模板,它的形态高度依赖语言特性。同一个模式在 Java、C++、Python、Go 中的实现可能截然不同,甚至有些模式在 Go 中根本没有存在的必要。
本系列教程面向已经有 Go 基础的开发者,目标是把 23 种经典设计模式「翻译」到 Go 的语言世界里。这一篇是总论,我们先回答几个根本问题:GoF 23 种模式到底讲了什么?Go 语言有哪些特性会让设计模式变形?SOLID 原则在 Go 中如何体现?
一、GoF 23 种设计模式概述
GoF 将 23 种模式按目的分为三大类:创建型、结构型、行为型。
1. 创建型模式(Creational Patterns)
关注「对象怎么创建」,把对象的创建和使用解耦。一共 5 种:
| 模式 | 一句话意图 |
|---|---|
| 单例(Singleton) | 保证一个类只有一个实例 |
| 工厂方法(Factory Method) | 由子类决定创建哪种对象 |
| 抽象工厂(Abstract Factory) | 创建一族相关对象 |
| 建造者(Builder) | 分步构建复杂对象 |
| 原型(Prototype) | 通过克隆已有对象创建新对象 |
2. 结构型模式(Structural Patterns)
关注「对象怎么组合」成更大的结构。一共 7 种:
| 模式 | 一句话意图 |
|---|---|
| 适配器(Adapter) | 转换接口,让不兼容的类协作 |
| 桥接(Bridge) | 分离抽象与实现 |
| 组合(Composite) | 树形结构统一处理 |
| 装饰器(Decorator) | 动态添加职责 |
| 外观(Facade) | 为子系统提供简化接口 |
| 享元(Flyweight) | 共享细粒度对象 |
| 代理(Proxy) | 控制对对象的访问 |
3. 行为型模式(Behavioral Patterns)
关注「对象怎么通信、职责怎么分配」。一共 11 种:
| 模式 | 一句话意图 |
|---|---|
| 责任链(Chain of Responsibility) | 请求沿链传递 |
| 命令(Command) | 把请求封装成对象 |
| 解释器(Interpreter) | 定义一种语言并解释执行 |
| 迭代器(Iterator) | 统一遍历接口 |
| 中介者(Mediator) | 集中管理对象间交互 |
| 备忘录(Memento) | 保存并恢复对象状态 |
| 观察者(Observer) | 发布-订阅 |
| 状态(State) | 状态改变行为 |
| 策略(Strategy) | 算法族可互换 |
| 模板方法(Template Method) | 定义算法骨架 |
| 访问者(Visitor) | 在不修改类的前提下增加操作 |
需要提醒的是:这 23 种模式诞生于 C++/Smalltalk 时代,带着浓厚的「面向对象 + 继承」烙印。在 Go 里,有些模式会自然变形,有些会消失,有些会被语言特性直接取代。本系列不会机械地把 23 种全部实现一遍,而是挑选在 Go 中真正有价值的模式深入讲解。
二、Go 语言实现设计模式的独特性
下面这些 Go 的语言特性,会从根本上影响设计模式的写法。
1. 没有继承,使用组合和嵌入
Go 没有 class extends,也没有 virtual、protected、abstract 这些关键字。Go 的「面向对象」是通过结构体(struct)+ 方法(method)+ 接口(interface)拼出来的。复用代码靠两种手段:
- 组合:把一个类型作为另一个类型的字段。
- 嵌入(embedding):把一个类型匿名地放进结构体,自动「提升」它的字段和方法。
嵌入看起来像继承,但本质不同。下面的例子展示了这种区别:
go
package main
import "fmt"
// Animal 是被嵌入的类型
type Animal struct {
Name string
}
func (a Animal) Speak() {
fmt.Printf("%s 发出声音\n", a.Name)
}
// Dog 通过嵌入 Animal 「获得」了 Speak 方法
type Dog struct {
Animal // 匿名字段,方法被提升
Breed string
}
func main() {
d := Dog{
Animal: Animal{Name: "旺财"},
Breed: "中华田园犬",
}
d.Speak() // 直接调用,看起来像继承
fmt.Printf("%s 是 %s\n", d.Name, d.Breed)
}关键差别在于:Java 的继承是「is-a」关系,Go 的嵌入是「has-a」关系但 syntactic sugar 让它看起来像「is-a」。Go 不支持多态意义上的方法重写(override),但可以通过在 外层类型重新定义同名方法来「覆盖」被嵌入类型的方法。
设计模式上的影响巨大:
- 模板方法模式 在 Java 里依赖抽象类 + 钩子方法,Go 只能用接口 + 嵌入 + 函数字段模拟。
- 装饰器模式 在 Java 里靠继承同一个抽象类,Go 里靠嵌入接口。
- 状态模式、策略模式 在 Java 里靠子类,Go 里靠实现同一个接口的不同结构体。
2. 没有构造器,使用 NewXxx 函数
Go 没有 new MyClass(...) 这种语法层面的构造器,惯例是用 NewXxx 工厂函数返回实例。这听起来是个小细节,但影响深远:
- 「构造」逻辑集中在
NewXxx里,不会出现 Java 那种「构造器重载爆炸」。 - 工厂模式在 Go 里几乎是「默认配置」,不需要刻意设计。
- 参数较多时,配合 Functional Options 可以优雅地处理默认值(后面专门讲)。
go
package main
import "fmt"
// Config 是一个需要复杂构造的对象
type Config struct {
Host string
Port int
Timeout int
}
// NewConfig 是惯用的构造函数,带默认值
func NewConfig(host string, opts ...func(*Config)) *Config {
c := &Config{
Host: host,
Port: 8080, // 默认值
Timeout: 30, // 默认值
}
for _, opt := range opts {
opt(c)
}
return c
}
func WithPort(p int) func(*Config) {
return func(c *Config) { c.Port = p }
}
func WithTimeout(t int) func(*Config) {
return func(c *Config) { c.Timeout = t }
}
func main() {
c := NewConfig("localhost", WithPort(9090), WithTimeout(60))
fmt.Printf("%+v\n", c)
}3. 接口是隐式实现的(鸭子类型)
Java 的接口需要显式 implements,Go 不需要。只要你实现了接口要求的所有方法,你就自动满足了这个接口。这种「结构化类型(structural typing)」让 Go 的设计模式写起来特别灵活:
- 不需要为了适配某个模式去改既有类的声明。
- 可以用接口包装第三方库的类型,而不动它本身。
- 「针对接口编程」在 Go 里成本极低。
go
package main
import "fmt"
// Logger 接口,任何有 Log 方法的类型都自动实现它
type Logger interface {
Log(msg string)
}
// ConsoleLogger 不需要写 "implements Logger"
type ConsoleLogger struct{}
func (ConsoleLogger) Log(msg string) {
fmt.Println("[CONSOLE]", msg)
}
func doWork(l Logger) {
l.Log("开始干活")
}
func main() {
doWork(ConsoleLogger{})
}这一点对适配器、策略、装饰器、观察者等模式是「降维打击」——你不需要为每个模式造一堆抽象类。
4. 没有泛型(1.18 前)到有泛型(1.18+)的演进
Go 1.18 之前没有泛型,大家用 interface{}(现在叫 any)绕过类型系统,导致大量类型断言和运行时错误。1.18 引入泛型后,许多模式可以写得类型安全:
- 通用容器(栈、队列、树)不再需要
interface{}。 - 工厂、迭代器、策略可以参数化。
- 函数式工具(Map、Filter、Reduce)变得可行。
go
package main
import "fmt"
// 泛型栈:Go 1.18+
type Stack[T any] struct {
data []T
}
func (s *Stack[T]) Push(v T) {
s.data = append(s.data, v)
}
func (s *Stack[T]) Pop() (T, bool) {
var zero T
if len(s.data) == 0 {
return zero, false
}
v := s.data[len(s.data)-1]
s.data = s.data[:len(s.data)-1]
return v, true
}
func main() {
ints := &Stack[int]{}
ints.Push(1)
ints.Push(2)
v, _ := ints.Pop()
fmt.Println("int 栈弹出:", v)
strs := &Stack[string]{}
strs.Push("hello")
s, _ := strs.Pop()
fmt.Println("string 栈弹出:", s)
}但要注意:Go 的泛型不像 C++ 模板那样强大,不支持泛型特化、不支持泛型方法在方法上单独声明类型参数(只能在接收者上声明)。这些限制会在后面具体模式中讨论。
5. 首选组合而非继承
这是 Go 设计哲学的核心。Go 官方 FAQ 明确说:「Go 不相信通过继承来表达类型关系」。这意味着:
- 不要试图用嵌入模拟复杂的继承层级。
- 多用接口 + 组合,少用抽象基类。
- 「is-a」想清楚是不是真的「is-a」,很多情况下其实是「behaves-like-a」。
设计模式上,比如要实现「装饰器」,Java 写法是继承同一个抽象 Decorator 类;Go 写法是结构体里嵌入目标接口,把调用转发给被包装对象。后面专门讲。
6. 函数是一等公民:函数式编程能力
Go 的函数可以赋值给变量、作为参数传递、作为返回值。这意味着很多「行为型」模式在 Go 里不需要造一个类,一个函数类型就够了:
- 策略模式:策略就是一个
func(...) ...类型。 - 命令模式:命令就是一个
func()。 - 观察者模式:观察者就是一个回调函数。
- 模板方法:钩子方法就是一个函数字段。
go
package main
import "fmt"
// 策略就是函数类型
type DiscountStrategy func(price float64) float64
func NoDiscount(p float64) float64 { return p }
func TenPercentOff(p float64) float64 { return p * 0.9 }
func FullReduce(p float64) float64 {
if p >= 100 {
return p - 20
}
return p
}
func checkout(price float64, strategy DiscountStrategy) float64 {
return strategy(price)
}
func main() {
fmt.Println("原价:", checkout(120, NoDiscount))
fmt.Println("九折:", checkout(120, TenPercentOff))
fmt.Println("满减:", checkout(120, FullReduce))
}这种写法比 Java 的「策略接口 + 多个实现类」简洁得多。当然,如果策略需要保存状态或多个方法,还是用接口更合适。
三、设计模式的分类与 Go 中的取舍
GoF 的 23 种模式不是在 Go 里都「等价有效」的。下面是按类别的取舍建议。
1. 创建型
| 模式 | 在 Go 中的价值 | 备注 |
|---|---|---|
| 单例 | 中等 | sync.Once 是标准做法,但要警惕全局状态 |
| 工厂方法 | 高 | NewXxx 是惯用法 |
| 抽象工厂 | 低 | 多数场景过度设计,接口组合即可替代 |
| 建造者 | 高 | Functional Options 是 Go 标志性写法 |
| 原型 | 中等 | 用 proto.Clone,但 Go 没有内建克隆 |
2. 结构型
| 模式 | 在 Go 中的价值 | 备注 |
|---|---|---|
| 适配器 | 高 | 接口转换天然适配 Go |
| 桥接 | 中等 | 用接口字段即可,不需特意命名 |
| 组合 | 高 | 树形结构常见 |
| 装饰器 | 高 | 中间件就是装饰器 |
| 外观 | 高 | 简化子系统 API |
| 享元 | 低 | Go 的内存模型让享元收益有限 |
| 代理 | 中等 | 与装饰器结构相同,区分在意图 |
3. 行为型
| 模式 | 在 Go 中的价值 | 备注 |
|---|---|---|
| 责任链 | 高 | HTTP 中间件链就是 |
| 命令 | 中等 | 函数即可,复杂场景才用接口 |
| 解释器 | 低 | Go 不是 DSL 友好语言 |
| 迭代器 | 中等 | range 已是内建迭代器 |
| 中介者 | 中等 | 事件总线场景有用 |
| 备忘录 | 低 | 序列化即可 |
| 观察者 | 高 | channel + 回调两种实现 |
| 状态 | 中等 | 状态机场景 |
| 策略 | 高 | 函数策略最常用 |
| 模板方法 | 中等 | Go 没有继承,要变通 |
| 访问者 | 低 | Go 的双分派支持差,很少用 |
这张表不是教条,而是经验之谈。实际项目里,要根据问题本身判断,不要为了用模式而用模式。
四、SOLID 原则在 Go 中的体现
SOLID 是面向对象设计的五条原则,在 Go 中它们依然有效,但表达方式不同。
1. S - 单一职责(Single Responsibility)
一个类型只做一件事。在 Go 里,这通常表现为:
- 不要把「数据模型 + 业务逻辑 + 持久化」全塞进一个 struct。
- 包的粒度要小,一个包专注一个领域。
2. O - 开闭原则(Open-Closed)
对扩展开放,对修改封闭。Go 通过接口实现:定义稳定接口,新增实现不改老代码。
go
package main
import "fmt"
// Notifier 是稳定的抽象,新增通知方式不需要改 doNotify
type Notifier interface {
Notify(msg string)
}
type EmailNotifier struct{}
func (EmailNotifier) Notify(msg string) { fmt.Println("Email:", msg) }
type SMSNotifier struct{}
func (SMSNotifier) Notify(msg string) { fmt.Println("SMS:", msg) }
func doNotify(n Notifier, msg string) {
n.Notify(msg)
}
func main() {
doNotify(EmailNotifier{}, "hello")
doNotify(SMSNotifier{}, "hello")
}3. L - 里氏替换(Liskov Substitution)
子类能透明替换父类。Go 没有继承,但这条原则在「接口实现」层面依然成立:实现同一个接口的不同类型,行为契约必须一致。比如实现了 io.Reader 的所有类型,Read 的语义必须符合文档约定。
4. I - 接口隔离(Interface Segregation)
不要让类型实现它用不到的方法。Go 这条贯彻得特别好,因为接口是隐式实现的,而且推荐「小接口」:
io.Reader只有一个Read方法。fmt.Stringer只有一个String方法。- 业务代码里,接口 1~3 个方法最常见。
「接口由消费者定义」是 Go 的名言——谁用谁在本地定义小接口,而不是由提供者定义一个大而全的接口。
5. D - 依赖倒置(Dependency Inversion)
依赖抽象,不依赖具体。Go 通过接口注入实现:
go
package main
import "fmt"
// 高层模块依赖 Logger 抽象,不依赖具体实现
type Logger interface {
Log(msg string)
}
type Service struct {
logger Logger
}
func NewService(l Logger) *Service {
return &Service{logger: l}
}
func (s *Service) Work() {
s.logger.Log("service working")
}
type ConsoleLogger struct{}
func (ConsoleLogger) Log(msg string) { fmt.Println(msg) }
func main() {
s := NewService(ConsoleLogger{})
s.Work()
}注意 NewService(l Logger):这就是依赖注入。Go 不需要 Spring 这种重型容器,构造函数注入就够了。
五、从 Java/C++ 到 Go 的思维转换
很多从 Java 转 Go 的同学会经历一个「水土不服」期。常见误区:
- 什么都搞接口:Java 里接口是显式声明的,习惯给每个类配一个
IXxx。Go 里接口应该在消费者那里定义,而且越小越好。先写实现,等真的需要抽象时再提接口。 - 滥用 getter/setter:Java 习惯所有字段私有 + getter/setter。Go 的字段默认就能导出/不导出,简单数据直接用字段访问,只有需要校验、副作用时才用方法。
- 用嵌入模拟继承层级:嵌入不是继承,不要建 5 层深的嵌入链。
- panic 当异常用:Java 的
try/catch在 Go 里是if err != nil,panic 只用于不可恢复的错误。 - 过度抽象:Java 的设计模式书里动辄 5 个类才实现一个功能。Go 里通常 1 个结构体 + 2 个函数就够了。
下面这个例子对比 Java 风格和 Go 风格的「策略模式」:
go
package main
import "fmt"
// === Java 风格的过度设计 ===
type PaymentStrategy interface {
Pay(amount float64) error
}
type AlipayStrategy struct{}
func (AlipayStrategy) Pay(amount float64) error {
fmt.Printf("Alipay 支付 %.2f\n", amount)
return nil
}
type WechatStrategy struct{}
func (WechatStrategy) Pay(amount float64) error {
fmt.Printf("微信支付 %.2f\n", amount)
return nil
}
type PaymentContext struct {
strategy PaymentStrategy
}
func (p *PaymentContext) Pay(amount float64) {
p.strategy.Pay(amount)
}
// === Go 风格的简洁写法 ===
type PayFunc func(amount float64) error
func PayWith(alipay bool) PayFunc {
if alipay {
return func(a float64) error {
fmt.Printf("Alipay 支付 %.2f\n", a)
return nil
}
}
return func(a float64) error {
fmt.Printf("微信支付 %.2f\n", a)
return nil
}
}
func main() {
// Java 风格
ctx := &PaymentContext{strategy: AlipayStrategy{}}
ctx.Pay(100)
// Go 风格
pay := PayWith(false)
pay(200)
}两种都「对」,但第二种更符合 Go 的精神。当然,如果策略需要多个方法或复杂状态,还是用接口。
六、本系列的组织
本系列共 12 篇,按以下顺序展开:
- 设计模式与 Go 语言(本篇)
- 单例模式
- 工厂模式(简单工厂、工厂方法、抽象工厂、泛型工厂)
- 建造者模式(链式 Builder、Functional Options)
- 适配器与外观模式
- 装饰器与代理模式
- 观察者模式(channel 与回调)
- 策略模式(接口策略与函数策略)
- 模板方法与命令模式
- 职责链与状态模式
- 迭代器与组合模式
- Go 惯用模式与反模式
每篇都会包含:意图讲解、Java/C++ 对比、Go 实现、标准库实例、实战示例、小结。代码示例全部可独立运行。
七、小结
- GoF 23 种模式分为创建型(5)、结构型(7)、行为型(11)三类,诞生于面向对象 + 继承时代。
- Go 没有继承、没有构造器、接口隐式实现、函数是一等公民、1.18+ 支持泛型——这些特性让设计模式的形态与 Java/C++ 显著不同。
- 不是所有模式在 Go 里都有价值,抽象工厂、享元、解释器、访问者等要慎用。
- SOLID 原则在 Go 中依然有效,但表达方式更轻量:小接口、构造函数注入、消费者定义接口。
- 从 Java 转 Go,最大的转变是「少造抽象,多用组合」。
下一篇我们正式进入具体模式,从最简单也最常被滥用的单例模式开始。