Golang中嵌入聚合与组合的详细解析

Golang中嵌入聚合与组合的详细解析 大家好,

今天在回顾一些用Go实现的设计模式并尝试将其转换为经典的UML类图时,我感到有些困惑。

我对这门语言的理解是,它没有继承,只有嵌入。一个嵌入的结构体/接口可以使用另一个“基础”结构体/接口的方法和字段。

有些人将嵌入称为聚合。因此我做了一些研究,这让我想起了很久以前在Java中遇到的关于聚合与组合的困惑。 在我看来,区分两者更务实的答案是:对于组合,如果对A的某个特定实例不再存在引用,那么它的B实例就会被销毁,而聚合则不是这样。

嵌入是通过引用结构体类型而不为字段分配名称来实现的。在内部,使用的是结构体类型的名称,如下所示:

type Car struct {
    Engine
}

如果我添加一个字段名,那么它就不再是嵌入。

所以,在我看来,它要么是组合,要么是聚合。 如果Engine结构体是通过值复制使用的,那么它就是组合,因为一旦Car实例被销毁,该实例也会被销毁。 如果Engine结构体是一个指针,那么这取决于Engine类型是否在其他地方被使用。

我的理解对吗,还是我遗漏了什么?

type Car struct {
    e Engine
}
type Car struct {
    e *Engine
}

提前感谢您的帮助。


更多关于Golang中嵌入聚合与组合的详细解析的实战教程也可以访问 https://www.itying.com/category-94-b0.html

7 回复

BigBoulard: 我的理解是对的吗,还是我遗漏了什么?

在我看来,你对聚合和组合的理解似乎是正确的。

更多关于Golang中嵌入聚合与组合的详细解析的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html


感谢 @skillian。我现在更明白了。

祝好。

你好 Jeff,

感谢你对此的反馈。我想补充一点,切片、映射以及通道都是“按指针传递”(或者说是“按引用传递”),但它们并非像在 C++ 中那样以经典方式创建。

我意识到我忘记了主要问题 😄 … 那么,从现在开始,对于具名字段已经很清楚了,但关于嵌入呢?它被认为是聚合还是组合?

谢谢

由于Go是一门按值传递的语言,你需要将对象的指针传递给函数,以便能够在函数中修改该对象。因此,我发现自己使用对象指针的频率远高于直接使用对象本身。

纯粹主义者会说应该通过通道传递对象本身,这样做也有其道理,尤其是当你打算编写支持并发的程序时。

但是,如果我不是为了并发而编程,我就直接使用对象的指针。

这可能是任何一种情况;在Go语言中,这种关系不是通过语法来传达的。嵌入一个结构体值(而不是指向结构体的指针)几乎肯定意味着组合。嵌入一个指针可能意味着关联、组合或聚合。Go语言使用文档和清晰的代码来传达结构体字段之间的关系。

type SinglyLinkedListNode struct {
    Value interface{}
    Next *SinglyLinkedListNode
}

type File struct {
    *Dir
    Name string
}

就我个人而言,我认为这两个结构体都相当清晰地传达了它们分别与 SinglyLinkedListNodeDir 的关系。在我假设的包中,我可以将 File 重构为更像这样的形式:

package paths

type elem struct {
    // Dir 拥有此文件系统元素。
    // 除根目录 Dir 外,所有元素都包含在一个目录中。
    *Dir

    // 此文件系统元素的名称。
    Name string
}

// File 是文件系统上的文件。
type File struct {
    elem
}

// Dir 是文件系统中包含文件和其他目录的目录。
type Dir struct {
    elem
}

在这里,我们使用了符合Go语言惯例的写法和注释,向开发者和用户传达哪些关系是关联、组合或聚合。

关于Go中嵌入与组合/聚合关系的专业解析

你的理解基本正确,但需要更精确地区分Go的嵌入机制与传统的UML关系。让我们通过代码示例来详细说明。

1. Go嵌入的本质

Go的嵌入(Embedding)是一种语法糖,它允许被嵌入类型的方法和字段提升到外层类型。但这不等于传统的继承或组合关系。

// 嵌入示例
type Engine struct {
    Power int
}

func (e Engine) Start() {
    fmt.Println("Engine started")
}

// 嵌入 - 方法提升
type Car struct {
    Engine  // 嵌入,不是字段名
}

func main() {
    c := Car{Engine{Power: 150}}
    c.Start()           // 可以直接调用Engine的方法
    fmt.Println(c.Power) // 可以直接访问Engine的字段
}

2. 值类型嵌入 vs 指针类型嵌入

值类型嵌入(类似组合)

type Car struct {
    Engine  // 值类型嵌入
}

func main() {
    c := Car{Engine{Power: 150}}
    // Engine是Car的一部分,生命周期与Car绑定
    // 当Car被销毁时,这个Engine实例也被销毁
    // 这符合组合的定义:部分不能独立于整体存在
}

指针类型嵌入(可能是聚合)

type Car struct {
    *Engine  // 指针类型嵌入
}

func main() {
    engine := &Engine{Power: 200}
    c1 := Car{Engine: engine}
    c2 := Car{Engine: engine}  // 多个Car共享同一个Engine
    
    // 这种情况下,Engine的生命周期不依赖于单个Car
    // 更符合聚合的定义:部分可以独立于整体存在
}

3. 明确字段名的情况

// 这不是嵌入,只是普通的字段
type Car struct {
    e Engine  // 有字段名,不是嵌入
}

func main() {
    c := Car{e: Engine{Power: 150}}
    // c.Start()  // 错误:不能直接调用
    c.e.Start()   // 必须通过字段名访问
}

4. 实际的内存布局证明

type Engine struct {
    Power int
}

type Car1 struct {
    Engine  // 嵌入
}

type Car2 struct {
    e Engine  // 字段
}

func main() {
    c1 := Car1{Engine{100}}
    c2 := Car2{e: Engine{100}}
    
    // 内存布局相同,但方法集不同
    fmt.Printf("Size of Car1: %d\n", unsafe.Sizeof(c1))  // 8
    fmt.Printf("Size of Car2: %d\n", unsafe.Sizeof(c2))  // 8
    
    // 方法集验证
    var _ interface{} = c1
    // Car1的方法集包含Engine的方法
    // Car2的方法集不包含Engine的方法
}

5. 接口嵌入的特殊情况

type Reader interface {
    Read([]byte) (int, error)
}

type Writer interface {
    Write([]byte) (int, error)
}

// 接口嵌入
type ReadWriter interface {
    Reader
    Writer
}

// 这纯粹是接口组合,不涉及生命周期问题

关键区别总结:

  1. 语法层面:嵌入使用类型名而不指定字段名
  2. 方法集:嵌入会提升方法,有字段名则不会
  3. 生命周期
    • 值类型嵌入:生命周期绑定,类似组合
    • 指针类型嵌入:生命周期可能独立,类似聚合
  4. UML映射:Go没有直接的UML对应关系,需要根据具体使用场景判断

你的理解方向正确,但需要记住:Go的嵌入主要是语法特性,而组合/聚合是设计概念。在实际设计中,应该根据业务逻辑和生命周期需求来选择使用值类型还是指针类型,而不是单纯依赖语法特性。

回到顶部