Cairo 中的构造函数

本文介绍了Cairo中构造函数的使用方法,包括构造函数在合约部署时的作用、Cairo构造函数与Solidity构造函数的不同之处,以及如何在Cairo中传递复杂类型和处理构造函数的返回值。此外,还提到了Cairo中没有像Solidity那样直接支持payable构造函数。

构造函数是在合约部署期间执行的一次性调用函数,用于初始化状态变量、执行合约设置任务、进行跨合约交互等等。

在 Cairo 中,构造函数使用合约 mod 块内的 #[constructor] 属性定义。

本文将介绍构造函数在 Cairo 中如何工作,初始化合约状态的规则,以及构造函数返回值与 Solidity 的不同之处。

一个简单的 Cairo 构造函数

让我们看一个简单的 Solidity 合约,它在其构造函数中初始化一个状态变量 count

contract Counter {
    uint256 public count;

    constructor(uint256 _count) {
        count = _count;
    }
}

这是等效的 Cairo 版本:

#[starknet::interface]
pub trait IHelloStarknet<TContractState> {
    fn get_count(self: @TContractState) -> felt252;
}

#[starknet::contract]
mod HelloStarknet {
    use starknet::storage::{StoragePointerReadAccess,StoragePointerWriteAccess};

    #[storage]
    struct Storage {
        count: felt252
    }

    // ************ CONSTRUCTOR FUNCTION ************* //
    #[constructor]
    fn constructor(ref self: ContractState, _count: felt252) {
        self.count.write(_count);
    }

    #[abi(embed_v0)]
    impl HelloStarknetImpl of super::IHelloStarknet<ContractState> {
        fn get_count(self: @ContractState) -> felt252 {
            self.count.read()
        }
    }
}

上面代码中的 #[constructor] 属性将该函数标记为合约的构造函数。该函数必须命名为 constructor,并在部署期间执行一次。它接受 ref self 以允许对合约存储的写入访问,以及初始化所需的任何参数,在本例中为 _count

上面的构造函数只是用作为参数传递的值初始化 count 存储变量。

让我们测试一下,使用 Scarb 创建一个新项目:

scarb new counter

接下来,将 src/lib.cairo 文件中生成的代码替换为上面的 HelloStarknet 合约代码。

为了验证 count 是否正确初始化,我们将编写一个测试,该测试使用特定值部署合约,然后断言存储的值与我们传递的值匹配。

打开测试文件 (tests/test_contract.cairo),然后将生成的代码替换为以下代码:

use counter::{IHelloStarknetDispatcher, IHelloStarknetDispatcherTrait};
use snforge_std::{ContractClassTrait, DeclareResultTrait, declare};
use starknet::ContractAddress;

fn deploy_contract(name: ByteArray) -> ContractAddress {
    let contract = declare(name).unwrap().contract_class();

    // CREATE ARGUMENT ARRAY FOR CONSTRUCTOR. WE'RE PASSING `5` AS THE INITIAL VALUE FOR `count`
        // 为构造函数创建参数数组。我们将 `5` 作为 `count` 的初始值传递
    let mut args = ArrayTrait::new();
    args.append(5);

    // DEPLOY THE CONTRACT WITH THE PROVIDED CONSTRUCTOR ARGUMENTS
        // 使用提供的构造函数参数部署合约
    let (contract_address, _) = contract.deploy(@args).unwrap();

    contract_address // Return address of deployed contract
        // 返回已部署合约的地址
}

#[test]
fn test_count() {
    let contract_address = deploy_contract("HelloStarknet");

    let dispatcher = IHelloStarknetDispatcher { contract_address };

    // CALL THE `get_count` FUNCTION TO READ THE CURRENT VALUE OF `count`
        // 调用 `get_count` 函数以读取 `count` 的当前值
    let result = dispatcher.get_count();

    // ASSERT THAT THE INITIALIZED VALUE MATCHES WHAT WE PASSED DURING DEPLOYMENT
        // 断言初始化的值与我们在部署期间传递的值匹配
    assert!(result == 5, "failed {} != 5", result);
}

此测试的关键部分是我们如何在部署期间将构造函数参数作为 felt252 值的数组传递。这部分很容易被忽略,但它很重要;我们在调用 deploy 之前将值 5 附加到 args 数组,这就是我们使用 5 初始化合约的方式。

// CREATE ARGUMENT ARRAY FOR CONSTRUCTOR. WE'RE PASSING `5` AS THE INITIAL VALUE FOR `count`
// 为构造函数创建参数数组。我们将 `5` 作为 `count` 的初始值传递
let mut args = ArrayTrait::new();
args.append(5);

// DEPLOY THE CONTRACT WITH THE PROVIDED CONSTRUCTOR ARGUMENTS
// 使用提供的构造函数参数部署合约
let (contract_address, _) = contract.deploy(@args).unwrap();

测试的其余部分确认此初始化按预期工作。我们调用 get_count,它返回 count 变量的当前值,然后断言它等于 5

与 Solidity 不同,Solidity 中构造函数参数可以是任何支持的类型,例如整数、地址、字符串、结构或数组,Cairo 要求所有构造函数参数在部署期间都作为 felt252 值传递。

这是因为 felt252 是 CairoVM 理解的基础类型,并且 Starknet CLI 将所有构造函数参数序列化为 felt252。我们不能在部署过程中直接传递 ContractAddress 或其他类型。但是,如果我们需要初始化其他类型,我们可以手动将它们编码为单独的 felt252 值,并在构造函数中解码它们。

felt252 以外的原始类型传递给构造函数

如前所述,我们可以在部署期间手动将非 felt252 值(例如 ContractAddress)编码为 felt252,将它们作为 felt252 数组 传递,然后在构造函数中解码它们以初始化合约的状态。 让我们看一个例子,看看这在实践中是如何运作的。

Solidity 版本:

contract SomeContract {
    uint256 public count;
    address public owner;
    bool public isActive;

    constructor(uint256 _count, address _owner, bool _isActive) {
        count = _count;
        owner = _owner;
        isActive = _isActive;
    }
}

这是 Cairo 中的等效版本:

use starknet::ContractAddress;

#[starknet::interface]
pub trait ISomeContract<TContractState> {
    fn get_count(self: @TContractState) -> u256;
    fn get_owner(self: @TContractState) -> ContractAddress;
    fn get_bool(self: @TContractState) -> bool;
}

#[starknet::contract]
mod SomeContract {
    use starknet::ContractAddress;
    use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};

    // Define the contract's storage.
        // 定义合约的存储。
    #[storage]
    struct Storage {
        count: u256,
        owner: ContractAddress,
        is_active: bool,
    }

        // CONSTRUCTOR FUNCTION
                // 构造函数
        #[constructor]
        fn constructor(
                ref self: ContractState,
                _count_high: u128,
                _count_low: u128,
                _owner: ContractAddress,
                _is_active: bool
            ) {

            // (high * 2^128) + low
                        // (高位 * 2^128) + 低位
            let _count: u256 = (_count_high.into() * 2_u256.pow(128)) + _count_low.into();

            // INIT STATE VARS
                        // 初始化状态变量
            self.count.write(_count);
            self.owner.write(_owner);
            self.is_active.write(_is_active);
        }
}

在这里,构造函数接受四个参数:

  • _count_high_count_low:两个 128 位的值,它们共同表示一个 256 位整数 (u256)。这种分离是必要的,因为任何大于最大可表示 felt252 的值都不能直接作为 Cairo 中的构造函数参数传递。通过将 256 位值分成两个 128 位的一半,可以在构造函数中安全地传输和重建它。
  • _owner:合约所有者的地址。
  • _is_active:一个布尔标志,指示合约是否应以活动状态启动。

因为 Cairo 本身不支持像 << 这样的位移运算符,所以代码使用算术运算将两个 128 位段组合成一个 u256。下面的表达式:

// (high * 2^128) + low
// (高位 * 2^128) + 低位
(_count_high.into() * 2_u256.pow(128)) + _count_low.into()

等效于将 _count_high 向左移动 128 位,然后加上 _count_low。结果 _count 存储为合约的初始计数器值。

最后,构造函数使用 .write() 方法将提供的每个参数写入其相应的存储变量,从而在部署时初始化状态。

编码和传递构造函数参数

下面的函数 deploy_contract 显示了在作为构造函数参数传递之前,如何对非 felt252 类型的值进行编码:

fn deploy_contract(name: ByteArray) -> ContractAddress {
    let contract = declare(name).unwrap().contract_class();

    // CREATE ARGUMENT ARRAY FOR CONSTRUCTOR
        // 为构造函数创建参数数组
    let mut args = ArrayTrait::new();

    // VALUES TO ENCODE
        // 要编码的值
    // count = max value of u256
        // count = u256 的最大值
    let count = 115792089237316195423570985008687907853269984665640564039457584007913129639935;
    let owner:ContractAddress = 0xbeef.try_into().unwrap();
    let is_active = true;

    // ENCODE INTO FELT252, THEN APPEND TO `args` ARRAY
        // 编码为 FELT252,然后附加到 `args` 数组
    args.append(count.high.into());    // count_high (u128) -> felt252
    args.append(count.low.into());     // count_low (u128) -> felt252
    args.append(owner.into());         // ContractAddress -> felt252
    args.append(is_active.into());     // bool -> felt252

    // DEPLOY THE CONTRACT WITH THE PROVIDED CONSTRUCTOR ARGUMENTS
        // 使用提供的构造函数参数部署合约
    let (contract_address, _) = contract.deploy(@args).unwrap();
    contract_address
}

在 Starknet 中处理多个构造函数参数时,它们必须作为 Array<felt252> 传递。在此示例中,count 值被分成两个 128 位的一半;在附加到数组之前,count_highcount_low。正如我们前面所说,这是因为单个 felt252 无法安全地容纳完整的 256 位整数。通过将每个半部分编码为单独的 felt252,构造函数稍后可以在合约内重建原始 u256 值。

以下是每种类型转换发生的情况:

  • u128felt252ContractAddressfelt252:使用 .into(),因为此转换始终是安全的且不会失败
  • boolfelt252:使用 .into(),因为布尔值表示为 0 或 1,始终适合 felt252

转换后,我们以与构造函数的参数序列完全匹配的顺序将每个编码值附加到 args 数组。此排序非常重要,因为如果参数不与构造函数期望的参数顺序对齐或对应,则部署将失败或产生意外行为。

最后一步是在将数组传递给 contract.deploy(@args) 时使用 @ 运算符,这是 Starknet 传递数组数据而不转移所有权的方式。

传递复杂类型

与 Solidity 不同,你可以无缝地将结构体和数组作为构造函数参数传递,Cairo 不支持将复杂类型直接传递到构造函数中,例如:

  • 自定义结构体
  • 动态数组
  • 带有数据的枚举

但是,如果我们想将这些类型传递给构造函数,目前有效的方法是:

  1. 将每个值声明为其自己的构造函数参数(将复杂类型展平为原始类型)
  2. 在构造函数中从这些参数手动重建复杂类型。

让我们考虑下面的 Solidity 合约,该合约通过构造函数初始化一个复杂类型(一个 struct):

// SPDX-License-Identifier: MIT
pragma solidity =0.8.30;

contract Bank {
    struct Account {
        address wallet;
        uint64 balance;
    }

    Account userAccount;

    constructor(Account memory _userAccount) {
        userAccount = _userAccount;
    }
}

这在 Solidity 中很简单; Account 结构体直接作为参数传递给构造函数,Solidity 处理其余的事情。

但是,在 Cairo 中,我们没有这种奢侈。像结构体这样的复杂类型不能直接传递给构造函数。每个字段都必须展平为单独的参数,然后才能在构造函数内部手动重建结构体,然后将其写入存储。

在下面的示例中,Account 结构体有两个字段; walletbalance。两者都分别作为 _wallet_balance 传递给构造函数。然后,构造函数重新创建 Account 结构体并将其存储在合约的状态中:

#[starknet::contract]
mod Bank {
    use starknet::ContractAddress;
    use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};

    // `Account` STRUCT
        // `Account` 结构体
    #[derive(Drop, starknet::Store)]
    pub struct Account {
        pub wallet: ContractAddress,
        pub balance: u64,
    }

    #[storage]
    struct Storage {
        // Use the `Account` struct in storage
                // 在存储中使用 `Account` 结构体
        pub user_account: Account,
    }

    // Constructor function
        // 构造函数
    #[constructor]
    // Each field is declared as its own constructor argument
        // 每个字段都声明为自己的构造函数参数
    fn constructor(
            ref self: ContractState,
            _wallet: ContractAddress,
            _balance: u64
        ) {
        // Reconstruct the struct manually from those arguments
                // 从这些参数手动重建结构体
        let new_user_account = Account { wallet: _wallet, balance: _balance };

        // WRITE `new_user_account` STRUCT TO STORAGE
                // 将 `new_user_account` 结构体写入存储
        self.user_account.write(new_user_account);
    }
}

请记住,我们在 Account 结构体之上添加的 starknet::Store 特征告诉 Cairo 编译器如何处理存储中的结构体。 如果没有它,编译器将不知道如何序列化/反序列化我们的结构体以进行存储操作。

练习:Cairo-Exercises 仓库中解决 constructor 练习。

构造函数中的返回值

在 Solidity 中,构造函数永远不会返回值。在部署期间,EVM 执行构造函数,并将其唯一的“输出”视为要在链上存储的运行时字节码。

另一方面,Cairo 的工作方式不同。部署后,它返回一个元组:(ContractAddress, Span<felt252>)

  • ContractAddress:已部署合约的地址。
  • Span<felt252>:保存构造函数返回数据的 felt252 值的跨度。任何 felt252 以外的类型都会在放置在此处之前自动转换。

为了证明这一点,让我们引导一个新的 scarb 项目:

scarb new rett

然后,我们向 lib.cairo 中生成的合约添加一个构造函数,如下所示:


#[starknet::interface]
pub trait IHelloStarknet<TContractState> {
    fn increase_balance(ref self: TContractState, amount: felt252);
    fn get_balance(self: @TContractState) -> felt252;
}

#[starknet::contract]
mod HelloStarknet {
    use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};

    #[storage]
    struct Storage {
        balance: felt252,
    }

    // ************************ NEWLY ADDED - START ***********************//
        // ************************ 新增 - 开始 ***********************//
    #[constructor]
    fn constructor(ref self: ContractState) -> felt252 {
        33
    }
    // ************************ NEWLY ADDED - END ************************//
        // ************************ 新增 - 结束 ***********************//

    #[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()
        }
    }
}

为了简单起见,我们的构造函数将只返回值 33

为了表明构造函数实际上返回一个值,让我们导航到测试文件 test_contract.cairo 并将生成的代码替换为此(为便于阅读简化了代码):

use rett::{IHelloStarknetDispatcher, IHelloStarknetDispatcherTrait};
use snforge_std::{ContractClassTrait, DeclareResultTrait, declare};
use starknet::ContractAddress;

fn deploy_contract(name: ByteArray) -> ContractAddress {
    let contract = declare(name).unwrap().contract_class();
    let (contract_address, _) = contract.deploy(@ArrayTrait::new()).unwrap();
    contract_address
}

#[test]
fn test_increase_balance() {
    let contract_address = deploy_contract("HelloStarknet");
}

我们将对此屏幕截图中高亮显示的部分进行更改(忽略我的编辑器中的🔥):

显示函数的屏幕截图,用于部署高亮显示更新部分的合约

更新后的测试代码

发生了哪些变化:

  1. 返回类型:deploy_contract 函数现在返回一个元组 (ContractAddress, felt252),而不仅仅是 ContractAddress
  2. 构造函数输出捕获:引入了 ret_vals 类型为 Span<felt252>,用于保存构造函数的返回值。
  3. 元组返回:我们将合约地址与 ret_vals 的第一个元素一起返回,因为构造函数只返回一个值。

最后,测试断言构造函数的返回值是 33,确认在部署期间正确传递了该值。

use rett::{IHelloStarknetDispatcher, IHelloStarknetDispatcherTrait};
use snforge_std::{ContractClassTrait, DeclareResultTrait, declare};
use starknet::ContractAddress;

// Change return type to a tuple so we can capture the constructor’s return value.
// 将返回类型更改为元组,以便我们可以捕获构造函数的返回值。
fn deploy_contract(name: ByteArray) -> (ContractAddress, felt252) {
    let contract = declare(name).unwrap().contract_class();

    // Capture both the contract address and the constructor’s return values (as a Span<felt252>).
        // 同时捕获合约地址和构造函数的返回值(作为 Span<felt252>)。
    let (contract_address, ret_vals) = contract.deploy(@ArrayTrait::new()).unwrap();

    // Return the address plus the first element in ret_vals (we expect only one value).
        // 返回地址加上 ret_vals 中的第一个元素(我们只期望一个值)。
    (contract_address, *ret_vals.at(0))
}

#[test]
fn test_increase_balance() {
    let (_, ret_val) = deploy_contract("HelloStarknet");

        // Verify that the constructor actually returned 33 as expected.
                // 验证构造函数是否按预期实际返回了 33。
    assert(ret_val == 33, 'Invalid return value.');
}

要确认,请运行 scarb test,测试应该通过。在以后的文章中,我们将看到如何直接从另一个合约部署合约。

是否有类似 Payable 的构造函数?

虽然 STRK 的行为类似于 ERC-20 代币,但它也用作 Starknet 的原生费用代币。但是,Starknet 没有像以太坊的 ETH 那样真正的“原生代币”。因此,Cairo 不支持“payable”构造函数。 如果我们想强制合约在部署时具有一定的 STRK 余额,我们可以将 STRK 转移到预测的地址,然后在构造函数中断言合约的余额至少为所需的金额。

本文是 Starknet 上的 Cairo 编程 教程系列的一部分

  • 原文链接: rareskills.io/post/cairo...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

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