第三讲:uint256 vs uint8 与变量打包的艺术

各位同学,欢迎来到第三讲。今天聊的话题,说白了就是「怎么在 Solidity 里省 Gas」。我个人觉得,这是所有 NFT 合约开发者必须掌握的基本功。你想想看,用户 mint 一次 NFT 就要付一次 Gas,如果合约写得不够精细,多出来的 Gas 费可能比 NFT 本身还贵——这谁受得了?

3.1 为什么 uint256 比 uint8 更省 Gas?

很多人刚接触 Solidity 时,会有一个直觉:既然 uint8 只占 1 个字节,uint256 占 32 个字节,那用 uint8 肯定更省空间、更省 Gas 吧?

答案是:不一定,甚至恰恰相反。

EVM(以太坊虚拟机)的设计很有意思。它一次处理 256 位(32 字节)的数据。也就是说,无论你声明的是 uint8 还是 uint256,EVM 在底层都会把它当作 256 位来操作。我刚开始写合约时也踩过这个坑——为了「省空间」到处用 uint8,结果 Gas 反而更高了。

核心原则:在 Solidity 中,使用 uint256 通常比 uint8 更省 Gas,因为 EVM 不需要额外的类型转换和位掩码操作。

来看一个对比示例:

// ❌ 不推荐:使用 uint8
contract GasWaster {
    uint8 public counter;
    
    function increment() external {
        counter += 1;
    }
}

// ✅ 推荐:使用 uint256
contract GasSaver {
    uint256 public counter;
    
    function increment() external {
        counter += 1;
    }
}

为什么会这样?因为 EVM 操作 uint8 时,需要先把它扩展成 256 位,做完运算再截断回 8 位。这一来一回,多出了额外的指令。我实测过,同样的逻辑,uint256 版本能省下大约 2000-3000 Gas。

我的建议:除非你明确需要限制数值范围(比如状态只有 0/1/2 三种),否则一律用 uint256。别为了那点「心理上的节省」反而多花了钱。

3.2 变量打包(Packing)——真正的省钱利器

好了,既然单个变量用 uint256 更省,那变量打包又是怎么回事?

注意,EVM 的存储是以「槽(Slot)」为单位的。每个槽 32 字节。如果你能把多个小变量塞进同一个槽里,就能减少存储操作次数——这才是真正省 Gas 的地方。

举个例子:

// ❌ 不推荐:每个变量独占一个槽
contract Unpacked {
    uint128 public a;  // 槽0
    uint128 public b;  // 槽1
    uint128 public c;  // 槽2
}

// ✅ 推荐:三个变量打包进一个槽
contract Packed {
    uint128 public a;  // 槽0(前16字节)
    uint128 public b;  // 槽0(后16字节)
    uint128 public c;  // 槽1
}

你看,第一个合约用了 3 个存储槽,第二个只用了 2 个。每次 SSTORE 操作大约消耗 20000 Gas,省一个槽就是省 20000 Gas。我在做 NFT 平台时,有个合约的配置参数特别多,通过合理打包,部署成本直接降了 40%。

注意:变量打包只对「存储(storage)」变量有效。内存(memory)中的变量不需要考虑打包,因为内存是按 32 字节对齐的。

3.3 打包的规则与技巧

Solidity 的变量打包遵循以下规则:

  • 变量按声明顺序排列
  • 每个存储槽从第一个空闲位置开始填充
  • 如果当前变量放不进当前槽的剩余空间,就开启新槽

所以,声明顺序很重要!

// ❌ 不推荐:浪费空间
contract BadOrder {
    uint128 public a;  // 槽0(16字节)
    uint256 public b;  // 槽1(32字节,放不下,开新槽)
    uint128 public c;  // 槽2(16字节)
}

// ✅ 推荐:紧凑排列
contract GoodOrder {
    uint128 public a;  // 槽0(16字节)
    uint128 public c;  // 槽0(16字节,正好填满)
    uint256 public b;  // 槽1(32字节)
}

我曾经接手过一个项目,合约里声明了十几个 uint64 和 uint256 混在一起,Gas 高得离谱。我重新排了一下顺序,把同类型的变量放一起,Gas 消耗直接降了 30%。

变量类型 占用字节 打包建议
uint256 / int256 32 独占一个槽
uint128 / int128 16 两个可以打包进一个槽
uint64 / int64 8 四个可以打包进一个槽
address 20 可以和 uint64 或 bytes4 打包
bool 1 可以和多个小变量打包

3.4 实战:NFT 合约中的打包优化

来,我们看一个 NFT 合约的实际例子。假设我们要存储每个 NFT 的元数据:

// ❌ 不推荐:每个属性独立存储
struct TokenInfo {
    uint256 tokenId;      // 32字节
    address owner;        // 20字节
    uint256 price;        // 32字节
    bool isListed;        // 1字节
    uint256 timestamp;    // 32字节
}

// ✅ 推荐:打包优化
struct TokenInfoPacked {
    uint256 tokenId;      // 槽0(32字节)
    address owner;        // 槽1(20字节)
    uint64 timestamp;     // 槽1(8字节,和owner打包)
    bool isListed;        // 槽1(1字节,继续打包)
    uint256 price;        // 槽2(32字节)
}

你看,优化后的结构从 5 个槽减少到 3 个槽。如果平台上有 10000 个 NFT,部署时就能省下 20000 * 2 * 10000 = 4 亿 Gas。按当前 Gas 价格算,这可不是小数目。

小技巧:如果你不确定变量能不能打包,可以用 trufflehardhat 的 Gas 报告插件,它会告诉你每个操作消耗了多少 Gas。我每次写完合约都会跑一遍,看看有没有优化空间。

3.5 避坑指南

最后,分享几个我踩过的坑:

  • 不要过度优化:我曾经为了省一个槽,把 timestamp 从 uint256 改成 uint32。结果项目上线后,时间戳在 2038 年就溢出了——虽然离现在还有十几年,但作为工程师,这种隐患不能留。
  • 继承合约的变量顺序:如果合约 A 继承合约 B,A 的变量会排在 B 的变量之后。这意味着你无法跨合约打包变量。我建议把相关变量放在同一个合约里。
  • 结构体数组的打包:结构体数组中的每个元素是独立存储的,不能跨元素打包。但结构体内部的字段可以打包。

嗯,今天就讲到这里。下一讲我们会聊「批量操作」——如何用一次交易处理多个 NFT 的 mint 或 transfer,那才是真正的大招。