从零开始的Web3学习 12| Solidity 条件判断(If Else) 、循环For和While、Error
先跟追这个系列的朋友说一声:前面我们刚把函数、映射、修饰器这些基础打完,这一篇开始进入 Solidity 的控制流和错误处理,也就是if / else、for / while以及require、revert、assert、自定义 error。为什么单独要花一整篇来讲这几个东西?因为 Solidity 里的条件判断和循环,跟你在 JavaScript 或者 Python 里写的控制流,从语法上看几乎一模一样,但从执行逻辑上看完全是两个世界。
普通程序里写一个while(true)死循环,最多让电脑风扇转起来;在链上写一个没有边界的循环,跑起来就是眼睁睁看着 gas 被烧掉,交易 rever,所有状态回滚。条件判断看似简单,但它直接决定了一条交易走哪条执行路径、消耗多少 gas、最终是否成功。这一篇我会把三者拆开讲清楚,并给出一套可以复制的完整示例合约和测试流程。适合正在学 Solidity 的入门开发者,也适合已经能写简单合约、但对循环边界和错误选择比较模糊的人参考。
1. If-Else、三目运算符与 Solidity 的条件分支规则
1.1 基本写法与类型约束
Solidity 的if / else跟 C 系语言长得几乎一样,基本的骨架长这样:
// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; contract IfDemo { uint256 public stored; function setIf(uint256 value) external { if (value > 100) { stored = value; } else if (value > 50) { stored = value * 2; } else { stored = 0; } } }这段代码没什么难懂的,但有一个细节新手特别容易忽略:Solidity 的if条件表达式必须是真正的bool类型,编译器不会帮你做隐式转换。你在 C 语言里写if (1)是可以的,Solidity 里直接编译报错。if (stored)这种写法也过不了,必须写成if (stored != 0)或者if (stored > 0)。我在刚学的时候就在这种地方栽过跟头,看起来只是多写了几个字符,实际是 Solidity 对类型安全的要求比常规语言更严格。
另一个值得注意的地方是if-else链的优先级。Solidity 没有switch语句,遇到多重分支,只能老老实实把else if串下去。如果分支特别多,比如超过五六个,就要开始考虑是否改用一个mapping加状态变量的组合来替代,因为链上每多一层分支判断,多路径重入测试和 gas 分析的复杂度都会上升。
1.2 分支不是语法糖,而是交易路径
很多新手把if / else当作纯粹的语法糖,觉得它只是让代码"看起来更有结构"。但在 Solidity 里,条件分支直接决定了一笔交易会执行哪些操作、写出哪些状态、消耗多少 gas。
举个例子,如果一个函数里有两个分支:一个分支只是读取一个变量,另一个分支要写一个mapping并触发事件。两者的 gas 开销可以差出几万甚至十几万。更关键的是,如果分支里写的是revert或者require,那么检测条件是否满足,相当于交易的第一道门卫。门卫放行,后面的状态修改才有意义;门卫拦截,整笔交易会回滚,已经改的 state 全部撤销。
这带来一个实战经验:把"条件检测"尽量集中在函数开头,不要让状态修改和条件判断交错出现。我见过不少合约在函数中间穿插判断,这种写法除了增加阅读成本,还会让"检查-生效-交互"这个经典模式变得难以维护。正确的做法是先把所有前置条件检查完,再进行状态更新,最后才是对外交互。
1.3 三目运算符的边界
三目运算符cond ? a : b在 Solidity 里是表达式,不是语句,它的典型用途是给变量赋值:
uint256 x = value > 100 ? value : 0;但它不能单独作为一个语句存在,你不能写value > 100 ? stored = value : stored = 0;。我在真实代码里很少用它做复杂嵌套,因为一旦嵌套超过两层,阅读体验会急剧下降。如果只是简单的二选一赋值,用它确实比if-else更省空间和 gas,算是 Solidity 里少数能感觉到"简洁即高效"的语法。
2. 循环的真正约束:Gas、边界与终止条件
2.1 For 循环:数组遍历的标准姿势
Solidity 的for循环和 JavaScript 几乎没有区别,最常见的场景是遍历一个数组或者一个确定长度的列表。
// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; contract ForDemo { uint256[] public numbers; function sumAll() external view returns (uint256 total) { for (uint256 i = 0; i < numbers.length; i++) { total += numbers[i]; } } }这段代码有三个值得抠的细节。
第一,循环变量建议用uint256而不是uint8或uint16。虽然短类型看起来省存储,但循环变量往往存在栈上,短类型反而可能在每次运算时都要做额外的位数转换。实测下来直接uint256最省事,gas 也不差。
第二,边界条件用i < numbers.length,不要用i <= numbers.length - 1。后者在数组长度为 0 时会因为uint下溢直接报错,而前者天然避免这个问题。现代 Solidity 版本默认带溢出检查,你一旦出现下溢,交易会 rever,不要指望像旧版本那样悄悄回绕。
第三,如果这个函数是view函数,遍历数组去计算聚合值非常舒服,因为不写状态,gas 也会相对低。但如果是非 view 函数,遍历的同时还要修改状态,就要注意后面会讲到的 gas 上限问题。
2.2 While 循环:条件驱动的使用场景
while循环在 Solidity 里比for少见,但并不是没用。我一般用它的场景是:循环次数事先不确定,但终止条件明确。典型例子是你有一个积分余额,要不断消耗积分直到余额接近某个阈值,或者直到抽奖次数达到上限。
// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; contract WhileDemo { mapping(address => uint256) public points; error InsufficientPoints(uint256 available, uint256 required); error ExceedMaxLoops(uint256 loops); uint256 public constant TICKET_PRICE = 5; uint256 public drawsLeft; constructor(uint256 _drawsLeft) { drawsLeft = _drawsLeft; } function multiDraw() external { uint256 budget = points[msg.sender]; uint256 loopCount = 0; uint256 maxLoops = 1000; while (budget >= TICKET_PRICE && drawsLeft > 0) { if (loopCount >= maxLoops) { revert ExceedMaxLoops(loopCount); } budget -= TICKET_PRICE; drawsLeft--; loopCount++; } points[msg.sender] = budget; } }这里选择while而不是for的原因是:循环次数依赖于两个动态条件(用户余额和剩余抽奖次数),而不是一个简单的固定长度。写成for也能实现,但需要先计算min(userCount, drawCount),增加了读取和判断逻辑,排队感很差。
这个例子还隐含了一个安全设计:maxLoops上限。链上环境不是无限执行的,你的循环消耗完一个区块的 gas 预算就会被强制终止。与其等到 out of gas 才结束,不如在业务层就明确设置最大循环轮数。真实项目里,批量转账、批量销毁、批量铸造几乎都会使用这种"设置上限 + 分批处理"的模式,因为单个交易能处理的条目数是有物理上限的。
2.3 为什么一个"看起来没毛病"的循环会烧光你的 Gas
这是整个循环专题里最重要的一个认知。普通服务器上的程序可以循环处理百万条数据,链上不行。以太坊一个区块能容纳的总 gas 是有上限的,比如当前很多链的区块 gas limit 是 3000 万左右。一个交易能用的 gas,最多也就这个级别。循环里的每一步,无论是读取 storage、写 storage、还是执行算术计算,每一笔都要花钱。
最典型的翻车现场就是我上面那个while的反面写法。如果我把budget >= TICKET_PRICE改成budget > TICKET_PRICE,并且budget恰好是TICKET_PRICE的整数倍,那么循环会永远执行下去,直到这笔交易的 gas 被耗尽。此时所有已扣减的积分会因为回滚而恢复,但用户付出的 gas 不会退回来。
你可能会觉得这种低级错误不会犯。实际上我在测试网络就干过一次。我用一个for循环把所有用户的积分清零,测试时数组长度只有几十,没问题;后来往数组里塞了几千个地址,测试直接报 out of gas。问题不在于我的逻辑写错了,而是我假设"循环一个数组"消耗小。几千个地址看起来不多,但每个地址都要读 storage、判断、写 storage,累加起来就是一个很恐怖的数字。
结论其实很简单:普通项目里,单个交易循环处理的数量建议控制在几十到一两百以内。超过这个量,就该考虑:
- 是不是可以把数据合并后在链下算好,再上传一个结果?
- 是不是改成多个用户自己调用单笔函数,而不是由一个合约统一处理?
- 是不是引入分批函数,每批处理 50 或 100 条?
链上的"批量"永远是一种有限度的批量。
3. Error 机制:三种写法和选择理由
3.1 require、revert、assert 的基础分工
Solidity 提供了多种触发错误的方式,但从目的上可以粗略分成两类:一类是业务逻辑的前置条件检查,另一类是代码内部不变量检查。前者用require和revert足够,后者用assert。
function transfer(uint256 amount) external { require(amount > 0, "Amount must be greater than 0"); require(balanceOf[msg.sender] >= amount, "Insufficient balance"); balanceOf[msg.sender] -= amount; }上面这个就是require的典型用法。它会检查条件,如果条件不满足,就回滚交易并返回错误字符串。
revert在功能上跟require类似,但用起来更灵活。你可以单独写一个if判断然后触发revert,而不是把所有条件都塞进require里。尤其是当你需要在报错时附带更多动态信息时,revert配合自定义 error 会更好用。
assert则完全不同。它不是用来做常规业务校验的,而是用来检查"理论上不应该出现的问题"。比如合约里的记账逻辑保证某个总额不变,正常运行时totalSupply == sum(balances)恒成立,如果这个条件被打破,那一定是合约本身有 bug。用assert检查这类不变量,一旦失败,不只是交易回滚,还可能消耗掉所有剩余 gas,这是刻意为之的,因为在链上可以把assert失败理解为"合约出了严重 bug,应该被立刻抓住"。
下面这张表可以帮你快速区分该用哪个:
| 写法 | 主要定位 | 失败后的 gas 行为 | 是否适合业务检测 |
|---|---|---|---|
| require | 前置条件、权限校验 | 回滚并返回剩余 gas | 适合 |
| if + revert | 复杂分支错误、带参数错误 | 回滚并返回剩余 gas | 适合 |
| assert | 内部不变量、代码逻辑确认 | 回滚并消耗剩余 gas | 不适合 |
| 自定义 error | 业务错误类型化 | 回滚并返回剩余 gas | 适合 |
3.2 自定义 Error:比字符串更省 Gas,也更规范
从 Solidity 0.8.4 开始,官方推荐用自定义error替代字符串错误。原因有两个:省 gas 和更规范。
省 gas 很容易理解。字符串本质是动态数据,require(cond, "Insufficient balance")会把这一整段字符串编码到交易的 calldata 和错误数据里。自定义error则是一个签名固定的错误类型,传输和存储的开销都小得多。业务越复杂,错误提示越多,用自定义error省下来的 gas 越可观。
更规范体现在错误可以带参数。你可以在报错时把具体数值一起抛出来,这在排查问题的时候非常有用。
// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; contract ErrorDemo { mapping(address => uint256) public balanceOf; error InsufficientBalance(uint256 available, uint256 required); error ZeroAmount(); function withdraw(uint256 amount) external { if (amount == 0) { revert ZeroAmount(); } uint256 available = balanceOf[msg.sender]; if (available < amount) { revert InsufficientBalance(available, amount); } balanceOf[msg.sender] = available - amount; } }前端通过 ethers.js 捕获的时候,会比解析字符串更可靠。比如你调用withdraw时余额不足,返回的错误会直接包含available和required两个字段,前端可以拿到这两个值做提示,而不是靠截取字符串文本。用过 Web2 时代错误码的开发者应该能感觉到,这本质上是把"字符串错误"升级成了"结构化错误对象"。
3.3 从字符串到 Error:我的选择习惯
如果单纯按效率和组织方式排序,我的习惯是这样的:
- 在新项目里,优先全部使用自定义
error。 - 简单的权限检查和数值边界检查,也可以用
require加短字符串,但仅限于两三个字面量的场景。 - 任何需要携带上下文数据、或者错误原因有多种可能性的场景,一律
error + revert。 assert只在确认"这里不可能失败"的内部检查中使用。
有一个常见问题是:require和if + revert到底选哪个?从 gas 上看两者差别不大,但从可读性上看,require更适合"一段简单条件 + 一个清晰原因"的场景;而if + revert适合"条件本身就需要多行计算、或者触发 error 时需要传参"的场景。不要为了炫技而强制用revert,也别为了少写代码把所有判断塞进一条长长的require里。以我读合约的经验,一条require里塞上四五个布尔条件,是最难 debug 的写法,因为报错你根本不知道是哪个条件触发的。
4. 综合实战:一个带 If-Else、For、Error 的积分批量发放合约
4.1 合约需求与完整代码
光讲语法不落地等于没讲。这一节我们来做一个真实的综合示例:一个积分账本合约,支持 owner 批量给多个地址发放积分,也支持用户消费积分。需求点如下:
- 只有 owner 可以调用批量发放函数。
- 批量发放时传入两个数组:地址数组和数量数组,数组长度必须一致。
- 发放数量不能为 0,地址不能是零地址。
- 用户消费积分时,如果余额不足,要报错并返回当前余额和所需数量。
- 遍历过程中如果条目太多,要能控制上限这一点我们会在 4.3 里验证。
// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; contract PointLedger { mapping(address => uint256) public balanceOf; address public owner; error NotOwner(address caller); error ZeroAddress(); error ZeroAmount(); error EmptyList(); error ArrayLengthMismatch(uint256 first, uint256 second); error InsufficientBalance(uint256 available, uint256 required); constructor() { owner = msg.sender; } modifier onlyOwner() { if (msg.sender != owner) { revert NotOwner(msg.sender); } _; } function batchCredit( address[] calldata users, uint256[] calldata amounts ) external onlyOwner { if (users.length == 0) { revert EmptyList(); } if (users.length != amounts.length) { revert ArrayLengthMismatch(users.length, amounts.length); } for (uint256 i = 0; i < users.length; i++) { if (users[i] == address(0)) { revert ZeroAddress(); } if (amounts[i] == 0) { revert ZeroAmount(); } balanceOf[users[i]] += amounts[i]; } } function spend(uint256 amount) external { if (amount == 0) { revert ZeroAmount(); } uint256 available = balanceOf[msg.sender]; if (available < amount) { revert InsufficientBalance(available, amount); } balanceOf[msg.sender] = available - amount; } }这段代码就是整篇内容的浓缩版。onlyOwner修饰器里用了if + revert并携带了调用者参数;batchCredit里用了EmptyList、ArrayLengthMismatch、ZeroAddress、ZeroAmount多个自定义 error;遍历用了for循环;spend里用到了余额不足时的动态报错。
4.2 用 Foundry 编写第一组测试
写合约不测试等于裸奔。Foundry 是目前 Solidity 项目里非常顺手的测试框架,直接用 Solidity 写测试,不需要额外学 JS。
先初始化一个 Foundry 项目(假设你已经安装了 forge):
forge init point-ledger cd point-ledger把上面的合约放到src/PointLedger.sol,然后在test/PointLedger.t.sol写测试:
// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; import {Test} from "forge-std/Test.sol"; import {PointLedger} from "../src/PointLedger.sol"; contract PointLedgerTest is Test { PointLedger ledger; function setUp() public { ledger = new PointLedger(); } function testBatchCredit() public { address[] memory users = new address[](2); users[0] = address(0x123); users[1] = address(0x456); uint256[] memory amounts = new uint256[](2); amounts[0] = 100; amounts[1] = 200; ledger.batchCredit(users, amounts); assertEq(ledger.balanceOf(users[0]), 100); assertEq(ledger.balanceOf(users[1]), 200); } function testBatchCreditRevertsOnLengthMismatch() public { address[] memory users = new address[](2); users[0] = address(0x123); users[1] = address(0x456); uint256[] memory amounts = new uint256[](1); amounts[0] = 100; vm.expectRevert( abi.encodeWithSelector( PointLedger.ArrayLengthMismatch.selector, 2, 1 ) ); ledger.batchCredit(users, amounts); } function testSpendRevertsInsufficientBalance() public { vm.expectRevert( abi.encodeWithSelector( PointLedger.InsufficientBalance.selector, 0, 50 ) ); ledger.spend(50); } }第一眼看到这个测试,可能会觉得比预期的还简单。但实际上它就是抓住了一个关键点:你不需要测试 Fromtend 的那些逻辑,你只需要确认合约的每个分支在正常与异常时都会到达你应该到达的终点。我在顺手调试合约的时候,最常见的方式也是这样,把几个正则分支覆盖到,同时用vm.expectRevert把每个预期的 revert 验证一遍,能解决大多数情况下 80% 的合约逻辑问题。
执行测试:
forge test -vvv-vvv会列出更详细的 trace,你可以清楚地看到哪一步触发revert、携带了什么参数。第一次跑通这套流程的时候,我才真正理解了自定义 error 的调试价值:以前用字符串错误,失败了你还要去日志里翻文本;现在直接在 trace 里看到InsufficientBalance(available=0, required=50),问题一目了然。
4.3 调整循环上限,观察 Gas 变化
回到循环和 gas 的话题。我们给batchCredit传一个超长列表,看看会发生什么。写一个压力测试:
function testLargeBatch() public { uint256 size = 1000; address[] memory users = new address[](size); uint256[] memory amounts = new uint256[](size); for (uint256 i = 0; i < size; i++) { users[i] = address(uint160(i + 1)); amounts[i] = 1; } uint256 gasBefore = gasleft(); ledger.batchCredit(users, amounts); uint256 gasUsed = gasBefore - gasleft(); console.log("Gas used for 1000 entries:", gasUsed); }我在本地跑过一次,1000条数据要消耗的 gas 在一个小目标之间他会超过两百万,如果是几千条,直接超过单笔交易的上限。问题的本质不是合约有 bug,而是链上循环的物理上限摆在那里。
面对这种情况,常见的改进方向有三个:
- 合约里直接加一个
maxBatchSize,比如 100,超过就 rever。这能防住用户误操作。 - 前端分页,把 1000 条数据拆成 10 批,每批调用一次
batchCredit。 - 如果业务允许,把积分的批量计算放到链下,合成成一个累加结果再上链,回避免循环。
这三种方向在实际项目里各自有适用场景,但很少有一种是彻底"最优"到可以无视其他方案的。说白了,写链上代码就是在物理约束下做取舍。
5. 我在实际写这些控制流时养成的几个习惯
最后聊点个人经验,这些不是语法层面的要求,但帮你少走很多弯路。
第一,凡是遍历数组的循环,第一件事就检查数组长度,以及数组长度和其他参数数组是否匹配。空数组和长度不匹配是那类最容易炸的边界,你花 15 秒提前判断,就能避免一整晚的 debug。我在几个月前一个批量空投合约里就是忽略了空数组,导致第一次执行直接报错,前端报的错误信息也含糊不清。后来加了isEmptyList和ArrayLengthMismatch两个错,问题才算从根源解决。
第二,循环里尽量避免做复杂的 storage 操作。比如遍历一个数组,然后往另一个mapping或动态数组里写数据。每次写 storage 都是交易成本的巨头,如果能在链下掐好数据或者先把数据装到内存数组里再一次性写入,往往能省下不少 gas。我自己测了一轮之后,非常支持把循环体内的状态变更尽量"延迟到最后一位或者只在for外执行",当然具体还是要看业务逻辑的优先级。
第三,错误设计不要临时拼凑。合约一旦部署,错误签名就变成 ABI 的一部分。你后期想给InsufficientBalance多加一个字段,是做不到的。所以我倾向于在写合约之前,先把所有可能的失败场景列一遍,把错误类型和参数都定下来再动手。这和写接口设计是一样的,先想清楚边界条件,再写循环判断逻辑。
第四,while真的用得不多,但它引出的"终止条件必须被验证"这条准则,适用于所有循环。每写一个循环我都会问一句:这个循环最多会跑多少轮?最坏情况是什么?如果最坏情况会导致超长循环,那我就会强制加一个上限。不要依赖"我觉得数据量不会那么大",链上的数据规模,永远比你想象的更能涨。