常量运算中的隐式转换
常量与变量之间的运算在程序中最常见,它遵循 Go 语言规范中运算符的规则。该规则规定,除非操作涉及位运算或未定义的常量,否则操作数两边的类型必须相同。这意味着常量在进行运算时,操作数两边的类型不一定相同,如下所示。
var a = 3 * 0.333在 Go 语言规范中,对于常量表达式也制定了专门的规则。除了移位操作,如果操作数两边是不同类型的无类型常量,则结果类型的优先级为:整数(int)<符文数(rune)<浮点数(float)<复数(Imag)。根据此规则,上面两个常数之间相乘的结果将是一个浮点数,因为浮点数的优先级比整数高。
常量与变量之间的转换
在 Go 语言中,常量与变量之间的运算遵守严格的类型规则,但常量本身在编译期间是有一定灵活性的,它们可以是无类型的,这允许它们在不失精度的情况下参与不同类型的运算。例如:
const a float64 = 1const b = a * 2在这个例子当中:a 是有类型的常量(float64),2 是无类型的,所以 2 这个常量可以在不丢失精度的情况下转换为 float64 类型,因此上面的例子并不会报错。但是下面这种情况就会报错:
const a int = 1const b = a * 2.5在这个例子当中:a 是有类型的常量(int),2.5 是无类型的,但是 2.5 并不能在不丢失精度的情况下转换为 int 类型,所以会报错。解决办法可以是去掉定义 a 常量时的 int,这样 a 常量也是一个无类型的常量,两个无类型常量之间的运算会按照优先级进行转换,那么 1 就会隐式类型转换为 1.0,最后 b 的值就是 2.5。如果将 2.5 改为 2.0 也不会报错,因为 2.0 可以在不丢失精度的情况下转换为 int 类型,这样 b 的值就是 2。
字符串本质
在 Go 语言中,字符串是基本的数据类型之一,它的本质特性可以从几个方面来理解:
-
不可变性: Go 中的字符串是不可变的。一旦一个字符串被创建,它所包含的内容就不能被改变。这意味着任何对字符串的"修改"操作其实都是创建了一个新的字符串。例如,拼接两个字符串并赋给一个新的变量,实际上并没有改变原始字符串,而是创建了一个包含两个原始字符串内容的全新字符串。
-
UTF-8 编码:
在 Go 语言中,字符串是以字节数组的形式在底层实现的,每个字符都是按照 UTF-8 编码转换成了一组字节。UTF-8 编码是一种
可变长度的编码方式,它可以使用 1 到 4 个字节来表示一个字符。这种编码的可变长度特性意味着在一个 Go 字符串中,不同的字符可能占用不同数量的字节。例如,在字符串"hello world"中,尽管它有 11 个字符,但是每个字符都是用单一字节表示的(因为它们都是 ASCII 字符),总共也是 11 个字节。如果一个字符串包含非 ASCII 字符(例如某些 Unicode 字符),那么它的字节长度可能会大于字符的实际数量。在 Go 中,可以通过下标访问字符串的字节,但这并不总是等同于访问单独的字符,因为一个 Unicode 字符可能由多个字节组成。当打印一个字符串时,Go 语言提供的标准库函数可以帮助你将字符串的字节表示为十六进制形式,这可以看到每个字符底层的字节编码。例如:
gos := "hello world"for i := 0; i < len(s); i++ { fmt.Printf("%x ", s[i])}这段代码将会输出字符串"hello world"的每个字符对应的十六进制字节值。最后会输出:68 65 6c 6c 6f 20 77 6f 72 6c 64。
此外,由于 Go 字符串采用 UTF-8 编码,它们甚至可以包含零值字节(
\x00或0x00),这与 C 语言字符串不同,后者使用零值字节作为字符串的结束标记。 -
内存结构(String Header): 在 Go 的内存中,字符串由一个结构体表示,这个结构体为
StringHeader,该结构体包含两个元素:一个指针和一个长度。Data 指针指向底层数组的第一个字节,Len 表示数组的长度。gotype StringHeader struct { Data uintptr Len int}
符文类型
在过去,我们只有一个字符集,那就是 ASCII(美国信息交换标准代码)。在那里,我们用 7 个比特来表示 128 个字符,包括大写和小写英文字母、数字以及各种标点符号和设备控制字符。由于这种字符的限制,大多数人无法使用他们自定义的书写系统。为了解决这个问题,Unicode 被发明了。Unicode 是 ASCII 的一个超集,包含了当今世界书写系统中的所有字符。它包括重音、变音符号、控制代码(如制表符和回车符)等,并为每个字符分配一个唯一的标准数字,称为 “Unicode 码点”,在 Go 语言中称为 “符文”。符文类型是 int32 的一个别名。
永远记住,字符串是一个字节的序列,而不是一个符文的序列。
func main() { s := "世界" for i := 0; i < len(s); i++ { fmt.Printf("%x ", s[i]) } for _, runeValue := range s { fmt.Printf("%#U ", runeValue) }}运行结果如下:
e4 b8 96 e7 95 8cU+4E16 '世' U+754C '界'在 Go 语言中,字符串是字节的序列。但是,当使用 range 循环遍历字符串时,Go 会自动解码 UTF-8 字符串(把它解码为实际的 Unicode 码点),并迭代每个 Unicode 字符的码点。这意味着在 range 循环中,实际得到的是每个 Unicode 字符的码点,而不是原始的字节值。
因此,第一个循环输出的是字符串中每个字节的十六进制值,而第二个循环输出的是每个 Unicode 字符的码点的对应值。
Go 的标准库 unicode/utf8 为解释 UTF-8 文本提供了强大的支持,包含了验证、分离、组合 UTF-8 字符的功能。
-
DecodeRuneInString
DecodeRuneInString函数用于从字符串的开头解码出第一个 UTF-8 编码的字符(即 rune)。gofunc main() { s := "Hello, 世界" // 解码并打印每个字符及其字节大小 for len(s) > 0 { r, size := utf8.DecodeRuneInString(s) fmt.Printf("%c 的字节数: %d\n", r, size) s = s[size:] }}输出结果:
textH 的字节数: 1e 的字节数: 1l 的字节数: 1l 的字节数: 1o 的字节数: 1, 的字节数: 1 的字节数: 1世 的字节数: 3界 的字节数: 3 -
RuneCountInString
RuneCountInString函数用于计算字符串中的 rune 数量,和 len 函数不同,len 返回的是字节长度,而 RuneCountInString 返回的是实际的字符数。texts := "Hello, 世界"fmt.Println("Bytes:", len(s)) // 输出字符串的字节长度:13fmt.Println("Runes:", utf8.RuneCountInString(s)) // 输出字符串的字符数:9 -
EncodeRune
EncodeRune函数用于将 rune 编码为 UTF-8 字节序列并返回。textbuf := make([]byte, 3) // 分配足够的空间r := '世'n := utf8.EncodeRune(buf, r)fmt.Printf("%x\n", buf[:n]) // 打印编码后的字节运行结果如下:
texte4b896 -
ValidString
ValidString函数用于验证字符串是否为有效的 UTF-8 编码。textvalid := utf8.ValidString("Hello, 世界")fmt.Println("Valid UTF-8:", valid) // trueinvalid := utf8.ValidString(string([]byte{0xff, 0xfe, 0xfd}))fmt.Println("Valid UTF-8:", invalid) // false,因为这不是有效的 UTF-8 编码序列
这些函数和功能展示了 Go 如何处理 UTF-8 编码的文本,并提供了强大的原语来支持字符串操作。
字符串底层原理
字符串解析
对于字符串解析,在语法分析阶段,采取递归下降的方式读取 UTF-8 字符,反引号或双引号是字符串的标识。分析的逻辑位于 src\cmd\compile\internal\syntax\scanner.go 文件中。
func (s *scanner) next() { nlsemi := s.nlsemi s.nlsemi = falseredo: ······ case '"': s.stdString() case '`': s.rawString() ······}可以看到如果在字符串中识别到反引号,则调用 rawString 函数;如果识别到双引号,则调用 stdString 函数,其实这两者的处理略有不同。
-
对于反引号的处理比较简单:一直循环向后读取,直到寻找到配对的反引号,如下所示。
gofunc (s *scanner) rawString() { ok := true s.nextch() for { if s.ch == '`' { s.nextch() break } if s.ch < 0 { s.errorAtf(0, "string not terminated") ok = false break } s.nextch() } s.setLit(StringLit, ok)} -
双引号调用 stdString 函数,如果出现另一个双引号则直接退出;如果出现了\\,则对后面一个字符进行转义。
gofunc (s *scanner) stdString() { ok := true s.nextch() for { if s.ch == '"' { s.nextch() break } if s.ch == '\\' { s.nextch() if !s.escape('"') { ok = false } continue } if s.ch == '\n' { s.errorf("newline in string") ok = false break } if s.ch < 0 { s.errorAtf(0, "string not terminated") ok = false break } s.nextch() } s.setLit(StringLit, ok)}可以看出,对于使用双引号表示的字符串不能出现换行符,比如下面的写法就会报错:
texts := "Hello world"
编译时字符拼接
在 go 语言中,字符串拼接有编译时拼接和运行时拼接。编译时拼接就是两个或多个字符串常量进行拼接,Go 编译器会在编译过程中将多个字符串常量合并成一个单一的字符串常量。这种拼接方式的特点是无论有多少个字符串常量进行拼接都不会影响程序运行时的性能。如下就属于编译时拼接:
const s1 = "Hello, " + "World!"s2:="Hello, " + "World!"运行时字符拼接
下面是多种字符串拼接的性能比较。
-
使用
+进行字符串拼接。这种拼接方式的底层原理是:每次使用 + 操作符拼接字符串时,实际上是在创建一个全新的字符串,并将原来的字符串和要拼接的字符串复制到这个新字符串中。所以使用 + 运算符进行字符串拼接,尤其是在循环中使用时,会导致多次内存分配和复制,因为每一次 + 操作都可能产生一个新的字符串对象,导致前一个字符串对象的内容被复制到新的更大的内存空间中。
需要注意的是,在使用 + 操作符拼接字符串的时候,当新字符串的大小小于 32 字节时,是用一个临时 buf 缓存这个新字符串,这个临时 buf 是在栈区;当新字符串的大小大于 32 字节时,会向内存堆区重新申请内存来存储这个新字符串。
go// src\runtime\string.goconst tmpStringBufSize = 32type tmpBuf [tmpStringBufSize]byte

-
使用
strings.Join()函数进行字符串拼接。gofunc Join(a []string, sep string) stringJoin()函数用于将元素类型为 string 的切片使用分割符号 sep 来拼接组成一个字符串。其底层原理概括起来就是:
-
预分配内存:
strings.Join()首先会遍历一次输入的字符串切片,来计算所需的总长度。这样做可以避免在拼接时多次重新分配内存,因为它可以一次性分配足够的内存来存放最终的结果。 -
一次复制: 有了总长度信息后,
strings.Join()将会分配一个足够大的字符串缓冲区,并将所有的字符串元素复制到这个缓冲区中,每个元素之间插入分隔符sep。这意味着字符串的实际内容只被复制一次。
gofunc Join(elems []string, sep string) string { switch len(elems) { case 0: return "" case 1: return elems[0] } n := len(sep) * (len(elems) - 1) for i := 0; i < len(elems); i++ { n += len(elems[i]) } var b Builder b.Grow(n) b.WriteString(elems[0]) for _, s := range elems[1:] { b.WriteString(sep) b.WriteString(s) } return b.String()}从源代码中可以看到,其大致的思路就是:
- 初始化 n 为所有分隔符的总长度,即分隔符长度乘以元素个数减一,因为分隔符的数量总是比元素数量少一个。
- 然后遍历 elems,累加每个元素的长度到 n 中,即现在得到了总长度。
- 创建一个 strings.Builder 实例 b,调用 b.Grow(n) 预分配足够的内存以避免多次分配。
- 使用 strings.Builder 拼接字符串:首先,将 elems 的第一个元素写入 b。然后,从 elems 的第二个元素开始的部分,在每个元素之前加上分隔符 sep 并写入 b。
- 最后,调用 b.String() 方法将 strings.Builder 中累积的字符串内容转换为普通的字符串,并将其作为结果返回。
这种方式的优势在于减少了内存分配和复制操作的次数。通过一次性预分配足够的内存,并使用 strings.Builder 的高效写入操作来完成大量字符串的拼接。
-
-
使用
fmt.Sprintf()函数进行字符串拼接。fmt.Sprintf()函数根据格式说明符来格式化并返回一个新的字符串。这个函数在内部使用了
io.Writer接口,通过写操作来创建最终的字符串。以下是它进行字符串拼接的一般流程:- 解析格式字符串:fmt.Sprintf() 接受一个格式字符串和一系列参数。格式字符串包含了零个或多个“占位符”,占位符对应于后面的参数。函数首先解析这个格式字符串,确定每个占位符的类型和对应的打印方式。
- 类型检查和转换:对于每个占位符,fmt.Sprintf() 检查相应的参数是否与其类型匹配。如果需要,它会转换参数以符合占位符的要求。例如,如果一个占位符指定了打印十六进制数,那么相应的参数会被转换为十六进制格式。
- 格式化:对于每一个参数,fmt.Sprintf() 根据占位符的指示对其进行格式化。这可能涉及添加填充、调整对齐、控制精度等操作。
- 写入缓冲区:格式化后的参数被转换为字符串,并写入到一个内部的缓冲区中。fmt.Sprintf() 在内部通过实现 io.Writer 接口来高效地将字符串写入缓冲区。
- 拼接:所有参数被格式化并写入缓冲区后,fmt.Sprintf() 将这些字符串拼接起来,形成最终的结果字符串。
- 返回结果:拼接完成后,fmt.Sprintf() 返回缓冲区中的内容作为最终的字符串。
在底层实现中,fmt.Sprintf() 可能会涉及到复杂的内存操作,如动态扩展缓冲区、处理 Unicode 字符的宽度、考虑本地化等。虽然 fmt.Sprintf() 提供了很强的灵活性和强大的格式化功能,但这些特性也意味着与简单的字符串拼接相比,它在性能上可能会有所损失,尤其是在涉及大量字符串操作或对性能敏感的应用中。
-
使用
Buffer.WriteString()进行字符串拼接。buffer.WriteString() 函数是 Go 语言中 bytes.Buffer 类型提供的一个方法,用于将字符串追加到缓冲区。bytes.Buffer 在内部使用了一个可增长的字节切片来实现对字符串和字节序列的动态拼接。以下是使用 Buffer.WriteString() 进行字符串拼接的底层原理:
-
初始化:创建一个 bytes.Buffer 实例时,它会初始化一个空的字节切片作为存储。
-
写入数据:当调用 buffer.WriteString(s string) 方法时,它会将字符串 s 的内容追加到内部字节切片的末尾。如果内部字节切片的容量(cap)足够大,追加操作就不需要分配新的内存。如果追加字符串后的总长度超过了字节切片的当前容量,bytes.Buffer 会通过分配一个更大的字节切片并复制现有内容来完成,然后追加新的字符串。
-
数据拷贝:在将数据全部写入缓冲区后,还需要将缓冲区的数据转换为字符串。bytes.Buffer 的 String() 方法就是将缓冲区中的数据转换为一个新的字符串。bytes.Buffer 的 String() 方法的源码如下:
gofunc (b *Buffer) String() string { if b == nil { return "<nil>" } return string(b.buf[b.off:])}这里最后使用 string() 转换来创建一个新的字符串。在 Go 语言中,string() 转换会导致分配新的内存并拷贝字节切片的数据到新的字符串中,这是因为字符串在 Go 中是不可变的,而字节切片是可变的。因此,为了确保字符串的不可变性,进行这种转换时需要数据拷贝。
使用 bytes.Buffer 的优势是它可以有效地管理内存(字节切片的动态扩容),减少因字符串拼接引起的频繁内存分配和复制操作,从而提高性能。
-
-
使用
Builder.WriterString()方法。strings.Builder 和 bytes.Buffer 这两种方法在拼接字符串上非常相似,但是有些区别:
-
strings.Builder 是专门为字符串拼接而设计的,无读取功能,它是只写的;而 bytes.Buffer 是为了处理字节切片和字符串的读写操作而设计的,它提供了丰富的读取操作,比如 Read, ReadByte, ReadBytes, ReadString 等方法。
-
虽然两者的 Grow 方法在功能上看似相同,但 strings.Builder 的内部实现可能更为精简,因为它不需要考虑读取操作,可能会有一些额外的性能优化。
-
bytes.Buffer 可以使用 WriteByte 或 WriteRune 方法直接写入单个字节或 rune;而 strings.Builder 则需要将这些转换为字符串后再进行写入。
-
两者在将内部缓冲区转换为字符串时有所不同。bytes.Buffer 需要进行一次内存拷贝来确保得到的字符串是不可变的;而 strings.Builder 通过 String 方法通常能避免内存拷贝。其 String 方法源代码如下:
gofunc (b *Builder) String() string { return *(*string)(unsafe.Pointer(&b.buf))}
-
字符串拼接性能比较
代码如下:
const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"func randomString(n int) string { b := make([]byte, n) for i := range b { b[i] = letterBytes[rand.Intn(len(letterBytes))] } return string(b)}func plusConcat(n int, str string) string { s := "" for i := 0; i < n; i++ { s += str } return s}func joinConcat(n int, str string) string { s := "" v := []string{s, str} for i := 0; i < n; i++ { s = strings.Join(v, "") } return s}func sprintfConcat(n int, str string) string { s := "" for i := 0; i < n; i++ { s = fmt.Sprintf("%s%s", s, str) } return s}func bufferConcat(n int, s string) string { buf := new(bytes.Buffer) for i := 0; i < n; i++ { buf.WriteString(s) } return buf.String()}func builderConcat(n int, str string) string { var builder strings.Builder for i := 0; i < n; i++ { builder.WriteString(str) } return builder.String()}func benchmark(b *testing.B, f func(int, string) string) { var str = randomString(10) for i := 0; i < b.N; i++ { f(10000, str) }}func BenchmarkPlusConcat(b *testing.B) { benchmark(b, plusConcat) }func BenchmarkJoinConcat(b *testing.B) { benchmark(b, joinConcat) }func BenchmarkSprintfConcat(b *testing.B) { benchmark(b, sprintfConcat) }func BenchmarkBuilderConcat(b *testing.B) { benchmark(b, builderConcat) }func BenchmarkBufferConcat(b *testing.B) { benchmark(b, bufferConcat) }benchmark 测试结果如下:
BenchmarkPlusConcat-8 13 90620915 ns/op 530998284 B/op 10028 allocs/opBenchmarkJoinConcat-8 2926 902203 ns/op 160000 B/op 10000 allocs/opBenchmarkSprintfConcat-8 8 138016425 ns/op 833804164 B/op 37509 allocs/opBenchmarkBuilderConcat-8 8492 158077 ns/op 514802 B/op 23 allocs/opBenchmarkBufferConcat-8 10000 282335 ns/op 368577 B/op 13 allocs/op所以字符串拼接性能由好到坏的顺序是:
strings.Builder > bytes.Buffer > strings.Join > +拼接符 > fmt.Sprintf