文章目录

表达式与控制结构
赋值
结构化赋值与返回多个值
Solidity 内部支持元组类型(tuple types),即由多个(可能类型不同)且数量在编译时已知的元素组成的列表。元组通常用于一次性返回多个值。这些返回值既可以赋值给新声明的变量,也可以赋值给已有变量(或更广义上的左值 LValues)。
需要注意的是,元组在 Solidity 中并不是一种独立的类型,它们仅作为表达式的一种语法组合存在。
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.5.0 <0.9.0;
contract C {
uint index;
function f() public pure returns (uint, bool, uint) {
return (7, true, 2);
}
function g() public {
// 用类型声明变量并从返回的元组中赋值,
// 不需要指定所有元素(但数量必须一致)。
(uint x, , uint y) = f();
// 交换变量值的常见技巧 —— 不适用于非值类型的存储变量。
(x, y) = (y, x);
// 可以省略部分组件(也适用于变量声明)。
(index, , ) = f(); // 设置 index 为 7
}
}
不允许将变量声明与非声明赋值混用,例如以下写法是无效的:(x, uint y) = (1, 2);
注意:当元组涉及引用类型(如数组、结构体等)时,进行多变量赋值时可能会引发非预期的复制行为,应特别小心使用。在这种情况下,赋值过程可能不会像预期那样浅拷贝或引用传递,可能导致逻辑错误或意外副作用。
数组和结构体的赋值复杂性
对于非值类型(如数组和结构体,包括 bytes 和 string),赋值的语义更为复杂,特别是在涉及内存(memory)与存储(storage)之间的数据传递时。
在下面的例子中,函数 g(x) 的调用不会改变 x,因为 x 会被复制到内存中;而 h(x) 则可以成功修改 x,因为它是通过引用传递的:
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.22 <0.9.0;
contract C {
uint[20] x;
function f() public {
g(x);
h(x);
}
function g(uint[20] memory y) internal pure {
y[2] = 3;
}
function h(uint[20] storage y) internal {
y[3] = 4;
}
}
解析如下:
-
g(x):传入的是 memory 类型的副本,对 y[2] 的修改不会影响原始的 x。
-
h(x):传入的是 storage 引用,对 y[3] 的修改将直接影响合约状态变量 x[3]。
这体现了 Solidity 中引用类型的核心行为:memory 会创建副本,storage 会引用原始数据。
因此,在处理数组和结构体等引用类型时,务必明确所用的数据位置(data location),以避免产生意料之外的副作用或逻辑错误。
作用域和声明
在 Solidity 中,变量在声明后将具有其类型对应的默认初始值,其字节表示为全零。这个“默认值”通常是该类型的典型“零状态”。例如,bool 类型的默认值为 false,而 uint 和 int 类型的默认值则为 0。对于定长数组以及 bytes1 至 bytes32,每个元素都将被初始化为其对应类型的默认值。对于动态数组、bytes 和 string 类型,默认值为空数组或空字符串。enum 类型的默认值为其定义中的第一个成员。
Solidity 的作用域规则遵循 C99(以及许多其他编程语言)中广泛采用的标准规则:变量从声明之后立即开始可见,直至包含该声明的最内层 {} 块结束为止。唯一的例外是 for 循环的初始化部分中声明的变量,其作用域仅限于整个 for 循环结构之内。
与参数相关的变量(如函数参数、修饰符参数、catch 参数等)在其所在的代码块中是可见的:函数和修饰符的参数在整个函数体或修饰符体内均可访问;catch 语句中的参数则仅在对应的 catch 块中可见。
在代码块之外声明的元素(如状态变量、函数、合约、用户定义类型等)即便在声明之前也可以被访问。这意味着状态变量可以在其定义之前被使用,函数也可以在定义前进行递归调用。
因此,下面的示例可以正常编译而不会产生任何警告,尽管两个变量使用了相同的名称,但由于它们处于互不重叠的作用域中,因此不会冲突:
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.5.0 <0.9.0;
contract C {
function minimalScoping() pure public {
{
uint same;
same = 1;
}
{
uint same;
same = 3;
}
}
}
以下代码展示了 C99 作用域规则的一个特殊情况:第一次对 x 的赋值实际上会影响到外层变量,而非内层变量。无论如何,编译器都会发出遮蔽(shadowing)警告,提醒开发者外部变量已被内部变量所隐藏。
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.5.0 <0.9.0;
// This will report a warning
contract C {
function f() pure public returns (uint) {
uint x = 1;
{
x = 2; // this will assign to the outer variable
uint x;
}
return x; // x has value 2
}
}
在 Solidity 0.5.0 版本之前,其作用域规则与 JavaScript 相似:在函数内部声明的变量在整个函数范围内都是可见的,无论该变量在函数体中的哪个位置被声明。换句话说,变量的作用域是函数级的(function-scoped),而非块级的(block-scoped)。
下面是一个示例,在 0.5.0 之前的版本中可以成功编译,但从 0.5.0 开始将会导致编译错误,因为 Solidity 从该版本起开始采用更严格的块级作用域规则:
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.5.0 <0.9.0;
// This will not compile
contract C {
function f() pure public returns (uint) {
x = 2;
uint x;
return x;
}
}
检查或不检查的算术运算
溢出或下溢是指在对一个不受限制的整数进行算术运算时,结果超出了该类型的表示范围。
在 Solidity 0.8.0 之前,算术运算会在发生溢出或下溢时自动环绕(wrap),这也导致了广泛使用额外的库来进行检查。自 Solidity 0.8.0 起,所有算术运算发生溢出或下溢时默认会回滚(revert),因此不再需要这些库。
如果希望恢复之前的行为,可以使用 unchecked 块:
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.0;
contract C {
function f(uint a, uint b) pure public returns (uint) {
// 这个减法会在下溢时环绕。
unchecked { return a - b; }
}
function g(uint a, uint b) pure public returns (uint) {
// 这个减法会在下溢时回滚。
return a - b;
}
}
调用 f(2, 3) 将返回 2**256-1,而调用 g(2, 3) 会导致断言失败。
unchecked 块可以在其内部的任何位置使用,但不能作为块的替代,并且不能嵌套使用。
该设置仅会影响语法上位于块内部的语句。从 unchecked 块内部调用的函数不会继承该属性。
-
为避免歧义,不能在 unchecked 块内部使用 _;
-
以下运算符在发生溢出或下溢时会导致断言失败,但如果在 unchecked 块内使用,则会在没有错误的情况下进行环绕(wrap): ++, --, +, 二进制 -, 一元 -, *, /, %, ** +=, -=, *=, /=, %=。
-
unchecked 块不能禁用零除或模零的检查。
-
按位运算符不会执行溢出或下溢检查。例如,type(uint256).max << 3 不会回滚,即使 type(uint256).max * 8 会回滚。这在使用按位移位(<<, >>, <<=, >>=)替代整数除法和乘法时尤其明显。
-
语句 int x = type(int).min; -x; 会导致溢出,因为负数范围可以容纳比正数范围多一个值。
-
显式类型转换将始终进行截断,并且永远不会导致断言失败,唯一的例外是从整数转换为枚举类型时。
错误处理:Assert、Require、Revert 和异常
Solidity 通过状态回退异常来处理错误。发生异常时,当前调用(及其所有子调用)对状态所做的所有更改都会被撤销,并向调用者标记错误。
如果异常发生在子调用中,除非被 try/catch 语句捕获,否则异常会自动“冒泡”,即异常会被重新抛出。需要注意的是,低级函数 send、call、delegatecall 和 staticcall 有一个例外:如果发生异常,它们会返回 false 作为第一个返回值,而不是像其他函数那样“冒泡”。
对于低级函数 call、delegatecall 和 staticcall,如果被调用的账户不存在,它们会返回 true 作为第一个返回值,这是 EVM 设计的特殊行为。因此,在调用之前,必须显式检查账户是否存在。
异常可以包含错误数据,并且这些数据会作为错误实例传递回调用者。Solidity 中有两种内建错误类型:Error(string) 和 Panic(uint256)。其中,Error 用于“常规”错误条件,而 Panic 用于表示代码中不应出现的错误,通常是由于代码逻辑错误或其他未预见的情况。
通过 assert 进行 Panic 和通过 require 进行 Error
Solidity 提供了便利函数 assert 和 require 来检查条件,并在条件不满足时抛出异常。
assert 函数会创建一个类型为 Panic(uint256) 的错误。编译器在某些情况下也会创建相同类型的错误,如下所述。
assert 仅应当用于测试内部错误,并检查不变式。正常情况下,代码不应当触发 Panic 错误,即使是无效的外部输入。如果触发了 Panic,意味着合约中存在错误,必须修复它。语言分析工具可以帮助评估合约,找出可能导致 Panic 的条件和函数调用。
以下情况会触发 Panic 异常,错误数据中提供的错误代码表示具体的 Panic 类型:
-
0x00:由编译器自动插入的通用 Panic 错误。
-
0x01:当调用 assert 且传入的参数计算结果为 false 时。
-
0x11:算术运算导致下溢或溢出,且不在 unchecked { … } 块内时。
-
0x12:除以零或取模零时(例如 5 / 0 或 23 % 0)。
-
0x21:将一个过大或负数的值转换为枚举类型时。
-
0x22:访问存储字节数组并且该数组编码错误时。
-
0x31:对空数组调用 .pop() 时。
-
0x32:访问数组、bytesN 或数组切片的越界或负索引时(即 x[i],其中 i >= x.length 或 i < 0)。
-
0x41:分配过多的内存或创建一个过大的数组时。
-
0x51:调用零初始化的内部函数类型变量时。
require 函数提供了三种重载方式:
-
require(bool):如果条件为 false,则回滚并不返回任何数据(甚至没有错误选择器)。
-
require(bool, string):如果条件为 false,则回滚并带有一个 Error(string) 错误消息。
-
require(bool, error):如果条件为 false,则回滚并带有自定义的错误类型,错误数据由第二个参数提供。
注意:require 的条件表达式会不加限制地评估,因此需要特别小心,确保它们不会引起意外的副作用。例如,在 require(condition, CustomError(f())); 和 require(condition, f()); 中,函数 f() 无论条件是否为 true 或 false 都会被调用。
以下情况会导致编译器生成 Error(string) 异常(或没有数据的异常):
-
调用 require(x) 且 x 的值为 false。
-
使用 revert() 或 revert(“description”)。
-
执行外部函数调用时,目标合约没有代码。
-
合约通过公共函数接收 Ether,但没有使用 payable 修饰符(包括构造函数和回退函数)。
-
合约通过公共 getter 函数接收 Ether。
在以下情况下,外部调用的错误数据(如果提供)会被转发,并可能导致 Error 或 Panic 异常(或其他任何类型的异常):
-
如果 .transfer() 失败。
-
如果我们通过消息调用一个函数,但它没有正确执行(例如,耗尽了 gas,找不到匹配的函数,或者自身抛出异常)。不过,低级操作调用(如 send、delegatecall、callcode 或 staticcall)除外,低级操作不会抛出异常,而是通过返回 false 来指示失败。
-
如果我们使用 new 关键字创建合约,但合约创建未能正确完成。
我们可以为 require 提供一个消息字符串或自定义错误,但 不能 为 assert 提供错误消息。
注意:如果我们没有为 require 提供字符串或自定义错误参数,它将回滚并返回空的错误数据,甚至不包括错误选择器。
该示例展示了如何使用 require 来检查输入条件,并使用 assert 来进行内部错误检查:
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.5.0 <0.9.0;
contract Sharer {
function sendHalf(address payable addr) public payable returns (uint balance) {
require(msg.value % 2 == 0, "Even value required.");
uint balanceBeforeTransfer = address(this).balance;
addr.transfer(msg.value / 2);
// 由于 transfer 在失败时会抛出异常且不能回调这里,
// 所以我们不可能仍然拥有一半的 Ether。
assert(address(this).balance == balanceBeforeTransfer - msg.value / 2);
return address(this).balance;
}
}
在内部,Solidity 执行 revert 操作(指令 0xfd)。这会导致 EVM 撤销对状态所做的所有更改。撤销的原因是没有安全的方式继续执行,因为预期的效果没有发生。为了保持事务的原子性,最安全的做法是撤销所有更改,并使整个交易(或至少是调用)无效。
在这两种情况下,调用者可以使用 try/catch 来响应此类失败,但被调用者中的更改将始终被撤销。
在 Solidity 0.8.0 之前,Panic 异常会使用无效的操作码,导致消耗所有可用的 gas。而在 Metropolis 发布之前,使用 require 进行的异常检查也会消耗所有 gas。
revert
通过 revert 语句和 revert 函数可以直接触发回滚(revert)。
revert 语句接受一个自定义错误作为直接参数,无需括号:
revert CustomError(arg1, arg2);
为了向后兼容,还可以使用 revert() 函数,它使用括号并接受一个字符串:
revert();
revert("description");
错误数据将传递回调用者,并可以在那里进行捕获。使用 revert() 会导致没有任何错误数据的回滚,而 revert(“description”) 会创建一个 Error(string) 错误。
与使用字符串描述相比,使用自定义错误实例通常更加节省成本,因为自定义错误的名称只需编码四个字节,而较长的描述可以通过 NatSpec 提供,并不会产生额外费用。
以下示例展示了如何将错误字符串和自定义错误实例与 revert 一起使用,并展示了与 require 的等效用法:
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.4;
contract VendingMachine {
address owner;
error Unauthorized();
function buy(uint amount) public payable {
if (amount > msg.value / 2 ether)
revert("Not enough Ether provided.");
// 另一种方式:
require(
amount <= msg.value / 2 ether,
"Not enough Ether provided."
);
// 执行购买操作。
}
function withdraw() public {
if (msg.sender != owner)
revert Unauthorized();
payable(msg.sender).transfer(address(this).balance);
}
}
这两种方式 if (!condition) revert(…); 和 require(condition, …); 是等价的,只要传递给 revert 和 require 的参数没有副作用(例如,它们只是字符串)。
注意:
require 函数与其他函数一样会被评估。这意味着所有参数在函数执行前都会被评估。特别是,在 require(condition, f()) 中,即使条件为 true,f() 函数也会被执行。
提供的字符串会像调用 Error(string) 函数一样进行 ABI 编码。在上面的示例中,revert(“Not enough Ether provided.”); 会返回以下十六进制数据作为错误返回信息:
0x08c379a0 // Error(string) 的函数选择器
0x0000000000000000000000000000000000000000000000000000000000000020 // 数据偏移量
0x000000000000000000000000000000000000000000000000000000000000001a // 字符串长度
0x4e6f7420656e6f7567682045746865722070726f76696465642e000000000000 // 字符串数据
调用者可以使用 try/catch 获取提供的消息,如下所示。
注意:
曾经有一个叫做 throw 的关键字,其语义与 revert() 相同。它在 Solidity 版本 0.4.13 中被弃用,并在版本 0.5.0 中被移除。
try/catch
外部调用的失败可以通过 try/catch 语句来捕获,示例如下:
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.8.1;
interface DataFeed {
function getData(address token) external returns (uint value);
}
contract FeedConsumer {
DataFeed feed;
uint errorCount;
function rate(address token) public returns (uint value, bool success) {
// 如果错误次数超过10次,永久禁用机制
require(errorCount < 10);
try feed.getData(token) returns (uint v) {
return (v, true);
} catch Error(string memory /*reason*/) {
// 如果调用 getData 中的 revert,并且提供了错误原因字符串时,执行此处
errorCount++;
return (0, false);
} catch Panic(uint /*errorCode*/) {
// 如果发生 panic(例如除零错误或溢出),执行此处
errorCount++;
return (0, false);
} catch (bytes memory /*lowLevelData*/) {
// 如果调用 revert(),执行此处
errorCount++;
return (0, false);
}
}
}
try 关键字后必须跟一个外部函数调用或合约创建表达式(如 new ContractName())。表达式中的错误不会被捕获,只有外部调用内部发生的 revert 才会被捕获。returns 部分(可选)声明与外部调用返回类型匹配的返回变量。如果没有错误,这些变量将被赋值,合约执行会继续到第一个成功的代码块。如果成功块执行完毕,执行将继续到 catch 块之后。
Solidity 支持根据错误类型使用不同的 catch 块:
-
catch Error(string memory reason) { … }:如果错误是由 revert(“reasonString”) 或 require(false, “reasonString”)(或其他导致类似异常的内部错误)引起的,将执行此 catch 块。
-
catch Panic(uint errorCode) { … }:如果错误是由 panic(例如 assert 失败、除零、无效数组访问、算术溢出等)引起的,将执行此 catch 块。
-
catch (bytes memory lowLevelData) { … }:如果错误签名与其他 catch 块不匹配,或解码错误消息时发生错误,或者异常未提供错误数据,则会执行此块。声明的变量将提供对低级错误数据的访问。
-
catch { … }:如果你不关心错误数据,可以使用 catch { … }(即使它是唯一的 catch 块)。
为了捕获所有错误情况,至少需要包含 catch { … } 或 catch (bytes memory lowLevelData) { … } 之一。
在 returns 和 catch 块中声明的变量仅在其后跟随的块中有效。
注意:
1.如果在 try/catch 语句内解码返回数据时发生错误,将导致当前执行的合约抛出异常,因此该错误不会被 catch 块捕获。如果在解码 catch Error(string memory reason) 时发生错误,而有低级 catch 块存在,那么该错误会被低级 catch 块捕获。
2.如果执行进入 catch 块,外部调用的状态更改效果已经被回滚。如果执行进入成功块,则效果不会回滚。如果效果已被回滚,执行要么继续在 catch 块中,要么整个 try/catch 语句会回滚(例如,由于解码失败或没有提供低级 catch 块)。
3.失败调用的原因可能有很多种。不要假设错误信息直接来自被调用合约:错误可能在调用链的更深处发生,且被调用合约只是转发了错误。此外,错误也可能是由于超出 gas 限制的情况,而不是故意的错误条件:调用方始终保留至少 1/64 的 gas,因此即使被调用合约耗尽 gas,调用方仍然有剩余的 gas。
1万+

被折叠的 条评论
为什么被折叠?



