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 并使用读取/写入操作来实现 increaseget_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 具有 trait impl CounterImpl<TContractState, +HasComponent<TContractState>> of super::IHelloStarknet<ComponentState<TContractState>>
    • 合约具有 trait 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 中并非如此。

要“引入”一个组件,我们需要遵循以下清单:

  1. 使用 use 导入组件。 在这种情况下,它是 use super::CounterComponent。 这仅仅使代码可用,而不是将其集成到组件中
  2. 我们必须声明 component! 宏。 这将在稍后详细解释
  3. 必须混入公共函数
  4. 存储必须嵌入为 #[substorage(v0)]
  5. 事件必须使用 #[flat] 嵌入

这些步骤都不是可选的。 下面是对清单中每个项目的详细解释。

导入组件

将我们的 CounterComponent 命名为“CounterComponent”是可选的。 它可以被称为“SparklingWaterIsTasty”,这很好。 但是,我们导入的组件名称必须与以下各项使用的名称相同:

  • component! 中的 path
  • 它必须是 Mixin、Storage 和 Event 的来源,如下所示

CounterComponent 关键字高亮显示

导入 impl

要在我们的合约中包含组件的外部函数,我们必须执行以下操作:

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

高亮显示了具有 abi 嵌入的代码

导入存储

与自动导入存储的合约继承不同,这必须在 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 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
RareSkills
RareSkills
https://www.rareskills.io/