字符串处理:那些被忽略的细节

深入理解 Go 的字符串处理,从底层编码到 strings/strconv/unicode 包的全面实战

字符串处理:那些被忽略的细节

你可能觉得字符串处理是编程中最简单的事情——不就是拼拼字符串、查查子串、做个替换嘛。直到你遇到中文乱码、emoji 长度不对、URL 编码错误这些坑,才会意识到:字符串处理远没有看起来那么简单。

Go 语言在这方面做得非常贴心。它原生支持 UTF-8 编码,提供了丰富的字符串操作包(stringsstrconvunicode),让你在大多数场景下都能轻松应对。

但有些细节,如果你不了解底层的原理,还是会踩坑。今天我们就来把 Go 的字符串处理彻底搞明白。

Go 字符串的本质

在 Go 中,字符串是一个不可变的字节序列。这句话包含两个重要的信息:

  1. 不可变:一旦创建,字符串的内容就不能修改
  2. 字节序列:字符串存储的是字节,而不是字符
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 有两个和字符相关的类型:

  • byteuint8 的别名):表示一个字节
  • runeint32 的别名):表示一个 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(
	"<", "&lt;",
	">", "&gt;",
	"&", "&amp;",
	"\"", "&quot;",
)

html := r.Replace(`<script>alert("xss")</script>`)
fmt.Println(html)
// &lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;

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 的字符串处理:

  1. 字符串本质:不可变的字节序列,UTF-8 编码
  2. byte vs rune:byte 是字节,rune 是 Unicode 码点
  3. strings 包:查找、分割、替换、修剪等操作
  4. strconv 包:字符串和数字/布尔值的互转
  5. unicode 包:字符分类和判断
  6. strings.Builder:高效的字符串拼接
  7. 类型转换:字符串 ↔ 字节切片 ↔ rune 切片
  8. 常见陷阱:中文截取、Unicode 规范化、字符串不可变

Go 的字符串处理设计得非常优雅。理解了底层的 UTF-8 编码原理,你就能避免大多数常见的坑。

练习时间

  1. 回文检测:写一个函数判断字符串是否是回文(忽略大小写和标点)
  2. 文本统计器:统计一个文件中每个单词的出现频率
  3. Markdown 解析器:解析 Markdown 中的标题和链接
  4. Unicode 规范化:实现一个函数,把各种 Unicode 等价字符统一化
  5. Levenshtein 距离:计算两个字符串之间的编辑距离

下一篇预告

下一篇文章,我们将学习 加密与安全。从哈希算法到对称加密,从数字签名到 HTTPS,让你写出安全可靠的 Go 程序。

我们下篇见!👋


参考资料:

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页