返回 SC 笔记
SC Day 3

Solidity 函数 + 事件 + 错误处理

函数可见性(public/private/internal/external)、view/pure、自定义修饰符、事件系统、错误处理四种方式

2026-04-12
第一阶段:基础构建
solidityfunctionseventserror-handlingmodifiers

日期: 2026-04-12 方向: Solidity 阶段: 第一阶段:基础构建 标签: #solidity #functions #events #error-handling #modifiers


今日目标

类型内容
学习函数可见性(public/private/internal/external)、view/pure、自定义修饰符、事件系统、错误处理四种方式
实操编写一个完整的 SimpleStorage 合约,包含所有上述特性
产出可部署的 SimpleStorage 合约 + 事件监听示例

一、函数可见性 (Visibility)

Solidity 函数有四种可见性,决定了谁可以调用这个函数。这是智能合约安全的第一道防线。

1.1 四种可见性详解

contract VisibilityDemo {
    // ============ public ============
    // 谁都能调用:外部账户、其他合约、本合约内部
    // 编译器自动为 public 状态变量生成 getter 函数
    uint public count = 0;

    function publicFunc() public returns (uint) {
        count += 1;
        return count;
    }

    // ============ external ============
    // 只能从外部调用(其他合约或 EOA)
    // 不能在合约内部直接调用(除非用 this.externalFunc())
    // 对于接收大量 calldata 参数时比 public 更省 gas
    function externalFunc(uint[] calldata data) external returns (uint) {
        return data.length;
    }

    // ============ internal ============
    // 只能在本合约和子合约中调用
    // 外部无法调用
    // 类似于面向对象语言中的 protected
    function internalFunc() internal view returns (uint) {
        return count;
    }

    // ============ private ============
    // 只能在本合约中调用
    // 子合约也不能调用!
    function privateFunc() private pure returns (string memory) {
        return "I am private";
    }

    // 演示调用关系
    function demo() public returns (uint) {
        publicFunc();           // ✅ 可以调用 public
        // externalFunc();      // ❌ 不能直接调用 external
        // this.externalFunc(new uint[](0)); // ✅ 可以通过 this 调用(但浪费 gas)
        internalFunc();         // ✅ 可以调用 internal
        privateFunc();          // ✅ 可以调用 private
        return count;
    }
}

contract Child is VisibilityDemo {
    function childDemo() public view returns (uint) {
        // publicFunc();        // ✅ 继承了 public
        internalFunc();         // ✅ 继承了 internal
        // privateFunc();       // ❌ 无法访问父合约的 private
        return 0;
    }
}

1.2 可见性选择指南

可见性使用场景Gas 优化
external只被外部调用的函数calldata 参数比 memory 省 gas
public需要内部和外部都能调用自动生成 getter
internal只在合约家族内使用的工具函数不暴露给外部
private绝对不想被子合约覆写的内部逻辑最限制

最佳实践:遵循最小权限原则——能用 private 就不用 internal,能用 internal 就不用 public,对外接口优先用 external


二、view 和 pure 函数

2.1 view 函数

view 函数承诺不修改状态,但可以读取状态。调用 view 函数不消耗 gas(除非从合约内部调用)。

uint public balance = 100;

// view: 读取状态变量但不修改
function getBalance() public view returns (uint) {
    return balance;  // ✅ 可以读
    // balance = 200; // ❌ 不能写
}

// 以下操作在 view 函数中被禁止:
// 1. 修改状态变量
// 2. 发射事件 (emit)
// 3. 创建其他合约
// 4. 使用 selfdestruct
// 5. 发送 ETH
// 6. 调用非 view/pure 函数

2.2 pure 函数

pure 函数承诺既不读取也不修改状态,它只依赖输入参数和本地变量。

// pure: 不读取也不修改状态
function add(uint a, uint b) public pure returns (uint) {
    return a + b;
}

function calculateHash(string memory input) public pure returns (bytes32) {
    return keccak256(abi.encodePacked(input));
}

// pure 函数中被禁止的额外操作(除了 view 的限制):
// 1. 读取状态变量
// 2. 读取 address(this).balance
// 3. 读取 block/tx/msg 的成员(msg.sig 和 msg.data 除外)
// 4. 调用非 pure 函数

2.3 三者对比

              修改状态   读取状态   Gas 消耗
 普通函数      ✅         ✅        要 gas
 view 函数     ❌         ✅        外部调用免费*
 pure 函数     ❌         ❌        外部调用免费*

 * 从合约内部调用 view/pure 函数仍然消耗 gas(因为是在一笔交易内)

三、自定义修饰符 (Modifiers)

修饰符(modifier)是 Solidity 独有的语法糖,用于在函数执行前后插入检查逻辑。它极大地减少了代码重复。

3.1 基础修饰符

contract ModifierDemo {
    address public owner;
    bool public paused;

    constructor() {
        owner = msg.sender;
    }

    // 最常用的修饰符:权限检查
    modifier onlyOwner() {
        require(msg.sender == owner, "Not the owner");
        _; // 这个下划线代表"执行被修饰的函数体"
    }

    // 带参数的修饰符
    modifier minimumAmount(uint _min) {
        require(msg.value >= _min, "Insufficient amount");
        _;
    }

    // 状态检查修饰符
    modifier whenNotPaused() {
        require(!paused, "Contract is paused");
        _;
    }

    // 使用修饰符
    function withdraw() public onlyOwner whenNotPaused {
        // 只有 owner 在合约未暂停时才能执行
        payable(owner).transfer(address(this).balance);
    }

    function deposit() public payable minimumAmount(0.01 ether) {
        // 必须发送至少 0.01 ETH
    }

    function pause() public onlyOwner {
        paused = true;
    }

    function unpause() public onlyOwner {
        paused = false;
    }
}

3.2 修饰符执行顺序

modifier checkA() {
    // Step 1: checkA 的前置逻辑
    require(msg.sender != address(0), "A check");
    _;
    // Step 4: checkA 的后置逻辑(少用)
}

modifier checkB() {
    // Step 2: checkB 的前置逻辑
    require(block.timestamp > 0, "B check");
    _;
    // Step 3: checkB 的后置逻辑
}

// 多个修饰符从左到右执行
function doSomething() public checkA checkB {
    // 这里是函数体
    // 执行顺序:checkA前 → checkB前 → 函数体 → checkB后 → checkA后
}

3.3 重入锁修饰符

// 这是安全领域最重要的修饰符之一
bool private _locked;

modifier nonReentrant() {
    require(!_locked, "Reentrant call");
    _locked = true;
    _;
    _locked = false;
}

function withdraw(uint amount) public nonReentrant {
    require(balances[msg.sender] >= amount);
    (bool success, ) = msg.sender.call{value: amount}("");
    require(success);
    balances[msg.sender] -= amount;
}

四、事件 (Events)

事件是智能合约与外部世界通信的重要机制。事件数据存储在交易日志(logs)中,不存储在合约 storage 里,因此 gas 成本远低于状态变量。

4.1 事件定义与触发

contract EventDemo {
    // 定义事件
    event Transfer(
        address indexed from,    // indexed: 可被过滤搜索
        address indexed to,      // 最多 3 个 indexed 参数
        uint256 value            // 非 indexed: 存储在 data 部分
    );

    event Approval(
        address indexed owner,
        address indexed spender,
        uint256 value
    );

    // 匿名事件(少用)
    event AnonymousEvent(uint data) anonymous;

    // 触发事件
    mapping(address => uint) public balances;

    function transfer(address to, uint amount) public {
        require(balances[msg.sender] >= amount, "Insufficient balance");

        balances[msg.sender] -= amount;
        balances[to] += amount;

        // emit 关键字触发事件
        emit Transfer(msg.sender, to, amount);
    }
}

4.2 indexed 参数详解

event Transfer(
    address indexed from,  // Topic[1] - 可以作为过滤条件
    address indexed to,    // Topic[2] - 可以作为过滤条件
    uint256 value          // Data - 不能直接过滤
);

// 事件在 EVM 中的底层结构:
// Topic[0] = keccak256("Transfer(address,address,uint256)") -- 事件签名
// Topic[1] = from address (indexed)
// Topic[2] = to address (indexed)
// Data = abi.encode(value) (non-indexed)

// indexed 的限制:
// - 最多 3 个 indexed 参数
// - indexed 的 string/bytes/array 会被哈希存储,无法恢复原始值
// - 非 indexed 参数更便宜但不能被用作搜索条件

4.3 前端监听事件

// 使用 ethers.js v6 监听事件
import { ethers } from "ethers";

const provider = new ethers.BrowserProvider(window.ethereum);
const contract = new ethers.Contract(contractAddress, abi, provider);

// 监听新事件(实时)
contract.on("Transfer", (from, to, value, event) => {
    console.log(`Transfer: ${from} → ${to}, amount: ${ethers.formatEther(value)}`);
    console.log("Block:", event.log.blockNumber);
    console.log("Tx Hash:", event.log.transactionHash);
});

// 查询历史事件
const filter = contract.filters.Transfer(myAddress, null); // from = myAddress
const events = await contract.queryFilter(filter, -1000); // 最近 1000 个区块
events.forEach(e => {
    console.log("Past transfer:", e.args.from, e.args.to, e.args.value);
});

4.4 事件的 Gas 成本

操作Gas 成本
基础事件触发~375 gas
每个 indexed topic~375 gas
每字节 data~8 gas
存储 32 字节到 storage~20,000 gas

事件比 storage 便宜约 50 倍!这就是为什么很多链下数据通过事件记录而非状态变量。


五、错误处理

Solidity 有四种错误处理方式,在不同场景下选择最适合的方式至关重要。

5.1 require

// require: 检查输入条件和外部调用返回值
// 失败时 revert 并退还剩余 gas
function transfer(address to, uint amount) public {
    require(to != address(0), "Cannot transfer to zero address");
    require(amount > 0, "Amount must be positive");
    require(balances[msg.sender] >= amount, "Insufficient balance");

    balances[msg.sender] -= amount;
    balances[to] += amount;
}

// require 的特点:
// - 检查不通过时 revert 并返回错误消息
// - 退还未使用的 gas
// - 适用于:输入验证、权限检查、外部调用检查

5.2 revert

// revert: 无条件回滚,通常与 if 配合使用
function withdraw(uint amount) public {
    if (amount == 0) {
        revert("Amount cannot be zero");
    }

    if (balances[msg.sender] < amount) {
        revert("Insufficient balance");
    }

    // 复杂条件判断时,revert 比嵌套 require 更清晰
    if (amount > maxWithdraw && msg.sender != owner) {
        revert("Exceeds max withdrawal for non-owner");
    }

    balances[msg.sender] -= amount;
    payable(msg.sender).transfer(amount);
}

5.3 assert

// assert: 检查内部不变量(invariants)
// 用于检测代码 bug,不应该在正常情况下触发
function internalCheck() internal view {
    // assert 消耗所有剩余 gas(Solidity 0.8.0 之前)
    // 0.8.0 之后也使用 revert,但语义上仍表示"不应该发生"
    assert(totalSupply == sumOfAllBalances());

    // 适用场景:
    // 1. 算术运算后的不变量检查
    // 2. 数据结构一致性验证
    // 3. 不应该到达的代码分支
}

function divide(uint a, uint b) public pure returns (uint) {
    require(b > 0, "Division by zero");  // 用户输入检查用 require
    uint result = a / b;
    assert(result * b <= a);             // 内部不变量用 assert
    return result;
}

5.4 自定义错误 (Custom Errors) - 推荐!

从 Solidity 0.8.4 开始,自定义错误是最 gas 高效的错误处理方式。

// 定义自定义错误
error InsufficientBalance(address account, uint256 requested, uint256 available);
error Unauthorized(address caller);
error ZeroAddress();
error AmountTooSmall(uint256 amount, uint256 minimum);
error TransferFailed();

contract CustomErrorDemo {
    mapping(address => uint) public balances;
    address public owner;

    constructor() {
        owner = msg.sender;
    }

    function transfer(address to, uint amount) public {
        if (to == address(0)) revert ZeroAddress();

        uint balance = balances[msg.sender];
        if (balance < amount) {
            revert InsufficientBalance(msg.sender, amount, balance);
        }

        balances[msg.sender] = balance - amount;
        balances[to] += amount;
    }

    function adminAction() public {
        if (msg.sender != owner) {
            revert Unauthorized(msg.sender);
        }
        // ... admin logic
    }
}

5.5 四种方式对比

方式Gas 成本适用场景信息
require(cond, "msg")中等输入验证字符串
revert("msg")中等复杂条件字符串
assert(cond)中等内部不变量无消息
revert CustomError()最低所有场景结构化数据

Gas 成本对比

// require 带字符串: 部署时和触发时都更贵
require(x > 0, "Value must be positive");
// 错误消息存储在合约字节码中,增加部署成本
// 触发时需要 ABI 编码字符串

// 自定义错误: 只存储 4 字节选择器
error ValueNotPositive();
if (x == 0) revert ValueNotPositive();
// 部署更小,触发更便宜
// 节省约 ~50% 的错误处理 gas

六、代码实战:完整 SimpleStorage 合约

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

/// @title SimpleStorage - 综合演示函数、事件和错误处理
/// @author SC Day3 Learning
/// @notice 一个带权限管理的键值存储合约

// ============ 自定义错误 ============
error SimpleStorage__NotOwner(address caller);
error SimpleStorage__NotAdmin(address caller);
error SimpleStorage__KeyEmpty();
error SimpleStorage__ValueEmpty();
error SimpleStorage__KeyNotFound(string key);
error SimpleStorage__ContractPaused();
error SimpleStorage__AlreadyAdmin(address admin);
error SimpleStorage__NotExistingAdmin(address admin);

contract SimpleStorage {
    // ============ 状态变量 ============
    address public immutable owner;
    bool public paused;
    uint256 public totalEntries;

    mapping(string => string) private store;
    mapping(string => bool) private keyExists;
    mapping(address => bool) public isAdmin;
    string[] private allKeys;

    // ============ 事件 ============
    event ValueSet(
        address indexed setter,
        string key,
        string value,
        uint256 timestamp
    );

    event ValueDeleted(
        address indexed deleter,
        string key,
        uint256 timestamp
    );

    event AdminAdded(address indexed admin, address indexed addedBy);
    event AdminRemoved(address indexed admin, address indexed removedBy);
    event ContractPaused(address indexed pausedBy);
    event ContractUnpaused(address indexed unpausedBy);

    // ============ 修饰符 ============
    modifier onlyOwner() {
        if (msg.sender != owner) {
            revert SimpleStorage__NotOwner(msg.sender);
        }
        _;
    }

    modifier onlyAdmin() {
        if (!isAdmin[msg.sender] && msg.sender != owner) {
            revert SimpleStorage__NotAdmin(msg.sender);
        }
        _;
    }

    modifier whenNotPaused() {
        if (paused) {
            revert SimpleStorage__ContractPaused();
        }
        _;
    }

    modifier validKey(string memory _key) {
        if (bytes(_key).length == 0) {
            revert SimpleStorage__KeyEmpty();
        }
        _;
    }

    // ============ 构造函数 ============
    constructor() {
        owner = msg.sender;
        isAdmin[msg.sender] = true;
        emit AdminAdded(msg.sender, msg.sender);
    }

    // ============ 外部函数(external)============

    /// @notice 设置键值对
    /// @param _key 键名
    /// @param _value 值
    function set(string calldata _key, string calldata _value)
        external
        whenNotPaused
        onlyAdmin
        validKey(_key)
    {
        if (bytes(_value).length == 0) {
            revert SimpleStorage__ValueEmpty();
        }

        if (!keyExists[_key]) {
            keyExists[_key] = true;
            allKeys.push(_key);
            totalEntries++;
        }

        store[_key] = _value;

        emit ValueSet(msg.sender, _key, _value, block.timestamp);
    }

    /// @notice 删除键值对
    function remove(string calldata _key)
        external
        whenNotPaused
        onlyAdmin
        validKey(_key)
    {
        if (!keyExists[_key]) {
            revert SimpleStorage__KeyNotFound(_key);
        }

        delete store[_key];
        keyExists[_key] = false;
        totalEntries--;

        emit ValueDeleted(msg.sender, _key, block.timestamp);
    }

    /// @notice 批量设置(展示 external + calldata 的 gas 优势)
    function batchSet(
        string[] calldata _keys,
        string[] calldata _values
    ) external whenNotPaused onlyAdmin {
        require(_keys.length == _values.length, "Length mismatch");
        require(_keys.length > 0, "Empty arrays");

        for (uint i = 0; i < _keys.length; i++) {
            // 内部调用用 internal 函数
            _setInternal(_keys[i], _values[i]);
        }
    }

    // ============ 公共函数(public)============

    /// @notice 查询值(view 函数 - 不消耗 gas)
    function get(string memory _key) public view validKey(_key) returns (string memory) {
        if (!keyExists[_key]) {
            revert SimpleStorage__KeyNotFound(_key);
        }
        return store[_key];
    }

    /// @notice 检查键是否存在
    function exists(string memory _key) public view returns (bool) {
        return keyExists[_key];
    }

    /// @notice 获取所有键
    function getAllKeys() public view returns (string[] memory) {
        return allKeys;
    }

    // ============ 内部函数(internal)============

    function _setInternal(string memory _key, string memory _value) internal {
        if (bytes(_key).length == 0 || bytes(_value).length == 0) return;

        if (!keyExists[_key]) {
            keyExists[_key] = true;
            allKeys.push(_key);
            totalEntries++;
        }

        store[_key] = _value;
        emit ValueSet(msg.sender, _key, _value, block.timestamp);
    }

    // ============ 纯函数(pure)============

    /// @notice 生成键的哈希(pure - 不读取也不修改状态)
    function hashKey(string memory _key) public pure returns (bytes32) {
        return keccak256(abi.encodePacked(_key));
    }

    /// @notice 拼接两个字符串
    function concat(string memory a, string memory b)
        public
        pure
        returns (string memory)
    {
        return string.concat(a, ":", b);
    }

    // ============ 管理函数 ============

    function addAdmin(address _admin) external onlyOwner {
        if (isAdmin[_admin]) {
            revert SimpleStorage__AlreadyAdmin(_admin);
        }
        isAdmin[_admin] = true;
        emit AdminAdded(_admin, msg.sender);
    }

    function removeAdmin(address _admin) external onlyOwner {
        if (!isAdmin[_admin]) {
            revert SimpleStorage__NotExistingAdmin(_admin);
        }
        if (_admin == owner) {
            revert SimpleStorage__NotOwner(msg.sender);
        }
        isAdmin[_admin] = false;
        emit AdminRemoved(_admin, msg.sender);
    }

    function togglePause() external onlyOwner {
        paused = !paused;
        if (paused) {
            emit ContractPaused(msg.sender);
        } else {
            emit ContractUnpaused(msg.sender);
        }
    }

    // ============ 内部不变量检查 ============

    /// @notice 验证合约状态一致性(用于调试)
    function checkInvariant() public view returns (bool) {
        uint256 existingCount = 0;
        for (uint i = 0; i < allKeys.length; i++) {
            if (keyExists[allKeys[i]]) {
                existingCount++;
            }
        }
        // assert 用于检查内部不变量
        assert(existingCount == totalEntries);
        return true;
    }
}

七、关键要点总结

要点说明
external > public外部接口优先用 external,calldata 更省 gas
view/pure 免 gas外部直接调用不消耗 gas(RPC call,非 transaction)
modifier 减少重复把检查逻辑提取到修饰符,DRY 原则
事件比 storage 便宜用事件记录链下数据,存储成本低 50 倍
自定义错误最省 gas比 require("string") 节省 ~50% gas
indexed 最多 3 个事件参数 indexed 用于链下过滤搜索
require = 输入验证检查用户输入和外部条件
assert = 不变量检查代码 bug,不应该在正常情况下触发

八、常见误区

误区 1:view/pure 函数在合约内部调用也免 gas

function writeThenRead() public {
    balances[msg.sender] = 100;    // 这消耗 gas
    uint b = getBalance();          // ← 这个 view 调用也消耗 gas!
    // 因为是在一笔交易中调用,getBalance 的执行也是交易的一部分
}

// 只有从外部直接调用 view/pure(通过 eth_call)才免 gas

误区 2:modifier 中 _ 的位置无所谓

modifier lockAndUnlock() {
    locked = true;
    _;              // 函数体在这里执行
    locked = false; // 函数体执行完后执行这行
}
// _ 的位置决定了函数体的执行时机
// 前置检查:_ 在最后
// 后置清理:_ 在中间

误区 3:事件数据可以在合约中读取

// ❌ 事件只写不读!
// 合约内部无法读取自己触发过的事件
// 事件是给链下(前端/索引器)使用的
emit Transfer(from, to, amount);
// 之后你无法在合约中查询 "最近一次 Transfer 的 to 是谁"

误区 4:require 和 assert 在 0.8.0+ 行为相同

// 从 0.8.0 开始,两者都使用 revert opcode
// 但语义不同:
// require: "条件不满足" → 输入/前提问题
// assert: "不变量被破坏" → 代码 bug
// 静态分析工具会区别对待它们

九、面试关联

Q: external 和 public 有什么区别?为什么 external 更省 gas?

A: external 函数的参数可以使用 calldata(只读内存区域,直接引用交易数据),而 public 函数的参数必须复制到 memory。对于大型数组参数,calldata 避免了内存复制,因此更省 gas。但 external 函数不能被合约内部直接调用(除非通过 this.func(),这会产生外部调用开销)。

Q: 为什么 Solidity 需要 view 和 pure 修饰符?

A: 这是 Solidity 的设计契约(design by contract)思想。view/pure 让编译器和开发者明确函数的副作用:view 保证不写状态,pure 保证不读也不写。这不仅帮助编译器优化(外部 view/pure 调用可以通过 eth_call 执行而不需要发交易),也让代码审计更容易判断函数是否可能修改状态。

Q: 自定义错误(custom error)相比 require 有什么优势?

A: 三个核心优势:(1) Gas 效率——自定义错误在 ABI 编码时只有 4 字节选择器 + 参数,而 require 的字符串消息存储在字节码中并在运行时编码,成本更高。(2) 结构化信息——可以携带类型化的参数(如 InsufficientBalance(address, uint256, uint256)),前端可以精确解析。(3) NatSpec 文档——可以用 @param 注释错误参数,IDE 和文档工具可以展示。

Q: 如何防止重入攻击?

A: 三种策略:(1) Checks-Effects-Interactions 模式——先做检查,再更新状态,最后进行外部调用。(2) nonReentrant 修饰符(ReentrancyGuard)——使用一个锁变量,函数执行时锁定,执行后解锁。(3) 使用 pull payment 模式——不主动给用户发 ETH,让用户自己来提取。最佳实践是三者结合使用。


十、参考资源

资源链接说明
Solidity 函数文档https://docs.soliditylang.org/en/latest/contracts.html#functions官方函数文档
Solidity 事件文档https://docs.soliditylang.org/en/latest/contracts.html#events官方事件文档
Solidity 错误处理https://docs.soliditylang.org/en/latest/control-structures.html#error-handling官方错误处理文档
OpenZeppelin ReentrancyGuardhttps://docs.openzeppelin.com/contracts/4.x/api/security#ReentrancyGuard重入锁参考实现
SWC Registryhttps://swcregistry.io/智能合约弱点分类
Solidity by Example - Eventshttps://solidity-by-example.org/events/事件代码示例