本文深入探讨了以太坊交易中的gas费用及其优化方法,强调了通过Solidity编写高效智能合约的重要性。文章列出了十种具体的gas优化技术,包括使用映射而非数组、启用Solidity编译器优化以及利用calldata等,旨在帮助开发者减少交易成本,同时提高合约的安全性和性能。
以太坊的 gas 费长期以来一直是用户关注的焦点,尽管最近的以太坊权益证明合并引入了更节能的系统,但对此并没有太大的影响。为了保持高标准、降低风险、编写干净的代码并创建安全、经济高效的智能合约,了解使用 Solidity 优化 gas 的技术至关重要。
本文将为你提供关于 Solidity gas 优化的关键概念、优化(以及低效)智能合约代码的示例以及如何将这些 Solidity gas 优化概念集成到你今天的 web3 项目中的提示。
Gas 是在以太坊网络上执行特定操作所需的计算努力的单位测量,并且 Solidity gas 优化是使你的 Solidity 智能代码执行更便宜的过程。
由于每个以太坊交易都需要使用计算资源,因此每个交易都需要支付费用。为了完成以太坊交易而需要的费用被称为 gas。
当 智能合约在 Solidity 中被编译 时,它被转换为一系列“操作码”,也称为 opcodes。每个 opcode 都有一个预定义的 gas 值,表示执行该特定操作所需的计算工作。
Opcodes 和字节码是相似的,然而字节码使用十六进制整数来表示它们。Bytecodes 由 以太坊虚拟机(也称为 EVM)执行,EVM 是一段执行智能合约的软件,位于以太坊节点和网络层之上。
优化的目标是减少执行智能合约所需的总体操作数量,优化的智能合约不仅减少处理交易所需的 gas,还是防止恶意滥用的保护。
在 Solidity 中有两种数据类型可以描述数据列表,数组和映射,它们的语法和结构截然不同,使每种类型有所不同的用途。虽然数组可以打包和迭代,但映射则成本更低。
例如,在 Solidity 中创建一个汽车数组可能如下所示:
string cars[];
cars = ["ford", "audi", "chevrolet"];
让我们看看如何为汽车创建一个映射:
mapping(uint => string) public cars;
使用 mapping 关键字时,你将指定键(uint)和值(string)的数据类型。然后,你可以使用构造函数添加一些数据。
constructor() public {
cars[101] = "Ford";
cars[102] = "Audi";
cars[103] = "Chevrolet";
}
}
除非需要迭代或数据类型可以打包,否则建议使用映射来管理数据列表以节省 gas。这对内存和存储都是有益的。
整数索引可用作映射中的键来控制有序列表。映射的另一个优点是,你可以直接访问任何值,而无需像迭代数组那样来处理。
Solidity 编译器 optimizer 旨在简化复杂表达式,从而通过内联操作、部署成本和函数调用成本来最小化代码的大小和执行成本。
Solidity optimizer 专注于内联操作。尽管诸如内联函数的操作可能导致代码显著增大,但常常使用它,因为这创造了额外简化的潜力。
部署成本和函数调用成本是编译器优化会影响你的智能合约 gas 的另两个领域。
例如,部署成本随着“运行”次数(指定每个 opcode 在合约生命周期中将被执行的次数)减少而减少。相反,函数调用的成本则随着运行次数的增加而增加。这是因为为更多运行优化的代码在部署时成本更高,而在部署后成本更低。
在下面的示例中,运行设置为 200 和 10,000:
module.exports = {
solidity: {
version: "0.8.9",
settings: {
optimizer: {
enabled: false,
runs: 200,
},
},
},
};
将运行增加到 10,000 并将默认值设置为 true:
module.exports = {
solidity: {
version: "0.8.9",
settings: {
optimizer: {
enabled: true,
runs: 10000,
},
},
},
};
因为 链上数据限制于区块链网络内部可以原生创建的内容(例如状态、账户地址、余额等),通过在存储变量中保存更少的数据、批处理操作和避免循环来减少不必要的操作和复杂计算。
你在存储变量中保存的数据越少,所需的 gas 就越少。将所有数据保存在链下,仅将智能合约的关键信息保存在链上。开发者可以通过将链下数据集成到区块链网络中来创建更复杂的应用程序,包括预测市场、稳定币和参数化保险。
使用事件存储数据是一种流行但不明智的 gas 优化方法,因为尽管在事件中存储数据相对比在变量中更便宜,但事件中的数据无法被其他链上的智能合约访问。
批处理操作使开发人员能够通过传递动态大小的数组来批量执行相同的功能,仅需在一笔交易中,而无需使用不同的值多次调用同一方法。
考虑以下场景:用户想用五个不同的输入调用 getData()。在精简形式中,用户只需支付每笔交易的固定 gas 成本,以及 msg.sender 检查的 gas 一次。
function batchSend(Call[] memory _calls) public payable {
for(uint256 i = 0; i < _calls.length; i++) {
(bool _success, bytes memory _data) = _calls[i].recipient.call{gas: _calls[i].gas, value: _calls[i].value}(_calls[i].data);
if (!_success) {
assembly { revert(add(0x20, _data), mload(_data)) }
}
}
}
避免在长数组中循环;它不仅会消耗大量 gas,如果 gas 价格上升过高,还可能导致合约无法超过块 gas 限制。
与其在数组中循环直到找到所需键,不如 使用映射,它是哈希表,允许你通过键在一次操作中检索任何值。
事件用于让用户知道区块链上发生的事情,因为智能合约无法听到自身的事件,因为合约数据存储在状态树中,而事件数据存储在交易凭证树中。
Solidity 中的事件 是加速与智能合约结合使用的外部系统开发的捷径。区块链中的所有信息都是公开的,任何活动都可以通过仔细检查交易被检测到。
包含一个机制以跟踪智能合约部署后活动对于降低整体 gas 很有帮助。尽管查看所有合约的交易是一种跟踪活动的方法,但由于合约间的消息调用不会在区块链上记录,因此这种方法可能不足以满足需求。
event myFirstEvent(address indexed sender, uint256 indexed amount, string message);
你可以使用索引参数作为这些事件的过滤器来搜索日志事件。
如果开发者使用的项小于 32 字节,智能合约的 gas 消耗可能更高,因为以太坊虚拟机一次只能处理 32 字节。 为了将元素的大小增加到所需的大小,EVM 必须执行额外的操作。
contract A { uint8 a = 0; }
在上述示例中的成本为 22,150 + 2,000 gas,而使用大于 32 字节的类型则为 7,050 gas。
contract A { uint a = 0; // 或 uint256 }
只有在处理存储值时,使用较小的参数才有利,因为编译器会将多个元素压缩到一个存储槽中,将多次读取或写入合并为一次操作。
较小的 无符号整数(如 uint8)仅在可以在相同存储空间中存储多个变量时更高效,如在 结构体 中。与 uint8 在循环和其他情况下相比,uint256 使用的 gas 更少。
在处理数据时,EVM 采用了新颖的方法:每个合约都有一个永久存储数据的位置,以及一个可读取、写入和更新的数据存储空间。
存储中有 2,256 个槽,每个槽可以保存 32 字节。根据其特定性质,“状态变量”或在智能合约中声明的变量(不在任何函数内)将储存在这些槽中。
较小的状态变量(即小于 32 字节的变量)按定义顺序作为索引值存储,以 0 作为位置 1,1 作为位置 2,依此类推。如果较小的值顺序声明,它们将存储在同一个槽中,包括非常小的值如 uint64。
考虑以下示例:
小值没有按顺序存储,使用了不必要的存储空间。
contract MyContract {
uint128 c;
uint256 b;
uint128 a;
}
小值按顺序存储,节省了存储空间,因为它们被打包在一起。
contract Leggo {
uint128 a;
uint128 c;
uint256 b;
}
删除未使用的变量有助于释放空间并获得 gas 退款。删除未使用的变量效果等同于将值类型重新分配其默认值,例如整数的默认值为 0,或地址的零地址。
// 使用 delete 关键字
delete myVariable;
// 或如果是整数,赋值为 0
myInt = 0;
然而,映射不受删除的影响,因为映射的键可能是任意的且通常是未知的。因此,如果删除一个结构,所有非映射成员将重置,并且还将递归进入其成员。 但是,可以删除单独的键及其相关联的值。
通常,从 calldata 直接加载变量比复制到内存更具成本效益。 如果你只需要读取数据,可以通过将数据保存在 calldata 中来节省 gas。
// calldata
function func2 (uint[] calldata nums) external {
for (uint i = 0; i < nums.length; ++i) {
...
}
// Memory
function func1 (uint[] memory nums) external {
for (uint i = 0; i < nums.length; ++i) {
...
}
}
因为在函数执行时 calldata 中的值无法更改,如果变量在调用函数时需要更新,则使用内存。
Immutable 和 constant 是可以在状态变量上使用的关键字,以限制其状态更改。常量变量在编译后不能更改,而不可变变量可以在构造函数中设置。常量变量也可以在文件级别声明,如下面的示例:
contract MyContract {
uint256 constant b = 10;
uint256 immutable a;
constructor() {
a = 5;
}
}
使用 external 函数可见性 进行 gas 优化,因为公共可见性修饰符相当于同时使用 external 和 internal 可见性修饰符,意味着 public 和 external 都可以从合约外部调用,这需要更多 gas。
请记住,在这两种可见性修饰符中,只有 public 修饰符可以从合约内部的其他函数调用。
function one() public view returns (string memory){
return message;
}
function two() external view returns (string memory){
return message;
}
此代码将在 Hardhat、Rinkeby 和 Mainnet 中始终产生相同的 gas, regardless of the environment in which it is run. 在测试你的功能时,特别关注那些与 mint 函数最相似的功能,因为它们是你的用户最频繁访问的功能。
在本指南中,我们讨论了 gas 优化的重要性、它为开发人员带来的价值以及使用 Solidity 编写 gas 优化智能合约的十种技术。
作为 web3 和区块链开发者,优化 Solidity 智能合约中的 gas 成本是创建高质量高效项目最具挑战性和最重要的方面之一。这需要实践以及对以太坊和 Solidity 的概念和实际操作有透彻的理解。Gas 优化不仅惠及你的项目,还有整个区块链生态系统。
- 原文链接: alchemy.com/overviews/so...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!