01.04 数组、切片与文本:组织会话消息
本章示例给出按语言规则推导的预期结果;学习时请逐步核对,并记录自己的实际结果。
本章怎样接上前面的知识
上一章已经能够判断一条文本是否合规,并用普通函数表达规则。现在需求变成:一个会话里有多条消息,需要依次检查、选择、展示;预览中文和表情时还不能随意截坏文本。
先修是 01.01–01.03 的起步部分:变量、类型、字符串、if、有限 for、普通函数、参数与多个返回值。递归和闭包不是本章前提。本章会从零解释数组、切片和 range,不要求提前理解结构体或指针语法。
会话 c-a 及用户 u-a、u-b 都是虚构身份。早期先用字符串保存消息正文,暂不包含发送者、时间和永久消息标识;下一章会补上这些信息。本地列表中的位置只表示本次排列中的位置,不能直接充当长期消息 ID。
| 学习层次 | 小节 | 学完留下什么证据 |
|---|---|---|
| 起步 | 一、固定数组 | 画出下标与元素对应关系,解释赋值复制 |
| 起步 | 二、切片与追加 | 区分长度与容量,正确创建和追加列表 |
| 应用 | 三、遍历与空列表 | 写出有明确空结果语义的筛选函数 |
| 第二遍深化 | 四、共享与复制 | 画出谁共享哪块数据,说明修改影响谁 |
| 起步 | 五、字符串与 UTF-8 | 区分字节、码点和用户看到的一组文字 |
| 应用 | 六、文本边界 | 校验编码,做明确以码点计数的预览 |
| 综合 | 七、本地历史与源码对照 | 串起列表、文本规则与失败结果 |
| 验收 | 八、分层练习 | 独立解释、修改和迁移 |
第一次可按一至三节 → 五、六节 → 七节学习,先运行脑中的顺序模型;再读第四节,回到第八节做共享相关练习。第四节仍是进入下一章前需要掌握的基础,只是适合分第二次学习。
代码约定: 可新建 im-collections 目录,按第一章的方法初始化模块 example.com/im-collections。每个“完整程序”单独替换 main.go。标为“片段”的代码只放在指定位置;不要拼接多个含同名变量的例子。本文所有输出都是预期,故意错误程序单列。
一、从一条消息到固定数组
1.1 为什么三个独立变量开始不够用
假设会话里有三条文本:"你好"、"收到"、"稍后聊"。用 first、second、third 三个变量当然能保存它们,但每增加一条消息都要增加名字;同一套检查也难以统一循环。我们需要把同一类数据放进一个有顺序的整体。
元素是整体里的一项值。数组是在一个值中保存固定数量、相同元素类型的数据。[3]string 表示“有 3 个元素,每个元素都是 string 的数组”。方括号里的 3 是长度;后面的 string 是元素类型。
package main
import "fmt"
func main() {
messages := [3]string{"你好", "收到", "稍后聊"}
fmt.Println(len(messages))
fmt.Println(messages[0])
fmt.Println(messages[1])
fmt.Println(messages[2])
}预期依次输出 3、你好、收到、稍后聊,各占一行。[3]string{...} 把类型与初始元素写在一起,这种直接写出复合值的形式称为复合字面量。花括号里的元素以逗号分隔。
1.2 下标从 0 开始,长度不是最后一个下标
messages[i] 表示用整数 i 选择一个元素;i 称为下标或索引。长度为 3 时,合法下标是 0、1、2。
下标 0 1 2
messages ["你好"] ["收到"] ["稍后聊"]| 想访问的内容 | 写法 | 原因 |
|---|---|---|
| 第一条 | messages[0] | 从 0 开始编号 |
| 最后一条 | messages[len(messages)-1] | 仅在长度大于 0 时成立 |
| 长度 | len(messages) | 得到元素数 3 |
| 第四条 | messages[3] | 数组只有三项,已经越界 |
合法范围写成 0 <= i && i < len(messages)。数学上也写作 [0, len(messages)):包含左端 0,不包含右端长度,这叫半开区间。后面的切片范围会反复用到它。
上一章学过的循环现在可以处理不同消息。下面是 main 内片段,要求已有 messages 并导入 fmt:
for i := 0; i < len(messages); i++ {
fmt.Printf("下标 %d:%s\n", i, messages[i])
}fmt.Printf 按第一个字符串中的格式说明输出:%d 接收整数并以十进制显示,%s 接收字符串并显示文本,\n 表示换行。后面的 i 与 messages[i] 依次对应这两个占位符。
循环依次访问 0、1、2;当 i 变成 3 时条件为假,停止访问。如果误写成 i <= len(messages),就会多执行一次,并在读取下标 3 时出错。
1.3 零值表示已存在的元素,不表示已经收到消息
完整程序:
package main
import "fmt"
func main() {
var messages [3]string
fmt.Printf("长度=%d,第一项=%q\n", len(messages), messages[0])
messages[0] = "你好"
fmt.Printf("第一项=%q,第二项=%q\n", messages[0], messages[1])
}预期:
长度=3,第一项=""
第一项="你好",第二项=""声明数组后,三个元素就已经存在,每个字符串元素的零值都是 ""。%q 是带引号且必要时带转义的显示格式,方便看清空字符串和不可见字符。
这里的“存在三个元素”是语言事实;“收到三条消息”是业务事实,两者不能混为一谈。如果规则禁止空正文,这个数组暂时只有第一项能作为合规正文,其余位置只是零值。动态消息列表用切片更容易表达“当前恰好有多少条”。
初始值也可以只写一部分,例如 [3]string{"你好"};其余两项自动取空字符串。显式元素超过固定长度才是错误。
数组长度属于类型的一部分:[2]string 和 [3]string 是不同类型。messages[1] = "收到" 只能修改一个已存在元素,不能改变数组长度。append 的目标是切片,不能直接把数组当作可增长列表传给它。
1.4 数组赋值复制元素值
先回忆 b := a 对一个整数的作用:把 a 当时的值交给 b,后续给 b 赋别的整数不会改变 a。数组赋值同样复制数组值,其中每个元素值都被复制。
package main
import "fmt"
func main() {
original := [2]string{"你好", "收到"}
backup := original
backup[0] = "已编辑"
fmt.Println(original)
fmt.Println(backup)
}预期:
[你好 收到]
[已编辑 收到]两个数组有独立的元素位置。修改 backup[0] 不会替换 original[0]。这不等于“语言保证把每段字符串的全部字节重新复制一份”:字符串值本身不可修改,内部字节存储如何复用不影响这里的结果。下一章遇到含指针、切片等元素的复合值时,还要继续区分元素值复制与更深层数据共享。
1.5 先检查范围,再访问
完整程序用变量表示待查询位置:
package main
import "fmt"
func main() {
messages := [3]string{"你好", "收到", "稍后聊"}
index := 3
if index < 0 || index >= len(messages) {
fmt.Println("拒绝:消息位置不存在")
return
}
fmt.Println(messages[index])
}预期打印拒绝信息;没有发生越界读取。检查应该位于 messages[index] 之前,读取之后再判断已经太晚。
需要区分两类故意错误:
- 对
[3]string直接写常量下标messages[3],编译时能确定越界,程序不能通过编译。 - 用变量
index := 3再读messages[index],运行到该访问时会发生范围错误,触发panic。此处先把它理解为运行不能按正常路径继续;第 01.07 章再讲异常边界。
小练习: 长度为 0 的数组是否存在最后一项?不存在;len(a)-1 得到 -1,因此“取最后一项”永远需要先确认非空。
二、切片、长度、容量和追加
2.1 消息条数变化时,用切片描述当前列表
切片用一个值描述底层数组的一段可访问元素。先把它看成“目前能操作哪些元素的窗口”;第四节再拆开窗口与底层数据的关系。
切片类型 []string 的方括号里没有固定长度。普通无显式下标的切片字面量,其初始长度和容量都等于写出的元素数。下面是完整程序:
package main
import "fmt"
func main() {
messages := []string{"你好", "收到"}
fmt.Println(len(messages))
messages = append(messages, "稍后聊")
fmt.Println(len(messages))
fmt.Println(messages)
}预期长度从 2 变为 3,最后打印 [你好 收到 稍后聊]。append 是内置函数,接收原切片和要加入的元素,返回表示追加结果的切片值。这里把结果重新赋给 messages。
[2]string{...} 与 []string{...} 虽然都能暂时装两条消息,前者的类型固定了长度,后者的类型没有固定长度。选择依据是数据性质,而不是恰好当前有几条。
2.2 len 是当前长度,cap 是这一窗口可扩展到的容量
len(s) 回答“当前有多少个可直接索引的元素”。cap(s) 回答“从当前起点开始,沿底层数组最多可以扩展到多少个元素”。容量也是元素数,不是字节数,也不是程序还剩多少内存。
make 可以创建指定长度、可选指定容量的切片:
messages := make([]string, 2, 4)这是 main 内片段。第一个参数是类型,第二个是长度,第三个是容量;得到长度 2、容量 4 的切片,两个当前元素都是空字符串。
底层位置 0 1 2 3
初始值 "" "" "" ""
当前可索引 └──────┘ len = 2
从起点可扩展 └────────────────────┘ cap = 4| 操作 | 是否合法 | 说明 |
|---|---|---|
messages[0] = "你好" | 合法 | 下标小于长度 |
messages[1] = "收到" | 合法 | 下标小于长度 |
messages[2] = "稍后聊" | 不合法 | 容量足够不意味着该下标已在长度内 |
messages = append(messages, "稍后聊") | 合法 | 追加返回新的长度 |
索引总看长度。 容量只是底层存储可利用的范围。make([]string, 4) 省略容量时,长度和容量都是 4;它不是“空列表,预留四项”。空列表预留四项应写 make([]string, 0, 4)。
make 的长度不能为负,容量不能小于长度。对来自输入的数量先检查范围,再创建列表;不能把外部给出的巨大数字直接当作分配要求。
2.3 逐条追加与整批追加
完整程序:
package main
import "fmt"
func main() {
messages := make([]string, 0, 4)
messages = append(messages, "你好")
messages = append(messages, "收到", "稍后聊")
more := []string{"晚安"}
messages = append(messages, more...)
fmt.Println(len(messages), cap(messages))
fmt.Println(messages)
}预期:
4 4
[你好 收到 稍后聊 晚安]more... 中的三个点表示把这个切片的元素展开为待追加的一批元素。append(messages, more) 不适用于这里:它试图把 []string 当作单个 string 元素加入,类型不匹配。
| 执行后 | 长度 | 容量 | 当前元素 |
|---|---|---|---|
make | 0 | 4 | 无 |
| 加入“你好” | 1 | 4 | 你好 |
| 再加入两条 | 3 | 4 | 你好、收到、稍后聊 |
展开 more | 4 | 4 | 你好、收到、稍后聊、晚安 |
如果再加入第五条,已有容量不够,append 会使用足够大的新底层数组,并把原元素放入其中。规范没有要求本次容量必须是 8,也没有承诺永远翻倍。程序应依赖“新长度够用、内容符合顺序”的保证,不能把某次观测到的扩容数字写成语言定律。
2.4 取一段消息:左边包含,右边不包含
messages[low:high] 选择下标从 low 到 high-1 的元素,新切片长度是 high-low。messages[1:3] 取原下标 1、2;新切片重新从下标 0 开始访问。
完整程序:
package main
import "fmt"
func main() {
messages := []string{"m-a", "m-b", "m-c", "m-d"}
page := messages[1:3]
fmt.Println(page)
fmt.Println(len(page), cap(page))
fmt.Println(page[0])
}预期:
[m-b m-c]
2 3
m-b原底层有四项,page 从原下标 1 开始,到底层末尾还能覆盖三项,因此容量为 3。它当前的长度仍然只有 2。messages[:2] 省略左界表示从 0 开始;messages[2:] 省略右界表示到当前长度;messages[:] 使用整个当前长度。
要取“当前历史中的一页”,业务检查通常应满足 0 <= low && low <= high && high <= len(messages)。这是业务可见范围。语言允许切片的双下标上界达到容量,从而重新暴露容量内的元素,但这不代表那些额外位置都是业务上已接收的消息。
例如对 make([]string, 2, 4) 写 s[:4] 在语言上可行,增加出来的位置可能只是零值。若本来存有旧内容,重新切片也不会自动把它清空。数组和字符串切片的上界规则还需按其长度判断,不要把切片容量规则直接套给字符串。
小练习: messages[2:2] 的长度是多少?是 0;空区间不是一个元素。messages[3:2] 的左界大于右界,不是合法范围。
三、按顺序遍历、筛选与空列表
3.1 range 同时给出位置和元素值
遍历就是依次访问一组元素。对切片,range 可以让循环产生下标和该位置的元素值:
package main
import "fmt"
func main() {
messages := []string{"你好", "收到", "稍后聊"}
for i, body := range messages {
fmt.Printf("%d:%s\n", i, body)
}
}预期访问顺序是下标 0、1、2。i 的类型是 int,body 的类型是元素类型 string;每轮得到相应位置的元素值。需要注意:对字符串本身使用 range 时,两个结果的含义不同,第五节会专门解释。
只需要内容时可写 for _, body := range messages,_ 表示有意不接收该结果;只需要下标时可写 for i := range messages。单独写一个变量接收的是下标,不能误认为内容。
3.2 给循环变量重新赋值,不会替换列表元素
完整程序:
package main
import "fmt"
func main() {
messages := []string{"你好", "收到"}
for _, body := range messages {
body = "预览:" + body
fmt.Println(body)
}
fmt.Println(messages)
for i := range messages {
messages[i] = "预览:" + messages[i]
}
fmt.Println(messages)
}预期最后两次列表输出分别为 [你好 收到] 和 [预览:你好 预览:收到]。第一轮循环中的 body 保存元素值的副本,重新给它赋值只改变该变量;第二轮通过 messages[i] 写回了底层元素位置。
在真实消息预览中,还要先明确是否允许修改原正文。通常应生成展示结果,保留原始内容。本例故意展示修改原列表的方式,帮助你识别副作用,不意味着预览需求应该覆盖原消息。
3.3 先用新结果列表筛选,保留原输入
本节先只排除空字符串,延续上一章的规则:单个空格不是空字符串。完整程序:
package main
import "fmt"
func nonEmpty(input []string) []string {
result := make([]string, 0, len(input))
for _, body := range input {
if body == "" {
continue
}
result = append(result, body)
}
return result
}
func main() {
input := []string{"你好", "", " ", "收到"}
result := nonEmpty(input)
fmt.Printf("输入=%q\n", input)
fmt.Printf("结果=%q\n", result)
}预期结果列表为 {"你好", " ", "收到"},输入列表仍有原来的四项。这里 %q 应用于字符串切片,会把各项带引号显示,空格不会与空值混淆。
input []string 表示函数参数接收字符串切片;函数名后面的 []string 表示返回一个字符串切片。先前已学过函数语法,变化的是参数和返回结果的具体类型。
| 输入下标 | 正文 | 动作 | 结果长度 |
|---|---|---|---|
| 0 | "你好" | 追加 | 1 |
| 1 | "" | continue | 1 |
| 2 | " " | 追加 | 2 |
| 3 | "收到" | 追加 | 3 |
预留容量等于输入长度,是因为筛选结果不会比输入更多。这并不是自动安全的无限输入策略;本例输入是四条固定数据,后续接入文件或网络时要在收集和分配之前控制输入规模。
暂时使用新结果列表,可以清楚保持“遍历输入、追加输出”的分工。在原切片里边删除边遍历需要同时推理下标移动和共享,等这些概念稳定后再作为算法练习。
3.4 nil 切片与空切片都没有元素,但值不完全相同
package main
import "fmt"
func main() {
var a []string
b := []string{}
c := make([]string, 0)
fmt.Println(len(a), cap(a), a == nil)
fmt.Println(len(b), cap(b), b == nil)
fmt.Println(len(c), cap(c), c == nil)
a = append(a, "第一条")
fmt.Println(a)
}预期:
0 0 true
0 0 false
0 0 false
[第一条]nil 是切片的零值。三者都可以用 len、cap、range 和 append;空列表的遍历执行零轮。没有元素时,三者都不能访问下标 0。切片可以与 nil 比较,两个切片不能直接用 == 比较内容。
“查询成功但没有历史”与“查询还没执行”可以是不同业务状态,但语言不会自动替你定义它们。只有函数契约明确约定时,nil 才能承担某种业务含义。本章筛选函数只承诺返回零项或多项合格内容,不以 nil 判断是否出错;配置错误另用明确结果报告。
3.5 range 的遍历范围不会因追加而自动扩大
下面是完整的顺序演示:
package main
import "fmt"
func main() {
ids := []string{"c-a", "c-b"}
for _, id := range ids {
ids = append(ids, "notice:"+id)
}
fmt.Println(ids)
}预期 [c-a c-b notice:c-a notice:c-b]。进入本次切片 range 时,用于遍历的切片值已经确定;它的原长度是 2,所以本轮只产生两个原位置,不会继续访问新追加的通知项。
但这不表示 range 给整份元素内容做了不可变化的快照。如果循环中修改共享底层数组里尚未访问的元素,后续读取仍可能看到新值。第一遍学习保持输入不变、结果写入另一列表,更容易证明每轮读到了什么。
这里只证明内存列表的下标顺序。网络接收次序、服务器序号、多设备同步和全局排序是后续业务协议问题,不能从一个 range 循环推导出来。
四、第二遍深化:共享、扩容与独立副本
4.1 变量不同,不代表元素位置不同
为了判断“修改一份预览会不会影响原列表”,需要建立一个简单的内存模型。内存位置可以先理解为保存一个值的格子;同一格子被两个入口使用时,一个入口写进去,另一个入口随后读取就会看到变化。这是共享关系,不要求现在会写地址运算。
切片值可用三项信息理解:底层数据的起点、当前长度和容量。复制切片值会复制这份描述;两份描述可以通向同一个底层数组。这里是便于推理的模型,不要求背诵机器字节布局。
package main
import "fmt"
func main() {
original := []string{"你好", "收到"}
view := original
view[0] = "已编辑"
fmt.Println(original)
fmt.Println(view)
}预期两行都是 [已编辑 收到]。与第一节数组复制的结果对照:
数组赋值后: original 的元素格子 backup 的另一排元素格子
切片赋值后: original 的描述 ─┐
├─ 同一排元素格子
view 的描述 ───┘值复制始终存在,区别在于所复制的值是什么。 数组值含有自己的元素;切片值描述一段数组。所以不能概括成“数组值传递、切片引用传递”。Go 函数参数同样按值传递,切片参数只是复制了一个可能继续指向相同元素的描述。
4.2 容量足够时,向短视图追加可能覆盖原列表
完整程序:
package main
import "fmt"
func main() {
base := make([]string, 2, 4)
base[0] = "你好"
base[1] = "收到"
view := base[:1]
view = append(view, "预览标记")
fmt.Println(base)
fmt.Println(view)
}预期两行都是 [你好 预览标记]。虽然 view 原来只展示第一项,它的容量仍然是 4;追加下一项就写到了共享底层数组下标 1,原来的 base[1] 因此被替换。
| 步骤 | base 描述 | view 描述 | 底层前两格 |
|---|---|---|---|
| 初始化 | 起点 0,长 2,容 4 | 无 | 你好、收到 |
| 切出视图 | 不变 | 起点 0,长 1,容 4 | 你好、收到 |
| 追加标记 | 不变 | 起点 0,长 2,容 4 | 你好、预览标记 |
base 的描述并没有被重新赋值,但是它所访问的元素变了。给子切片追加,不能只看子切片自己的长度,还要检查是否存在共享数据和剩余容量。
4.3 容量不足后,旧视图仍停留在旧数组
完整程序显式把初始容量设为 2,以便确定发生重新分配:
package main
import "fmt"
func main() {
base := make([]string, 2, 2)
base[0] = "你好"
base[1] = "收到"
view := base[:1]
updated := append(base, "稍后聊")
updated[0] = "已编辑"
fmt.Println(base)
fmt.Println(view)
fmt.Println(updated)
fmt.Println(len(updated), cap(updated) >= 3)
}预期:
[你好 收到]
[你好]
[已编辑 收到 稍后聊]
3 true增加第三项时,原容量不够,updated 描述新的底层数组。旧的 base、view 继续描述旧数组,不会自动追随 updated。新数组有独立元素位置,所以之后修改 updated[0] 不影响旧数组的第一项。
实际容量可以大于等于 3。示例只打印规范保证的比较结果,不把“至少 3”伪装成终端会直接打印的文字,也不猜测具体增长倍数。
4.4 函数可以改到共享元素,但不会自动改调用者的长度
完整程序:
package main
import "fmt"
func markFirst(messages []string) {
if len(messages) == 0 {
return
}
messages[0] = "预览:" + messages[0]
}
func addMessage(messages []string, body string) []string {
messages = append(messages, body)
return messages
}
func main() {
messages := make([]string, 1, 3)
messages[0] = "你好"
markFirst(messages)
fmt.Println(messages)
addMessage(messages, "收到")
fmt.Println(len(messages), messages)
messages = addMessage(messages, "稍后聊")
fmt.Println(len(messages), messages)
}预期:
[预览:你好]
1 [预览:你好]
2 [预览:你好 稍后聊]第一次 addMessage 把“收到”写入了共享底层下标 1,但调用者没有接收返回描述,长度仍为 1。第二次从这个长度 1 的切片追加,又在下标 1 写入“稍后聊”,覆盖前一次的内容,然后调用者接收长度 2 的结果。
| 动作 | 函数内发生什么 | 调用者可见什么 |
|---|---|---|
修改 messages[0] | 写共享元素 | 原长度内可见新内容 |
messages = append(...) | 更新形参的切片描述 | 调用者描述仍原样 |
| 返回并接收切片 | 把新描述交回调用者 | 调用者获得新长度和可能的新底层数组 |
调用普通的 addMessage(...) 而丢弃返回值,在语法上允许,但这里是业务错误。直接单写内置 append(messages, "收到") 并把结果丢弃,则不能作为合法的独立表达式语句通过编译。不要把这两种反例混写成同一种语言行为。
4.5 copy 复制元素,目标必须先有长度
完整程序:
package main
import "fmt"
func main() {
source := []string{"你好", "收到"}
backup := make([]string, len(source))
n := copy(backup, source)
backup[0] = "已编辑"
fmt.Println(n)
fmt.Println(source)
fmt.Println(backup)
wrong := make([]string, 0, 2)
fmt.Println(copy(wrong, source), len(wrong))
}预期:
2
[你好 收到]
[已编辑 收到]
0 0copy(目标, 来源) 把元素值写入目标已有的位置,返回实际复制数量;该数量是两个切片长度的较小者。它不会帮目标扩大长度。容量 2、长度 0 的 wrong 没有可写入的当前元素,因此复制数量为 0。
这里 source 与 backup 的列表元素位置独立,但两个字符串值可能仍共享不可修改的文本存储。以后如果元素是切片、指针或含共享字段的结构体,copy 也不会自动递归复制它们指向的全部数据。当前先准确记住“复制元素值”,后续再判断元素内部是否继续共享。
4.6 限制容量与独立复制解决的问题不同
第二种保护手段是三下标形式:s[low:high:max]。结果长度为 high-low,容量为 max-low。它限制新切片可利用的底层范围,不会复制已有元素。
下面是 main 内片段,需导入 fmt:
base := []string{"你好", "收到", "稍后聊"}
limited := base[:1:1]
limited[0] = "已编辑"
fmt.Println(base[0])
limited = append(limited, "预览标记")
limited[0] = "新的预览"
fmt.Println(base[0], limited[0])预期先打印 已编辑,再打印 已编辑 新的预览。在追加之前,现有下标 0 仍共享;追加时长度已等于受限容量,必须使用新底层数组;此后在新数组里替换字符串元素才不影响 base。
| 需求 | 选择 | 仍需注意 |
|---|---|---|
| 短时间查看一段,不写入 | 普通子切片 | 数据仍共享,别人修改也可能可见 |
| 阻止通过这个视图追加时覆盖后方元素 | 三下标限制容量 | 已有元素仍共享,不是只读权限 |
| 独立替换列表元素 | make 加 copy | 更深层共享需看元素类型 |
4.7 一小段视图可能保留一大块数组
假设读入一万条消息后,仅需长期保存最后两条预览。如果写 preview := all[len(all)-2:],虽然长度只有 2,这个子切片仍关联原底层数组,可能让整块大数组继续保留。
可以把需要的元素复制到一个小的新切片。下面是 main 内片段,前提是 all 已声明且至少有两条:
start := len(all) - 2
preview := make([]string, 2)
copy(preview, all[start:])
all = nil只有其他代码也不再引用旧底层数组时,旧数组才可能成为垃圾回收的候选;all = nil 不是立即释放命令,也不会让别人的切片失效。preview 中保存的字符串还会保留其所需文本存储,所以复制列表不等于保证全部旧字节立刻消失。
现在的工程判断是:短暂只读视图可以共享,长期独立结果应评估复制成本和保留成本。具体堆分析、垃圾回收和性能测量放到运行时与系统章节。Go 官方切片说明中的子切片保留示例可作为第二遍延伸阅读。
五、字符串、UTF-8 与三种长度
5.1 字符串是一段不可修改的字节序列
已经知道 len("你好") == 6。现在把原因补齐:字节是由 8 个二进制位组成的存储单位;Go 的 byte 是 uint8 的别名,能表示 0–255。字符串保存的是一段字节序列,字符串值中的字节不可通过下标赋值修改。
完整程序:
package main
import "fmt"
func main() {
text := "A你🙂"
fmt.Println(len(text))
fmt.Println(text[0])
fmt.Printf("%c\n", text[0])
}预期是 8、65、A,各占一行。text[0] 的类型是 byte,默认显示它的整数值 65;%c 按字符形式显示对应值。对多字节编码的中文只读一个字节,就没有读到整个中文码点。
这里的单引号 'B' 是字符码点字面量,表示 B 对应的码点值;双引号则表示字符串,第 5.4 节会继续比较两者。text[0] = 'B' 是故意错误片段:字符串不能这样修改。给整个变量重新赋值,例如 text = "B你🙂",是让变量保存另一个字符串值,与修改原字符串的内部字节是两回事。
5.2 Unicode 编号与 UTF-8 编码负责不同问题
Unicode 码点可以理解为字符体系中的编号,常写作 U+4F60。UTF-8是一种把码点编码成字节序列的规则;一个可由 UTF-8 编码的 Unicode 码点需要 1 到 4 个字节。不要把“编号是什么”和“编号怎样存储”混为一个概念。
rune 是 int32 的别名,用于表示码点值。它作为整数类型能存的数值多于合法 Unicode 字符编号;写成 rune 不会自动让任意整数成为合法字符。此处通过合法 UTF-8 解码得到的“你”、“🙂”都是有效码点。
| 本例的内容 | 码点 | UTF-8 字节数 | 在 "A你🙂" 中的起始字节偏移 |
|---|---|---|---|
A | U+0041 | 1 | 0 |
你 | U+4F60 | 3 | 1 |
🙂 | U+1F642 | 4 | 4 |
| 合计 | 3 个码点 | 8 | — |
“偏移”表示从开头跳过多少个字节。码点 🙂 前面有 1+3 个字节,所以它的起始偏移是 4。当前这些源文件中文字面量按 UTF-8 写入,但 Go 字符串可以保存任意字节,包括非法 UTF-8,不能把所有 string 一律当作已经校验的文本。Go 官方字符串说明分别解释了字节、码点及字符串表示。
5.3 range 字符串返回字节偏移和解码后的码点
完整程序:
package main
import "fmt"
func main() {
text := "A你🙂"
for offset, r := range text {
fmt.Printf("%d %U %c\n", offset, r, r)
}
}预期:
0 U+0041 A
1 U+4F60 你
4 U+1F642 🙂%U 显示 U+ 形式的码点,%c 显示对应字符。这里循环运行三轮,偏移却不是 0、1、2。遍历对象是字符串,第一结果代表字节偏移,第二结果是解码得到的 rune。
| 遍历对象 | 第一结果 | 第二结果 |
|---|---|---|
[]string 消息列表 | 消息下标 | 该条 string 的值 |
[]rune 码点列表 | 码点列表下标 | 该 rune 的值 |
string 文本 | 起始字节偏移 | UTF-8 解码的 rune |
同一个语法形式服务于不同类型,必须根据右侧对象判断含义。不能把 range text 的偏移直接称为“第几个汉字”。
5.4 转成字节或码点切片,再构造新字符串
完整程序:
package main
import "fmt"
func main() {
text := "A你🙂"
bytes := []byte(text)
bytes[0] = 'B'
runes := []rune(text)
runes[1] = '好'
fmt.Println(len(bytes), len(runes))
fmt.Println(text)
fmt.Println(string(bytes))
fmt.Println(string(runes))
}预期:
8 3
A你🙂
B你🙂
A好🙂[]byte(text) 得到可以逐字节操作的切片;[]rune(text) 按 UTF-8 解码成码点切片。单引号形式 '好' 是 rune 字面量,双引号形式 "好" 是字符串,两者类型和用途不同。
修改转换得到的切片不会修改原来的 text;string(bytes) 和 string(runes) 构造相应字符串值。这里讨论语言上可观察的独立性,不断言编译器在每一种场景都必须进行完全相同的物理分配。
如果只是想知道有效文本有多少个码点,没必要先创建 []rune:下一节会用 utf8.RuneCountInString 计数。是否需要一份可修改或可索引的码点列表,决定了转换是否有价值。
5.5 一个用户感知的字符,可能由多个码点组成
比较两段文本:"é" 和 "e\u0301"。前者使用单个带重音的码点;后者由 e 加上组合重音两个码点构成。\u0301 是在字符串里写出 Unicode 码点的一种转义形式,UTF-8 编码后的字节数取决于这个码点。
| 文本 | 字节数 | 码点数 | 常见显示 |
|---|---|---|---|
"é" | 2 | 1 | 一个带重音的字母 |
"e\u0301" | 3 | 2 | 也可能显示为一个带重音的字母 |
"🙂" | 4 | 1 | 一个表情 |
"🇨🇳" | 8 | 2 | 常见渲染为一面旗帜 |
用户感知的一组文字常用字素簇描述,其分段规则由 Unicode 文本分段标准定义。连接起来的表情、组合音标等都说明:一个码点并不总等于一个字素簇;字素簇也不等于终端列宽或屏幕像素宽度,显示还受字体和渲染环境影响。
Go 不会自动把看起来相同的两段文本规范化成相同字节。"é" == "e\u0301" 的预期结果为 false。身份比较、搜索与排序如果需要特定规范化规则,应另行明确,不能在消息存储中悄悄改写原文。
因此需求“最多 100 个字符”仍不够清楚:传输大小上限通常需要字节预算;面向用户的字符计数可能需要字素簇;本章先实现明确的码点预览,保留其显示限制。Unicode 文本分段标准是继续研究字素簇规则的依据。
六、文本边界:先校验,再生成预览
6.1 没有索引越界,也可能截坏中文
对字符串使用 text[:n] 按字节选择前缀,不会自动寻找文字边界。完整程序:
package main
import (
"fmt"
"unicode/utf8"
)
func main() {
text := "你好"
prefix := text[:4]
fmt.Println(len(text), utf8.ValidString(text))
fmt.Println(len(prefix), utf8.ValidString(prefix))
}预期:
6 true
4 falseunicode/utf8 是标准库包,导入后用包名 utf8 调用函数。ValidString 检查整段字节是不是合法 UTF-8,返回布尔结果。
“你”占前三个字节,“好”还需要另外三个字节。截到第 4 字节只留下了“好”的一部分:操作在字符串长度内,所以没有发生范围错误,但结果不再是完整的 UTF-8 编码。索引合法与文本合法是两项不同检查。
6.2 先分辨合法编码,再谈码点数
完整程序:
package main
import (
"fmt"
"unicode/utf8"
)
func main() {
good := "A你🙂"
bad := string([]byte{0xff})
fmt.Println(utf8.ValidString(good), utf8.RuneCountInString(good))
fmt.Println(utf8.ValidString(bad), utf8.RuneCountInString(bad))
}预期:
true 3
false 10xff 是十六进制整数 255;这个单字节不能独立构成合法 UTF-8。它仍然能存在于 Go 字符串中。
RuneCountInString 并不是“计数失败就返回错误”。遇到无效或不完整编码时,会按宽度为一个字节的错误单元计数;因此第二行计数 1 不表示输入合规。字符串 range 遇到无效编码会产生替代码点 U+FFFD,并前进一个字节。直接转 []rune 再转回字符串可能改变原字节,不能把这个过程描述成无损修复。
“拒绝非法 UTF-8”是本章为文本预览和批处理新增的要求;01.03 的原校验只约定上限有效、正文非空和字节数不超限,未承诺这一项。其他业务可能选择保存原始二进制或做明确修复,但需要不同字段、协议和记录规则。utf8 官方文档说明了校验、计数与解码遇到错误时的行为。
6.3 用码点边界做一个契约清楚的预览函数
函数约定如下:
limit表示最多保留多少个码点,不能为负;0 合法。- 输入必须是合法 UTF-8。
- 结果保留原文本的码点前缀,不修改原文。
- 第二返回值表示输入条件有效,不表示“发生过截断”。
- 此版不添加省略号,也不承诺保留完整字素簇。
完整程序:
package main
import (
"fmt"
"unicode/utf8"
)
func previewRunes(text string, limit int) (string, bool) {
if limit < 0 || !utf8.ValidString(text) {
return "", false
}
runes := []rune(text)
if len(runes) <= limit {
return text, true
}
return string(runes[:limit]), true
}
func main() {
preview, valid := previewRunes("A你🙂", 2)
fmt.Printf("有效=%t,预览=%q\n", valid, preview)
}预期 有效=true,预览="A你"。先校验后转换;转换结果有三个码点,下标 0、1 被保留,runes[:2] 不会切开某个码点的 UTF-8 编码。
| 输入 | 上限 | 结果 | 第二结果 |
|---|---|---|---|
"A你🙂" | 2 | "A你" | true |
"你好" | 2 | 原文 | true |
"你好" | 0 | "" | true |
"" | 2 | "" | true |
"你好" | -1 | "" | false |
| 非法 UTF-8 | 2 | "" | false |
空文本可以是一个有效预览输入;“新消息正文不能为空”属于另一条校验规则。这两个函数目的不同,不能因为新消息拒绝空字符串,就断言所有处理文本的函数都必须拒绝空字符串。
6.4 码点安全不等于完整显示,预览也不等于消息正文
previewRunes("e\u0301", 1) 会返回 "e",编码合法,但重音没有保留。对由多个码点组合的旗帜或连接表情,也可能得到视觉上不完整的结果。因此界面真正要求“不拆开用户看到的字符”时,应使用经过验证的字素簇分段方案,并单独检验字体和布局行为。
预览不能代替正文校验。若发送正文超过协议字节上限,程序应按约定拒绝或明确询问编辑;不能静默截短后仍声称发送了原文。本章只在原正文通过校验后生成显示用途的预览。
[]rune(text) 需要形成码点列表,输入越大,处理和存储成本越高。综合程序先控制输入消息数和每条原始字节数,再调用预览。这里不承诺一次转换的固定内存倍率,实际成本还与编码内容和运行时有关;后续文件、网络章节将把限制放到接收数据的更早阶段。
小练习: 如果产品要在预览末尾加 …,需要先问清上限是否包括这个符号。它自己也占字节和码点,不能先截满预算再无条件附加,还继续声称满足原预算。
七、综合:本地历史、边界用例与 OpenIM 对照
7.1 先写本批处理的业务契约
现在为会话 c-a 处理一批固定的本地正文。规则先用自然语言确定:
- 批量上限与单条字节上限必须为正数;配置不合法时,整批不处理。
- 输入条数超过批量上限时,整批不处理,不静默丢掉后半部分。
- 单条正文为空、超过字节上限或不是合法 UTF-8 时,计入拒绝数。
- 一个空格仍按既定规则接受;本题不自动去空格。
- 返回结果保留被接受正文的原顺序和原内容,不覆盖输入列表元素。
- 预览另行生成,按码点限制;本地接受不表示持久化、网络发送、设备收到或已读。
输入为空和配置失败必须能区分。如果只返回空列表、拒绝数 0,调用者无法判断是正常没有消息还是配置错误。因此 acceptedMessages 返回三个结果:接受列表、内容拒绝数、整批错误说明。第三项为空表示整批处理有效;非空时,前两项不作为成功统计。
7.2 完整程序
下面可单独作为完整的 main.go。它只组合已经讲过的语法,没有结构体、指针、并发或外部服务。
package main
import (
"fmt"
"unicode/utf8"
)
func validateText(text string, maxBytes int) (bool, string) {
if maxBytes <= 0 {
return false, "字节上限配置无效"
}
if text == "" {
return false, "空文本"
}
if len(text) > maxBytes {
return false, "超过字节上限"
}
if !utf8.ValidString(text) {
return false, "不是有效 UTF-8"
}
return true, ""
}
func acceptedMessages(input []string, maxBytes int, maxBatch int) ([]string, int, string) {
if maxBytes <= 0 || maxBatch <= 0 {
return nil, 0, "批量或字节上限配置无效"
}
if len(input) > maxBatch {
return nil, 0, "输入条数超过批量上限"
}
accepted := make([]string, 0, len(input))
rejected := 0
for _, text := range input {
ok, _ := validateText(text, maxBytes)
if !ok {
rejected++
continue
}
accepted = append(accepted, text)
}
return accepted, rejected, ""
}
func previewRunes(text string, limit int) (string, bool) {
if limit < 0 || !utf8.ValidString(text) {
return "", false
}
runes := []rune(text)
if len(runes) <= limit {
return text, true
}
return string(runes[:limit]), true
}
func main() {
const maxBytes = 6
const maxBatch = 8
previewLimit := 1
if previewLimit < 0 {
fmt.Println("拒绝:预览上限无效")
return
}
input := []string{
"你好",
"",
" ",
"🙂",
"abcdefg",
string([]byte{0xff}),
"A你🙂",
}
accepted, rejected, reason := acceptedMessages(input, maxBytes, maxBatch)
if reason != "" {
fmt.Println("整批拒绝:", reason)
return
}
fmt.Printf("会话=c-a 接受=%d 拒绝=%d\n", len(accepted), rejected)
for i, body := range accepted {
preview, valid := previewRunes(body, previewLimit)
if !valid {
fmt.Println("拒绝:预览输入无效")
return
}
fmt.Printf("%d 原文=%q 预览=%q\n", i, body, preview)
}
}预期:
会话=c-a 接受=3 拒绝=4
0 原文="你好" 预览="你"
1 原文=" " 预览=" "
2 原文="🙂" 预览="🙂"acceptedMessages 中使用 _ 丢弃的是单条拒绝原因:当前需求只统计总数,因此可以有意不展示各条原因。整批错误 reason 则必须接收并检查;如果下一步要求逐条反馈,需要返回更丰富的结果,下一章结构体可以帮助组织它们。
previewRunes 的布尔结果依然被检查。按照本例前面的校验和非负预览上限,正常会走成功路径;保留检查使调用约定清楚,也便于未来调整规则时发现违约。
7.3 逐条追踪,核对数量守恒
| 原下标 | 文本 | 字节数 | 判断 | 接受累计 | 拒绝累计 |
|---|---|---|---|---|---|
| 0 | "你好" | 6 | 恰好等于上限,接受 | 1 | 0 |
| 1 | "" | 0 | 空文本 | 1 | 1 |
| 2 | " " | 1 | 合法空格文本 | 2 | 1 |
| 3 | "🙂" | 4 | 合法 UTF-8 | 3 | 1 |
| 4 | "abcdefg" | 7 | 超过字节上限 | 3 | 2 |
| 5 | 单字节 0xff | 1 | 非法 UTF-8 | 3 | 3 |
| 6 | "A你🙂" | 8 | 超过字节上限 | 3 | 4 |
整批成功时,应满足 len(accepted) + rejected == len(input),每条输入恰好进入接受或拒绝的一类。若整批配置或数量检查失败,本题不开始逐条处理,因此不应该把返回的零计数解释为“全部接受”或“全部拒绝”。
筛选后,原下标 3 的表情成为结果下标 2。这再次说明列表位置会变化,不能直接用下标代表消息身份。
7.4 十六组边界用例
以下是学习者需要逐项推导、再按自己环境核对的用例,不是本次已执行的测试记录。
| 编号 | 改动或输入 | 预期与理由 |
|---|---|---|
| 1 | var input []string,配置有效 | 成功,接受 0、拒绝 0 |
| 2 | input := []string{} | 同样成功,结果数为 0 |
| 3 | 仅输入 "" | 内容拒绝 1 条 |
| 4 | 仅输入 " ",字节上限 1 | 接受 1 条,不自动去空格 |
| 5 | "你好",上限 6 | 接受,恰好边界 |
| 6 | "你好",上限 5 | 内容拒绝,超过字节上限 |
| 7 | "🙂",上限 4 | 接受;长度是 4 字节 |
| 8 | string([]byte{0xff}),上限 6 | 内容拒绝,编码无效 |
| 9 | 字节上限为 0 或负数 | 整批配置错误,不逐条累计 |
| 10 | 批量上限为 0 或负数 | 整批配置错误 |
| 11 | 输入 9 条,批量上限 8 | 整批数量错误,不静默截掉一条 |
| 12 | 输入恰好 8 条 | 进入逐条校验,不保证八条都合规 |
| 13 | 预览上限 0 | 合格消息预览为空,原正文保留 |
| 14 | 预览上限 -1 | main 拒绝预览配置 |
| 15 | "e\u0301",预览上限 1 | 预览为 e,码点有效但组合重音未保留 |
| 16 | 修改成功返回列表的某个字符串元素 | 不替换原输入对应元素;结果有独立列表存储 |
这批输入本来已由固定字面量放入内存。此处的条数和字节检查约束的是后续处理与额外结果分配,不能声称已经阻止外部输入最初占用内存。接入文件和网络时,需要在读入阶段限制原始总量;这属于后续 I/O 课程的职责。
7.5 对照 OpenIM:找到已经认识的局部操作
主参照固定为 OpenIM v3.8.3-patch.16,提交 f6411a8a1a31d3df36f4c2b3ad28481a94141e1f。本章实际核对的是同一文件中的三个函数;现在只观察其中的集合操作,不要求读懂整个消息同步系统。
| 已核对入口 | 本章能够理解的动作 | 后续再解释的部分 |
|---|---|---|
GetSeqMessage | 把取得的一批消息追加到响应列表 | 响应结构、指针、存储查询和序号边界 |
GetMaxSeq | 遍历原会话 ID,期间追加相应通知会话 ID | 会话身份规则、权限和同步协议 |
PullMessageBySeqs | 用消息列表长度是否为 0 决定后续处理 | 拉取范围、查询失败和翻页结束条件 |
这些入口都在固定提交的 internal/rpc/msg/sync_msg.go。
其中 GetSeqMessage 的这一行已经能识别核心操作:
pullMsgs.Msgs = append(pullMsgs.Msgs, msgs...)这是一段上游源码摘录,不能单独当作本章 main.go 运行。点号前后涉及真实消息对象的字段,下一章再讲结构体;当前只需看出“接收 append 的结果”和“用 ... 展开一批消息”。真实消息元素不是本章简化的纯字符串。
GetMaxSeq 在遍历会话 ID 的循环体中继续向切片追加通知 ID。第三节的 ids 示例解释了为什么新增项不会自动扩大这一轮遍历次数;这不等于消息元素自动获得快照,也不能据此判断整个系统的同步正确性。
本章的空格规则、6 字节上限、8 条批量限制、码点预览和三个返回值,都是为了教学而明确设定的契约,不能说成 OpenIM 的实际协议。当前没有读取兼容 SDK 的完整实现,也没有验证端到端投递保证。下一阶段在具备结构体、接口、网络和存储知识后,再扩大源码阅读范围。
八、分层练习、解析与下一步
先独立作答,再打开解析。预测题要写出每一步的长度、容量或元素关系;业务题要先写规则再给代码。除特别说明外,片段放在 main 内,打印需要导入 fmt,UTF-8 操作还需要 unicode/utf8。
练习 1:数组里有几个元素
messages := [3]string{"你好"}
backup := messages
backup[0] = "收到"写出两个数组的长度和全部元素。是否因为只写了一个初值而不能编译?
提示与解析
提示:区分类型规定的长度与显式提供的初值数量。
两个数组长度都是 3。messages 的内容是 {"你好", "", ""},backup 是 {"收到", "", ""}。初值可以少于长度,其余元素取零值;提供超出固定长度的元素才是错误。赋值复制元素位置,给副本元素赋新字符串不改变原数组。
练习 2:最后一条为什么不能直接取
这里的 messages 是运行期长度可变的 []string 切片。有人写 last := messages[len(messages)-1],认为任何输入都能使用。说明失败条件,并写安全检查。
提示与解析
提示:让输入恰好为空。
切片长度为 0 时,下标算出 -1,访问越界。应先确认 len(messages) > 0 再读取;否则给出“当前没有历史”的正常结果。不要把不存在的最后一条默认成空正文,否则会混淆空列表和一条内容为空的记录。
这项运行期检查不能掩盖非法的常量数组下标。对固定的 [0]string 数组,直接写 a[len(a)-1] 会形成编译期已知的下标 -1;即使放在 if len(a) > 0 的不可达分支里,编译器仍要检查它,代码依然不能通过编译。
练习 3:有容量为什么仍越界
s := make([]string, 1, 4)分别判断 s[1]、s[:4]、s[1:1] 是否在语言上合法,再说明它们是否都适合直接作为“已有消息”。
提示与解析
提示:索引与重切片的上界规则不同。
s[1] 越界,因为当前长度只有 1。s[:4] 合法,因为切片重切上界可以达到容量;结果长度 4,新增可见的位置此时是空字符串零值。s[1:1] 是合法空区间,长度 0、容量 3。
语言允许扩大可见窗口,不代表业务收到更多消息。按当前历史取页,仍要让上界不超过当前业务长度。
练习 4:两种“丢弃返回值”差在哪里
甲直接写 append(messages, "新正文");乙调用第四节的 addMessage(messages, "新正文") 但不接收返回值。两种代码都只是“运行后没追加”吗?
提示与解析
提示:先判断语法与编译规则,再判断业务效果。
甲不能把内置 append 的结果直接丢弃作为独立语句,会编译失败。乙调用普通函数可以忽略返回结果,但调用者的切片描述不会自动更新,因而拿不到新长度或新数组;容量足够时,底层元素还可能已经写入。正确用法是明确接收返回切片。
练习 5:range 是完整快照吗
messages := []string{"m-a", "m-b"}
for i, body := range messages {
if i == 0 {
messages[1] = "changed"
}
fmt.Println(body)
}两次输出是什么?与循环中单纯追加新元素相比,有什么区别?
提示与解析
提示:固定的是本轮切片遍历范围,不是所有元素值的深层快照。
预期输出 m-a、changed。第一次循环修改了原底层下标 1,第二轮读取该位置时得到新值。追加产生的新位置不会自动扩大原来的两轮;修改原范围内尚未读取的位置却可能影响结果。为降低推理难度,筛选时使用独立输出列表,不修改输入。
练习 6:copy 的目标长度陷阱
source := []string{"a", "b"}
result := make([]string, 0, 10)
n := copy(result, source)预测 n、len(result),修改代码以取得可独立替换元素的副本。
提示与解析
提示:复制上限取两个长度的较小值。
两个结果都是 0。把目标改为 make([]string, len(source)) 再 copy,复制数量为 2。容量预留不会自动变成目标当前元素;也不能用 result := source 替代复制,否则列表元素仍共享。
练习 7:限制容量前后,谁还共享
base := []string{"a", "b", "c"}
view := base[:1:1]
view[0] = "x"
view = append(view, "y")
view[0] = "z"写出最终 base 与 view,并标出共享关系何时改变。
提示与解析
提示:分别画出追加之前和之后的底层数组。
最终 base 为 [x b c],view 为 [z y]。view[0] = "x" 时仍共享原下标 0。view 的长度和容量都为 1,追加一项必须使用新底层数组,此后给 view[0] 赋值不再改原数组。不能把“限制容量仍共享现有元素”延伸成“容量不足并追加之后还必然共享”。
练习 8:nil 能不能直接代表查询失败
一个函数正常查不到历史时返回 nil 切片。调用者写 if messages == nil { fmt.Println("查询失败") }。说明有什么契约问题;两个切片能否用 == 比较内容?
提示与解析
提示:语言零值与业务状态分开表达。
返回 nil 可以合法表达零条消息,不自动代表查询失败。是否失败应由明确状态或后续学习的 error 返回值表达。切片只能与 nil 比较,不能把两个切片直接用 == 比较内容;比较内容需要按长度、位置和元素规则检查,后续还会学习标准库辅助函数。
练习 9:给文本标上三种坐标
对 "A你🙂" 写出字节数、码点数、字符串 range 的三个偏移;判断 text[1:4]、text[:3] 的编码是否有效。
提示与解析
提示:按 1、3、4 字节累加。
长度为 8 字节、3 个码点,起始偏移为 0、1、4。text[1:4] 恰好得到“你”,有效;text[:3] 得到 A 加“你”的前两个字节,无效。码点数也不能普遍推导字素簇数;组合音标和连接表情需要单独分段。
练习 10:预览的 true 究竟承诺什么
分别推导 previewRunes("你好", 0)、previewRunes("你好", -1)、previewRunes("e\u0301", 1)。第三种结果是否可以宣称“完整保留了第一个用户可见字符”?
提示与解析
提示:布尔结果包括数量参数有效性,不能只理解为文本编码有效。
结果依次为 ("", true)、("", false)、("e", true)。0 表示合法的空预览,负数违反参数契约。组合重音被截掉,第三种仍是合法 UTF-8 码点前缀,但不满足完整字素簇要求。它也没有证明消息已发送或被读取。
练习 11:整批失败为何不能只返回零计数
修改综合程序:令 maxBytes 为 0。说明处理是否应继续,接受数与拒绝数能否当作成功统计。再说明 len(accepted)+rejected==len(input) 在什么前提下成立。
提示与解析
提示:配置错误属于整批条件。
函数返回非空整批错误说明,调用者应打印原因并停止。此时的零计数只是占位,不是已处理统计。数量守恒成立于整批条件有效、完整遍历所有输入、每条恰好进入接受或拒绝一类的情形。不能为了满足公式而把配置错误算成每条正文都错误。
练习 12:两条预览为什么仍占着大数组
输入列表有很多元素,函数返回 all[len(all)-2:]。调用者清空 all 变量后,旧数组是否一定立刻释放?写出你需要核对的关系。
提示与解析
提示:查看仍有哪些切片指向同一底层存储。
返回的子切片本身仍关联旧数组;还有别名时更不能释放。可把所需两项复制到小列表,并在不再使用时让旧引用退出;具体回收时机由运行时决定。复制字符串元素后,所需文本字节仍会被新元素引用,不能承诺“所有历史字符串都立刻清空”。
练习 13:独立实现最近 N 条的副本
写普通函数 recentCopy(input []string, count int) ([]string, bool)。要求:负数拒绝;0 返回合法空列表;数量超过输入时返回全部;保留顺序;修改返回列表的字符串元素不能替换输入元素。
提示与参考实现
提示:先确定实际数量,再计算起点;结果使用 make 加 copy。
这是与 main 并列的包级函数,不需要额外导入:
func recentCopy(input []string, count int) ([]string, bool) {
if count < 0 {
return nil, false
}
if count > len(input) {
count = len(input)
}
start := len(input) - count
result := make([]string, count)
copy(result, input[start:])
return result, true
}对 {"a", "b", "c"} 和 2,结果为 {"b", "c"};对数量 0,start 等于长度,末尾空区间合法。对 nil 输入和正数,数量被限制为 0,仍返回成功空列表。先写边界推演,再按学习环境检查自己的实现。
练习 14:从本地列表迁移到 IM 需求
产品要求“每个会话展示最近两条消息,预览最多十个字符;点击后能准确打开同一条原消息”。目前只保存 []string,列表下标能长期标识消息吗?还缺哪几项需求定义?
提示与解析
提示:分别考虑身份、顺序、文本单位和结果状态。
至少要补充:
- 会话怎样标识,不同会话历史怎样区分。
- 每条消息怎样获得稳定身份;过滤、插入和重新排列都会改变列表下标。
- “最近”按什么字段或协议顺序判断;不能仅凭当前显示下标推断跨设备顺序。
- “十个字符”是字节、码点还是字素簇;省略号是否计入预算。
- 正常空历史、尚未加载和读取失败如何区分。
- 当前列表只是内存记录,持久化、投递与已读需要后续状态和协议支持。
下一章先解决数据组织与身份:用结构体组织消息字段,用 map 按会话查找,并正式讲地址和指针。网络保证继续按学习路线后置。
进入下一章前的检查
不要求背出每个术语。请留下四类证据:
- 解释: 用自己的话说明数组、切片、长度、容量和三种文本单位。
- 追踪: 画出普通赋值、子切片、容量满后追加、
copy的元素关系。 - 实现: 独立写筛选函数和最近 N 条副本,覆盖空列表与非法数量。
- 业务判断: 能区分本地加入、永久消息身份、预览规则和真实投递保证。
遇到问题按概念回看:索引问题回第一、二节;循环副本与空结果回第三节;修改影响谁回第四节;乱码和组合表情回第五、六节;错误统计回第七节。
下一章:01.05 map、结构体与指针。本章已讲的共享关系会继续使用,届时再正式引入 &、*、字段、键与缺失值,不需要提前猜这些语法。
核对资料与延伸阅读
- Go 规范:数组与切片类型,核对长度、类型与零值。
- Go 规范:切片表达式,核对双下标、三下标与边界。
- Go 规范:append 与 copy,核对追加与元素复制。
- Go 规范:range,核对切片与字符串遍历。
- Go 规范:表达式语句,核对不能丢弃
append返回值的语法限制。 - Go 官方:切片使用与内部模型,回访共享和内存保留。
- Go 官方:字符串、字节与 rune,建立文本表示模型。
- unicode/utf8 标准库,核对有效性与计数行为。
- Unicode 文本分段,了解字素簇边界。
本章按上述公开资料核对基础规则;扩容策略、实际分配与回收时机属于实现和运行环境相关问题,本文没有声称执行过测量或示例。