前面我们已经学习了 Go 的变量、常量、数据类型、输入输出、条件控制以及切片。接下来开始学习 Go 语言中使用频率非常高的一种类型:字符串。
很多初学者会把字符串理解成“字符组成的数组”。这个说法容易入门,但在 Go 中并不准确。按照 Go 官方语言规范 的定义,字符串是一个可能为空的字节序列,字符串的长度是字节数,并且字符串一旦创建,内容就不能被修改。
简单理解:Go 字符串本质上是只读的字节序列;它可以保存 UTF-8 文本,但语言本身并不要求字符串一定是合法 UTF-8。
这会带来几个非常重要的结论:
len(s)返回字节数,不一定是字符数;s[i]访问的是第i个字节;for range遍历字符串时得到的是 rune;- 字符串不能直接修改某一个字节;
- 字符串拼接会生成新的字符串值;
- 需要修改文本时,通常转换成
[]byte或[]rune; strings.Builder适合高效构建字符串。
本章按照“官方定义 → 实际代码 → 运行结果 → 底层解释 → 自定义实现”的顺序,把 Go 字符串真正讲清楚。
字符串的基本声明
最简单的字符串声明如下:
package main import "fmt" func main() { var message string = "Hello, Go" language := "Go" fmt.Println(message) fmt.Println(language) }运行结果:
Hello, Go Go字符串可以是空字符串:
var empty string fmt.Println(empty == "")字符串的零值就是空字符串"",不是nil。
字符串字面量
Go 支持两种字符串字面量:解释型字符串和原始字符串。
双引号解释型字符串
双引号字符串会解析转义字符:
message := "第一行\n第二行\tGo" fmt.Println(message)运行结果:
第一行 第二行 Go常见转义符包括:
| 转义符 | 含义 |
|---|---|
\n | 换行 |
\t | 制表符 |
\\ | 反斜杠 |
\" | 双引号 |
\r | 回车 |
\xNN | 两位十六进制字节 |
\uNNNN | 四位十六进制 Unicode 码点 |
\UNNNNNNNN | 八位十六进制 Unicode 码点 |
反引号原始字符串
反引号字符串不会解析大多数转义字符,并且可以跨行:
message := `第一行\n第二行 第三行` fmt.Println(message)运行结果:
第一行\n第二行 第三行原始字符串非常适合保存正则表达式、JSON、SQL 和多行模板:
sql := ` SELECT id, name FROM users WHERE active = true ` fmt.Println(sql)原始字符串不能包含反引号本身。如果文本中必须出现反引号,需要改用双引号并转义。
字节、字符和 rune
这是 Go 字符串中最容易产生误解的部分。
byte
byte是uint8的别名,表示一个字节,取值范围是 0 到 255。
text := "Go" fmt.Println(text[0]) fmt.Println(text[1])运行结果:
71 111G的 ASCII 编码是 71,o的 ASCII 编码是 111。字符串下标访问得到的是byte,不是字符串。
rune
rune是int32的别名,用来表示一个 Unicode 码点:
var letter rune = '中' fmt.Printf("%c %U %d\n", letter, letter, letter)运行结果:
中 U+4E2D 20013单引号包裹的是 rune 字面量,双引号包裹的是字符串字面量:
letter := '中' // rune text := "中" // string二者不是同一种类型。
字符串长度:字节数和字符数
len返回字符串的字节数:
package main import "fmt" func main() { ascii := "Go" chinese := "中国" fmt.Println(len(ascii)) fmt.Println(len(chinese)) }运行结果:
2 6ASCII 字符通常占 1 个 UTF-8 字节,而中文字符通常占 3 个 UTF-8 字节。因此"中国"有 2 个 Unicode 码点,但长度是 6 个字节。
如果需要统计 rune 数量,可以使用utf8.RuneCountInString:
package main import ( "fmt" "unicode/utf8" ) func main() { text := "Go 中国" fmt.Println("字节数:", len(text)) fmt.Println("rune 数:", utf8.RuneCountInString(text)) }运行结果:
字节数: 9 rune 数: 5这里的 5 个 rune 是G、o、空格、中和国。
需要注意,“rune 数”也不一定等于用户看到的“字符数”。某些视觉字符可能由多个 Unicode 码点组合而成,文本规范化属于更复杂的问题。
按下标访问字符串
字符串可以通过下标访问字节:
package main import "fmt" func main() { text := "Go" fmt.Printf("%c\n", text[0]) fmt.Printf("%c\n", text[1]) }运行结果:
G o对中文字符串直接按下标访问,会得到 UTF-8 编码中的单个字节:
text := "中" fmt.Printf("%x\n", text[0])运行结果:
e4如果想按 Unicode 字符处理,不能使用text[i],应该使用range或转换成[]rune。
字符串不可变
Go 官方规范明确规定,字符串是不可变的。下面的代码无法编译:
text := "Go" // text[0] = 'g' // 编译错误:cannot assign to text[0]如果需要修改 ASCII 字符串,可以先转换成[]byte:
package main import "fmt" func main() { text := "Go" bytes := []byte(text) bytes[0] = 'g' text = string(bytes) fmt.Println(text) }运行结果:
go如果需要修改中文等 Unicode 文本,应该转换成[]rune:
package main import "fmt" func main() { text := "中国" runes := []rune(text) runes[0] = '美' text = string(runes) fmt.Println(text) }运行结果:
美国这两种转换的使用场景不同:
| 转换方式 | 适合场景 |
|---|---|
[]byte | 处理原始字节、ASCII、网络协议、文件数据 |
[]rune | 按 Unicode 码点修改文本 |
string | 不可变文本、键、日志、输出 |
range遍历字符串
使用range遍历字符串时,索引是字节位置,第二个值是 rune:
package main import "fmt" func main() { text := "Go中国" for index, r := range text { fmt.Printf("index=%d rune=%c code=%U\n", index, r, r) } }运行结果:
index=0 rune=G code=U+0047 index=1 rune=o code=U+006F index=2 rune=中 code=U+4E2D index=5 rune=国 code=U+56FD为什么索引从 2 跳到 5?因为中在 UTF-8 中占 3 个字节,下一 个 rune 的起始字节索引就是 5。
只需要 rune 时,可以忽略索引:
for _, r := range text { fmt.Printf("%c ", r) }只需要字节时,使用普通下标循环:
for i := 0; i < len(text); i++ { fmt.Printf("%x ", text[i]) }两种遍历方式没有谁绝对更好,关键是明确自己处理的是字节还是 Unicode 码点。
无效 UTF-8 的字符串
Go 字符串保存的是字节序列,不要求一定是合法 UTF-8:
package main import ( "fmt" "unicode/utf8" ) func main() { data := string([]byte{0xff, 0xfe}) fmt.Println(utf8.ValidString(data)) fmt.Printf("%x\n", []byte(data)) for _, r := range data { fmt.Printf("%U ", r) } }运行
false fffe U+FFFD U+FFFD当range解码到无效 UTF-8 时,会使用 Unicode replacement runeU+FFFD。但原始字节仍然保存在字符串中。
因此:
- 需要处理文本时,可以检查
utf8.ValidString; - 需要处理二进制数据时,不要默认它是 UTF-8;
- 字符串可以保存任意字节,但“字符串内容是合法文本”需要业务自己保证。
字符串拼接
最简单的拼接方式是使用+:
result := "" for i := 0; i < 1000; i++ { result += fmt.Sprint(i) }运行结果:
Hello, Go!少量字符串拼接使用+清晰直接。如果在循环中不断拼接,可能产生很多中间字符串:
result := "" for i := 0; i < 1000; i++ { result += fmt.Sprint(i) }这种写法逻辑正确,但大量拼接时更适合使用strings.Builder。
strings.Builder构建字符串
strings.Builder专门用于高效构建字符串:
package main import ( "fmt" "strings" ) func main() { var builder strings.Builder builder.WriteString("Go") builder.WriteString(" ") builder.WriteString("字符串") fmt.Println(builder.String()) }运行结果:
Go 字符串在循环中使用:
var builder strings.Builder for i := 1; i <= 3; i++ { fmt.Fprintf(&builder, "第 %d 行\n", i) } fmt.Print(builder.String())运行结果:
第 1 行 第 2 行 第 3 行如果大致知道最终大小,可以使用Grow提前预留空间:
var builder strings.Builder builder.Grow(1024)官方文档说明,Builder 的零值可以直接使用,并且复制一个已经使用过的 Builder 是不安全的。通常应该创建一个 Builder,使用指针传递给写入函数,最后调用String。
strings包常用操作
Go 官方strings包提供了大量字符串处理函数:
package main import ( "fmt" "strings" ) func main() { text := " Go makes strings simple " fmt.Println(strings.TrimSpace(text)) fmt.Println(strings.ToUpper(text)) fmt.Println(strings.Contains(text, "strings")) fmt.Println(strings.HasPrefix(text, " Go")) fmt.Println(strings.Count(text, "s")) fmt.Println(strings.ReplaceAll(text, "Go", "Golang")) parts := strings.Fields(text) fmt.Println(parts) fmt.Println(strings.Join(parts, "-")) }运行结果:
Go makes strings simple GO MAKES STRINGS SIMPLE true true 2 Golang makes strings simple [Go makes strings simple] Go-makes-strings-simple常用函数可以这样分类:
| 分类 | 函数 |
|---|---|
| 查找判断 | Contains、HasPrefix、HasSuffix、Index |
| 大小写 | ToUpper、ToLower |
| 空白处理 | TrimSpace、Trim |
| 分割连接 | Split、Fields、Join |
| 替换 | Replace、ReplaceAll |
| 重复 | Repeat |
| 读写 | Reader、Builder |
字符串和数字转换
字符串和数字之间不能直接赋值,需要使用strconv包:
package main import ( "fmt" "strconv" ) func main() { number, err := strconv.Atoi("123") if err != nil { fmt.Println("转换失败:", err) return } text := strconv.Itoa(456) fmt.Println(number + 1) fmt.Println(text + "7") }运行结果:
124 4567常用转换函数:
| 函数 | 作用 |
|---|---|
strconv.Atoi | 字符串转 int |
strconv.Itoa | int 转字符串 |
strconv.ParseInt | 字符串转指定进制整数 |
strconv.FormatInt | 整数格式化为字符串 |
strconv.ParseFloat | 字符串转浮点数 |
strconv.FormatFloat | 浮点数格式化为字符串 |
strconv.ParseBool | 字符串转 bool |
strconv.FormatBool | bool 转字符串 |
不要使用string(number)把整数转换成数字文本:
fmt.Println(string(65)) // 输出 A,不是 "65"string(65)是把 Unicode 码点 65 转换为字符串"A"。需要数字文本时,应该使用strconv.Itoa(65)。
strings.Reader和字符串输入
strings.Reader可以把字符串包装成io.Reader,因此可以和前面学过的输入输出函数配合使用:
package main import ( "fmt" "strings" ) func main() { reader := strings.NewReader("rose 18 95.5") var name string var age int var score float64 _, err := fmt.Fscan(reader, &name, &age, &score) if err != nil { fmt.Println("读取失败:", err) return } fmt.Println(name, age, score) }运行结果:
rose 18 95.5这让测试代码非常方便:不需要真的从终端输入,可以使用固定字符串模拟输入。
bytes.Buffer和字符串
bytes.Buffer适合构建和读取字节数据,也可以转换成字符串:
package main import ( "bytes" "fmt" ) func main() { var buffer bytes.Buffer buffer.WriteString("Go") buffer.WriteByte(' ') buffer.WriteString("bytes") fmt.Println(buffer.String()) }运行结果:
Go bytes选择strings.Builder还是bytes.Buffer:
| 类型 | 更适合 |
|---|---|
strings.Builder | 最终结果是字符串 |
bytes.Buffer | 需要处理字节、实现 Reader/Writer 或混合读写 |
[]byte | 需要直接修改字节内容 |
字符串比较
Go 字符串可以直接比较:
fmt.Println("Go" == "Go") fmt.Println("Go" < "Rust")字符串比较按照字节序列进行字典序比较,不是按本地语言的拼音或自然语言排序规则。
package main import "fmt" func main() { fmt.Println("apple" < "banana") fmt.Println("Go" == "go") }运行结果:
true false如果需要忽略大小写比较,可以使用strings.EqualFold:
fmt.Println(strings.EqualFold("Go", "go")) // true需要按语言环境排序时,应该使用更适合 Unicode 和本地化规则的专门库,不能只依赖<。
自己实现一个简化版字符串
前面我们已经实现过自己的切片。字符串本质上是不可变字节序列,因此可以用一个结构体封装[]byte,实现一个简化版字符串类型。
这个实现有三个目标:
- 保存字符串字节;
- 提供字节长度;
- 提供拼接和只读访问;
- 通过复制避免外部修改内部数据。
package main import "fmt" type MyString struct { data []byte } func NewMyString(text string) MyString { data := make([]byte, len(text)) copy(data, text) return MyString{data: data} } func (s MyString) Len() int { return len(s.data) } func (s MyString) ByteAt(index int) byte { if index < 0 || index >= len(s.data) { panic("index out of range") } return s.data[index] } func (s MyString) String() string { return string(s.data) } func (s MyString) Concat(other MyString) MyString { data := make([]byte, 0, len(s.data)+len(other.data)) data = append(data, s.data...) data = append(data, other.data...) return MyString{data: data} } func main() { first := NewMyString("Go") second := NewMyString(" 字符串") result := first.Concat(second) fmt.Println(result.String()) fmt.Println("字节长度:", result.Len()) fmt.Printf("第一个字节: %c\n", result.ByteAt(0)) }运行结果:
Go 字符串 字节长度: 11 第一个字节: G这里的MyString没有暴露内部data,调用者只能通过方法读取。每次创建和拼接都会复制字节,因此不会和外部传入的[]byte共享可变存储。
但它仍然只是教学版本:
ByteAt访问的是字节,不是 rune;- 没有实现 UTF-8 校验;
- 没有实现
range; - 拼接每次都重新分配内存;
- 没有实现字符串查找、切分和替换。
生产代码应该优先使用内置string和标准库。
常见错误和避坑提醒
误区一:把 len 当成字符数
fmt.Println(len("中国")) // 6,不是 2需要 rune 数量时使用utf8.RuneCountInString,需要按 rune 修改时转换为[]rune。
误区二:直接修改字符串下标
字符串不可变,不能写入s[i]。请转换成[]byte或[]rune后修改,再转回字符串。
误区三:把 string 当成 []rune
字符串下标拿到的是字节,不是 Unicode 字符。中文和 emoji 可能占多个字节。
误区四:用 string(number) 做数字格式化
string(65)得到的是"A"。数字转文本应该使用strconv.Itoa或strconv.FormatInt。
误区五:循环中频繁使用 +
少量拼接使用+没问题;大量循环构建文本时,优先使用strings.Builder。
误区六:默认所有字符串都是合法 UTF-8
Go 字符串可以保存任意字节。处理外部输入或二进制数据时,需要根据业务判断是否调用utf8.ValidString。
误区七:以为 range 的索引是字符序号
range 的索引是 UTF-8 字节偏移量。多字节 rune 会让下一个索引跳过多个字节。
一份可以直接复制的综合示例
下面把字符串统计、rune 遍历、清洗和构建组合起来:
string_demo.go
package main import ( "fmt" "strings" "unicode" "unicode/utf8" ) func main() { source := " Go 语言,Go 字符串 " cleaned := strings.TrimSpace(source) builder := strings.Builder{} builder.Grow(len(cleaned)) runeCount := 0 for _, r := range cleaned { if unicode.IsSpace(r) { continue } builder.WriteRune(r) runeCount++ } result := builder.String() fmt.Println("原文:", source) fmt.Println("处理后:", result) fmt.Println("字节数:", len(result)) fmt.Println("rune 数:", runeCount) fmt.Println("UTF-8 合法:", utf8.ValidString(result)) }运行结果:
原文: Go 语言,Go 字符串 处理后: Go语言,Go字符串 字节数: 22 rune 数: 10 UTF-8 合法: true程序执行过程是:
- 使用
TrimSpace删除首尾空白; - 使用
range按 rune 遍历; - 使用
unicode.IsSpace跳过空白字符; - 使用
strings.Builder构建新字符串; - 使用
len统计字节数; - 使用
utf8.ValidString检查 UTF-8 合法性。
总结
本章我们学习了 Go 字符串的完整基础:
- 字符串是不可变的字节序列;
len返回字节数,不一定是字符数;string[i]访问的是 byte;rune表示 Unicode 码点,range遍历字符串时返回 rune;- 需要修改 ASCII 数据时使用
[]byte,需要按 Unicode 字符修改时使用[]rune; - Go 字符串可以保存任意字节,不保证一定是合法 UTF-8;
strings包提供查找、替换、切分、连接和清洗功能;strconv负责字符串和数字之间的转换;strings.Builder适合高效构建字符串;bytes.Buffer适合需要字节读写的场景;- 字符串比较按照字节序列进行;
- 自定义字符串类型可以帮助理解不可变字节序列和复制语义。
真正理解 Go 字符串之后,就不会把它简单看成“字符数组”。更准确的理解是:字符串是不可变的字节序列,UTF-8 只是最常见的编码方式;byte 负责底层数据,rune 负责 Unicode 码点,range 负责按 UTF-8 解码遍历,而 strings、strconv 和 utf8 包共同完成实际文本处理。
官方资料:
- Go 语言规范:String types
- Go 语言规范:String literals
- Go 官方博客:Strings, bytes, runes and characters in Go
- Go 官方文档:strings
- Go 官方文档:strconv
- Go 官方文档:unicode/utf8