什么是 storage、memory 和 calldata?数据放在哪里,为何影响修改和 Gas

storage、memory 和 calldata 决定数据的生命周期、可修改性与复制方式。本文用对比和实例讲清引用、副本、Gas 与常见代码风险。

什么是 storage、memory 和 calldata?数据放在哪里,为何影响修改和 Gas

同样是一个数组,在函数参数后写 storage、memory 或 calldata,结果可能完全不同:改一个元素,有时会永久改变链上状态,有时只改临时副本,有时编译器直接不允许修改。这里的差别不是命名风格,而是数据究竟放在哪里、活多久,以及赋值时复制还是共享引用。

这三个关键词主要服务于数组、结构体、映射、bytes 和 string 等引用类型。理解它们,要同时回答三件事:生命周期多长、能否修改、与另一个变量赋值后是否指向同一份数据。

storage 是持久状态,memory 是一次调用里的临时工作区,calldata 是外部调用带来的只读输入。

storage:跟随合约长期存在

状态变量默认位于 storage,数据写入链上状态并跨交易保留。修改 storage 通常会产生较高 Gas 成本,因为全网需要对新状态达成一致并保存。映射只能存在于 storage,不能整体搬进 memory 后照常使用。

函数内写 uint[] storage ref = values; 时,ref 通常不是独立副本,而是指向已有 storage 对象的局部引用。通过 ref 修改元素、追加或删除,会直接改变 values。名字是局部的,影响却是持久的。

Solidity storage 保存链上持久状态且局部变量可能是引用
局部 storage 引用改动的是原对象,代码评审时必须追踪它指向哪里。

memory:只活在当前调用中

memory 是执行期间的临时区域,函数结束后不再保留。它适合中间计算、构造返回值或处理需要修改的临时数组。把 storage 数据赋给 memory,会创建独立副本;修改副本不会自动写回状态,除非随后明确赋值回 storage。

memory 不是“免费”。分配、扩展和复制都消耗 Gas,大数组可能显著增加执行成本。优化时应先减少不必要的数据规模和复制,而不是机械地把所有变量改成 memory。

calldata:外部调用携带的只读输入

calldata 是非持久、不可修改的数据区域,通常保存函数参数。它避免先把完整参数复制到 memory,并通过只读约束表达“函数不会改这份输入”。官方文档建议在语义允许时优先使用 calldata,因为可以避免复制。

但 calldata 不能在函数中随意新建,也不能原地修改。如果算法需要排序、过滤或重写数组,就要复制到 memory,或改为只读遍历。选择位置首先要正确表达意图,Gas 优化排在其后。

Solidity calldata 保存只读函数输入并可避免复制
calldata 适合只读输入;需要修改时再有意识地复制。

三种位置快速对比

位置生命周期可修改典型用途
storage随合约状态长期存在可以余额、配置、订单、映射
memory当前外部调用期间可以临时计算、可变副本、返回值
calldata当前调用期间不可以外部传入的只读参数

真正容易出错的是赋值语义

Solidity 官方文档说明,storage 与 memory 之间,或从 calldata 赋值到其他位置时,会产生独立复制;memory 变量之间的赋值通常只建立引用,所以修改其中一个,另一个也可能看到变化;从 storage 赋给局部 storage 变量同样通常只是引用。

赋值方向大致行为常见误解
storage → memory复制改 memory 会自动写回
memory → storage 状态变量复制并更新状态只是换了一个局部名字
storage → 局部 storage引用局部变量不会影响原数据
memory → memory常为引用两份数据完全独立
calldata → memory复制复制没有执行成本

一个简单例子为何会产生两种结果

假设状态数组 values 为 [1, 2]。函数先创建 uint[] storage a = values,再执行 a[0] = 9,交易成功后 values 会变成 [9, 2]。如果创建的是 memory 副本并把首项改成 9,values 仍然是 [1, 2],除非代码最后执行 values = copy。

阅读代码时不能只看变量名叫 temp 或 copy,要看数据位置和赋值来源。一个名为 cache 的 storage 引用照样能永久改状态,一个名为 records 的 memory 变量也可能只是临时副本。

为什么引用会制造隐蔽副作用

内部函数接收 storage 引用时,可以修改调用者传入的状态对象。函数名如果看起来只是“计算”或“检查”,这种副作用很容易在审查中被忽略。应使用清楚命名,限制修改范围,并用测试验证调用前后哪些存储槽发生变化。

为什么复制会带来 Gas 与正确性问题

把大型 storage 数组整体复制到 memory,再只读取一个元素,可能浪费 Gas;把 calldata 复制后忘记写回,又可能让开发者误以为状态已更新。反过来,为省复制而传递 storage 引用,也会扩大可修改范围。优化必须建立在明确的数据流和基准测试上。

外部、公共与内部函数怎样选择

现代 Solidity 允许在多种函数可见性中使用 memory 或 calldata 参数。只读引用类型输入通常适合 calldata;需要构造或修改临时值时用 memory;只有函数确实要操作既有链上对象时,内部或私有函数才接受 storage 引用。构造函数参数不能使用 calldata。

还要知道 transient storage

较新的 Solidity 还支持 transient 状态变量:它像键值存储,但只在当前交易内存在,交易结束后清零,常见用途是可组合的重入锁。它不是 memory,也不是引用类型可随意使用的第四种通用位置。官方文档目前说明,引用类型尚不能以 transient 作为数据位置,且使用时要注意多次调用组合产生的状态残留。

开发者检查清单

  • 每个引用类型变量的数据位置是否明确?
  • 局部 storage 变量指向哪个状态对象?
  • memory 之间赋值后是否共享引用?
  • calldata 是否被无必要地整体复制?
  • 临时修改是否真的需要写回 storage?
  • 大数组操作是否做过 Gas 和上限测试?
  • 内部函数的状态副作用是否从命名和接口可见?

普通用户为什么也值得了解

用户不需要读懂每个存储槽,但可以理解两类审计提示:“错误使用 storage 引用”可能导致永久状态被意外修改;“无界复制或遍历”可能让交易因 Gas 不足而无法执行。看到项目宣称“已优化 Gas”,也应关注功能和安全是否保持一致,而不是只看单次费用数字。

官方资料

本文的数据位置与赋值规则依据 Solidity Types:Data Location;transient 状态说明参考 Solidity Contracts:Transient Storage。具体语法与限制请以项目编译器版本对应文档为准。

storage、memory 和 calldata 的选择,本质上是在声明数据的所有权、生命周期与修改权限。先判断数据需不需要持久化、需不需要修改,再确认赋值是复制还是引用,绝大多数困惑就能沿着这条线解开。


本文为链上指南原创科普内容,仅用于解释智能合约机制,不构成任何投资建议。智能合约开发和链上交互均有技术风险,请结合锁定版本的官方文档、测试与独立审计。