3、数据类型选择:uint256 vs uint8的Gas差异、固定大小字节数组(bytes32) vs 动态字节数组(bytes)、string vs bytes的取舍
好,咱们直接进入正题。数据类型的选择,说白了就是「用多大的碗装多少饭」。你选对了,Gas就省了;选错了,每一笔交易都在白白烧钱。我见过太多合约,明明可以用uint8,偏要用uint256,结果每次存储多花几千Gas。嗯,咱们一个一个拆开讲。
3.1 uint256 vs uint8:你以为小的就省Gas?
很多人第一反应:uint8只占1个字节,uint256占32个字节,那肯定uint8更省Gas啊。对不对?
答案是:不一定,甚至很多时候uint8更费Gas。
为什么会这样?这得从EVM(以太坊虚拟机)的存储机制说起。EVM的存储槽(storage slot)是256位(32字节)对齐的。你存一个uint8,它也得占满一个完整的存储槽。剩下的248位空间,如果没别的变量塞进去,就白白浪费了。
但Gas的消耗,是按「操作」算的,不是按「数据大小」算的。EVM读写一个存储槽,固定消耗20000 Gas(写)或 2900 Gas(读)。你存uint8和存uint256,只要它们各自独占一个槽,Gas消耗一模一样。
更坑的是,EVM在处理小于256位的数据时,还得额外做「位掩码」操作——把多余的高位清零。这反而多花了Gas。
核心结论:
- 在存储(storage)中,uint8和uint256的Gas消耗完全一样,除非你能把多个小变量打包进同一个槽。
- 在内存(memory)中,uint8反而更贵,因为EVM需要额外的位操作指令。
- 在调用数据(calldata)中,uint8确实能省一点空间,但省下的Gas微乎其微。
我个人习惯是:能用uint256就用uint256,除非你明确知道要打包变量。比如你有一个结构体,里面有三个uint8的字段,那把它们挨着放,EVM会自动打包进一个槽,这时候就省了。
// ❌ 不推荐:每个uint8独占一个槽,浪费空间
contract BadExample {
uint8 public a;
uint8 public b;
uint8 public c;
}
// ✅ 推荐:打包进一个槽,省Gas
contract GoodExample {
uint8 public a;
uint8 public b;
uint8 public c;
// 注意:这里a、b、c是挨着声明的,EVM会自动打包
// 但如果中间插了一个uint256,打包就失效了
}
避坑指南:我曾经在审计一个DeFi项目时,发现他们把十几个uint8变量分散声明,中间还混着address和uint256。结果每个uint8都独占一个槽,白白多花了十几万Gas。后来我建议他们按类型分组,把小的放一起,大的放一起,Gas直接降了30%。
3.2 bytes32 vs bytes:固定大小 vs 动态数组
这个选择其实很直观。bytes32是固定32字节,bytes是动态长度。但Gas差异可不小。
先说结论:
- 如果你确定数据长度不超过32字节,无脑用bytes32。
- 如果数据长度可能超过32字节,或者你根本不知道多长,那就用bytes。
为什么bytes32更省?因为它是「值类型」,直接存在栈上或者存储槽里。而bytes是「引用类型」,它存的是一个指向数据位置的指针,读写时多一层间接寻址,Gas自然就上去了。
我举个例子:
// 省Gas:固定32字节
bytes32 private fixedData;
// 费Gas:动态数组,每次读写多花至少几百Gas
bytes private dynamicData;
在存储中,bytes32只占1个槽,而bytes除了数据本身,还得额外存一个长度字段(在第一个槽),数据从下一个槽开始。你想想看,光是存个长度就多花20000 Gas。
实际测试数据:
| 操作 | bytes32 | bytes(32字节数据) |
|---|---|---|
| 存储写入 | ~20000 Gas | ~40000 Gas |
| 存储读取 | ~2900 Gas | ~5800 Gas |
所以我的建议是:能用固定长度就别用动态的。比如哈希值、地址的bytes表示、固定长度的标识符,统统用bytes32。
3.3 string vs bytes:文本数据的终极抉择
这个问题其实很简单,但很多人纠结。string和bytes在底层几乎是一样的——string就是加了UTF-8编码校验的bytes。但就是这层校验,带来了Gas差异。
我的建议:
- 如果你存的是纯文本、人类可读的内容(比如用户名、描述、备注),用string。
- 如果你存的是二进制数据、哈希、编码后的数据(比如签名、加密数据),用bytes。
为什么?因为string的操作会触发UTF-8校验。你每次拼接、截取、比较string,EVM都得检查是不是合法的UTF-8序列。这额外开销可不小。而bytes就是纯粹的字节数组,没有任何校验,操作起来快得多。
我记得有一次,一个NFT项目把图片的IPFS哈希存成了string。每次铸造都要做UTF-8校验,白白多花了几百Gas。我让他们改成bytes32(因为IPFS哈希固定46字节,其实可以用两个bytes32拼起来),Gas直接砍半。
注意:如果你非要用string,记住一点——尽量少做拼接操作。string的拼接在Solidity里会创建新的内存数组,然后复制数据,Gas消耗是O(n)的。数据一长,Gas就飞了。
// ❌ 不推荐:string拼接,Gas爆炸
function badConcat(string memory a, string memory b) public pure returns (string memory) {
return string(abi.encodePacked(a, b));
}
// ✅ 推荐:用bytes处理,省Gas
function goodConcat(bytes memory a, bytes memory b) public pure returns (bytes memory) {
return abi.encodePacked(a, b);
}
个人经验:我一般这样选——如果数据最终要展示给用户看,用string;如果只是合约内部处理或者传给其他合约,用bytes。另外,abi.encodePacked()是个好东西,它能把多个bytes/string拼在一起,而且不产生额外的内存分配,Gas很省。
3.4 总结:一张表搞定选择
| 场景 | 推荐类型 | 理由 |
|---|---|---|
| 数值变量(不打包) | uint256 | EVM原生支持,无额外开销 |
| 多个小数值(可打包) | uint8/uint16/uint32 | 打包进一个槽,省存储 |
| 固定长度数据(≤32字节) | bytes32 | 值类型,无间接寻址 |
| 动态长度数据 | bytes | 比string少UTF-8校验 |
| 人类可读文本 | string | 语义清晰,方便前端展示 |
| 二进制/编码数据 | bytes | 无校验,操作快 |
说白了,数据类型的选择就是「用最合适的工具干最合适的活」。别为了省那一点点空间,选了小类型,结果反而多花了Gas。也别为了图方便,所有数据都用string,结果每次操作都在做无意义的UTF-8校验。
嗯,这一章就到这。下一章咱们聊聊「存储布局优化」,怎么把变量排兵布阵,让Gas消耗降到最低。到时候我会分享一个我踩过的坑——因为变量顺序不对,多花了十几万Gas的惨痛经历。