跳到主要内容

优化 Solidity 中的气体

在本教程中,我们将学习 Solidity 中的一些气体优化技术。这是我们要求最多的一个级别,所以让我们开始吧,不要再多说了。

技巧和窍门

可变包装

如果你还记得我们在之前的一个关卡中谈到了存储槽。现在,如果你记得的话,solidity 中有趣的一点是,每个存储槽是 32 字节。

如果你正确地打包你的变量,这个存储槽可以被优化,这将进一步意味着你在部署你的智能合约时的气体优化。

打包你的变量意味着你将较小尺寸的变量打包或放在一起,使它们共同形成 32 个字节。例如,你可以将 32 个 uint8 打包到一个存储槽中,但要做到这一点,你必须连续声明它们,因为变量的声明顺序在 solidity 中很重要。

给出两个代码样本。

uint8 num1;
uint256 num2;
uint8 num3;
uint8 num4;
uint8 num5。
uint8 num1;
uint8 num3;
uint8 num4;
uint8 num5。
uint256 num2。

第二种情况更好,因为在第二种情况下,solidity 编译器会把所有的 uint8 放在一个存储槽中,但在第一种情况下,它会把 uint8 num1 放在一个槽中,但现在它看到的下一个是 uint256,它本身需要 32 字节,因为 256/8 比特=32 字节,所以它不能和 uint8 num1 放在同一个存储槽中,所以现在它将需要另一个存储槽。之后,uint8 num3, num4, num5 将被放在另一个存储槽中。因此,第二个例子需要 2 个存储槽,而第一个例子则需要 3 个存储槽。

还需要注意的是,内存中的元素和 calldata 不能被打包,也不能被 solidity 的编译器优化。

存储与内存

改变存储变量比改变内存中的变量需要更多的气体。最好是在所有的逻辑都已经实现之后,在最后更新存储变量。

因此,给定两个代码的样本

contract A {
uint public counter = 0;

function count() {
for(uint i = 0; i < 10; i++) {
counter++;
}
}
}
contract B {
uint public counter = 0;

function count() {
uint copyCounter;
for(uint i = 0; i < 10; i++) {
copyCounter++;
}
counter = copyCounter;
}
}

第二段代码样本更加气体优化,因为我们只向存储变量 counter 写了一次,而第一段代码样本是在每个迭代中向存储写。尽管我们在第二个代码样本中多写了一次,但向内存写 10 次,向存储写 1 次,仍然比直接向存储写 10 次便宜。

固定长度和可变长度的变量

我们谈到了固定长度和可变长度变量的存储方式。固定长度的变量存储在 stack 中,而可变长度的变量则存储在 heap 中。

从本质上讲,为什么会出现这种情况,是因为在堆中,你确切地知道在哪里可以找到一个变量和它的长度,而在堆中,鉴于变量的可变性,会有额外的遍历成本。

因此,如果你能使你的变量固定大小,这对气体优化总是有好处的。

给出两个代码的例子。

string public text = "Hello";
uint[] public arr;
bytes32 public text = "Hello";
uint[2] public arr;

第二个例子更加气体优化,因为所有的变量都是固定长度的。

外部、内部和公共函数

在 solidity 中调用函数可能是非常耗气的,与其调用多个函数,不如调用一个函数并从中提取所有数据。

回想一下,公共函数是那些既可以在外部(由用户和其他智能合约)又可以在内部(从同一合约的另一个函数)调用的函数。

然而,当你的合约正在创建只被外部调用的函数时,这意味着合约本身不能调用这些函数,你最好使用外部关键字而不是公共关键字,因为公共函数中的所有输入变量都被复制到内存中,这需要耗费气体,而对于外部函数,输入变量被存储在 calldata 中,这是一个用于存储函数参数的特殊数据位置,存储在 calldata 中需要的气体比存储在内存中要少。

同样的原理也适用于为什么调用内部函数比调用公共函数更便宜。这是因为当你调用内部函数时,参数是作为变量的引用传递的,不会再被复制。

函数修改器

这是一个令人着迷的问题,因为几周前,我正在调试我们一个学生的这个错误,他们遇到了 "堆栈太深 "的错误。这通常发生在你在函数中声明了大量的变量,而该函数的可用堆栈空间已经无法使用。正如我们在 Ethereum 存储层面所看到的,EVM 只允许在一个函数中最多有 16 个变量,因为它不能在堆栈中执行超过 16 层深度的操作。

现在,即使在修改器中移动了很多 require 语句,也无济于事,因为函数修改器使用的堆栈与它们所处的函数相同。为了解决这个问题,我们在修改器中使用了一个内部函数,因为内部函数不与原始函数共享相同的限制性堆栈,但修改器却可以。

使用库

库是无状态的合约,不存储任何状态。现在,当你从你的合同中调用一个库的公共函数时,该函数的字节码不会与你的合同一起被部署,因此你可以节省一些气体成本。例如,如果你的合同有排序或做数学等的功能。你可以把它们放在一个库中,然后调用这些库函数来为你的合同做数学或排序。要阅读更多关于库的信息,请点击这个链接。

然而,有一个小的注意事项。如果你正在编写自己的库,你将需要部署它们并支付气体 - 但一旦部署,它可以被其他智能合约重复使用,而不需要自己部署。由于它们不存储任何状态,库只需要被部署到区块链上一次,并被分配一个地址,Solidity 编译器足够聪明,可以自己计算出来。因此,如果你使用 OpenZeppelin 的库,例如,它们不会增加你的部署成本。

短路的条件反射

如果您使用(||)或(&&),最好以这样的方式编写您的条件,使最少的函数/变量值被执行或检索,以确定整个语句是真的还是假的。

由于条件检查在找到第一个满足条件的值时就会停止,所以你应该把最有可能验证/否决条件的变量放在前面。在 OR 条件(||)中,尽量把最有可能为真的变量放在前面,在 AND 条件(&&)中,尽量把最有可能为假的变量放在前面。一旦该变量被检查,条件就可以退出,而不需要检查其他的值,从而节省气体。

释放存储空间

由于存储空间需要花费气体,你实际上可以释放存储空间,删除不必要的数据来获得气体退款。因此,如果你不再需要某些状态值,使用 Solidity 中的删除关键字来获得一些气体退款。

短的错误字符串

确保你的 require 语句中的错误字符串的长度很短,字符串的长度越长,它的气体成本就越高。

require(counter >= 100, "NOT REACHED"); // Good
require(balance >= amount, "Counter is still to reach the value greater than or equal to 100, ............................................";

第一种要求比第二种要求更有气体优化。

注意:在较新的 Solidity 版本中,现在有使用 error 关键字的自定义错误,其行为与事件非常相似,可以实现类似的气体优化。

感谢大家对本文的关注 🚀 希望你喜欢它 :)

🤔 以下哪一个不是对代码进行气体优化的原因? 允许你编写更复杂的函数 使得用户与你的 DApp 互动的成本降低 以太坊为编写优化代码的开发者提供奖励。

参考