GO [ 字符串 ]
前面我们已经学习了 Go 的变量、常量、数据类型、输入输出、条件控制以及切片。接下来开始学习 Go 语言中使用频率非常高的一种类型字符串。很多初学者会把字符串理解成“字符组成的数组”。这个说法容易入门但在 Go 中并不准确。按照 Go 官方语言规范 的定义字符串是一个可能为空的字节序列字符串的长度是字节数并且字符串一旦创建内容就不能被修改。简单理解Go 字符串本质上是只读的字节序列它可以保存 UTF-8 文本但语言本身并不要求字符串一定是合法 UTF-8。这会带来几个非常重要的结论len(s)返回字节数不一定是字符数s[i]访问的是第i个字节for range遍历字符串时得到的是 rune字符串不能直接修改某一个字节字符串拼接会生成新的字符串值需要修改文本时通常转换成[]byte或[]runestrings.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 字符串中最容易产生误解的部分。bytebyte是uint8的别名表示一个字节取值范围是 0 到 255。text : Go fmt.Println(text[0]) fmt.Println(text[1])运行结果71 111G的 ASCII 编码是 71o的 ASCII 编码是 111。字符串下标访问得到的是byte不是字符串。runerune是int32的别名用来表示一个 Unicode 码点var letter rune 中 fmt.Printf(%c %U %d\n, letter, letter, letter)运行结果中 U4E2D 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.RuneCountInStringpackage 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 字符串可以先转换成[]bytepackage main import fmt func main() { text : Go bytes : []byte(text) bytes[0] g text string(bytes) fmt.Println(text) }运行结果go如果需要修改中文等 Unicode 文本应该转换成[]runepackage main import fmt func main() { text : 中国 runes : []rune(text) runes[0] 美 text string(runes) fmt.Println(text) }运行结果美国这两种转换的使用场景不同转换方式适合场景[]byte处理原始字节、ASCII、网络协议、文件数据[]rune按 Unicode 码点修改文本string不可变文本、键、日志、输出range遍历字符串使用range遍历字符串时索引是字节位置第二个值是 runepackage main import fmt func main() { text : Go中国 for index, r : range text { fmt.Printf(index%d rune%c code%U\n, index, r, r) } }运行结果index0 runeG codeU0047 index1 runeo codeU006F index2 rune中 codeU4E2D index5 rune国 codeU56FD为什么索引从 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-8package 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 UFFFD UFFFD当range解码到无效 UTF-8 时会使用 Unicode replacement runeUFFFD。但原始字节仍然保存在字符串中。因此需要处理文本时可以检查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字符串转 intstrconv.Itoaint 转字符串strconv.ParseInt字符串转指定进制整数strconv.FormatInt整数格式化为字符串strconv.ParseFloat字符串转浮点数strconv.FormatFloat浮点数格式化为字符串strconv.ParseBool字符串转 boolstrconv.FormatBoolbool 转字符串不要使用string(number)把整数转换成数字文本fmt.Println(string(65)) // 输出 A不是 65string(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.EqualFoldfmt.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-8Go 字符串可以保存任意字节。处理外部输入或二进制数据时需要根据业务判断是否调用utf8.ValidString。误区七以为 range 的索引是字符序号range 的索引是 UTF-8 字节偏移量。多字节 rune 会让下一个索引跳过多个字节。一份可以直接复制的综合示例下面把字符串统计、rune 遍历、清洗和构建组合起来string_demo.gopackage 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]访问的是 byterune表示 Unicode 码点range遍历字符串时返回 rune需要修改 ASCII 数据时使用[]byte需要按 Unicode 字符修改时使用[]runeGo 字符串可以保存任意字节不保证一定是合法 UTF-8strings包提供查找、替换、切分、连接和清洗功能strconv负责字符串和数字之间的转换strings.Builder适合高效构建字符串bytes.Buffer适合需要字节读写的场景字符串比较按照字节序列进行自定义字符串类型可以帮助理解不可变字节序列和复制语义。真正理解 Go 字符串之后就不会把它简单看成“字符数组”。更准确的理解是字符串是不可变的字节序列UTF-8 只是最常见的编码方式byte 负责底层数据rune 负责 Unicode 码点range 负责按 UTF-8 解码遍历而 strings、strconv 和 utf8 包共同完成实际文本处理。官方资料Go 语言规范String typesGo 语言规范String literalsGo 官方博客Strings, bytes, runes and characters in GoGo 官方文档stringsGo 官方文档strconvGo 官方文档unicode/utf8