变量存储
当一个智能合约部署到以太坊,并被触发执行,EVM 会为合约分配五种存储空间:
- 存储区(storage)
- 内存区(memory)
- 调用数据区(calldata)
- 瞬时存储(Transient storage)
- 栈(stack)
存储区(storage)
声明在合约中的变量,通常被称为 状态变量
,被保存在存储区:
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
contract Counter {
uint number;
}
每一个部署后的合约拥有一个唯一的以太坊地址,每一个合约地址关联一个独立的存储区。所有存储区中的变量保存在以太坊区块链上,因此所有存储区变量的更改记录也会作为区块链历史而持久化记录
下来。
由于存储区变量是保存在区块链上的,因此相比其他存储区域,它的读取和修改都非常昂贵,需要消耗更多的燃料(gas)。
内存区(memory)
内存区变量通常用于保存函数调用参数变量,函数返回变量,以及函数内部声明的变量。
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
contract Counter {
uint number;
function computeSum(uint[] memory increments, uint len)
private
pure
returns (uint sum)
{
uint i;
for(i = 0; i < len; i++)
sum += increments[i];
}
}
在上面的函数 computeSum
中,函数参数 increments
以及 len
,被保存在 memory
中。函数局部变量 i
以及返回值 sum
也被保存在 memory
中。
调用数据区(calldata)
当发生外部函数调用时,调用参数会被加载保存在一片被称为 calldata 的存储空间中。
跟存储区不一样的是,calldata
是不允许修改的。在 calldata
、memory
和 calldata
中,calldata
的使用是最便宜的。
瞬时存储(Transient storage)
当我们需要使用一个全局变量时,我们通常需要将它定义为状态变量,保存在存储区。但是保存在存储区的话,读取和修改的费用都很贵。为了解决这一点,EIP-1153
提案增加了瞬时存储。
瞬时存储与状态变量基本相当,但是它将不会被持久性记录到区块链上。而是当一次交易执行完成之后,就自动销毁,因而它的成本更低。
目前 Solidity(v0.8.24) 还不支持直接声明瞬时存储变量,但可以使用内联汇编的方式,使用相关指令 tload
tstore
指令。
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.8.24;
contract Storage {
modifier nonreentrant() {
assembly {
if tload(0) {
revert(0, 0)
}
tstore(0, 1)
}
_;
assembly {
tstore(0, 0)
}
}
}
栈(stack)
栈对于 Solidity
开发人员而言,几乎是完全透明的。由于 EVM 是堆栈类机器,而不是寄存器类机器。因此其底层所有的操作都是在堆栈上进行的。
例如,在运算 c=a+b
时(假设 a, b, c 保存在 memory
中),EVM 操作如下:
- a 压入栈顶
- b 压入栈顶
- 栈顶两元素相加,结果压回堆顶
- 弹出栈顶元素保存回c中
对于每一次交易,Solidity
将为其分配大小为 1024
个字(每个字 256
位)的栈空间。开发人员需要注意的是,如果函数调用嵌套过深,可能会导致栈溢出的错误。因此应当避免使用 递归
等需要大量消耗栈空间的操作。
不同存储区域变量间的赋值
对于 值类型
永远都是直接拷贝值。因此,我们只需要考虑 引用类型
变量在不同的存储区域间的赋值。而 引用类型
实际上只有以下三种:
- 数组(包括 string 字符串)
- 结构体变量
- mapping 类型
而不同的存储区域指的也是三种:
- calldata
- storage
- memory
对于相同的存储区域间变量赋值,显然是合法的,而且不涉及到拷贝:
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
contract SameRefTypeAssignment {
function fn(
int[] calldata calldataA,
int[] storage storageA,
int[] memory memoryA
) internal pure {
int[] calldata calldataB;
int[] memory memoryB;
int[] storage storageB;
// 合法,calldataB 和 calldataA 现在指向 calldata 中的同一片数据,未发生数组拷贝
calldataB = calldataA;
// 合法,memoryB 和 memoryA 现在指向 memory 中的同一片数据,未发生数组拷贝
memoryB = memoryA;
// 合法,storageB 和 storageA 现在指向 storage 中的同一片数据,未发生数组拷贝
storageB = storageA;
}
}
要判断不同存储区域间的引用变量赋值是否合法(假设为 A = B),只需要判断以下两点:
- 如果可以将 B 中的内容拷贝到 A 的存储区中,则拷贝,然后 A 指向该拷贝后的内容区域
- 如果不能进行以上拷贝操作,则该操作非法
calldata 引用变量的赋值
calldata
是当发生外部函数调用时,用来保存函数参数的。因此,我们唯一能够新增一片 calldata
存储块的方法,就是调用外部函数,除此之外别无它法。基于此:
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
contract CalldataAssigment {
function fn(int[] memory memoryA, int[] storage storageA) internal view {
int[] calldata calldataA;
// 不合法,calldata 引用必须指向 calldata 存储区域
// 因此需要创建一个 calldata 存储块,把 memoryA 中的内存拷贝到 calldata
// 然后让 calldataA 指向该新创新的存储块,但是 calldata 存储区域是只读的
// 且只有调用外部函数时才会为函数参数分配新的存储块,因此该拷贝不可能发生
// 因此以下操作非法
calldataA = memoryA;
// 与上面同理
calldataA = storageA;
// 外部函数调用 this.externalFn,Solidity(EVM) 为该外部函数调用分配了一块 calldata
// 存储块,用于保存函数参数 calldataB,因此可以将 memoryA 数组拷贝到 该新的 calldata 存储块中
// 函数参数 calldataB 将指向该新的 calldata 存储块
// 因此该参数变量赋值合法,该过程涉及到了数组拷贝
this.externalFn(memoryA);
// 与上面同理,该过程同样涉及到了数组拷贝,但是从 storage 拷贝到 calldata
this.externalFn(storageA);
}
function externalFn(int[] calldata calldataB) external {}
}
storage 引用变量的赋值
storage
存储区的变量都是在合约内静态声明的。除此之外,我们没有其他方法来创建一块新的 storage
存储块。基于此:
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
contract StorageAssigment {
int[] storageVar;
function fn(int[] calldata calldataA, int[] memory memoryA) internal view {
int[] storage storageA;
// 不合法,storage 引用必须指向 storage 存储块
// 因此需要创建一个 storage 存储块,把 calldataA 中的内存拷贝到 storage 中
// 然后让 storageA 指向该新创新的存储块,但是 storageA 存储区域只能通过在合约内
// 静态声明,而由自己不能动态分配,因此该拷贝不可能发生
// 因此以下操作非法
storageA = calldataA;
// 与上面同理
storageA = memoryA;
// 该赋值合法。可以将 calldataA 中的数据拷贝到 storageVar 指向的存储区中
// 然后 storageVar 仍指向该存储区数据块,该过程发生了数组拷贝
storageVar = calldataA;
// 与上面同理,该过程同样涉及到了数组拷贝,但是从 memory 拷贝到 storage
storageVar = memoryA;
}
}
memory 引用变量的赋值
对于 memory
引用变量的赋值,我们往往可以动态地申请一块新的 memory 内存块来使用。基于此:
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
contract MemoryAssigment {
function fn(
int[] storage storageA,
int[] calldata calldataA
) internal pure {
int[] memory memoryA;
// 该赋值合法,memory 引用必须指向 memory 存储块,memory 存储空间总是可以动态创建
// 因此可以创建一个 memory 存储块,把 calldataA 中的内存拷贝到 memory 中
// 然后让 memoryA 指向该新创新的内存块
memoryA = calldataA;
// 与上面同理
memoryA = storageA;
}
}