Skip to content

1.5 数据类型:byte、rune与字符串

Go 语言中的字符串是一种原生(内置)数据类型,与其他基本类型(如 intbool)一样,使用起来非常方便。理解 Go 字符串的核心在于理解它与 UTF-8 编码以及 byterune 字符类型的关系。

一、字符串的定义与基本特性

1. 核心特性

特性描述
不可变性字符串一旦创建,就不能被修改。修改字符串必须创建新的字符串。
底层实现字符串的本质是一个只读的 byte 字节切片 ([]byte)。
编码Go 语言的字符串默认使用 UTF-8 编码
零值字符串变量的默认零值是空字符串 ""

2. 字符串的声明与表示

(1) 申明字符串(双引号 ""

Go 语言里的字符串的内部实现使用UTF-8编码。 字符串的值为双引号(")中的内容,可以在Go语言的源码中直接添加非ASCII码字符,例如:

go
s1 := "hello"
s2 := "你好"
fmt.Println(s1)

(2) 多行字符串(反引号 ``)

Go语言中要定义一个多行字符串时,就必须使用反引号字符:

go
s1 := `第一行
    第二行
    第三行
`
fmt.Println(s1)

3. 字符串转义符

Go语言支持多种转义字符来表示特殊字符:

转义符含义
\n换行符
\r回车符
\t制表符
\'单引号
\"双引号
\\反斜杠

示例:

go
s := "这是第一行\n这是第二行"
fmt.Println(s)
s2 := "Tab:\t缩进"
fmt.Println(s2)
s3 := "C:\\Windows\\System32"  // 用\\表示一个\
fmt.Println(s3)

二、字符类型:byterune

由于 Go 字符串使用 UTF-8 编码,单个字符可能占用 1 到 4 个字节,因此 Go 提供了两种字符类型来处理不同的编码需求。

1. byte 类型

  • 本质: byteuint8 的别名。
  • 大小: 占用 1 个字节(8 位)。
  • 用途: 主要用来表示 ASCII 字符(如英文字母、数字和常见符号),因为 ASCII 字符只占用 1 个字节。
  • 字面量: 使用单引号 ' 包裹。
go
var a byte = 'A' // ASCII 码 65
var b uint8 = 66 // byte 和 uint8 本质相同

fmt.Printf("%c\n", a) // 输出: A

2. rune 类型

  • 本质: runeint32 的别名。
  • 大小: 占用 4 个字节(32 位)。
  • 用途: 主要用来表示 Unicode 字符(即 UTF-8 编码中的一个字符,包括中文、日文、表情符号等)。
  • 字面量: 同样使用单引号 ' 包裹。
go
var chineseChar rune = '' // 中文汉字需要 3 个字节存储,但用 4 字节的 rune 表示

fmt.Printf("%c\n", chineseChar) // 输出: 中
go
import (
	"fmt"
	"unsafe"
)

func main() {
	var a byte = 'A'
	var b rune = 'B'
	fmt.Printf("a 占用 %d 个字节数\nb 占用 %d 个字节数", unsafe.Sizeof(a), unsafe.Sizeof(b))
}

输出如下:
a 占用 1 个字节数
b 占用 4 个字节数

总结: 在 Go 中,单引号 'A' 表示一个字符byterune),双引号 "A" 表示一个字符串string)。

三、字符串与字符的遍历与长度

1. 长度:len()

内置函数 len() 返回的是字符串包含的字节数(Byte Count),而不是字符数。

go
s := "hello,中国"
// 'h','e','l','l','o',',' 占 6 字节
// '中','国' 各占 3 字节 (UTF-8)
fmt.Println("字节长度:", len(s)) // 输出: 12

2. 按字节遍历

使用索引或普通的 for 循环遍历字符串,获取的是组成字符串的原始字节。如果字符串包含中文等多字节字符,会产生乱码。

go
s := "你好"
for i := 0; i < len(s); i++ {
    // 打印每个字节的数值和对应的字符(可能会乱码)
    fmt.Printf("%v(%c) ", s[i], s[i]) 
}
// 输出(示例):228(å) 189(½) 160( ) 229(å) 165(¥) 189(½)

3. 按字符遍历

使用 for range 循环遍历字符串时,Go 会自动处理 UTF-8 编码,每次迭代返回的是一个 rune 类型的字符和一个字符起始位置的索引

go
s := "你好,世界"
for index, char := range s {
    // index 是字节索引,char 是 rune 类型 (Unicode 字符)
    fmt.Printf("索引:%d, 字符:%c\n", index, char)
}
// 输出:
// 索引:0, 字符:你 (中文字符,占 3 个字节)
// 索引:3, 字符:好
// ...

4. 字符数

len(s) 统计的是字节数。若要统计字符串按 UTF-8 解码后的 Unicode 码点(rune)个数,常用下面两种方式。

方式说明
utf8.RuneCountInString(s)标准库 unicode/utf8:按字节扫描并解码 UTF-8,不分配 []rune,一般只关心个数时更省内存
len([]rune(s))先把字符串解码并复制[]rune 再取长度;若后续本来就要按 rune 切片处理,可以一举两得。
go
import (
	"fmt"
	"unicode/utf8"
)

func main() {
	s := "hello,中国"
	fmt.Println("字节数 len(s):", len(s))                        // 12
	fmt.Println("码点数 utf8.RuneCountInString:", utf8.RuneCountInString(s)) // 8
	fmt.Println("码点数 len([]rune(s)):", len([]rune(s)))         // 8
}

注意: 码点个数一般不等于用户眼中的「字形」个数(例如部分 emoji 由多个码点组成),需要按字形统计时应使用 unicode/utf8 之外的专门库(如 golang.org/x/text/unicode/norm 等),本节不展开。

四、字符串的修改与类型转换

由于字符串是不可变的,如果需要修改字符串内容,必须先将其转换为可变的类型(如字节切片或 rune 切片),修改后再转换回字符串。

1. 修改英文字符

适用于只包含 ASCII 字符的场景,效率较高。

go
s := "hello"
// 1. 转换为 []byte
byteSlice := []byte(s) 
// 2. 修改第一个元素
byteSlice[0] = 'H' 
// 3. 转换回 string
newS := string(byteSlice) 
fmt.Println(newS) // 输出: Hello

2. 修改多字节字符

处理包含中文等 Unicode 字符时,必须使用 []rune 转换,以避免破坏 UTF-8 编码结构。

go
s2 := "博客"
// 1. 转换为 []rune (处理 Unicode 字符)
runeSlice := []rune(s2) 
// 2. 修改第一个字符
runeSlice[0] = '' 
// 3. 转换回 string
newS2 := string(runeSlice)
fmt.Println(newS2) // 输出: 狗客

3. 字符串与数值类型的转换

字符串和数值(int, float, bool)之间的转换,不能直接使用类型转换语法(如 int("123")),必须依赖标准库 strconv 包提供的函数。

(1) 数值转字符串

函数描述示例
strconv.Itoa(i int)将整型(int)转换为字符串。strconv.Itoa(123) -> "123"
strconv.FormatInt(i, base)将整型转换为指定进制(base)的字符串。strconv.FormatInt(15, 16) -> "f"
strconv.FormatFloat(f, fmt, prec, bitSize)将浮点型转换为字符串。strconv.FormatFloat(3.14, 'f', 2, 64) -> "3.14"
go
import "strconv"

num := 42
s := strconv.Itoa(num)
fmt.Printf("int 转 string: %s, 类型: %T\n", s, s) // 42, string

(2) 字符串转数值

字符串转数值可能会失败(例如试图将 "abc" 转为整数),因此这些函数通常返回两个值:结果和错误。

函数描述示例
strconv.Atoi(s string)将字符串转换为整型(int)。strconv.Atoi("123") -> 123, nil
strconv.ParseInt(s, base, bitSize)将字符串转换为指定大小和进制的整型。strconv.ParseInt("ff", 16, 64) -> 255, nil
strconv.ParseFloat(s, bitSize)将字符串转换为浮点型。strconv.ParseFloat("3.14", 64) -> 3.14, nil
go
import "strconv"

s := "12345"
i, err := strconv.Atoi(s)

if err != nil {
    fmt.Println("转换失败:", err)
} else {
    fmt.Printf("string 转 int: %d, 类型: %T\n", i, i) // 12345, int
}

五、字符串的常用操作

Go 语言标准库 strings 包提供了大量的字符串操作函数。

函数描述示例
len(s)返回字符串的字节长度。len("go") -> 2
s1 + s2拼接字符串。"go" + "lang" -> "golang"
strings.Split(s, sep)分割字符串,返回切片。strings.Split("a,b,c", ",") -> ["a", "b", "c"]
strings.Join(s, sep)连接切片元素,返回字符串。strings.Join([]string{"a","b"}, "-") -> "a-b"
strings.Contains(s, substr)判断是否包含子串。strings.Contains("golang", "go") -> true
strings.HasPrefix(s, prefix)判断是否以前缀开头。strings.HasPrefix("hello", "he") -> true
strings.Index(s, substr)查找子串第一次出现的位置(字节索引)。strings.Index("golang", "a") -> 3
fmt.Sprintf格式化拼接字符串(常用于复杂拼接)。fmt.Sprintf("%s版本%d", "Go", 1) -> "Go版本1"

六、字符串的底层与内存

理解字符串的底层结构有助于我们写出更高效的 Go 代码。

在 Go 语言中,字符串的底层存储是字节数组([] byte)。例如,字符串"你好a"的底层字节数组如下:

s := "你好a"
// 底层字节数组(UTF-8编码):
// []byte{0xE4, 0xBD, 0xA0, 0xE5, 0xA5, 0xBD, 0x61}
// 对应:'你'(3字节)、'好'(3字节)、'a'(1字节)

1. 字符串的底层结构

在 Go 语言内部,string 类型实际上是一个轻量级的结构体,它包含两个字段:

  1. 一个指向底层字节数组的指针(pointer)。
  2. 一个表示字符串长度的整数(len)。

其内存布局示意图如下:

+-------------------+      +-------------------+
|     字符串变量s     |      |   底层字节数组    |
|                   |      |                 |
|  [0] 指针 --------+-----> |  0: 'h'         |
|                   |      |  1: 'e'          |
|  [8] 长度 (5)      |      |  2: 'l'          |
+-------------------+      |  3: 'l'          |
                           |  4: 'o'          |
                           +-------------------+

在64位系统中,指针和长度各占8字节,因此字符串变量本身占16字节,存储在栈上;底层字节数组存储在堆上,由Go的垃圾回收器管理。

底层字节数组不可变

字符串的指针指向的字节数组是只读的,任何试图直接修改的操作(如通过指针强制转换)都会触发运行时错误:

go
s := "hello"
// 错误示例:试图修改字符串底层字节(编译通过,但运行时panic)
ptr := (*byte)(unsafe.Pointer(&s)) 
*ptr = 'H' // runtime error: invalid memory address or nil pointer dereference

“修改”字符串的本质是创建新字符串

若要“修改”字符串,实际是创建一个新的字节数组并生成新的字符串变量,原字符串不受影响:

go
s := "hello"
s2 := s[:3] + "p" // 新字符串"help",底层是新的字节数组

此时内存布局变为:

s: 指针→"hello"(len=5)
s2: 指针→"help"(len=4,新数组)

Go 的字符串不可修改,本质是因为其底层字节数组被设计为只读,且字符串变量仅存储指向该数组的指针和长度。这种设计保证了数据安全、内存高效复用,并简化了并发处理。若需修改字符串内容,需先转换为字节切片[]byte(会复制数据),修改后再转回字符串。

2. 字符串与切片/数组的转换开销

string[]byte[]rune 相互转换,会涉及内存重新分配和数据复制

转换操作描述内存开销
string(b)[]bytestring复制字节切片内容到新分配的字符串内存中。较高
[]byte(s)string[]byte复制字符串内容到新分配的字节切片内存中。较高
[]rune(s)string[]rune复制并进行 UTF-8 解码,将每个字符存储为 4 字节的 rune很高

最佳实践: 尽量避免在紧密的循环中频繁进行字符串和切片之间的转换。如果只是进行字符串构建,推荐使用 strings.Builder

3. 字符串拼接效率

在 Go 语言中,使用不同的方式拼接字符串,效率有巨大的差别:

拼接方式性能特点推荐场景
+ 操作符每次拼接都会创建新的字符串和新的底层数组,效率最低(尤其是大量循环)。少量、固定的字符串拼接。
fmt.Sprintf依赖反射和格式化,效率一般。需要复杂的格式化输出时。
strings.Join内部预估容量,只进行一次分配和复制,效率较高。拼接一个已知元素的 []string 切片时。
strings.Builder专为构建字符串设计,可以预估容量并减少内存分配次数,效率最高循环内进行大量字符串拼接时。

strings.Builder 示例:

go
import "strings"

func buildString(parts []string) string {
    var builder strings.Builder
    // 预估容量,减少内存重分配
    builder.Grow(100) 
    
    for _, p := range parts {
        builder.WriteString(p)
    }
    return builder.String()
}

七、字符串的格式化输出

fmt 包提供了一系列格式化输出的动词(Verb),尤其在处理字符串时非常有用。

格式化动词描述示例
%s输出字符串。fmt.Printf("%s", "hello")
%q输出带双引号的字符串,并对特殊字符进行转义(解释型表示法)。fmt.Printf("%q", "a\n") -> "a\n"
%c输出 Unicode 字符。fmt.Printf("%c", '中')
%v输出值的默认格式。
%#v输出值的 Go 语法表示,常用于调试结构体。
go
package main

import "fmt"

func main() {
    path := "C:\temp\file.txt"
    
    // %s: 直接输出
    fmt.Printf("路径原始输出: %s\n", path) 
    
    // %q: 输出解释型字符串(带引号和转义)
    fmt.Printf("路径带引号输出: %q\n", path) 
    
    // %c: 打印 rune 字符
    fmt.Printf("第一个字符: %c\n", path[0]) // 注意:path[0] 是 byte,但 %c 仍可将其作为字符打印
}

八、思考:为什么字符串不可修改?

1. 安全性与可靠性

不可修改的字符串能避免意外篡改。例如,若多个变量引用同一个字符串,修改其中一个不会影响其他变量,保证数据一致性。例:

go
s1 := "hello"
s2 := s1
// 若字符串可修改,修改s1会导致s2也变化,违背直觉

2. 内存复用与效率

字符串不可修改,使得相同内容的字符串可以共享底层内存(如字符串常量池),减少内存占用。例如,代码中多次出现的相同字符串字面量(如"hello"),在编译后可能只存储一份。

3. 简化并发处理

在并发场景中,不可修改的字符串无需加锁保护,多个goroutine可安全读取,降低并发编程复杂度。

4.与其他类型的协同

Go中字符串与字节切片([]byte)可相互转换,但转换时会复制数据(或共享只读内存),这种设计通过不可修改性避免了原字符串被切片意外修改的风险。