和很多其它编程语言一样,字符串类型是Go中的一种重要类型。本文将列举出关于字符串的各种事实。
type _string struct {
elements *byte // 引用着底层的字节
len int // 字符串中的字节数
}
从这个声明来看,我们可以将一个字符串的内部定义看作为一个字节序列。
事实上,我们确实可以把一个字符串看作是一个元素类型为byte
的(且元素不可修改的)切片。
注意,前面的文章已经提到过多次,byte
是内置类型uint8
的一个别名。
""
或者``
来表示。
+
和+=
来衔接字符串。
==
和!=
比较运算符来比较。
并且和整数/浮点数一样,同一个字符串类型的值也可以用>
、<
、>=
和<=
比较运算符来比较。
当比较两个字符串值的时候,它们的底层字节将逐一进行比较。如果一个字符串是另一个字符串的前缀,并且另一个字符串较长,则另一个字符串为两者中的较大者。
package main
import "fmt"
func main() {
const World = "world"
var hello = "hello"
// 衔接字符串。
var helloWorld = hello + " " + World
helloWorld += "!"
fmt.Println(helloWorld) // hello world!
// 比较字符串。
fmt.Println(hello == "hello") // true
fmt.Println(hello > helloWorld) // false
}
更多关于字符串类型和值的事实:
strings
标准库提供的函数来进行各种字符串操作。
len
来获取一个字符串值的长度(此字符串中存储的字节数)。
aString[i]
来获取aString
中的第i
个字节。
表达式aString[i]
是不可寻址的。换句话说,aString[i]
不可被修改。
aString[start:end]
来获取aString
的一个子字符串。
这里,start
和end
均为aString
中存储的字节的下标。
aString[start:end]
的估值结果也将和基础字符串aString
共享一部分底层字节。
package main
import (
"fmt"
"strings"
)
func main() {
var helloWorld = "hello world!"
var hello = helloWorld[:5] // 取子字符串
// 104是英文字符h的ASCII(和Unicode)码。
fmt.Println(hello[0]) // 104
fmt.Printf("%T \n", hello[0]) // uint8
// hello[0]是不可寻址和不可修改的,所以下面
// 两行编译不通过。
/*
hello[0] = 'H' // error
fmt.Println(&hello[0]) // error
*/
// 下一条语句将打印出:5 12 true
fmt.Println(len(hello), len(helloWorld),
strings.HasPrefix(helloWorld, hello))
}
注意:如果在
aString[i]
和aString[start:end]
中,aString
和各个下标均为常量,则编译器将在编译时刻验证这些下标的合法性,但是这样的元素访问和子切片表达式的估值结果总是非常量(这是Go语言设计之初的一个失误,但因为兼容性的原因导致难以弥补)。比如下面这个程序将打引出4 0
。
package main
import "fmt"
const s = "Go101.org" // len(s) == 9
// len(s)是一个常量表达式,但len(s[:])却不是。
var a byte = 1 << len(s) / 128
var b byte = 1 << len(s[:]) / 128
func main() {
fmt.Println(a, b) // 4 0
}
a
和b
两个变量估值不同的具体原因请阅读移位操作类型推断规则和哪些函数调用在编译时刻被估值。
Unicode标准为全球各种人类语言中的每个字符制定了一个独一无二的值。 但Unicode标准中的基本单位不是字符,而是码点(code point)。大多数的码点实际上就对应着一个字符。 但也有少数一些字符是由多个码点组成的。
码点值在Go中用rune值来表示。
内置rune
类型为内置int32
类型的一个别名。
在具体应用中,码点值的编码方式有很多,比如UTF-8编码和UTF-16编码等。 目前最流行编码方式为UTF-8编码。在Go中,所有的字符串常量都被视为是UTF-8编码的。 在编译时刻,非法UTF-8编码的字符串常量将导致编译失败。 在运行时刻,Go运行时无法阻止一个字符串是非法UTF-8编码的。
在UTF-8编码中,一个码点值可能由1到4个字节组成。 比如,每个英语码点值(均对应一个英语字符)均由一个字节组成,而每个中文码点值(均对应一个中文字符)均由三个字节组成。
在常量和变量一文中,我们已经了解到整数可以被显式转换为字符串类型(但是反之不行)。
这里介绍两种新的字符串相关的类型转换规则:byte
的切片类型。
rune
的切片类型。
在一个从码点切片到字符串的转换中,码点切片中的每个码点值将被UTF-8编码为一到四个字节至结果字符串中。
如果一个码点值是一个不合法的Unicode码点值,则它将被视为Unicode替换字符(码点)值0xFFFD
(Unicode replacement character)。
替换字符值0xFFFD
将被UTF-8编码为三个字节0xef 0xbf 0xbd
。
当一个字符串被转换为一个码点切片时,此字符串中存储的字节序列将被解读为一个一个码点的UTF-8编码序列。
非法的UTF-8编码字节序列将被转化为Unicode替换字符值0xFFFD
。
当一个字符串被转换为一个字节切片时,结果切片中的底层字节序列是此字符串中存储的字节序列的一份深复制。 即Go运行时将为结果切片开辟一块足够大的内存来容纳被复制过来的所有字节。当此字符串的长度较长时,此转换开销是比较大的。 同样,当一个字节切片被转换为一个字符串时,此字节切片中的字节序列也将被深复制到结果字符串中。 当此字节切片的长度较长时,此转换开销同样是比较大的。 在这两种转换中,必须使用深复制的原因是字节切片中的字节元素是可修改的,但是字符串中的字节是不可修改的,所以一个字节切片和一个字符串是不能共享底层字节序列的。
请注意,在字符串和字节切片之间的转换中,bytes
标准库包中的Runes
函数来将一个字节切片转换为码点切片。
但此包中没有将码点切片转换为字节切片的函数。
package main
import (
"bytes"
"unicode/utf8"
)
func Runes2Bytes(rs []rune) []byte {
n := 0
for _, r := range rs {
n += utf8.RuneLen(r)
}
n, bs := 0, make([]byte, n)
for _, r := range rs {
n += utf8.EncodeRune(bs[n:], r)
}
return bs
}
func main() {
s := "颜色感染是一个有趣的游戏。"
bs := []byte(s) // string -> []byte
s = string(bs) // []byte -> string
rs := []rune(s) // string -> []rune
s = string(rs) // []rune -> string
rs = bytes.Runes(bs) // []byte -> []rune
bs = Runes2Bytes(rs) // []rune -> []byte
}
for-range
循环中跟随range
关键字的从字符串到字节切片的转换;
package main
import "fmt"
func main() {
var str = "world"
// 这里,转换[]byte(str)将不需要一个深复制。
for i, b := range []byte(str) {
fmt.Println(i, ":", b)
}
key := []byte{'k', 'e', 'y'}
m := map[string]string{}
// 这个string(key)转换仍然需要深复制。
m[string(key)] = "value"
// 这里的转换string(key)将不需要一个深复制。
// 即使key是一个包级变量,此优化仍然有效。
fmt.Println(m[string(key)]) // value
}
注意:在最后一行中,如果在估值string(key)
的时候有数据竞争的情况,则这行的输出有可能并不是value
。
但是,无论如何,此行都不会造成恐慌(即使有数据竞争的情况发生)。
package main
import "fmt"
import "testing"
var s string
var x = []byte{1023: 'x'}
var y = []byte{1023: 'y'}
func fc() {
// 下面的四个转换都不需要深复制。
if string(x) != string(y) {
s = (" " + string(x) + string(y))[1:]
}
}
func fd() {
// 两个在比较表达式中的转换不需要深复制,
// 但两个字符串衔接中的转换仍需要深复制。
// 请注意此字符串衔接和fc中的衔接的差别。
if string(x) != string(y) {
s = string(x) + string(y)
}
}
func main() {
fmt.Println(testing.AllocsPerRun(1, fc)) // 1
fmt.Println(testing.AllocsPerRun(1, fd)) // 3
}
for-range
循环遍历字符串中的码点
for-range
循环控制中的range
关键字后可以跟随一个字符串,用来遍历此字符串中的码点(而非字节元素)。
字符串中非法的UTF-8编码字节序列将被解读为Unicode替换码点值0xFFFD
。
package main
import "fmt"
func main() {
s := "éक्षिaπ囧"
for i, rn := range s {
fmt.Printf("%2v: 0x%x %v \n", i, rn, string(rn))
}
fmt.Println(len(s))
}
此程序的输出如下:
0: 0x65 e
1: 0x301 ́
3: 0x915 क
6: 0x94d ्
9: 0x937 ष
12: 0x93f ि
15: 0x61 a
16: 0x3c0 π
18: 0x56e7 囧
21
从此输出结果可以看出:
é
由两个码点(共三字节)组成,其中一个码点需要两个字节进行UTF-8编码。क्षि
由四个码点(共12字节)组成,每个码点需要三个字节进行UTF-8编码。a
由一个码点组成,此码点只需一个字节进行UTF-8编码。π
由一个码点组成,此码点只需两个字节进行UTF-8编码。囧
由一个码点组成,此码点只需三个字节进行UTF-8编码。for
循环:
package main
import "fmt"
func main() {
s := "éक्षिaπ囧"
for i := 0; i < len(s); i++ {
fmt.Printf("第%v个字节为0x%x\n", i, s[i])
}
}
当然,我们也可以利用前面介绍的编译器优化来使用
for-range
循环遍历一个字符串中的字节元素。
对于官方标准编译器来说,此方法比刚展示的方法效率更高。
package main
import "fmt"
func main() {
s := "éक्षिaπ囧"
// 这里,[]byte(s)不需要深复制底层字节。
for i, b := range []byte(s) {
fmt.Printf("The byte at index %v: 0x%x \n", i, b)
}
}
从上面几个例子可以看出,len(s)
将返回字符串s
中的字节数。
len(s)
的时间复杂度为O(1)
。
如何得到一个字符串中的码点数呢?使用刚介绍的for-range
循环来统计一个字符串中的码点数是一种方法,使用unicode/utf8
标准库包中的RuneCountInString是另一种方法。
这两种方法的效率基本一致。第三种方法为使用len([]rune(s))
来获取字符串s
中码点数。标准编译器从1.11版本开始,对此表达式做了优化以避免一个不必要的深复制,从而使得它的效率和前两种方法一致。
注意,这三种方法的时间复杂度均为O(n)
。
+
运算符来衔接字符串,我们也可以用下面的方法来衔接字符串:
fmt
标准库包中的Sprintf
/Sprint
/Sprintln
函数可以用来衔接各种类型的值的字符串表示,当然也包括字符串类型的值。
strings
标准库包中的Join
函数。
bytes
标准库包提供的Buffer
类型可以用来构建一个字节切片,然后我们可以将此字节切片转换为一个字符串。
strings
标准库包中的Builder
类型可以用来拼接字符串。
和bytes.Buffer
类型类似,此类型内部也维护着一个字节切片,但是它在将此字节切片转换为字符串时避免了底层字节的深复制。
标准编译器对使用+
运算符的字符串衔接做了特别的优化。
所以,一般说来,当所有被衔接的字符串都可以呈现在一条+
字符串衔接语句中的情况下,使用+
运算符进行字符串衔接是比较高效的。
在上一篇文章中,我们了解到内置函数copy
和append
可以用来复制和添加切片元素。
事实上,做为一个特例,如果这两个函数的调用中的第一个实参为一个字节切片的话,那么第二个实参可以是一个字符串。
(对于append
函数调用,字符串实参后必须跟随三个点...
。)
换句话说,在此特例中,字符串可以当作字节切片来使用。
package main
import "fmt"
func main() {
hello := []byte("Hello ")
world := "world!"
// helloWorld := append(hello, []byte(world)...) // 正常的语法
helloWorld := append(hello, world...) // 语法糖
fmt.Println(string(helloWorld))
helloWorld2 := make([]byte, len(hello) + len(world))
copy(helloWorld2, hello)
// copy(helloWorld2[len(hello):], []byte(world)) // 正常的语法
copy(helloWorld2[len(hello):], world) // 语法糖
fmt.Println(string(helloWorld2))
}
==
和!=
比较,如果这两个字符串的长度不相等,则这两个字符串肯定不相等(无需进行字节比较)。
所以两个相等的字符串的比较的时间复杂度取决于它们底层引用着字符串切片的指针是否相等。
如果相等,则对它们的比较的时间复杂度为O(1)
,否则时间复杂度为O(n)
。
上面已经提到了,对于标准编译器,一个字符串赋值完成之后,目标字符串和源字符串将共享同一个底层字节序列。 所以比较这两个字符串的代价很小。
一个例子:package main
import (
"fmt"
"time"
)
func main() {
bs := make([]byte, 1<<26)
s0 := string(bs)
s1 := string(bs)
s2 := s1
// s0、s1和s2是三个相等的字符串。
// s0的底层字节序列是bs的一个深复制。
// s1的底层字节序列也是bs的一个深复制。
// s0和s1底层字节序列为两个不同的字节序列。
// s2和s1共享同一个底层字节序列。
startTime := time.Now()
_ = s0 == s1
duration := time.Now().Sub(startTime)
fmt.Println("duration for (s0 == s1):", duration)
startTime = time.Now()
_ = s1 == s2
duration = time.Now().Sub(startTime)
fmt.Println("duration for (s1 == s2):", duration)
}
输出如下:
duration for (s0 == s1): 10.462075ms
duration for (s1 == s2): 136ns
1ms等于1000000ns!所以请尽量避免比较两个很长的不共享底层字节序列的相等的(或者几乎相等的)字符串。
Go101.org网站内容包括Go编程各种相关知识(比如Go基础、Go优化、Go细节、Go实战、Go测验、Go工具等)。后续将不断有新的内容加入。敬请收藏关注期待。
本丛书微信公众号(联系方式一)名称为"Go 101"。二维码在网站首页。此公众号将时不时地发表一些Go语言相关的原创短文。各位如果感兴趣,可以搜索关注一下。
《Go语言101》系列丛书项目目前托管在Github上(联系方式二)。欢迎各位在此项目中通过提交bug和PR的方式来改进完善《Go语言101》丛书中的各篇文章。我们可以在项目目录下运行go run .
来浏览和确认各种改动。
本书的twitter帐号为@Golang_101(联系方式三)。玩推的Go友可以适当关注。
你或许对本书作者老貘开发的一些App感兴趣。
sync
标准库包sync/atomic
标准库包