Sway StorageMap 存储映射实战:键值对持久化存储的声明、读写与底层原理
【免费下载链接】sway🌴 Empowering everyone to build reliable and efficient smart contracts.项目地址: https://gitcode.com/GitHub_Trending/sw/sway
导读
StorageMap是 Sway 标准库提供的一种持久化键值对(key-value)存储结构,常被形象地称为哈希表(hash table)。在 Sway 智能合约开发中,它是实现「地址 → 余额」「用户 → 权限」这类映射关系的核心工具:数据被永久写入链上存储(storage),并可通过键在常数时间O(1)内完成定位。本文将基于本仓库 Sway 参考文档(storage-map.md)及其配套示例,系统讲解StorageMap的声明、读取、写入三大操作,并结合标准库源码剖析其底层存储槽计算、Option 语义与完整方法集,帮助你写出可复用的映射式合约存储逻辑。
StorageMap 是什么:以键定位值的持久化哈希表
StorageMap是一种将值v与键k关联起来的数据结构。键k被用来在存储(storage)中定位值v所在的位置。与传统内存哈希表不同,Sway 的StorageMap的数据是持久化的——它直接写入链上存储槽,而不是堆(heap)内存。
哈希表最核心的收益在于查找效率:无论值位于表的哪个位置,定位该值所需的计算量都是常数级别的,即时间复杂度为O(1)。在链上合约场景中,这意味着读取某个用户余额或查询某个标识对应的状态,不会因为数据量的增长而线性变慢。
StorageMap的一个显著特点是高度的灵活性——它使用泛型(generics)同时约束键k和值v的类型。Sway 语言层面的泛型机制可参考 generics 文档。唯一的前提约束是:k与v都必须是单个值(single value)。这个「单个值」并不等同于单一基础类型——值v可以是结构体、元组、数组等复合类型。因此,如果你需要用一个复杂结构作为键或值,只需要把数据包装(wrap)进一个单一类型即可,StorageMap仍然能够正常工作。
从源码结构看,标准库中
StorageMap定义为pub struct StorageMap<K, V> {}(storage_map.sw),是一个零尺寸(zero-sized)存储类型,可以嵌套在其他存储类型内部使用,例如StorageMap<K, StorageMap>这样的嵌套映射。
声明 StorageMap:泛型键值对与 prelude 导入
无需手动导入
StorageMap类型被包含在标准库的 prelude 中,因此声明时不需要额外use导入。如果在后续代码中需要使用msg_sender()来获取调用者身份,则需要显式导入相关路径(示例中通过use std::hash::*;引入哈希与发送者相关能力,见 main.sw)。
初始化写法
StorageMap在storage块中按初始化指南的方式声明:每个变量被命名、关联类型并给出默认值。示例代码如下(完整文件见 storage_map/src/main.sw):
storage { // k = Identity, v = u64 balance: StorageMap<Identity, u64> = StorageMap::<Identity, u64> {}, // k = (Identity, u64), v = bool user: StorageMap<(Identity, u64), bool> = StorageMap::<(Identity, u64), bool> {}, }示例中声明了两个 storage 变量:
balance:以单个值作为键,映射类型为StorageMap<Identity, u64>,即用Identity(调用者身份)映射到u64(余额)。user:将两个值包装成一个元组(Identity, u64)作为键,映射类型为StorageMap<(Identity, u64), bool>,即用「身份 + 用户编号」的组合唯一标识一个用户及其状态。
第二行正体现了上文提到的「复杂键需包装为单一类型」的设计:把两个字段打包进一个元组,就能组合成复合键。声明语法要求显式写出初始化表达式StorageMap::<K, V> {},这是空映射的字面量构造方式。
从存储中读取:.get(key)与 Option 语义
基本用法
从存储中检索数据,通过.get(key)方法完成:指定要读取的 storage 变量,在末尾追加.get(),并在括号内传入想要检索的数据的键。示例代码(main.sw):
#[storage(read)] fn reading_from_storage(id: u64) { let user = storage.user.get((msg_sender().unwrap(), id)).read(); }在这个示例中,合约把调用者的Identity(通过msg_sender().unwrap()取得)与调用者提供的id包装成一个元组,作为复合键传入.get(),从而读取storage.user中该键对应的值。
返回Option,配合unwrap_or兜底
.get(key)返回一个Option:如果映射中不存在key对应的值,get会返回None。因此读取后必须处理可能为空的情况。参考文档明确指出,示例合约通过调用unwrap_or来处理返回的Option——当映射user中没有该键的条目时,将用户状态置为零值兜底。
需要特别说明的是:.get()返回的是StorageKey<V>,对其调用.read()才能得到最终的Option<V>;在需要默认值时可在read()结果上继续调用unwrap_or(default),这是文档所描述的「将user设为零」的完整链路。完整的读取-兜底模式可写为:
let user = storage.user.get((msg_sender().unwrap(), id)).read().unwrap_or(false);存储纯度标注
读取函数必须标注#[storage(read)]属性,表明该函数仅读取存储、不修改存储。这与读写指南中「处理存储时必须使用 storage 注解指示函数纯度」的要求一致。Sway 编译器会依据注解执行纯度检查,防止在只读上下文中意外写入存储。
写入存储:.insert(key, value)与读改写模式
基本用法
写入存储与读取类似,区别在于使用的方法变成了.insert(key, value)。示例代码(main.sw):
#[storage(read, write)] fn writing_to_storage() { let balance = storage.balance.get(msg_sender().unwrap()).read(); storage.balance.insert(msg_sender().unwrap(), balance + 1); }这个示例实现了一个典型的「读-改-写」(read-modify-write)流程:
- 先通过
.get(msg_sender().unwrap()).read()读取当前调用者的余额; - 将其加 1;
- 再通过
.insert(msg_sender().unwrap(), balance + 1)把新余额写回storage.balance。
因为insert会修改存储,所以函数需要标注#[storage(read, write)],同时声明了读取与写入两种纯度。
关于默认值的一个细节
示例代码第 23 行直接对.read()的结果(Option<u64>)执行+ 1,从源码可见该示例默认调用者的余额已存在(即此前已insert过)。在实际生产代码中,若映射可能尚未初始化,建议先通过unwrap_or(0)提供默认值,再执行算术运算,以避免对None的处理缺失:
#[storage(read, write)] fn safe_increment() { let current = storage.balance.get(msg_sender().unwrap()).read().unwrap_or(0); storage.balance.insert(msg_sender().unwrap(), current + 1); }标准库源码剖析:存储槽如何由键计算而来
StorageMap的实现位于标准库 storage_map.sw,它建立在更底层的StorageKey与storage_api抽象之上(模块导出见 storage.sw)。理解其内部机制有助于写出更高效的存储代码。
键到存储槽的哈希推导
StorageMap中键k到存储槽(slot)的映射不是线性地址,而是通过哈希函数推导而来。核心逻辑在get_slot_key中(storage_map.sw):
fn get_slot_key(self, key: K) -> b256 { sha256((STORAGE_MAP_DOMAIN, key, self.field_id())) }- 使用
sha256对「域前缀(STORAGE_MAP_DOMAIN)+ 键 + 字段标识(field_id)」的元组求哈希,得到b256类型的存储槽地址; STORAGE_MAP_DOMAIN取值为1u8(storage_map.sw)。源码注释说明:为了防止用户输入(键)的哈希原像与编译器为 storage 字段生成的原像相撞,标准库为存储映射域添加一个单字节前缀,从而在密钥空间中隔离不同的存储域;- 由于同一个
field_id的哈希输入固定,同一键在链上总是映射到同一存储槽,因此StorageMap天然具备确定性——这正是合约状态一致性的基础。
get返回StorageKey<V>
get(key)的返回类型是StorageKey<V>(storage_map.sw),它描述「无论该位置是否真的存有值,键对应的值在存储中的位置」。真正读取值需要通过StorageKey上的.read()方法完成(storage_key.sw):
pub fn read(self) -> T { read_quads::<T>(self.slot, self.offset).unwrap() }read()内部通过read_quads读取对应存储槽,并返回Option<T>解包后的值——若槽位为空则触发回退(revert)。这就是文档中「get 返回Option」的底层来源:Option语义由底层存储读取在「槽未写入」时产生None的机制承载。而try_read()(storage_key.sw)则提供不触发回退、直接返回Option<T>的读取方式,适合需要安全兜底的场景。
写入与清除:insert/remove/try_insert
标准库为StorageMap提供了比文档示例更完整的操作集(默认experimental_dynamic_storage = false分支,storage_map.sw):
insert(key, value):通过write_quads::<V>(key, 0, value)将值写入由get_slot_key推导出的存储槽(storage_map.sw)。注解#[storage(read, write)]表明写入可能需要先读取部分数据以覆盖;其文档注明:当值占满整个槽时读次数为 0,否则为 1(用于读取将被部分覆盖的旧数据),写次数恒为 1。remove(key):返回bool,true表示该键此前存有值,通过clear_quads清除(storage_map.sw)。try_insert(key, value):仅当键尚无值时插入,返回Result<V, StorageMapError<V>>;若键已存在,返回StorageMapError::OccupiedError(旧值)并不覆盖原值(storage_map.sw)。这是实现「首次创建、禁止覆盖」类业务(如一次性注册)的利器。
当
experimental_dynamic_storage = true时,标准库提供另一套基于write_slot/read_slot的实现(storage_map.sw),方法名略有差异(如remove_existed),语义面向动态存储实验特性。
完整方法速查
| 方法 | 签名要点 | 作用 | 存储访问 |
|---|---|---|---|
get(key) | 返回StorageKey<V> | 定位键对应值的存储位置,配合.read()/.try_read()取值 | 0 次(仅计算位置) |
insert(key, value) | 无返回值 | 写入/覆盖键值对 | 写 1;若值未占满槽则读 1 |
remove(key) | 返回bool | 清除键对应值,返回是否存在旧值 | 清除 1 |
try_insert(key, value) | 返回Result<V, StorageMapError<V>> | 键不存在时才写入,否则返回旧值 | 读 1;写入时再写 1 |
实践要点与组合建议
- 复合键的包装:
StorageMap的键、值都必须是单一类型。需要多字段键时,用元组(如(Identity, u64))、结构体或数组包裹;同样,结构体、元组、数组等复合类型都可以直接作为值v存储。 - Option 必处理:
get(key)在键不存在时返回None。读取后使用unwrap_or(default)提供默认值,或使用match显式分支处理,避免直接解包导致合约回退。 - 纯度注解不可缺:只读函数标注
#[storage(read)],读改写函数标注#[storage(read, write)],写入专用函数可标注#[storage(write)]。注解与storage.变量名语法是 Sway 存储安全的两道闸门。 - 优先
try_insert实现幂等写入:对于「账户只能注册一次」等语义,用try_insert天然返回冲突旧值,避免「先 get 再 insert」的竞态窗口。 - 与其他存储工具搭配:
StorageMap属于标准库存储工具家族,其余还包括持久化向量StorageVec(storage-vec.md)以及直接操作存储槽的store()/get()(store-get.md),总览见 libraries/index.md。映射适合「键 → 值」关系,向量适合按索引存取,裸槽操作则适合底层精细控制,应按业务形态选择。 - 配套示例可运行:本文全部代码取自仓库示例 storage_map/src/main.sw,其
Forc.toml位于 storage_map/Forc.toml,可直接在 Sway 合约工程中编译验证。
小结
StorageMap是 Sway 合约中最常用的持久化数据结构之一:它以O(1)的常数时间完成键值定位,通过泛型同时支持基础类型与复合类型(结构体、元组、数组)作为键或值,仅需将复杂数据包装为单一类型。声明上,StorageMap位于 prelude 无需导入,在storage块中按StorageMap::<K, V> {}初始化;读取使用.get(key)(返回Option,可用unwrap_or兜底),写入使用.insert(key, value),并配合#[storage(read)]/#[storage(read, write)]纯度注解。底层实现上,每个键通过sha256((STORAGE_MAP_DOMAIN, key, field_id))推导出确定性的存储槽地址,标准库还提供了remove、try_insert等更丰富的原子操作。掌握这套声明、读写与原理,你就能在合约中稳健地管理一切「以键查值」的持久化状态。
【免费下载链接】sway🌴 Empowering everyone to build reliable and efficient smart contracts.项目地址: https://gitcode.com/GitHub_Trending/sw/sway
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考