26

在 Go 运行时中的 Strings

 5 years ago
source link: https://studygolang.com/articles/15472?amp%3Butm_medium=referral
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

动机(Movivation)

可以认为这是对 Rob Pike 的一篇关于 Go 字符串的 博客 的解释。以 Go 程序员的思维来写。

在博客文章中,他说没有什么证据可以证明 Go 字符串是以 slices 的方式实现。我猜这不是他的疏忽,但起码可以证明一点,这不是这篇博客的核心。再说,任何想要自己研究的都可以在使用 Go 代码的过程中探索。我就是这样做的。所以在这篇文章中,我尽力解释 Go 字符串实现与 slice 实现的关系。

这里不讨论字符串编码。这不是在 runtime 中我想要探索的东西。但我模仿 slice 的常见字符串操作,如字符串的长度(len("go")),串联("go" + "lang"),索引("golang"[0])和切片("golang"[0:2])。老实说,索引或者切片是在他们自己的内部范围中的操作,这意味着,他们在字符串上的可用性与字符串上的性质没有任何关系(或很少)。这可能并不完全正确,但请(先)接受它,因为事实上,这将通过底层所谓的基本类型返回,把我们带入到 Go 编译器中。再说了,这篇文章又不是我的誓言(我需要对所说的一切负责)。

字符串的本质(The Nature of Strings)

我还没有遇到一种编程语言,其中的字符串具有不同的底层内存结构:内存为连续的槽位。这意味着字符串的字节在内存中都是彼此相邻,两者之间没有其他的东西。也就是说,如果你在程序中使用了著名,12 个字节的字符串:"hello, world",并且有机会在内存中检查他们,你会发现他们位于同一行,每个字节(或字符)紧跟着另一个,他们之间没有空格或外来的字节。据我所知,Go 并没有偏离这个原则。

但这都是关于内存,物理上的东西。当我们站在这个角度上看,所有事情都是相同的,也就是说编程语言之间的差异不见了。因此,让我们返回运行时的上一层,在这里我们将看到不同语言如何处理它们的业务,我们也将在这里找到 Go 是如何实现它的字符串数据的细节。幸运的是,Go 运行时的一个重要部分是用 Go 编写的,感谢上帝的保佑(不然我得研究个半死),(已存在的)大量的讨论已经解释了明显和不那么明显的实现细节。在编写本文时,可以在 Github 上找到运行时字符串的实现。让我们一起走近它吧。

Go 字符串(Go's String)

在 Go 运行时,字符串是 stringStruct 类型:

type stringStruct struct {
	str unsafe.Pointer
	len int
}

它由一个 str、指向实际字节所在的内存块的指针和 len(字符串的长度)组成。由于字符串是不可变的,因此前面说的这些不会改变。

创建一个新的字符串(Creating a New String)

负责在运行时创建新字符串的函数为 rawstring。以下是它具体的实现(代码中的注释是我的):

func rawstring(size int) (s string, b []byte) {
	// 1. 分配符合字符串大小的内存块,并返回指针给它:
	p := mallocgc(uintptr(size), nil, false)

	// 2. 用刚返回的指针创建一个元数据(stringStruct),并指定该字符串的大小。
	stringStructOf(&s).str = p
	stringStructOf(&s).len = size

	// 3. 准备一个字节类型的切片,实际上会将字符串数据存储在这里。
	*(*slice)(unsafe.Pointer(&b)) = slice{p, size, size}
}

rawstring 返回一个字符串和一个字节切片,其中应该存储字符串的实际字节,并且这个字节切片将用于字符串的所有操作。我们可以安全地将它们称为数据([]byte)和元数据(stringStruct)。

但这并不是结束。也许这是唯一一次,你需要研究字符串里面实际的非零字节切片。事实上,对 rawstring 的注释已经提示调用者,它只使用一次字节切片(写入字符串的内容),然后就删除它。其余的时间,字符串结构本身就已经足够了。

知道了这一点,让我们看看如何实现一些常见的字符串操作。这对我们也是有意义的,这里将会介绍为什么不建议大家通过旧的串连来构建大字符串。

相同的字符串操作(Common String Operations)

长度(len("go"))

由于字符串是不可变的,也就是说字符串的长度将保持不变。其实,当我们存储字符串时,我们就已经知道这就是我们存储在 stringStruct 的 len 字段中的内容。因此,无论字符串的大小如何,对字符串长度的请求都花费相同的时间。在 Big-O 术语中,它是一个 O(1) 操作。

串联("go" + "lang")(Concatenation ("go" + "lang"))

这是一个简单的过程。Go 首先通过对要连接的所有字符串的长度求和来确定结果字符串的长度。然后它请求连续内存块的大小。(这里)有优化检查,更重要的是安全检查。安全检查确保结果字符串的长度不超过 Go 的最大整数值。

然后,该过程的下一步开始。各个字符串的字节将一个接一个地复制到新字符串中。也就是说,存储器中不同位置的字节被复制到新位置。这项工作不是很合理,应该尽可能避免。因此建议使用 strings.Builder,因为它最小化了内存复制。这将使我们的性能最接近可变字符串。

索引("golang"[0])(Indexing ("golang"[0]))

Go 的索引运算符为 [index],其中 index 是一个整数。在撰写本文时,它可用于数据,切片,字符串和一些指针。

什么是数组,切片和字符串具有的共同的基础类型?在物理内存方面,它是一个连续的内存块。在 Go 用语中,是一个数组。对于字符串,这是 rawstring 返回的字节切片,它是存储字符串内容的位置。也就是我们的索引。不言而喻,我上面提到的与索引操作符兼容的 “一些指针” 是具有数组底层类型的那些。

请注意,它在 map 上的语法相同,但行为不同。对于 map,键的类型确定了括号之间的值的类型。

切片("golang"[0:2])(Slicing ("golang"[0:2]))

slice 运算符与索引运算符具有相同的兼容性:操作数必须具有基础类型的数组。因此它适用于同一组类型:数组,切片,字符串和一些指针。

在字符串上有一个警告。完整切片运算符为[low:high:capacity]。一次性它允许您创建切片并设置底层数组的容量。但是请记住字符串是不可变的,因此永远不需要(分配)基础数组大于字符串内容所需的字节。也因此,字符串不存在切片运算符。

strings 和 strconv 包(The strings and strconv packages)

Go 提供了用于处理字符串的 string 和 strconv 包。我已经提到了用于构建大字符串的更高效的 Builder。它由 strings 包提供。还有其他的细节。他们一起为字符串转换,比较,搜索和替换等提供调整功能。它会在构建自己的字符串之前检查它们。

误解的根源(Source of Confusion)

cap(slice) vs cap(string)

内置函数 cap 返回切片底层数组的容量。在切片的整个生命周期中,底层阵列的容量可以不断变化。通常它会增长以容纳新元素。如果字符串是切片,为什么它不能返回 cap 查询?答案很简单:Go 字符串是不可变的。也就是说,它的大小永远不会增长或缩小,这反过来意味着如果实现了 cap,它将与 len 相同。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK