导语:为"资产"而非"状态"设计的语言
Solidity 把链上一切都建模为状态变量:一个 mapping(address => uint256) balances 就代表所有余额。这种建模直观,但代价是资产只是一行整数,编译器无法阻止你把余额复制一份、或者忘记把旧余额清零。历史上大量重入、双花、精度丢失漏洞都源于此。
Move 走的是另一条路:把资产建模为资源(Resource)——一种受线性类型系统约束的值,不可复制、不可隐式丢弃。本文回答三个问题:Move 的资源语义到底强在哪里、Sui 与 Aptos 如何在同一个语言之上构建出完全不同的存储与执行模型、以及这套体系相对 EVM 的工程代价。
一句话总结:Move 把"资产守恒"从运行时检查提升为编译期与字节码验证期的强制约束,这是它与 EVM 系语言最本质的分野。
1. Move 的核心:资源类型与线性语义
1.1 资源不可复制、不可丢弃
Move 中没有"余额数字"这种概念,只有一枚真正被持有的 Coin 结构体。它默认不具备 copy 与 drop 能力,因此编译器会拒绝下面这类写法:
struct Coin has store {
value: u64,
}
public fun duplicate(c: &Coin): Coin {
// 编译错误:Coin 不具备 copy ability
// 无法构造出第二个 Coin
*c
}
public fun destroy(c: Coin) {
// 编译错误:Coin 不具备 drop ability
// 参数 c 必须在函数结束时被显式消耗(move 到别处)
}
要"花掉"一枚币,唯一合法路径是**解构(destructure)**它,把内部的 value 取出,并生成一枚新的余额或转账对象:
public fun split(c: Coin, amount: u64): (Coin, Coin) {
let Coin { value } = c; // 解构,c 被消耗
let part = Coin { value: amount };
let rest = Coin { value: value - amount };
(part, rest)
}
这段代码的关键在于:value - amount 会因下溢而 abort,c 被解构后不再存在,没有任何路径能凭空多出一枚币。资产守恒从"程序员要小心"变成了"编译器不允许出错"。
对比一下 Solidity 里最常见的余额写法,问题就一目了然:
// Solidity:编译器完全不知道 balances 代表资产
mapping(address => uint256) public balances;
function transfer(address to, uint256 amount) external {
balances[msg.sender] -= amount; // 若顺序写反,重入即可双花
balances[to] += amount;
}
这里的 balances 只是一个普通的整数映射。编译器不会阻止你写 uint256 copy = balances[msg.sender] 然后凭空造出等量的"资产",也不会提醒你漏掉清零。安全性完全依赖开发者纪律与审计,而 Move 把这条纪律内建为类型规则。
1.2 abilities 四件套
Move 用一个精简的 ability 集合控制类型的行为,理解它们就理解了语言的约束边界:
| ability | 含义 | 缺失时的后果 |
|---|---|---|
copy | 值可被复制 | 该类型的值只能被移动,无法克隆 |
drop | 值可被隐式丢弃 | 作用域结束时必须被显式消耗 |
store | 值可存入全局存储(字段) | 不能作为结构体字段或存入账户 |
key | 值可作为全局存储的顶层对象 | 不能成为账户下的独立资源 |
has key 标记的是"可被全局存储索引"的类型,has store 标记的是"可作为字段嵌套"的类型。一个典型的可转让资产需要 has key, store,而一个只能被销毁的凭证只需 has drop。这套组合让"这枚币能不能被放进另一个结构体"变成静态可判定的事实。
1.3 引用与可变性同样受控
Move 的引用规则借鉴自 Rust 的所有权体系:同一时刻要么存在一个可变引用 &mut T,要么存在任意多个不可变引用 &T,二者不可共存。这意味着函数内部不可能在持有 &mut 的同时通过另一条路径读到同一个资源,从而在语言层面消解了 EVM 里典型的"读-改-写"竞态:
public fun bump(counter: &mut Counter) {
let r1 = &mut counter.value; // 可变借用
let r2 = &counter.value; // 编译错误:与 r1 冲突
*r1 = *r1 + 1;
}
2. Sui 的对象模型与所有权
2.1 Object 为中心
Sui 把 Move 的全局存储换成了对象(Object)模型。每个对象是一个带唯一 UID 的结构体,拥有自己的版本号与所有权归属。合约不再通过 mapping 查询状态,而是接收对象作为输入:
public struct Sword has key, store {
id: UID,
power: u64,
}
public fun attack(sword: &mut Sword, target: &mut Monster, ctx: &mut TxContext) {
target.hp = target.hp - sword.power;
// 需要创建新对象时用 object::new(ctx)
}
对象天然带版本号(version),每次被修改版本加一。这为乐观并发控制提供了基础:交易只需声明它读写哪些对象及其版本,冲突检测就是版本比对。
2.2 四种所有权
Sui 的所有权分类直接决定了交易能否并行执行:
| 所有权类型 | 含义 | 执行方式 |
|---|---|---|
| Owned by address | 归单个地址所有 | 可并行(不冲突即可同时执行) |
| Owned by object | 被另一个对象持有 | 跟随持有者 |
| Shared | 共享对象,人人可写 | 需共识排序,串行 |
| Immutable | 不可变,只读 | 完全并行 |
2.3 简单交易走"快路径"
这是 Sui 性能主张的技术根源:如果一笔交易只触及地址拥有的对象,它不需要经过全链共识,只需所有者签名即可在本地验证后执行。只有涉及 Shared Object 的交易才需要走 Narwhal/Bullshark 共识排序。因此 Sui 的 TPS 上限取决于"共享对象争用比例",而非全网节点数。
# 查看对象所有权与版本
sui client object <OBJECT_ID> --json | jq '.owner, .version'
设计启示很直接:把高频写入的状态尽量放在用户私有对象里,而不是共享对象里。例如游戏道具、NFT、代币余额都可以做成 owned object;只有订单簿、AMM 池这类需要多方共同写入的状态才必须共享。
2.4 动态字段与集合
对象不能无限增长,因此 Sui 提供了**动态字段(Dynamic Fields)**把子数据挂在对象之下,避免整体读写:
use sui::dynamic_field as df;
public fun add_attr(obj: &mut Sword, key: vector<u8>, val: u64) {
df::add(&mut obj.id, key, val); // 挂载
}
public fun read_attr(obj: &Sword, key: vector<u8>): u64 {
*df::borrow(&obj.id, key) // 读取
}
动态字段只在实际访问时加载,因此一个持有十万件道具的对象仍能保持低读写成本。这是 Sui 对象模型在"大状态"场景下的关键补丁。
2.5 生态与典型项目
Sui 上较有代表性的项目集中在游戏、社交与支付方向,因为它们最能吃到"低延迟 + 私有对象并行"的红利;Aptos 则更偏向 DeFi 与基础设施,因为账户式存储与 EVM 迁移的映射关系更直接。两者都支持赞助交易(Sponsored Transaction),即由第三方代付 gas,这是面向普通用户做免 gas 体验的前提,也常与账户抽象思路配合使用。
// Sui 赞助交易的关键:gas 由 sponsor 支付
// 交易构造时显式指定 gas_owner = sponsor 地址
public fun sponsored_entry(ctx: &mut TxContext) {
assert!(ctx.sender() != @0x0, EInvalidSender);
}
3. Aptos 的账户式模型与 MoveVM
3.1 全局存储仍是"账户 + 资源"
Aptos 保留了 Move 原生的全局存储语义:每个账户地址下挂着一组资源(Resource)。资源用 move_to / move_from 搬进搬出,所有权由 signer 保障:
module 0x1::coin {
public entry fun transfer<CoinType>(
from: &signer, to: address, amount: u64
) acquires Balance {
let from_addr = signer::address_of(from);
let from_bal = borrow_global_mut<Balance<CoinType>>(from_addr);
let to_bal = borrow_global_mut<Balance<CoinType>>(to);
from_bal.value = from_bal.value - amount;
to_bal.value = to_bal.value + amount;
}
}
注意 acquires Balance 子句:Move 要求函数显式声明它访问了哪些全局资源,编译器据此做别名分析。这与 EVM 中"任意 SLOAD“的自由度形成鲜明对比,也是 Aptos 能安全并行化的前提。
3.2 Block-STM 并行执行
Aptos 的执行引擎叫 Block-STM:它乐观地假定交易互不冲突并并行执行,执行时记录每个交易读写的内存位置(类似数据库的 MVCC)。若检测到冲突(后写的交易读到了旧值),就回滚并按依赖顺序重放。
| 阶段 | 动作 | 失败处理 |
|---|---|---|
| 乐观执行 | 多线程并行跑交易 | — |
| 冲突检测 | 比对读写集与版本 | 标记冲突交易 |
| 重放 | 按依赖顺序重跑 | 直到无冲突 |
| 提交 | 写入状态并出块 | — |
官方在 32 核机器上测得的加速比约为 8~16 倍,取决于交易图的冲突密度。关键洞察:Move 的 acquires 声明让冲突检测有了静态依据,这是"并行安全"能落到工程上的原因,而不只是理论主张。
3.3 存储与模块升级
Aptos 的模块(Module)一旦发布默认不可变,升级需要通过 aptos_framework::code 的治理路径显式发布新版本,且新旧版本共存直到被 compatible 检查放行。这与 EVM 里靠 delegatecall 代理模式实现的"可升级合约"是完全不同的哲学——升级是例外而非常态。
4. 字节码验证器与安全边界
Move 源码编译后还要过一道字节码验证器(Bytecode Verifier),这是 EVM 完全没有的环节。它在上链前静态检查:
| 验证项 | 拦截的问题 |
|---|---|
| 类型安全 | 类型混淆、非法 downcast |
| 资源安全 | 资源被复制或悬空引用 |
| 引用安全 | 悬垂引用、越界索引 |
| 能力检查 | 非法获取未声明的 ability |
| 栈平衡 | 栈溢出 / 下溢 |
| 全局存储 | 访问未在 acquires 中声明的资源 |
通过验证器的模块才能发布,因此 Move 的运行时不需要像 EVM 那样在每次 CALL 时重新校验字节码。代价是验证器本身复杂、编译期更长,且某些"动态派发"的表达被限制。例如 Move 不支持任意函数指针跳转,泛型是编译期单态化而非运行时多态——这既换来了可验证性,也限制了某些元编程技巧。
5. 工具链与形式化验证
5.1 常用命令
# Sui
sui move build # 编译
sui move test # 运行单元测试
sui move coverage # 覆盖率
# Aptos
aptos move compile
aptos move test --coverage
aptos move prove # 调用 Move Prover
5.2 Move Prover:规格即证明
Move 的杀手锏是内置的 Move Prover,可以直接在源码里写规格(specification),由 SMT 求解器证明其恒成立:
spec module {
// 不变量:所有余额之和守恒
invariant forall addr: address where exists<Balance>(addr):
global<Balance>(addr).value >= 0;
}
spec fun transfer {
aborts_if from_bal.value < amount;
ensures global<Balance>(to).value == old(global<Balance>(to).value) + amount;
}
这类规格在 Aptos 框架的 coin、staking 等核心模块中是强制要求的。它把"资产守恒"从单元测试的抽样验证,升级为对所有输入的数学证明。
5.3 测试框架的差异
Sui 用 #[test] 注解加 #[expected_failure] 断言失败路径,并且能直接构造 TxContext 来模拟交易上下文;Aptos 用 #[test] 配合 #[expected_failure(abort_code = ...)] 精确匹配 abort 码。两者都支持覆盖率报告,但都不像 EVM 生态那样有 forge fuzz 这类成熟的属性测试工具,模糊测试更多要靠自建脚本。
5.4 部署与升级流程
以 Sui 为例,一次完整的发布流程如下:
# 1. 编译并生成字节码与依赖
sui move build
# 2. 发布模块(首次)
sui client publish --gas-budget 100000000
# 3. 后续升级:先拿到 UpgradeCap 对象 ID
sui client call \
--package <UPGRADE_CAP_PKG> \
--module package \
--function authorize_upgrade \
--args <UPGRADE_CAP_ID> <DIGEST>
# 4. 提交新版本
sui client upgrade --upgrade-capability <UPGRADE_CAP_ID>
升级策略有两种:兼容升级(只增不改)与不兼容升级(需 UpgradeCap 的 only_additive 策略被显式放开)。生产环境强烈建议保留兼容升级,否则已发布的结构体布局变化会让存量对象不可读。
6. Move 与 Solidity 的工程对比
| 维度 | Move(Sui/Aptos) | Solidity(EVM) |
|---|---|---|
| 资产建模 | 线性资源,类型层防复制 | 状态变量,靠代码自觉 |
| 并行执行 | 原生支持(对象/acquires) | 单线程,靠 L2 扩容 |
| 静态验证 | 字节码验证器 + Move Prover | 仅编译器检查 |
| 生态成熟度 | 新,工具与审计经验较少 | 成熟,审计与工具链完善 |
| 学习曲线 | 陡(资源/ability 需重塑思维) | 平缓(类 JS/TS 语法) |
| 升级模型 | 模块不可变 + 治理升级 | 代理模式(delegatecall) |
| 代币标准 | 泛型 Coin<T> / FungibleAsset | ERC-20 / ERC-721 等固定接口 |
实践建议:新资产协议优先考虑 Move,尤其是需要严格资产守恒的场景(支付、托管、稳定币);而需要复用庞大 EVM 生态与既有审计经验的项目,Solidity 仍是更务实的选择。若团队已熟悉 Rust 的所有权思维,Move 的迁移成本会显著低于预期,可参考 Rust 核心概念 快速建立心智模型。
需要提醒的是,Move 的"安全性"并非万无一失:它挡住的是资产复制类漏洞,挡不住逻辑类漏洞(比如错误的利率公式、访问控制缺失)。因此审计仍然必要,只是审计的注意力可以从"会不会双花"转移到"业务逻辑对不对”。
小结
Move 的价值不在于"更快的语言",而在于把资产安全变成类型系统的责任:copy/drop 缺失、acquires 声明、字节码验证器、Move Prover 四层防线,让"复制资产"这类漏洞在编译期就无处藏身。Sui 与 Aptos 的差异则是同一语言下的架构选择——Sui 用对象所有权换取并行与低延迟,Aptos 用 Block-STM 在账户模型上做乐观并行。理解这条主线,再看具体的 Move 合约代码就不会迷失在语法细节里。
对于需要跨链资产流转的场景,Move 链通常通过桥接与跨链互操作方案 连接,而账户与权限设计可对照账户抽象 一节理解其与 EVM 系的差异。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。