- 示例工程
【免费下载链接】awesome-low-level-design
Learn Low Level Design (LLD) and prepare for interviews using free resources.
本篇指南以仓库中 oop/golang/composition/README.md 为核心,系统讲解 Go 语言中组合(Composition)的概念、代码形态、与继承/聚合/关联的差异,并结合本仓库design-patterns/golang/下的真实设计模式实现,展示组合在工程中的落地方式。读完本文,你将掌握在 Go 中通过组合构建复杂对象、用接口实现运行时替换行为,以及如何判断何时该选择组合而非继承。
什么是组合(Composition)
组合是面向对象编程(OOP)中的核心概念之一,它允许用一个对象去构建另一个对象,从而促进代码复用、灵活性与可维护性。与继承建立的"is-a"(是一个)关系不同,组合表达的是"has-a"(拥有一个)关系。
在 Go 中,组合的实现方式是:一个 struct 将另一个 struct 的实例(或实例的接口)作为自己的字段。被包含的 struct 通常称为组件(component),包含方称为复合 struct(composite struct)。通过把简单的对象组合起来,可以构建出复杂的系统。
示例:一辆 Car 及其组件
考虑一辆Car,它由Engine、Wheel、Transmission等多个组件构成。与其从这些组件去"继承",不如让Car对象把它们作为字段包含进来:
package main import "fmt" type Engine struct { Horsepower int } func (e Engine) Start() { fmt.Printf("Engine started with %d HP.\n", e.Horsepower) } type Wheel struct { Type string } func (w Wheel) Rotate() { fmt.Printf("The %s wheel is rotating.\n", w.Type) } type Transmission struct { Type string } func (t Transmission) ShiftGear() { fmt.Printf("Transmission shifted: %s\n", t.Type) } type Car struct { Engine Engine Wheel Wheel Transmission Transmission } func NewCar() Car { return Car{ Engine: Engine{Horsepower: 150}, Wheel: Wheel{Type: "Alloy"}, Transmission: Transmission{Type: "Automatic"}, } } func (c Car) Drive() { c.Engine.Start() c.Wheel.Rotate() c.Transmission.ShiftGear() fmt.Println("Car is moving!") } func main() { car := NewCar() car.Drive() }运行输出:
Engine started with 150 HP. The Alloy wheel is rotating. Transmission shifted: Automatic Car is moving!这个示例体现了组合的三个要点:
- 字段承载:
Car通过字段持有Engine、Wheel、Transmission三个独立组件; - 构造封装:
NewCar()集中负责各组件初始状态的装配(150 马力、合金轮、自动变速箱),调用方无需关心组件细节; - 行为编排:
Drive()逐一调用组件方法,把多个独立能力编排为整体行为。
Engine、Wheel、Transmission各自独立,它们不知道Car的存在——这正是组合与继承的本质区别:组件可以独立存在、独立复用。
为什么优先组合而非继承(Favor Composition over Inheritance)
"优先组合而非继承"是一条被广泛认可的设计原则,其背后有四条关键理由。
1. 封装性与灵活性
组合允许在运行时通过替换组件来动态改变对象行为;而继承一旦建立,修改类层次结构往往会破坏现有代码。例如,把Car.Engine从汽油引擎换成电动引擎,只需替换字段值,无需改动Car的任何方法。
2. 更好的代码复用
组合促进可复用组件的诞生。Engine、Wheel、Transmission这些 struct 可以在多种车型(Car、Bike、Truck)中直接复用而无需修改。组件是自包含的单元,复用的粒度更小、耦合更弱。
3. 避免继承的陷阱
继承容易导致深层的类层次结构,维护困难;它强制建立严格的父子关系,对某些设计而言过于僵化。层数越深,修改底层父类带来的涟漪效应就越大,而组合把这种影响限制在组合方内部。
4. 支持基于接口的设计
组合可以与接口结合,实现强解耦。调用方只依赖接口,不依赖具体实现——这也是依赖倒置原则(DIP)在 Go 中的自然表达。
组合与接口:运行时替换行为的钥匙
仅靠字段组合是"静态"的——Car的引擎类型在编译期就确定了。要让行为可以在运行时替换,就需要把字段类型改为接口。原文档给出了PetrolEngine/DieselEngine两种引擎的实现:
package main import "fmt" type Engine interface { Start() } type PetrolEngine struct {} func (p PetrolEngine) Start() { fmt.Println("Petrol Engine started.") } type DieselEngine struct {} func (d DieselEngine) Start() { fmt.Println("Diesel Engine started.") } type Car struct { engine Engine } func (c Car) StartCar() { c.engine.Start() fmt.Println("Car is ready to go!") } func main() { petrolCar := Car{engine: PetrolEngine{}} petrolCar.StartCar() dieselCar := Car{engine: DieselEngine{}} dieselCar.StartCar() }运行输出:
Petrol Engine started. Car is ready to go! Diesel Engine started. Car is ready to go!这里的核心变化是:Car的字段类型从具体的Enginestruct 变成了Engine接口。于是:
- 同一个
Car结构可以容纳任意实现了Start()的类型,未来新增ElectricEngine、HybridEngine无需改动Car; Car与具体引擎实现彻底解耦,它只依赖Engine接口约定的行为契约;- 这种"面向接口组合"正是依赖注入的雏形,让单元测试时可以用假引擎替换真实引擎。
注意示例中Car构造使用字面量Car{engine: PetrolEngine{}}而非构造函数,这是因为engine字段是小写(包内私有),无法在包外直接注入;在生产代码中,通常通过构造函数或 setter 完成注入。
组合 vs 聚合 vs 关联:三种关系如何区分
组合容易与聚合(Aggregation)、关联(Association)混淆。三者都表达对象之间的联系,但生命周期归属与独立性截然不同。仓库中 oop/golang/aggregation/README.md 与 oop/golang/association/README.md 给出了完整的对比:
| 特性 | 关联(Association) | 聚合(Aggregation) | 组合(Composition) |
|---|---|---|---|
| 关系语义 | Knows-a(认识) | Has-a(拥有) | Has-a(拥有) |
| 对象独立性 | 双方完全独立 | 被包含对象可以独立存在 | 被包含对象不能脱离容器存在 |
| 生命周期 | 各自独立存活 | 被包含对象比容器活得久 | 被包含对象随容器销毁 |
| 典型示例 | 教师和学生 | 大学和教授 | 汽车和引擎 |
三个概念的关键判别点:
- 关联最松散:
Teacher认识Student,两者生命周期完全无关,语义只是"知道彼此存在",可以是单向或双向; - 聚合是弱拥有:
University拥有Professors,但教授辞职后依然作为独立个体存在,容器销毁不影响组件。实现上通常用指针/切片引用(如[]*Professor); - 组合是强拥有:
Car的Engine无法脱离汽车独立发挥意义,组件的生命周期由容器管理。实现上用值字段(如Engine而非*Engine)。
在 Go 中,这种差异可以直接从字段形态读出:值类型字段倾向组合,指针/切片引用倾向聚合。Go 没有显式的"删除对象"语义(由 GC 决定回收),但设计上区分这两种关系,能帮助你在建模时想清楚"谁拥有谁的生命周期"。
仓库实战:设计模式源码中的组合
本仓库的 design-patterns/golang/ 目录中,多个经典设计模式都以组合为骨架实现。以下实例可以直接验证"组合是 Go 工程的默认组织方式"。
装饰器模式:组合的层层包装
装饰器模式是组合的典型应用——用一个装饰器包住另一个对象,逐层叠加行为。看 beverage_decorator.go:
// BeverageDecorator is the base decorator that wraps a Beverage type BeverageDecorator struct { beverage Beverage }而具体的装饰器通过内嵌方式复用基类组合能力,milk_decorator.go:
type MilkDecorator struct { *BeverageDecorator }MilkDecorator内嵌*BeverageDecorator,本质是"组合 + 方法提升",再覆写GetDescription()与Cost()增加" with Milk"和+0.5费用。配合 simple_coffee.go(基础咖啡成本 1.0),可以任意叠加牛奶、糖等装饰器而不改动既有类——这正是组合带来的"开闭原则"优势。
策略模式:组合 + 接口实现运行时行为切换
shopping_cart.go 中,购物车通过接口字段持有支付策略:
type ShoppingCart struct { amount float64 strategy PaymentStrategy }SetPaymentStrategy(strategy PaymentStrategy)在运行时替换策略;Checkout()调用strategy.Pay(c.amount)。具体的 credit_card_payment.go、PayPalPayment各自实现Pay。这与原文档Car+Engine接口的写法完全同构——策略模式就是"组合 + 接口"的封装形态。
责任链模式:对象组合成链
base_handler.go 用接口字段把处理器串成链:
type BaseHandler struct { next RequestHandler }每个处理器组合"下一个处理器",HandleNext把请求向后传递。链条的"后继关系"由对象组合而非继承表达,新增一种校验、鉴权或限流处理器只需实现接口并挂到链上。
桥接模式:抽象与实现解耦
shape.go 中BaseShape组合了渲染器接口:
type BaseShape struct { renderer Renderer }形状(Circle/Rectangle)与渲染方式(Raster/Vector)各自独立变化,通过组合桥接起来——这正是"组合优于继承"用于解决多维度变化问题的经典场景。
组合模式:递归的树形组合
composite/folder.go 中Folder持有children []FileSystemItem(文件与文件夹的统一接口),实现文件系统的树形递归结构。这里的"组合"既是模式名,也字面意义上用组合实现了任意深度的嵌套。
这些实例共同印证:在 Go 中,组合不是一种可选项,而是语言层面的默认工程手段——因为 Go 本身不支持类继承(只有 struct 内嵌与接口),"通过字段/接口组装对象"是唯一且自然的组织方式。仓库 oop/golang/ 目录下还提供了 abstraction、encapsulation、inheritance、polymorphism、aggregation、association 等配套主题的文档,可以对照阅读,构建完整的 Go OOP 知识图谱。
何时使用组合
综合原文档,以下场景优先选择组合:
- 构建由多个组件构成的复杂对象时(如
Car由引擎、车轮、变速箱组装); - 希望在不引入僵化继承层次的前提下实现代码复用时;
- 需要在运行时动态切换不同行为时(如车辆使用不同类型的引擎,购物车切换支付策略);
- 遵循"favor composition over inheritance"(优先组合而非继承)原则时。
反向信号同样清晰:如果你只需要复用某类型的全部行为、且不存在"多维度独立变化",可以考虑 struct 内嵌(Go 对继承的近似替代);如果组件需要脱离容器独立存活(教授离职后依然存在),则应选择聚合而非组合——具体可对照 oop/golang/aggregation/README.md 中的判别标准。
小结
组合是 Go 中构建可复用、可替换系统的基本功。通过"值字段组合"实现静态装配,通过"接口字段组合"实现运行时多态,再配合聚合与关联的边界辨析,你就能在建模时准确回答"谁拥有谁、谁的生命周期归谁"。仓库中 oop/golang/composition/README.md 提供了基础定义与可运行示例,而 design-patterns/golang/ 下五个模式的源码,则展示了组合在装饰器、策略、责任链、桥接、组合模式中的真实落地——建议动手运行这些示例,把"组合优于继承"从原则变成肌肉记忆。
- 示例工程
【免费下载链接】awesome-low-level-design
Learn Low Level Design (LLD) and prepare for interviews using free resources.
相关推荐
StarRocks json_contains 函数完全指南:JSON 包含关系判定与源码级实现解析
StarRocks json_contains 函数完全指南:JSON 包含关系判定与源码级实现解析 json_contains 是 StarRocks 提供的
示例工程什么是PI-Desktop的Plan模式:先审批实现计划再执行的AI编程完整指南
什么是PI Desktop的Plan模式:先审批实现计划再执行的AI编程完整指南 PI Desktop 是一款本地优先的 AI 编程智能体桌面应用(Electr
示例工程Go 聚合关系(Aggregation)完全指南:以 awesome-low-level-design 仓库为例掌握 "has-a" 组合设计
Go 聚合关系(Aggregation)完全指南:以 awesome low level design 仓库为例掌握 "has a" 组合设计 聚合(Aggre
示例工程
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考