数据结构决定业务代码的日常手感
写 Go 后端时,你每天都会处理列表、键值表和文本。用户列表是切片,配置项是 Map,HTTP 请求里的路径、标题和 JSON 字段大多是字符串。语法上它们都很容易上手,但如果不了解一些底层直觉,很容易写出表面能跑、遇到边界就出问题的代码。
切片看起来像动态数组,但它背后引用了底层数组;Map 使用方便,但遍历顺序不稳定,也不是并发安全的;字符串不可变,len 返回字节数,不是中文字符数。初学阶段只要把这些重点记清楚,就能避开很多坑。
这篇文章会用业务化示例讲切片、Map 和字符串。我们不钻复杂实现细节,只建立足够可靠的工程直觉:什么时候 append,什么时候 copy,如何判断 key 是否存在,如何处理中文字符串,以及如何把这些能力组合成真实函数。
数组和切片不是一回事
Go 有数组:
var nums [3]int
nums[0] = 10
nums[1] = 20
nums[2] = 30
fmt.Println(nums)
数组长度是类型的一部分。[3]int 和 [4]int 是不同类型。真实业务代码里,直接使用数组的机会不多,因为数据长度通常不固定。
更常用的是切片:
nums := []int{10, 20, 30}
nums = append(nums, 40)
fmt.Println(nums)
切片没有固定长度,可以追加元素。你可以把它理解为对底层数组的一段视图,包含指针、长度和容量。查看长度与容量:
names := []string{"小林", "阿周"}
fmt.Println(len(names))
fmt.Println(cap(names))
len 是当前元素个数,cap 是从切片起点到底层数组末尾的容量。大多数业务代码不需要手动管理容量,但理解容量能帮助你看懂 append 的行为。
append 可能复用,也可能换底层数组
看一个例子:
names := []string{"小林", "阿周"}
names = append(names, "老陈")
如果原底层数组还有容量,append 会直接写进去;如果容量不够,它会分配一个更大的底层数组,把旧元素复制过去,再追加新元素。也就是说,append 可能改变切片引用的底层数组。
因此一定要接住 append 的返回值:
names = append(names, "老陈")
不要写:
append(names, "老陈")
这段代码编译不过,因为 Go 不允许你忽略 append 的结果。这个限制很好,它提醒你切片追加后的视图可能已经变化。
如果要预估元素数量,可以用 make 提前分配容量:
users := make([]string, 0, 100)
for i := 0; i < 100; i++ {
users = append(users, fmt.Sprintf("user-%d", i))
}
第一个 0 是长度,第二个 100 是容量。意思是当前没有元素,但预留 100 个位置。这样可以减少扩容次数。
截取切片时要注意共享底层数组
切片可以截取:
nums := []int{1, 2, 3, 4, 5}
part := nums[1:3]
fmt.Println(part) // [2 3]
part 和 nums 共享底层数组。修改 part 会影响 nums:
part[0] = 99
fmt.Println(nums) // [1 99 3 4 5]
这在某些场景很高效,但也容易造成意外。如果你希望得到独立副本,要使用 copy:
part := nums[1:3]
clone := make([]int, len(part))
copy(clone, part)
clone[0] = 99
fmt.Println(nums) // [1 2 3 4 5]
fmt.Println(clone) // [99 3]
真实项目里,当你从一个大切片里截取一小段并长期保存时,最好考虑复制。否则小切片可能一直引用着大底层数组,导致大数组无法被垃圾回收。
nil 切片和空切片
声明但不初始化的切片是 nil:
var names []string
fmt.Println(names == nil) // true
fmt.Println(len(names)) // 0
空切片不是 nil:
names := []string{}
fmt.Println(names == nil) // false
fmt.Println(len(names)) // 0
两者都可以 append,都可以安全遍历。多数业务逻辑只关心长度,不必纠结它是不是 nil。
if len(names) == 0 {
fmt.Println("no names")
}
但在 JSON 输出里可能有差别。nil 切片可能编码为 null,空切片编码为 []。如果 API 明确要求返回空数组,可以初始化为空切片。
type Response struct {
Items []string `json:"items"`
}
resp := Response{Items: []string{}}
这类细节在接口设计里很常见。
Map 是键值表,不是有序表
Map 创建方式:
scores := map[string]int{
"小林": 95,
"阿周": 88,
}
也可以用 make:
scores := make(map[string]int)
scores["小林"] = 95
读取:
score := scores["小林"]
fmt.Println(score)
如果 key 不存在,会返回值类型的零值:
score := scores["不存在"]
fmt.Println(score) // 0
这有时会造成歧义。分数为 0 和 key 不存在都得到 0。要判断 key 是否存在,使用第二个返回值:
score, ok := scores["不存在"]
if !ok {
fmt.Println("score not found")
return
}
fmt.Println(score)
ok 是 Go 里很常见的命名,表示查找是否成功。
删除 key:
delete(scores, "小林")
删除不存在的 key 不会报错。
Map 遍历顺序不稳定
遍历 Map:
for name, score := range scores {
fmt.Println(name, score)
}
输出顺序不保证稳定。你不能依赖它每次都一样。如果需要稳定顺序,先收集 key 并排序:
names := make([]string, 0, len(scores))
for name := range scores {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
fmt.Println(name, scores[name])
}
这在生成报表、测试输出、静态页面内容时非常重要。测试里如果直接比较 Map 遍历输出,可能本地通过,CI 偶尔失败。
Map 也不是并发安全的。如果多个 goroutine 同时读写同一个 Map,会出问题。入门阶段先记住:并发场景下要用锁、channel 或 sync.Map,不要随便共享普通 Map。
字符串不可变
Go 字符串是不可变的。你不能修改字符串中的某个字节:
name := "hello"
// name[0] = 'H' // 编译错误
如果要构造字符串,可以使用 strings.Builder:
var b strings.Builder
b.WriteString("Hello")
b.WriteString(", ")
b.WriteString("Go")
fmt.Println(b.String())
少量拼接用 + 没问题:
message := "你好," + name
循环里大量拼接更建议用 strings.Builder,避免反复分配新字符串。
切分和判断字符串常用 strings 包:
input := " go,php,python "
input = strings.TrimSpace(input)
parts := strings.Split(input, ",")
for _, part := range parts {
fmt.Println(strings.TrimSpace(part))
}
判断前缀、后缀、包含:
strings.HasPrefix(path, "/api/")
strings.HasSuffix(filename, ".go")
strings.Contains(title, "Go")
这些函数比手写下标判断更清楚,也更不容易出错。
中文字符串与 rune
len("小林") 返回字节数,不是字符数:
fmt.Println(len("小林"))
如果要统计字符数量,可以转成 []rune:
name := "小林"
fmt.Println(len([]rune(name)))
遍历字符:
for index, r := range "小林" {
fmt.Printf("index=%d char=%c\n", index, r)
}
这里的 index 仍然是字节位置,不是第几个字符。r 是 rune。
写中文内容截断时尤其要小心。不要直接按字节截:
func shorten(s string, max int) string {
runes := []rune(s)
if len(runes) <= max {
return s
}
return string(runes[:max]) + "..."
}
这段代码可以避免把一个中文字符截坏。真实产品里还要考虑 emoji、组合字符和显示宽度,但入门阶段先做到不按字节粗暴截中文。
组合示例:统计标签出现次数
假设一批文章都有标签,我们想统计每个标签出现次数,并按标签名排序输出。
package main
import (
"fmt"
"sort"
"strings"
)
func countTags(posts [][]string) map[string]int {
counts := make(map[string]int)
for _, tags := range posts {
for _, tag := range tags {
tag = strings.TrimSpace(tag)
if tag == "" {
continue
}
counts[tag]++
}
}
return counts
}
func sortedKeys(counts map[string]int) []string {
keys := make([]string, 0, len(counts))
for key := range counts {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
func main() {
posts := [][]string{
{"Go", "Backend", "Tutorial"},
{"Go", "HTTP"},
{"Backend", "Database"},
}
counts := countTags(posts)
for _, tag := range sortedKeys(counts) {
fmt.Printf("%s: %d\n", tag, counts[tag])
}
}
这个小例子把切片、Map、字符串处理和排序放在一起。它没有复杂算法,却很像真实内容系统、日志分析或后台报表里的代码。
注意几个细节:countTags 返回 Map,因为我们需要通过标签快速累计;sortedKeys 返回切片,因为展示时需要稳定顺序;处理标签前先 TrimSpace,避免 "Go" 和 " Go " 被当成不同标签;空字符串直接跳过。
小结
切片、Map 和字符串是 Go 业务代码里最常见的三类数据。切片适合有序列表,但要知道截取会共享底层数组;Map 适合快速查找,但遍历无序,也不能在并发读写时裸用;字符串不可变,处理中文时要区分字节和 rune。
入门阶段不要急着背底层实现,只要把几个工程判断记住:追加切片要接住返回值;长期保存子切片时考虑复制;判断 Map key 是否存在要用 value, ok;需要稳定输出时对 key 排序;中文截断不要直接按字节。
这些知识会反复出现。等你开始写 HTTP 接口、读写 JSON、处理数据库结果时,切片、Map 和字符串就是每天都要用的基本功。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。