字符串处理:那些被忽略的细节
你可能觉得字符串处理是编程中最简单的事情——不就是拼拼字符串、查查子串、做个替换嘛。直到你遇到中文乱码、emoji 长度不对、URL 编码错误这些坑,才会意识到:字符串处理远没有看起来那么简单。
Go 语言在这方面做得非常贴心。它原生支持 UTF-8 编码,提供了丰富的字符串操作包(strings、strconv、unicode),让你在大多数场景下都能轻松应对。
但有些细节,如果你不了解底层的原理,还是会踩坑。今天我们就来把 Go 的字符串处理彻底搞明白。
Go 字符串的本质
在 Go 中,字符串是一个不可变的字节序列。这句话包含两个重要的信息:
- 不可变:一旦创建,字符串的内容就不能修改
- 字节序列:字符串存储的是字节,而不是字符
package main
import (
"fmt"
)
func main() {
s := "Hello, 世界!"
// len 返回的是字节数,不是字符数
fmt.Println("字节数:", len(s)) // 16
// 遍历字节
for i := 0; i < len(s); i++ {
fmt.Printf("byte[%d] = %02x\n", i, s[i])
}
}
输出:
字节数: 16
byte[0] = 48 (H)
byte[1] = 65 (e)
byte[2] = 6c (l)
byte[3] = 6c (l)
byte[4] = 6f (o)
byte[5] = 2c (,)
byte[6] = 20 (空格)
byte[7] = e4 (世的第一个字节)
byte[8] = b8
byte[9] = 96
byte[10] = e7 (界的第一个字节)
...
看到没?一个中文"世"占了 3 个字节。这就是 UTF-8 编码的特点。
UTF-8 编码规则
| 字符范围 | 字节数 | 例子 |
|---|---|---|
| ASCII (0-127) | 1 字节 | A, B, 0-9 |
| 拉丁扩展 | 2 字节 | é, ü, ñ |
| CJK (中日韩) | 3 字节 | 你, 世, 界 |
| Emoji 和特殊字符 | 4 字节 | 😀, 🎉 |
字符串的底层结构
Go 的字符串在底层由一个结构体表示:
type StringHeader struct {
Data uintptr // 指向字节数组的指针
Len int // 字节数
}
这个结构体只有 16 字节(在 64 位系统上),所以传递字符串的开销很小——你传递的只是指针和长度,不是整个字符串的内容。
byte 和 rune
Go 有两个和字符相关的类型:
byte(uint8的别名):表示一个字节rune(int32的别名):表示一个 Unicode 码点(一个"字符")
package main
import (
"fmt"
)
func main() {
s := "Hello, 世界!"
// 按字节遍历
fmt.Println("=== 按字节遍历 ===")
for i := 0; i < len(s); i++ {
fmt.Printf("%02x ", s[i])
}
fmt.Println()
// 按 rune(字符)遍历
fmt.Println("=== 按字符遍历 ===")
for i, r := range s {
fmt.Printf("index=%d rune=%c (U+%04X)\n", i, r, r)
}
// 获取字符数
fmt.Println("\n字符数:", len([]rune(s))) // 10
}
range 遍历字符串时,Go 会自动按 UTF-8 编码解码,每次返回一个 rune。这是处理 Unicode 字符串最安全的方式。
⚠️ 注意:range 返回的索引是字节的起始位置,不是字符的序号:
s := "你好"
for i, r := range s {
fmt.Printf("i=%d, r=%c\n", i, r)
}
// i=0, r=你
// i=3, r=好(跳过了 3 个字节!)
strings 包详解
strings 包提供了丰富的字符串操作函数。让我们来逐一了解。
查找
package main
import (
"fmt"
"strings"
)
func main() {
s := "Hello, World! Hello, Go!"
// 是否包含子串
fmt.Println(strings.Contains(s, "World")) // true
fmt.Println(strings.Contains(s, "Python")) // false
// 是否以某前缀/后缀开头
fmt.Println(strings.HasPrefix(s, "Hello")) // true
fmt.Println(strings.HasSuffix(s, "Go!")) // true
// 查找子串的位置
fmt.Println(strings.Index(s, "World")) // 7
fmt.Println(strings.Index(s, "Python")) // -1(未找到)
fmt.Println(strings.LastIndex(s, "Hello")) // 14
// 统计出现次数
fmt.Println(strings.Count(s, "Hello")) // 2
}
分割和连接
// 分割
parts := strings.Split("a,b,c,d", ",")
fmt.Println(parts) // [a b c d]
// 按任意空白字符分割
fields := strings.Fields(" hello world go ")
fmt.Println(fields) // [hello world go]
// 按条件分割
f := func(c rune) bool {
return c == ',' || c == ';'
}
result := strings.FieldsFunc("a,b;c,d", f)
fmt.Println(result) // [a b c d]
// 连接
joined := strings.Join([]string{"a", "b", "c"}, "-")
fmt.Println(joined) // a-b-c
替换和修剪
// 替换
s := "Hello, World!"
fmt.Println(strings.Replace(s, "World", "Go", 1)) // Hello, Go!
fmt.Println(strings.ReplaceAll(s, "l", "L")) // HeLLo, WorLd!
// 修剪空白
fmt.Println(strings.TrimSpace(" hello ")) // hello
fmt.Println(strings.Trim("!!!hello!!!", "!")) // hello
fmt.Println(strings.TrimLeft("000123", "0")) // 123
fmt.Println(strings.TrimRight("hello...", ".")) // hello
// 修剪前缀/后缀
fmt.Println(strings.TrimPrefix("HelloWorld", "Hello")) // World
fmt.Println(strings.TrimSuffix("file.txt", ".txt")) // file
大小写转换
s := "Hello, World!"
fmt.Println(strings.ToUpper(s)) // HELLO, WORLD!
fmt.Println(strings.ToLower(s)) // hello, world!
fmt.Println(strings.Title(s)) // Hello, World!(已弃用,见下文)
// 大小写不敏感比较
fmt.Println(strings.EqualFold("hello", "HELLO")) // true
Replacer
当你需要同时替换多个字符串时,strings.Replacer 比多次调用 Replace 更高效:
r := strings.NewReplacer(
"<", "<",
">", ">",
"&", "&",
"\"", """,
)
html := r.Replace(`<script>alert("xss")</script>`)
fmt.Println(html)
// <script>alert("xss")</script>
strconv 包:字符串和其他类型的转换
数字转换
package main
import (
"fmt"
"strconv"
)
func main() {
// 字符串 → 整数
n, err := strconv.Atoi("42")
if err != nil {
fmt.Println("转换失败:", err)
}
fmt.Println(n) // 42
// 整数 → 字符串
s := strconv.Itoa(42)
fmt.Println(s) // "42"
// 更通用的 Parse 系列
i64, _ := strconv.ParseInt("42", 10, 64) // 十进制,64位
fmt.Println(i64) // 42
u64, _ := strconv.ParseUint("FF", 16, 64) // 十六进制
fmt.Println(u64) // 255
f, _ := strconv.ParseFloat("3.14", 64)
fmt.Println(f) // 3.14
b, _ := strconv.ParseBool("true")
fmt.Println(b) // true
// Format 系列
fmt.Println(strconv.FormatInt(255, 16)) // "ff"
fmt.Println(strconv.FormatInt(255, 2)) // "11111111"
fmt.Println(strconv.FormatFloat(3.14, 'f', 2, 64)) // "3.14"
}
引号处理
// 给字符串加引号(自动转义)
quoted := strconv.Quote("Hello, \"World\"!")
fmt.Println(quoted) // "Hello, \"World\"!"
// 去掉引号
unquoted, _ := strconv.Unquote(quoted)
fmt.Println(unquoted) // Hello, "World"!
// 判断是否可以打印
fmt.Println(strconv.IsPrint('A')) // true
fmt.Println(strconv.IsPrint('\n')) // false
unicode 包
unicode 包提供了 Unicode 字符的分类和判断功能:
package main
import (
"fmt"
"unicode"
)
func main() {
chars := []rune{'A', 'a', '1', '你', ' ', '!', '\n'}
for _, c := range chars {
fmt.Printf("%c: letter=%v digit=%v space=%v print=%v\n",
c,
unicode.IsLetter(c),
unicode.IsDigit(c),
unicode.IsSpace(c),
unicode.IsPrint(c),
)
}
// 更多判断函数
fmt.Println(unicode.IsUpper('A')) // true
fmt.Println(unicode.IsLower('a')) // true
fmt.Println(unicode.IsPunct('!')) // true
fmt.Println(unicode.IsControl('\n')) // true
// 大小写转换
fmt.Println(unicode.ToUpper('a')) // 65 (A)
fmt.Println(unicode.ToLower('A')) // 97 (a)
}
strings 包中的 Map 函数
strings.Map 可以对字符串中的每个字符做变换:
// 把所有非字母字符去掉
clean := strings.Map(func(r rune) rune {
if unicode.IsLetter(r) {
return r
}
return -1 // -1 表示删除这个字符
}, "Hello, World! 123")
fmt.Println(clean) // HelloWorld
strings.Builder:高效的字符串拼接
当需要拼接大量字符串时,直接用 + 会导致性能问题——每次拼接都创建一个新的字符串。strings.Builder 是更好的选择:
package main
import (
"fmt"
"strings"
"testing"
)
// 方式一:直接拼接(慢)
func concatDirect(n int) string {
s := ""
for i := 0; i < n; i++ {
s += "hello"
}
return s
}
// 方式二:strings.Builder(快)
func concatBuilder(n int) string {
var builder strings.Builder
builder.Grow(n * 5) // 预分配空间
for i := 0; i < n; i++ {
builder.WriteString("hello")
}
return builder.String()
}
// 方式三:strings.Join(快)
func concatJoin(n int) string {
parts := make([]string, n)
for i := 0; i < n; i++ {
parts[i] = "hello"
}
return strings.Join(parts, "")
}
func main() {
n := 10000
result1 := testing.Benchmark(func(b *testing.B) {
for i := 0; i < b.N; i++ {
concatDirect(n)
}
})
result2 := testing.Benchmark(func(b *testing.B) {
for i := 0; i < b.N; i++ {
concatBuilder(n)
}
})
result3 := testing.Benchmark(func(b *testing.B) {
for i := 0; i < b.N; i++ {
concatJoin(n)
}
})
fmt.Printf("直接拼接: %v\n", result1)
fmt.Printf("Builder: %v\n", result2)
fmt.Printf("Join: %v\n", result3)
}
strings.Builder 的优势在于它内部维护一个字节缓冲区,避免了每次拼接都分配新内存。Grow() 方法可以预分配空间,进一步减少扩容次数。
字符串和其他类型的互转
字符串 ↔ 字节切片
s := "Hello, 世界"
// 字符串 → 字节切片
bytes := []byte(s)
fmt.Println(bytes) // [72 101 108 108 111 44 32 228 184 150 30028]
// 字节切片 → 字符串
s2 := string(bytes)
fmt.Println(s2) // Hello, 世界
⚠️ 注意:这两次转换都会复制数据。在 Go 中,字符串是不可变的,所以从字符串转到字节切片时必须复制,否则修改字节切片会影响原来的字符串。
字符串 ↔ rune 切片
s := "Hello, 世界"
// 字符串 → rune 切片
runes := []rune(s)
fmt.Println(runes) // [72 101 108 108 111 44 32 19990 30028]
fmt.Println(len(runes)) // 9(字符数)
// 修改
runes[7] = '你'
runes[8] = '好'
// rune 切片 → 字符串
s2 := string(runes)
fmt.Println(s2) // Hello, 你好
字符串反转
Go 没有内置的字符串反转函数,但我们可以用 rune 切片实现:
func reverseString(s string) string {
runes := []rune(s)
for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
runes[i], runes[j] = runes[j], runes[i]
}
return string(runes)
}
fmt.Println(reverseString("Hello, 世界")) // 界世 ,olleH
实战:文本处理器
让我们用所学知识写一个实用的文本处理工具:
package main
import (
"fmt"
"strings"
"unicode"
)
// TextProcessor 文本处理器
type TextProcessor struct {
text string
}
func NewTextProcessor(text string) *TextProcessor {
return &TextProcessor{text: text}
}
// WordCount 统计单词数
func (tp *TextProcessor) WordCount() int {
return len(strings.Fields(tp.text))
}
// CharCount 统计字符数
func (tp *TextProcessor) CharCount() int {
return len([]rune(tp.text))
}
// LineCount 统计行数
func (tp *TextProcessor) LineCount() int {
if tp.text == "" {
return 0
}
return strings.Count(tp.text, "\n") + 1
}
// Slugify 生成 URL 友好的 slug
func (tp *TextProcessor) Slugify() string {
var builder strings.Builder
for _, r := range strings.ToLower(tp.text) {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
builder.WriteRune(r)
} else if r == ' ' || r == '-' || r == '_' {
builder.WriteRune('-')
}
}
// 去除连续的 -
result := builder.String()
for strings.Contains(result, "--") {
result = strings.ReplaceAll(result, "--", "-")
}
return strings.Trim(result, "-")
}
// Truncate 截断字符串(按字符数)
func (tp *TextProcessor) Truncate(maxLen int, suffix string) string {
runes := []rune(tp.text)
if len(runes) <= maxLen {
return tp.text
}
return string(runes[:maxLen]) + suffix
}
// ExtractEmails 提取邮箱
func (tp *TextProcessor) ExtractEmails() []string {
words := strings.Fields(tp.text)
var emails []string
for _, word := range words {
// 简单的邮箱判断
if strings.Contains(word, "@") && strings.Contains(word, ".") {
at := strings.Index(word, "@")
dot := strings.LastIndex(word, ".")
if at > 0 && dot > at+1 && dot < len(word)-1 {
emails = append(emails, word)
}
}
}
return emails
}
// CamelToSnake 驼峰转蛇形
func CamelToSnake(s string) string {
var builder strings.Builder
for i, r := range s {
if unicode.IsUpper(r) {
if i > 0 {
builder.WriteRune('_')
}
builder.WriteRune(unicode.ToLower(r))
} else {
builder.WriteRune(r)
}
}
return builder.String()
}
// SnakeToCamel 蛇形转驼峰
func SnakeToCamel(s string) string {
parts := strings.Split(s, "_")
var builder strings.Builder
for i, part := range parts {
if i == 0 {
builder.WriteString(strings.ToLower(part))
} else {
builder.WriteString(strings.Title(part))
}
}
return builder.String()
}
func main() {
text := `
Hello World! 这是一段测试文本。
包含多种语言的字符,比如日本語和한국어。
联系邮箱:test@example.com 和 admin@site.org。
还有 camelCase 和 snake_case 的变量名。
`
tp := NewTextProcessor(text)
fmt.Println("=== 文本统计 ===")
fmt.Printf("单词数: %d\n", tp.WordCount())
fmt.Printf("字符数: %d\n", tp.CharCount())
fmt.Printf("行数: %d\n", tp.LineCount())
fmt.Println("\n=== Slugify ===")
fmt.Println(NewTextProcessor("Hello World! Go 语言入门教程").Slugify())
// hello-world-go-语言入门教程
fmt.Println("\n=== 截断 ===")
fmt.Println(NewTextProcessor("这是一段很长的文本需要截断").Truncate(10, "..."))
// 这是一段很长的文本需...
fmt.Println("\n=== 提取邮箱 ===")
for _, email := range tp.ExtractEmails() {
fmt.Println(" ", email)
}
fmt.Println("\n=== 命名转换 ===")
fmt.Println(CamelToSnake("myVariableName")) // my_variable_name
fmt.Println(SnakeToCamel("my_variable_name")) // myVariableName
}
常见陷阱
陷阱 1:中文字符串截取
// ❌ 按字节截取会导致乱码
s := "你好世界"
fmt.Println(s[:6]) // "你好"(刚好 6 字节,碰巧正确)
fmt.Println(s[:4]) // "你" + 乱码(截断了第二个字符的一半)
// ✅ 按 rune 截取
runes := []rune(s)
fmt.Println(string(runes[:2])) // "你好"(始终正确)
陷阱 2:字符串比较
// 这两个字符串看起来一样,但可能不同
s1 := "café" // é 是一个字符(U+00E9)
s2 := "café" // e + 组合重音符(U+0301)
fmt.Println(s1 == s2) // false!
// 需要用 unicode/norm 包做规范化
import "golang.org/x/text/unicode/norm"
fmt.Println(norm.NFC.String(s1) == norm.NFC.String(s2)) // true
陷阱 3:修改字符串
// ❌ 不能修改字符串
s := "hello"
// s[0] = 'H' // 编译错误!
// ✅ 转成字节切片再修改
bytes := []byte(s)
bytes[0] = 'H'
s = string(bytes) // "Hello"
小结
今天我们深入学习了 Go 的字符串处理:
- 字符串本质:不可变的字节序列,UTF-8 编码
- byte vs rune:byte 是字节,rune 是 Unicode 码点
- strings 包:查找、分割、替换、修剪等操作
- strconv 包:字符串和数字/布尔值的互转
- unicode 包:字符分类和判断
- strings.Builder:高效的字符串拼接
- 类型转换:字符串 ↔ 字节切片 ↔ rune 切片
- 常见陷阱:中文截取、Unicode 规范化、字符串不可变
Go 的字符串处理设计得非常优雅。理解了底层的 UTF-8 编码原理,你就能避免大多数常见的坑。
练习时间
- 回文检测:写一个函数判断字符串是否是回文(忽略大小写和标点)
- 文本统计器:统计一个文件中每个单词的出现频率
- Markdown 解析器:解析 Markdown 中的标题和链接
- Unicode 规范化:实现一个函数,把各种 Unicode 等价字符统一化
- Levenshtein 距离:计算两个字符串之间的编辑距离
下一篇预告
下一篇文章,我们将学习 加密与安全。从哈希算法到对称加密,从数字签名到 HTTPS,让你写出安全可靠的 Go 程序。
我们下篇见!👋
参考资料:
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。