本文深入探讨了如何在Solidity中优化Gas费用,涵盖了从存储、内存和calldata的选择,到变量打包、减少冗余存储写入、函数层面的优化、循环效率提升以及数据结构选择等多个方面。此外,还介绍了高级的Gas优化策略,如使用inline assembly和bitwise操作,旨在帮助开发者编写更高效、更经济的智能合约。
2025年4月11日
以太坊网络上的每一笔交易都有成本——gas。你的智能合约效率越高,用户与它交互时需要支付的 gas 就越少。Gas 是执行操作所需的费用,无论这些操作涉及转移 ETH、与智能合约交互还是执行链上计算。操作越复杂,消耗的 gas 就越多。如果你的智能合约没有优化,用户最终可能会支付不必要的费用,在某些情况下,由于区块 gas 限制,交易可能会失败。
随着 Polygon 和 Arbitrum 等 Layer2 解决方案的兴起,有些人可能认为 gas 优化不再那么重要。然而,这些解决方案仍然建立在以太坊的基础上,并会产生费用。Layer2 网络上的 gas 费用可能较低,但并非可以忽略不计,尤其是在采用率上升和网络需求增长时。此外,以太坊的区块 gas 限制(通常在 3000 万 gas 单位左右)对交易执行施加了约束。一个没有经过良好优化的合约可能会达到这些限制,从而导致交易失败和 gas 费用浪费。
对于开发者来说,编写高效的 Solidity 代码不仅仅是为了节省成本,更是为了提高可用性。一个消耗过多 gas 的合约会降低用户互动,使其与更优化的替代方案相比吸引力下降。本指南探讨了降低 Solidity 中 gas 成本的实用方法,确保合约平稳运行,而不会耗尽资金。**
Solidity 提供了三种存储数据的主要方式:
传递参数时,尽可能使用 calldata 而不是 memory,以降低 gas 成本。
并非所有操作的 gas 成本都相同。有些操作比其他操作贵得多:
了解哪些操作会消耗最多的 gas 有助于你编写更高效的合约。
Solidity 将数据存储在 256 位的插槽中。如果你使用较小的类型(如 uint128),则可以在一个插槽中容纳两个值,从而降低 gas 成本。Solidity 编译器和优化器会自动处理打包;你只需要在合约中连续声明可打包的变量即可。
contract OptimizedStorage {
uint128 a;
uint128 b; // Shares a storage slot with `a`(与 `a` 共享一个 storage 插槽)
uint256 c; // Needs a separate slot(需要单独的插槽)
}
如果 c 放在 a 和 b 之间,Solidity 将使用额外的 storage 插槽,不必要地增加 gas 成本。
Solidity 提供了多种整数大小(uint8、uint16、uint256)。虽然使用较小的类型似乎总是可以节省 gas,但只有在打包变量时才是如此。否则,使用 uint256 通常更有效,因为 EVM 本机处理 256 位字。
写入 storage 是 Solidity 中最昂贵的操作之一。与其频繁修改存储的变量,不如在 memory 中计算重复值,然后仅将最终结果写入 storage 一次。
contract GasSaver {
uint256 public total;
function add(uint256[] calldata values) external {
uint256 sum;
for (uint i = 0; i < values.length; i++) {
sum += values[i];
}
total = sum; // Single storage write(单次 storage 写入)
}
}
通过首先在 memory 中计算总和,然后仅将其写入 storage 一次,此方法可显著降低 gas 成本。
在不再需要某个 storage 变量时清除它,可以为你赢得 gas 退款。
delete myVariable; // Triggers a gas refund(触发 gas 退款)
打算用于外部调用的函数应标记为 external 而不是 public。这样可以防止 Solidity 将函数参数复制到 memory 中,从而降低 gas 成本。
contract EfficientFunctions {
function process(uint256 data) external returns (uint256) {
return data * 2;
}
}
如果一个值需要在函数中多次使用,请将其存储在局部变量中,而不是重新计算它。
Inefficient (Unnecessary Recalculations) (低效 (不必要的重新计算))
function calculate(uint256 a, uint256 b) external pure returns (uint256) {
return (a * b) + (a * b) + (a * b);
}
在这种情况下,乘法 a * b 执行了三次,增加了 gas 的使用量。
Optimized Example (Store the Computation Once) (优化示例(只存储一次计算结果))
function calculate(uint256 a, uint256 b) external pure returns (uint256) {
uint256 product = a * b;
return product + product + product;
}
现在,乘法只发生一次,并且结果被重复使用,从而节省了 gas。
循环可能很耗费资源,尤其是在处理大型数组时。通过只获取一次数组的长度来避免不必要的迭代,而不是在循环内多次调用 .length。
function processArray(uint256[] calldata data) external {
uint256 length = data.length;
for (uint256 i = 0; i < length; i++) {
// Process data(处理数据)
}
}
加法、减法和乘法等算术运算通常包括 溢出检查,以确保结果保持在数据类型的有效值范围内(例如,uint256)。如果发生溢出,Solidity 会自动回滚交易以防止出现意外行为。
但是,这些检查会消耗额外的 gas,因为 EVM 需要执行比较以确保不会发生溢出。如果你确信某个操作不会溢出(例如,当你知道涉及的数字足够小的时候),你可以使用 unchecked 关键字来跳过这些溢出检查,这样可以节省 gas。
Why is unchecked Gas Efficient?(为什么 unchecked 节省 Gas?)
Example:(示例:)
以下是在一个简单的加法运算中使用 unchecked 的示例:
pragma solidity ^0.8.0;
contract GasOptimization {
function safeAdd(uint256 a, uint256 b) public pure returns (uint256) {
return a + b; // Normal addition with overflow check(带有溢出检查的正常加法)
}
function uncheckedAdd(uint256 a, uint256 b) public pure returns (uint256) {
unchecked {
return a + b; // Addition without overflow check(没有溢出检查的加法)
}
}
}
在 uncheckedAdd 函数中,加法运算是在不检查结果是否溢出的情况下完成的。这会跳过内部安全检查,并且可以节省 gas,尤其是在你确定这些值不会导致溢出的情况下。
使用 unchecked 时要小心。跳过溢出检查意味着,如果数字确实溢出,你的合约可能会接受无效的操作,从而可能导致意外行为或安全风险。因此,仅在确定不会发生溢出的情况下才使用 unchecked,并且你对该风险感到满意。
Solidity 是我使用过的第一种映射实际上比数组便宜的语言!这归结为 EVM 如何处理数据存储——数组不是在 memory 中按顺序存储的,而是更像映射一样工作。虽然你可以通过打包较小的数据类型(如 uint8)来优化数组,但映射不提供这种优势。
也就是说,映射缺少内置的长度属性,并且无法直接迭代,因此在某些情况下,即使它会花费更多 gas,你也可能必须使用数组。数组和映射之间的选择实际上取决于你的具体用例。尽可能使用映射进行查找。它们比数组更便宜,用于查找和更新值。
数组对于有序数据来说是不错的选择,但在修改元素时可能会很昂贵。
结构体打包是一种 Solidity 中的优化技术,它通过有效地利用 storage 插槽来帮助降低 gas 成本。EVM 将数据存储在 32 字节(256 位)的插槽中,并且在创建结构体时,其变量存储在这些插槽中。
How Struct Packing Works(结构体打包的工作原理)
结构体中的每个变量都占用 storage 空间,并且结构体打包的目标是确保多个较小的变量可以容纳在单个 32 字节的插槽中。如果变量的排列效率低下,它们可能会溢出到多个插槽中,从而导致更高的 gas 成本。
Example of Poor Packing (More Expensive) (低效打包的示例(更昂贵))
struct User {
uint256 balance; // Takes 32 bytes(占用 32 字节)
uint128 rewards; // Starts a new slot (16 bytes)(启动一个新插槽(16 字节))
uint256 level; // Takes another full slot (32 bytes)(占用另一个完整插槽(32 字节))
}
在这里,即使前一个插槽中存在未使用的空间,level 也会启动一个新插槽。
Example of Optimized Struct Packing(优化结构体打包的示例)
struct User {
uint128 rewards; // Takes 16 bytes(占用 16 字节)
uint128 level; // Fits in the same slot as `rewards`(与 `rewards` 共享同一个插槽)
uint256 balance; // Starts a new slot(启动一个新插槽)
}
通过在 balance 之前排列 rewards 和 level,我们可以更好地利用 storage,从而减少所需的插槽数量。
像 && 和 || 这样的逻辑运算符会在结果已知时立即停止计算。利用这一点来节省 gas。
if (x > 0 && y > 0) {
// If x is false, y is never checked(如果 x 为 false,则永远不会检查 y)
}
在Solidity中,当你回滚交易时,你可以提供一条错误消息,通常是一个字符串,以帮助开发人员了解交易失败的原因。但是,在错误消息中使用字符串在 Gas 方面可能会非常昂贵。
原因是字符串在Solidity中是动态大小的,这意味着它们会消耗大量的存储器和内存,尤其是当它们很长时。每次使用字符串时,都必须存储然后检索它,这会增加 Gas 成本。
现在,自定义错误是一种更具Gas效率的替代方案。你不需要使用字符串,而是定义一个带有特定参数的自定义错误,该参数在交易回滚时传递。这些错误的编码效率要高得多,这意味着所需的存储量和操作量更少,最终节省了 Gas。
例如:
// Custom error definition(自定义错误定义)
error InsufficientBalance(address user, uint256 requested, uint256 available);
// Reverting with custom error(使用自定义错误回滚)
if (balance[msg.sender] < amount) {
revert InsufficientBalance(msg.sender, amount, balance[msg.sender]);
}
在本例中,你需要使用自定义错误来更有效地打包数据,而不是使用"余额不足"之类的字符串消息。这降低了回滚交易的计算成本,并使你的合约在性能和成本方面都得到了更好的优化。因此,简而言之,使用自定义错误有助于降低与Solidity中错误处理相关的 Gas 成本,从而使你的智能合约在性能和成本方面都更加优化。
Solidity 中的内联汇编允许开发人员在其智能合约中直接编写低级代码。它本质上就像直接与 EVM 对话,而不是使用更高级别的 Solidity 语言。
在 gas 优化 方面,内联汇编可能是一个强大的工具。这是因为它允许你更细粒度地控制操作的执行方式,通常比 Solidity 的更高级别的抽象更节省 gas。
原因如下:
Example:(示例:)
function addNumbers(uint256 a, uint256 b) public pure returns (uint256 result) {
assembly {
result := add(a, b)
}
}
在这个例子中,加法运算是用内联汇编编写的,它比用于加法的 Solidity 高级函数调用更有效。这个简单的例子可能没有显示出很大的差异,但是在更复杂的函数中,汇编可以显着降低 gas 成本。
但是,要小心:内联汇编功能强大,但很难正确使用。很容易犯错误,并且因为它绕过了一些 Solidity 的内置安全检查,所以可能会引入漏洞。建议谨慎使用汇编,并且仅在你确信优化足够重要以证明增加的复杂性是合理的时才使用。
按位运算是gas优化的另一个绝佳工具。这些操作允许你直接操作数据的各个位(构成内存中的值的0和1)。通过使用按位运算,你在处理某些类型的数据时,可以更有效地执行某些任务,从而节省 Gas 成本。
Why are Bitwise Operations Gas Efficient?(为什么按位运算效率高?)
Example: Using Bitwise AND, OR, and Shifting(示例:使用按位与、或和移位)
假设你想在单个uint256变量中存储几个标志。每个标志可以代表合约中的不同条件或状态,你可以将它们打包成一个整数,而不是为每个标志设置单独的布尔变量。
// Example of setting flags using bitwise operations(使用按位运算设置标志的示例)
contract FlagStorage {
uint256 flags;
// Setting a flag (bit 0)(设置标志(位0))
function setFlag(uint256 flag) public {
flags |= (1 << flag); // Set the bit corresponding to the flag(设置了与标志对应的位)
}
// Checking a flag (bit 0)(检查标志(位0))
function checkFlag(uint256 flag) public view returns (bool) {
return (flags & (1 << flag)) != 0; // Check if the bit is set(检查是否设置了该位)
}
// Resetting a flag (bit 0)(重置标志(位0))
function resetFlag(uint256 flag) public {
flags &= ~(1 << flag); // Clear the bit corresponding to the flag(清除与标志对应的位)
}
}
Here’s how it works:
它是这样工作的:
通过将多个标志打包成一个整数,你减少了需要的存储量。你只需要使用一个uint256来保存多个标志,而不是拥有几个布尔变量(每个变量都占用一个存储槽)。这不仅节省了 Gas,而且在区块链上节省了空间。
在Solidity中优化 Gas 归结为做出周到的决定。它是关于最小化存储写入次数,组织循环以提高效率,以及为作业选择正确的数据类型。即使是微小的调整也能随着时间的推移显着节省 Gas 成本,无论是在部署期间还是在用户与你的合约交互时。
随着Solididity和以太坊的发展,重要的是要及时了解新的更新和最佳实践。这确保了你的合约保持精简和成本效益,而不会错过可能有助于提高性能的新功能。最终,优化gas 就是在效率和功能之间取得平衡,以便你的智能合约可以用更少的钱做更多的事情。
- 原文链接: blog.immunebytes.com/202...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!