Solidity 基础:contract、state 和 function
1. 概览
Solidity 是在 Ethereum 及其他 EVM 兼容链上编写智能合约的主要语言。本文帮助你掌握三个最基础的构建块:contract、state variable 和 function —— 通过一个简单的 Wallet 合约来讲解。读完后,你将能够编写具有状态存储的合约、使用 modifier 控制访问权限,并用 Hardhat 编写第一个测试。
2. 什么是 Contract
在 Solidity 中,contract 类似于 Java 中的 class —— 它定义了将被部署到区块链上成为独立地址的 state(数据)和 behavior(函数)。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract Wallet {
// state variables 和 function 将在这里
}
// SPDX-License-Identifier这一行声明许可证,编译器在缺少它时会发出警告。pragma solidity ^0.8.24将编译器版本锁定为 0.8.24 及以上(但低于 0.9.0)。- 每个合约部署后都会获得一个独立的地址(address),就像一个在链上永久存活的“对象”。
注意: 生产环境中不要使用过于宽松的 pragma,如
>=0.8.0 <0.9.0—— 新编译器可能会改变默认行为(例如溢出检查)。
3. State variable —— 链上存储的数据
State variable 是直接写入区块链存储的变量,在函数调用之间持久存在,写入时消耗 gas。
contract Wallet {
address public owner;
uint256 public balance;
mapping(address => uint256) public allowance;
constructor() {
owner = msg.sender;
}
}
address public owner: 存储合约所有者的地址。关键字public会自动生成一个免费的 getter 函数owner()。uint256 public balance: 余额,256 位无符号整数类型 —— 这是 Solidity 中最常用的数字类型。mapping(address => uint256): 类似于HashMap<Address, BigInteger>,但无法遍历 key,只能按 key 查询。constructor()在部署时恰好执行一次,msg.sender是发起部署交易的地址。
3.1. 区分 storage、memory 和 calldata
function updateAllowance(address spender, uint256 amount) external {
// amount 位于 calldata(只读,gas 便宜)
allowance[spender] = amount; // 写入 storage
}
storage: 链上的永久数据(state variable)。memory: 函数执行期间的临时数据,函数结束后被清除。calldata: 类似memory但只读,用于external函数的参数 —— 比memory更节省 gas。
注意: 对于
array、struct、string类型的参数忘记声明memory/calldata会导致编译器错误 —— 这不同于 Java,Java 中你无需关心内存区域。
4. 函数和可见性
Solidity 中的函数必须明确声明可见性,没有像 Java 那样的默认“包私有”。
contract Wallet {
address public owner;
uint256 public balance;
constructor() {
owner = msg.sender;
}
function deposit() external payable {
balance += msg.value;
}
function withdraw(uint256 amount) external {
require(msg.sender == owner, "Wallet: not owner");
require(amount <= balance, "Wallet: insufficient balance");
balance -= amount;
(bool success, ) = owner.call{value: amount}("");
require(success, "Wallet: transfer failed");
}
function getBalance() external view returns (uint256) {
return balance;
}
}
external: 只能从合约外部调用(例如 MetaMask 钱包、其他合约)—— 当参数是大型 array/string 时比public更节省 gas。payable: 允许函数接收交易中附带的 ETH;msg.value是随交易发送的 wei 数量。view: 承诺不修改 state,离线调用时(通过eth_call)不消耗 gas。require(condition, "message"): 检查条件,如果为假则回滚整个交易并显示消息 —— 类似于抛出IllegalArgumentException,但会回滚 state 和已使用的 gas(除了检查本身消耗的 gas)。owner.call{value: amount}(""): 推荐的 ETH 发送方式,而不是transfer()/send(),因为它不受 2300 gas 的硬性限制。
四个可见性级别:
| 可见性 | 从合约内部调用 | 从子合约调用 | 从外部调用 |
|---|---|---|---|
private |
✅ | ❌ | ❌ |
internal |
✅ | ✅ | ❌ |
public |
✅ | ✅ | ✅ |
external |
❌(需使用 this.foo()) |
❌ | ✅ |
5. Modifier —— 复用检查条件
在多个函数中重复 require(msg.sender == owner, ...) 表明应该将其提取为 modifier。
contract Wallet {
address public owner;
uint256 public balance;
modifier onlyOwner() {
require(msg.sender == owner, "Wallet: not owner");
_;
}
constructor() {
owner = msg.sender;
}
function withdraw(uint256 amount) external onlyOwner {
require(amount <= balance, "Wallet: insufficient balance");
balance -= amount;
payable(owner).transfer(amount);
}
}
modifier onlyOwner()定义一个检查块;_;是原始函数体将被插入的位置。- 在
withdraw声明后附加onlyOwner使代码读起来像“只有钱包所有者才能提取资金” —— 比在每个函数中重复require更清晰。 - Modifier 可以接收参数,例如
modifier minAmount(uint256 amount) { require(amount > 0, "..."); _; }。
6. Event —— 为链下记录日志
合约无法直接向前端返回历史数据;event 用于记录日志,dApp 可以监听这些日志。
contract Wallet {
event Deposited(address indexed from, uint256 amount);
event Withdrawn(address indexed to, uint256 amount);
uint256 public balance;
function deposit() external payable {
balance += msg.value;
emit Deposited(msg.sender, msg.value);
}
}
event Deposited(...)声明日志结构,最多 3 个参数可以用indexed修饰以便过滤。emit Deposited(...)将日志写入交易收据,gas 成本远低于保存到 storage。- 前端使用 ethers.js 等库监听
contract.on("Deposited", ...)来实时更新 UI。
7. 学习 Solidity 时的常见错误
- 混淆
transfer/send/call发送 ETH:transfer限制为 2300 gas,容易在接收合约具有复杂逻辑(代理、多签)时失败。使用call并配合require检查结果,如第 4 部分的示例。 - 忘记
require验证输入: 缺少验证会导致合约被利用(例如负数提取、溢出 —— 虽然从 Solidity 0.8 开始溢出时会自动回滚,但业务逻辑仍需单独验证)。 - 在错误的场景下比较
external和public: 对于只从外部调用的函数使用public会在参数是数组/字符串时浪费更多 gas,因为 Solidity 必须将数据复制到memory而不是直接从calldata读取。 - 在可能的情况下不声明
view/pure: 导致区块浏览器、钱包等工具无法识别只读函数,用户误认为需要支付 gas。
何时不应该使 function
public: 如果函数永远不会从其他合约或继承中调用,使用external来节省 gas 并限制攻击面。
8. 使用 Hardhat 编写测试
为 Hardhat 项目添加依赖(Node.js):
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
测试 withdraw 行为,只允许所有者:
const { expect } = require("chai");
const { ethers } = require("hardhat");
describe("Wallet", function () {
it("只有所有者才能提取资金", async function () {
const [owner, stranger] = await ethers.getSigners();
const Wallet = await ethers.getContractFactory("Wallet");
const wallet = await Wallet.deploy();
await wallet.connect(owner).deposit({ value: ethers.parseEther("1") });
await expect(
wallet.connect(stranger).withdraw(ethers.parseEther("1"))
).to.be.revertedWith("Wallet: not owner");
await expect(
wallet.connect(owner).withdraw(ethers.parseEther("1"))
).to.changeEtherBalance(owner, ethers.parseEther("1"));
});
});
ethers.getContractFactory("Wallet")编译并加载合约的 artifact。wallet.connect(stranger)模拟来自非所有者地址的交易。revertedWith("...")断言交易使用来自require的正确消息回滚。changeEtherBalance检查所有者的 ETH 余额在提取后恰好增加 1 ETH —— 这是一种常见的测试方式,而不仅仅检查返回值。
运行测试:
npx hardhat test
Wallet
✔ 只有所有者才能提取资金 (120ms)
1 passing (120ms)
9. 总结
你已经了解了 Solidity 的四个基础概念:contract 作为部署单元、state variable 使用三个内存区域(storage/memory/calldata)存储链上数据、function 配合 visibility 和 modifier 控制访问权限,以及 event 与链下通信。这些概念是你进阶学习 inheritance、interface 或 ERC-20/ERC-721 标准等高级主题的基础 —— 所有这些都建立在这四个基本构建块之上。