9. 指针、修改值和生命周期
学习取地址、解引用、通过指针修改值以及悬空指针。
Wave Foundation
区分值和存储位置
整数 42 和存储该整数的地址是两个不同的值。指针指向存储位置。传递地址可以让函数读取或修改调用者的存储。
本章介绍取地址、解引用、修改原始值、指针算术和生命周期。动态分配将在下一章介绍。我们先从局部变量和数组元素的地址开始。
获取地址并解引用
fun main() {
var value: i32 = 42;
var address: ptr<i32> = &value;
println("value={}", value);
println("through pointer={}", deref address);
deref address = 99;
println("changed={}", value);
}
执行结果:
value=42
through pointer=42
changed=99
&value 获取地址,deref address 读取或写入该地址中的值,依此类推。99 并不是存储在 address 中,而是写入 address 所指向的整数。变量 address 本身仍然指向 value。
ptr<i32> 是用于访问 i32 存储的指针类型。该类型不记录长度,也不提供自动释放。
重新赋值指针
fun main() {
var first: i32 = 10;
var second: i32 = 20;
var selected: ptr<i32> = &first;
selected = &second;
deref selected = 25;
println("{} {}", first, second);
}
执行结果:
10 25
selected = &second 将另一个地址存储到指针变量中。first 的值不会改变。之后,通过 deref 写入值时,second 会发生变化。将“更改地址”和“通过地址更改值”分开说明,可以减少混淆。
让函数修改原始值
fun increment(value: ptr<i32>) {
deref value = deref value + 1;
}
fun main() {
var count: i32 = 4;
increment(&count);
increment(&count);
println("{}", count);
}
执行结果:
6
该函数接收的是 count 的地址,而不是值 4。修改该存储也会修改调用者中的 count。该函数需要一个有效且可写的 i32 地址。传入 null 不符合此要求。
每个函数都会定义自己是否接受 null。如果不接受,调用者必须提供有效地址;如果接受,函数就必须包含处理 null 的分支。
处理 null 指针
fun try_increment(value: ptr<i32>) -> bool {
if (value == null) {
return false;
}
deref value = deref value + 1;
return true;
}
fun main() {
var count: i32 = 7;
if (!try_increment(null)) {
println("no value");
}
if (try_increment(&count)) {
println("count={}", count);
}
}
执行结果:
no value
count=8
null 检查只能处理地址缺失的情况。将任意非 null 数字转换为指针,并不会创建有效内存。读取和写入还要求目标存储处于有效的生命周期内,具有足够的大小、正确的对齐方式以及适当的访问权限。
数组地址和逐元素指针算术
fun main() {
var values: array<i32, 3> = [10, 20, 30];
var first: ptr<i32> = &values[0];
var second: ptr<i32> = first + 1;
println("{}", deref first);
println("{}", deref second);
println("{}", deref first[2]);
}
执行结果:
10
20
30
给指针加 1,会使它按目标类型移动一个元素。i32 的下一个元素和 u8 的下一个元素需要移动的字节数不同。如果再次将 first + 1 乘以类型大小并加到它上面,指针就会移到错误的位置。
指针索引也必须在有效范围内进行。first 本身不会记住数组长度 3,因此将这段范围传给函数时,需要同时传递指针和长度。
将可读范围传递给函数
fun sum(values: ptr<i32>, count: i32) -> i32 {
var total: i32 = 0;
for (var index: i32 = 0; index < count; index += 1) {
total += deref values[index];
}
return total;
}
fun main() {
var values: array<i32, 4> = [2, 4, 6, 8];
println("first two={}", sum(&values[0], 2));
println("all={}", sum(&values[0], 4));
}
执行结果:
first two=6
all=20
count 的单位是元素个数。此函数要求调用者提供包含 count 个可读 i32 的范围。传入大于实际数组长度的值违反了约定。即使 API 使用 ptr<u8> 与 i64 的组合,也应在文档中确认长度的单位是字节还是元素。
生命周期:地址的有效期有多长?
局部变量只在函数调用和代码块的生命周期内有效。如果返回函数内部局部变量的地址,供调用者稍后读取,那么该存储空间可能已经结束了生命周期。
下面是一个不应实现的糟糕设计:
fun invalid_address() -> ptr<i32> {
var local: i32 = 42;
return &local;
}
如果只需要返回一个值,就返回 i32。如果需要写入调用者提供的存储空间,就将指针作为输入。如果需要独立的存储空间,以便在函数调用结束后继续保留内容,就显式进行分配,并移交释放它的责任。
两个指针指向同一存储空间
复制指针会创建一个指向同一地址的别名,但不会复制内存。
fun main() {
var value: i32 = 1;
var first: ptr<i32> = &value;
var second: ptr<i32> = first;
deref second = 9;
println("{} {}", value, deref first);
}
执行结果:
9 9
通过 second 做出的修改也可以通过 first 看到。一旦源存储被释放,两个指针都会变得不可用。将 null 赋给一个指针变量,不会自动改变其他副本。
练习:交换两个整数
编写一个函数,接收两个 i32 地址并交换它们的值。第一个值必须先存入临时变量,才能覆盖原值。检查两次传入同一个地址时,该值是否仍能保留。
完整解答
fun swap(left: ptr<i32>, right: ptr<i32>) {
var saved: i32 = deref left;
deref left = deref right;
deref right = saved;
}
fun main() {
var first: i32 = 3;
var second: i32 = 8;
swap(&first, &second);
println("{} {}", first, second);
swap(&first, &first);
println("{}", first);
}
执行结果:
8 3
8
该函数还要求两个地址都指向有效且可写的整数存储。要处理 null,请像 try_increment 一样添加一个表示成功或失败的返回结果。
Wave 显式内存类型模型
Wave 的指针设计基于 Wave 显式内存类型模型。该模型将指针和数组定义为语言层面的显式内存类型,而不是语法技巧或库抽象。
ptr<T> 是指向存储 T 值的内存地址的类型;array<T, N> 是定长内存类型,连续存储 N 个 T 值。因此,指针和数组的结构会原样显示在函数参数、返回值、结构字段和其他类型中。
null
var buffer: ptr<u8> = null;
if (buffer == null) {
println("no buffer");
}
null 是一个不指向有效内存地址的指针值。null 只能赋给 ptr<T> 类型,不能用作整数、布尔值或数组值。
当分配或查找函数没有结果时,可以返回 null。在解引用这类结果之前,应先检查 null。解引用 null 指针不会访问有效存储。
指针转换
当需要转换地址或其他指针表示形式时,请使用 as。
var raw: i64 = 0;
var p: ptr<u8> = raw as ptr<u8>;
仅在低级边界处使用整数与指针之间的转换,并考虑目标平台的地址宽度和 ABI。