构造函数是在合约部署期间执行的一次性调用函数,用于初始化状态变量、执行合约设置任务、进行跨合约交互等操作。
在 Cairo 中,构造函数是使用合约的 mod 块内的 #[constructor] 属性来定义的。
本文将介绍构造函数在 Cairo 中是如何工作的、Scarb 测试中构造函数参数的手动与自动序列化,以及构造函数的返回值与 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`
let mut args = ArrayTrait::new();
args.append(5_felt252);
// 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`
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`
let mut args = ArrayTrait::new();
args.append(5_felt252);
// DEPLOY THE CONTRACT WITH THE PROVIDED CONSTRUCTOR ARGUMENTS
let (contract_address, _) = contract.deploy(@args).unwrap();
测试的其余部分确认此初始化按预期工作。我们调用 get_count,它返回 count 变量的当前值,然后断言它等于 5。
与 Solidity 的 foundry 测试中构造函数参数可以是各种类型(整数、字符串、数组、结构体、地址等)不同,Scarb 测试中的合约部署要求所有的构造函数参数都作为 felt252 值传递。这是因为 Starknet VM 是一个基于 felt252 的系统,底层的所有内容都是作为 felts 进行编码和传递的。
尽管如此,在测试部署期间,我们并不总是需要手动将每个参数转换为 felts。Starknet Foundry 提供了一个辅助函数,可以在测试中自动处理构造函数参数的序列化和合约部署。我们将稍后介绍它,但在使用该辅助函数之前,我们将学习如何序列化基本类型和复杂类型,以便了解该辅助函数在幕后执行的操作。
向构造函数传递非 felt252 的基本类型
如前所述,我们可以在部署期间手动将非 felt252 值(例如 ContractAddress)序列化为 felt252,将它们作为 felt252 数组传递,然后在构造函数中对其进行解码以初始化合约状态。让我们看一个例子,了解这在实践中是如何工作的。
Solidity 版本:
contract SomeContract {
uint256 count;
address owner;
bool isActive;
constructor(uint256 _count, address _owner, bool _isActive) {
count = _count;
owner = _owner;
isActive = _isActive;
}
}
这是 Cairo 中的等效代码:
#[starknet::contract]
mod HelloStarknet {
use starknet::ContractAddress;
use starknet::storage::{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: u256,
_owner: ContractAddress,
_is_active: bool
) {
// INIT STATE VARS
self.count.write(_count);
self.owner.write(_owner);
self.is_active.write(_is_active);
}
}
这里的构造函数接受三个参数:
_count:256 位计数值。_owner:合约所有者的地址。_is_active:一个布尔标志,指示合约是否应以活跃状态启动。
然后使用 .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 SERIALIZE
let count: u256 = 115792089237316195423570985008687907853269984665640564039457584007913129639935; // count = max value of u256
let owner: ContractAddress = 0xbeef.try_into().unwrap();
let is_active: bool = true;
// Serialize the u256 `count` value into two felt252 elements (low, high)
// and push them into the constructor `args` array.
count.serialize(ref args);
// SERIALIZE INTO FELT252, THEN PUSH TO `args` ARRAY
owner.serialize(ref args); // ContractAddress -> felt252
is_active.serialize(ref args); // bool -> felt252
// DEPLOY THE CONTRACT WITH THE PROVIDED CONSTRUCTOR ARGUMENTS
let (contract_address, _) = contract.deploy(@args).unwrap();
contract_address
}
在 Starknet 中处理多个构造函数参数时,必须将它们作为 Array<felt252> 传递。
在此示例中,由于 count 变量的类型为 u256,因此 .serialize() 方法会将其值拆分为两个 128 位的一半(即 low 和 high),然后再附加到 args 数组中。正如我们之前所说,这是因为单个 felt252 无法安全地容纳完整的 256 位整数。通过正确序列化这些值(将每一半编码为单独的 felt252),构造函数可以自动将它们反序列化为原始的 u256 值。
剩余的构造函数值:owner(一个 ContractAddress)和 is_active(一个 bool)各自都可以放入单个 felt252 中,因此序列化它们非常简单。按照与构造函数参数顺序完全匹配的顺序对它们进行序列化非常重要,因为如果参数与构造函数预期的参数顺序不对齐或不对应,部署将会失败或产生意外行为。
最后一步在将数组传递给 contract.deploy(@args) 时使用了 @ 运算符,这是 Starknet 在不转移所有权的情况下传递数组数据的方式。
在下一节中,我们将了解如何使用辅助函数在测试中自动化构造函数参数序列化,该辅助函数为我们执行上述所有序列化步骤。
传递复杂类型
就像基本类型一样,复杂的构造函数参数也必须在部署之前序列化为 felt252 值数组。
考虑下面这个 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 中非常直观。
下面是 Cairo 中的等效实现:
#[starknet::contract]
pub mod HelloStarknet {
use starknet::ContractAddress;
use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};
// `Account` STRUCT
#[derive(Drop, starknet::Store)]
pub struct Account {
pub wallet: ContractAddress,
pub balance: u64,
}
#[storage]
struct Storage {
// Use the `Account` struct in storage
pub user_account: Account,
}
// Constructor function
#[constructor]
// Each field is declared as its own constructor argument
fn constructor(
ref self: ContractState,
new_user_account: Account,
) {
// WRITE `new_user_account` STRUCT TO STORAGE
self.user_account.write(new_user_account);
}
}
在这里,合约模块被标记为 pub mod,使其成为一个公共模块。这允许外部代码(例如测试或其他模块)访问其内部定义的项,比如 Account 结构体。由于 Account 也被声明为 pub,因此它在合约模块之外变得完全可访问,这对于在测试中序列化此结构体是必需的。
派生在 Account 结构体上的 starknet::Store trait 使得 Cairo 能够正确地序列化和反序列化该结构体以进行存储。如果没有此 trait,编译器在向存储写入或从存储读取时将不知道如何处理该结构体。
最后,构造函数接收一个单一参数(类型为 Account 的 new_user_account),并将其直接写入存储。
使用 deploy_for_test 自动序列化构造函数参数与部署
该方法使用 Starknet Foundry 提供的辅助函数 deploy_for_test 为我们处理了所有繁重的工作,该函数会在部署之前自动序列化构造函数的输入。
通过这种方法,无需手动构建 Array<felt252> 或在每个参数上调用 .serialize()。相反,辅助函数读取合约的构造函数签名并在幕后执行正确的编码,确保每个参数完全按照合约预期的方式进行序列化。
deploy_for_test 函数签名:

deploy_for_test 函数参数
红框中是 deploy_for_test 参数。前两个是:
class_hash:合约编译后的类哈希 (class hash)deployment_params:一个包含部署新合约实例所需字段的结构体
在这两个固定参数之后,函数将接收与合约定义的构造函数参数数量一样多的参数:
<constructor_param1>: <constructor_param_type1><constructor_param2>: <constructor_param_type2>- …
<constructor_paramN>: <constructor_param_typeN>
换句话说,参数 1 到 N 直接映射到合约的构造函数参数。例如,给定如下构造函数:
#[constructor]
fn constructor(
ref self: ContractState,
count: u256,
owner: ContractAddress,
is_active: bool
) {
...
}
deploy_for_test 调用将如下所示:
deploy_for_test(
// **** First two params - START **** //
class_hash,
deployment_params,
// **** First two params - END **** //
// **** Constructor params - START **** //
count,
owner,
is_active,
// **** Constructor params - END **** //
);
deploy_for_test 函数返回类型

蓝框中是返回类型。该函数返回一个 Result,可以是两种结果之一:
-
Ok(..):意味着部署成功。它返回一个元组,包含:
- 新部署合约的
ContractAddress,以及 - 一个
Span<felt252>,表示构造函数返回的任何值(本章稍后讨论)。
- 新部署合约的
-
Err(..):意味着部署失败。在这种情况下,该函数返回一个包含由部署失败触发的错误数据的
Array<felt252>。
实际例子
要在实践中使用此函数,让我们部署前面显示的合约,它的构造函数只接收一个 Account 参数。因为模块(合约)和 Account 结构体都被标记为 pub,测试环境可以自动导入并序列化它们。
下面是一个完整的示例,演示了如何声明合约、准备部署参数、构造 Account 参数,并最终使用 deploy_for_test 部署合约:
// ********* NEW IMPORTS - START ********* //
use myconstructor::HelloStarknet::{Account, deploy_for_test};
use starknet::deployment::DeploymentParams;
// ********** NEW IMPORTS - END ********** //
use myconstructor::{IHelloStarknetDispatcher, IHelloStarknetDispatcherTrait};
use snforge_std::{DeclareResult, DeclareResultTrait, declare};
use starknet::ContractAddress;
fn deploy_contract(name: ByteArray) -> ContractAddress {
// 1. Declare contract to get the class_hash
let declare_result: DeclareResult = declare(name).unwrap();
let class_hash = declare_result.contract_class().class_hash;
// 2. Create deployment parameters
let deployment_params = DeploymentParams { salt: 0, deploy_from_zero: true };
// 3. Create new account
let new_account = Account { wallet: 'BOB'.try_into().unwrap(), balance: 5 };
// 4. Use `deploy_for_test` to deploy the contract
// It automatically handles serialization of constructor parameters
let (_contract_address, _) = deploy_for_test(*class_hash, deployment_params, new_account)
.expect('Deployment failed');
_contract_address
}
-
use myconstructor::HelloStarknet::{Account, deploy_for_test};这里,
myconstructor是项目名称,而HelloStarknet是该项目中定义的合约模块。通过导入 HelloStarknet 合约模块,我们可以访问Account结构体和deploy_for_test辅助函数。 -
use starknet::deployment::DeploymentParams;此导入引入了
DeploymentParams,它是由 Starknet 核心库提供的一个结构体。它允许配置合约的部署方式(例如使用自定义盐值 (salt) 或从零部署)。在调用deploy_for_test时总是需要它,因为该函数期望将部署参数作为其第二个实参。
最后,deploy_contract 函数将所有内容整合在一起。它首先声明合约以获取其 class_hash,然后准备所需的 DeploymentParams,构造一个将传递给合约构造函数的新 Account 结构体,最后调用 deploy_for_test。
练习: 完成 the Cairo-Exercises 仓库中的 constructor 练习。
构造函数的返回值
在 Solidity 中,构造函数从不返回值。在部署期间,EVM 执行构造函数,并将其唯一的“输出”视为要存储在链上的运行时字节码。
而 Cairo 的工作方式有所不同。部署后,它返回一个元组:(ContractAddress, Span<felt252>)。
ContractAddress:部署后合约的地址。Span<felt252>:一个包含构造函数返回数据的felt252值的跨度 (span)。除了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。 - 构造函数输出捕获:引入了
Span<felt252>类型的ret_vals来保存构造函数的返回值。 - 元组返回:我们在返回合约地址的同时返回
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>).
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).
(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.
assert(ret_val == 33, 'Invalid return value.');
}
为了确认,运行 scarb test,测试应该会通过。在后面的文章中,我们将看到如何直接从一个合约部署另一个合约。
Starknet 中类似 Payable 的构造函数
尽管 STRK 的表现类似于 ERC-20 代币,但它也是 Starknet 的原生费用代币。然而,Starknet 并没有像以太坊的 ETH 那样真正意义上的“原生代币”。因此,Cairo 不支持“payable(可支付)”构造函数。如果我们想在部署时强制合约拥有一定的 STRK 余额,我们可以将 STRK 转移到预测地址,然后在构造函数中断言合约的余额至少为预期金额。
本文是关于 Cairo Programming on Starknet 的教程系列的一部分