Cairo 组件 第一部分
本文介绍了 Cairo 中的 Component 概念,它类似于 Solidity 中的抽象合约,可以定义存储、事件和函数,但不能独立部署。文章通过一个示例,详细讲解了如何在 Cairo 中创建和使用 Component,包括接口定义、Component 声明、合约集成以及存储和事件的导入。
Cairo 中的组件行为类似于 Solidity 中的抽象合约。它们可以定义和使用存储、事件和函数,但不能单独部署。组件的预期用途是以类似于 Solidity 中抽象合约的方式分离逻辑(例如,可重用性)。
考虑以下 Solidity 代码:
abstract contract C {
uint256 balance;
function increase_balance(uint256 amount) public {
require(amount != 0, "amount cannot be zero");
balance = balance + amount;
}
function get_balance() public view returns (uint256) {
return x;
}
}
contract D is C {
}
合约 C 因为是抽象的所以不能被部署。但是,如果 D 被部署,那么 D 将拥有 C 的所有功能和状态。具体来说,D 将拥有按 C 中定义的方式运行的公共函数 increase_balance() 和 get()。
D 接收了 C 的所有函数、事件和存储。
我们今天将构建的合约是上面显示的 Solidity 代码的 Cairo 等价物。
最简组件示例
创建一个空目录并在其中运行 scarb init。
将以下代码粘贴到 src/lib.cairo 中。使用 scarb test 运行生成的测试;它们应该全部通过。
以下是代码的功能:
- 它声明了一个接口,其中包含两个函数,用于增加和返回合约中存储的余额。
- 它创建了一个组件,该组件定义了自己的存储
x并使用读取/写入操作来实现increase和get_balance函数。 - 合约导入组件,注册其存储和事件,并通过其 ABI 公开组件的实现。
我们将在代码后面解释它们是如何组合在一起的:
// 与 SCARB 默认创建的 TRAIT 相同
#[starknet::interface]
pub trait IHelloStarknet<TContractState> {
/// 增加合约余额。
fn increase_balance(ref self: TContractState, amount: felt252);
/// 检索合约余额。
fn get_balance(self: @TContractState) -> felt252;
}
// COMPONENT 是新的
#[starknet::component]
pub mod CounterComponent {
use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};
#[storage]
pub struct Storage {
x: felt252,
}
#[event]
#[derive(Drop, Debug, PartialEq, starknet::Event)]
pub enum Event {}
#[embeddable_as(CounterImplMixin)]
impl CounterImpl<TContractState, +HasComponent<TContractState>> of super::IHelloStarknet<ComponentState<TContractState>> {
fn get_balance(self: @ComponentState<TContractState>) -> felt252 {
self.x.read()
}
fn increase_balance(ref self: ComponentState<TContractState>, amount: felt252) {
assert(amount != 0, 'Amount cannot be 0');
self.x.write(self.x.read() + amount);
}
}
}
// 这个合约没有任何功能,它只使用了组件
#[starknet::contract]
mod HelloStarknet {
use super::CounterComponent;
component!(path: CounterComponent, storage: counter, event: CounterEvent);
#[abi(embed_v0)]
impl CounterImpl = CounterComponent::CounterImplMixin<ContractState>;
#[storage]
struct Storage {
#[substorage(v0)]
counter: CounterComponent::Storage,
}
#[event]
#[derive(Drop, Debug, PartialEq, starknet::Event)]
pub enum Event {
#[flat]
CounterEvent: CounterComponent::Event,
}
}
上述合约的分解
IHelloStarknet 接口
文件顶部的 trait 与 Scarb 默认创建的 trait 相比未更改。
我们没有更改它,因为测试文件专门导入此接口。 使用不同的名称会导致测试无法编译:
#[starknet::interface]
pub trait IHelloStarknet<TContractState> {
/// 增加合约余额。
fn increase_balance(ref self: TContractState, amount: felt252);
/// 检索合约余额。
fn get_balance(self: @TContractState) -> felt252;
}
Counter 组件
CounterComponent(类似于我们之前看到的 Solidity 中的“抽象合约”)几乎与 Scarb 默认创建的合约相同。 差异在代码块之后解释。
CounterComponent:
#[starknet::component]
pub mod CounterComponent {
use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};
#[storage]
pub struct Storage {
x: felt252,
}
#[event]
#[derive(Drop, Debug, PartialEq, starknet::Event)]
pub enum Event {}
#[embeddable_as(CounterImplMixin)]
impl CounterImpl<TContractState, +HasComponent<TContractState>> of super::IHelloStarknet<ComponentState<TContractState>> {
fn get_balance(self: @ComponentState<TContractState>) -> felt252 {
self.x.read()
}
fn increase_balance(ref self: ComponentState<TContractState>, amount: felt252) {
assert(amount != 0, 'Amount cannot be 0');
self.x.write(self.x.read() + amount);
}
}
}
Scarb 创建的默认合约:
#[starknet::contract]
mod HelloStarknet {
use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};
#[storage]
struct Storage {
balance: felt252,
}
#[abi(embed_v0)]
impl HelloStarknetImpl of super::IHelloStarknet<ContractState> {
fn increase_balance(ref self: ContractState, amount: felt252) {
assert(amount != 0, 'Amount cannot be 0');
self.balance.write(self.balance.read() + amount);
}
fn get_balance(self: @ContractState) -> felt252 {
self.balance.read()
}
}
}
以下是 CounterComponent 和 Scarb 生成的默认合约之间的区别:
- 该组件使用属性
#[starknet::component]进行注释- 合约使用属性
#[starknet::contract]进行注释
- 合约使用属性
- 组件中的
impl具有属性#[embeddable_as(CounterImplMixin)]- 合约具有属性
#[abi(embed_v0)]
- 合约具有属性
- 在组件中,
CounterImpl具有 traitimpl CounterImpl<TContractState, +HasComponent<TContractState>> of super::IHelloStarknet<ComponentState<TContractState>>- 合约具有 trait
impl HelloStarknetImpl of super::IHelloStarknet<ContractState>
- 合约具有 trait
- 该组件声明了一个空事件块,即使它不使用事件
- 合约可以省略事件块,但组件不能。 实际上,大多数真实世界的组件都会有事件。 为了最大限度地简化,我们暂时将事件保留为空。
接下来是对上面列出的差异的详细解释。
#[starknet::component] vs #[starknet::contract]
如果我们打算构建组件而不是合约,编译器需要知道模块的类型。使用 #[starknet::component] 注释 mod 块告诉编译器我们正在构建一个组件,而 [starknet::contract] 告诉编译器我们正在构建一个合约。
#[embeddable_as(CounterImplMixin)]
此属性允许合约“引入”来自组件的 impl。
// Counter 混入
#[abi(embed_v0)]
impl CounterImpl = CounterComponent::CounterImplMixin<ContractState>;
在合约中:CounterComponent 指的是 CounterComponent 模块,CounterImplMixin 指的是它正在引入(“混入”)的 Impl。
名称 CounterImplMixin 是任意的。
我们可以在组件中编写 #[embeddable_as(FooBar)],并在合约中放置以下代码:
#[abi(embed_v0)]
impl CounterImpl = CounterComponent::FooBar<ContractState>;
如果我们想为不同的目的公开不同的
impl块,我们可以在组件中定义多个embeddable_as实现(我们将在后续文章中展示一个示例)。
“Mixin”不是一种语言构造或编译器识别的术语。它是 Cairo 中的惯用术语,用于从组件包含在合约中的 impl,并且该 impl 将在合约中公开新的“公共”函数。合约可以包含一个不公开任何外部函数的 impl,但这不会被认为是“混入”。
合约中的 #[abi(embed_v0)] 公开了来自 counter impl 的函数。如果我们不包含 #[abi(embed_v0)],如下所示:
// #[abi(embed_v0)] 被注释掉
impl CounterImpl = CounterComponent::Counter<ContractState>;
我们的代码仍然可以编译,但不会有公共函数,因此测试不会通过。
理解组件中的 impl 定义
上面组件中的 impl 定义如下所示:
impl CounterImpl<TContractState, +HasComponent<TContractState>> of super::IHelloStarknet<ComponentState<TContractState>>
乍一看这很吓人,尤其是如果你没有 Rust 背景的话。好消息是它主要是样板,你将在所有组件中重用此模式,而不必重写它。但我们应该知道它是什么意思。
在组件中,每个 impl 都遵循以下结构:
impl {ImplName}<TContractState, +HasComponent<TContractState>> of {PathToTrait}::{TraitName}<ComponentState<TContractState>>
让我们分解一下:
{ImplName}是你给实现块的名称。 它可以是你选择的任何名称。TContractState表示合约的状态类型。+HasComponent<TContractState>告诉编译器使用此组件的合约包含其状态。of {PathToTrait}::{TraitName}将实现链接到定义组件接口的 trait。ComponentState<TContractState>表示 trait 在合约状态的组件部分上运行。
在我们的例子中:
{PathToTrait}是super,因为 trait 在同一文件中声明。{TraitName}是IHelloStarknet,因为测试需要此特定 trait 名称。
一旦你理解了这个模式,你就可以在为组件声明实现时重复使用它。
合约如何使用组件
再次显示合约代码:
#[starknet::contract]
mod HelloStarknet {
use super::CounterComponent;
component!(path: CounterComponent, storage: counter, event: CounterEvent);
#[abi(embed_v0)]
impl CounterImpl = CounterComponent::CounterImplMixin<ContractState>;
#[storage]
struct Storage {
#[substorage(v0)]
counter: CounterComponent::Storage,
}
#[event]
#[derive(Drop, Debug, PartialEq, starknet::Event)]
pub enum Event {
#[flat]
CounterEvent: CounterComponent::Event,
}
}
在 Solidity 中,当合约从抽象合约继承时,函数、存储变量和事件会自动“拉入”。 在 Cairo 中并非如此。
要“引入”一个组件,我们需要遵循以下清单:
- 使用
use导入组件。 在这种情况下,它是use super::CounterComponent。 这仅仅使代码可用,而不是将其集成到组件中 - 我们必须声明
component!宏。 这将在稍后详细解释 - 必须混入公共函数
- 存储必须嵌入为
#[substorage(v0)] - 事件必须使用
#[flat]嵌入
这些步骤都不是可选的。 下面是对清单中每个项目的详细解释。
导入组件
将我们的 CounterComponent 命名为“CounterComponent”是可选的。 它可以被称为“SparklingWaterIsTasty”,这很好。 但是,我们导入的组件名称必须与以下各项使用的名称相同:
component!中的path- 它必须是 Mixin、Storage 和 Event 的来源,如下所示

导入 impl
要在我们的合约中包含组件的外部函数,我们必须执行以下操作:
- 声明一个
impl并使用#[abi(embed_v0)]属性使其外部化(如下面的橙色部分) - 从组件中引入
CounterImplMixin。 名称CounterImplMixin必须与组件的#[embeddable_as(CounterImplMixin)]中声明的名称匹配。 匹配组件中的 impl 名称并不能保证导入有效,你必须使用在embeddable_as宏中声明的名称。 - 最后,我们通过“传递”
ContractState来使CounterImplMixin混入“访问”合约存储(如下面的白色框中所示)。

导入存储
与自动导入存储的合约继承不同,这必须在 Cairo 中手动完成。
合约的所有存储都存在于合约中标记为 #[storage] 的结构中。 尽管在组件中也有 #[storage] 结构,但这并不“算数”,因为它在组件中。
幸运的是,我们不必单独导入每个存储变量。 我们使用 #[substorage(v0)] 属性“一次性”导入存储。
现在让我们展示如何导入存储:
- 从组件导入的所有存储都必须在合约的存储结构中有一个键。 此键的名称必须与
component!宏中声明的名称匹配(下面的绿色框和箭头)。 这可以命名为任何名称,但它们必须在component!中声明的值和结构中的键之间保持一致。 名称counter本身是任意的。 此链接也是编译器如何知道counter内部的存储是在别处定义的。 - 为了引入组件(不是合约)的存储结构,我们将其作为结构中的值放入
CounterComponent::Storage中(下面的黄色和紫色框)。 请注意,此处的Storage是组件中结构的名称。

导入事件
导入事件遵循与导入存储相同的模式:
component!宏中声明的CounterEvent必须与合约的Event枚举中的相应项匹配。 这种一对一匹配是编译器如何知道事件是在合约外部定义的。 名称CounterEvent是任意的,但我们选择的任何名称都必须完全相同地出现在component!宏和枚举变体中。- 条目上方的
#[flat]属性(如下面的橙色框中所示)是必需的样板,它告诉编译器将组件的事件展平到合约的事件结构中,而不是嵌套它们。 CounterComponent是我们如何引入Event。 Magenta 中的Event是在组件中声明的Event枚举。

总结
组件创建自己的函数、存储和事件,但不能作为合约部署。
可以使用导入并将引用声明为“组件!”将组件导入到合约中
函数、存储和事件必须单独导入。
要导入函数,请创建一个使用 #[abi(embed_v0)] 声明的新 impl,并将实现设置为 #[embeddable_as(mixin_name)] 中指定的混入名称。
要导入存储,请使用 #[substorage(v0)] 在合约的存储中创建一个新键。 将存储的键设置为与 component! 宏中为 storage: 声明的名称相同。 然后将值设置为组件中存储结构的路径。
要导入事件,请在事件枚举中创建一个新条目,并将 #[flat] 属性应用于它。 将条目设置为与 component! 宏中为 event: 声明的名称相同。 然后将类型设置为组件中枚举的路径。
本文是有关 Starknet 上的 Cairo 编程 的系列教程的一部分。
- 原文链接: rareskills.io/post/cairo...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~