从零开始的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 项目假设你已经安装了 forgeforge 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(available0, required50)问题一目了然。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真的用得不多但它引出的终止条件必须被验证这条准则适用于所有循环。每写一个循环我都会问一句这个循环最多会跑多少轮最坏情况是什么如果最坏情况会导致超长循环那我就会强制加一个上限。不要依赖我觉得数据量不会那么大链上的数据规模永远比你想象的更能涨。