在 Starknet 中,Contract Storage(合约存储)是智能合约状态所在的持久化内存。与在函数内部声明并在执行后消失的变量不同,存储中的数据会永久保留在区块链上。
然而,仅仅声明一个变量是不够的。要在 Cairo 中有效地与合约存储进行交互,编译器需要两部分逻辑:
- 数据表示(Data Representation): 如何对存储的数据类型进行序列化和反序列化。这由
starknet::Storetrait 处理。 - 访问逻辑(Access Logic): 如何实际读取或写入特定的存储槽。这由一组 Access Traits(访问 Trait)处理。
对于整数、bool、felt252、ByteArray 等类型,Cairo 已经提供了 starknet::Store trait 的实现。因此,这些类型可以直接在合约的存储中使用,无需任何额外工作。例如,在下面的合约中,felt252 和 u256 都是有效的存储成员,因为它们已经实现了该 trait。
#[storage]
struct Storage {
num1: felt252,
num2: u256,
}
然而,在处理存储中的复杂类型(如映射、数组或用户定义的结构体)时,我们必须派生(derive)该 trait,或者使用 Cairo 提供的特殊类型来表示存储中的类型。这些情况将在后面的部分中详细讨论。
本文将介绍可用于存储的各种类型,以及每种类型在存储中使用时所需的 trait。
存储访问 Trait
访问 trait 决定了如何根据被访问的类型从存储中读取或写入值。Cairo 在内部使用不同的访问 trait 来解析这些操作,具体取决于我们是与已经实现了 starknet::Store trait 的类型交互,还是与特殊类型交互。
以下是这些访问 trait 的快速概述,我们稍后将深入探讨细节:
StoragePointerReadAccess和StoragePointerWriteAccess:用于读取和写入简单类型或实现了starknet::Store的自定义结构体的值。StorageMapReadAccess和StorageMapWriteAccess:处理存储中映射(键值对)类型的读取和写入。StoragePathEntry:帮助解析对嵌套映射的访问。VecTrait和MutableVecTrait:提供对存储中动态数组的访问。
既然我们知道 Cairo 存储中使用的任何类型都必须实现 starknet::Store trait,并且对其进行读写需要导入相应的访问 trait,那么在探讨合约级别的读写操作之前,让我们先看看哪些类型已经实现了 starknet::Store trait。
实现 starknet::Store Trait 的类型
以下 Cairo 类型实现了 starknet::Store trait:
- felt252
- unsigned and signed integers
- bool
- bytes31
- ByteArray
- ContractAddress
- Tuple
由于上述类型已经实现了 starknet::Store trait,在存储中进行读写只需导入必要的访问 trait,即可使用 read() 和 write() 方法。
考虑一个使用上述类型声明了几个状态变量的合约。下面的代码片段展示了如何在存储中声明每种类型:
#[starknet::contract]
mod HelloStarknet {
use starknet::ContractAddress;
#[storage]
struct Storage {
// felt252: Field element
user_id: felt252,
// u256: 256-bit unsigned integer
total_supply: u256,
// bool: Boolean value for true/false conditions
is_paused: bool,
// bytes31: Fixed-size byte array (31 bytes), for storing short strings/data
contract_name: bytes31,
// ByteArray: for storing long strings
contract_description: ByteArray,
// ContractAddress: Starknet contract address type
owner_address: ContractAddress,
// Tuple: Groups multiple values together
version_info: (u8, i8) // (unsigned integer, signed integer)
}
}
现在我们已经了解了如何在 Cairo 的存储中声明不同的数据类型,下一步是理解如何操作它们,即如何实际将值写入存储以及随后如何读取它们。
写操作
在写入上面声明的任何状态变量之前,我们必须首先导入 StoragePointerWriteAccess trait。该 trait 为存储变量启用了 .write(value) 方法,使我们能够通过它们的存储指针直接赋值。
它可以在 starknet::storage 模块中找到:
// Import `StoragePointerWriteAccess` trait
use starknet::storage::StoragePointerWriteAccess;
以下是如何对不同的简单类型执行写操作(新增的代码用 /* NEWLY ADDED */ 注释标出):
#[starknet::contract]
mod HelloStarknet {
use starknet::ContractAddress;
// Import `StoragePointerWriteAccess` trait
use starknet::storage::StoragePointerWriteAccess; /* NEWLY ADDED */
#[storage]
struct Storage {
user_id: felt252,
total_supply: u256,
is_paused: bool,
contract_name: bytes31,
contract_description: ByteArray,
owner_address: ContractAddress,
version_info: (u8, i8)
}
/* NEWLY ADDED */
#[abi(embed_v0)]
impl HelloStarknetImpl of super::IHelloStarknet<ContractState> {
fn write_vars(ref self: ContractState) {
// Writing to felt252
self.user_id.write(12345);
// Writing to u256
self.total_supply.write(1000000_u256);
// Writing to bool
self.is_paused.write(false);
// Writing to bytes31 (short string)
self.contract_name.write('HelloContract'.try_into().unwrap());
// Writing to ByteArray (long string)
self.contract_description.write("This is a very very very long textttt");
// Writing to ContractAddress
self.owner_address.write(0x1234.try_into().unwrap());
// Writing to tuple
self.version_info.write((1_u8, -2_i8));
}
}
}
读操作
对于读操作,我们需要导入 StoragePointerReadAccess trait,它允许我们在上述声明的类型上使用 .read() 方法:
use starknet::storage::StoragePointerReadAccess;
在上一节合约的基础上,下面的代码导入了 StoragePointerReadAccess trait 并从状态变量中读取值(新增的代码用 /* NEWLY ADDED */ 注释标出):
#[starknet::contract]
mod HelloStarknet {
use starknet::ContractAddress;
// Import `StoragePointerWriteAccess` trait
use starknet::storage::{
StoragePointerWriteAccess,
/* NEWLY ADDED */
StoragePointerReadAccess,
};
#[storage]
struct Storage {
user_id: felt252,
total_supply: u256,
is_paused: bool,
contract_name: bytes31,
contract_description: ByteArray,
owner_address: ContractAddress,
version_info: (u8, i8)
}
#[abi(embed_v0)]
impl HelloStarknetImpl of super::IHelloStarknet<ContractState> {
fn write_vars(ref self: ContractState) {
self.user_id.write(12345);
self.total_supply.write(1000000_u256);
self.is_paused.write(false);
self.contract_name.write('HelloContract'.try_into().unwrap());
self.contract_description.write("This is a very very very long textttt");
self.owner_address.write(0x1234.try_into().unwrap());
self.version_info.write((1_u8, -2_i8));
}
/* NEWLY ADDED */
fn read_vars(self: @ContractState) {
// felt252: Reading user ID returns a field element (0 to P-1 range)
let _ = self.user_id.read();
// u256: Reading large integer, useful for token balances and big numbers
let _ = self.total_supply.read();
// bool: Reading boolean state, returns true or false
let _ = self.is_paused.read();
// bytes31: Reading fixed-size byte array, used for short strings
let _ = self.contract_name.read();
// ByteArray: Reading dynamic-size byte array, used for long strings
let _ = self.contract_description.read();
// ContractAddress: Reading Starknet address, type-safe contract/user address
let _ = self.owner_address.read();
// Tuple: Reading compound type returns both values as (u8, i8) pair
let _ = self.version_info.read();
}
}
}
Mapping 和 Vec
像字典和数组这样的集合类型不能直接存储在 Cairo 合约存储中。这是因为它们使用的是动态内存布局,而存储系统默认不支持这种布局。相反,Cairo 提供了用于在存储中处理集合的特殊类型:Map 和 Vec。
这些特殊类型可以在 starknet::storage 模块中找到,用于在合约存储中声明映射和向量。在我们将它们用于 Storage 结构体之前,我们需要显式地从核心库中导入这些特殊类型,如下所示:
use starknet::storage::{ Map, Vec };
注意,
Map和Vec不需要一起导入,您可以根据用例仅导入您需要的那一个。例如,如果您的合约只需要映射,那么只导入 Map 类型即可。
导入 Map 和 Vec 之后,我们就可以在 storage 结构体中使用它们了,如下所示:
use starknet::storage::{ Map, Vec };
#[storage]
struct Storage {
// mapping(address => uint256) my_map;
my_map: Map<ContractAddress, u256>,
// uint64[] my_vec;
my_vec: Vec<u64>,
}
在上述代码中,每条声明上方都注释了在 Solidity 中声明 Map 和 Vec 类型的等效方式。Map 类型接受两个泛型参数:KeyType(键类型)和 ValueType(值类型)。在我们的示例中,ContractAddress 是键,u256 是值,这意味着此映射为每个地址存储一个 u256 金额。另一方面,Vec 类型只接受一个类型参数,表示该类型元素的数组。在我们的示例中,它是一个 64 位无符号整数的数组。
注意,Cairo 中并没有像 Solidity 或其他语言中那样传统的“固定数组”存储类型。
设置好这些状态变量后,让我们看看如何使用读写操作与它们进行交互。
Map 类型的读写操作
在我们的示例中,my_map 表示从地址到 u256 值的映射,类似于我们在 Solidity 中定义的 mapping(address => uint256)。
my_map: Map<ContractAddress, u256>
在执行这些操作之前,我们首先需要导入实现存储读写功能的必要访问 trait。它们同样位于 starknet::storage 模块中。
#[starknet::contract]
mod HelloStarknet {
// IMPORT MAP TYPE AND NECESSARY ACCESS TRAITS
use starknet::storage::{
Map,
StorageMapWriteAccess, // Enables .write(key, value) operations
StorageMapReadAccess, // Enables .read(key) operations
};
}
这些 trait 启用了诸如 .write(key, value) 和 .read(key) 的方法,我们将在接下来的示例中使用它们。如果不导入它们,我们就无法对存储集合执行任何此类操作。
一切就绪后,我们现在可以实现 Map 类型的读写操作了。
写操作
我们使用 StorageMapWriteAccess 提供的 .write(key, value) 方法。语法非常直观:
self.my_map.write(key, value);
以下是各部分的作用:
self.my_map引用在 storage 结构体中声明的Map。.write(...)是更新映射的方法。key是用于存储值的标识符(在我们的示例中,是一个ContractAddress)。value是实际被保存的数据(在我们的示例中,是一个u256)。
每次调用 write() 时,如果键不存在,则插入新的键值对;如果键已经存在,则覆盖现有值。
下面是在函数中使用 .write() 的示例:
#[starknet::contract]
mod HelloStarknet {
//...
#[abi(embed_v0)]
impl HelloStarknetImpl of super::IHelloStarknet<ContractState> {
fn write_to_mapping(
ref self: ContractState,
user: ContractAddress,
amount: u256
) {
self.my_map.write(user, amount); // write operation
}
}
}
这会为给定的 user 地址存储 amount。在底层,Cairo 会根据键处理将其写入到合适的存储槽的操作。
读操作
我们使用 StorageMapReadAccess 提供的 .read(key) 方法。语法如下所示:
self.my_map.read(key);
以下是它的功能分解:
self.my_map引用 storage 结构体中的 map 实例。.read(...)访问存储的值。key是我们想要查找的标识符,在我们的示例中,它是一个ContractAddress,因为我们使用它作为映射的键。
read() 方法返回与键关联的值。如果该键之前没有被写入过,它将返回映射中 valueType 的默认值(例如,对于 u256 返回 0)。
示例:
#[starknet::contract]
mod HelloStarknet {
// ...
#[abi(embed_v0)]
impl HelloStarknetImpl of super::IHelloStarknet<ContractState> {
fn get_value(self: @ContractState, user: ContractAddress) -> u256 {
self.my_map.read(user) // read operation
}
}
}
此函数读取为给定 user 地址存储的值并将其返回。
嵌套映射操作
在存储中对嵌套映射进行读写需要一个额外的访问 trait,名为 StoragePathEntry。该 trait 启用了 .entry(key) 方法,该方法提供了对存储在给定键下的内部映射的访问。
换句话说,当我们处理值本身是一个 Map 的嵌套映射时,我们无法使用 .read() 或 .write() 直接访问它。相反,我们必须先调用 .entry(key) 进入内层,然后对其执行操作。
声明嵌套映射
让我们在存储中声明嵌套映射。这将是一个两级映射,外层映射使用 ContractAddress(用户地址)作为键,内层映射也使用 ContractAddress(代币地址)作为键,存储的值是一个代表代币余额的 u256:
#[storage]
struct Storage {
// user_address => token_address => balance
two_level_mapping: Map<ContractAddress, Map<ContractAddress, u256>>,
}
导入所需的 Trait
use starknet::storage::StoragePathEntry;
StoragePathEntry 启用了 .entry(key) 方法,用于获取序列中下一个键的存储路径。
虽然 .entry() 使我们能够访问嵌套层,但仅靠它本身并不足以执行读或写操作。我们仍然需要导入启用这些特定方法的 trait。我们需要哪些确切的 trait,取决于我们如何执行这些操作。
我们将探讨在存储中读写嵌套映射的两种方法。
写操作和读操作
方法 1:始终为 N 层链式调用 .entry()(一直到值)
这种方法通过每个映射层级链式调用多个 .entry(),直到到达目标存储槽。然后,它使用 .write(value) 或 .read() 直接与存储的值交互。
#[starknet::contract]
mod HelloStarknet {
use starknet::ContractAddress;
// IMPORT TRAITS
use starknet::storage::{
Map,
StoragePathEntry, // Enables .entry(key)
/* ADDITIONAL TRAITS */
StoragePointerWriteAccess, // Enables .write(value)
StoragePointerReadAccess, // Enables .read()
};
#[storage]
struct Storage {
// user_address => token_address => balance
two_level_mapping: Map<ContractAddress, Map<ContractAddress, u256>>,
}
#[abi(embed_v0)]
impl HelloStarknetImpl of super::IHelloStarknet<ContractState> {
fn write_nested_map(
ref self: ContractState,
key1: ContractAddress,
key2: ContractAddress,
value: u256,
) {
// WRITE OPERATION
self.two_level_mapping.entry(key1).entry(key2).write(value);
}
fn read_nested_map(
ref self: ContractState,
key1: ContractAddress,
key2: ContractAddress,
) -> u256 {
// READ OPERATION
self.two_level_mapping.entry(key1).entry(key2).read()
}
}
}
对于读写操作,两个函数都使用了两次 .entry() 方法:
- 第一次
.entry(key1)访问外层映射 - 第二次
.entry(key2)深入到内层映射,到达特定的存储槽
一旦我们到达该确切的存储位置,我们就使用:
- 由
StoragePointerWriteAccesstrait 启用的.write(value)直接向该槽写入一个值。 - 由
StoragePointerReadAccesstrait 启用的.read()从该槽读取值。
方法 2:为 N-1 层链式调用 .entry()(在最内层映射停止)
这种方法使用 .entry() 方法向下深入每一个映射层级,直到到达内层映射,然后将其视为一个整体,直接使用 .write(key, value) 和 .read(key) 与之交互。
#[starknet::contract]
mod HelloStarknet {
use starknet::ContractAddress;
// IMPORT TRAITS
use starknet::storage::{
Map,
StoragePathEntry, // Enables .entry(key) method
/* ADDITIONAL TRAITS */
StorageMapWriteAccess, // Enables .write(key, value) method
StorageMapReadAccess, // Enables .read(key) method
};
#[storage]
struct Storage {
// user_address => token_address => balance
two_level_mapping: Map<ContractAddress, Map<ContractAddress, u256>>,
}
#[abi(embed_v0)]
impl HelloStarknetImpl of super::IHelloStarknet<ContractState> {
fn write_nested_map(
ref self: ContractState,
key1: ContractAddress,
key2: ContractAddress,
value: u256,
) {
// WRITE OPERATION
self.two_level_mapping.entry(key1).write(key2, value);
}
fn read_nested_map(
ref self: ContractState,
key1: ContractAddress,
key2: ContractAddress,
) -> u256 {
// READ OPERATION
self.two_level_mapping.entry(key1).read(key2)
}
}
}
对于读写操作,上述代码中的每个函数都使用了一次 .entry(key1) 方法:
- 此
.entry(key1)提供对内层映射的访问权限。
一旦我们有了对该内层映射的引用,我们就可以像对待普通的 Map 一样对待它:
- 由
StorageMapWriteAccesstrait 启用的.write(key2, value)将值存储在key2下。 - 由
StorageMapReadAccesstrait 启用的.read(key2)检索存储在key2下的值。
此方法将内层值视为一整个映射,而不是单个存储槽。
这两种方法都是有效的,开发者只需根据他们计划与嵌套映射交互的方式导入相应的 trait 即可。
接下来,我们将探讨如何对 Vec 类型执行类似的操作,包括推入(push)新元素和从特定索引读取。
Vec 类型的读写操作
Cairo 中的 Vec 类型用于表示合约存储中可增长的数组,类似于 Solidity 中的动态数组(如 uint64[])。它支持常见的操作,如追加新元素、按索引访问项以及从数组末尾删除元素。
继续我们的示例,我们将与存储声明中的 Vec 类型进行交互:
#[storage]
struct Storage {
// Solidity equivalent: uint64[] my_vec;
my_vec: Vec<u64>,
}
但在此之前,我们需要了解与 Vec 类型关联的两个 trait:VecTrait 和 MutableVecTrait。
VecTrait 提供了与存储中的向量交互的只读方法。这包括:
.len()– 返回向量中当前元素的数量。其返回类型为u64。.get(index)– 安全地返回指向给定索引处元素的指针。如果索引越界,则返回None。.at(index)– 返回指向给定索引处元素的指针,但如果索引无效,程序会引发 panic(崩溃)。
MutableVecTrait 通过添加允许修改存储中向量内容的变更方法来扩展 VecTrait。这些方法包括:
.push(value)– 将新元素追加到向量的末尾。.pop()– 移除并返回最后一个元素,如果向量为空,则返回None。.allocate()– 在向量末尾为新元素预留空间并返回一个可写指针,对复杂或嵌套类型非常有用。
根据向量操作的不同,我们可能还需要导入访问 trait,如 StoragePointerWriteAccess 或 StoragePointerReadAccess。
在接下来的小节中,我们将通过示例演示如何使用这些 trait 执行追加、读取、更新和删除元素等常见操作。
向 my_vec 向量推入新值
在 push_number 函数中使用的 push 方法会先增加向量的长度,然后将该值写入向量末尾的新存储槽中。
// IMPORT TRAITS
use starknet::storage::{Vec, MutableVecTrait};
fn push_number(ref self: ContractState, value: u64) {
// PUSH OPERATION
self.my_vec.push(value);
}
从现有索引读取
如果我们想检索现有索引处的值,可以使用 .get() 或 .at() 获取指针,然后使用 .read() 读取其值:
// IMPORT TRAITS
use starknet::storage::{Vec, MutableVecTrait, StoragePointerReadAccess};
fn read_my_vec(self: @ContractState, index: u64) -> u64 {
// VEC READ OPERATION
self.my_vec.at(index).read() // Will panic if index is out of bounds
}
练习:为什么我们要添加 StoragePointerReadAccess trait?
在现有索引处更新值
要在现有索引处更新值,我们可以使用 .get() 或 .at() 获取指针,然后使用 .write(value) 修改其值:
// IMPORT TRAITS
use starknet::storage::{Vec, MutableVecTrait, StoragePointerWriteAccess};
fn write_my_vec(ref self: ContractState, index: u64, val: u64) -> u64 {
// VEC WRITE OPERATION
self.my_vec.at(index).write(val) // Will panic if index is out of bounds
}
获取向量的长度
.len() 以 u64 类型返回向量中当前元素的数量。
// IMPORT TRAITS
use starknet::storage::{Vec, MutableVecTrait};
fn get_vec_len(self: @ContractState) -> u64 {
// RETURN VEC LENGTH
self.my_vec.len()
}
弹出(Pop)最后一个元素
use starknet::storage::{Vec, MutableVecTrait};
fn pop_last(ref self: ContractState) {
// POP OPERATION
let _ = self.my_vec.pop();
}
.pop() 会检索存储在向量最后一个位置的值,减少向量的长度,然后返回检索到的值;如果向量为空,则返回 None。
存储中的 Struct 和 Enum 类型
与默认实现了 starknet::Store trait 的类型(u8、bool、felt252 等)不同,结构体(struct)要求您显式派生该 trait,否则,任何试图在存储中使用该结构体的操作都会在编译时失败。
要将结构体读取或写入存储,它必须实现必要的读写函数,而这正是 starknet::Store trait 自动完成的工作。
为了能将结构体存储在 Cairo 存储中,我们需要在其定义上方添加 #[derive(starknet::Store)] 属性来派生该 trait:
#[derive(starknet::Store)]
struct User {
id: u32,
name: bytes31,
is_admin: bool,
}
完成后,该结构体就可以用于与存储相关的操作了,包括在 #[storage] 结构体中作为映射和数组中的类型。
下面的合约示例演示了如何在存储中声明自定义结构体、导入必要的 trait,并对该结构体执行读写操作。
#[starknet::contract]
mod HelloStarknet {
// IMPORT TRAITS
use starknet::storage::{
StoragePointerReadAccess, // Enables .read()
StoragePointerWriteAccess // Eabales .write(value)
};
// CUSTOM STRUCT DEFINITION
#[derive(starknet::Store)]
struct UserData {
id: u32,
name: bytes31,
is_admin: bool,
}
#[storage]
struct Storage {
// CUSTOM STRUCT DECLARATION
user: UserData,
}
#[abi(embed_v0)]
impl HelloStarknetImpl of super::IHelloStarknet<ContractState> {
// WRITE OPERATION
fn write_struct(ref self: ContractState, _id: u32, _name: bytes31, _is_admin: bool) {
self.user.id.write(_id); // Write to field 1
self.user.name.write(_name); // Write to field 2
self.user.is_admin.write(_is_admin); // Write to field 3
}
// READ OPERATION
fn read_struct(ref self: ContractState) -> (u32, bytes31, bool) {
let id = self.user.id.read(); // Read from field 1
let name = self.user.name.read(); // Read from field 2
let is_admin = self.user.is_admin.read(); // Read from field 3
(id, name, is_admin)
}
}
}
注意我们导入了以下 trait:
use starknet::storage::{
StoragePointerReadAccess, // Enables .read() on storage paths
StoragePointerWriteAccess // Enables .write(value) on storage paths
};
我们导入这些 trait 是因为结构体字段是简单类型,如果没有它们,像 .read() 和 .write(value) 这样的调用将无法编译。
写操作
在 write_struct 函数中:
// WRITE OPERATION
fn write_struct(
ref self: ContractState,
_id: u32,
_name: bytes31,
_is_admin: bool
) {
self.user.id.write(_id); // Write to field 1
self.user.name.write(_name); // Write to field 2
self.user.is_admin.write(_is_admin); // Write to field 3
}
每次调用都会将一个新值写入所存储结构体的特定字段。这表明,即使结构体作为单个对象存储,它的字段也可以被独立地访问和更新。
读操作
此 read_struct 函数逐个读取每个字段并以元组形式将它们返回:
fn read_struct(ref self: ContractState) -> (u32, bytes31, bool) {
let id = self.user.id.read(); // Read from field 1
let name = self.user.name.read(); // Read from field 2
let is_admin = self.user.is_admin.read(); // Read from field 3
(id, name, is_admin)
}
Enum 类型
枚举(Enum)遵循与结构体相似的模式,我们必须显式为其派生 starknet::Store 才能对其进行存储。每个变体类型也必须实现 starknet::Store trait。此外,由于枚举可能包含在替换或丢弃值时需要妥善清理的数据,因此我们还需要派生 Drop trait。
以下是定义枚举并在存储中使用的基本示例:
#[starknet::contract]
mod HelloStarknet {
// DEFINE ENUM
#[derive(starknet::Store, Drop)]
enum UserRole {
Admin,
Mod,
#[default]
User,
}
#[storage]
struct Storage {
// DECLARE ENUM
my_role: UserRole,
}
}
在我们的枚举定义中,包含了 #[default] 属性,这是任何要在存储中使用的枚举都必须具备的。该属性将其中一个变体标记为默认值(在我们的例子中是 User 变体),当存储值未被设置时,将分配该默认值。
枚举的读写操作
以下代码导入了在存储中读写枚举所需的访问 trait,接着是执行这些操作的两个函数:
// IMPORT TRAITS
use starknet::storage::{
StoragePointerReadAccess, // Enables .read() on enum stored in storage
StoragePointerWriteAccess, // Enables .write(value) on enum stored in storage
};
// WRITE OPERATION
fn write_enum(ref self: ContractState) {
// Write the Admin variant to storage
self.my_role.write(UserRole::Admin);
}
// READ OPERATION
fn read_enum(self: @ContractState) {
// Read the current value of the enum from storage
let _ = self.my_role.read();
}
在 Cairo 中,像 Vec 或 Map 这样的集合类型不能包含为结构体的字段或枚举的变体,因为它们依赖动态内存布局,而存储系统默认不支持这种布局。
// STRUCT: This will NOT work ❌ - Vec has dynamic size
#[derive(starknet::Store)]
struct InvalidUser {
name: felt252,
balance: u256,
friends: Vec<ContractAddress>, // ERROR: Cannot store Vec in struct
tokenBal: Map<ContractAddress, u256>, // ERROR: Cannot store Map in struct
}
// ENUM: This will also NOT work ❌ - Map has dynamic size
#[derive(starknet::Store)]
enum InvalidUserRole {
Admin: Map<felt252, bool>, // ERROR: Cannot store Map in enum variant
#[default]
User,
}
如果我们需要在结构体内存储集合,我们必须使用一种称为存储节点(storage node)的特殊结构体。
存储节点 (Storage Nodes)
存储节点仍然是结构体,但有一个关键区别:它们可以包含如 Vec 和 Map 这样的动态集合类型。与我们之前讨论的不支持集合的常规用户定义结构体不同,存储节点专门设计用于处理集合,使其非常适合在存储中管理嵌套或动态数据。
定义存储节点
要定义一个存储节点,我们使用 #[starknet::storage_node] 属性,而不是常规的结构体派生:
// Storage node - CAN contain collections
#[starknet::storage_node]
struct UserStorageNode {
name: felt252,
balance: u256,
friends: Vec<ContractAddress>, // ✅ Now allowed!
tokenBal: Map<ContractAddress, u256>, // ✅ Also allowed!
}
#[starknet::storage_node] 属性允许对集合类型的支持,并自动处理必要的存储逻辑。
使用存储节点
一旦被定义,存储节点就可以像其他任何类型一样在 #[storage] 结构体内部进行声明。例如,我们将使用上面定义的存储节点类型(UserStorageNode)声明一个名为 user_data 的存储变量,如下所示:
#[storage]
struct Storage {
user_data: UserStorageNode,
}
接下来,我们将展示如何初始化 user_data 存储变量以及如何从中读取。
存储节点操作
写入存储节点
要写入存储节点,我们直接通过存储变量(在我们的示例中为 user_data)访问其字段,然后根据字段类型使用适当的存储方法,如 .write(key, value)、.push() 或 .entry(key).write(value),如下所示:
#[abi(embed_v0)]
impl HelloStarknetImpl of super::IHelloStarknet<ContractState> {
fn write_nodes(ref self: ContractState) {
// Write to simple fields (felt252 and u256)
self.user_data.name.write(3);
self.user_data.balance.write(1000_u256);
// Push a new address to the friends vector
self.user_data.friends.push(get_caller_address());
// Write to nested map using either of the two valid approaches
// Approach 1
self.user_data.tokenBal.entry(get_caller_address()).write(23);
// Approach 2
self.user_data.tokenBal.write(get_caller_address(), 23);
}
}
从存储节点读取
从存储节点读取遵循类似的模式:我们直接访问每个字段,对简单值调用 .read(),对特定的向量元素调用 .at(index),或对映射调用 .read(key)。
#[abi(embed_v0)]
impl HelloStarknetImpl of super::IHelloStarknet<ContractState> {
fn read_nodes(self: @ContractState) {
// Read simple values
let _ = self.user_data.name.read();
let _ = self.user_data.balance.read();
// Read a value from the vector at index 0
let _ = self.user_data.friends.at(0);
// Read token balance from the nested map
let _ = self.user_data.tokenBal.read(get_caller_address());
}
}
练习: 通过观察上方存储节点的读写操作,列出执行以下操作所需的 trait:
.write(value).push(value).entry(key).write(key, value).read().at(index).read(key)
结论
总结一下,以下是 Cairo 中最常用的存储访问 trait 的概览。每个 trait 都启用了特定的方法,使我们能够与简单类型、Map、Vec、结构体和枚举等存储类型进行交互。根据我们想要如何读取或写入存储,我们需要导入下面列出的相应 trait:
| Trait | 启用方法 | 用途 |
|---|---|---|
StoragePointerReadAccess |
.read() |
从存储路径(简单类型或结构体字段)读取值。 |
StoragePointerWriteAccess |
.write(value) |
将值写入存储路径(简单类型或结构体字段)。 |
StorageMapReadAccess |
.read(key) |
根据键从 Map 中读取值。 |
StorageMapWriteAccess |
.write(key, value) |
根据键将值写入 Map。 |
StoragePathEntry |
.entry(key) |
导航深入嵌套存储(例如,嵌套的 Map 或存储节点)。 |
VecTrait |
.len(), .get(index), .at(index) |
对 Vec 的只读访问:检查长度,可选或直接获取元素。 |
MutableVecTrait |
.push(value), .pop(), .allocate() |
变更 Vec:添加、删除或为元素预留空间。 |
本文是 Cairo Programming on Starknet 教程系列的一部分