第 15 篇的数组使用了 mallocfree 的直接配对:一个变量持有一块堆内存,作用域结束时释放。字符串让事情变得更复杂。两个变量可以指向同一份字符串——把 a 赋给 b 时,复制整块内存太浪费,让两者共享同一份数据更合理。但共享引入了新问题:a 退出作用域时,b 可能还活着。直接释放会导致悬垂指针;不释放又会泄漏。引用计数解决这个矛盾,而且不需要垃圾回收器。

堆上的字符串表示

Sprout 字符串存放在堆上,布局固定为三个部分:

1
2
3
┌──────────────┬──────────────┬──────────────────────┐
│ refcount: i64│ byte_len: i64│ bytes: u8 ... │
└──────────────┴──────────────┴──────────────────────┘

refcount 记录有多少个变量指向这块内存。byte_len 是字节数组的长度。bytes 存放 UTF-8 编码的内容。

Sprout 按字节长度计量字符串,不提供按字符索引的操作。"Hello, 世界!" 占 14 个字节——“Hello, " 共 7 个 ASCII 字节(含逗号后的空格),“世"和"界"各占三字节,”!” 一字节。语言层面暴露的 len() 返回 14 而不是 10。这是一个明确的设计选择:把字节索引冒充字符索引会在多字节字符的边界产生切割错误,而真正的 Unicode 字符计数涉及规范化和字形簇,不属于本阶段的目标。

分配一个字符串字面量时,编译器生成的代码调用 malloc(16 + byte_len),在前 8 字节写入 1(初始引用计数),接下来 8 字节写入长度,随后拷贝字面量内容。返回给变量的指针指向这整个块的起始地址。

retain 和 release

两个操作构成引用计数的全部机制:

  • retain:将 refcount 加一。发生在赋值、参数传递等"多了一个持有者"的场合。
  • release:将 refcount 减一。如果减到零,调用 free 释放整块内存。发生在变量离开作用域、被覆盖赋值等"少了一个持有者"的场合。

翻译到 LLVM IR 时,retain 是一条 loadadd 1store;release 是 loadsub 1store,然后检查结果是否为零,为零则跳入 free 分支。

这套机制同样适用于第 15 篇引入的数组。数组的堆块头部可以增加一个 refcount 字段,赋值时 retain,离开作用域时 release,逻辑与字符串完全一致。本篇以字符串为例展开,但 retain/release 的插入规则和作用域清理策略对所有堆分配类型通用。

赋值与别名

考虑这段程序:

1
2
3
4
5
6
fn main() -> i64 {
let a: string = "hello"; // malloc, refcount=1
let b: string = a; // retain(a), refcount=2
// 作用域结束:release(b), refcount=1; release(a), refcount=0, free
return 0;
}

let b = a 不分配新内存。b 获得和 a 相同的指针,编译器在赋值处插入 retain。作用域结束时,先 release b(refcount 降到 1),再 release a(refcount 降到 0,执行 free)。

覆盖赋值需要正确处理引用计数:

1
2
let mut s: string = "first";   // malloc, refcount=1
s = "second"; // retain("second"), release("first"), 将 "second" 写入 s

编译器在赋值语句处生成三步:(1) 对新值调用 retain,(2) 对旧值调用 release,(3) 将新值写入目标。这个顺序确保即使新旧值是同一个对象(自赋值 s = s),引用计数也不会意外归零——retain 先把计数加到 2,release 再降回 1,对象始终存活。这是 Swift ARC 和 Objective-C ARC 采用的标准协议。

作用域清理

每个作用域结束时,编译器必须释放该作用域内所有活跃的字符串变量。这包括三种退出路径:

  1. 块末尾正常退出:按声明逆序依次 release。
  2. 显式 return:release 当前作用域和所有外层作用域中的活跃字符串,然后返回。
  3. 条件分支后合流:分支内声明的字符串在分支结束时释放,外层的不受影响。

提前返回是最容易出错的场景。假设:

1
2
3
4
5
6
7
8
9
fn example() -> i64 {
let a: string = "outer";
if true {
let b: string = "inner";
return 0; // 必须 release b,然后 release a
}
// 正常路径:release a
return 1;
}

return 0 处的清理代码需要释放 b(当前 if 作用域)和 a(函数作用域)。编译器遍历从当前作用域到函数顶层的所有嵌套层,收集每一层中活跃的字符串变量,逐一插入 release 调用。

这些释放指令由编译器在 IR 生成阶段自动插入,程序员不写任何析构代码。每个变量在每条退出路径上恰好被 release 一次——不多也不少。多了是 double-free,少了是泄漏。

防止 double-free

编译器怎样保证每条路径恰好一次 release?关键在于:release 点的生成绑定在 CFG 的结构上,而不是运行时的条件上。

每个基本块的终结指令(retbr)之前,编译器检查该块退出时哪些字符串变量仍然活跃。活跃性由 SSA 的数据流分析确定——如果一个变量的值在某个退出点之后不再被使用,它就需要在这里 release。

对于 if-else 两个分支各自 return 的情况,两条路径各自生成自己的清理序列,互不干扰。不存在"两条路径都 release 同一个变量"的情况,因为控制流不会同时走两条路。

具体到 LLVM IR,每个 return 前的清理块大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
cleanup_early_ret:
; release b
%rc_b = load i64, ptr %b_ptr
%rc_b1 = sub i64 %rc_b, 1
store i64 %rc_b1, ptr %b_ptr
%dead_b = icmp eq i64 %rc_b1, 0
br i1 %dead_b, label %free_b, label %release_a

free_b:
call void @free(ptr %b_ptr)
br label %release_a

release_a:
; release a ... 同样的模式

泄漏测试

验收方式直截了当:包装 mallocfree,分别递增全局计数器。程序结束时,两个计数器必须相等。

1
2
3
4
5
6
7
8
9
10
11
fn main() -> i64 {
let a: string = "hello";
let b: string = a;
let mut c: string = "world";
c = b;
if true {
let d: string = "temp";
return 0;
}
return 1;
}

这个程序涉及别名(b = a)、覆盖赋值(c = b)和提前返回(if 内的 return)。运行后检查 malloc 次数等于 free 次数,且程序不崩溃。如果任何路径漏掉了 release,计数器会不平衡;如果多释放了一次,程序在 free 时就会崩溃或被内存检测工具捕获。

练习与资料

追踪以下程序中每个字符串对象的引用计数变化,标注每一步是 retain 还是 release,写出每条退出路径上的 free 时机:

1
2
3
4
5
6
7
8
9
10
fn choose(flag: bool) -> i64 {
let a: string = "alpha"; // ?
let b: string = "beta"; // ?
let mut r: string = a; // ?
if flag {
r = b; // ?
return 0; // 清理什么?
}
return 1; // 清理什么?
}

提示:r = b 处需要先 retain b(“beta”),再 release r 的旧值(“alpha”),最后将 b 的指针写入 r。两条 return 路径各自需要释放不同的引用集合。

Sprout 禁止递归堆类型和闭包,因此引用关系构成有向无环图,引用计数不会出现循环导致的永久泄漏。闭包和递归对象的内存管理需要追踪式垃圾回收,不在本系列主线范围内。

Apple 的 Objective-C ARC 文档描述了编译器自动插入 retain/release 的工业实践;Swift 的 ARC 章节解释了强引用和引用环的关系。本篇的实现远比它们简单,但核心思路——编译器而非程序员负责插入内存操作——是一致的。

上一篇:15 - 数据布局与受检数组。下一篇:17 - 结构体、枚举与模式匹配