Solidity Gas 优化

本文探讨了区块链开发中gas优化的各种策略,通过代码示例,将优化方法归为架构级优化(合约设计模式)、代码级优化(开发者控制)和部署优化三个类别。内容涉及变量类型选择与布局、存储设计、函数设计、逻辑与循环优化、事件与错误处理、工具与测试、以及汇编层面的优化技巧,旨在降低gas消耗,提高合约执行效率和用户体验。

引言

在区块链开发中,Gas 是用于衡量执行交易和智能合约成本的单位。每个操作——无论是存储、计算还是数据传输——都会消耗一定量的 Gas,用户在发送交易时必须支付相应的费用。因此,Gas 优化不仅降低了用户的交易成本,还提高了合约执行效率,增强了用户体验。

本文通过实际的代码示例探讨各种 Gas 优化策略。优化方法大致可分为三类:架构级别的优化(合约设计模式)→ 代码级别的优化(开发者可控)→ 部署优化。

变量类型选择与布局

1. 单独使用变量

尽可能使用 256 位的数据类型,因为 EVM 以 256 位槽(32 字节)为单位读取存储。

参考:Ethereum StackExchange

contract A {
    uint8 public a = 0;

    function setA(uint8 _a) public {
        a = _a;
    }
}
contract B {
    uint256 public a = 0;

    function setA(uint256 _a) public {
        a = _a;
    }
}

测试表明,无论是部署还是执行读/写操作,合约 B 比合约 A 消耗的 Gas 更少。

2. 在 Structs 中选择变量类型

由于 EVM 的紧密打包机制,在 struct 中使用较小的数据类型可以节省 Gas,因为它们会被打包到同一个存储槽中。

contract A {
    struct Unpacked {
        uint256 x;
        uint256 y;
        uint256 z;
    }
}
contract B {
    struct Packed {
        uint64 x;
        uint64 y;
        uint128 z;
    }
}

3. 分组多个变量

紧密打包的同样概念适用:如果一个操作同时访问多个存储变量,你可以尽可能将它们打包到一个槽中。例如,你可以使用 uint128 而不是 uint256 来表示时间。

uint128 startTime;
uint128 endTime;
function setTime(uint256 _startTime, uint256 _endTime) external {
    // ...
}

4. 存储变量可见性

如果一个状态变量不需要从外部访问,将其标记为 private 而不是 public

contract A {
    uint256 public a;
    uint256 b;
function setA(uint8 _a) public {
        a = _a;
    }
    function setB(uint256 _b) public {
        b = _b;
    }
}

5. 布局和变量顺序

变量在存储中的排列顺序会影响插槽的利用率。尝试将小数据类型(如 uint8bool)组合在同一个存储槽中。

contract A {
    struct UnOrdered {
        uint64 x;
        uint256 y;
        address z;
    }
}
contract B {
    struct Ordered {
        uint64 x;
        address y;
        uint256 z;
    }
}

6. 使用常量或 Immutable 变量

使用 constantimmutable 关键字可以帮助减少 Gas 消耗。

contract GasOptimized {
    uint256 public constant TAX_RATE = 10;
    address public immutable FIX;
}

7. 瞬态存储

在 Solidity ^0.8.24 及以上版本中,你可以利用瞬态存储来节省 Gas,因为它避免了存储访问。但是,请注意潜在的风险。例如,在修饰符中:

modifier nonreentrant {
    assembly {
        if tload(0) { revert(0, 0) }
        tstore(0, 1)
    }
    _;
    // 解锁保护,以便该模式可组合。
    // 函数退出后,即使在同一事务中也可以再次调用。
    assembly {
        tstore(0, 0)
    }
}

在 Solidity ^0.8.27 之后,支持 transient 关键字。

存储设计

1. 避免将存储从非零更改为零

正如以太坊黄皮书中所述:

contract A {
    uint256 a;
  function setData(uint256 _data) public {
          a = _data;
      }
}
contract B {
    uint256 a = 1;
    function setData(uint256 _data) public {
        a = _data;
    }
}

其他技巧包括:

  • 使用 uint256(1)/uint256(2) 而不是 false/true
  • 使用循环时,从 n 迭代到 0,而不是从 0 迭代到 n

2. 尽量减少重复的存储读取

首次访问存储变量的成本为 G_coldload(2100 Gas),而后续访问的成本为 G_warmaccess(100 Gas)。因此,在进入循环之前将存储变量复制到内存中,以避免重复的昂贵读取。

function sumInefficient() public view returns (uint256) {
    uint256 total = 0;
    for (uint256 i = 0; i < number; i++) {
        total += number;
    }
    return total;
}
function sumEfficient() public view returns (uint256) {
    uint256 total = 0;
    uint256 len = number;
    for (uint256 i = 0; i < len; i++) {
        total += number;
    }
    return total;

例如,当数组长度为 10 时,这可能会导致显着的 Gas 节省。

3. 删除未使用的变量

如果不再需要某个变量,删除它可以获得 Gas 退款。

contract UnusedVariables {
    uint256 public data;
function useDelete() public {
        data = 123; // 示例操作
        delete data; // 将数据重置为其默认值
    }
    function skipDelete() public {
        data = 123; // 没有删除的示例操作
    }
}

函数设计

1. 函数参数类型

当函数参数在执行期间不需要修改时,首选使用 calldata 而不是 memory

function loop_memory(uint256[] memory arr) external pure returns (uint256 sum) {
    for (uint256 i = 0; i < arr.length; i++) {
        sum += arr[i];
    }
}
function loop_calldata(uint256[] calldata arr) external pure returns (uint256 sum) {
    for (uint256 i = 0; i < arr.length; i++) {
        sum += arr[i];
    }
}

2. 函数可见性

明确指定函数的可见性可以减少 Gas 的使用。与 public 函数相比,标记为 external 的函数对于外部调用来说更有效率。此外,使用 viewpure 告诉编译器该函数不修改状态,从而实现进一步的优化。

// Public 函数示例
function publicFunction(uint256[100] calldata data) public pure returns (uint256) {
    return data[0];
}
// External 函数示例
function externalFunction(uint256[100] calldata data) external pure returns (uint256) {
    return data[0];
}
  • External 函数可以直接从 calldata 读取,避免了将数据复制到内存中的成本。
  • Public 函数可以在内部和外部调用;内部调用通过内存传递参数。

3. 函数命名

Solidity 编译器按接口选择器(4 个字节)对所有函数进行排序,并在函数调用期间迭代每个函数(每次迭代的成本为 22 Gas)。因此,频繁调用的函数最好具有较小的选择器值。例如:

red()    => 2930cf24  // red() 选择器
white()  => a0811074  // white() 选择器
yellow() => be9faf13  // yellow() 选择器
blue()   => ed18f0a7  // blue() 选择器
purple() => ed44cd44  // purple() 选择器
green()  => f2f1e132  // green() 选择器

与调用 red() 相比,调用 green() 将额外花费 110 Gas (5×22)。

逻辑和循环优化

1. 优化条件检查

复杂的条件语句会增加 Gas 的使用。简化逻辑并使用短路求值(&&||)以避免不必要的计算。

// 情况 1:花费 100 Gas
if(case2 && case1) revert;
// 情况 2:花费 50 Gas
if(case2 || case1) revert;

此示例演示了条件检查中的短路如何减少 Gas 消耗。

2. 使用 Unchecked 循环

在循环中利用 unchecked 块可以节省溢出检查的 Gas:

// 非优化的标准循环
for (uint i = 0; i < arr.length; i++) {
    // 自动溢出检查
}
// 使用 unchecked 优化
for (uint i = 0; i < arr.length; ) {
    // 循环体...
    unchecked { i++; } // 通过省略溢出检查来节省 Gas
}

注意: 由于 Solidity v0.8.22,编译器在 for 循环中引入了 unchecked 优化,因此手动添加 unchecked 常常是不必要的。

3. 添加冗余条件检查

例如,在 UniswapV2 中,在继续进行进一步操作之前,检查 amount 是否大于零,可以避免当值为零时的不必要传输:

if (amount0Out > 0) _safeTransfer(_token0, to, amount0Out); // 乐观地转移 Token
if (amount1Out > 0) _safeTransfer(_token1, to, amount1Out); // 乐观地转移 Token

4. 避免初始化默认值

声明一个没有初始化的状态变量允许 EVM 在部署时自动分配其默认值,与显式初始化相比,可以节省 Gas:

uint256 number;          // 花费较少
uint256 number = 0;      // 花费较多
bool claim;              // 花费较少
bool claim = false;      // 花费较多
address owner;           // 花费较少
address owner = address(0);  // 花费较多

事件和错误

1. 自定义错误类型

与依赖带有错误字符串的 require 语句相比,使用自定义错误可以更有效地利用 Gas。

// 使用 require 的非优化方式
function withdraw(uint amount) public {
    require(balance >= amount, "Insufficient balance"); // 更多 Gas 消耗
}
// 使用自定义错误进行优化
error InsufficientBalance(uint available, uint required);
function withdrawOptimized(uint amount) public {
    if (balance < amount)
        revert InsufficientBalance(balance, amount); // 节省 Gas
}

工具和测试

1. 分析工具

使用 Gas 分析工具(例如 Remix、Truffle、Hardhat 等)来测试和识别合约中 Gas 消耗高的区域。定期监控 Gas 使用情况并根据需要进行优化。

2. 编译器优化

在 Solidity 编译器设置中启用优化器:

settings: {
  optimizer: {
    enabled: true,
    runs: 200  // 合约执行次数
  }
}

汇编

1. 添加两个数字

contract AssemblyExample {
    function add(uint256 a, uint256 b) public pure returns (uint256 result) {
        assembly {
            result := add(a, b)
        }
    }
}

2. 常见操作

常见的操作,例如 ERC20/ERC721 中的操作(如 approvesafeTransferFrom)或外部调用(call/delegateCall),可以用汇编重写以进一步节省 Gas。

结论

Gas 优化是 Solidity 智能合约开发的一个关键方面。通过仔细选择数据类型、优化控制结构、减少外部调用和选择高效的算法,开发人员可以显着降低 Gas 消耗,从而提高合约执行效率和用户体验。持续监控和定期优化你的合约对于实现最佳性能至关重要。

Solidity Gas 优化文章的翻译和 Medium 格式版本到此结束。祝你优化合约愉快!

  • 原文链接: blog.blockmagnates.com/s...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
blockmagnates
blockmagnates
江湖只有他的大名,没有他的介绍。