本文介绍了 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 函数。我们将在代码后面解释它们是如何组合在一起的:
// 与 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,
}
}
文件顶部的 trait 与 Scarb 默认创建的 trait 相比未更改。
我们没有更改它,因为测试文件专门导入此接口。 使用不同的名称会导致测试无法编译:
#[starknet::interface]
pub trait IHelloStarknet<TContractState> {
/// 增加合约余额。
fn increase_balance(ref self: TContractState, amount: felt252);
/// 检索合约余额。
fn get_balance(self: @TContractState) -> felt252;
}
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 具有 trait impl CounterImpl<TContractState, +HasComponent<TContractState>> of super::IHelloStarknet<ComponentState<TContractState>>
impl HelloStarknetImpl of super::IHelloStarknet<ContractState>接下来是对上面列出的差异的详细解释。
#[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
要在我们的合约中包含组件的外部函数,我们必须执行以下操作:
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 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!