Golang

关注公众号 jb51net

关闭
首页 > 脚本专栏 > Golang > Go string 字符串格式化

Go string 字符串处理与格式化详解从底层原理到工程实践

作者:XMYX-0

这篇文章给大家介绍Go string字符串处理与格式化详解从底层原理到工程实践,本文结合实例代码给大家介绍的非常详细,对大家的学习或工作具有一定的参考借鉴价值,需要的朋友参考下吧

Go string 字符串处理与格式化:从底层原理到工程实践

在 Go 开发中,string 几乎无处不在:

很多 Go 初学者觉得字符串“没什么”,直到线上开始出现:

这时候才发现:

string 不是“文本”,而是 Go 中一套非常讲究性能与不可变性的设计。

这一篇,我们就把 Go 的字符串体系看看。

核心概念

string 到底解决什么问题?

本质上:

string 是 Go 用来表达“只读字节序列”的类型。

注意:

不是“字符序列”。

这是很多问题的根源。

Go 官方定义:

type string struct {
    data *byte
    len  int
}

它本质上:

也就是说:

"hello"

和:

[]byte{104,101,108,108,111}

本质非常接近。

package main
import (
	"fmt"
)
func main() {
	a := "hello"
	fmt.Println(a)         // 打印字符串
	fmt.Println(&a)        // 打印字符串的地址
	fmt.Println([]byte(a)) // 将字符串转换为字节切片并打印
}

输出:

hello
0xc000012090
[104 101 108 108 111]

为什么 Go 的 string 是不可变的?

这是 Go 的核心设计之一。

原因非常重要:

为了共享内存

例如:

package main
import "fmt"
func main() {
	a := "hello world"
	b := a[:5]
	fmt.Println(b) // hello
}

这里:

b == "hello"

Go 不会复制内存。

而是:

如果 string 可变:

那么:

b[0] = 'H'

会影响:

a

这会导致:

因此:

Go 通过“不可变”换取了字符串共享与零拷贝能力。

小结

string 的核心设计思想:

这也是 Go 非常“工程化”的设计体现。

package main
import (
	"fmt"
)
func main() {
	a := "hello world"
	b := a[:5]
	fmt.Println(b) // hello
	bytes := []byte(b)
	// 修改
	bytes[0] = 'H'
	// 转回 string
	b = string(bytes)
	fmt.Println(b) // Hello
	fmt.Println(a) // hello world
}

输出:

hello
Hello
hello world

基础使用示例

最简单的字符串处理

package main
import (
	"fmt"
	"strings"
)
func main() {
	// 原始字符串
	message := "hello golang"
	// 转大写
	upper := strings.ToUpper(message)
	// 判断包含
	contains := strings.Contains(message, "go")
	// 替换字符串
	replaced := strings.ReplaceAll(message, "golang", "world")
	fmt.Println("原:", message)
	fmt.Println("大写:", upper)
	fmt.Println("包含:", contains)
	fmt.Println("替换:", replaced)
}

输出:

原: hello golang
大写: HELLO GOLANG
包含: true
替换: hello world

strings 包为什么这么重要?

Go 把字符串操作全部放在:

strings

包中。

而不是作为对象方法。

例如:

strings.Split()     // 分割字符串
strings.TrimSpace() // 去除字符串前后空格
strings.HasPrefix() // 判断字符串是否以某个前缀开头
strings.Builder     // 字符串拼接

这是 Go 的设计哲学:

类型尽量简单,能力通过 package 扩展。

常用字符串函数速查

函数作用
strings.Contains是否包含
strings.Split分割
strings.Join拼接
strings.TrimSpace去空格
strings.ReplaceAll全量替换
strings.HasPrefix前缀判断
strings.HasSuffix后缀判断
strings.Builder高性能拼接

进阶使用示例

场景一:高性能字符串拼接

很多人会这样写:

package main
import "fmt"
func main() {
	result := ""
	for i := 0; i < 10; i++ {
		result += "go"
	}
	fmt.Println(result)
}

输出:

gogogogogogogogogogo

这是性能灾难。

因为 string 不可变。

每次:

+=

都会:

时间复杂度:

O(n²)

正确写法:strings.Builder

package main
import (
	"fmt"
	"strings"
)
func main() {
	var builder strings.Builder
	for i := 0; i < 10; i++ {
		builder.WriteString("go")
	}
	result := builder.String()
	fmt.Println(len(result))
}

Builder 为什么快?

因为:

类似:

bytes.Buffer

但:

strings.Builder

专门针对字符串优化。

小结

大量拼接时:

这是 Go 里非常经典的性能优化。

场景二:处理中文字符串

这是线上高危区。

看代码:

package main
import "fmt"
func main() {
	s := "你好"
	fmt.Println(len(s))
}

输出:

6

很多人懵了。

为什么不是 2?

原因:len 统计的是字节数

UTF-8 中:

你 -> 3字节
好 -> 3字节

所以:

6

是正确的。

正确统计中文字符数

package main
import (
	"fmt"
	"unicode/utf8"
)
func main() {
	s := "你好"
	fmt.Println(utf8.RuneCountInString(s))
}

输出:

2

rune 到底是什么?

rune == int32

表示:

Unicode 码点。

Go 中:

for range

默认按 rune 遍历。

例如:

package main
import "fmt"
func main() {
	s := "你好Go"
	for index, char := range s {
		fmt.Printf("%d -> %c\n", index, char)
	}
}

输出:

0 -> 你
3 -> 好
6 -> G
7 -> o

注意:

index 是字节偏移。

不是字符下标。

小结

Go 字符串:

这是理解 Go 字符串的关键。

场景三:格式化输出

Go 的格式化核心:

fmt.Sprintf

例如:

package main
import "fmt"
func main() {
	name := "zhangsan"
	age := 18
	result := fmt.Sprintf(
		"name=%s age=%d",
		name,
		age,
	)
	fmt.Println(result)
}

输出:

name=zhangsan age=18

常见格式化占位符

占位符含义
%s字符串
%d整数
%f浮点
%v默认格式
%+v带字段名
%#vGo 语法格式
%T类型

%+v 与 %#v 非常实用

type User struct {
	Name string
	Age  int
}
fmt.Printf("%+v\n", user)

输出:

{Name:tom Age:18}

而:

fmt.Printf("%#v\n", user)

输出:

main.User{Name:"tom", Age:18}

调试时极其方便。

常见错误与坑(重点)

坑一:直接修改 string

错误示例

package main
func main() {
	s := "hello"
	s[0] = 'H'
}

编译报错:

cannot assign to s[0]

为什么会错?

因为:

string 是只读的

底层设计就是不可变。

这样:

正确写法

转成:

[]byte

修改。

package main
import "fmt"
func main() {
	s := "hello"
	bytes := []byte(s)
	bytes[0] = 'H'
	fmt.Println(string(bytes))
}

输出:

Hello

小结

修改字符串:

string -> []byte -> 修改 -> string

这是标准流程。

坑二:中文截断乱码

这是线上最常见问题之一。

错误示例

package main
import "fmt"
func main() {
	s := "你好世界"
	fmt.Println(s[:4])
}

可能输出乱码。

为什么会错?

因为:

[:4]

按字节切。

UTF-8 中文:

一个中文 = 3字节

截断后:

可能只截到半个字符。

导致:

UTF-8 非法。

正确写法

按 rune 处理。

package main
import "fmt"
func main() {
	s := "你好世界"
	runes := []rune(s)
	fmt.Println(string(runes[:2]))
}

输出:

你好

小结

涉及:

必须优先考虑:

rune

不要直接按 byte 切。

坑三:循环拼接导致性能雪崩

错误示例

result := ""
for i := 0; i < 100000; i++ {
	result += "go"
}

为什么会错?

因为 string 不可变。

每次:

+=

都会:

形成:

O(n²)

复杂度。

数据量一大:

CPU 飙升。

正确写法

var builder strings.Builder
for i := 0; i < 100000; i++ {
	builder.WriteString("go")
}

更进一步:预分配容量

builder.Grow(200000)

可以减少扩容次数。

这是很多高性能项目的优化技巧。

底层原理解析(核心)

string 底层结构

Go runtime:

type stringStruct struct {
	str unsafe.Pointer
	len int
}

核心:

没有 capacity(容量)。

因为:

不可变。

slice 为什么有 cap?

因为 slice 可扩容。

type slice struct {
	array unsafe.Pointer
	len   int
	cap   int
}

而 string:

不允许修改。

所以不需要:

cap

这是一个非常经典的设计细节。

string 切片为什么高效?

例如:

s := "hello world"
sub := s[:5]

不会复制数据。

只是:

新建 string header

底层仍指向原数据。

这会带来什么问题?

可能导致:

“大字符串内存泄漏”。

例如:

func getSmallString() string {
	big := loadHugeText()
	return big[:10]
}

虽然只返回 10 字节。

但:

整个大字符串仍被引用。

GC 无法释放。

正确做法

主动复制。

result := string([]byte(big[:10]))

这样:

才会创建新内存。

这是非常隐蔽的线上问题

很多:

都踩过。

strings.Builder 底层原理

Builder 本质:

type Builder struct {
	buf []byte
}

核心思想:

关键优化:

unsafe.Pointer

避免最终转换复制。

为什么 Builder 不允许拷贝?

官方文档:

Do not copy a non-zero Builder.

因为:

内部 buffer 被共享。

拷贝后:

可能导致数据错乱。

对比与扩展

string vs []byte

对比项string[]byte
是否可变不可变可变
是否适合文本一般
修改性能
内存共享支持支持
网络 IO一般更适合

strings.Builder vs bytes.Buffer

对比项strings.Builderbytes.Buffer
面向 string
支持 byte一般
性能更优更通用
推荐场景字符串拼接二进制处理

fmt.Sprintf vs Builder

很多人滥用:

fmt.Sprintf

例如:

result := fmt.Sprintf("%s%s%s", a, b, c)

其实:

性能不如:

builder.WriteString()

因为:

fmt 是反射型格式化框架。

功能强。

但成本高。

如何选择?

只拼接字符串

用:

strings.Builder

需要复杂格式化

用:

fmt.Sprintf

网络 IO / 二进制

用:

bytes.Buffer

最佳实践

涉及 Unicode 时优先考虑 rune

不要默认:

len == 字符数

这是很多 bug 的根源。

高频拼接必须使用 Builder

尤其:

少用 fmt.Sprintf 做简单拼接

例如:

a + b

比:

fmt.Sprintf("%s%s", a, b)

更轻量。

大字符串切片后注意内存引用

很多线上内存泄漏:

本质都是:

小字符串引用大对象

统一 UTF-8 编码

Go 默认 UTF-8 非常友好。

不要混入:

否则问题会非常复杂。

思考与升华

如果让你设计 string,你会怎么做?

你需要考虑:

实际上:

Go 的 string 设计,本质是在:

性能
安全
简洁

之间做平衡。

为什么 Go 不把 string 设计成字符数组?

因为:

现代字符串:

本质是“编码后的字节流”。

尤其 UTF-8:

字符长度天然不固定。

因此:

按 byte 存储。

按 rune 解释。

这是最合理的工程方案。

一个非常重要的思想

很多语言:

字符串是“字符集合”。

而 Go:

字符串是:

只读字节序列

字符语义只是:

UTF-8 解释结果。

这个认知转变,非常关键。

点睛总结

Go 的 string,看似简单。

实际上背后体现的是:

“通过不可变换取共享,通过 UTF-8 兼容世界,通过简单结构换取高性能。”

真正理解 string。

你才真正开始理解 Go 的设计哲学。

到此这篇关于Go string 字符串处理与格式化详解从底层原理到工程实践的文章就介绍到这了,更多相关Go string 字符串格式化内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

您可能感兴趣的文章:
阅读全文