Introduction to Design Patterns and the Gang of Four Canon
如果你曾经写过一段代码,三天后回头看完全不知道自己在写什么,或者为了加一个小功能不得不复制粘贴三次几乎一样的代码块、最后改得全站bug,你其实已经触碰到了软件设计领域最普遍的痛点:当代码从几十行的练习脚本变成几百上千行的实际项目时,如何让它既容易修改、又不容易出问题? 这个问题困扰了软件开发行业几十年,而1994年出版的《设计模式:可复用面向对象软件的基础》一书,以及书中总结的23个经典设计模式,就是给这个问题给出的经过无数次项目验证的通用答案。
什么是设计模式?它和你写的代码有什么关系?
很多人第一次听到“设计模式”这个词,会下意识觉得它是某种高大上的、只有大厂工程师才会用的“屠龙之术”,和平时写小游戏、写Discord机器人、做网页的自己没关系。但事实上,设计模式的本质非常简单:它是针对特定场景下反复出现的软件设计问题,总结出来的可复用、经过验证的解决方案,核心是一套设计思路,而不是固定不变的代码模板。
> [!TIP] 侧边栏:模式不是软件领域独有的概念 > 你肯定见过“模式”出现在其他领域:建筑师克里斯托弗·亚历山大在1977年出版的《建筑模式语言》里,首先提出了“模式”的概念,用来总结建筑设计中反复出现的通用问题和解法;后来教育领域有“教学模式”,游戏设计领域有“游戏机制模式”,甚至你平时解题用的“题型模板”本质上也是一种模式。设计模式只是把“模式”这个通用思路引入到了软件工程领域而已。
举个你肯定遇到过的例子:假设你正在用Roblox Lua写一个开放世界小游戏,需要在地图上随机生成三种敌人:僵尸、骷髅、蜘蛛。如果你没有学过设计模式,很可能会写出这样的代码: ```lua -- 糟糕的写法:所有生成逻辑都写在一起 function spawnEnemy(enemyType, x, y) if enemyType == "zombie" then local enemy = Instance.new("Model") enemy.Name = "Zombie" -- 僵尸的专属逻辑:加腐烂debuff,移动速度慢 enemy:AddTag("Zombie") enemy.Speed = 5 enemy:AddDebuff("Rot", 10) elseif enemyType == "skeleton" then local enemy = Instance.new("Model") enemy.Name = "Skeleton" -- 骷髅的专属逻辑:远程攻击,移动速度快 enemy:AddTag("Skeleton") enemy.Speed = 8 enemy:AddAttack("Arrow", 15) elseif enemyType == "spider" then local enemy = Instance.new("Model") enemy.Name = "Spider" -- 蜘蛛的专属逻辑:可以爬墙,攻击带毒 enemy:AddTag("Spider") enemy.Speed = 7 enemy:CanClimb = true enemy:AddDebuff("Poison", 5) end enemy.Position = Vector3.new(x, y, 0) workspace.Enemies:AddChild(enemy) return enemy end
-- 生成敌人的时候调用 spawnEnemy("zombie", 10, 20) spawnEnemy("skeleton", 30, 40) ``` 这段代码现在看起来没问题,但过两周你打算加第四个敌人“蝙蝠”,就需要跑到这个函数里加一段`elseif enemyType == "bat"`的逻辑;如果还要改所有敌人的生成规则,比如加一个“难度越高敌人血量越高”的逻辑,你就得把每个`if`分支里的代码都改一遍,稍有不慎就会把僵尸的逻辑改到骷髅身上,最后玩家举报bug修到你头大。
而如果你学过工厂方法模式(属于创建型模式,我们会在第2章详细讲),你会把每种敌人的逻辑单独拆成一个类,写一个统一的工厂函数来生成敌人,加新敌人只需要加一个新的类,完全不用改原来的生成逻辑: ```lua -- 优化后的写法:用工厂方法模式 -- 每个敌人类单独写,互不影响 local Zombie = {} function Zombie:new() local obj = {} setmetatable(obj, self) self.__index = self obj.Name = "Zombie" obj.Speed = 5 obj:AddDebuff("Rot", 10) return obj end
local Skeleton = {} function Skeleton:new() local obj = {} setmetatable(obj, self) self.__index = self obj.Name = "Skeleton" obj.Speed = 8 obj:AddAttack("Arrow", 15) return obj end
-- 工厂函数:统一生成敌人,不用关心具体类型 function spawnEnemy(enemyClass, x, y) local enemy = enemyClass:new() enemy.Position = Vector3.new(x, y, 0) workspace.Enemies:AddChild(enemy) return enemy end
-- 生成敌人的时候调用,加新敌人完全不用改spawnEnemy函数 spawnEnemy(Zombie, 10, 20) spawnEnemy(Skeleton, 30, 40) -- 加蝙蝠的时候,只需要写一个Bat类,然后调用spawnEnemy(Bat, 50, 60)就行 ``` 你看,这就是设计模式的价值:它不会给你写具体的代码,而是给你一套组织代码的思路,让你写的代码更容易扩展、更容易修改、更容易和别人协作。上面的工厂方法模式,就是GoF总结的23个经典模式中的一个,它的核心思路是“把对象的创建逻辑和对象的业务逻辑分开”,这个思路不仅可以用在游戏开发里,还可以用在写Discord机器人、做网页、甚至写数据分析脚本里,是通用的。
GoF《设计模式》的出版背景:它为什么能成为软件工程的“圣经”?
要理解设计模式的价值,我们必须回到1994年,回到那个互联网还没普及、软件还是装在光盘里卖的年代,看看这本书是怎么诞生的。
1990年代初,面向对象编程(OOP)已经在C++、Smalltalk等语言里发展了十多年,但行业里对“怎么写好面向对象代码”还没有统一的方法论:很多开发者虽然用了类和对象,但写出来的代码和过程式代码没什么区别,只是把全局函数换成了类方法,该有的重复代码、耦合度高、改一个功能崩一片的问题一个都没少。当时软件行业正在经历从单机软件到分布式系统的转型,代码的复杂度越来越高,很多中大型项目因为代码太乱,最后要么烂尾,要么维护成本高到离谱,开发团队甚至比维护代码的时间还长。
当时贝尔实验室的工程师Erich Gamma、斯坦福大学的Richard Helm、伊利诺伊大学的Ralph Johnson,以及当时在斯坦福做博士后的John Vlissides,四个人都是面向对象编程社区的活跃研究者,经常在行业会议上交流。他们发现一个很有意思的现象:很多优秀的开发者在解决类似的设计问题时,会用到非常相似的思路,但这些思路都藏在开发者的个人经验里,没有形成系统的知识体系,新人要踩无数个坑才能学会。比如有人提出来“保证一个类只有一个实例能解决很多配置管理的问题”,有人提出来“把请求封装成对象能实现请求的回退和队列”,这些零散的思路没人系统整理。
于是四个人花了两年时间,整理出了当时面向对象社区里最常用的23个设计模式,每个模式都包含了“模式名称、问题场景、解决方案、实际案例、优缺点”五个部分,于1994年出版了《设计模式:可复用面向对象软件的基础》(*Design Patterns: Elements of Reusable Object-Oriented Software*)一书。这本书出版后立刻引起了轰动,首年就卖出了10万册,至今销量已经超过50万册,被翻译成了30多种语言,成了全球大学软件工程专业的必读教材,也是无数开发者职业生涯里的第一本设计类书籍。
因为四个作者的首字母分别是G(Gamma)、H(Helm)、J(Johnson)、V(Vlissides),后来大家就把他们合称为GoF(Gang of Four,四人帮)——当然这个“帮派”可和黑帮没关系,是编程圈对四位先驱的专属爱称。这本书也被简称为“GoF书”,书中总结的23个模式也被称为“GoF 23种经典设计模式”。
> [!TIP] 侧边栏:GoF趣闻 > 四个作者写这本书的初衷根本不是出版赚钱,只是想把他们在面向对象社区里分享的讲义整理成册,方便学生和学习者使用。当时他们找出版社的时候,很多出版社都觉得“这种讲设计思路的书没人会买”,最后是Addison-Wesley出版社愿意冒险出版,没想到一炮而红,成了出版社历史上最畅销的技术书籍之一。四个作者后来也都成了软件工程领域的泰斗:Erich Gamma是Eclipse IDE的核心开发者,Ralph Johnson是Eclipse基金会的主席,John Vlissides是UML(统一建模语言)的核心贡献者之一。
这本书的意义不仅仅是总结了23个模式,更重要的是它第一次把软件设计从“个人手艺”变成了“可学习、可传承的学科”:在之前,优秀的软件设计思路都是靠师傅带徒弟、靠开发者自己踩坑积累,新人要学很久才能掌握;而这本书把所有的隐性知识都变成了显性的、结构化的知识,就像之前厨师做菜都是靠手把手教,现在有了标准菜谱,普通人照着做就能做出合格的菜。这本书的出版,直接推动了软件工程从“编码时代”向“设计时代”的转型,至今仍然是所有想进阶的中级开发者的必读书目。
设计模式的三大分类:解决三类完全不同的设计问题
GoF在书中把23个模式按照“解决的问题类型”分成了三大类:创建型模式(Creational Patterns)、结构型模式(Structural Patterns)、行为型模式(Behavioral Patterns)。这个分类逻辑非常清晰,你可以把它理解成“造乐高积木的三个步骤”:先造出不同类型的积木块(创建型),再把积木块拼成更大的部件(结构型),最后让这些部件动起来、协同工作(行为型)。
我们可以用下面的流程图直观地看到三类
```mermaid graph TD A[软件设计常见问题] --> B[创建型问题:如何高效、灵活地创建对象?] A --> C[结构型问题:如何组合对象形成更复杂的结构?] A --> D[行为型问题:如何让对象协同工作、分配职责?] B --> B1[抽象工厂] B --> B2[建造者] B --> B3[工厂方法] B --> B4[原型] B --> B5[单例] C --> C1[适配器] C --> C2[桥接] C --> C3[组合] C --> C4[装饰器] C --> C5[外观] C --> C6[享元] C --> C7[代理] D --> D1[责任链] D --> D2[命令] D --> D3[解释器] D --> D4[迭代器] D --> D5[中介者] D --> D6[备忘录] D --> D7[观察者] D --> D8[状态] D --> D9[策略] D --> D10[模板方法] D --> D11[访问者] ```
三类设计问题的逻辑非常清晰:创建型模式解决的是“造东西”的问题——就像你玩Roblox的时候,要生成100个敌人,是每次写一遍生成逻辑,还是写一个统一的生成器,加新敌人只要加个配置就行?结构型模式解决的是“拼东西”的问题——就像你拼乐高,是把所有零件粘在一起,还是用接口、适配器把不同的部件拼成可拆卸、可替换的结构?行为型模式解决的是“让东西动起来、协同工作”的问题——就像你写Discord机器人,是写一堆if-else判断用户输入的命令,还是让每个命令自己监听对应的输入,收到通知再处理?
GoF选择这个分类逻辑的核心依据是面向对象编程的三大核心操作:创建对象、组合对象、对象交互,这个分类体系直到今天仍然是设计模式教学的主流框架,几乎所有设计类书籍都会沿用这个逻辑。
创建型模式:把“创建逻辑”和“业务逻辑”彻底分开
创建型模式一共5个,核心目标是解耦对象的创建过程和对象的使用过程,解决的是“创建对象时代码重复、扩展性差、耦合度高”的问题。你之前看到的敌人生成的例子,用的就是创建型模式里的工厂方法模式。
我们可以用你非常熟悉的Minecraft模组开发举个例子:假设你正在写一个模组,要添加三种新的矿物:铜矿、锡矿、铀矿。如果你不用创建型模式,你的矿物生成逻辑会写死在世界生成函数里: ```java // 糟糕的Minecraft模组写法:矿物生成逻辑耦合在世界生成代码里 public void generateOres(World world, Random random, int x, int y, int z) { // 铜矿生成逻辑 if (random.nextInt(100) < 15) { world.setBlock(x, y, z, ModBlocks.COPPER_ORE); // 铜矿的特殊逻辑:生成时有小概率变成富铜矿 if (random.nextInt(100) < 5) { world.setBlock(x, y, z, ModBlocks.RICH_COPPER_ORE); } } // 锡矿生成逻辑 else if (random.nextInt(100) < 10) { world.setBlock(x, y, z, ModBlocks.TIN_ORE); // 锡矿的特殊逻辑:生成时周围一定有煤炭 world.setBlock(x+1, y, z, Blocks.COAL_ORE); world.setBlock(x-1, y, z, Blocks.COAL_ORE); } // 铀矿生成逻辑 else if (random.nextInt(100) < 2) { world.setBlock(x, y, z, ModBlocks.URANIUM_ORE); // 铀矿的特殊逻辑:生成后周围生物会中毒 world.getEntitiesWithinAABB(EntityLiving.class, ...).forEach(e -> e.addPotionEffect(...)); } } ``` 这段代码现在看起来没问题,但过两周你要加第四种矿物“铅矿”,就必须跑到这个函数里加一段新的else if逻辑;如果要改所有矿物的生成概率,就得把每个分支里的概率代码都改一遍,非常容易出错。
而如果你用抽象工厂模式(创建型模式的另一个成员),你会把每种矿物的生成逻辑单独封装成一个工厂类,世界生成函数只需要调用工厂的生成方法,完全不用关心矿物的具体类型: ```java // 优化后的写法:用抽象工厂模式 // 抽象工厂接口:所有矿物工厂都要实现这个接口 public interface OreFactory { Block generateOre(Random random); }
// 铜矿工厂 public class CopperOreFactory implements OreFactory { @Override public Block generateOre(Random random) { Block ore = ModBlocks.COPPER_ORE; if (random.nextInt(100) < 5) { ore = ModBlocks.RICH_COPPER_ORE; } return ore; } }
// 铀矿工厂 public class UraniumOreFactory implements OreFactory { @Override public Block generateOre(Random random) { Block ore = ModBlocks.URANIUM_ORE; // 铀矿的特殊逻辑:周围生物中毒 world.getEntitiesWithinAABB(EntityLiving.class, ...).forEach(e -> e.addPotionEffect(...)); return ore; } }
// 世界生成函数:完全不用关心矿物的具体逻辑 public void generateOres(World world, Random random, int x, int y, int z, List<OreFactory> factories) { for (OreFactory factory : factories) { if (random.nextInt(100) < factory.getGenerationChance()) { world.setBlock(x, y, z, factory.generateOre(random)); } } }
// 加新矿