💡 本次解读的文章是 2020 年 IWBOSE 的一篇与智能合约 Gas 优化相关的论文,这篇论文提供了一套设计模式和提示,用以帮助在以太坊上开发智能合约时节约 Gas 成本,并根据所提供模式的特点,将其分为 5 类。
一、本文贡献
(1)提供一套 Gas 节约的设计模式;
(2)根据模式特点,将其进行归纳和分类。
二、Gas 节约设计模式
在智能合约的开发和执行过程中,存在着以 gas / gwei 计算成本,可以将这些成本划分为:与创建智能合约或发送交易相关的固定成本、与智能合约状态永久存储相关的成本、与执行函数所必需的临时变量在内存中存储有关的成本以及与操作执行相关的成本。
为此,本文归纳整理了 24 种设计模式,并将其分为 5 类,分别是:外部交易(External Transactions)、存储(Storage)、节约空间(Saving Space)、操作(Operations)以及杂项(Miscellaneous)。有些模式可能属于不止一类,因此,本文将其分配给最合适的类别。
2.1 外部交易
这个类别包括使用 Web3. js 标准库从外部地址创建合约和发送交易的模式。
(1) Proxy
模式名称 | Proxy |
---|---|
问题描述 | 智能合约一旦部署后是不可变的,如果某个智能合约由于 bug 或需要扩展而必须改变时,则必须部署一个新合约,同时更新所有直接调用旧合约的相关智能合约,并部署新版本的智能合约,这样可能是非常昂贵的。 |
解决方案 | 使用代理委托模式(Proxy delegate pattern),一个代理持有被引用的智能合约地址,在其状态变量中,可以改变。这样,只需更新对新智能合约的引用即可。 |
(2)Data Contract
模式名称 | Data Contract |
---|---|
问题描述 | 当一个持有大量数据的智能合约必须更新时,同时必须将其所有数据复制到新部署的智能合约时,需要消耗大量的气体。 |
解决方案 | 将数据保存在单独的智能合约中,由一个或多个智能合约访问,使用数据并保持处理逻辑。如果必须更新这一逻辑,则数据保留在数据合同中,这种模式通常也包含在代理模式的实现中。 |
(3)Event Log
模式名称 | Event Log |
---|---|
问题描述 | 事件往往维护着系统的重要信息,这些信息必须在之后被与区块链交互的外部系统所使用。在区块链中存储这些信息可能非常昂贵。 |
解决方案 | 如果发生过的事件数据是外部系统需要的,而不是智能合约需要的,则让外部系统直接访问区块链中的事件日志。 |
2.2 存储
这个类别包括与使用 Storage 存储永久数据有关的模式。
(1)Limit Storage
模式名称 | Limit Storage |
---|---|
问题描述 | 存储(Storage)使用远比最贵的内存(memory)使用昂贵,因此,需要尽量避免使用存储。 |
解决方案 | 限制存储在区块链中的数据,对于非永久性数据总是使用内存,此外,可限制存储的变化,即在执行函数时,将中间结果保存在内存或堆栈中,并仅在所有计算结束时更新存储。 |
(2)Packing Variables
模式名称 | Packing Variables |
---|---|
问题描述 | 在以太坊中,内存的最小单位是一个 256 位的槽(slot),而一些整数的二进制表示并不需要用到 256 位。 |
解决方案 | 对变量进行打包,在声明存储变量时,应连续声明数据类型相同的存储变量,这样打包由 Solidity 编译器自动完成。(注意,该模式对无法打包的 Memory 和 Calldata 内存不起作用) |
(3)Packing Booleans
模式名称 | Packing Booleans |
---|---|
问题描述 | 在 Solidity 中,布尔变量存储为 uint8 (8位无符号整数),然而,只用1 bit足够存储它们。如果最多需要32个布尔值在一起,可以只遵循 Packing Variables 模式,如果需要更多,则会使用比实际需要更多的插槽。 |
解决方案 | 在单个 uint256 变量中打包布尔值,为此创建将布尔函数打包和解包为单个变量的函数,运行这些功能的成本比额外存储的成本要便宜。 |
2.3 节约空间
这个类别包括内存和存储中与节省空间相关的模式。
(1)Uint* vs Uint256
模式名称 | Uint* vs Uint256 |
---|---|
问题描述 | EVM 每次运行在 256 位,因此使用一个 uint * (小于256位的无符号整数),它将首先转换为 uint256 ,这需要额外的气体。 |
解决方案 | 在一个 slot 中封装更多变量时使用小于或等于 128 位的无符号整数,如果不是,最好使用 uint256 变量。 |
(2)Mapping vs Array
模式名称 | Mapping vs Array |
---|---|
问题描述 | 在 Solidity 中只提供了两种数据类型来表示数据列表:数组和映射,映射更便宜,而数组是封装和可迭代的。 |
解决方案 | 为了节省气体,建议使用映射来管理数据列表,除非需要迭代或者有可能封装数据类型,这对于存储和内存都是有用的。当然也可以使用整数索引作为键来管理带有映射的有序列表。 |
(3)Fixed Size
模式名称 | Fixed Size |
---|---|
问题描述 | 在 Solidity 中,任何固定大小的变量(fixed size variable)都比可变变量(variable size)更便宜。 |
解决方案 | 每当有可能设定一个数组大小的上界时,使用固定大小的数组而不是动态数组。 |
(4)Default Value
模式名称 | Default Value |
---|---|
问题描述 | 在创建变量时对其进行初始化是一个很好的习惯,然而,这需要消耗以太坊中的 gas。 |
解决方案 | 在 Solidity 中,所有变量默认设置为零。因此,不要显式地初始化一个变量,如果它的值需要设置为零。 |
(5)Minimize on-chain data
模式名称 | Minimize on-chain data |
---|---|
问题描述 | Storage 的 gas 成本非常高,远高于 Memory 的成本。 |
解决方案 | 最小化链上数据:链上的存储变量数据越少,gas 成本就越少,即只存储智能合约的关键数据并保留所有可能的链外数据。这种模式是在源头减少存储的使用,而 Limit Storage 模式是在使用存储时减少 gas 消耗。 |
(6)Explicitly mark external function
模式名称 | Explicitly mark external function |
---|---|
问题描述 | 公共函数的输入参数自动复制到内存中,需要耗费气体。 |
解决方案 | 外部函数的输入参数从 Calldata 内存中直接读取,显示标记为只对外调用的外部函数。 |
2.4 操作
这个类别包括与在智能合约函数内执行操作所使用 gas 有关的模式。
(1)Limit External Calls
模式名称 | Limit External Calls |
---|---|
问题描述 | 每次调用外部智能合约都相当昂贵,甚至可能不安全。 |
解决方案 | 在 Solidity 中,限制外部调用,最好是调用一个单一的、多用途的、参数较多的函数并返回所需的结果,而不是对每个数据进行不同的调用。 |
(2)Internal Function Calls
模式名称 | Internal Function Calls |
---|---|
问题描述 | 调用公共函数比调用内部函数更昂贵,因为在前者中所有的参数都被复制到内存中。 |
解决方案 | 在可能的情况下,优先选择内部函数调用。 |
(3)Fewer functions
模式名称 | Fewer functions |
---|---|
问题描述 | 在以太坊智能合约中实现一个函数需要消耗 gas。 |
解决方案 | 尽量拥有较少的函数,但又不能太少,平衡函数数量与复杂度。(实现一个具有许多小函数的智能合约是昂贵的,但是,过大的函数会使测试变得复杂,并可能危及安全性) |
(4)Use Libraries
模式名称 | Use Libraries |
---|---|
问题描述 | 如果一个智能合约倾向于用自己的代码来执行所有的任务,那么它将会非常昂贵。 |
解决方案 | 均衡地使用库,外部库的字节码不是智能合约的一部分,从而节省了气体,然而,调用它们存在安全问题。 |
(5)Short Circuit
模式名称 | Short Circuit |
---|---|
问题描述 | 每个操作都需要耗费气体。 |
解决方案 | 短路判断,在使用逻辑运算符时,对表达式进行排序,以降低评估第二个表达式的概率。 |
(6)Short Constant Strings
模式名称 | Short Constant Strings |
---|---|
问题描述 | 存储字符串是昂贵的。 |
解决方案 | 保持短的常量字符串,确保常量字符串适合32字节。例如,可以使用字符串澄清错误,但必须保持较短的长度以避免浪费内存。 |
(7)Limit Modifiers
模式名称 | Limit Modifiers |
---|---|
问题描述 | 修饰器(Modifiers)的代码内联在被修改的函数内部,从而增加了大小和 gas 成本。 |
解决方案 | 限制修饰器的使用。内部函数不是内联的,而是被称为单独的函数。它们在运行时稍微昂贵一些,但是如果多次使用,在部署时可以节省大量的冗余字节码。 |
(8)Avoid redundant operations
模式名称 | Avoid redundant operations |
---|---|
问题描述 | 每个操作都需要耗费气体。 |
解决方案 | 避免冗余操作。例如,避免双重检查;使用 SafeMath 库可以防止下溢和溢出,因此不需要进行检查。 |
(9)Single Line Swap
模式名称 | Single Line Swap |
---|---|
问题描述 | 每个赋值和定义变量都需要耗费气体。 |
解决方案 | Solidity 允许在一条指令中交换两个变量的值,因此,不使用辅助变量就可进行交换:(a, b) = (b, a) 。 |
(10)Write Values
模式名称 | Write Values |
---|---|
问题描述 | 每个操作都需要耗费气体。 |
解决方案 | 写入值而不是计算它们,如果在编译时已经知道某些数据的值,则直接写出这些值。在初始化时不要使用 Solidity 函数来推导数据的值。 |
2.5 杂项
这个类别包括上述类别中未涉及到的模式。
(1)Freeing storage
模式名称 | Freeing storage |
---|---|
问题描述 | 有时,不再使用存储变量。 |
解决方案 | 为了保持区块链的规模较小,每次释放存储资源都会得到 gas 退款。因此,可以方便地使用关键字删除,只要它们不再是必要的。 |
(2)Optimizer
模式名称 | Optimizer |
---|---|
问题描述 | 以穷举方式优化 Solidity 代码来节约 gas 是困难的。 |
解决方案 | 始终开启 Solidity 优化器,它是所有 solidity 编译器的一个选项,执行编译器可以进行优化,但是,这种优化是有限的。 |