Skip to content

设计模式与 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,也没有 virtualprotectedabstract 这些关键字。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 的同学会经历一个「水土不服」期。常见误区:

  1. 什么都搞接口:Java 里接口是显式声明的,习惯给每个类配一个 IXxx。Go 里接口应该在消费者那里定义,而且越小越好。先写实现,等真的需要抽象时再提接口。
  2. 滥用 getter/setter:Java 习惯所有字段私有 + getter/setter。Go 的字段默认就能导出/不导出,简单数据直接用字段访问,只有需要校验、副作用时才用方法。
  3. 用嵌入模拟继承层级:嵌入不是继承,不要建 5 层深的嵌入链。
  4. panic 当异常用:Java 的 try/catch 在 Go 里是 if err != nil,panic 只用于不可恢复的错误。
  5. 过度抽象: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 篇,按以下顺序展开:

  1. 设计模式与 Go 语言(本篇)
  2. 单例模式
  3. 工厂模式(简单工厂、工厂方法、抽象工厂、泛型工厂)
  4. 建造者模式(链式 Builder、Functional Options)
  5. 适配器与外观模式
  6. 装饰器与代理模式
  7. 观察者模式(channel 与回调)
  8. 策略模式(接口策略与函数策略)
  9. 模板方法与命令模式
  10. 职责链与状态模式
  11. 迭代器与组合模式
  12. Go 惯用模式与反模式

每篇都会包含:意图讲解、Java/C++ 对比、Go 实现、标准库实例、实战示例、小结。代码示例全部可独立运行。

七、小结

  • GoF 23 种模式分为创建型(5)、结构型(7)、行为型(11)三类,诞生于面向对象 + 继承时代。
  • Go 没有继承、没有构造器、接口隐式实现、函数是一等公民、1.18+ 支持泛型——这些特性让设计模式的形态与 Java/C++ 显著不同。
  • 不是所有模式在 Go 里都有价值,抽象工厂、享元、解释器、访问者等要慎用。
  • SOLID 原则在 Go 中依然有效,但表达方式更轻量:小接口、构造函数注入、消费者定义接口。
  • 从 Java 转 Go,最大的转变是「少造抽象,多用组合」。

下一篇我们正式进入具体模式,从最简单也最常被滥用的单例模式开始。