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 就别用 uint8uint16。为什么?因为Solidity的存储是256位对齐的,你用 uint8 并不会节省gas,反而可能因为类型转换出bug。我曾经在早期项目里为了省gas用了 uint64,结果跟其他合约交互时类型不匹配,排查了半天——得不偿失。

关键点:状态变量的可见性决定了谁能读它。

  • public:自动生成getter函数,任何人都能读
  • internal:只有本合约和子合约能读
  • private:只有本合约能读(注意:链上数据其实都能被外部看到,private只是阻止其他合约访问)

3.3 函数:合约的"行为"

函数就是合约能做什么事。ERC20里最核心的函数就几个:transferapprovetransferFrom。但咱们先看函数的基本结构:

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里经常用到

小技巧:如果你写一个函数只是查数据,记得加上 viewpure。这样调用时不需要花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标准里定义了必须触发的事件:TransferApproval

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 参数。我个人习惯把 fromto 这种关键地址都加上 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标准里缺失的 approvetransferFrom 补上,再处理一些边界情况。

个人经验:写合约的时候,先把事件定义好,再写函数。为什么?因为事件定义了你的合约"对外承诺"了什么。先想清楚要广播什么信息,再想怎么实现,思路会清晰很多。我后来带团队时,都要求先写事件和接口,再写实现——效果不错。

好,Solidity基础回顾就到这里。这些东西看着简单,但每个点都有坑。你写合约的时候,多想想:这个状态变量真的需要public吗?这个修饰符会不会被滥用?这个事件参数要不要加indexed?想清楚了,合约质量自然就上去了。