在 Solidity 中,合约可以通过两种方法调用其他合约:通过合约接口(被视为高级调用),或者使用 call 方法(一种低级方法)。
尽管这两种方法都使用 CALL 操作码,但 Solidity 对它们的处理方式不同。
在本文中,我们将对两者进行比较:为什么低级调用永远不会 revert,而高级调用会 revert;以及为什么向空地址发起低级调用会被认为是成功的,而在调用不存在的合约时,高级调用会 revert。
为什么低级调用(或 delegatecall)永远不会 revert,而通过合约接口调用却会 revert
在解释原因之前,让我先引用一下 Solidity 官方文档中关于这个问题的说明。
当子调用中发生异常时,它们会自动“向上冒泡”(即异常会被重新抛出),除非它们在 try/catch statement 中被捕获。该规则的例外是 send 以及低级函数 call、delegatecalll 和 staticcall:在发生异常的情况下,它们会将 false 作为其第一个返回值,而不是“向上冒泡”。
下面我们将展示高级调用和低级 call 调用,以比较它们的行为。在下面的示例中,我将使用 call 方法,但相同的原理也可以扩展到 delegatecall。
Caller 可以通过两种方式调用 Called 中的 ops()。请注意,ops() 总是会 revert:
pragma solidity ^0.8.0;
contract Caller {
// first call to ops()
function callByCall(address _address) public returns (bool success) {
(success, ) = _address.call(abi.encodeWithSignature("ops()"));
}
// second call to ops()
function callByInterface(address _address) public {
Called called = Called(_address);
called.ops();
}
}
contract Called {
// ops() always reverts
function ops() public {
revert();
}
}
尽管这两种方法都用于调用相同的函数,并且都使用 CALL 操作码,但 Solidity 编译器会生成不同的字节码来以不同的方式处理失败情况。在 Caller 合约中执行这两个函数将会发现,Caller.callByInterface 会 revert,而 Caller.callByCall 则不会。
在 EVM 层面,CALL 操作码返回一个布尔值来指示调用是否成功,并将该返回值放在堆栈上。操作码本身并不触发 revert。
当通过合约接口进行调用时,Solidity 会替我们处理这个返回值。它会显式检查返回值是否为 false,并触发 revert,除非该调用是在 try/catch 块中进行的。
然而,在使用低级调用时,我们需要手动处理这个返回的布尔值,如果需要的话,显式地触发 revert。
contract Caller {
//...
function callByCall(address address) public returns (bool success) {
(success, ) = address.call(abi.encodeWithSignature("ops()"));
if (!success) {
revert("Something went wront");
}
}
//...
}
高级调用和低级调用之间的这种差异如下图所示。

调用空地址时 call 与通过接口调用的区别
Solidity 的低级 call 方法不会执行预先检查来验证被调用的地址是否对应于一个合约。合约可以使用 EXTCODESIZE(即 address.code.length 在幕后的操作码)来 检查该地址是否为智能合约。如果大小为零,则表明该地址没有部署合约。然而,call 方法并没有包含这种检查;它会直接执行 CALL 操作码,而不管目标地址的情况。
当使用接口时,会检查目标的代码大小。换句话说,在为 callByInterface 函数生成的字节码中,会在执行 CALL 操作码之前,对指定的地址执行 EXTCODESIZE 操作码。如果 EXTCODESIZE 返回的大小为零(表示该地址没有合约),则函数会在执行 CALL 操作码之前 revert。这就解释了为什么当使用不存在的合约地址执行时,callByInterface 函数会 revert,而 callByCall 不会。
低级调用与高级调用在与空合约交互时的这种差异如下图所示。

从根本上说,如果执行过程中遇到 REVERT 操作码、Gas 耗尽或尝试了被禁止的操作(例如除以零),它就会 revert。当向一个空地址发起调用时,上述情况都不会发生。
通过 RareSkills 了解更多
如果你是 Solidity 新手,请查看我们的免费 Solidity 课程。如果你是更有经验的 Solidity 程序员,请查看我们的高级 Solidity 训练营。
作者
本文由 João Paulo Morais 与 RareSkills 合作编写。
原载于 2024 年 5 月 1 日