Solidity 中 require、assert、revert 和自定义错误有什么区别?
错误处理是智能合约安全的第一道防线。Solidity 提供了 require、assert、revert 三种内置机制,从 0.8.4 起又引入了自定义错误(Custom Error),在 Gas 效率和错误信息可读性上做了大幅改进。理解它们的区别和适用场景,是写好合约的基本功。
核心结论:四种机制怎么选?
| 机制 | 用途 | Gas 退还 | 错误信息 | 适用场景 |
|---|---|---|---|---|
| require | 输入验证、外部条件 | 退还剩余 | 字符串 | 检查函数参数、返回值 |
| assert | 内部不变量检查 | 退还剩余(0.8.0+) | Panic 编码 | 检测代码 bug |
| revert | 显式回退 | 退还剩余 | 字符串或自定义错误 | 复杂条件分支 |
| 自定义错误 | Gas 优化的 revert | 退还剩余 | 结构化参数 | 高频调用函数 |
关键区别:require 适合单行条件检查;assert 用于"不该发生"的内部逻辑;revert 用于复杂条件判断;自定义错误是 revert 的 Gas 优化版,编码只占 4 字节而非完整字符串。
require:输入验证的首选
require 在条件不满足时回滚所有状态变更,并退还剩余 Gas。它最常用于检查函数参数和外部条件:
soliditycontract RequireExample { mapping(address => uint256) public balances; function transfer(address _to, uint256 _amount) external { require(_to != address(0), "Invalid recipient"); require(_amount > 0, "Amount must be positive"); require(balances[msg.sender] >= _amount, "Insufficient balance"); balances[msg.sender] -= _amount; balances[_to] += _amount; } }
多个条件检查时,建议拆成多条 require 而不是用 && 连接——失败时能精确知道哪个条件不满足,调试体验更好。
assert:只用于内部不变量
assert 检查的是"理论上永远为真"的条件。如果 assert 失败,说明代码存在 bug,不是输入问题。
soliditycontract AssertExample { uint256 public totalSupply; mapping(address => uint256) public balances; function mint(address _to, uint256 _amount) external { require(_to != address(0), "Invalid address"); require(_amount > 0, "Invalid amount"); totalSupply += _amount; balances[_to] += _amount; // 检查不变量:总量不应小于单地址余额 assert(totalSupply >= balances[_to]); } }
注意:0.8.0 之前 assert 失败会消耗所有剩余 Gas,0.8.0 之后与 require 行为一致,也会退还剩余 Gas。但语义上仍应区分使用——require 验证外部输入,assert 验证内部逻辑。
revert 与自定义错误
revert 可以在任意位置显式回退交易。从 Solidity 0.8.4 开始,推荐配合自定义错误使用,相比字符串能显著节省 Gas:
soliditycontract CustomErrorExample { // 自定义错误定义——只编码 4 字节选择器 + 参数 error InsufficientBalance(uint256 available, uint256 required); error Unauthorized(address caller, bytes32 role); error DeadlineExpired(uint256 deadline, uint256 current); mapping(address => uint256) public balances; function withdraw(uint256 _amount) external { uint256 balance = balances[msg.sender]; if (balance < _amount) { revert InsufficientBalance(balance, _amount); } balances[msg.sender] = balance - _amount; } }
Gas 对比:为什么自定义错误更省?
require 的字符串错误信息需要完整存储在合约字节码中,触发时还要写入内存。而自定义错误只存储 4 字节的选择器哈希,参数按 ABI 编码追加:
soliditycontract GasComparison { error CustomErr(uint256 code); // require 字符串:约 200-300 gas + 字符串存储成本 function useRequire(uint256 v) external pure { require(v > 0, "Value must be greater than zero"); } // 自定义错误:约 50-100 gas function useCustomError(uint256 v) external pure { if (v == 0) revert CustomErr(1); } }
OpenZeppelin 的实测数据显示,在 ERC-20 transfer 中用自定义错误替换 require 字符串,整体 Gas 可降低约 30%。
try/catch:处理外部调用异常
try/catch 从 Solidity 0.6.0 开始支持,只能用于外部合约调用,不能捕获当前合约内部的错误:
soliditycontract TryCatchExample { error ExternalCallFailed(address target); function safeCall(address _target, uint256 _value) external returns (bool success, uint256 result) { try IExternal(_target).operation(_value) returns (uint256 _result) { return (true, _result); } catch Error(string memory reason) { // 捕获 revert("string") / require(false, "string") revert ExternalCallFailed(_target); } catch Panic(uint256 errorCode) { // 捕获 assert 失败、算术溢出、除零等 // 0x01: assert 失败, 0x11: 算术溢出, 0x12: 除零 revert ExternalCallFailed(_target); } catch (bytes memory lowLevelData) { // 捕获自定义错误及其他低级错误 revert ExternalCallFailed(_target); } } } interface IExternal { function operation(uint256) external returns (uint256); }
try/catch 最常见的应用场景是批量调用外部合约,部分失败不影响其他调用:
solidityfunction batchCall( address[] calldata targets, uint256[] calldata values ) external returns (bool[] memory successes) { successes = new bool[](targets.length); for (uint i = 0; i < targets.length; i++) { try IExternal(targets[i]).operation(values[i]) returns (uint256) { successes[i] = true; } catch { successes[i] = false; // 单个失败不回滚整个交易 } } }
生产级错误设计模式
在真实项目中,错误定义应该模块化、语义清晰。参考 OpenZeppelin 的风格:
soliditycontract ProductionToken { // 通用错误 error ZeroAddress(); error ZeroAmount(); error InsufficientBalance(uint256 available, uint256 required); error Unauthorized(address caller); // 业务错误 error TransferFailed(address from, address to, uint256 amount); error CooldownActive(uint256 endTime); mapping(address => uint256) public balances; mapping(address => uint256) public stakeEndTime; uint256 public constant COOLDOWN = 7 days; function withdraw(uint256 _amount) external { if (_amount == 0) revert ZeroAmount(); uint256 balance = balances[msg.sender]; if (balance < _amount) revert InsufficientBalance(balance, _amount); uint256 cooldownEnd = stakeEndTime[msg.sender] + COOLDOWN; if (block.timestamp < cooldownEnd) revert CooldownActive(cooldownEnd); balances[msg.sender] = balance - _amount; (bool ok, ) = msg.sender.call{value: _amount}(""); if (!ok) revert TransferFailed(address(this), msg.sender, _amount); } }
这种设计有几个要点:错误名自解释,参数携带调试信息,模块间错误不混淆,调用方能根据错误类型做不同处理。
面试追问:0.8.0 后 assert 的 Gas 行为变了什么?
Solidity 0.8.0 之前,assert 失败会消耗所有剩余 Gas(作为"惩罚"),require 失败则退还剩余 Gas。0.8.0 起 EIP-2200 生效后,两者 Gas 退还行为统一了——assert 失败也退还剩余 Gas。但语义区分仍然重要:assert 失败 = 代码有 bug,require 失败 = 输入不合法。
另外,EIP-6093 提出了一套标准化的错误类型(如 ERC20InsufficientBalance、ERC20InvalidSender 等),OpenZeppelin 已经在最新版合约中采用,面试中提到这个说明你关注生态演进。