错误处理
作为开发者的我们知道,我们所编写出来的程序难免会出现 bug ,而要做的是捕获异常,给用户抛出一个友好地错误提示。
而在 Solidity 中,根据状态恢复
异常来处理错误,该异常将撤销在当前调用中对状态所做的所有修改,与此同时,还向调用者标记错误。
它有许多功能来解决在编译时或运行时可能发生的潜在问题。即使语法错误检查发生在编译时,运行时错误也很难捕捉,主要发生在合约执行过程中。一些运行时错误的例子包括除以0的类型错误,数组超出索引错误,等等。
实际上,Solidity的错误处理确保了原子性这一属性。当一个智能合约调用因错误而终止时,所有的状态变化(即对变量、余额等的改变)都会被恢复,一直到合约调用链。
Solidity 有三种处理错误的方式:require
、assert
和 *revert*
。
require
require(condition);
require(condition, "description");
require
语句声明了运行该函数的先决条件,即它声明了在执行代码之前应该满足的约束条件。它接受一个参数并在评估后返回一个布尔值,它也有一个自定义的字符串信息选项。如果是false
,就会产生异常并终止执行。未使用的gas
被返回给调用者,状态被逆转为原始状态。以下是require
类型的异常被触发的一些情况。
- 1、当
require(条件表达式)
被调用时,其条件表达式结果为false
。 - 2、当一个被消息调用的函数没有正确结束时。
- 3、当使用 new 关键字创建一个合约,而这个过程没有正常结束。
- 4、当一个无代码的合约被定位到一个外部函数时。
- 5、当使用公共getter方法将以太坊发送到合约时。
- 6、当.transfer()方法失败时。
- 6.1、当一个断言被调用时,其结果是假的。
- 6.2、当一个函数的零初始化变量被调用时。
- 6.3、当一个大值或负值被转换为一个枚举值时。
- 6.4、当一个值被零除或模数化的时候。
- 6.5、当在一个索引中访问一个数组,这个数组太大或者是负数。
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.0;
contract requireContract{
function requireTest(uint256 _number) pure public {
require(_number < 100, "number too big");
}
}
引入require
语句的原因是为了使代码更具可读性。require 语句应在以下情况下使用。
- 1、验证用户输入,正如我们上面所做的。
- 2、在任何行动之前验证状态条件
- 3、验证来自外部合同的响应
- 4、检查溢出和欠溢出的情况
assert
assert语句也可以撤销所有的状态变化,但它会消耗事务中提供的所有气体,即使它没有被使用。这使得它不如revert或require宽松,因此使用频率较低。Assert是为 "真正糟糕的事情 "而存在的,应该只用于内部错误。
assert
应该在以下情况下使用。
- 1、检查常量,如this.balance > totalSupply
- 2、执行后验证状态
- 3、溢出和下溢检查,如果revert/require不合适的话
Solidity 在某些情况下自动创建assert
型异常。
- 1、被零除法或模数
- 2、访问一个超出其界限的数组
- 3、将过大或负数转换为枚数
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.0;
contract assertContract{
function assertDiv(int x,int y) pure public returns(int){
assert(y == 0);
return x / y;
}
}
revert
revert
语句将停止执行并撤销所有的状态变化。剩余的gas
将被退还给调用者(但到目前为止所使用的gas
将消失)。revert
允许返回一个值。这可以作为一个错误信息来澄清为什么执行revert
语句。
有两种方式来触发revert
。
revert CustomError(arg1, arg2)
revert("description")
第一种是自定义的error
类型(Solidity 0.8.4 出现的新类型)。
使用自定义错误实例通常会比传递一个字符串作为描述在消耗gas
方面更便宜。
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.0;
contract revertContract{
//自定义错误实例
error WrongNumber(uint256 threshold, uint256 number);
string public status;
uint256 myThreshold = 100;
function revertString(uint256 _number) public{
status = "error";
if(_number > myThreshold){
revert("Number too big");
}
status = "success";
}
function revertCustomError(uint256 _number) public{
status = "error";
if(_number > myThreshold){
revert WrongNumber({ threshold: myThreshold, number: _number});
}
status = "success";
}
}
三种方式处理错误消耗 gas 对比
- 1、revert 语句,从示例可以看出最终耗费了
46607 gas
- 2、revert 语句与自定义 error 相配合,反而比比上述消耗跟多的
gas
,它最终消耗了46733 gas
。
- 3、 我们在示例中使用了
assert
语句,明显比revert
语句消耗gas
更少,它最终消耗了22077 gas
。
- 4、 我们最后来看看
equire
语句。第一种情况是带了错误描述,而它却又比assert
语句少耗费了gas
,从示例可以看出它消耗了21898 gas
。
- 5、而
require
第二种情况,便是只有条件表达式,可以看出在示例中它以上几种情况消耗gas
最少的,它最终消耗了21605 gas
从上述的示例可以看出,使用require
语句检查错误,最终消耗的gas
是最少的,这也证明了很多项目使用require
的原因了。
try catch 捕获异常
现在我们可以用它们来处理外部函数调用的失败,而不需要回滚整个事务(被调用函数中的状态变化仍然会被回滚,但调用函数中的变化不会)。
//CharitySplitter.sol
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.0;
contract CharitySplitter {
address public owner;
constructor (address _owner) {
require(_owner != address(0), "no-owner-provided");
owner = _owner;
}
}
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.0;
import "./CharitySplitter.sol";
contract CharitySplitterFactory {
mapping (address => CharitySplitter) public charitySplitters;
uint public errorCount;
event ErrorHandled(string reason);
event ErrorNotHandled(bytes reason);
function createCharitySplitter(address charityOwner) public {
try new CharitySplitter(charityOwner)
returns (CharitySplitter newCharitySplitter)
{
charitySplitters[msg.sender] = newCharitySplitter;
} catch {
errorCount++;
}
}
}
try
关键词后面必须有一个表达式,代表外部函数调用或合约创建( new ContractName()
)。
在表达式上的错误不会被捕获(例如,如果它是一个复杂的表达式,还涉及内部函数调用),只有外部调用本身发生的revert 可以捕获。 接下来的 returns
部分(是可选的)声明了与外部调用返回的类型相匹配的返回变量。 在没有错误的情况下,这些变量被赋值,合约将继续执行第一个成功块内代码。 如果到达成功块的末尾,则在 catch
块之后继续执行。
Solidity 根据错误的类型,支持不同种类的捕获代码块:
catch Error(string memory reason) { ... }
: 如果错误是由revert("reasonString")
或require(false, "reasonString")
(或导致这种异常的内部错误)引起的,则执行这个catch子句。catch Panic(uint errorCode) { ... }
: 如果错误是由 panic 引起的(如:assert
失败,除以0,无效的数组访问,算术溢出等),将执行这个catch子句。catch (bytes memory lowLevelData) { ... }
: 如果错误签名不符合任何其他子句,如果在解码错误信息时出现了错误,或者如果异常没有一起提供错误数据。在这种情况下,子句声明的变量提供了对低级错误数据的访问。catch { ... }
: 如果你对错误数据不感兴趣,你可以直接使用catch { ... }
(甚至是作为唯一的catch子句) 而不是前面几个catch子句。
有计划在未来支持其他类型的错误数据。 Error
和 Panic
字符串目前是按原样解析的,不作为标识符处理。
为了捕捉所有的错误情况,你至少要有子句 catch { ... }
或 catch (bytes memory lowLevelData) { ... }
.
在 returns
和 catch
子句中声明的变量只在后面的块的范围内有效。