文章目录

引用类型
引用类型的值可以通过多个不同的名称进行修改。这与值类型形成对比,在值类型中,每当使用一个值类型的变量时,都会获得一个独立的副本。因此,引用类型比值类型需要更谨慎地处理。
目前,引用类型包括结构体(structs)、数组(arrays)和映射(mappings)。如果使用引用类型,必须明确提供数据存储的位置:memory(其生命周期仅限于外部函数调用期间)、storage(存储状态变量的位置,其生命周期与合约的生命周期一致)或 calldata(一个特殊的数据存储区域,其中包含函数参数)。
如果赋值或类型转换导致数据存储位置发生变化,则会自动触发复制操作,而在同一数据存储位置内部进行赋值时,仅在某些情况下会触发复制(对于 storage 类型)。
数据存储位置
每个引用类型都有一个额外的注释,即“数据存储位置”,用于指明其存储位置。数据存储位置包括 memory、storage 和 calldata。calldata 是一个不可修改、不可持久化的区域,其中存储了函数参数,其行为大多数情况下类似于 memory。
在函数体中声明或作为返回参数的 calldata 位置的数组和结构体必须在使用或返回之前进行赋值。在某些使用非平凡控制流的情况下,编译器可能无法正确检测初始化。在这些情况下,一个常见的解决方法是先将受影响的变量赋值给自身,然后再进行正确的初始化。
分配行为
数据存储位置不仅与数据的持久性相关,还会影响赋值的语义:
-
在 storage 和 memory(或 calldata)之间的赋值总是会创建一个独立的副本。
-
在 memory 之间的赋值仅创建引用。这意味着对一个 memory 变量的修改会影响所有引用同一数据的 memory 变量。
-
从 storage 赋值给本地 storage 变量时,也只是赋值引用。
-
其他所有对 storage 的赋值都会进行复制。例如,对状态变量的赋值,或对 storage 结构体类型的本地变量的成员赋值,即使本地变量本身只是一个引用。
举个例子:
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.5.0 <0.9.0;
contract C {
// x 的数据存储位置是 storage。
// 这是唯一可以省略数据存储位置的地方。
uint[] x;
// memoryArray 的数据存储位置是 memory。
function f(uint[] memory memoryArray) public {
x = memoryArray; // 可以执行,会复制整个数组到 storage
uint[] storage y = x; // 可以执行,赋值的是指针,y 的数据存储位置是 storage
y[7]; // 合法,返回第 8 个元素
y.pop(); // 合法,通过 y 修改 x
delete x; // 合法,清空数组,同时影响 y
// 以下操作无法执行,因为它需要在 storage 中创建一个新的临时/匿名数组,
// 但 storage 是静态分配的:
// y = memoryArray;
// 同样,“delete y” 也是不合法的,因为对指向 storage 对象的本地变量的赋值
// 只能来自已有的 storage 对象。
// 它会“重置”指针,但没有合理的位置可以指向。
// 更多细节请参考“delete” 运算符的文档。
// delete y;
g(x); // 调用 g,传递 x 的引用
h(x); // 调用 h,创建一个独立的临时副本存储在 memory
}
function g(uint[] storage) internal pure {}
function h(uint[] memory) public pure {}
}
数组
数组可以是编译时固定大小的,也可以是动态大小的。
固定大小为 k,元素类型为 T 的数组写作 T[k],而动态大小的数组写作 T[]。
例如,一个包含 5 个 uint 动态数组的数组写作 uint[][5]。该表示法与某些其他语言相反。在 Solidity 中,X[3] 始终是一个包含 3 个 X 类型元素的数组,即使 X 本身是一个数组。而在 C 语言等其他语言中,这种情况可能不同。
索引从 0 开始,访问顺序与声明顺序相反。
例如,如果有一个变量 uint[][5] memory x,要访问第三个动态数组中的第七个 uint,应使用 x[2][6],而访问第三个动态数组则使用 x[2]。同样,如果有一个数组 T[5] a,其中 T 也可以是数组,则 a[2] 的类型始终为 T。
数组元素可以是任何类型,包括 mapping 或 struct。但一般的类型限制仍然适用,例如 mapping 只能存储在 storage 数据位置,并且 public 可见性的函数参数必须是 ABI 类型。
可以将状态变量数组标记为 public,Solidity 会自动为其创建一个 getter。数值索引会成为 getter 的必填参数。
访问超出数组末尾的索引会导致断言失败(Assertion Failure)。
动态大小数组的 push() 和 push(value) 方法可用于在数组末尾追加新元素:
-
.push() 追加一个零初始化的元素,并返回对该元素的引用。
-
.push(value) 追加指定值的元素。
注意:
动态数组只能在 storage 中调整大小。在 memory 中,此类数组可以是任意大小,但一旦分配后,其大小就无法更改。
特殊数组:bytes 和 string 类型
bytes 和 string 类型是特殊的数组。bytes 类型类似于 bytes1[],但在 calldata 和 memory 中会进行紧密打包(packed)。string 等同于 bytes,但不允许使用长度或索引访问。
Solidity 不提供字符串操作函数,但可以使用第三方字符串库。也可以通过 keccak256(abi.encodePacked(s1)) == keccak256(abi.encodePacked(s2)) 来比较两个字符串的哈希值,或者使用 string.concat(s1, s2) 来连接两个字符串。
应优先使用 bytes 而非 bytes1[],因为 bytes 的开销更小。使用 bytes1[] 在 memory 中存储时,每个元素之间会填充 31 个字节的填充数据(padding),而 storage 中由于紧密打包不存在填充。通常,bytes 适用于任意长度的原始字节数据,而 string 适用于任意长度的字符串(UTF-8 编码)。如果可以限制长度,应使用 bytes1 至 bytes32 这样的值类型,因为它们的成本更低。
注意:
如果想要访问字符串 s 的字节表示,可使用 bytes(s).length 获取长度,或 bytes(s)[7] = ‘x’; 进行修改。但这样访问的是 UTF-8 编码的底层字节,而不是独立的字符。
bytes.concat 和 string.concat 的功能
string.concat 用于连接任意数量的 string 值。该函数返回一个存储在 memory 位置的 string,其中包含所有参数的内容,且不会包含额外的填充(padding)。如果传递的参数类型不能隐式转换为 string,则需要先将其转换为 string 类型。
类似地,bytes.concat 用于连接任意数量的 bytes 或 bytes1 至 bytes32 类型的值。该函数返回一个存储在 memory 位置的 bytes,包含所有参数的内容,也不会有填充(padding)。如果传递的参数是 string 或其他不能隐式转换为 bytes 的类型,也需要先将其转换为 bytes 或适当的 bytes1 至 bytes32 类型。
示例如下:
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.12;
contract C {
string s = "Storage";
function f(bytes calldata bc, string memory sm, bytes16 b) public view {
// 连接多个字符串
string memory concatString = string.concat(s, string(bc), "Literal", sm);
assert((bytes(s).length + bc.length + 7 + bytes(sm).length) == bytes(concatString).length);
// 连接多个字节数组
bytes memory concatBytes = bytes.concat(bytes(s), bc, bc[:2], "Literal", bytes(sm), b);
assert((bytes(s).length + bc.length + 2 + 7 + bytes(sm).length + b.length) == concatBytes.length);
}
}
如果调用 string.concat() 或 bytes.concat() 时不传递任何参数,则返回一个空数组。
分配 memory 数组
可以使用 new 关键字创建一个动态长度的 memory 数组。与 storage 数组不同,memory 数组的大小是固定的,不能通过方法(如 push())进行调整。因此,必须在创建时确定所需大小,或者创建一个新数组并复制所有元素。
与 Solidity 中的所有变量一样,新分配的数组元素总是初始化为默认值。
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.16 <0.9.0;
contract C {
function f(uint len) public pure {
uint ; // 创建一个长度为 7 的 uint 数组
bytes memory b = new bytes(len); // 创建一个长度为 len 的 bytes 数组
assert(a.length == 7);
assert(b.length == len);
a[6] = 8; // 赋值
}
}
数组字面量(Array Literals)
数组字面量是用 […] 包裹的多个逗号分隔的表达式。例如:[1, a, f(3)]。
数组字面量的类型遵循以下规则:
-
它始终是一个 静态大小的 memory 数组,其长度等于表达式的数量。
-
数组的基本类型是第一个表达式的类型,其他所有表达式必须能隐式转换为该类型。如果无法转换,将报错。
-
仅有一个可以转换的共同类型是不够的,必须保证至少有一个元素的原始类型为该类型。
示例:
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.16 <0.9.0;
contract C {
function f() public pure {
g([uint(1), 2, 3]); // 显式转换第一个元素的类型
}
function g(uint[3] memory) public pure {
// ...
}
}
在这个例子中,[1, 2, 3] 的类型是 uint8[3] memory,因为 1、2 和 3 默认都是 uint8 类型。如果希望它是 uint[3] memory,需要显式转换第一个元素的类型。
无效示例
[1, -1] // 无效,1 是 uint8,-1 是 int8,无法隐式转换
有效示例
[int8(1), -1] // 有效,所有元素都是 int8
二维数组字面量
不同类型的定长 memory 数组之间无法进行隐式转换,即使它们的基本类型可以转换。因此,在使用二维数组字面量时,必须显式指定共同的基本类型。
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.16 <0.9.0;
// 声明合约 C
contract C {
/**
* @dev 返回一个 4 行 2 列的 `uint24` 类型的二维静态数组
* @return x 返回的数组 `uint24[2][4] memory`
*/
function f() public pure returns (uint24[2][4] memory) {
// 定义一个 `uint24[2][4]` 类型的二维静态数组,并初始化
uint24[2][4] memory x = [
[uint24(0x1), 1], // 第一行: 0x1(16 进制)转换为 uint24,第二个元素为 1
[0xffffff, 2], // 第二行: 0xffffff(最大 uint24 值),第二个元素为 2
[uint24(0xff), 3], // 第三行: 0xff(255),第二个元素为 3
[uint24(0xffff), 4] // 第四行: 0xffff(65535),第二个元素为 4
];
// 返回二维数组
return x;
}
}
如果没有显式指定 uint24 类型,下面的代码将会报错:
uint[2][4] memory x = [[0x1, 1], [0xffffff, 2], [0xff, 3], [0xffff, 4]];
固定大小的 memory 数组不能赋值给动态大小的 memory 数组。示例如下:
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.0 <0.9.0;
// 这段代码无法编译
contract C {
function f() public {
uint[] memory x = [uint(1), 3, 4]; // 无法将 `uint[3] memory` 赋值给 `uint[] memory`
}
}
未来可能会移除此限制,但由于 ABI 传递数组的方式,此限制目前仍然存在。
如果要初始化动态大小的数组,必须使用 new 关键字分配内存并手动赋值。例如:
// 使用 new 关键字创建动态数组并手动赋值
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.16 <0.9.0;
contract C {
function f() public pure {
uint[] memory x = new uint[](3);
x[0] = 1;
x[1] = 3;
x[2] = 4;
}
}
数组成员(Array Members)
length
每个数组都有一个 length 成员,它表示数组的元素数量。memory 数组的长度在创建时是固定的,但可以在运行时动态确定。
push()
-
动态存储(storage)数组 和 bytes(但不包括 string)具有 push() 成员函数,允许你在数组末尾添加一个 零初始化 的元素。
-
它返回对新元素的引用,因此可以像 x.push().t = 2 或 x.push() = b 这样使用。
push(x)
-
动态存储(storage)数组 和 bytes(但不包括 string)也具有 push(x) 成员函数,允许你在数组末尾追加指定的元素。
-
该函数不返回任何值。
pop()
-
动态存储(storage)数组 和 bytes(但不包括 string)具有 pop() 成员函数,允许你从数组末尾移除一个元素。
-
调用 pop() 会 隐式调用 delete 来清除被移除的元素。
-
该函数不返回任何值。
注意:
通过调用 push() 增加存储数组的长度会产生恒定的 gas 费用,因为存储中的新元素会被默认初始化为零。
然而,通过调用 pop() 减少存储数组的长度的费用 取决于被移除元素的大小。如果被移除的元素是 数组,则删除费用会非常昂贵,因为删除操作类似于显式调用 delete 来清除所有被移除的元素。
在 external(而非 public)函数中使用数组的数组时,必须启用 ABI 编码器 v2(ABI coder v2)。
在 Byzantium 版本之前的 EVM 中,无法访问函数返回的动态数组。如果你的函数返回动态数组,请确保你的 EVM 版本是 Byzantium 或更高版本。
悬空引用(Dangling References)到存储数组元素
在操作存储数组时,需要特别小心避免悬空引用。悬空引用是指指向某个已经不存在或已被移动但未更新引用的元素的引用。
例如,悬空引用可能会发生在你将一个数组元素的引用存储到一个局部变量中,然后对包含该元素的数组进行 .pop() 操作时:
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.8.0 <0.9.0;
contract C {
uint[][] s;
function f() public {
// 存储对 s 最后一个数组元素的指针。
uint[] storage ptr = s[s.length - 1];
// 删除 s 的最后一个数组元素。
s.pop();
// 尝试对不再数组中的元素进行写操作。
ptr.push(0x42);
// 之后对 s 进行 push 操作时,不会添加一个空数组,
// 而是会导致 s 的最后一个元素长度为 1,且其第一个元素是 0x42。
s.push();
assert(s[s.length - 1][0] == 0x42);
}
}
在上述代码中,ptr.push(0x42) 不会回滚,尽管 ptr 已不再指向有效的 s 数组元素。由于编译器假设未使用的存储区域始终被零化,后续的 s.push() 操作不会显式地将零写入存储空间,导致 s 的最后一个元素的长度为 1,且第一个元素是 0x42。
需要注意的是,Solidity 不允许声明对值类型(如 uint、bool 等)的存储引用。这类显式的悬空引用仅限于嵌套引用类型。然而,在使用复杂表达式进行元组赋值时,仍可能会产生悬空引用:
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.8.0 <0.9.0;
contract C {
uint[] s;
uint[] t;
constructor() {
// 向存储数组中推送一些初始值。
s.push(0x07);
t.push(0x03);
}
function g() internal returns (uint[] storage) {
s.pop();
return t;
}
function f() public returns (uint[] memory) {
// 以下代码首先会评估 `s.push()` 作为对新元素索引 1 的引用,
// 然后调用 `g` 函数将此新元素弹出,导致左侧的元组元素成为悬空引用。
// 尽管如此,赋值仍然会进行,并且会写入 `s` 数据区外。
(s.push(), g()[0]) = (0x42, 0x17);
// 随后对 `s` 进行 push 操作时,会暴露上一条语句写入的值,
// 即函数结束时 `s` 的最后一个元素的值为 0x42。
s.push();
return s;
}
}
在编写代码时,为了安全起见,建议每次赋值时只操作一次存储,并避免在赋值语句的左侧使用复杂表达式。
当操作字节数组(bytes)元素的引用时,尤其是在执行 .push() 操作时,需要特别小心,因为这可能会导致存储布局从短格式转换为长格式:
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.8.0 <0.9.0;
// 这段代码会报告警告
contract C {
bytes x = "012345678901234567890123456789";
function test() external returns(uint) {
(x.push(), x.push()) = (0x01, 0x02);
return x.length;
}
}
在上述代码中,当第一次执行 x.push() 时,x 仍然以短格式存储,因此 x.push() 返回的是 x 第一个存储槽中的元素的引用。然而,当第二次执行 x.push() 时,字节数组的存储格式会发生变化,转为长格式。此时,x.push() 所引用的元素已经被移到数组的数据区域,而引用仍然指向原始位置(即长度字段)。因此,赋值操作会破坏 x 的长度字段。
为了避免潜在的问题,建议在单次赋值语句中,每次只增加一个字节数组元素,并且避免在同一语句中同时访问数组的元素。
尽管上述描述的悬空引用行为在当前版本的编译器中是如此,但任何包含悬空引用的代码都应视为具有未定义行为。因此,务必确保在编写代码时避免产生悬空引用。
2168

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



