原文 Design Philosophy On Data And Semantics

楔子

这是一个由四部分组成的系列文章的终结篇,该系列文章将提供对Go中指针,栈,堆,逃逸分析和值/指针语法背后设计和机制的理解。这篇文章主要关注代码中应用值/指针语义的数据和设计哲学

四部分系列文章索引:

  1. Language Mechanics On Stacks And Pointers
  2. Language Mechanics On Escape Analysis
  3. Language Mechanics On Memory Profiling
  4. Design Philosophy On Data And Semantics

设计哲学

“值语义将值保持在栈上,它减少了垃圾回收(GC)压力。然而,值语义在任何给定值在存储,追踪和维护时,需要多个副本。指针语义将值放在堆上,它带来了GC的压力。然而,指针语义是有效的,因为仅需要存储,追踪和维护一个值。”- Bill Kennedy

如果您希望在整个软件中保持完整性和可读性,那么对于给定类型的数据,一致地使用值/指针语义是至关重要的。为什么?因为,如果在函数之间传递数据时更改数据的语义,就很难保持代码的清晰一致的心理模型。代码库和团队越大,代码库中隐藏的bug、数据竞争和副作用就越多。

我想从一组设计理念开始,这些理念将驱动选择一种语义而不是另一种语义的指导方针。

心智模型

”让我们想象一个项目最终会有一百万行代码或更多。这些项目在美国成功的概率现在非常低,远低于50%。这是值得商榷的。“ - Tom Love(Objective-C的发明者)

Tom还提到一盒复印纸能装10万行代码。花点时间让它沉下去。在那个盒中,你能维护多少代码的心理模型?

我相信,要求一个开发人员维护一个超过一令纸(ream令,约500张,约10k行代码)的心智模型已经是相当高的要求了。但是,我们假设每个开发者大约 10k 行代码,维护一个超过百万行代码的代码库,则需要100个开发者的团队。这100个人需要合作,分组,追踪并经常沟通。现在看下你当前的1~10个开发者的团队。你在小范围内做得如何?每个开发人员有10k行代码,您的代码库的大小是否与团队的大小一致?

调试(Debugging)

“最难的 bug 是那些你的心智模型是错误的情况,所以你根本看不到问题所在” - Brian Kernighan

我不相信使用调试器,除非你已经失去了你的心理模型的代码,现在正在浪费精力试图理解这个问题。调试器被滥用时是邪恶的,当调试器成为任何可观察到的bug的第一反应时,你就知道你在滥用它。

如果你在生产中遇到问题,你会去寻求什么?没错,日志。如果日志在开发过程中对您不起作用,那么当出现生产缺陷时,它们肯定对您不起作用。日志需要有一个代码库的心理模型,这样您就可以通过阅读代码来发现bug。

可读性

”C 是我看到过的在力量和表现力之意平衡得最好的。你可以通过编程相当直接地做任何你想做的事情,同时你在机器上会发生什么有一个非常好的心智模型;你可以相当好地预测它运行的速度,你了解发生了什么….“ - Brian Kernighan

我相信 Brian 的这句话同样适用于Go。维持这种“心智模式”就是一切。它推动了完整性、可读性和简单性。这些都是编写良好的软件的基石,使其能够得到维护和持续使用。为给定类型的数据编写保持一致使用值/指针语义的代码是实现这一点的重要方法。

面向数据的设计

”如果您不了解数据,那么您就不了解问题。 这是因为所有问题都是唯一的,并且特定于您正在使用的数据。 当数据改变时,您的问题也在改变。 当您的问题在变化时,算法(数据转换)也需要随之变化。“ - Bill Kennedy

考虑一下,你工作的每个问题是数据转移的问题。你写的每个函数和运行的每个程序会使用一些输入数据,然后产生一些输出数据。你软件的心智模型是,从这个看法,是理解这些数据转移(即,他们在代码库中是如何组织和应用的)。”少即是多“的观点对于以更少的层,更少的陈述,概括,更少的复杂性和更少的努力来解决问题至关重要。 这使您和您的团队的工作变得更轻松,但也使硬件更容易执行这些数据

类型(就是生命)

“完整性意味着每一个分配,每一次内存读取和每一次内存写入都是准确,一致和高效的。 类型系统对于确保我们具有这种微观的完整性至关重要。” -William Kennedy

如果数据驱动您所做的一切,那么代表数据的类型就至关重要。 在我的世界中,“类型就是生命”,因为类型为编译器提供了确保数据完整性的能力。 类型还驱动并指示语义规则代码必须尊重其操作的数据。 这是正确使用值/指针语义的地方:从类型开始。

数据(拥有能力)

“在可行或合理的情况下,使一条数据具有某种功能的方法才有效。” -William Kennedy

值/指针语义的想法并没有打动Go开发人员,直到他们必须确定方法的接收器类型。 我看到的问题很多,我应该使用值接收器还是指针接收器? 听到这个问题后,我就知道开发人员对这些语义没有很好的了解。

方法的目的是提供某种数据功能。 考虑一下。 一条数据可以执行某些操作。 我一直希望焦点集中在数据上,因为驱动程序功能的是数据。 数据驱动着您编写的算法,放置的封装以及可以实现的性能。

多态性

”多态性意味着你写一个确定的程序,它根据所操作的数据的表现不同的行为“ Tom Kurtz(BASIC的发明者)

Tom所说的上面引号的内容,我爱了爱了。函数的表现不同,取决于它所操作的数据。数据的行为使函数与它们可以接受和使用的具体数据类型解耦。 这是一条数据具有功能的一个核心原因。 正是这种想法是架构和设计可以适应变化的系统的基石。

原型优先

“除非开发人员对软件的用途有一个非常好的了解,否则该软件很可能会表现不佳。 如果开发人员对应用程序不太了解,那么获得尽可能多的用户输入和经验就至关重要。” - Brian Kernighan

我希望您始终首先专注于理解实现数据转换以解决问题所需的具体数据和算法。 采取这种原型优先方法,并编写也可以在生产环境中部署的具体实现(如果这样做合理可行)。 在具体的实现可行之后,一旦您了解了什么可行和不可行,则应集中精力进行重构,以通过赋予数据功能将实现与具体数据解耦。

语义指导方针

您必须在声明特定数据类型时决定将其用于哪种语义,值或指针。接受或返回该类型数据的API必须遵守为该类型选择的语义。不允许API规定或更改语义。他们必须知道用于数据的语义,并遵守该语义。这至少,部分是,如何在大型代码库中实现一致性的。

以下是基本准则:

  • 在声明类型时,必须确定所使用的语义。
  • 函数和方法必须尊重给定类型的语义选择。
  • 避免让方法接收器使用与给定类型相对应的语义。
  • 避免使用与给定类型对应的语义不同的函数来接收/返回数据。
  • 避免更改给定类型的语义。

这些准则有一些例外,其中最大的例外是编组。Unmarshaling封送总是需要使用指针语义。Marshaling和Unmarhaling似乎始终是该规则的例外。

对于给定的类型,您如何选择一种语义而不是另一种?这些准则将帮助您回答问题。下面,我们将在某些情况下应用准则:

内置类型

Go的内置类型的代表是数字,文本和布尔数据。这些类型应该使用值语义进行处理。不要使用指针来共享这些类型的值,除非你有一个非常好的原因。

作为例子,看下这些来自 strings 包的函数声明

Listing 1

1
2
3
func Replace(s, old, new string, n int) string
func LastIndex(s, sep string) int
func ContainsRune(s string, r rune) bool

所有这些函数使用在设计API时使用值语义

引用类型

在语言中,引用类型的代表是 slice, map, interface, function 和 channel 类型。这些类型应该使用值语义,因为他们被设置为存在于栈上来最小化GC的压力。它们允许每个函数拥有自己的值副本,而不是每个函数调用引起潜在的分配。这是可能的,因为这些值包含一个指针,以在调用间共享底层的数据结构。

除非您有充分的理由,否则不要使用指针共享这些类型的值。 将调用堆栈中的 slice 或 map 值共享到 Unmarshal 函数中可能是一个例外。 例如,查看 net 包中声明的这两种类型。

Listing 2

1
2
type IP []byte
type IPMask []byte

IPIPMask 类型都基于一个字节切片。这意味着他们都是引用类型,他们应该遵循值语义规则。这儿有一个名为 Mask 的方法,它被声明为 IP 类型,接收的 IPMask 的值。

Listing 3

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
func (ip IP) Mask(mask IPMask) IP {
    if len(mask) == IPv6len && len(ip) == IPv4len && allFF(mask[:12]) {
        mask = mask[12:]
    }
    if len(mask) == IPv4len && len(ip) == IPv6len && bytesEqual(ip[:12], v4InV6Prefix) {
        ip = ip[12:]
    }
    n := len(ip)
    if n != len(mask) {
        return nil
    }
    out := make(IP, n)
    for i := 0; i < n; i++ {
        out[i] = ip[i] & mask[i]
    }
    return out
}

注意这个方法是个修改的操作,它使用一个值语义API的风格。它使用一个 IP 值作为接收者并且依赖入参 IPMask 值,创建新的 IP 值并返回它的副本给调用者。该方法尊重您对引用类型使用值语义的事实。

这与内置函数 append 一样。

Listing 4

1
2
var data []string
data = append(data, "string")

函数 append 为些改变操作使用值语义。你传递一个切片值给 append ,然后修改它并返回一个新的切片值。

unmarshaling 仍然是特殊的,它需要指针语义。

Listing 5

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
func (ip *IP) UnmarshalText(text []byte) error {
  	if len(text) == 0 {
  		*ip = nil
  		return nil
  	}
  	s := string(text)
  	x := ParseIP(s)
  	if x == nil {
  		return &ParseError{Type: "IP address", Text: s}
  	}
  	*ip = x
  	return nil
  }

方法 UnmarshalText 实现了 encoding.TextUnmarshaler 接口。如果不再使用指针语义,它无法工作。但是这也是正确的,因为共享会下通常是安全的。unmarshaling 的外面,如果将指针语义用于引用类型,则应引发一个标志。

用户定义类型

这是你最需要做决定的地方。在你声明一个类型时,必须决定使用哪种语义。

如果我给你下面的类型并告诉你为 time 包写一个API?

Listing 6

1
2
3
4
5
type Time struct {
    sec  int64
    nsec int32
    loc  *Location
}

将使用哪种语义?

Time 包的工厂函数 Now 查看该类型的实现。

Listing 7

1
2
3
4
func Now() Time {
  	sec, nsec := now()
  	return Time{sec + unixToInternal, nsec, Local}
  }

对于类型来说,工厂函数是最重要的函数之一,因为它告诉你执行哪种语义。函数 Now 清楚是说明使用了值语义。该函数创建一个 Time 类型的值,返回该值的副本给调用者。共享 Time 值没有必要,它也不需要放到堆上。

同时,看下改变操作的 Add 方法。

Listing 8

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
func (t Time) Add(d Duration) Time {
  	t.sec += int64(d / 1e9)
  	nsec := t.nsec + int32(d%1e9)
  	if nsec >= 1e9 {
  		t.sec++
  		nsec -= 1e9
  	} else if nsec < 0 {
  		t.sec--
  		nsec += 1e9
  	}
  	t.nsec = nsec
  	return t
  }

你可以再次看到 Add 方法遵循了为该类型选择的语义。Add 方法使用值接收者操作它自己的 Time 值的副本,在该副本中,在进行调用时,使用 Time 值的副本。然后它改变了自己的副本并返回一个新的 Time 值的副给调用者。

这里有一个接收 Time 值的函数:

Listing 9

1
func div(t Time, d Duration) (qmod2 int, r Duration) {

再次地,使用值语义接收一个Time 类型的值。对于 Time API,唯一使用指针语义是那些与 Unmarshal 相关的函数:

Listing 10

1
2
3
4
func (t *Time) UnmarshalBinary(data []byte) error {
func (t *Time) GobDecode(data []byte) error {
func (t *Time) UnmarshalJSON(data []byte) error {
func (t *Time) UnmarshalText(data []byte) error {

在大多数情况下,使用值语义的能力是有限的。在函数间复制数据是不正确或不合理的。数据更改需要限制为一个值并共享。这是指针语言需要被使用的时候。如果你不能100%确保使用拷贝它是正确的或是合理的,那么使用指针语义。

看下os 包中 File 类型的工厂函数。

Listing 11

1
2
3
func Open(name string) (file *File, err error) {
    return OpenFile(name, O_RDONLY, 0)
}

函数 Open 返回一个 File 类型的指针。这意味着你应该使用指针语义并一直共享 File 值。将语义从指针修改为值对程序来说是毁灭性的。当函数共享值,你应该假设你不能复制该指针所指向的值。如果这么做,结果将是不确定的。

查看更多的API,您将看到指针语义的一致使用。

Listing 12

1
2
3
4
5
6
7
8
9
func (f *File) Chdir() error {
    if f == nil {
        return ErrInvalid
    }
    if e := syscall.Fchdir(f.fd); e != nil {
        return &PathError{"chdir", f.name, e}
    }
    return nil
}

即使 File 值不会修改,该 Chdir 方法也使用指针语义。方法必须尊重该类型的语义改变。

Listing 13

1
2
3
4
5
6
7
8
9
func epipecheck(file *File, e error) {
    if e == syscall.EPIPE {
        if atomic.AddInt32(&file.nepipe, 1) >= 10 {
            sigpipe()
        }
    } else {
        atomic.StoreInt32(&file.nepipe, 0)
    }
}

这里有个名为 epiipecheck 的函数,它使用指针接收 File 的值。再次地,注意为类型 File 的值使用指针语义的一致性。

结论

我一直在代码审查中寻找对值/指针语义的一致使用。 它可以帮助您使代码随着时间的推移保持一致和可预测。 它还允许每个人维护清晰,一致的代码思维模型。 随着代码库和团队规模的扩大,对值/指针语义的一致使用变得更加重要。

Go的惊人之处在于,指针语义和值语义之间的选择超出了接收者和函数参数的声明。 语言涵盖了从 for range 工作到接口语义,接口,函数值和 slice 的机制。 在以后的文章中,我将说明在语言的这些不同部分中如何显示值/指针语义。