基于DDD知识揭示Go中结构指针两个优点


当谈到 Go中结构体值时,人们纠结:通过指针传递这些值还是只是复制值?

  • 由于指针会带来一些开销,因此人们自然的反应是不惜一切代价避免使用它们,并尽可能传递结构值复制副本。
  • 而我通常选择使用指针结构的两个原因是标识性和一致性。

对于我的项目,我宁愿使用 语义:

  • 我会查看结构类型应该表示什么,并预先决定是否要为其使用指针或值复制语义。
  • 一旦我做出决定,这个决定几乎总是在项目期间持续存在。 

为了在指针和值复制语义之间划清界限,我选择的标准部分受到领域驱动设计 (DDD) 的启发,部分受到Ardan Labs 这篇文章的启发。

这一切都与类型的标识身份有关。

标识身份Identity
DDD 将实体与值对象区分开来。解释两者之间差异的最简单方法是问自己这个问题:

  • 如果同一结构类型的两个值的所有属性都相等,那么这两个值是否相同?

同样的问题,稍微修改一下:

  • 如果我更改其中一个属性的值,是否会破坏结构值的标识?

最后但并非最不重要的一点是:

  • 该结构类型的零值有用吗?

如果所有问题的答案都是 "是",那么您就拥有了一个经典值对象,可以自由使用值复制语义。
为什么呢?
因为值对象是不可变的。

如果你改变了其中一个属性的值,那么你实质上是创建了一个全新的值。

值对象
Go 标准库中最好的例子就是 time.Time,time.Time 结构表示纳秒精度的瞬间时间。

从本质上讲,time.Time 值是对两个 uint64 的封装

  • 如果改变这两个值中的任何一个,我们就会得到一个完全不同的 time.Time 值。
  • 换句话说,它的特性是其属性相等的乘积。

此外,一个空的 time.Time 值就是它的逻辑零值:
var t, tt time.Time
fmt.Println(t == tt) // true
fmt.Println(t.IsZero())
// true

能否使用指针语义来使用 time.Time?
当然可以
指针只是对内存中已知值的引用。

你可以寻址任何值,无论其类型如何。但在这种情况下,你不会得到任何实际的好处,这就是为什么大多数时候,时间实例都是作为值副本传递的原因。

有人可能会反对使用 *time.Time,认为在值可能根本不存在的情况下(可选性)使用 *time.Time可能会很有用。

一个很好的例子是:
一个带有 DeletedAt 属性的结构体,只有在数据被删除(如从数据库中删除)时,该属性才会被设置:

type DBRow {
    // Other attrs ..
    DeletedAt *time.Time
}

我认为可以通过引入一个单独的结构类型来解决这个问题,在这个结构类型中,DeletedAt 总是有效的:

type DBRow {
    // Other attrs ..
}

type DeletedDBRow {
    DBRow
// 很好地引入了该行的所有属性
    DeletedAt time.Time
// 我们假设该值始终有效
}

实体
很自然,这就引出了第二种类型,即实体。以上述三个问题为标准,我们可以将任何结构类型定义为实体,只要它没有合理的零值,或者其任何属性的改变都不意味着一个新值。

对于 time.Time,无论我们有多少个相同的 time.Time 副本,时间实例(如 2024 年 1 月 1 日)始终是相同的。时间实例的身份是从其内部状态推断出来的。

对于更复杂的类型,我们可能不能这么说,因为即使它们可能具有相同的内部状态,但它们是不同的副本,会随着时间的推移发生不同的演变,这将带来不希望看到的后果。

想想文件引用或数据库连接。如果有多个副本引用相同的资源,就有可能从两个不同的位置读取或更改文件,或者无法正确关闭数据库事务(更有甚者,会耗尽可用数据库连接的最大值)。

如果我们无法合理地确定一个对象的身份,那么我们就需要一种指向它的引用形式,即 "无论我们有多少份该引用的副本,它们都指向同一个源"。指针完美地扮演了这一角色。

让我们用一个不太明显的自定义类型示例来说明这一点。假设我们正在构建一个项目管理系统,Project 和 Person 是其中的两个核心数据模型。一个项目必须有一个标题和一个相关的 Person 类型的所有者。

在处理 Project 时,我们先使用值复制语义。马上就会出现几个有趣的问题:

  • 首先,项目的零值到底是什么--一个没有所有者、标题为空的项目是有用的零值吗?
  • 一个有所有者但没有标题的项目又如何呢?
  • 或者,反过来呢?也许,通用的零值是更好的选择?

即使我们在这一点上存在分歧,但我们可能最终会出现由同一个人拥有多个标题相同的项目,这种情况又有多常见呢?这肯定是合法的,也是可能的。如果我们遵循价值复制语义,那么如果项目 A 和项目 B 拥有相同的所有者和相同的标题,我们如何确定它们是不同的呢?

me := Person{Name: "John"}

a := Project{Title:
"ToDos", Owner: me}
b := Project{Title:
"ToDos", Owner: me}

fmt.Println(a == b)
// 真,这在逻辑上是错误的


有人会说,这就是 ID 的作用。好吧,那我们就给每个人都加上唯一的 ID,这样我们就可以根据这些人的不同来进行区分了:

me := Person{Name: "John"}

a := Project{ID: 1, Title:
"ToDos", Owner: me}
b := Project{ID: 2, Title:
"ToDos", Owner: me}

fmt.Println(a == b)
// false


但话又说回来,如果这些 ID 都是 0,或者有人忘记设置了呢?那么,这两个值又会相等,这在逻辑上是错误的。

但还有其他问题。如果我们现在将项目 A 的一个副本传递给函数,并在函数内部对其进行修改,那么我们现在就会有两个指向相同 ID 但标题不同的副本。

根据上述假设,识别条目身份的唯一合理方法就是对该类型使用指针语义

同样的道理也适用于 Person 类型:

me := Person{Name: "John"}

a := Project{Title:
"ToDos", Owner: &me}
b := Project{Title:
"ToDos", Owner: &me}

fmt.Println(&a == &b)
// false, as it should be


总结
指针的用途远比复制数值要广泛得多

事实上,在大多数情况下,我并不惧怕使用指针结构类型,在真正有意义的情况下,我才会使用值复制,而且我可以保证,我不必为日后改变语义而付出代价。

指针并不坏,它们不会咬人,也不会让代码有异味,而且它们确实是编程的正常组成部分。

拥有一个一致的代码库,在这个代码库中,每种类型都可以通过合理的推理进行引用,这远比针对可能(而且很可能)永远不会发生的情况进行优化要重要得多。