3. Solidity基础回顾:合约结构、状态变量与函数、修饰符(modifier)、事件(event)
好,咱们正式开始写合约之前,先把Solidity最核心的几块骨头啃一啃。这部分内容,说白了就是智能合约的"骨架"和"血肉"。你如果能把合约结构、状态变量、函数、修饰符、事件这五样东西搞明白,写一个标准的ERC20代币合约,基本就没什么障碍了。
3.1 合约结构:一个文件里到底该放什么?
我个人习惯,写合约之前先想清楚结构。一个典型的Solidity文件,从上到下大概是这样的:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
// 1. 导入依赖
import "./SomeContract.sol";
// 2. 接口定义(如果有)
interface IMyInterface {
function doSomething() external;
}
// 3. 合约主体
contract MyToken {
// 3.1 状态变量
string public name;
// 3.2 事件
event Transfer(address indexed from, address indexed to, uint256 value);
// 3.3 修饰符
modifier onlyOwner() {
require(msg.sender == owner, "Not owner");
_;
}
// 3.4 构造函数
constructor() {
owner = msg.sender;
}
// 3.5 函数
function transfer(address to, uint256 amount) public returns (bool) {
// ...
}
// 3.6 回退函数(如果需要)
receive() external payable {}
fallback() external payable {}
}
嗯,这里要注意:顺序不是强制的,但业界基本都这么排。你想想看,如果状态变量和函数混在一起,找起来多费劲。我在项目中遇到过有人把事件定义写在最底下,结果审计的时候被审计师吐槽了——他们习惯从上往下读。
3.2 状态变量:合约的"记忆"
状态变量,说白了就是存在区块链上的数据。跟普通编程语言的变量不同,它一旦写入,就永久保存在链上,除非你主动修改或销毁。
| 类型 | 说明 | 示例 |
|---|---|---|
| uint256 | 无符号整数(最常用) | uint256 public totalSupply; |
| address | 以太坊地址 | address public owner; |
| mapping | 键值对映射 | mapping(address => uint256) public balanceOf; |
| bool | 布尔值 | bool public paused; |
| string | 字符串 | string public name; |
我个人建议,能用 uint256 就别用 uint8 或 uint16。为什么?因为Solidity的存储是256位对齐的,你用 uint8 并不会节省gas,反而可能因为类型转换出bug。我曾经在早期项目里为了省gas用了 uint64,结果跟其他合约交互时类型不匹配,排查了半天——得不偿失。
关键点:状态变量的可见性决定了谁能读它。
public:自动生成getter函数,任何人都能读internal:只有本合约和子合约能读private:只有本合约能读(注意:链上数据其实都能被外部看到,private只是阻止其他合约访问)
3.3 函数:合约的"行为"
函数就是合约能做什么事。ERC20里最核心的函数就几个:transfer、approve、transferFrom。但咱们先看函数的基本结构:
function 函数名(参数列表) 可见性 状态可变性 修饰符 returns (返回值类型) {
// 函数体
}
举个例子:
function transfer(address to, uint256 amount)
public
virtual
override
returns (bool)
{
require(balanceOf[msg.sender] >= amount, "Insufficient balance");
balanceOf[msg.sender] -= amount;
balanceOf[to] += amount;
emit Transfer(msg.sender, to, amount);
return true;
}
这里有几个概念你得搞清楚:
- 可见性:
public(公开)、external(仅外部调用)、internal(内部)、private(私有) - 状态可变性:
view(只读不写)、pure(不读也不写)、payable(可接收ETH) - virtual/override:用于继承和重写,ERC20里经常用到
小技巧:如果你写一个函数只是查数据,记得加上 view 或 pure。这样调用时不需要花gas(外部调用时)。我刚开始写合约时经常忘记加,白白浪费测试网的ETH。
3.4 修饰符(modifier):代码复用的利器
修饰符,说白了就是函数的"前置检查器"。你可以在函数执行前做一些验证,比如检查调用者是不是管理员、合约有没有暂停等。
modifier onlyOwner() {
require(msg.sender == owner, "Not the contract owner");
_; // 这个下划线代表"继续执行原函数"
}
function mint(address to, uint256 amount) public onlyOwner {
_mint(to, amount);
}
嗯,这里要注意:_ 的位置决定了检查逻辑是在函数体之前还是之后执行。如果你把 _ 放在修饰符中间,那就可以实现"先执行一部分逻辑,再检查,再执行剩余逻辑"的效果。不过说实话,我很少这么用,容易把人绕晕。
避坑指南:我曾经在修饰符里写了一个循环,结果gas消耗爆炸了。修饰符里尽量只做简单的require检查,别写复杂逻辑。另外,多个修饰符的执行顺序是从左到右,别搞反了。
3.5 事件(event):链上的"广播"
事件是Solidity里最优雅的设计之一。它让你能在链上"喊一嗓子",然后链下的应用(比如前端、索引器)能听到这声喊。ERC20标准里定义了必须触发的事件:Transfer 和 Approval。
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
为什么要有事件?你想想看,如果前端想知道某个地址的余额变化,总不能每秒钟去链上查一次吧?有了事件,前端可以订阅 Transfer 事件,一旦有转账发生,立刻就能知道。
indexed 关键字很关键。加了 indexed 的参数可以被高效检索。一个事件最多可以有3个 indexed 参数。我个人习惯把 from、to 这种关键地址都加上 indexed,方便后续查询。
重要提醒:事件不是必须的,但ERC20标准强制要求。而且,事件是低成本的数据存储方式——触发事件的gas远低于写状态变量。如果你需要记录一些历史数据,用事件比用mapping存储划算得多。
3.6 把这些串起来:一个迷你ERC20雏形
咱们把上面讲的东西揉到一起,看看一个最简的ERC20长什么样:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract MiniERC20 {
string public name = "Mini Token";
string public symbol = "MTK";
uint8 public decimals = 18;
uint256 public totalSupply;
mapping(address => uint256) public balanceOf;
mapping(address => mapping(address => uint256)) public allowance;
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
address public owner;
modifier onlyOwner() {
require(msg.sender == owner, "Not owner");
_;
}
constructor(uint256 _initialSupply) {
owner = msg.sender;
_mint(msg.sender, _initialSupply);
}
function transfer(address to, uint256 amount) public returns (bool) {
require(balanceOf[msg.sender] >= amount, "Insufficient balance");
balanceOf[msg.sender] -= amount;
balanceOf[to] += amount;
emit Transfer(msg.sender, to, amount);
return true;
}
function _mint(address to, uint256 amount) internal {
totalSupply += amount;
balanceOf[to] += amount;
emit Transfer(address(0), to, amount);
}
}
你看,这个合约虽然简陋,但已经把状态变量、函数、修饰符、事件都用上了。下一章咱们会在这个基础上,把ERC20标准里缺失的 approve 和 transferFrom 补上,再处理一些边界情况。
个人经验:写合约的时候,先把事件定义好,再写函数。为什么?因为事件定义了你的合约"对外承诺"了什么。先想清楚要广播什么信息,再想怎么实现,思路会清晰很多。我后来带团队时,都要求先写事件和接口,再写实现——效果不错。
好,Solidity基础回顾就到这里。这些东西看着简单,但每个点都有坑。你写合约的时候,多想想:这个状态变量真的需要public吗?这个修饰符会不会被滥用?这个事件参数要不要加indexed?想清楚了,合约质量自然就上去了。