4、内存与存储:memory、storage、calldata三者的Gas成本对比

聊到Solidity的Gas优化,有个话题绕不开——数据存储位置的选择。说白了,就是你的变量放哪。放错了地方,Gas可能翻好几倍。我见过不少新手合约,明明可以用calldata的地方非要用memory,结果部署上去跑一次交易,Gas高得吓人。

今天咱们就把memory、storage、calldata这三个家伙彻底掰扯清楚。什么时候用谁,为什么用,Gas差多少,我都会结合我踩过的坑来讲。

先看Gas成本对比

直接上数据,这是我个人测试过的结果(基于Solidity 0.8.x,EVM版本柏林以后):

操作 Gas消耗(近似值) 说明
从storage读取(SLOAD) 2100(首次) / 100(重复) 冷读取2100,热读取100
写入storage(SSTORE) 20000 ~ 22100 从0写到非0最贵
从memory读取(MLOAD) 3 几乎可以忽略
写入memory(MSTORE) 3 同样极低
从calldata读取(CALLDATALOAD) 3 ~ 16 取决于偏移量,但远低于SLOAD

看到差距了吗?一次storage读取,够你在memory里读写几百次了。所以优化Gas的第一条铁律就是:能不碰storage就别碰

storage:能少用就少用

storage是永久存储,数据会留在链上。每次读写都贵得离谱。我早期做过一个NFT项目,合约里有个循环,每次迭代都去读storage里的数组长度。结果部署后用户mint一次要花0.05 ETH的Gas,直接被骂惨了。

后来怎么改的?把数组长度先读到memory里,循环里用memory变量。Gas直接降了60%。

核心原则:storage变量只在需要持久化的时候才写。临时计算、循环遍历,一律用memory或calldata。

memory:临时工作台

memory就像你的工作台。函数执行期间数据放这,执行完就清空。读写成本极低,但有个限制——不能用在外部函数的参数里(除了string和bytes这种动态类型)。

我个人习惯是:只要函数内部需要修改数据,或者需要复制一份数据来做计算,就用memory。比如你从storage读了一个结构体,想在函数里改几个字段再写回去,那就先读到memory里改,改完一次性写回storage。千万别在storage上直接改来改去,那Gas会飞。

// ❌ 错误做法:直接在storage上操作
function badUpdate(uint256 id) external {
    MyStruct storage s = myStructs[id];
    s.value1 = 1;
    s.value2 = 2;
    s.value3 = 3;
    // 每次赋值都是一次SSTORE,贵!
}

// ✅ 正确做法:读到memory,改完一次性写回
function goodUpdate(uint256 id) external {
    MyStruct memory s = myStructs[id];
    s.value1 = 1;
    s.value2 = 2;
    s.value3 = 3;
    myStructs[id] = s; // 只有一次SSTORE
}

calldata:只读且免费(几乎)

calldata是交易输入数据所在的位置。它是只读的,而且读取成本比memory还低一点。最关键的是——calldata不需要复制数据。你想想看,如果你传一个很大的数组进来,用memory的话,EVM会把这个数组从calldata复制到memory里,这要花Gas。但如果你直接用calldata,就省掉了这次复制。

什么时候用calldata?只要你的函数参数是外部传入的,而且你不需要修改它,就用calldata。

我的经验:所有external函数的数组参数、结构体参数,只要不修改,一律用calldata。这是零成本的优化,改一下关键字就行。

// ❌ 浪费Gas
function process(uint256[] memory data) external {
    uint256 sum = 0;
    for (uint256 i = 0; i < data.length; i++) {
        sum += data[i];
    }
}

// ✅ 省Gas
function process(uint256[] calldata data) external {
    uint256 sum = 0;
    for (uint256 i = 0; i < data.length; i++) {
        sum += data[i];
    }
}

注意:calldata不能用在internal函数里,也不能修改。如果你需要在函数内部修改传入的数据,那就只能用memory。

避坑指南:我曾经犯过的错

我曾经写过一个批量转账合约,参数是个地址数组。我图省事用了memory,结果用户传100个地址的时候,Gas高到离谱。后来改成calldata,Gas直接降了30%。

还有一次,我在一个循环里反复读取storage mapping里的值。每次循环都触发一次SLOAD。后来我把mapping的值先读到memory变量里,循环里直接用memory,Gas优化效果立竿见影。

重要提醒:storage的冷读取(第一次读)要2100 Gas,热读取(同一笔交易内第二次读)只要100 Gas。所以如果你在同一个函数里多次读取同一个storage变量,第二次开始会便宜很多。但即便如此,也比memory贵几十倍。

总结一下选择策略

  • 需要永久保存? → storage,但尽量减少读写次数
  • 临时计算、需要修改? → memory
  • 外部传入、只读不写? → calldata(首选)
  • 循环里频繁读取? → 先读到memory里
  • 结构体批量修改? → 读到memory改完再写回storage

嗯,说白了就是一句话:能放calldata不放memory,能放memory不放storage。这个原则你记住了,Gas优化就成功了一半。

下一章咱们聊聊更进阶的——如何利用EVM的存储布局来进一步压榨Gas。到时候我会分享一个我实际项目中用到的骚操作,把存储读写成本降到了极限。