Sway 常见集合(Common Collections)完整实战指南:堆上 Vec、持久化 StorageVec 与 StorageMap 源码级解析
【免费下载链接】sway🌴 Empowering everyone to build reliable and efficient smart contracts.项目地址: https://gitcode.com/GitHub_Trending/sw/sway
本文是 Sway 语言官方书籍《Common Collections》一章的中文深度指南,核心围绕 Sway 标准库中最常用的三类集合展开:堆上动态数组Vec<T>、持久化存储向量StorageVec<T>与键值映射StorageMap<K, V>。你将从本文掌握如何在脚本、谓词与合约中创建、更新、读取和迭代这些集合,理解它们的内存模型(堆 vs 合约存储)差异,并结合 sway-lib-std 的源码实现,弄清push/get/insert等核心方法背后的容量增长、存储槽位与泛型机制,最终能够为"购物车价格列表""钱包余额账本"等真实合约场景选择恰当的集合。
一、集合总览:为什么需要它们?
大多数 Sway 数据类型只代表一个具体的值,而集合(collections)可以容纳多个值。与内置的数组(array)和元组(tuple)不同——它们被分配在"栈"上且大小在编译期固定、无法增长——集合所指向的数据存储在"堆"(heap)或合约"存储"(storage)中,这意味着数据量无需在编译期确定,可以在程序运行期间增长。
每种集合具有不同的能力与成本,在 Sway 程序中,最常用的三类集合如下:
| 集合类型 | 数据存放位置 | 核心用途 | 能否在脚本/谓词中使用 |
|---|---|---|---|
Vec<T>(堆上向量) | 堆(heap) | 在内存中相邻存放可变数量的同类型值 | ✅ 可以(不依赖合约存储) |
StorageVec<T>(存储向量) | 合约持久化存储(storage) | 带索引的持久化同类型值列表 | ❌ 仅限合约(只有合约能访问持久化存储) |
StorageMap<K, V>(存储映射) | 合约持久化存储(storage) | 用任意类型键K关联值V | ❌ 仅限合约 |
下面依次深入这三类集合的创建、更新、读取与迭代,并穿插源码实现细节。
二、Vec<T>:堆上的动态数组
Vec<T>允许在单个数据结构中存放多个值,且这些值在内存中相邻排列。它只能存储同类型的值,非常适合"文件中的文本行""购物车中商品的价格"这类列表场景。
Vec<T>已包含在标准库 prelude 中,无需手动导入。从源码可以确认这一点:sway-lib-std/src/prelude.sw 中pub use ::vec::{Vec, VecIter};将其直接暴露给所有程序。
2.1 创建新的向量
调用Vec::new创建空向量:
let v: Vec<u64> = Vec::new();这里必须添加类型注解,因为没有插入任何值时,编译器无法推断元素类型。Vec<T>基于泛型实现,标准库提供的Vec<T>可以容纳任意类型;在尖括号中指定类型即可告诉编译器v将存放u64元素。
从源码看,Vec::new只是构造了一个"零容量"的向量:sway-lib-std/src/vec.sw 中Vec<T>由buf: RawVec<T>(内部堆缓冲)与len: u64(当前长度)组成,RawVec::new调用alloc::<T>(0)分配 0 容量——向量在 push 元素之前不会真正分配堆内存。如果你提前知道要存放的元素数量,可以使用Vec::with_capacity(capacity)预分配容量,避免后续反复扩容。
2.2 更新向量:push追加元素
let mut v = Vec::new(); v.push(5); v.push(6); v.push(7); v.push(8);与任何变量一样,想要修改向量的值必须使用mut关键字(参见声明变量)。这里的数字都是u64,编译器从数据中自行推断类型,因此无需Vec<u64>注解。
源码级原理:push的实现(sway-lib-std/src/vec.sw)在写入前会检查self.len == self.buf.cap,若容量不足则调用RawVec::grow。而grow(vec.sw)采用翻倍策略:new_cap = if self.cap == 0 { 1 } else { 2 * self.cap },然后通过realloc在堆上分配新缓冲并把旧数据拷贝过去。这正是向量"无需编译期确定大小、可随程序运行增长"的实现基础。
2.3 读取元素:get与Option<T>
通过索引读取元素使用get方法:
let third = v.get(2); match third { Some(third) => log(third), None => revert(42), }注意两个细节:
- 索引从0开始,索引值
2取到的是第三个元素; get方法传入索引参数,返回的是Option<T>。
当get传入的索引越界时,它返回None而不会 panic/revert:
let does_not_exist = v.get(100); // ...decide here how to handle an out-of-bounds access这在"越界偶尔会在正常逻辑下发生"的场景特别有用:例如索引来自合约方法参数,参数过大时get返回None,合约方法可以据此选择 revert,或返回一个包含当前向量长度的有意义错误,给用户重新传入合法值的机会。get的实现(vec.sw)先做if self.len <= index { return None; }的边界检查,再通过指针偏移读取,是安全的越界防护路径。
2.4 迭代向量中的值
使用while循环配合len方法按合法索引遍历:
let mut i = 0; while i < v.len() { log(v.get(i).unwrap()); i += 1; }这里有两个细节:len返回向量的长度;对get返回的Option调用unwrap取出元素。由于每个i都已知小于向量长度,unwrap不会失败(不会引发 revert)。
更符合惯例、更便捷的方式是for循环搭配iter方法——iter返回一个按顺序遍历所有元素的迭代器:
for elem in v.iter() { log(elem); }⚠️ 重要警告:在迭代过程中修改向量(例如添加或删除元素)属于逻辑错误,会导致未定义行为(undefined behavior):
for elem in v.iter() { log(elem); if elem == 3 { v.push(6); // Modification causes undefined behavior! } }为什么这是未定义行为?看VecIter的实现(vec.sw)即可理解:由于 Sway 的复制语义,iter()返回的迭代器内部保存的是向量的一个"副本",next中检查的长度是创建迭代器那一刻的长度。如果在迭代中修改原向量,修改不会反映到self.values.len上,遍历结果将与直觉不符。因此对迭代期修改的唯一正确态度就是——不要这样做。
while循环仅在需要对遍历施加更多控制时使用。例如下面的例子从尾部开始、只访问每隔一个的元素:
// Start from the end let mut i = v.len() - 1; while 0 <= i { log(v.get(i).unwrap()); // Access every second element i -= 2; }2.5 用枚举存储多种类型
向量只能存同类型值,这有时很不方便——例如需要存一组不同类型元素的列表。好在枚举的所有变体都定义在同一枚举类型下,因此可以用一个枚举来代表不同类型的元素。
例如,我们需要从表格的一行中取值,该行的列有的存整数、有的存b256、有的存布尔值:
enum TableCell { Int: u64, B256: b256, Boolean: bool, } let mut row = Vec::new(); row.push(TableCell::Int(3)); row.push(TableCell::B256(0x0101010101010101010101010101010101010101010101010101010101010101)); row.push(TableCell::Boolean(true));这样row的类型是Vec<TableCell>,从"类型系统"的角度看所有元素同属TableCell,但实际承载了三种不同的数据类型。
2.6 更多Vec<T>方法
除push外,标准库为Vec<T>提供了丰富的常用方法,完整列表见 sway-lib-std/src/vec.sw。几个典型方法的行为与边界条件如下:
| 方法 | 行为 | 越界/空时的行为 |
|---|---|---|
pop | 移除并返回最后一个元素 | 空向量返回None |
remove(index) | 移除并返回指定索引处的元素,后续元素左移 | 索引越界会assertrevert(vec.sw) |
insert(index, element) | 在指定索引处插入元素,后续元素右移 | 索引大于长度会 revert(vec.sw) |
set(index, value) | 覆盖指定索引处的元素 | 索引越界 revert |
len/is_empty | 返回长度 / 是否为空 | — |
clear | 清空所有元素(不释放已分配容量) | — |
swap(a, b) | 交换两个索引处的元素 | 任一索引越界 revert |
last | 返回最后一个元素 | 空向量返回None |
resize(new_len, value) | 扩容时用value填充、缩小时截断 | — |
另外Vec<T>还实现了AbiEncode/AbiDecode(vec.sw),因此可以直接作为 ABI 方法参数/返回值在合约间传递。
三、StorageVec<T>:持久化存储向量
第二个集合是StorageVec<T>。与堆上的Vec<T>一样,StorageVec<T>允许多个同类型值存放在单一数据结构中,每个值分配一个索引;但与Vec<T>不同的是,StorageVec的元素存放在持久化存储(persistent storage)中,且连续元素并不一定存放在键连续的存储槽位(storage slots)里。
使用StorageVec<T>前必须手动导入:
use std::storage::storage_vec::*;另一个重要区别是:StorageVec<T>只能在合约中使用,因为只有合约才有权访问持久化存储。
3.1 创建新的StorageVec
必须在storage块中声明向量:
storage { v: StorageVec<u64> = StorageVec {}, }与任何存储变量一样,声明StorageVec需要两样东西:类型注解和初始化器。初始化器只是一个空的StorageVec结构体——因为StorageVec<T>本身就是一个空结构体!它的一切有趣行为都实现在方法中。
从源码可以印证:sway-lib-std/src/storage/storage_vec.sw 中pub struct StorageVec<V> {}是一个零大小的存储类型(zero-sized storage type),可以嵌套在其他存储类型内部(如StorageVec<StorageVec<u64>>、StorageVec<StorageMap<u64, b256>>)。
StorageVec<T>同样基于泛型实现,尖括号中指定具体类型即可。与Vec<T>一样,StorageVec也是通过storage.v这样的语法访问的。
3.2 更新:push与 storage 注解
#[storage(read, write)] fn push_to_storage_vec() { storage.v.push(5); storage.v.push(6); storage.v.push(7); storage.v.push(8); }两个细节:
- 使用
push前需要先用storage关键字访问向量; push需要访问存储,因此调用push的ABI 函数必须带storage注解。虽然表面上#[storage(write)]似乎就够了,但read注解同样必需——因为每次push都要读取(然后更新)StorageVec的长度,而这个长度本身也存放在持久化存储中。
注意:合约中任何尝试向向量 push 的私有函数同样需要 storage 注解。
注意:声明
StorageVec<T>时无需加mut关键字——所有存储变量默认都是可变的。
源码级原理:在非动态存储模式下(experimental_dynamic_storage = false),StorageVec的方法实现(storage_vec.sw)始终使用self.field_id作为存储槽位,field_id位置保存向量的长度,而实际内容存储在sha256(self.field_id)派生的槽位上。push方法在 storage_vec.sw 中被标注为:3 次存储读取、2 次存储写入——这就是"每次 push 都要读长度"在 gas 成本层面的直接体现。
3.3 读取元素:get与Option<StorageKey<T>>
#[storage(read)] fn read_from_storage_vec() { let third = storage.v.get(2); match third { Some(third) => log(third.read()), None => revert(42), } }注意三个细节:
- 索引从0开始,索引值
2是第三个元素; get返回的是Option<StorageKey<T>>,需要再调用一次.read()才能真正读出存储中的值;- 调用
get的 ABI 函数只需#[storage(read)]注解——get不写存储,正如预期。
与Vec::get一致,当索引越界时StorageVec::get返回None而不 panic,便于合约方法处理"索引来自外部参数、偶尔越界"的正常情况(revert 或返回带长度的错误信息)。
3.4 迭代存储向量
迭代StorageVec与迭代Vec<T>概念上一致,唯一区别是需要额外的read()调用才能真正读出存储的值:
#[storage(read)] fn iterate_over_a_storage_vec() { // 用 while 循环逐个遍历(不推荐的方式) let mut i = 0; while i < storage.v.len() { log(storage.v.get(i).unwrap().read()); i += 1; } // 首选且最高效的方式:for 循环 for elem in storage.v.iter() { log(elem.read()); } // 仅在需要更多遍历控制时使用 while // 例如从尾部开始、只访问每隔一个的元素 let mut i = storage.v.len() - 1; while 0 <= i { log(storage.v.get(i).unwrap().read()); i -= 2; } }⚠️ 重要警告:迭代过程中修改存储向量(增删元素)同样是逻辑错误,会导致未定义行为。
3.5 用枚举存储多种类型
StorageVec与Vec一样只能存同类型值。定义枚举后再声明StorageVec来"间接"存多种类型:
enum TableCell { Int: u64, B256: b256, Boolean: bool, } storage { row: StorageVec<TableCell> = StorageVec {}, }接着可以向该StorageVec压入不同的枚举变体:
#[storage(read, write)] fn push_to_multiple_types_storage_vec() { storage.row.push(TableCell::Int(3)); storage .row .push(TableCell::B256(0x0101010101010101010101010101010101010101010101010101010101010101)); storage.row.push(TableCell::Boolean(true)); }3.6 嵌套存储向量
StorageVec支持嵌套:
storage { nested_vec: StorageVec<StorageVec<u64>> = StorageVec {}, }访问嵌套向量:
#[storage(read, write)] fn access_nested_vec() { storage.nested_vec.push(StorageVec {}); storage.nested_vec.push(StorageVec {}); let mut inner_vec0 = storage.nested_vec.get(0).unwrap(); let mut inner_vec1 = storage.nested_vec.get(1).unwrap(); inner_vec0.push(0); inner_vec0.push(1); inner_vec1.push(2); inner_vec1.push(3); inner_vec1.push(4); assert(inner_vec0.len() == 2); assert(inner_vec0.get(0).unwrap().read() == 0); assert(inner_vec0.get(1).unwrap().read() == 1); assert(inner_vec0.get(2).is_none()); assert(inner_vec1.len() == 3); assert(inner_vec1.get(0).unwrap().read() == 2); assert(inner_vec1.get(1).unwrap().read() == 3); assert(inner_vec1.get(2).unwrap().read() == 4); assert(inner_vec1.get(3).is_none()); }源码提示:StorageVec的嵌套能力并非无限制。根据 storage_vec.sw 的文档注释,部分方法对嵌套存储类型(如StorageVec<StorageString>)不适用:例如remove会 revert;pop虽能弹出最后一个元素但总是返回None且不会从存储中移除。另外在启用动态存储(experimental_dynamic_storage = true)时,元素类型V的大小必须小于等于 1024 字节,否则属于未定义行为、大概率运行时 revert。
四、StorageMap<K, V>:存储映射
StorageMap<K, V>是第三种重要集合。标准库中的StorageMap<K, V>通过哈希函数决定如何把键K和值V放入存储槽位,这类似于 Rust 的HashMap<K, V>,但存在若干差异(最核心的差异是:它持久化于合约存储而非内存)。
存储映射适用于不按索引、而按任意类型键查找数据的场景。例如实现一个基于账本的子货币智能合约时,可以用存储映射记录每个钱包的余额——键是钱包的Address,值是余额;给定一个Address即可取出其余额。
与StorageVec<T>一样,StorageMap<K, V>只能在合约中使用。它包含在标准库 prelude 中(prelude.sw 中pub use ::storage::storage_map::*;),无需手动导入。
4.1 创建新的存储映射
在storage块中声明:
storage { map: StorageMap<Address, u64> = StorageMap::<Address, u64> {}, }与任何存储变量一样需要类型注解 + 初始化器;初始化器是空的StorageMap结构体,因为StorageMap<K, V>本身就是空结构体,一切行为都在方法中。存储映射基于泛型实现,键K和值V都可以是任意类型。上面的声明告诉编译器:map将把Address键映射到u64值。
4.2 更新:insert与#[storage(write)]
向映射插入键值对使用insert方法:
#[storage(write)] fn insert_into_storage_map() { let addr1 = Address::from(0x0101010101010101010101010101010101010101010101010101010101010101); let addr2 = Address::from(0x0202020202020202020202020202020202020202020202020202020202020202); storage.map.insert(addr1, 42); storage.map.insert(addr2, 77); }两个细节:
- 使用
insert前需要先用storage关键字访问映射; insert需要写入存储,调用它的 ABI 函数必须带#[storage(write)]注解。
注意:合约中任何尝试向映射 insert 的私有函数同样需要 storage 注解。
注意:声明
StorageMap<K, V>时无需加mut关键字——所有存储变量默认都是可变的。
4.3 读取值:get、try_read与Option
通过键取出值使用get方法:
#[storage(read, write)] fn get_from_storage_map() { let addr1 = Address::from(0x0101010101010101010101010101010101010101010101010101010101010101); let addr2 = Address::from(0x0202020202020202020202020202020202020202020202020202020202020202); storage.map.insert(addr1, 42); storage.map.insert(addr2, 77); let value1 = storage.map.get(addr1).try_read().unwrap_or(0); }这里value1将是与第一个地址关联的值42。get返回的是一个指向存储槽位的键封装(实际可解出Option<V>语义):若映射中没有该键的值,get链上的读取将返回None。上面的程序通过unwrap_or处理Option——如果map中没有该键的条目,就把value1置为0。这也是"余额账本"场景的典型写法:查不到余额时默认返回 0。
4.4 多键映射:元组作为键
使用元组作键即可实现多键映射:
storage { map_two_keys: StorageMap<(b256, bool), b256> = StorageMap::<(b256, bool), b256> {}, }4.5 嵌套存储映射
存储映射支持嵌套:
storage { nested_map: StorageMap<u64, StorageMap<u64, u64>> = StorageMap::<u64, StorageMap<u64, u64>> {}, }访问嵌套映射:
#[storage(read, write)] fn access_nested_map() { storage.nested_map.get(0).insert(1, 42); storage.nested_map.get(2).insert(3, 24); assert(storage.nested_map.get(0).get(1).read() == 42); assert(storage.nested_map.get(0).get(0).try_read().is_none()); // Nothing inserted here assert(storage.nested_map.get(2).get(3).read() == 24); assert(storage.nested_map.get(2).get(2).try_read().is_none()); // Nothing inserted here }注意嵌套访问的链式写法:storage.nested_map.get(0)先定位到外层键0对应的内层映射,再对其调用insert/get;try_read()则用于"不确定该键是否存在"的安全读取,assert(...is_none())验证未插入的键确实返回空。
五、场景选型与工程实践
综合三节内容,选择集合的核心判据是数据是否需要在交易之间持续存在:
- 纯内存、过程内计算(脚本、谓词、合约内的临时计算):用
Vec<T>。它零初始化成本,push触发翻倍扩容,适合排序、遍历、临时聚合等场景,还能通过AbiEncode/AbiDecode直接跨 ABI 传递。 - 跨交易的持久化列表(如合约维护的待处理项、历史记录):用
StorageVec<T>。每个push都要读写存储(约 3 读 2 写),成本高于堆向量,但数据在交易间永续存在。 - 按任意键查找的账本型数据(如地址 → 余额、资产 ID → 供应量):用
StorageMap<K, V>。哈希定位槽位、天然支持Address/b256/元组键,配合try_read().unwrap_or(default)可优雅处理缺失键。
本仓库中还有大量与集合配套的可运行示例,建议动手实验:堆向量完整示例见 examples/vec/src/main.sw,存储向量示例见 examples/storage_vec/src/main.sw,存储映射示例见 examples/storage_map/src/main.sw,对应的Forc.toml都位于各自示例目录下,可通过forc build直接构建验证。在编写实际合约时,请同时注意 storage 注解的读写标注(push/insert需read, write或write,纯读取需read),并严格遵守"迭代期间不修改集合"的约束,以避免未定义行为。
【免费下载链接】sway🌴 Empowering everyone to build reliable and efficient smart contracts.项目地址: https://gitcode.com/GitHub_Trending/sw/sway
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考