编码方式与实质

Go 语言对字符串采用 UTF-8 编码,实质上一个字符串是一个字节数组 []byte 。
看如下代码输出可以证明:

  1. s := "abc你好"
  2. for i := 0; i < len(s); i++ {
  3. fmt.Println(s[i])
  4. }
  5. // 输出如下
  6. 97
  7. 98
  8. 99
  9. 228
  10. 189
  11. 160
  12. 229
  13. 165
  14. 189

输出为什么是这样的呢?
因为 a, b, c 的 UTF-8 编码分别就是 97, 98, 99,“你”和“好”的 UTF-8 编码分别是(228, 189, 160)和(229, 165, 189)。
强烈建议先看下面这篇文章,弄清楚 ASCII 编码,Unicode 编码和 UTF-8 编码之间的联系。
ASCII_and_Unicode_and_UTF-8

遍历

常规遍历

  1. s := "abc你好"
  2. for i := 0; i < len(s); i++ {
  3. fmt.Printf("%c\n", s[i])
  4. }
  5. 输出如下:
  6. a
  7. b
  8. c
  9. ä
  10. ½
  11. å
  12. ¥
  13. ½

这样的遍历方式对于纯英文字符串是没有问题的,原因在推荐文章中讲的很明白,因为英文字符的 UTF-8 编码和 ASCII 编码是一样的,但是中文字符、韩文、日文等字符就不一样了,会出现乱码。

UTF-8 遍历

  1. s := "abc你好"
  2. for x, y := range s {
  3. fmt.Printf("%d, %c\n", x, y)
  4. }
  5. 输出如下:
  6. 0, a
  7. 1, b
  8. 2, c
  9. 3,
  10. 6,

可以发现出现了 3-6 的跳跃。
如果看了上面推荐的文章就会明白,中文字符占用 2-4 个字节不等,所以才会出现用 s[3]-s[5] 共 3 个字节用来表示“你”,再一次证明了 Go 语言采用 UTF-8 编码字符串。
如果不要求下标按顺序对应字符顺序,这样的遍历方式已经可以了。

Unicode 遍历

UTF-8 遍历存在的问题是“下标按顺序不对应字符顺序”,Unicode 遍历可以解决这个问题,代价是牺牲一些空间,方案如下:
因为一个字符最多占 4 个字节空间,所以我们可以先把 string 转化为 []int32 切片,然后再输出即可。

  1. s := "abc你好"
  2. st := []rune(s) // rune 是 int32 的别名
  3. for i := 0; i < len(st); i++ {
  4. fmt.Printf("%d, %c\n", i, st[i])
  5. }
  6. 输出如下:
  7. 0, a
  8. 1, b
  9. 2, c
  10. 3,
  11. 4,

此时,满足下标按顺序对应字符顺序。
缺点是:本来只需要 1 + 1 + 1 + 3 + 3 = 9 个字节的空间存储字符串,现在需要 字符串操作 - 图1 个字节。
因此,非必要的情况下一般采用 UTF-8 遍历。

拼接

结论

一般使用 strings.Builder 拼接字符串。

介绍

Go 语言常用的 5 种字符串拼接方式。

  • 使用 +

    1. func plusConcat(s1 string, s2 string) string {
    2. return s1 + s2
    3. }
  • 使用 fmt.Sprintf

    1. func sprintfConcat(s1 string, s2 string) string {
    2. s := fmt.Sprintf("%s%s", s1, s2)
    3. return s
    4. }
  • 使用 strings.Builder

    1. func builderConcat(s1 string, s2 string) string {
    2. var builder strings.Builder
    3. builder.WriteString(s1)
    4. builder.WriteString(s2)
    5. return builder.String()
    6. }
  • 使用 bytes.Buffer

    1. func bufferConcat(s1 string, s2 string) string {
    2. var buf bytes.Buffer
    3. buf.WriteString(s1)
    4. buf.WriteString(s2)
    5. return buf.String()
    6. }
  • 使用 []byte
    1. func byteConcat(s1 string, s2 string) string {
    2. buf := make([]byte, 0, len(s1)+len(s2))
    3. buf = append(buf, s1...)
    4. buf = append(buf, s2...)
    5. return string(buf)
    6. }

对比

5 种方法都会出现重新申请内存空间的情况(发生内存复制),区别在于内存分配机制。

  • +fmt.Sprintf 的机制是申请所拼接字符串的总空间。假设一个大小为 10 B 的字符串拼接 1 万次,则需要 10 + 102 + 103 + …… + 10*9999 B 约为 500 MB 内存空间。
  • strings.Builder , bytes.Buffer[]byte 则有预定义的内存分配机制(不详细讲),采用上述的假设,最后需要的空间只有上述的千分之一。
  • strings.Builderbytes.Buffer 略快的原因是,两者底层都是 []byte 实现,但是前者直接把 []byte 转为了子串,而后者重新申请了一次内存空间进行转换。
    1. 16 32 64 128 256 512 1024 2048
    2. 2688 3456 4864 6144 8192 10240 13568 18432 24576 32768 40960 57344 73728 98304 122880
    一开始是 2 的次方数,后来逐渐平缓。

以下是简单的基准测试

  1. // main.go
  2. package main
  3. import (
  4. "fmt"
  5. "strings"
  6. "bytes"
  7. )
  8. func main() {
  9. }
  10. func PlusConcatenate() {
  11. t := "t"
  12. s := ""
  13. for i := 0; i < 100; i++ {
  14. s += t
  15. }
  16. }
  17. func SprintfConcatenate() {
  18. t := "t"
  19. s := ""
  20. for i := 0; i < 100; i++ {
  21. s = fmt.Sprintf("%s%s", s, t)
  22. }
  23. }
  24. func BufferConcatenate() {
  25. t := "t"
  26. var buffer bytes.Buffer
  27. for i := 0; i < 100; i++ {
  28. buffer.WriteString(t)
  29. }
  30. }
  31. func BuilderConcatenate() {
  32. t := "t"
  33. var builder strings.Builder
  34. for i := 0; i < 100; i++ {
  35. builder.WriteString(t)
  36. }
  37. }
  38. func SliceConcatenate() {
  39. t := "t"
  40. buf := make([]byte, 0)
  41. for i := 0; i < 100; i++ {
  42. buf = append(buf, t...)
  43. }
  44. }
  45. // main_test.go
  46. package main
  47. import (
  48. "testing"
  49. )
  50. func BenchmarkPlusConcatenate(b *testing.B) {
  51. for i := 0; i < b.N; i++ {
  52. PlusConcatenate()
  53. }
  54. }
  55. func BenchmarkSprintfConcatenate(b *testing.B) {
  56. for i := 0; i < b.N; i++ {
  57. SprintfConcatenate()
  58. }
  59. }
  60. func BenchmarkBufferConcatenate(b *testing.B) {
  61. for i := 0; i < b.N; i++ {
  62. BufferConcatenate()
  63. }
  64. }
  65. func BenchmarkBuilderConcatenate(b *testing.B) {
  66. for i := 0; i < b.N; i++ {
  67. BuilderConcatenate()
  68. }
  69. }
  70. func BenchmarkSliceConcatenate(b *testing.B) {
  71. for i := 0; i < b.N; i++ {
  72. SliceConcatenate()
  73. }
  74. }
  75. /*
  76. 测试结果:
  77. goos: windows
  78. goarch: amd64
  79. pkg: goproject-vscode/test-string-concatenation
  80. cpu: Intel(R) Core(TM) i7-8750H CPU @ 2.20GHz
  81. BenchmarkPlusConcatenate-12 209023 4969 ns/op
  82. BenchmarkSprintfConcatenate-12 67346 17912 ns/op
  83. BenchmarkBufferConcatenate-12 1592191 717.2 ns/op
  84. BenchmarkBuilderConcatenate-12 3648463 329.4 ns/op
  85. BenchmarkSliceConcatenate-12 5138461 235.9 ns/op
  86. PASS
  87. ok goproject-vscode/test-string-concatenation 7.559s
  88. */

可见 Sprintf 是最差的,因为它本来就不适合用于拼接字符串;+ 与 bytes.Buffer 和 strings.Builder 相差一个数量级;bytes.Buffer 和 strings.Builder 性能相近,而 strings.Builder 略优;[]byte 最优,但实际应用中一般需要再转为 string 返回。

strings 包

strings 包里提供了许多关于字符串操作的函数。