Go 切片、Map 和字符串:写业务代码绕不开的数据结构

本文讲解 Go 中切片、Map 和字符串的常用写法、底层直觉和常见坑,帮助初学者写出更可靠的数据处理代码。

数据结构决定业务代码的日常手感

写 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]

partnums 共享底层数组。修改 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 和字符串就是每天都要用的基本功。

继续阅读

探索更多技术文章

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

全部文章 返回首页