← COMP5565 全部讲次
L04b · 第 4 周

L04b Solidity:在广场中央装一台谁都能按的机器

每个函数都得先回答'谁能按',每个变量都得先回答'存哪儿'。

一句话版

Solidity 逼着你把两件在普通语言里靠默认值糊弄过去的事写清楚——这段数据存在哪里,这个函数谁能按。

一个类比:在广场中央装一台谁都能按的机器

写普通程序像在自己屋里摆一台机器:门锁着,谁能进你说了算,忘了标”员工专用”也没事。写合约像把机器搬到广场正中央——每个按钮都暴露在外,你没写”这个按钮只有我能按”,全世界都会替你按一遍。所以 Solidity 从 0.5.0 起取消了可见性的默认值,不写就编译不过。

第二件事是存放位置。屋里的机器,纸条随手放桌上;广场上的机器,每写一个字都要刻进一块公共石板,而且刻字按字收费。于是 Solidity 要求你对每个引用类型说清楚:刻在石板上(storage)、写在临时便签上(memory)、还是只读一眼别人递进来的单子(calldata)。

类比在哪里失效:广场上的机器坏了能拆走重装,合约部署后代码动不了——所以这一讲花大量篇幅在“写之前就把话说死”上,可见性、数据位置、状态可变性全都要显式写出来,没有事后补救的机会。另一处:真机器上的按钮按下去要么响要么不响,合约的函数调用还有第三种结局——整笔交易回滚,你付了 gas 但什么都没发生。

概念卡

1. 三种数据位置(storage / memory / calldata)

人话定义:storage 是链上永久存储,改一次最贵;memory 只活在这次函数调用里,可以改;calldata 是外部传进来的那份原始输入,只读、最省 gas(p.14–15)。

例子:状态变量(写在合约体里、函数外面的那些)永远在 storage,这点没得选。函数里的引用类型(数组、结构体、string、bytes、映射)必须显式标注。一个很实际的规则:外部函数的引用类型参数一律先考虑 calldata,只有确实要修改它时才退回 memory 并付一次拷贝的代价。

storagememorycalldata
活多久永久,写进区块链函数执行期间本次调用期间
能改吗能能不能
gas最贵中最省
常见误解

以为值类型(uint、bool、address)也要标位置 → 不用,它们总是按值拷贝。另一个高频坑:在函数里写 Person storage p = people[0]; 拿到的是引用,改 p 就是改链上;写成 Person memory p = ... 拿到的是副本,改完不写回去等于白改。这个差别不报错,只是静默地什么都没发生。

2. 函数五段式与四种可见性(Function Declaration)

人话定义:一条完整的函数声明有五段——function 名字 (参数) 可见性 状态可变性 returns (返回类型)(p.27)。

例子:四种可见性各自能被谁调用:

  • public:合约内外都能调,自动为状态变量生成 getter
  • external:只能从外部调,合约内部想调要写 this.f()(走一次外部调用,更贵)
  • internal:本合约 + 继承它的子合约
  • private:只有本合约,子合约也不行

状态可变性三档:view 只读不写状态、pure 连读都不读(只用参数和常量)、payable 才能接收 ETH。三者都不写则默认可以改状态。

常见误解

以为 private 意味着数据保密 → 链上所有存储都是公开可读的,private 拦的只是其他合约的代码,不是人的眼睛。用区块浏览器或直接读存储槽,任何人都能看到 private 变量的值。把私钥、密码、未公开的答案放进 private 变量,等于贴在公告栏上再盖一张透明胶片。

3. 一笔钱打进来,谁来接(receive / fallback)

人话定义:receive 接住纯转账,fallback 接住所有认不出来的调用(p.37–39)。两者都必须是 external,receive 还必须 payable。

例子:以太坊上的每笔调用都带一段 calldata,分派规则完全由它决定:

receive 与 fallback 的分派决策树:一笔调用进来先看 calldata 是否为空;为空时,若合约定义了 receive 就执行 receive,没有 receive 则看有没有 payable 的 fallback,有则执行、没有则整笔交易回滚;calldata 非空时,先用前四字节的函数选择器去匹配合约里的函数,匹配成功就执行该函数,匹配失败则交给 fallback,若连 fallback 都没有定义同样回滚

记这张图只要抓一个分叉点:calldata 空不空。空的是”有人直接往这个地址转钱”,非空的是”有人想调某个函数”。

常见误解

以为定义了 fallback 就能收钱 → fallback 不写 payable 的话,带 ETH 的调用照样回滚。还有一个部署后才发现的坑:这两个函数被调用时可用的 gas 很紧(通过 transfer 或 send 转账时只给 2300 gas),在里面写状态变量基本必然 out of gas。所以它们的函数体应当极短,复杂逻辑留给正常函数。

4. 错误处理三件套(require / assert / custom error)

人话定义:Solidity 用状态回滚异常处理错误——异常一抛,这次调用及其所有子调用对状态做的改动全部撤销,交易要么完整成功、要么完全不影响状态(p.55)。

例子:三件工具各管一段:

工具管什么触发写法
require外部输入、前置条件、外部调用返回值require(cond, "msg")
assert理论上永远不该为假的内部不变量assert(cond)
custom error替代 require 的错误字符串revert Errorname();

判断口诀:require 防的是“用户做错了”,assert 防的是“我的代码写错了”。课件把开头那句 require 叫门条件(gate condition)——条件不满足直接拦住,后面的代码根本不会跑(p.56),并要求永远带上错误消息,否则钱包里只显示一句 execution reverted(p.57)。

常见误解

以为错误消息写多长都无所谓 → 字符串会被编译进字节码,越长部署越贵。具体反例:p.58 那句 require(address(this).balance >= withdraw_amount, "Insufficient balance in faucet for withdrawal request") 把 53 个字符刻进了链上;换成 revert InsufficientBalance(address(this).balance, amount) 之后只留一个 4 字节选择器,还能把两个具体数值带回给调用方。另一个过时认知:assert 失败会吃光全部 gas——0.8.0 之后它改抛 Panic(uint256),剩余 gas 照样退(p.59)。

5. 事件:合约唯一的对外喇叭(Events / indexed)

人话定义:事件把参数序列化后写进交易日志,轻客户端和 DApp 可以“watch”它并据此更新界面(p.60–61)。

例子:event Withdrawal(address indexed to, uint amount);,触发时写 emit Withdrawal(msg.sender, withdraw_amount);。一条日志分成两块:

区域存什么能过滤吗数量
topics事件签名哈希 + 所有 indexed 参数能最多 4 个
data所有非 indexed 参数不能无限制

topic[0] 固定是事件签名的 keccak256 哈希,所以 indexed 最多标 3 个(p.61)。标哪几个的判据是“这个字段会不会被当成查询条件”——课件把 to / from 标了、amount 没标,因为你会查某个地址的全部提款,不会查金额正好是 0.05 ETH 的记录。emit 自 0.5.0 起强制,p.62 的 receive() 里只做一件事:emit Deposit(msg.sender, msg.value);,不然链下根本不知道谁往合约里打了钱。

常见误解

以为合约能读回自己发过的事件 → 读不了。EVM 有写日志的操作码 LOG0–LOG4,没有读日志的操作码,日志躺在交易收据里、不在合约可访问的状态中。具体反例:想用一个事件记下“上次操作时间”再在下次调用里取出来,只能改用状态变量。第二个坑是给动态类型标 indexed:event Named(string indexed name) 的 topic 里存的是这个字符串的 keccak256 哈希,链下能按完整值精确匹配,却永远拿不回原文。

把它们串起来

这半份课件从头到尾在回答同一个问题:在一个谁都能调、改完不能撤、每步都花钱的环境里,语言该强制你说清楚哪些事。

第一组答案写在函数签名上。数据位置管“花多少钱”,可见性管“谁能碰”,view / pure / payable 管“这次调用能造成多大破坏”。三样叠起来,签名读完就知道这个函数的危险半径,不必翻开函数体。

第二组答案是兜底。receive 和 fallback 处理那些没对上号的调用——直接打进来的钱、认不出的选择器。合约地址一旦公开,别人调什么、带不带 ETH 都不由你决定,这两个函数就是“意外情况怎么收场”的显式声明。

第三组答案分对内和对外。对内是错误处理:require 挡住不合法的外部输入并退还剩余 gas,assert 守住本不该发生的内部矛盾,自定义错误把前者的成本再压低一截,三者共享同一个语义——出事就整笔回滚。对外是事件:状态改完了,链下怎么知道?合约没法主动通知任何人,只能往日志里写一条,等前端自己来订阅。

课件里的坑

  • [前后矛盾] p.28 说可见性必须显式声明,p.33 的表格却还在写”默认 public”。以 p.28 为准——函数自 0.5.0 起没有默认可见性;状态变量的默认可见性是 internal,与函数不是一回事(p.28、p.33)
  • [代码写错] p.58 出现 this.balance,这在 0.5.0 之后不合法,正确写法是 address(this).balance。同一份课件的 p.26 和 p.42 都写对了,只有这一处漏改(p.58)
  • [过时语法] 三处需要连带翻新:p.50 用 function Grandfather() 这种与合约同名的旧式构造器写法(0.5.0 起非法,且注释写 man() 与代码不符);p.40 写 constructor(uint _data) public(0.7.0 起构造器不能带可见性);p.18 / p.19 的 byte 已在 0.8.0 更名为 bytes1;p.27 / p.33 / p.34 把 constant 当函数修饰符用,它在 0.5.0 被移除、由 view 取代
  • [语义不准] p.11 把 fixed / ufixed 列为可用类型。它们只能声明,不支持任何算术运算,实际开发里定点小数一律靠整数自己按比例缩放(p.11)
  • [已随合并改名] p.25 列出 block.difficulty,以太坊转 PoS 后它已更名为 block.prevrandao,语义也从难度变成了随机数信标(p.25)

课后 10 分钟:考点复习

这 10 分钟怎么用:先默写三张表——三种数据位置的三行对比、四种可见性各自的调用范围、receive / fallback 的分派条件;再默一遍 require / assert / 自定义错误各管什么;最后做下面的变式题。三张表写得出来,这一讲的核心就拿住了。

必背

  1. 三种数据位置:storage 上链永久保存、改一次最贵;memory 函数执行期间存在、可改;calldata 只读且最省 gas,外部函数的引用类型参数首选 calldata。
  2. 函数声明五段式:function 名字(参数) 可见性 状态可变性 returns(返回类型);四种可见性 public / external / internal / private 自 0.5.0 起必须显式写出,没有默认值;view 只读、pure 连读都不读、payable 才能收 ETH。
  3. 一笔转账进来时:calldata 为空且合约有 receive 就走 receive,否则走 payable 的 fallback,两者都没有就回滚;calldata 非空则按函数选择器匹配,匹配不上走 fallback,没有 fallback 也回滚;两者都必须是 external,receive 必须 payable。
  4. 错误处理三件套:require 校验外部输入与前置条件、失败退还剩余 gas;assert 检查绝不应发生的内部不变量;自定义错误(custom error)配 revert 使用,编码开销最小,是 0.8.4 之后的推荐写法。
  5. 事件的参数最多 3 个可标 indexed,因为一笔日志只有 4 个 topic,topic[0] 已被事件签名的 keccak256 哈希占用;indexed 参数可被过滤检索,非 indexed 参数放在 data 区。

完整例题

题面(课件 p.22):给定下面这段代码,写出三个函数各自的返回值,以及状态变量 choice 在未赋值时的值。

enum ActionChoices { GoLeft, GoRight, GoStraight, SitStill }
ActionChoices choice;
ActionChoices constant defaultChoice = ActionChoices.GoStraight;

function getDefaultChoice() public pure returns (uint) {
    return uint(defaultChoice);
}
function getLargestValue() public pure returns (ActionChoices) {
    return type(ActionChoices).max;
}
function getSmallestValue() public pure returns (ActionChoices) {
    return type(ActionChoices).min;
}
  1. 先把枚举摊平成编号。枚举成员从 0 开始按声明顺序编号,与名字无关:GoLeft = 0、GoRight = 1、GoStraight = 2、SitStill = 3。
  2. getDefaultChoice():defaultChoice 是 GoStraight,uint(...) 把枚举显式转成整数,得 2。注意这个转换必须显式写,Solidity 不会替你隐式转。
  3. getLargestValue():type(枚举).max 取的是编号最大的那个成员,也就是 SitStill。返回类型声明的是 ActionChoices,所以返回的是枚举值本身,对外部调用方而言表现为整数 3。
  4. getSmallestValue():同理取编号最小的 GoLeft,对外表现为 0。
  5. choice 未赋值时:Solidity 没有 null,所有变量都有零值。枚举的零值就是编号 0 的成员,即 GoLeft。
  6. 这一步值得多想一层:零值等于第一个成员,意味着”没选”和”选了第一项”在链上完全无法区分。实际工程里的做法是把一个占位成员放在枚举开头,例如 Unset,让零值有明确含义。
  7. 三个函数都是 pure 而不是 view——它们只读常量和类型信息,没有碰任何状态变量。choice 虽然在同一份合约里,但没有被这三个函数读到。

变式题(先自己做)

(1) 如果把枚举改成 enum ActionChoices { Unset, GoLeft, GoRight, GoStraight, SitStill },三个函数的返回值各变成什么?(2) 假设这份合约已经部署、链上已存了一批用户的 choice,这次改动会造成什么后果?

提示

第一问照着例题第 1 步重新编号一遍即可,别去猜哪个函数”应该”不变。第二问想一件事:链上存的是编号还是名字?已经写进存储槽的那个数字,会因为你改了源码而跟着变吗?

参考答案与自检(非官方评分标准)

自检要点:① 重新编号时四个旧成员全部后移一位,只改一个是典型错误;② 第二问要答出”存储里是数字”这个机制,只写”会出问题”不算;③ 注意 defaultChoice 指的是名字 GoStraight,源码里这一行没改。

(1) 新编号为 Unset = 0、GoLeft = 1、GoRight = 2、GoStraight = 3、SitStill = 4。于是:

  • getDefaultChoice() 返回 3(原来是 2)
  • getLargestValue() 返回 SitStill,对外是 4(原来是 3)
  • getSmallestValue() 返回 Unset,对外是 0(成员变了,数字仍是 0)
  • choice 的零值变成 Unset,这正是加占位成员想要的效果 ✓

(2) 链上存储里存的是编号这个数字,不存成员名。原先存了 2(当时表示 GoStraight)的那个用户,在新版本里会被读成 GoRight;存了 0 的用户从 GoLeft 变成 Unset。全体历史数据整体错位一格,而且不会有任何报错。

这就是为什么”在枚举中间或开头插入成员”属于破坏性变更。安全的做法只有一条:新成员一律追加到末尾。要往前加,就必须配套一次数据迁移,或者干脆换一个新的存储槽重新开始 ✓。

闪卡自测

1. storage、memory、calldata 分别活多久、能不能改?

storage 永久存在链上、可改、最贵;memory 只活在本次函数执行期间、可改;calldata 是外部传入的原始数据、只读、最省 gas(p.14–15)。外部函数的引用类型参数首选 calldata。

2. public 和 external 的区别在哪?

public 合约内外都能直接调;external 只能从外部调,本合约内部要调必须写 this.f(),那会走一次真正的外部调用、额外花 gas。好处是 external 的参数可以用 calldata,处理大数组时更省(p.28)。

3. 为什么 private 变量并不保密?

private 限制的是其他合约的代码能不能访问它,链上存储本身对所有人公开可读。任何人用区块浏览器或直接读存储槽都能看到它的值。不要把任何敏感数据放进合约,无论标什么可见性。

4. view 和 pure 差在哪?

view 承诺不修改状态,但可以读状态变量;pure 承诺连读都不读,只用参数、常量和纯计算。两者都不消耗 gas——前提是从链下直接调用;如果是在一笔交易里被别的函数调用,照样算进这笔交易的 gas(p.30)。

5. 一笔带 calldata 的调用匹配不到任何函数会怎样?

交给 fallback。若合约没有定义 fallback,整笔交易回滚。calldata 为空的纯转账则优先找 receive,没有 receive 才退给 payable fallback(p.37–39)。

6. require、assert 和自定义错误各自用在什么地方?

require 校验外部输入和前置条件(调用者给的参数、余额够不够、时间到没到),失败时退还剩余 gas;assert 检查绝不应该发生的内部不变量,触发了说明代码里有 bug;自定义错误配 revert 使用,编码开销最小,是 0.8.4 之后的推荐写法(p.55–59)。

7. 事件的参数为什么最多只能标 3 个 indexed?

一笔日志最多有 4 个 topic,其中 topic[0] 已经被事件签名的 keccak256 哈希占用了,只剩 3 个位置给 indexed 参数。匿名事件(anonymous)不占 topic[0],所以能标 4 个。indexed 参数可被链下过滤检索,其余参数放在 data 区(p.60–63)。

8. 合约为什么需要事件?直接读状态变量不行吗?

合约没法主动通知任何人,链下的前端也无法订阅”某个变量变了”。事件写进交易收据的日志区,链下可以按 topic 订阅和历史检索。另一个实际理由是便宜——日志的存储成本远低于状态变量(p.60)。

下一讲

同一份课件还剩最难的一块:合约怎么调用另一个合约、delegatecall 凭什么能在不动外壳的情况下换掉逻辑、重入攻击从哪个缝里钻进来,以及收尾那两页最佳实践。

下一讲 →
L04c L04c 调用其他合约与安全:把家门钥匙交给一支上门的装修队

个人整理的学习笔记,不是官方材料;数字与结论以课件和讲师为准。