本文介绍了Cairo中构造函数的使用方法,包括构造函数在合约部署时的作用、Cairo构造函数与Solidity构造函数的不同之处,以及如何在Cairo中传递复杂类型和处理构造函数的返回值。此外,还提到了Cairo中没有像Solidity那样直接支持payable构造函数。
构造函数是在合约部署期间执行的一次性调用函数,用于初始化状态变量、执行合约设置任务、进行跨合约交互等等。
在 Cairo 中,构造函数使用合约 mod 块内的 #[constructor] 属性定义。
本文将介绍构造函数在 Cairo 中如何工作,初始化合约状态的规则,以及构造函数返回值与 Solidity 的不同之处。
让我们看一个简单的 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_high 和 count_low。正如我们前面所说,这是因为单个 felt252 无法安全地容纳完整的 256 位整数。通过将每个半部分编码为单独的 felt252,构造函数稍后可以在合约内重建原始 u256 值。
以下是每种类型转换发生的情况:
u128 到 felt252 和 ContractAddress 到 felt252:使用 .into(),因为此转换始终是安全的且不会失败bool 到 felt252:使用 .into(),因为布尔值表示为 0 或 1,始终适合 felt252转换后,我们以与构造函数的参数序列完全匹配的顺序将每个编码值附加到 args 数组。此排序非常重要,因为如果参数不与构造函数期望的参数顺序对齐或对应,则部署将失败或产生意外行为。
最后一步是在将数组传递给 contract.deploy(@args) 时使用 @ 运算符,这是 Starknet 传递数组数据而不转移所有权的方式。
与 Solidity 不同,你可以无缝地将结构体和数组作为构造函数参数传递,Cairo 不支持将复杂类型直接传递到构造函数中,例如:
但是,如果我们想将这些类型传递给构造函数,目前有效的方法是:
让我们考虑下面的 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 结构体有两个字段; wallet 和 balance。两者都分别作为 _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");
}
我们将对此屏幕截图中高亮显示的部分进行更改(忽略我的编辑器中的🔥):

更新后的测试代码
发生了哪些变化:
deploy_contract 函数现在返回一个元组 (ContractAddress, felt252),而不仅仅是 ContractAddress。ret_vals 类型为 Span<felt252>,用于保存构造函数的返回值。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,测试应该通过。在以后的文章中,我们将看到如何直接从另一个合约部署合约。
虽然 STRK 的行为类似于 ERC-20 代币,但它也用作 Starknet 的原生费用代币。但是,Starknet 没有像以太坊的 ETH 那样真正的“原生代币”。因此,Cairo 不支持“payable”构造函数。 如果我们想强制合约在部署时具有一定的 STRK 余额,我们可以将 STRK 转移到预测的地址,然后在构造函数中断言合约的余额至少为所需的金额。
本文是 Starknet 上的 Cairo 编程 教程系列的一部分
- 原文链接: rareskills.io/post/cairo...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!