The Move Book - 中文版
这是 Move 书 - 一本全面介绍 Move 编程语言和 Sui 区块链的指南。该书面向对 Move 感兴趣并希望在 Sui 上进行开发的开发者。
本书仍在积极开发中,尚未完成。如果您有任何反馈或建议,请随时在 GitHub 仓库 上打开问题或提交拉取请求。
如果您在寻找 Move 参考资料,可以在 这里 找到。
相关链接:
- 原书链接:The Move Book
- 原书仓库:move-book
- 中文版仓库: move-book cn
前言
这本书专注于 Move,一种专注于安全编程本质的智能合约语言,专为数字资产而生。Move 基于以下价值观而设计:
-
默认安全: 不安全的语言对智能合约开发和主流数字资产是一个严重障碍。智能合约语言的首要任务是在设计之时防止尽可能多的潜在安全问题(例如重入、缺少访问控制检查、算术溢出等)。因此任何对 Move 语言的更改都应保留或增强其现有的安全保证。
-
自然表达: Move 必须使程序员能够编写他们能想到的任何智能合约。我们不仅关心 Move 能做什么,还关心编写 Move 的感觉如何 —— 语言应该足够丰富,以确保完成任务所需的功能是可用的,同时又足够简洁,使得选择功能时显而易见。Move工具链应成为提高生产力的助手和思考的伙伴。
-
直观易用: 智能合约只是一个有用应用的一部分。Move 语言设计时应该理解其使用的更广泛背景,并在设计时兼顾智能合约开发者和应用开发者。开发者应该能够轻松学习如何读取 Move 管理的状态、构建由 Move 驱动的交易以及编写新的 Move 代码。
Move 的核心技术要素包括:
- 通过可编程 对象 (object) 提供安全、熟悉且灵活的数字资产抽象。
- 丰富的 能力 (ability) 系统(受线性类型启发),赋予程序员极大的控制权,决定值的创建、销毁、存储、复制和转移方式。
- 具有强封装特性的 模块 (module) 系统,支持代码重用,同时保持权限控制。
- 用于创建对象之间层次关系的 动态字段 (dynamic field)。
- 可编程交易块 (PTB),支持在客户端原子性地组合Move驱动的API。
Move 于 2018 年在 Facebook 的 Libra 项目中诞生,并于 2019 年公开亮相,首个 Move 驱动的网络于 2020 年上线。截至 2024 年 4 月,已有多个 Move 驱动的链在生产中运行,还有更多链在开发中。Move 是一种嵌入式语言,具有平台无关的核心,这意味着它在使用它的每条链中都具有略微不同的特性。
创建一种新的编程语言并围绕它建立一个社区是一个雄心勃勃的长期项目。它必须在某些方面比竞争对手好一个数量级才有机会,但同时,社区的质量比技术基础更重要。Move 是一种年轻的语言,但在差异化和社区方面起步良好。一个小但是充满热情的智能合约程序员和核心贡献者团队,他们共同秉承Move的价值观,正不断拓展智能合约的潜能、它们所能激活的应用范围,以及谁能够(安全地)编写这些合约的边界。如果这激发了你的热情,不妨继续深入阅读!
—— Sam Blackshear,Move 创造者
开始之前
Move 需要一个运行和开发应用程序的环境,在这一小节中,我们将涵盖 Move 语言的先决条件:如何设置你的 IDE,如何安装编译器以及什么是 Move 2024 版本。如果你已经熟悉这些主题或已经安装了 CLI,可以跳过本章,直接进入下一章。
安装 Sui
Move 是一种编译型语言,因此你需要安装一个编译器来编写和运行 Move 程序。编译器包含在 Sui 二进制文件中,可以通过以下方法之一进行安装或下载。
下载二进制文件
你可以从 发布页面 下载最新的 Sui 二进制文件。该二进制文件适用于 macOS、Linux 和 Windows。对于教育目的和开发,我们推荐使用 mainnet
版本。
使用 Homebrew 安装 (MacOS)
你可以使用 Homebrew 包管理器安装 Sui。
brew install sui
使用 Chocolatey 安装 (Windows)
你可以使用 Windows 的 Chocolatey 包管理器安装 Sui。
choco install sui
使用 Cargo 构建安装 (MacOS, Linux)
你可以使用 Cargo 包管理器本地安装和构建 Sui(需要 Rust)
cargo install --git https://github.com/MystenLabs/sui.git --bin sui --branch mainnet
确保你的系统有最新版本的 Rust,可以使用以下命令更新。
rustup update stable
故障排除
有关安装过程的故障排除,请参考 安装 Sui 指南。
设置你的 IDE
目前最流行的 Move 开发 IDE 有两个:VSCode 和 IntelliJ IDEA。它们都提供基本功能,如语法高亮和错误消息,但在附加功能上有所不同。无论你选择哪个 IDE,都需要使用终端运行 Move CLI。
IntelliJ 插件不支持 Move 2024 版,某些语法不会被高亮显示。
VSCode
- VSCode 是微软推出的一款免费开源 IDE。
- Move (Extension) 是由 MystenLabs 维护的 Move 语言服务器扩展。
- Move Syntax 是由 Damir Shamanaev 开发的简单语法高亮扩展。
IntelliJ IDEA
- IntelliJ IDEA 是 JetBrains 推出的一款商业 IDE。
- Move Language Plugin 是由 Pontem Network 开发的 Move 语言扩展。
Emacs
- Emacs 是一款免费开源的文本编辑器。
- move-mode 是由 Ashok Menon 开发的 Emacs Move 模式。
Github Codespaces
Github 推出的基于 Web 的 IDE,可以直接在浏览器中运行,提供几乎完整的 VSCode 体验。
- Github Codespaces
- Move Syntax 也可以在扩展市场中找到。
Move 2024
Move 2024 是由 Mysten Labs 维护的新版 Move 语言。本书中的所有示例均使用 Move 2024 编写。如果你习惯了 2024 年之前的 Move 版本,请参考 Move 2024 迁移指南 了解新版中的变化和改进。
你好,世界!
在本章中,您将学习如何创建一个新的包,编写一个简单的模块,进行编译,并使用Move CLI运行测试。请确保您已经安装了Sui并设置了 IDE 环境。运行以下命令来测试 Sui 是否正确安装。
# 它应该打印出客户端版本号。例如:sui-client 1.22.0-036299745。
sui client --version
Move CLI 是 Move 语言的命令行界面;它内置于 Sui 二进制文件中,提供了一组命令来管理包、编译和测试代码。
本章的结构如下:
创建一个新的包
要创建一个新的程序,我们将使用 sui move new
命令,后面跟上应用程序的名称。我们的第一个程序将被命名为 hello_world
。
注意:在本章和其他章节中,如果您看到以
$
(美元符号)开头的代码块,表示应该在终端中运行以下命令。不要包含这个符号。这是一种在终端环境中显示命令的常见方式。
$ sui move new hello_world
sui move
命令可以访问 Move CLI,它是一个内置的编译器、测试运行器和用于处理 Move 的实用工具。new
命令后面跟上包的名称将在新的文件夹中创建一个新的包。在我们的例子中,文件夹的名称是"hello_world"。
我们可以查看文件夹的内容,以确认包已成功创建。
$ ls -l hello_world
Move.toml
sources
tests
目录结构
Move CLI将创建应用程序的基本结构,并预先创建目录结构和所有必要的文件。让我们看看里面的内容。
hello_world
├── Move.toml
├── sources
│ └── hello_world.move
└── tests
└── hello_world_tests.move
Manifest
Move.toml
文件被称为包清单,它包含包的定义和配置设置。它被Move编译器用来管理包的元数据、获取依赖项和注册命名地址。我们将在概念章节中详细解释它。
默认情况下,包具有一个以包名称命名的地址。
[addresses]
hello_world = "0x0"
源代码
sources/
目录包含源文件。Move源文件的扩展名是.move
,通常以文件中定义的模块命名。例如,在我们的例子中,文件名是 hello_world.move
,Move CLI 已经在其中放置了注释的代码:
/*
/// 模块:hello_world
module hello_world::hello_world {
}
*/
/*
和*/
是 Move 中的注释符。它们之间的所有内容都会被编译器忽略,可用于文档或笔记。我们在 基本语法 中解释了所有注释代码的方式。
注释掉的代码是一个模块定义,它以关键字 module
开头,后面是命名地址(或地址字面量),然后是模块名称。模块名称是模块在包中的唯一标识符,并且在包内必须是唯一的。模块名称用于从其他模块或交易中引用该模块。
测试代码
tests/
目录包含包测试。编译器在常规构建过程中将排除这些文件,但在测试和开发模式下使用它们。这些测试用 Move 语言编写,并标有 #[test]
属性。测试可以分组在单独的模块中(通常命名为 模块名_tests.move),或者放在它们所测试的模块内部。
模块、导入、常量和函数可以用 #[test_only]
注解。这个属性用于在构建过程中排除模块、函数或导入。当你想为测试添加辅助功能,但不希望将它们包含在将发布到链上的代码中时将大有用处。
hello_world_tests.move 文件包含一个被注释掉的测试模块模板:
/*
#[test_only]
module hello_world::hello_world_tests {
// uncomment this line to import the module
// use hello_world::hello_world;
const ENotImplemented: u64 = 0;
#[test]
fun test_hello_world() {
// pass
}
#[test, expected_failure(abort_code = hello_world::hello_world_tests::ENotImplemented)]
fun test_hello_world_fail() {
abort ENotImplemented
}
}
*/
其他文件夹
此外,Move CLI 支持 examples/
文件夹。该文件夹中的文件处理方式与放置在 tests/
文件夹下的文件类似 - 它们只在测试和开发模式下被构建。这些文件旨在展示如何使用该包或如何将其与其他包集成。最常见的用例是用于文档目的和库包。
编译包
Move 是一种编译型语言,因此它需要将源文件编译成 Move 字节码。字节码只包含关于模块、其成员和类型的必要信息,同时排除了注释和某些标识符(例如,常量的标识符)。
为了演示这些特性,让我们用以下内容替换 sources/hello_world.move 文件的内容:
/// 命名地址 `hello_world` 下的 `hello_world` 模块。
/// 命名地址在 `Move.toml` 中设置。
module hello_world::hello_world {
// 从标准库导入 `String` 类型
use std::string::String;
/// 返回 "Hello, World!" 作为 `String`。
public fun hello_world(): String {
b"Hello, World!".to_string()
}
}
在编译过程中,代码被构建但不会运行。编译后的包只包含可以被其他模块调用或在交易中使用的函数。我们将在 概念 (concepts) 章节中解释这些概念。现在,让我们看看运行 sui move build 时会发生什么。
# 在 `hello_world` 文件夹中运行
$ sui move build
# 或者,如果你处于该文件夹之外
$ sui move build --path hello_world
它应该在你的控制台输出以下消息:
UPDATING GIT DEPENDENCY https://github.com/MystenLabs/sui.git
INCLUDING DEPENDENCY Sui
INCLUDING DEPENDENCY MoveStdlib
BUILDING hello_world
在编译过程中,Move 编译器会自动创建一个 build 文件夹,其中放置所有获取和编译的依赖项,以及当前包的模块的字节码。
如果你使用版本控制系统(如 Git),build 文件夹应该被忽略。例如,你应该使用
.gitignore
文件并在其中添加build
。
运行测试
在开始测试之前,我们应该添加一个测试。Move 编译器支持用 Move 编写的测试,并提供执行环境。测试可以放在源文件中,也可以放在 tests/
文件夹中。测试用 #[test]
属性标记,编译器会自动发现它们。我们将在 测试 部分深入解释测试。
请用以下内容替换 tests/hello_world_tests.move
:
#[test_only]
module hello_world::hello_world_tests {
use hello_world::hello_world;
#[test]
fun test_hello_world() {
assert!(hello_world::hello_world() == b"Hello, World!".to_string(), 0);
}
}
这里我们导入 hello_world
模块,并调用其 hello_world
函数来测试输出是否为字符串 "Hello, World!"。现在我们已经准备好了测试,让我们在测试模式下编译并运行测试。Move CLI 有 test
命令用于此目的:
$ sui move test
将有以下内容输出:
INCLUDING DEPENDENCY Sui
INCLUDING DEPENDENCY MoveStdlib
BUILDING hello_world
Running Move unit tests
[ PASS ] 0x0::hello_world_tests::test_hello_world
Test result: OK. Total tests: 1; passed: 1; failed: 0
如果你在包文件夹外运行测试,可以指定包的路径:
$ sui move test --path hello_world
你还可以通过指定一个字符串来一次运行单个或多个测试。所有包含该字符串的测试都将被运行:
$ sui move test test_hello
下一步
在本节中,我们解释了 Move 包的基础知识:结构、清单文件、构建和测试流程。在下一节中,我们将编写一个应用程序,了解 Move 代码如何构建以及这门语言能做什么。
进一步阅读
- Manifest 部分
- The Move Reference 中的包相关内容
你好,Sui!
在上一节中,我们创建了一个新的包并演示了创建、构建和测试一个 Move 包的基本流程。在本节中,我们将编写一个简单的应用程序,该应用程序使用存储模型并可以进行交互。为此,我们将创建一个简单的待办事项列表应用程序。
创建一个新的包
按照与Hello, World!相同的流程,我们将创建一个名为 todo_list
的新包。
$ sui move new todo_list
添加代码
为了加快速度并专注于应用程序逻辑,我们将提供待办事项列表应用程序的代码。将 sources/todo_list.move 文件的内容替换为以下代码:
注意:虽然内容一开始可能会显得有些复杂,我们将在接下来的部分逐步解释。现在请专注于手头的任务。
/// Module: todo_list
module todo_list::todo_list {
use std::string::String;
/// List of todos. Can be managed by the owner and shared with others.
public struct TodoList has key, store {
id: UID,
items: vector<String>
}
/// Create a new todo list.
public fun new(ctx: &mut TxContext): TodoList {
let list = TodoList {
id: object::new(ctx),
items: vector[]
};
(list)
}
/// Add a new todo item to the list.
public fun add(list: &mut TodoList, item: String) {
list.items.push_back(item);
}
/// Remove a todo item from the list by index.
public fun remove(list: &mut TodoList, index: u64): String {
list.items.remove(index)
}
/// Delete the list and the capability to manage it.
public fun delete(list: TodoList) {
let TodoList { id, items: _ } = list;
id.delete();
}
/// Get the number of items in the list.
public fun length(list: &TodoList): u64 {
list.items.length()
}
}
构建包
为了确保一切操作正确无误,请运行 sui move build
命令来构建包。如果一切顺利,你应该看到类似以下的输出:
$ sui move build
UPDATING GIT DEPENDENCY https://github.com/MystenLabs/sui.git
INCLUDING DEPENDENCY Sui
INCLUDING DEPENDENCY MoveStdlib
BUILDING todo_list
如果没有错误,那么说明你已经成功构建了该包。如果有错误,请确保:
- 代码复制正确
- 文件名和包名正确
在这个阶段,代码失败的原因不多。但如果仍然遇到问题,请尝试查看包的结构,位置在 这里。
设置账户
为了发布和与包交互,我们需要设置一个账户。为了简单和演示的目的,我们将使用 sui devnet 环境。
如果你已经设置了账户,请跳过此步骤。
如果你是第一次设置账户,需要运行 sui client
命令,然后 CLI 将提示你回答多个问题。下面是答案示例,以 >
开头:
$ sui client
Config file ["/path/to/home/.sui/sui_config/client.yaml"] doesn't exist, do you want to connect to a Sui Full node server [y/N]?
> y
Sui Full node server URL (Defaults to Sui Testnet if not specified) :
>
Select key scheme to generate keypair (0 for ed25519, 1 for secp256k1, 2: for secp256r1):
> 0
回答完问题后,CLI 将生成一个新的密钥对并保存到配置文件中。现在你可以使用这个账户与网络交互了。
要检查账户设置是否正确,请运行 sui client active-address
命令:
$ sui client active-address
0x....
该命令将输出你账户的地址,以 0x
开头,后面跟着64个字符。
获取测试币
在 devnet 和 testnet 环境中,CLI 提供了一种方式来请求测试币到你的账户,以便你可以与网络进行交互。要请求测试币,请运行 sui client faucet
命令:
$ sui client faucet
Request successful. It can take up to 1 minute to get the coin. Run sui client gas to check your gas coins.
稍等片刻后,你可以运行 sui client balance
命令来检查硬币是否已发送到你的账户:
$ sui client balance
╭────────────────────────────────────────╮
│ Balance of coins owned by this address │
├────────────────────────────────────────┤
│ ╭──────────────────────────────────╮ │
│ │ coin balance (raw) balance │ │
│ ├──────────────────────────────────┤ │
│ │ Sui 1000000000 1.00 SUI │ │
│ ╰──────────────────────────────────╯ │
╰────────────────────────────────────────╯
或者,你可以通过运行 sui client objects
命令来查询你的账户拥有的对象。实际输出会有所不同,因为对象 ID 是唯一的,摘要也是唯一的,但结构类似:
$ sui client objects
╭───────────────────────────────────────────────────────────────────────────────────────╮
│ ╭────────────┬──────────────────────────────────────────────────────────────────────╮ │
│ │ objectId │ 0x4ea1303e4f5e2f65fc3709bc0fb70a3035fdd2d53dbcff33e026a50a742ce0de │ │
│ │ version │ 4 │ │
│ │ digest │ nA68oa8gab/CdIRw+240wze8u0P+sRe4vcisbENcR4U= │ │
│ │ objectType │ 0x0000..0002::coin::Coin │ │
│ ╰────────────┴──────────────────────────────────────────────────────────────────────╯ │
╰───────────────────────────────────────────────────────────────────────────────────────╯
现在我们已经设置了账户并且账户中有了硬币,我们可以开始与网络进行交互。我们将从将包发布到网络开始。
发布
要将包发布到网络上,我们将使用 sui client publish
命令。该命令将自动构建包,并使用其字节码在单个事务中进行发布。
在发布过程中,我们使用
--gas-budget
参数指定了事务的 gas 预算。本节不涉及详细讨论这个主题,但重要的是要知道,在 Sui 中,每个交易都需要支付 gas 费用,而 gas 费用是用 SUI 币支付的。
gas-budget
以 MISTs 表示。1 SUI 等于 10^9 MISTs。为了演示,我们将使用 100,000,000 MISTs,相当于 0.1 SUI。
# 在 `todo_list` 文件夹中运行以下命令
$ sui client publish --gas-budget 100000000
# 或者,你可以指定包的路径
$ sui client publish --gas-budget 100000000 todo_list
发布命令的输出相对较长,因此我们将分部分展示并解释它。
$ sui client publish --gas-budget 100000000
UPDATING GIT DEPENDENCY https://github.com/MystenLabs/sui.git
INCLUDING DEPENDENCY Sui
INCLUDING DEPENDENCY MoveStdlib
BUILDING todo_list
Successfully verified dependencies on-chain against source.
Transaction Digest: GpcDV6JjjGQMRwHpEz582qsd5MpCYgSwrDAq1JXcpFjW
正如你所见,当我们运行 publish
命令时,CLI 首先构建包,然后验证链上的依赖项,最后发布包。命令的输出是事务摘要,这是交易的唯一标识符,可用于查询交易状态。
事务数据 (Transaction Data)
TransactionData
部分包含我们刚发送的交易信息。它包括字段如 sender
(发送者地址)、使用 --gas-budget
参数设置的 gas_budget
(gas 预算)以及我们用于支付的币种。它还打印了 CLI 运行的命令。在本示例中,运行了 Publish
和 TransferObject
命令 - 后者将一个特殊对象 UpgradeCap
转移给了发送者。
╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Transaction Data │
├──────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Sender: 0x091ef55506ad814920adcef32045f9078f2f6e9a72f4cf253a1e6274157380a1 │
│ Gas Owner: 0x091ef55506ad814920adcef32045f9078f2f6e9a72f4cf253a1e6274157380a1 │
│ Gas Budget: 100000000 MIST │
│ Gas Price: 1000 MIST │
│ Gas Payment: │
│ ┌── │
│ │ ID: 0x4ea1303e4f5e2f65fc3709bc0fb70a3035fdd2d53dbcff33e026a50a742ce0de │
│ │ Version: 7 │
│ │ Digest: AXYPnups8A5J6pkvLa6RekX2ye3qur66EZ88mEbaUDQ1 │
│ └── │
│ │
│ Transaction Kind: Programmable │
│ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │
│ │ Input Objects │ │
│ ├──────────────────────────────────────────────────────────────────────────────────────────────────────────┤ │
│ │ 0 Pure Arg: Type: address, Value: "0x091ef55506ad814920adcef32045f9078f2f6e9a72f4cf253a1e6274157380a1" │ │
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯ │
│ ╭─────────────────────────────────────────────────────────────────────────╮ │
│ │ Commands │ │
│ ├─────────────────────────────────────────────────────────────────────────┤ │
│ │ 0 Publish: │ │
│ │ ┌ │ │
│ │ │ Dependencies: │ │
│ │ │ 0x0000000000000000000000000000000000000000000000000000000000000001 │ │
│ │ │ 0x0000000000000000000000000000000000000000000000000000000000000002 │ │
│ │ └ │ │
│ │ │ │
│ │ 1 TransferObjects: │ │
│ │ ┌ │ │
│ │ │ Arguments: │ │
│ │ │ Result 0 │ │
│ │ │ Address: Input 0 │ │
│ │ └ │ │
│ ╰─────────────────────────────────────────────────────────────────────────╯ │
│ │
│ Signatures: │
│ gebjSbVwZwTkizfYg2XIuzdx+d66VxFz8EmVaisVFiV3GkDay6L+hQG3n2CQ1hrWphP6ZLc7bd1WRq4ss+hQAQ== │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
交易影响 (Transaction Effects)
交易影响部分包含了交易的状态、交易对网络状态所做的更改以及交易涉及的对象。
╭───────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Transaction Effects │
├───────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Digest: GpcDV6JjjGQMRwHpEz582qsd5MpCYgSwrDAq1JXcpFjW │
│ Status: Success │
│ Executed Epoch: 411 │
│ │
│ Created Objects: │
│ ┌── │
│ │ ID: 0x160f7856e13b27e5a025112f361370f4efc2c2659cb0023f1e99a8a84d1652f3 │
│ │ Owner: Account Address ( 0x091ef55506ad814920adcef32045f9078f2f6e9a72f4cf253a1e6274157380a1 ) │
│ │ Version: 8 │
│ │ Digest: 8y6bhwvQrGJHDckUZmj2HDAjfkyVqHohhvY1Fvzyj7ec │
│ └── │
│ ┌── │
│ │ ID: 0x468daa33dfcb3e17162bbc8928f6ec73744bb08d838d1b6eb94eac99269b29fe │
│ │ Owner: Immutable │
│ │ Version: 1 │
│ │ Digest: Ein91NF2hc3qC4XYoMUFMfin9U23xQmDAdEMSHLae7MK │
│ └── │
│ Mutated Objects: │
│ ┌── │
│ │ ID: 0x4ea1303e4f5e2f65fc3709bc0fb70a3035fdd2d53dbcff33e026a50a742ce0de │
│ │ Owner: Account Address ( 0x091ef55506ad814920adcef32045f9078f2f6e9a72f4cf253a1e6274157380a1 ) │
│ │ Version: 8 │
│ │ Digest: 7ydahjaM47Gyb33PB4qnW2ZAGqZvDuWScV6sWPiv7LTc │
│ └── │
│ Gas Object: │
│ ┌── │
│ │ ID: 0x4ea1303e4f5e2f65fc3709bc0fb70a3035fdd2d53dbcff33e026a50a742ce0de │
│ │ Owner: Account Address ( 0x091ef55506ad814920adcef32045f9078f2f6e9a72f4cf253a1e6274157380a1 ) │
│ │ Version: 8 │
│ │ Digest: 7ydahjaM47Gyb33PB4qnW2ZAGqZvDuWScV6sWPiv7LTc │
│ └── │
│ Gas Cost Summary: │
│ Storage Cost: 10404400 MIST │
│ Computation Cost: 1000000 MIST │
│ Storage Rebate: 978120 MIST │
│ Non-refundable Storage Fee: 9880 MIST │
│ │
│ Transaction Dependencies: │
│ 7Ukrc5GqdFqTA41wvWgreCdHn2vRLfgQ3YMFkdks72Vk │
│ 7d4amuHGhjtYKujEs9YkJARzNEn4mRbWWv3fn4cdKdyh │
╰───────────────────────────────────────────────────────────────────────────────────────────────────╯
事件 (Events)
如果有任何 事件 被触发,你将会在这个部分看到它们。由于我们的包没有使用事件,所以这个部分为空。
╭─────────────────────────────╮
│ No transaction block events │
╰─────────────────────────────╯
对象变更 (Object Changes)
这部分记录了交易所作出的对象变更。在我们的例子中,我们创建了一个新的 UpgradeCap
对象,这是一个特殊对象,允许发送者在未来升级包。我们还改变了 Gas 对象,并且发布了一个新的包。在 Sui 中,包也是对象之一。
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Object Changes │
├──────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Created Objects: │
│ ┌── │
│ │ ObjectID: 0x160f7856e13b27e5a025112f361370f4efc2c2659cb0023f1e99a8a84d1652f3 │
│ │ Sender: 0x091ef55506ad814920adcef32045f9078f2f6e9a72f4cf253a1e6274157380a1 │
│ │ Owner: Account Address ( 0x091ef55506ad814920adcef32045f9078f2f6e9a72f4cf253a1e6274157380a1 ) │
│ │ ObjectType: 0x2::package::UpgradeCap │
│ │ Version: 8 │
│ │ Digest: 8y6bhwvQrGJHDckUZmj2HDAjfkyVqHohhvY1Fvzyj7ec │
│ └── │
│ Mutated Objects: │
│ ┌── │
│ │ ObjectID: 0x4ea1303e4f5e2f65fc3709bc0fb70a3035fdd2d53dbcff33e026a50a742ce0de │
│ │ Sender: 0x091ef55506ad814920adcef32045f9078f2f6e9a72f4cf253a1e6274157380a1 │
│ │ Owner: Account Address ( 0x091ef55506ad814920adcef32045f9078f2f6e9a72f4cf253a1e6274157380a1 ) │
│ │ ObjectType: 0x2::coin::Coin<0x2::sui::SUI> │
│ │ Version: 8 │
│ │ Digest: 7ydahjaM47Gyb33PB4qnW2ZAGqZvDuWScV6sWPiv7LTc │
│ └── │
│ Published Objects: │
│ ┌── │
│ │ PackageID: 0x468daa33dfcb3e17162bbc8928f6ec73744bb08d838d1b6eb94eac99269b29fe │
│ │ Version: 1 │
│ │ Digest: Ein91NF2hc3qC4XYoMUFMfin9U23xQmDAdEMSHLae7MK │
│ │ Modules: todo_list │
│ └── │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
余额变更 (Balance Changes)
这一部分记录了对 SUI 代币的变动情况。在我们的案例中,我们花费了约 0.015 SUI,相当于 10,500,000 MIST。你可以在输出的 amount 字段中看到这个数值。
╭───────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Balance Changes │
├───────────────────────────────────────────────────────────────────────────────────────────────────┤
│ ┌── │
│ │ Owner: Account Address ( 0x091ef55506ad814920adcef32045f9078f2f6e9a72f4cf253a1e6274157380a1 ) │
│ │ CoinType: 0x2::sui::SUI │
│ │ Amount: -10426280 │
│ └── │
╰───────────────────────────────────────────────────────────────────────────────────────────────────╯
可选输出
只需在发布时加上 --json
标志即可将输出格式设置为 JSON 格式。这对于想要以编程方式解析输出或稍后使用它的人来说非常有用。
$ sui client publish --gas-budget 100000000 --json
使用结果
当包成功发布到链上之后,我们可以开始与之进行交互。为了做到这一点,我们需要找到包的地址(对象ID)。这个地址可以在 Object Changes
输出的 Published Objects
部分找到。每个包的地址都是唯一的,因此您需要从输出中复制它。
在这个示例中,地址是:
0x468daa33dfcb3e17162bbc8928f6ec73744bb08d838d1b6eb94eac99269b29fe
现在我们有了地址,接下来我们将展示如何通过发送交易与该包进行交互。
发送交易
为了演示与todo_list
包的交互,我们将发送一个交易来创建一个新的列表并向其中添加一个项目。交易通过sui client ptb
命令发送,它允许充分利用交易块。这个命令可能看起来很庞大和复杂,但我们将逐步解释。
准备变量
在构建命令之前,让我们存储将在交易中使用的值。将0x4....
替换为您已发布的包的地址。MY_ADDRESS
变量将自动从CLI输出中设置为您的地址。
$ export PACKAGE_ID=0x468daa33dfcb3e17162bbc8928f6ec73744bb08d838d1b6eb94eac99269b29fe
$ export MY_ADDRESS=$(sui client active-address)
构建CLI中的交易
现在来构建一个实际的交易。该交易将由两部分组成:我们将调用todo_list
包中的new
函数来创建一个新的列表,然后将列表对象转移到我们的账户。交易将如下所示:
$ sui client ptb \
--gas-budget 100000000 \
--assign sender @$MY_ADDRESS \
--move-call $PACKAGE_ID::todo_list::new \
--assign list \
--transfer-objects "[list]" sender
在这个命令中,我们使用ptb
子命令来构建一个交易。随后的参数定义了交易将执行的实际命令和操作。我们首先进行的两个调用是实用函数调用,用于设置发送者地址为命令输入,并为交易设置gas预算。
# sets the gas budget for the transaction
--gas-budget 100000000 \n
# registers a variable "sender=@..."
--assign sender @$MY_ADDRESS \n
接下来,我们执行对包中函数的实际调用。我们使用--move-call
,紧接着是包ID、模块名和函数名。在这种情况下,我们调用的是todo_list
包中的new
函数。
# calls the "new" function in the "todo_list" package under the $PACKAGE_ID address
--move-call $PACKAGE_ID::todo_list::new
我们定义的函数实际上返回一个值,我们需要将其存储起来。我们使用--assign
命令为返回的值赋予一个名称。在这种情况下,我们将其命名为list
。然后,我们将该对象转移给我们的账户。
--move-call $PACKAGE_ID::todo_list::new \
# assigns the result of the "new" function to the "list" variable (from the previous step)
--assign list \
# transfers the object to the sender
--transfer-objects "[list]" sender
一旦命令构建完成,您可以在终端中运行它。如果一切正确,您应该看到类似于前面章节中的输出。输出将包含交易摘要、交易数据和交易效果。
剧透:完整的交易输出
Transaction Digest: BJwYEnuuMzU4Y8cTwMoJbbQA6cLwPmwxvsRpSmvThoK8
╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Transaction Data │
├──────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Sender: 0x091ef55506ad814920adcef32045f9078f2f6e9a72f4cf253a1e6274157380a1 │
│ Gas Owner: 0x091ef55506ad814920adcef32045f9078f2f6e9a72f4cf253a1e6274157380a1 │
│ Gas Budget: 100000000 MIST │
│ Gas Price: 1000 MIST │
│ Gas Payment: │
│ ┌── │
│ │ ID: 0xe5ddeb874a8d7ead328e9f2dd2ad8d25383ab40781a5f1aefa75600973b02bc4 │
│ │ Version: 22 │
│ │ Digest: DiBrBMshDiD9cThpaEgpcYSF76uV4hCoE1qRyQ3rnYCB │
│ └── │
│ │
│ Transaction Kind: Programmable │
│ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │
│ │ Input Objects │ │
│ ├──────────────────────────────────────────────────────────────────────────────────────────────────────────┤ │
│ │ 0 Pure Arg: Type: address, Value: "0x091ef55506ad814920adcef32045f9078f2f6e9a72f4cf253a1e6274157380a1" │ │
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯ │
│ ╭──────────────────────────────────────────────────────────────────────────────────╮ │
│ │ Commands │ │
│ ├──────────────────────────────────────────────────────────────────────────────────┤ │
│ │ 0 MoveCall: │ │
│ │ ┌ │ │
│ │ │ Function: new │ │
│ │ │ Module: todo_list │ │
│ │ │ Package: 0x468daa33dfcb3e17162bbc8928f6ec73744bb08d838d1b6eb94eac99269b29fe │ │
│ │ └ │ │
│ │ │ │
│ │ 1 TransferObjects: │ │
│ │ ┌ │ │
│ │ │ Arguments: │ │
│ │ │ Result 0 │ │
│ │ │ Address: Input 0 │ │
│ │ └ │ │
│ ╰──────────────────────────────────────────────────────────────────────────────────╯ │
│ │
│ Signatures: │
│ C5Lie4dtP5d3OkKzFBa+xM0BiNoB/A4ItthDCRTRBUrEE+jXeNs7mP4AuGwi3nzfTskh29+R1j1Kba4Wdy3QDA== │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
╭───────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Transaction Effects │
├───────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Digest: BJwYEnuuMzU4Y8cTwMoJbbQA6cLwPmwxvsRpSmvThoK8 │
│ Status: Success │
│ Executed Epoch: 1213 │
│ │
│ Created Objects: │
│ ┌── │
│ │ ID: 0x74973c4ea2e78dc409f60481e23761cee68a48156df93a93fbcceb77d1cacdf6 │
│ │ Owner: Account Address ( 0x091ef55506ad814920adcef32045f9078f2f6e9a72f4cf253a1e6274157380a1 ) │
│ │ Version: 23 │
│ │ Digest: DuHTozDHMsuA7cFnWRQ1Gb8FQghAEBaj3inasJxqYq1c │
│ └── │
│ Mutated Objects: │
│ ┌── │
│ │ ID: 0xe5ddeb874a8d7ead328e9f2dd2ad8d25383ab40781a5f1aefa75600973b02bc4 │
│ │ Owner: Account Address ( 0x091ef55506ad814920adcef32045f9078f2f6e9a72f4cf253a1e6274157380a1 ) │
│ │ Version: 23 │
│ │ Digest: 82fwKarGuDhtomr5oS6ZGNvZNw9QVXLSbPdQu6jQgNV7 │
│ └── │
│ Gas Object: │
│ ┌── │
│ │ ID: 0xe5ddeb874a8d7ead328e9f2dd2ad8d25383ab40781a5f1aefa75600973b02bc4 │
│ │ Owner: Account Address ( 0x091ef55506ad814920adcef32045f9078f2f6e9a72f4cf253a1e6274157380a1 ) │
│ │ Version: 23 │
│ │ Digest: 82fwKarGuDhtomr5oS6ZGNvZNw9QVXLSbPdQu6jQgNV7 │
│ └── │
│ Gas Cost Summary: │
│ Storage Cost: 2318000 MIST │
│ Computation Cost: 1000000 MIST │
│ Storage Rebate: 978120 MIST │
│ Non-refundable Storage Fee: 9880 MIST │
│ │
│ Transaction Dependencies: │
│ FSz2fYXmKqTf77mFXNq5JK7cKY8agWja7V5yDKEgL8c3 │
│ GgMZKTt482DYApbAZkPDtdssGHZLbxgjm2uMXhzJax8Q │
╰───────────────────────────────────────────────────────────────────────────────────────────────────╯
╭─────────────────────────────╮
│ No transaction block events │
╰─────────────────────────────╯
╭───────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Object Changes │
├───────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Created Objects: │
│ ┌── │
│ │ ObjectID: 0x74973c4ea2e78dc409f60481e23761cee68a48156df93a93fbcceb77d1cacdf6 │
│ │ Sender: 0x091ef55506ad814920adcef32045f9078f2f6e9a72f4cf253a1e6274157380a1 │
│ │ Owner: Account Address ( 0x091ef55506ad814920adcef32045f9078f2f6e9a72f4cf253a1e6274157380a1 ) │
│ │ ObjectType: 0x468daa33dfcb3e17162bbc8928f6ec73744bb08d838d1b6eb94eac99269b29fe::todo_list::TodoList │
│ │ Version: 23 │
│ │ Digest: DuHTozDHMsuA7cFnWRQ1Gb8FQghAEBaj3inasJxqYq1c │
│ └── │
│ Mutated Objects: │
│ ┌── │
│ │ ObjectID: 0xe5ddeb874a8d7ead328e9f2dd2ad8d25383ab40781a5f1aefa75600973b02bc4 │
│ │ Sender: 0x091ef55506ad814920adcef32045f9078f2f6e9a72f4cf253a1e6274157380a1 │
│ │ Owner: Account Address ( 0x091ef55506ad814920adcef32045f9078f2f6e9a72f4cf253a1e6274157380a1 ) │
│ │ ObjectType: 0x2::coin::Coin<0x2::sui::SUI> │
│ │ Version: 23 │
│ │ Digest: 82fwKarGuDhtomr5oS6ZGNvZNw9QVXLSbPdQu6jQgNV7 │
│ └── │
╰───────────────────────────────────────────────────────────────────────────────────────────────────────╯
╭───────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Balance Changes │
├───────────────────────────────────────────────────────────────────────────────────────────────────┤
│ ┌── │
│ │ Owner: Account Address ( 0x091ef55506ad814920adcef32045f9078f2f6e9a72f4cf253a1e6274157380a1 ) │
│ │ CoinType: 0x2::sui::SUI │
│ │ Amount: -2339880 │
│ └── │
╰───────────────────────────────────────────────────────────────────────────────────────────────────╯
我们要关注的部分是"Object Changes"(对象变化)部分,更具体地说是其中的"Created Objects"(创建的对象)部分。它包含了您创建的 TodoList
的对象 ID、类型和版本信息。我们将使用这个对象 ID 来与对应列表进行交互。
╭───────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Object Changes │
├───────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Created Objects: │
│ ┌── │
│ │ ObjectID: 0x20e0bede16de8a728ab25e228816b9059b45ebea49c8ad384e044580b2d3e553 │
│ │ Sender: 0x091ef55506ad814920adcef32045f9078f2f6e9a72f4cf253a1e6274157380a1 │
│ │ Owner: Account Address ( 0x091ef55506ad814920adcef32045f9078f2f6e9a72f4cf253a1e6274157380a1 ) │
│ │ ObjectType: 0x468daa33dfcb3e17162bbc8928f6ec73744bb08d838d1b6eb94eac99269b29fe::todo_list::TodoList │
│ │ Version: 22 │
│ │ Digest: HyWdUpjuhjLY38dLpg6KPHQ3bt4BqQAbdF5gB8HQdEqG │
│ └── │
│ Mutated Objects: │
│ ┌── │
│ │ ObjectID: 0xe5ddeb874a8d7ead328e9f2dd2ad8d25383ab40781a5f1aefa75600973b02bc4 │
│ │ Sender: 0x091ef55506ad814920adcef32045f9078f2f6e9a72f4cf253a1e6274157380a1 │
│ │ Owner: Account Address ( 0x091ef55506ad814920adcef32045f9078f2f6e9a72f4cf253a1e6274157380a1 ) │
│ │ ObjectType: 0x2::coin::Coin<0x2::sui::SUI> │
│ │ Version: 22 │
│ │ Digest: DiBrBMshDiD9cThpaEgpcYSF76uV4hCoE1qRyQ3rnYCB │
│ └── │
╰───────────────────────────────────────────────────────────────────────────────────────────────────────╯
在这个示例中,对象 ID 是0x20e0bede16de8a728ab25e228816b9059b45ebea49c8ad384e044580b2d3e553
。而拥有者应该是您的账户地址。我们通过在交易的最后一个命令中将对象转移给发送者来实现这一点。
另一种测试您是否成功创建了列表的方法是检查账户对象。
$ sui client objects
它应该有一个类似于以下内容的对象:
╭ ... ╮
│ ╭────────────┬──────────────────────────────────────────────────────────────────────╮ │
│ │ objectId │ 0x20e0bede16de8a728ab25e228816b9059b45ebea49c8ad384e044580b2d3e553 │ │
│ │ version │ 22 │ │
│ │ digest │ /DUEiCLkaNSgzpZSq2vSV0auQQEQhyH9occq9grMBZM= │ │
│ │ objectType │ 0x468d..29fe::todo_list::TodoList │ │
│ ╰────────────┴──────────────────────────────────────────────────────────────────────╯ │
| ... |
将对象传递给函数
我们在上一步中创建的TodoList是一个可以作为其所有者进行交互的对象。您可以在该对象上调用todo_list
模块中定义的函数。为了演示这一点,我们将向列表中添加一个项目。首先,我们只添加一个项目,然后在第二个交易中添加3个项目并删除另一个项目。
请再次检查您是否已经设置了前一步骤中的变量,然后为列表对象添加一个变量。
$ export LIST_ID=0x20e0bede16de8a728ab25e228816b9059b45ebea49c8ad384e044580b2d3e553
现在我们可以构建一个交易,向列表中添加一个项目。命令将如下所示:
$ sui client ptb \
--gas-budget 100000000 \
--move-call $PACKAGE_ID::todo_list::add @$LIST_ID "'Finish the Hello, Sui chapter'"
在这个命令中,我们调用了 todo_list
包中的 add
函数。该函数接受两个参数:列表对象和要添加的项目。项目是一个字符串,所以我们需要用单引号将其包裹起来。该命令将项目添加到列表中。
如果一切正确,您应该看到类似于前面章节中的输出。现在,您可以检查列表对象,看看项目是否已经被添加进去了。
$ sui client object $LIST_ID
输出应该包含您添加的项目。
╭───────────────┬───────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ objectId │ 0x20e0bede16de8a728ab25e228816b9059b45ebea49c8ad384e044580b2d3e553 │
│ version │ 24 │
│ digest │ FGcXH8MGpMs5BdTnC62CQ3VLAwwexYg2id5DKU7Jr9aQ │
│ objType │ 0x468daa33dfcb3e17162bbc8928f6ec73744bb08d838d1b6eb94eac99269b29fe::todo_list::TodoList │
│ owner │ ╭──────────────┬──────────────────────────────────────────────────────────────────────╮ │
│ │ │ AddressOwner │ 0x091ef55506ad814920adcef32045f9078f2f6e9a72f4cf253a1e6274157380a1 │ │
│ │ ╰──────────────┴──────────────────────────────────────────────────────────────────────╯ │
│ prevTx │ EJVK6FEHtfTdCuGkNsU1HcrmUBEN6H6jshfcptnw8Yt1 │
│ storageRebate │ 1558000 │
│ content │ ╭───────────────────┬───────────────────────────────────────────────────────────────────────────────────────────╮ │
│ │ │ dataType │ moveObject │ │
│ │ │ type │ 0x468daa33dfcb3e17162bbc8928f6ec73744bb08d838d1b6eb94eac99269b29fe::todo_list::TodoList │ │
│ │ │ hasPublicTransfer │ true │ │
│ │ │ fields │ ╭───────┬───────────────────────────────────────────────────────────────────────────────╮ │ │
│ │ │ │ │ id │ ╭────┬──────────────────────────────────────────────────────────────────────╮ │ │ │
│ │ │ │ │ │ │ id │ 0x20e0bede16de8a728ab25e228816b9059b45ebea49c8ad384e044580b2d3e553 │ │ │ │
│ │ │ │ │ │ ╰────┴──────────────────────────────────────────────────────────────────────╯ │ │ │
│ │ │ │ │ items │ ╭─────────────────────────────────╮ │ │ │
│ │ │ │ │ │ │ finish the Hello, Sui chapter │ │ │ │
│ │ │ │ │ │ ╰─────────────────────────────────╯ │ │ │
│ │ │ │ ╰───────┴───────────────────────────────────────────────────────────────────────────────╯ │ │
│ │ ╰───────────────────┴───────────────────────────────────────────────────────────────────────────────────────────╯ │
╰───────────────┴───────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
可以通过在命令中添加--json
标志来获取对象的JSON表示形式。
$ sui client object $LIST_ID --json
{
"objectId": "0x20e0bede16de8a728ab25e228816b9059b45ebea49c8ad384e044580b2d3e553",
"version": "24",
"digest": "FGcXH8MGpMs5BdTnC62CQ3VLAwwexYg2id5DKU7Jr9aQ",
"type": "0x468daa33dfcb3e17162bbc8928f6ec73744bb08d838d1b6eb94eac99269b29fe::todo_list::TodoList",
"owner": {
"AddressOwner": "0x091ef55506ad814920adcef32045f9078f2f6e9a72f4cf253a1e6274157380a1"
},
"previousTransaction": "EJVK6FEHtfTdCuGkNsU1HcrmUBEN6H6jshfcptnw8Yt1",
"storageRebate": "1558000",
"content": {
"dataType": "moveObject",
"type": "0x468daa33dfcb3e17162bbc8928f6ec73744bb08d838d1b6eb94eac99269b29fe::todo_list::TodoList",
"hasPublicTransfer": true,
"fields": {
"id": {
"id": "0x20e0bede16de8a728ab25e228816b9059b45ebea49c8ad384e044580b2d3e553"
},
"items": ["Finish the Hello, Sui chapter"]
}
}
}
链式命令
链接多个命令可以在单个交易中执行。这展示了交易块的威力!使用相同的列表对象,我们将添加三个项目并删除一个项目。命令将如下所示:
$ sui client ptb \
--gas-budget 100000000 \
--move-call $PACKAGE_ID::todo_list::add @$LIST_ID "'Finish Concepts chapter'" \
--move-call $PACKAGE_ID::todo_list::add @$LIST_ID "'Read the Move Basics chapter'" \
--move-call $PACKAGE_ID::todo_list::add @$LIST_ID "'Learn about Object Model'" \
--move-call $PACKAGE_ID::todo_list::remove @$LIST_ID 0
如果之前的命令都成功执行了,这个命令也应该不会有问题。你可以检查列表对象,看看项目是否已被添加和删除。JSON 格式会更易读一些!
sui client object $LIST_ID --json
{
"objectId": "0x20e0bede16de8a728ab25e228816b9059b45ebea49c8ad384e044580b2d3e553",
"version": "25",
"digest": "EDTXDsteqPGAGu4zFAj5bbQGTkucWk4hhuUquk39enGA",
"type": "0x468daa33dfcb3e17162bbc8928f6ec73744bb08d838d1b6eb94eac99269b29fe::todo_list::TodoList",
"owner": {
"AddressOwner": "0x091ef55506ad814920adcef32045f9078f2f6e9a72f4cf253a1e6274157380a1"
},
"previousTransaction": "7SXLGBSh31jv8G7okQ9mEgnw5MnTfvzzHEHpWf3Sa9gY",
"storageRebate": "1922800",
"content": {
"dataType": "moveObject",
"type": "0x468daa33dfcb3e17162bbc8928f6ec73744bb08d838d1b6eb94eac99269b29fe::todo_list::TodoList",
"hasPublicTransfer": true,
"fields": {
"id": {
"id": "0x20e0bede16de8a728ab25e228816b9059b45ebea49c8ad384e044580b2d3e553"
},
"items": [
"Finish Concepts chapter",
"Read the Move Basics chapter",
"Learn about Object Model"
]
}
}
}
命令不必在同一个包中,也不必操作同一个对象。在单个交易块中,你可以与多个包和对象进行交互。这是一个强大的功能,可以让你在链上构建复杂的交互!
总结
在本指南中,我们展示了如何在 Move 区块链上发布包并使用 Sui CLI 与其交互。我们演示了如何创建一个新的列表对象、添加项目以及删除项目。我们还展示了如何在单个交易块中链接多个命令。本指南应该能为你在 Sui 区块链上构建自己的应用程序提供一个良好的起点!
理解 Sui 和 Move 的基本概念对于编写 Sui 上的 Move 程序至关重要。在本章中,你将了解到什么是包(package)、如何与其交互,什么是账户(account)和交易(transaction),以及数据如何在 Sui 上存储。尽管本章内容并非详尽的参考资料,你可以参考 Sui 文档 获取更多详细信息,但它将为你提供足够的基础概念,帮助你开始在 Sui 上编写 Move 程序。
包
Move 是一种用于编写智能合约的语言,这些合约被存储和运行在区块链上。一个程序通常被组织成一个包 (package)。包被发布到区块链上,并通过一个 地址 进行标识。已发布的包可以通过发送交易来调用其函数进行交互。它还可以作为其他包的依赖项。
要创建一个新的包,请使用
sui move new
命令。要了解更多命令的详细信息,请运行sui move new --help
。
包由模块组成 - 单独的作用域,包含函数、类型和其他项。
package 0x...
module a
struct A1
fun hello_world()
module b
struct B1
fun hello_package()
包结构
在本地,一个包是一个包含 Move.toml
文件和一个 sources
目录的文件夹。Move.toml
文件 - 称为 "包清单" - 包含关于包的元数据,而 sources
目录包含模块的源代码。包通常具有以下结构:
sources/
my_module.move
another_module.move
...
tests/
...
examples/
using_my_module.move
Move.toml
tests
目录是可选的,包含包的测试代码。放置在 tests
目录中的代码不会发布到链上,仅在测试中使用。examples
目录可以用于包含代码示例,同样也不会发布到链上。
已发布的包
在开发过程中,包没有地址,需要设置为 0x0
。一旦包被发布,它将在区块链上获得一个唯一的 地址,其中包含其模块的字节码。已发布的包变得 不可变 (immutable),可以通过发送交易进行交互。
0x...
my_module: <bytecode>
another_module: <bytecode>
链接
Manifest
Move.toml
是描述 包 及其依赖关系的清单文件,采用 TOML 格式,包含多个部分,其中最重要的是 [package]
、[dependencies]
和 [addresses]
。
[package]
name = "my_project"
version = "0.0.0"
edition = "2024"
[dependencies]
Sui = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/sui-framework", rev = "framework/testnet" }
[addresses]
std = "0x1"
alice = "0xA11CE"
[dev-addresses]
alice = "0xB0B"
各部分详解
Package
[package]
部分用于描述包。该部分的字段不会被发布到链上,但会用于工具和版本管理;它还指定了编译器使用的 Move 版本。
name
- 导入时包的名称;version
- 包的版本,可用于版本管理;edition
- Move 语言的版本;目前唯一有效的值是2024
。
Dependencies
[dependencies]
部分用于指定项目的依赖关系。每个依赖关系都以键值对的形式指定,键是依赖的名称,值是依赖的规范。依赖规范可以是 git 仓库的 URL 或本地目录的路径。
# git 仓库
Sui = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/sui-framework", rev = "framework/testnet" }
# 本地目录
MyPackage = { local = "../my-package" }
包还可以从其他包导入地址。例如,Sui 依赖项将 std
和 sui
地址添加到项目中。这些地址可以在代码中用作地址的别名。
使用 override 解决版本冲突
有时候依赖项会有相同包的不同版本之间的冲突。例如,如果有两个依赖项使用了不同版本的 Sui 包,可以在 [dependencies]
部分使用 override
字段来解决冲突。在依赖关系中指定的版本将覆盖依赖项本身指定的版本。
[dependencies]
Sui = { override = true, git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/sui-framework", rev = "framework/testnet" }
Dev-dependencies
可以在清单中添加 [dev-dependencies]
部分,用于在开发和测试模式下覆盖依赖关系。例如,如果想在开发模式下使用不同版本的 Sui 包,可以在 [dev-dependencies]
部分添加自定义的依赖规范。
Addresses
[addresses]
部分用于为地址添加别名。可以在此部分指定任何地址,然后在代码中作为别名使用。例如,如果将 alice = "0xA11CE"
添加到此部分,可以在代码中使用 alice
代替 0xA11CE
。
Dev-addresses
[dev-addresses]
部分与 [addresses]
类似,但仅在测试和开发模式下有效。需要注意的是,这部分无法引入新的别名,只能覆盖现有的别名。因此,在上面的示例中,如果将 alice = "0xB0B"
添加到此部分,那么在测试和开发模式下,alice
地址将是 0xB0B
,而在常规构建中则是 0xA11CE
。
TOML 样式
TOML 格式支持两种表格样式:内联样式和多行样式。上面的示例使用的是内联样式,但对于依赖关系,多行样式也是可行的。
# 内联样式
[dependencies]
Sui = { override = true, git = "", subdir = "crates/sui-framework/packages/sui-framework", rev = "framework/testnet" }
MyPackage = { local = "../my-package" }
# 多行样式
[dependencies.Sui]
override = true
git = "https://github.com/MystenLabs/sui.git"
subdir = "crates/sui-framework/packages/sui-framework"
rev = "framework/testnet"
[dependencies.MyPackage]
local = "../my-package"
进一步阅读
- Move Reference 中的 Packages 章节
地址
地址是区块链上位置的唯一标识符。它用于标识包 (package)、账户 (account)和对象 (object)。地址的固定大小为32字节,通常表示为以 0x
开头的十六进制字符串。地址不区分大小写。
0xe51ff5cd221a81c3d6e22b9e670ddf99004d71de4f769b0312b68c7c4872e2f1
上面的地址示例是一个有效的地址。它长度为64个字符(32字节),并且以 0x
开头。
Sui 还有一些预留地址,用于标识标准包和对象。预留地址通常是简单的值,易于记忆和输入。例如,标准库的地址是 0x1
。少于32字节的地址,在左侧用零填充。
0x1 = 0x0000000000000000000000000000000000000000000000000000000000000001
以下是一些预留地址的示例:
0x1
- Sui 标准库的地址(别名std
)0x2
- Sui 框架的地址(别名sui
)0x6
- 系统Clock
对象的地址
你可以在 附录 B 中找到所有预留地址的详细信息。
进一步阅读
账户
账户 (account) 是识别用户的一种方式。账户由私钥生成,并通过地址来识别。账户可以拥有对象,并且可以发送交易。每个交易都有一个发送者,发送者通过地址来识别。
Sui 支持多种加密算法用于生成账户。支持的曲线有 ed25519、secp256k1,还有一种特殊的账户生成方式 - zklogin。Sui 的加密灵活性使得账户生成具有灵活性和多样性。
进一步阅读
- Cryptography in Sui - 来自Sui 博客
- Keys and Addresses - 来自Sui 文档
- Signatures - 来自Sui 文档
交易(Transaction)
交易是区块链世界中的一个基本概念,它是与区块链交互的方式。交易用于改变区块链的状态,也是唯一可以改变 状态的方式。在 Move 中,交易用于调用包中的函数、部署新的包或升级现有的包。
用户如何与程序交互
用户通过调用程序中的公开函数与区块链上的智能合约进行交互。这些公开函数定义了可以在交易中执行的操 作。交易是由账户发起的,账户发送交易时指定它要操作的对象。
交易结构
每个交易都会显式地指定它操作的对象!
交易由以下部分组成:
- 发送者:签署交易的账户;
- 指令列表:要执行的操作链;
- 指令输入:命令的参数,这些参数可以是
纯类型
(例如数字或字符串)或对象类型
(交易需要访问的对 象); - Gas 对象:支付交易费用的
Coin
对象; - Gas 价格与预算:交易的费用。
输入
交易的输入是其参数,分为两种类型:
-
纯类型参数:主要是基础类型,包括:
bool
;- 无符号整数 (
u8
,u16
,u32
,u64
,u128
,u256
); address
;std::string::String
,UTF8 字符串;std::ascii::String
,ASCII 字符串;vector<T>
,T
为纯类型;std::option::Option<T>
,T
为纯类型;std::object::ID
,通常指向一个对象。详情见 什么是对象。
-
对象参数:这些是交易需要访问的对象或对象的引用。对象参数必须是共享对象、冻结对象,或者是交易发 送者拥有的对象,交易才能成功执行。更多信息请参见 对象模型。
指令
Sui 交易可以包含多个指令。每个指令可以是一个内置命令(如发布包),也可以是对已发布包中的函数调用。指 令按顺序执行,它们可以使用之前指令的执行结果,形成一个链。交易要么整体成功,要么整体失败。
输入:
- sender = 0xa11ce
指令:
- payment = SplitCoins(Gas, [ 1000 ])
- item = MoveCall(0xAAA::market::purchase, [ payment ])
- TransferObjects(item, sender)
在这个例子中,交易由三个指令组成:
SplitCoins
:一个内置命令,它从传入的对象(这里是Gas
对象)中分割出新的硬币;MoveCall
:调用位于地址0xAAA
的包中market
模块的purchase
函数,并传入payment
对象作 为参数;TransferObjects
:一个内置命令,将对象转移给接收者。
交易效果
交易效果是交易对区块链状态的改变。具体来说,交易可以通过以下方式改变状态:
- 使用 gas 对象支付交易费用
- 创建、更新或删除对象
- 触发事件
执行交易的结果包括多个部分:
- 交易摘要 (Transaction Digest):用于标识交易的哈希值;
- 交易数据 (Transaction Data):交易的输入、指令和 gas 对象;
- 交易效果 (Transaction Effect):交易的状态及其效果,具体包括:交易状态、对象更新及其新版本、使 用的 gas 对象、交易的 gas 成本以及触发的事件;
- 事件 (Events):交易触发的自定义事件;
- 对象变更 (Object Changes):对象的变更,包括所有权的变化;
- 余额变更 (Balance Changes):账户余额的变化。
Move 基础知识
本章介绍了 Move 语言的基本语法。它涵盖了语言的基础知识,如类型、模块、函数和控制流。它侧重于介绍语言本身,而不涉及存储模型或区块链,并解释了语言的基本概念。如果想了解 Sui 特定的功能,比如存储函数和能力, 请参考使用对象章节,不过建议先从本章开始阅读。
模块是 Move 中的代码组织基本单元。模块用于组织和隔离代码,模块的所有成员默认情况下对模块私有。在本节中,您将学习如何定义模块,声明其成员以及如何从其他模块访问它们。
模块声明
使用 module
关键字后跟包地址、模块名称和模块体在花括号 {}
内来声明模块。模块名称应采用 snake_case
形式,即所有小写字母,单词之间用下划线分隔。模块名称在包内必须是唯一的。
通常,sources/
文件夹中的单个文件包含一个模块。文件名应与模块名称匹配 - 例如,donut_shop
模块应存储在 donut_shop.move
文件中。您可以在Coding Conventions部分了解更多有关编码约定的信息。
module book::my_module {
// module body
}
模块的成员包括结构体、函数、常量和导入:
地址 / 命名地址
模块地址可以指定为地址_字面量_(不需要 @
前缀)或在包清单中指定的命名地址。在下面的示例中,两者是相同的,因为在 Move.toml
的 [addresses]
部分有一个 book = "0x0"
记录。
module 0x0::address_literal { /* ... */ }
module book::named_address { /* ... */ }
在 Move.toml
中的地址部分:
# Move.toml
[addresses]
book = "0x0"
模块成员
模块成员声明在模块体内部。为了说明这一点,让我们定义一个简单的模块,其中包含一个结构体、一个函数和一个常量:
module book::my_module_with_members {
// import
use book::my_module;
// a constant
const CONST: u8 = 0;
// a struct
public struct Struct {}
// method alias
public use fun function as Struct.struct_fun;
// function
fun function(_: &Struct) { /* function body */ }
}
进一步阅读
- 在 Move 参考文档中阅读有关模块的更多信息。
注释
注释是一种为代码添加注释或文档的方法。它们会被编译器忽略,不会生成 Move 字节码。你可以使用注释来解释代码的功能,向自己或其他开发者添加备注,暂时移除部分代码或生成文档。Move 中有三种类型的注释:行注释、块注释和文档注释。
行注释
module book::comments_line {
fun some_function() {
// this is a comment line
}
}
你可以使用双斜杠 //
来注释掉余下的行。编译器会忽略 //
之后的所有内容。
module book::comments_line_2 {
// let's add a note to everything!
fun some_function_with_numbers() {
let a = 10;
// let b = 10 this line is commented and won't be executed
let b = 5; // here comment is placed after code
a + b; // result is 15, not 10!
}
}
块注释
块注释用于注释掉一段代码。它们以 /*
开始,以 */
结束。编译器会忽略 /*
和 */
之间的所有内容。你可以使用块注释来注释掉单行或多行代码,甚至可以注释掉一行中的一部分。
module book::comments_block {
fun /* you can comment everywhere */ go_wild() {
/* here
there
everywhere */ let a = 10;
let b = /* even here */ 10; /* and again */
a + b;
}
/* you can use it to remove certain expressions or definitions
fun empty_commented_out() {
}
*/
}
这个例子有点极端,但它展示了如何使用块注释来注释掉一行中的一部分。
文档注释
文档注释是一种特殊的注释,用于为代码生成文档。它们类似于块注释,但以三个斜杠 ///
开始,并放在它们所记录的项目定义之前。
/// Module has documentation!
module book::comments_doc {
/// This is a 0x0 address constant!
const AN_ADDRESS: address = @0x0;
/// This is a struct!
public struct AStruct {
/// This is a field of a struct!
a_field: u8,
}
/// This function does something!
/// And it's documented!
fun do_something() {}
}
原始类型
Move 提供了多种内置的基本类型,它们是构成所有其他类型的基础。这些原始类型包括:
然而,在深入讨论这些类型之前,让我们先了解如何在 Move 中声明和赋值变量。
变量和赋值
变量使用 let
关键字声明。默认情况下它们是不可变的,但可以使用 let mut
关键字使其可变。let mut
语句的语法如下:
let <变量名>[: <类型>] = <表达式>;
let mut <变量名>[: <类型>] = <表达式>;
其中:
<变量名>
- 变量的名称<类型>
- 变量的类型,可选的<表达式>
- 要分配给变量的值
let x: bool = true;
let mut y: u8 = 42;
可变变量可以使用 =
运算符重新赋值。
y = 43;
变量还可以通过重新声明进行隐藏。
let x: u8 = 42;
let x: u16 = 42;
布尔类型
bool
类型表示布尔值 - 是或否、真或假。它有两个可能的值:true
和 false
,它们在 Move 中是关键字。对于布尔值,无需显式指定类型 - 编译器可以从值中推断出来。
let x = true;
let y = false;
布尔类型经常用于存储标志和控制程序的流程。更多信息,请参阅控制流部分。
整数类型
Move 支持各种大小的无符号整数:从 8 位到 256 位。整数类型包括:
u8
- 8 位u16
- 16 位u32
- 32 位u64
- 64 位u128
- 128 位u256
- 256 位
let x: u8 = 42;
let y: u16 = 42;
// ...
let z: u256 = 42;
与布尔类型不同,整数类型需要推断。在大多数情况下,编译器将从值中推断出类型,通常默认为 u64
。但有时编译器无法推断类型,将需要显式的类型标注。可以在赋值时提供或使用类型后缀。
// Both are equivalent
let x: u8 = 42;
let x = 42u8;
运算
Move 支持整数的标准算术运算:加法、减法、乘法、除法和取余。这些操作的语法如下:
语法 | 运算 | 如果中止则为 |
---|---|---|
+ | 加法 | 结果对整数类型过大 |
- | 减法 | 结果小于零 |
* | 乘法 | 结果对整数类型过大 |
% | 取模除法 | 除数为零 |
/ | 截断除法 | 除数为零 |
更多操作,包括位运算,请参阅Move 参考文档。
操作数的类型必须匹配,否则编译器将报错。操作的结果将与操作数相同类型。要对不同类型执行操作,需要将操作数转换为相同类型。
使用 as
进行类型转换
Move 支持整数类型之间的显式转换。其语法为:
<表达式> as <类型>
注意,可能需要在表达式周围加上括号以避免歧义。
let x: u8 = 42;
let y: u16 = x as u16;
let z = 2 * (x as u16); // ambiguous, requires parentheses
一个更复杂的例子,防止溢出:
let x: u8 = 255;
let y: u8 = 255;
let z: u16 = (x as u16) + ((y as u16) * 2);
溢出
Move 不支持溢出/下溢,导致值超出类型范围的操作将引发运行时错误。这是为了防止意外行为的安全特性。
let x = 255u8;
let y = 1u8;
// 这将引发错误
let z = x + y;
进一步阅读
地址类型
在Move中,为了表示地址,使用了一种特殊的类型称为address
。它是一个32字节的值,用于表示区块链上的任何地址。地址有两种语法形式:以0x
为前缀的十六进制地址和命名地址。
// address literal
let value: address = @0x1;
// named address registered in Move.toml
let value = @std;
let other = @sui;
地址字面量以@
符号开头,后面跟着一个十六进制数字或标识符。十六进制数字被解释为一个32字节的值。编译器将在Move.toml文件中查找该标识符,并将其替换为相应的地址。如果在Move.toml文件中找不到该标识符,编译器将抛出错误。
转换
Sui框架提供了一组辅助函数来处理地址。由于地址类型是一个32字节的值,可以将其转换为u256
类型,反之亦然。它还可以转换为vector<u8>
类型和从vector<u8>
类型转换回地址类型。
示例:将地址转换为u256
类型,然后再转换回来。
use sui::address;
let addr_as_u256: u256 = address::to_u256(@0x1);
let addr = address::from_u256(addr_as_u256);
示例:将地址转换为vector<u8>
类型,然后再转换回来。
use sui::address;
let addr_as_u8: vector<u8> = address::to_bytes(@0x1);
let addr = address::from_bytes(addr_as_u8);
示例:将地址转换为字符串。
use sui::address;
use std::string::String;
let addr_as_string: String = address::to_string(@0x1);
进一步阅读
- 在Move参考中的Address。
表达式
在编程语言中,表达式是返回一个值的代码单元。在 Move 中,几乎所有东西都是表达式,唯一的例外是 let
语句,它是一个声明。本节将介绍表达式的类型以及作用域的概念。
表达式之间使用分号
;
隔开。如果分号后面没有表达式,编译器会插入一个单位值()
- 一个空表达式。
字面量
我们在 原始类型 一节介绍了 Move 的基本类型。为了举例说明,我们使用了字面量。字面量是一种在源代码中表示固定值的符号。字面量用于初始化变量和向函数传递参数。Move 支持以下字面量:
- 布尔值:
true
和false
- 整数:
0
、1
、123123
等数字 - 整数 (十六进制):
0x0
、0x1
、0x123
等十六进制表示 - 字节向量:
b"bytes_vector"
- 字节 (十六进制):
x"0A"
let b = true; // true is a literal
let n = 1000; // 1000 is a literal
let h = 0x0A; // 0x0A is a literal
let v = b"hello"; // b'hello' is a byte vector literal
let x = x"0A"; // x'0A' is a byte vector literal
let c = vector[1, 2, 3]; // vector[] is a vector literal
运算符
算术运算符、逻辑运算符和位运算符用于对值进行运算。运算的结果是一个值,因此运算符也是表达式。
let sum = 1 + 2; // 1 + 2 is an expression
let sum = (1 + 2); // the same expression with parentheses
let is_true = true && false; // true && false is an expression
let is_true = (true && false); // the same expression with parentheses
代码块
代码块是一系列语句和表达式的组合,它返回代码块中最后一个表达式的值。代码块用一对花括号 {}
表示。代码块也是一个表达式,因此它可以在任何需要表达式的场合使用。
// block with an empty expression, however, the compiler will
// insert an empty expression automatically: `let none = { () }`
// let none = {};
// block with let statements and an expression.
let sum = {
let a = 1;
let b = 2;
a + b // last expression is the value of the block
};
// block is an expression, so it can be used in an expression and
// doesn't have to be assigned to a variable.
{
let a = 1;
let b = 2;
a + b; // not returned - semicolon.
// compiler automatically inserts an empty expression `()`
};
函数调用
我们将在 函数 一节详细讲解函数。但是我们在之前的章节已经使用过函数调用,所以这里值得一提。函数调用是一种表达式,它调用一个函数并返回函数体中最后一个表达式的值。
fun add(a: u8, b: u8): u8 {
a + b
}
#[test]
fun some_other() {
let sum = add(1, 2); // add(1, 2) is an expression with type u8
}
控制流表达式
控制流表达式用于控制程序的流程。它们也是表达式,因此会返回值。我们将在 控制流 一节介绍控制流表达式。这里是一个非常简短的概述:
// if is an expression, so it returns a value; if there are 2 branches,
// the types of the branches must match.
if (bool_expr) expr1 else expr2;
// while is an expression, but it returns `()`.
while (bool_expr) { expr; };
// loop is an expression, but returns `()` as well.
loop { expr; break };
使用结构体定义自定义类型
在 Move 的类型系统中,定义自定义类型可以根据应用程序的特定需求进行定制。这不仅仅局限于数据层面,还包括其行为。本节介绍了结构体的定义及其使用方法。
结构体
要定义自定义类型,可以使用 struct
关键字后跟类型的名称。在名称之后,可以定义结构体的字段。每个字段使用 field_name: field_type
语法进行定义,字段定义之间用逗号分隔。字段可以是任何类型,包括其他结构体。
Move 不支持递归结构体,这意味着结构体不能包含自身作为字段。
/// A struct representing an artist.
public struct Artist {
/// The name of the artist.
name: String,
}
/// A struct representing a music record.
public struct Record {
/// The title of the record.
title: String,
/// The artist of the record. Uses the `Artist` type.
artist: Artist,
/// The year the record was released.
year: u16,
/// Whether the record is a debut album.
is_debut: bool,
/// The edition of the record.
edition: Option<u16>,
}
在上面的示例中,我们定义了一个 Record
结构体,它包含五个字段:title
字段是 String
类型,artist
字段是 Artist
类型,year
字段是 u16
类型,is_debut
字段是 bool
类型,edition
字段是 Option<u16>
类型。edition
字段的类型是 Option<u16>
,表示版本号是可选的。
结构体默认是私有的,意味着它们不能被导入和在定义之外的模块中使用。它们的字段也是私有的,无法从模块外部访问。有关不同可见性修饰符的更多信息,请参阅可见性章节。
结构体的字段是私有的,只能由定义结构体的模块访问。要在其他模块中读取和写入结构体的字段,必须由定义结构体的模块提供公共函数来访问字段。
创建和使用实例
我们已经描述了结构体的定义方式。现在让我们看看如何初始化结构体并使用它。结构体可以使用 struct_name { field1: value1, field2: value2, ... }
的语法进行初始化。字段的初始化顺序可以任意,但必须设置所有字段。
let mut artist = Artist {
name: b"The Beatles".to_string()
};
在上面的示例中,我们创建了 Artist
结构体的一个实例,并将 name
字段设置为字符串 "The Beatles"。
要访问结构体的字段,可以使用 .
运算符后跟字段名。
// Access the `name` field of the `Artist` struct.
let artist_name = artist.name;
// Access a field of the `Artist` struct.
assert!(artist.name == string::utf8(b"The Beatles"), 0);
// Mutate the `name` field of the `Artist` struct.
artist.name = string::utf8(b"Led Zeppelin");
// Check that the `name` field has been mutated.
assert!(artist.name == string::utf8(b"Led Zeppelin"), 1);
只有定义结构体的模块才能访问其字段(可变和不可变)。因此,上述代码应该与 Artist
结构体位于同一个模块中。
解构结构体
结构体默认是非可丢弃的,这意味着初始化的结构体值必须被使用:要么存储,要么进行 解构。解构结构体意味着将其拆解为其各个字段。可以使用 let
关键字后跟结构体名称和字段名称来完成解构。
// Unpack the `Artist` struct and create a new variable `name`
// with the value of the `name` field.
let Artist { name } = artist;
在上面的示例中,我们解构 Artist
结构体,并创建了一个新变量 name
,其值为 name
字段的值。因为变量未被使用,编译器会发出警告。为了消除警告,可以使用下划线 _
表示变量是有意未使用的。
// Unpack the `Artist` struct and ignore the `name` field.
let Artist { name: _ } = artist;
进一步阅读
- 在 Move 参考中查看 结构体。
能力:简介
Move 具有独特的类型系统,允许自定义 类型能力。
在前一节中,我们介绍了struct
定义及其用法。
但是,Artist
和Record
结构体的实例在编译代码时必须被解包。这是没有 能力 的结构体的默认行为。
在本书中,你会看到名称为“Ability: <名称>”的章节,其中“<名称>”是能力的名称。这些章节将详细介绍该能力,它如何工作以及如何在 Move 中使用。
什么是能力?
能力是一种允许类型具有特定行为的方式。它们是结构体声明的一部分,并定义了结构体实例允许的行为。
能力语法
使用has
关键字后跟能力列表在结构体定义中设置能力。能力之间用逗号分隔。Move 支持四种能力:copy
、drop
、key
和store
,每种能力用于定义结构体实例的特定行为。
/// 这个结构体具有 `copy` 和 `drop` 能力。
struct VeryAble has copy, drop {
// field: Type1,
// field2: Type2,
// ...
}
概述
能力的快速概述:
除引用外,所有内置类型都具有
copy
、drop
和store
能力。引用具有copy
和drop
能力。
copy
- 允许结构体被 复制。详见Ability: Copy章节。drop
- 允许结构体被 丢弃 或 舍弃。详见Ability: Drop章节。key
- 允许结构体用作存储中的 键。详见Ability: Key章节。store
- 允许结构体 存储 在具有 key 能力的结构体中。详见Ability: Store章节。
虽然在这里提到它们很重要,但我们将在后续章节中详细介绍每种能力,并给出如何使用它们的适当上下文。
没有能力
没有能力的结构体不能被丢弃、复制或存储在存储中。我们称这种结构体为 Hot Potato。这是一个玩笑,但也是记住没有能力的结构体就像一个烫手山芋的好方法——它只能被传递,需要特殊处理。Hot Potato 是 Move 中最强大的模式之一,我们在Hot Potato章节中详细介绍了它。
延伸阅读
- Move参考中的类型能力。
能力:Drop
drop
能力是最简单的能力,允许对结构体的实例进行“忽略”或“丢弃”。在许多编程语言中,这被认为是默认行为。然而,在Move中,不允许忽略没有drop
能力的结构体。这是Move语言的一个安全特性,它确保所有资产都得到正确处理。试图忽略没有drop
能力的结构体将导致编译错误。
module book::drop_ability {
/// This struct has the `drop` ability.
public struct IgnoreMe has drop {
a: u8,
b: u8,
}
/// This struct does not have the `drop` ability.
public struct NoDrop {}
#[test]
// Create an instance of the `IgnoreMe` struct and ignore it.
// Even though we constructed the instance, we don't need to unpack it.
fun test_ignore() {
let no_drop = NoDrop {};
let _ = IgnoreMe { a: 1, b: 2 }; // no need to unpack
// The value must be unpacked for the code to compile.
let NoDrop {} = no_drop; // OK
}
}
drop
能力通常在自定义的集合类型上使用,以消除在不再需要集合时的特殊处理需求。例如,vector
类型具有 drop
能力,这使得在不再需要时可以忽略该向量。然而,Move类型系统最大的特点是能够没有 drop
。这确保了资产得到正确处理,而不被忽略。
一个仅具有 drop
能力的结构体称为 Witness。我们在见证和抽象实现部分解释了 Witness 的概念。
带有 drop
能力的类型
Move中的所有原生类型都具有 drop
能力。包括:
标准库中定义的所有类型也都具有 drop
能力。包括:
进一步阅读
- Move参考中的Type Abilities。
导入模块
Move 通过允许模块导入来实现高模块化和代码重用。同一个包中的模块可以互相导入,新的包也可以依赖现有的包并使用它们的模块。本节将介绍导入模块的基础知识以及如何在您自己的代码中使用它们。
导入模块
在同一个包中定义的模块可以互相导入。use
关键字后面跟着模块路径,该路径由包地址(或别名)和模块名称组成,两者之间用 ::
分隔。
// 文件: sources/module_one.move
module book::module_one {
/// Struct defined in the same module.
public struct Character has drop {}
/// Simple function that creates a new `Character` instance.
public fun new(): Character { Character {} }
}
同一个包中定义的另一个模块可以使用 use
关键字导入第一个模块。
// 文件: sources/module_two.move
module book::module_two {
use book::module_one; // importing module_one from the same package
/// Calls the `new` function from the `module_one` module.
public fun create_and_ignore() {
let _ = module_one::new();
}
}
导入成员
您还可以从模块中导入特定成员。这在您只需要来自模块的单个函数或单个类型时非常有用。语法与导入模块相同,但您在模块路径之后添加成员名称。
module book::more_imports {
use book::module_one::new; // imports the `new` function from the `module_one` module
use book::module_one::Character; // importing the `Character` struct from the `module_one` module
/// Calls the `new` function from the `module_one` module.
public fun create_character(): Character {
new()
}
}
分组导入
可以使用花括号 {}
将导入分组到单个 use
语句中。当您需要从同一个模块导入多个成员时,这非常有用。Move 允许对来自同一个模块和来自同一个包的导入进行分组。
module book::grouped_imports {
// imports the `new` function and the `Character` struct from
/// the `module_one` module
use book::module_one::{new, Character};
/// Calls the `new` function from the `module_one` module.
public fun create_character(): Character {
new()
}
}
在 Move 中,单个函数的导入并不常见,因为函数名称可能会重叠并引起混淆。推荐的做法是导入整个模块并使用模块路径来访问函数。类型具有唯一名称,应该单独导入。
要将成员和模块本身一起导入到组导入中,可以使用 Self
关键字。Self
关键字指的是模块本身,可用于导入模块及其成员。
module book::self_imports {
// imports the `Character` struct, and the `module_one` module
use book::module_one::{Self, Character};
/// Calls the `new` function from the `module_one` module.
public fun create_character(): Character {
module_one::new()
}
}
解决命名冲突
从不同模块导入多个成员时,可能会出现命名冲突。例如,如果您导入了两个都具有相同名称的函数的模块,则需要使用模块路径来访问该函数。不同的包中也可能存在具有相同名称的模块。为了解决冲突并避免歧义,Move 提供了 as
关键字来重命名导入的成员。
module book::conflict_resolution {
// `as` can be placed after any import, including group imports
use book::module_one::{Self as mod, Character as Char};
/// Calls the `new` function from the `module_one` module.
public fun create(): Char {
mod::new()
}
}
添加外部依赖项
通过 sui
二进制程序生成的新包都包含一个 Move.toml
文件,其中对 Sui 框架 包有一个依赖项。Sui 框架依赖于 标准库 包。这两个包都可以在默认配置中使用。包依赖项在 包清单 中定义如下:
[dependencies]
Sui = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/sui-framework", rev = "framework/testnet" }
Local = { local = "../my_other_package" }
dependencies
部分包含了一个包依赖项列表。键是包的名称,值是 git 导入表或本地路径。git 导入表包含包的 URL、包所在的子目录以及包的修订版本。本地路径是包目录的相对路径。
如果在 Move.toml
文件中添加了依赖项,编译器在构建包时会自动获取(稍后会重新获取)依赖项。
从另一个包导入模块
通常,包会在 [addresses]
部分定义它们的地址,因此您可以使用别名代替地址。例如,您可以使用 sui::coin
模块代替 0x2::coin
模块。sui
别名在 Sui 框架包中定义。类似地,std
别名在标准库包中定义,可用于访问标准库模块。
要从另一个包导入模块,请使用 use
关键字后跟模块路径。模块路径由包地址(或别名)和模块名称组成,两者之间用 ::
分隔。
module book::imports {
use std::string; // std = 0x1, string is a module in the standard library
use sui::coin; // sui = 0x2, coin is a module in the Sui Framework
}
标准库
Move 标准库提供了用于本机类型和操作的功能集合。它是一组标准的模块集,不涉及存储操作,而是提供基本工具来处理和操作数据。它是Sui 框架的唯一依赖,并与之一起导入使用。
最常见的模块
在本书中,我们详细讨论了标准库中大多数模块,但也有助于概述其功能,让您对可用的功能和实现它们的模块有所了解。
模块 | 描述 | 章节 |
---|---|---|
std::string | 提供基本的字符串操作 | 字符串 |
std::ascii | 提供基本的 ASCII 操作 | 字符串 |
std::option | 实现 Option<T> 类型 | Option |
std::vector | 对向量类型进行本地操作 | Vector |
std::bcs | 包含 bcs::to_bytes() 函数 | BCS |
std::address | 包含单一的 address::length 函数 | 地址 |
std::type_name | 允许运行时类型反射 | 类型反射 |
std::hash | 哈希函数:sha2_256 和 sha3_256 | 加密和哈希 |
std::debug | 包含调试函数,仅在 测试 模式下可用 | 调试 |
std::bit_vector | 提供位向量操作 | - |
std::fixed_point32 | 提供 FixedPoint32 类型 | - |
导出的地址
标准库导出了一个命名地址 - std = 0x1
。
[addresses]
std = "0x1"
隐式导入
一些模块会隐式导入,可以在模块中直接使用而无需显式导入。对于标准库来说,这些模块和类型包括:
- std::vector
- std::option
- std::option::Option
不依赖于 Sui 框架导入 std
Move 标准库可以直接导入到包中。然而,仅导入 std
是不足以构建有意义的应用程序,因为它不提供任何存储能力,也不能与链上状态进行交互。
MoveStdlib = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/move-stdlib", rev = "framework/mainnet" }
源代码
Move 标准库的源代码可在Sui 仓库中找到。
向量
向量是在 Move 中存储元素集合的一种本地方式。它们类似于其他编程语言中的数组,但有一些不同之处。在本节中,我们介绍 vector
类型及其操作。
向量语法
vector
类型使用 vector
关键字定义,后跟尖括号中的元素类型。元素的类型可以是任何有效的 Move 类型,包括其他向量。Move 提供了一种向量字面量语法,允许你使用 vector
关键字后跟方括号来创建向量,其中包含元素(或者对于空向量不包含任何元素)。
// An empty vector of bool elements.
let empty: vector<bool> = vector[];
// A vector of u8 elements.
let v: vector<u8> = vector[10, 20, 30];
// A vector of vector<u8> elements.
let vv: vector<vector<u8>> = vector[
vector[10, 20],
vector[30, 40]
];
vector
类型是 Move 中的内置类型,不需要从模块中导入。然而,向量操作定义在 std::vector
模块中,你需要导入该模块才能使用这些操作。
向量操作
标准库提供了许多操作向量的方法。以下是一些常用的操作:
push_back
: 在向量末尾添加一个元素。pop_back
: 移除向量的最后一个元素。length
: 返回向量中元素的数量。is_empty
: 如果向量为空则返回 true。remove
: 移除给定索引处的元素。
let mut v = vector[10u8, 20, 30];
assert!(v.length() == 3, 0);
assert!(!v.is_empty(), 1);
v.push_back(40);
let last_value = v.pop_back();
assert!(last_value == 40, 2);
销毁非可丢弃类型的向量
非可丢弃类型的向量不能被丢弃。如果你定义了一个没有 drop
能力的类型的向量,则该向量的值不能被忽略。然而,如果向量是空的,编译器要求显式调用 destroy_empty
函数。
/// A struct without `drop` ability.
public struct NoDrop {}
#[test]
fun test_destroy_empty() {
// Initialize a vector of `NoDrop` elements.
let v = vector<NoDrop>[];
// While we know that `v` is empty, we still need to call
// the explicit `destroy_empty` function to discard the vector.
v.destroy_empty();
}
进一步阅读
- 在 Move 参考文档中查看 向量。
Option(选项)
Option 是一种表示可选值的类型,它可能存在,也可能不存在。Move 中的 Option 概念借鉴自 Rust,它是 Move 中非常有用的原语。Option
在标准库中定义,如下所示:
文件:move-stdlib/source/option.move
// 文件:move-stdlib/source/option.move
/// 可能存在,也可能不存在的值的抽象。
struct Option<Element> has copy, drop, store {
vec: vector<Element>
}
'std::option' 模块在每个模块中都被隐式导入,您不需要添加导入语句。
Option
是一个泛型类型,它接受类型参数 Element
。它有一个名为 vec
的字段,类型为 vector
,存储着 Element
的值。Vector 的长度可以为 0 或 1,用于表示值的存在或不存在。
Option 类型有两个变体:Some
和 None
。Some
变体包含一个值,而 None
变体表示值的不存在。Option
类型用于以类型安全的方式表示值的缺失,并避免使用空或undefined
值。
实际应用
为了展示为什么 Option 类型是必要的,让我们看一个例子。考虑一个应用程序,它接受用户输入并将其存储在一个变量中。有些字段是必需的,而有些是可选的。例如,用户的中间名是可选的。虽然我们可以使用空字符串表示中间名的缺失,但这将需要额外的检查来区分空字符串和缺失的中间名。相反,我们可以使用 Option
类型来表示中间名。
module book::user_registry {
use std::string::String;
/// A struct representing a user record.
public struct User has drop {
first_name: String,
middle_name: Option<String>,
last_name: String,
}
/// Create a new `User` struct with the given fields.
public fun register(
first_name: String,
middle_name: Option<String>,
last_name: String,
): User {
User { first_name, middle_name, last_name }
}
}
在上面的示例中,middle_name
字段的类型是 Option<String>
。这意味着 middle_name
字段可以包含一个 String
值,也可以为空。这样清晰地表示了中间名是可选的,并避免了额外的检查来区分空字符串和缺失的中间名。
使用 Option
要使用 Option
类型,您需要导入 std::option
模块并使用 Option
类型。然后,您可以使用 some
或 none
方法创建一个 Option
值。
// `option::some` creates an `Option` value with a value.
let mut opt = option::some(b"Alice");
// `option.is_some()` returns true if option contains a value.
assert!(opt.is_some(), 1);
// internal value can be `borrow`ed and `borrow_mut`ed.
assert!(opt.borrow() == &b"Alice", 0);
// `option.extract` takes the value out of the option, leaving the option empty.
let inner = opt.extract();
// `option.is_none()` returns true if option is None.
assert!(opt.is_none(), 2);
字符串
虽然 Move 没有内置的字符串类型,但它在标准库中提供了两种字符串的标准实现。std::string
模块定义了一个 String
类型和处理 UTF-8 编码字符串的方法,而第二个模块 std::ascii
则提供了 ASCII String
类型及其方法。
Sui 执行环境会自动将字节向量转换为事务输入中的
String
。因此,在许多情况下,不需要在事务块中构造字符串。
字符串即字节
无论使用哪种类型的字符串,重要的是要知道字符串只是字节。string
和 ascii
模块提供的封装只是封装而已。它们提供了安全检查和与字符串相关的方法,但归根结底,它们只是字节的向量。
module book::custom_string {
/// Anyone can implement a custom string-like type by wrapping a vector.
public struct MyString {
bytes: vector<u8>,
}
/// Implement a `from_bytes` function to convert a vector of bytes to a string.
public fun from_bytes(bytes: vector<u8>): MyString {
MyString { bytes }
}
/// Implement a `bytes` function to convert a string to a vector of bytes.
public fun bytes(self: &MyString): &vector<u8> {
&self.bytes
}
}
使用 UTF-8 字符串
虽然标准库中有两种类型的字符串,但应该将 string
模块视为默认选项。它具有许多常见操作的本地实现,因此比完全在 Move 中实现的 ascii
模块更高效。
定义
std::string
模块中的 String
类型定义如下:
// 文件:move-stdlib/sources/string.move
/// `String` 保存一个字节序列,该序列保证是 utf8 格式的。
public struct String has copy, drop, store {
bytes: vector<u8>,
}
创建字符串
要创建一个新的 UTF-8 String
实例,可以使用 string::utf8
方法。为了方便起见,标准库还为 vector<u8>
提供了别名 .to_string()
。
// the module is `std::string` and the type is `String`
use std::string::{Self, String};
// strings are normally created using the `utf8` function
// type declaration is not necessary, we put it here for clarity
let hello: String = string::utf8(b"Hello");
// The `.to_string()` alias on the `vector<u8>` is more convenient
let hello = b"Hello".to_string();
常见操作
UTF8 字符串提供了许多用于操作字符串的方法。字符串的常见操作包括连接、切片和获取长度。另外,对于自定义的字符串操作,可以使用 bytes()
方法获取底层字节向量。
let mut str = b"Hello,".to_string();
let another = b" World!".to_string();
// append(String) 将内容添加到字符串的末尾
str.append(another);
// `sub_string(start, end)` 复制字符串的一个子串
str.sub_string(0, 5); // "Hello"
// `length()` 返回字符串中的字节数
str.length(); // 12 (字节)
// 方法也可以链式调用!获取子串的长度
str.sub_string(0, 5).length(); // 5 (字节)
// 字符串是否为空
str.is_empty(); // false
// 获取底层字节向量以进行自定义操作
let bytes: &vector<u8> = str.bytes();
安全的 UTF-8 操作
默认的 utf8
方法在传入的字节不是有效的 UTF-8 时可能会中止。如果不确定传入的字节是否有效,应改用 try_utf8
方法。它返回一个 Option<String>
,如果字节不是有效的 UTF-8,则不包含值,否则包含一个字符串。
提示:以
try_*
开头的名称表示函数返回一个包含期望结果的 Option,如果操作失败,则返回none
。这是从 Rust 借用的常见命名约定。
// this is a valid UTF-8 string
let hello = b"Hello".try_to_string();
assert!(hello.is_some(), 0); // abort if the value is not valid UTF-8
// this is not a valid UTF-8 string
let invalid = b"\xFF".try_to_string();
assert!(invalid.is_none(), 0); // abort if the value is valid UTF-8
UTF-8 的限制
string
模块没有提供一种访问字符串中单个字符的方法。这是因为 UTF-8 是一种可变长度编码,并且一个字符的长度可以从 1 到 4 个字节不等。类似地,length()
方法返回的是字符串的字节数,而不是字符数。
然而,sub_string
和 insert
等方法会检查字符边界,并在索引位于字符中间时中止。
ASCII 字符串
本节内容即将推出!
控制流
控制流语句用于控制程序执行的流程。它们用于做出决策,重复执行代码块,以及提前退出代码块。Move 语言支持以下控制流语句(将在下文详细解释):
if
和if-else
- 根据条件决定是否执行一段代码loop
和while
循环 - 重复执行一段代码break
和continue
语句 - 提前退出循环return
语句 - 提前退出函数
条件语句
if
表达式用于在程序中做出决策。它会计算一个 布尔表达式,如果表达式为真,则执行一段代码块。配合 else
使用,则可以在表达式为假的情况下执行另一段代码块。
if
表达式的语法如下:
if (<布尔表达式>) <表达式>;
if (<布尔表达式>) <表达式> else <表达式>;
与任何其他表达式一样,如果后面还有其他表达式,if
也需要一个分号。else
关键字可选,除非结果值需要赋值给变量。稍后我们将介绍这种情况。
#[test]
fun test_if() {
let x = 5;
// `x > 0` is a boolean expression.
if (x > 0) {
std::debug::print(&b"X is bigger than 0".to_string())
};
}
让我们看看如何使用 if
和 else
将值赋给变量:
#[test]
fun test_if_else() {
let x = 5;
let y = if (x > 0) {
1
} else {
0
};
assert!(y == 1, 0);
}
这里我们将 if
表达式的值赋给变量 y
。如果 x
大于 0,则将值 1 赋给 y
,否则为 0。else
块是必需的,因为这两个分支都必须返回相同类型的返回值。如果省略 else
块,编译器会抛出一个错误。
条件表达式是 Move 中最重要的控制流语句之一。它们可以利用用户提供的输入或一些已经存储的数据来做出决策。特别地,它们用于 assert!
宏 中检查条件是否成立,如果不成立则中止执行。我们很快就会讲到这一点!
使用循环重复语句
循环用于多次执行一段代码块。Move 具有两种内置的循环类型:loop
和 while
。在许多情况下它们可以互换使用,但是通常情况下,当迭代次数提前知道时使用 while
循环,当迭代次数未知或者有多个退出点时使用 loop
循环。
循环在处理集合(例如向量)或在满足某个条件之前重复执行代码块时非常有用。但是,使用循环需要注意,因为它们可能导致无限循环,从而耗尽 Gas 并导致交易中止。
while
循环
while
语句用于只要布尔表达式为真就执行一段代码块。就像我们在 if
语句中看到的一样,布尔表达式会在循环的每次迭代之前进行计算。与条件语句一样,while
循环也是一个表达式,如果后面还有其他表达式,则需要一个分号。
while
循环的语法如下:
while (<布尔表达式>) { <表达式>; };
这是一个条件非常简单的 while
循环示例:
// This function iterates over the `x` variable until it reaches 10, the
// return value is the number of iterations it took to reach 10.
//
// If `x` is 0, then the function will return 10.
// If `x` is 5, then the function will return 5.
fun while_loop(mut x: u8): u8 {
let mut y = 0;
// This will loop until `x` is 10.
// And will never run if `x` is 10 or more.
while (x < 10) {
y = y + 1;
x = x + 1;
};
y
}
#[test]
fun test_while() {
assert!(while_loop(0) == 10, 0); // 10 times
assert!(while_loop(5) == 5, 0); // 5 times
assert!(while_loop(10) == 0, 0); // loop never executed
}
无限 loop
现在让我们想象一个布尔表达式始终为真的场景。例如,如果我们直接将 true
传递给 while
条件。正如你所料,这将创建一个无限循环,这几乎就是 loop
语句的工作方式。
#[test, expected_failure(out_of_gas, location=Self)]
fun test_infinite_while() {
let mut x = 0;
// This will loop forever.
while (true) {
x = x + 1;
};
// This line will never be executed.
assert!(x == 5, 0);
}
一个无限的 while
循环,或者没有条件的 while
循环,就是一个 loop
循环。它的语法很简单:
loop { <表达式>; };
让我们用 loop
而不是 while
重写前面的例子:
#[test, expected_failure(out_of_gas, location=Self)]
fun test_infinite_loop() {
let mut x = 0;
// This will loop forever.
loop {
x = x + 1;
};
// This line will never be executed.
assert!(x == 5, 0);
}
无限循环本身在 Move 中并不是非常有用,因为 Move 中的每个操作都需要 Gas,无限循环会导致 Gas 耗尽。但是,它们可以与 break
和 continue
语句结合使用来创建更复杂的循环。
提前退出循环
正如我们已经提到的,无限循环本身
常量
常量是在模块级别定义的不可变值。它们通常用作给模块中使用的静态值命名的一种方式。例如,如果有一个产品的默认价格,可以为其定义一个常量。常量存储在模块的字节码中,每次使用时,值都会被复制。
module book::shop_price {
use sui::coin::Coin;
use sui::sui::SUI;
/// The price of an item in the shop.
const ITEM_PRICE: u64 = 100;
/// The owner of the shop, an address.
const SHOP_OWNER: address = @0xa11ce;
/// An item sold in the shop.
public struct Item { /* ... */ }
/// Purchase an item from the shop.
public fun purchase(coin: Coin<SUI>): Item {
assert!(coin.value() == ITEM_PRICE, 0);
transfer::public_transfer(coin, SHOP_OWNER);
Item { /* ... */ }
}
}
命名约定
常量必须以大写字母开头 - 这在编译器级别是强制执行的。对于用作值的常量,有一个约定,使用大写字母和下划线来分隔单词。这是一种让常量在代码中突出显示的方式。一个例外是错误常量,它们使用ECamelCase编写。
/// Price of the item used at the shop.
const ITEM_PRICE: u64 = 100;
/// Error constant.
const EItemNotFound: u64 = 1;
常量是不可变的
常量无法更改或赋予新值。它们是包字节码的一部分,并且是固有的不可变的。
module book::immutable_constants {
const ITEM_PRICE: u64 = 100;
// 会产生错误
fun change_price() {
ITEM_PRICE = 200;
}
}
使用配置模式
应用程序的常见用例是定义一组在整个代码库中使用的常量。但是由于常量是模块的私有内容,无法从其他模块中访问。解决这个问题的方法之一是定义一个"config"模块,导出常量。
module book::config {
const ITEM_PRICE: u64 = 100;
const TAX_RATE: u64 = 10;
const SHIPPING_COST: u64 = 5;
/// Returns the price of an item.
public fun item_price(): u64 { ITEM_PRICE }
/// Returns the tax rate.
public fun tax_rate(): u64 { TAX_RATE }
/// Returns the shipping cost.
public fun shipping_cost(): u64 { SHIPPING_COST }
}
这样,其他模块可以导入和读取常量,并简化更新过程。如果需要更改常量,只需要在包升级期间更新配置模块即可。
链接
终止执行
区块链上的交易可以成功,也可以失败。成功执行的交易会将所有对对象和链上数据的改动应用,并且该交易会被提交到区块链上。如果交易失败(abort),则不会应用这些改动。abort
关键字用于中止交易,并撤销到目前为止进行的所有更改。
需要注意的是,Move 语言中没有类似其他语言的捕获机制 (catch mechanism)。如果交易中止,到目前为止进行的所有更改都将被撤销,并且该交易被视为失败。
终止 (Abort)
abort
关键字用于中止交易的执行。它与中止代码 (abort code) 一起使用,该代码会返回给交易的调用者。中止代码是一个无符号 64 位整数 (u64
)。
let user_has_access = true;
// abort with a predefined constant if `user_has_access` is false
if (!user_has_access) {
abort 0
};
// there's an alternative syntax using parenthesis`
if (user_has_access) {
abort(1)
};
上面的代码肯定会中止执行,并返回中止代码 1
。
断言 (assert!)
assert!
是一个内置宏,用于断言一个条件是否成立。如果条件为假,则交易会中止,并返回给定的中止代码。assert!
宏提供了一种方便的方法,可以在条件不满足时中止交易。该宏可以替代使用 if
语句和 abort
来编写的代码。code
参数是必需的,它必须是一个 u64
值。
// aborts if `user_has_access` is `false` with abort code 0
assert!(user_has_access, 0);
// expands into:
if (!user_has_access) {
abort 0
};
错误代码
为了使中止代码更具描述性,定义 错误代码 是一个好习惯。错误代码使用 const
关键字声明,通常以 E
开头,后面跟驼峰式命名。错误代码与其他常量没有什么不同,没有特殊的处理方式,但是它们可以提高代码的可读性,并使人们更容易理解中止场景。
/// Error code for when the user has no access.
const ENoAccess: u64 = 0;
/// Trying to access a field that does not exist.
const ENoField: u64 = 1;
/// Updates a record.
public fun update_record(/* ... , */ user_has_access: bool, field_exists: bool) {
// asserts are way more readable now
assert!(user_has_access, ENoAccess);
assert!(field_exists, ENoField);
/* ... */
}
进一步阅读
函数是 Move 程序的基本构建块。它们可以从用户交互中调用,也可以从其他函数中调用,并将可执行的代码组织成可重用的单元。函数可以接受参数并返回值。在模块级别,它们使用 fun
关键字声明。默认情况下,它们是私有的,只能在模块内部访问。
module book::math {
/// Function takes two arguments of type `u64` and returns their sum.
/// The `public` visibility modifier makes the function accessible from
/// outside the module.
public fun add(a: u64, b: u64): u64 {
a + b
}
#[test]
fun test_add() {
let sum = add(1, 2);
assert!(sum == 3, 0);
}
}
在这个示例中,我们定义了一个名为 add
的函数,它接受两个类型为 u64
的参数,并返回它们的和。该函数被从同一模块中的 test_add
函数调用,后者是一个测试函数。在测试中,我们将 add
函数的结果与期望值进行比较,如果结果不同,则终止执行。
函数声明
在 Move 中,有一个约定,即使用
snake_case
命名函数。这意味着函数名称应全部小写,并用下划线分隔单词。例如,do_something
、add
、get_balance
、is_authorized
等等。
函数使用 fun
关键字声明,后跟函数名称(有效的 Move 标识符),括号中是参数列表,以及返回类型。函数体是一段包含语句和表达式的代码块。函数体中的最后一个表达式是函数的返回值。
fun return_nothing() {
// empty expression, function returns `()`
}
访问函数
与任何其他模块成员一样,函数可以通过路径导入和访问。路径由模块路径和函数名称组成,用 ::
分隔。例如,如果在 book
包的 math
模块中有一个名为 add
的函数,则其路径为 book::math::add
;如果模块已导入,则为 math::add
。
module book::use_math {
use book::math;
fun call_add() {
// function is called via the path
let sum = math::add(1, 2);
}
}
多返回值
Move 函数可以返回多个值,这在需要从函数返回多个值时非常有用。函数的返回类型是类型的元组。返回值是表达式的元组。
fun get_name_and_age(): (vector<u8>, u8) {
(b"John", 25)
}
具有元组返回的函数调用的结果必须通过 let (tuple)
语法解包到变量中:
// Tuple must be destructured to access its elements.
// Name and age are declared as immutable variables.
let (name, age) = get_name_and_age();
assert!(name == b"John", 0);
assert!(age == 25, 0);
如果声明的某些值需要声明为可变的,则在变量名之前放置 mut
关键字:
// declare name as mutable, age as immutable
let (mut name, age) = get_name_and_age();
如果某些参数未被使用,则可以用 _
符号忽略它们:
// ignore the name, only use the age
let (_, age) = get_name_and_age();
进一步阅读
- Functions 在 Move 参考文档中
结构体方法
Move 编译器支持_接收者语法_,允许在结构体实例上定义可调用的方法。这类似于其他编程语言中的方法语法。这是一种方便的方式,可以在结构体的字段上定义操作。
方法语法
如果函数的第一个参数是模块内部的结构体,则可以使用 .
运算符调用该函数。如果函数使用另一个模块中的结构体,则默认不会将方法与结构体关联起来。在这种情况下,可以使用标准的函数调用语法来调用该函数。
当导入一个模块时,方法会自动与结构体关联起来。
module book::hero {
/// A struct representing a hero.
public struct Hero has drop {
health: u8,
mana: u8,
}
/// Create a new Hero.
public fun new(): Hero { Hero { health: 100, mana: 100 } }
/// A method which casts a spell, consuming mana.
public fun heal_spell(hero: &mut Hero) {
hero.health = hero.health + 10;
hero.mana = hero.mana - 10;
}
/// A method which returns the health of the hero.
public fun health(hero: &Hero): u8 { hero.health }
/// A method which returns the mana of the hero.
public fun mana(hero: &Hero): u8 { hero.mana }
#[test]
// Test the methods of the `Hero` struct.
fun test_methods() {
let mut hero = new();
hero.heal_spell();
assert!(hero.health() == 110, 1);
assert!(hero.mana() == 90, 2);
}
}
方法别名
对于定义多个结构体及其方法的模块,可以定义方法别名来避免名称冲突,或为结构体提供更好的方法名。
别名的语法如下:
// 用于本地方法关联
use fun function_path as Type.method_name;
// 公共别名
public use fun function_path as Type.method_name;
公共别名只允许用于同一模块中定义的结构体。如果结构体在另一个模块中定义,仍然可以创建别名,但不能公开。
在下面的示例中,我们更改了 hero
模块,并添加了另一种类型 - Villain
。Hero
和 Villain
都具有类似的字段名称和方法。为了避免名称冲突,我们为这些方法添加了前缀 hero_
和 villain_
。但是,我们可以为这些方法创建别名,以便在结构体实例上调用时不需要前缀。
module book::hero_and_villain {
/// A struct representing a hero.
public struct Hero has drop {
health: u8,
}
/// A struct representing a villain.
public struct Villain has drop {
health: u8,
}
/// Create a new Hero.
public fun new_hero(): Hero { Hero { health: 100 } }
/// Create a new Villain.
public fun new_villain(): Villain { Villain { health: 100 } }
// Alias for the `hero_health` method. Will be imported automatically when
// the module is imported.
public use fun hero_health as Hero.health;
public fun hero_health(hero: &Hero): u8 { hero.health }
// Alias for the `villain_health` method. Will be imported automatically
// when the module is imported.
public use fun villain_health as Villain.health;
public fun villain_health(villain: &Villain): u8 { villain.health }
#[test]
// Test the methods of the `Hero` and `Villain` structs.
fun test_associated_methods() {
let hero = new_hero();
assert!(hero.health() == 100, 1);
let villain = new_villain();
assert!(villain.health() == 100, 3);
}
}
正如你所看到的,在测试函数中,我们在 Hero
和 Villain
的实例上调用了 health
方法,而不使用前缀。编译器将自动将方法与结构体关联起来。
别名一个外部模块的方法
还可以将在另一个模块中定义的函数与当前模块的结构体关联起来。按照相同的方法,我们可以为在另一个模块中定义的方法创建别名。让我们使用标准库中的 bcs::to_bytes
方法,并将其与 Hero
结构体关联起来。这将允许将 Hero
结构体序列化为字节向量。
// TODO: better example (external module...)
module book::hero_to_bytes {
// Alias for the `bcs::to_bytes` method. Imported aliases should be defined
// in the top of the module.
// public use fun bcs::to_bytes as Hero.to_bytes;
/// A struct representing a hero.
public struct Hero has drop {
health: u8,
mana: u8,
}
/// Create a new Hero.
public fun new(): Hero { Hero { health: 100, mana: 100 } }
#[test]
// Test the methods of the `Hero` struct.
fun test_hero_serialize() {
// let mut hero = new();
// let serialized = hero.to_bytes();
// assert!(serialized.length() == 3, 1);
}
}
进一步阅读
- 在 Move 参考中的 方法语法。
可见性修饰符
每个模块成员都有一个可见性。默认情况下,所有模块成员都是私有的,意味着它们只能在定义它们的模块内部访问。然而,你可以添加可见性修饰符来使模块成员对外部可见,或者对同一包内的模块可见,还有一种是入口类型,这可以在事务中调用,但不能从其他模块中调用。
内部可见性
在没有可见性修饰符的情况下定义在模块中的函数或结构体是私有的,它们无法从其他模块中调用。
module book::internal_visibility {
// 这个函数只能在同一模块的其他函数中调用
fun internal() { /* ... */ }
// 同一模块 -> 可以调用 internal()
fun call_internal() {
internal();
}
}
module book::try_calling_internal {
use book::internal_visibility;
// 不同模块 -> 无法调用 internal()
fun try_calling_internal() {
internal_visibility::internal();
}
}
公共可见性
通过在 fun
或 struct
关键字前添加 public
关键字,可以使结构体或函数变为公共可见性。
module book::public_visibility {
// 这个函数可以从其他模块中调用
public fun public() { /* ... */ }
}
公共函数可以被导入并从其他模块中调用。以下代码将会编译通过:
module book::try_calling_public {
use book::public_visibility;
// 不同模块 -> 可以调用 public()
fun try_calling_public() {
public_visibility::public();
}
}
包可见性
Move 2024 引入了 包可见性 修饰符。具有 包可见性 的函数可以从同一包内的任何模块中调用,但不能从其他包中调用。
module book::package_visibility {
public(package) fun package_only() { /* ... */ }
}
包函数可以从同一包内的任何模块中调用:
module book::try_calling_package {
use book::package_visibility;
// 同一包 `book` -> 可以调用 package_only()
fun try_calling_package() {
package_visibility::package_only();
}
}
所有权和作用域
Move 中的每个变量都拥有一个作用域和一个所有者。作用域是变量有效的代码范围,所有者是该变量所属的范围。一旦所有者作用域结束,变量就会被丢弃。这是 Move 的一个基本概念,理解它的工作原理非常重要。
所有权
在函数作用域中定义的变量由该作用域所有。运行时会遍历函数作用域并执行每个表达式和语句。一旦函数作用域结束,定义在其中的变量就会被丢弃或释放。
module book::ownership {
public fun owner() {
let a = 1; // a 由 `owner` 函数拥有
} // a 在此处被丢弃
public fun other() {
let b = 2; // b 由 `other` 函数拥有
} // b 在此处被丢弃
#[test]
fun test_owner() {
owner();
other();
// a 和 b 在此处无效
}
}
在上面的例子中,变量 a
由 owner
函数拥有,变量 b
由 other
函数拥有。当调用这些函数中的每一个时,都会定义变量,当函数结束后,变量就会被丢弃。
返回值
如果我们将 owner
函数改为返回变量 a
,那么 a
的所有权将被转移到函数的调用者。
module book::ownership {
public fun owner(): u8 {
let a = 1; // a 在此处定义
a // 作用域结束,a 被返回
}
#[test]
fun test_owner() {
let a = owner();
// a 在此处有效
} // a 在此处被丢弃
}
按值传递
此外,如果我们将变量 a
传递给另一个函数,则 a
的所有权将被转移到该函数。执行此操作时,我们将值从一个作用域 移动 到另一个作用域。这也被称为 move 语义。
module book::ownership {
public fun owner(): u8 {
let a = 10;
a
} // a 被返回
public fun take_ownership(v: u8) {
// v 由 `take_ownership` 拥有
} // v 在此处被丢弃
#[test]
fun test_owner() {
let a = owner();
take_ownership(a);
// a 在此处无效
}
}
具有块的作用域
每个函数都有一个主作用域,还可以通过使用块来拥有子作用域。块是一系列语句和表达式,它有自己的作用域。在块中定义的变量由该块拥有,当块结束时,变量将被丢弃。
module book::ownership {
public fun owner() {
let a = 1; // a 由 `owner` 函数的作用域拥有
{
let b = 2; // b 由块拥有
{
let c = 3; // c 由块拥有
}; // c 在此处被丢弃
}; // b 在此处被丢弃
// a = b; // 错误:b 在此处无效
// a = c; // 错误:c 在此处无效
} // a 在此处被丢弃
}
但是,如果我们使用块的返回值,则变量的所有权将被转移到块的调用者。
module book::ownership {
public fun owner(): u8 {
let a = 1; // a 由 `owner` 函数的作用域拥有
let b = {
let c = 2; // c 由块拥有
c // c 被返回
}; // c 在此处被丢弃
a + b // a 和 b 在此处都有效
}
}
可复制类型
Move 中的一些类型是可复制的,这意味着它们可以被复制而不转移所有权。这对于那些小而易于复制的类型(例如整数和布尔值)非常有用。Move 编译器会在将它们传递给函数或从函数返回时,或者当它们被 移动 到一个作用域然后在它们原来的作用域中访问时自动复制这些类型。
进一步阅读
- Move 参考中的 局部变量和作用域。
能力:复制
在 Move 中,类型上的 copy 能力表示该类型的实例或值可以被复制。尽管在处理数字或其他简单类型时,这种行为可能非常自然,但在 Move 中自定义类型默认不具备这种能力。这是因为 Move 旨在表达数字资产和资源,而无法复制是资源模型的一个关键要素。
然而,Move 类型系统允许您定义具有 copy 能力的自定义类型。
public struct Copyable has copy {}
在上面的例子中,我们定义了一个具有 copy 能力的自定义类型 Copyable
。这意味着 Copyable
的实例可以被复制,无论是隐式的还是显式的。
let a = Copyable {};
let b = a; // `a` is copied to `b`
let c = *&b; // explicit copy via dereference operator
let Copyable {} = a; // doesn't have `drop`
let Copyable {} = b; // doesn't have `drop`
let Copyable {} = c; // doesn't have `drop`
在上面的例子中,a
被隐式地复制到 b
,然后使用解引用操作符显式地复制到 c
。如果 Copyable
没有 copy 能力,代码将无法编译,Move 编译器会抛出错误。
复制与丢弃
copy
能力与 drop
能力 密切相关。如果一个类型具有 copy 能力,那么它很可能也应该具有 drop
能力。这是因为 drop 能力用于在实例不再需要时清理资源。如果一个类型只有 copy,那么管理它的实例会变得更加复杂,因为这些值不能被忽略。
public struct Value has copy, drop {}
Move 中的所有原始类型都表现得像是具有 copy 和 drop 能力。这意味着它们可以被复制和丢弃,并且 Move 编译器会为它们处理内存管理。
具有 copy
能力的类型
Move 中的所有本机类型都具有 copy
能力。这包括:
标准库中定义的所有类型也具有 copy
能力。这包括:
进一步阅读
- 类型能力 在 Move 参考中。
Sui Move 引用详解
在上一节 关于所有权和作用域的讨论中,我们解释了当值传递给函数时,它会移动到函数的作用域。这意味着函数成为该值的拥有者,原始作用域 (原始拥有者) 不再能使用它。这是 Move 中一个重要的概念,它确保值不会同时在多个地方使用。然而,在某些情况下,我们希望将值传递给函数,但仍保留对该值的拥有权。 这正是 引用发挥作用的地方。
为了说明这一点,让我们来看一个简单的例子 - 地铁票应用。我们将介绍 4 种不同的场景:
- 乘客可以在售票亭以固定价格购买地铁票。
- 可以向检票员出示地铁票以证明乘客拥有有效票卡。
- 可以在地铁闸口使用地铁票进入地铁,并扣除一次乘车次数。
- 地铁票用完后可以回收。
程序结构
地铁票应用的初始结构很简单。我们定义了 Card
类型和代表单张地铁票乘坐次数的常量 USES
。我们还添加了一个错误常量,用于处理地铁票用完的情况。
module book::metro_pass {
/// Error code for when the card is empty.
const ENoUses: u64 = 0;
/// Number of uses for a metro pass card.
const USES: u8 = 3;
/// A metro pass card
public struct Card { uses: u8 }
/// Purchase a metro pass card.
public fun purchase(/* pass a Coin */): Card {
Card { uses: USES }
}
}
引用
引用是一种向函数展示值而不放弃拥有权的方式。 在我们的例子中,当我们将地铁票出示给检票员时,我们不想放弃对它的拥有权,也不允许他们扣除乘车次数。我们只想允许检票员读取地铁票信息并验证其有效性。
为了做到这一点,在函数签名中,我们使用符号 &
表示我们传递的是值的引用,而不是值本身。
/// Show the metro pass card to the inspector.
public fun is_valid(card: &Card): bool {
card.uses > 0
}
现在,函数无法获得地铁票的所有权,也不能扣除乘车次数。但是它可以读取地铁票信息。值得注意的是,这样的函数签名使得不带地铁票调用该函数变得不可能。这是一个重要的特性,它允许我们在下一章节讨论的 能力模式。
可变引用
在某些情况下,我们希望允许函数更改地铁票的值。例如,当我们在闸口使用地铁票时,我们想要扣除一次乘车次数。为了实现这一点,我们在函数签名中使用关键字 &mut
。
/// Use the metro pass card at the turnstile to enter the metro.
public fun enter_metro(card: &mut Card) {
assert!(card.uses > 0, ENoUses);
card.uses = card.uses - 1;
}
正如您在函数体中看到的,&mut
引用允许修改值,函数可以扣除乘车次数。
按值传递
最后,让我们来看一下将值本身传递给函数会发生什么。在这种情况下,函数获取该值的拥有权,并且原始作用域将无法再使用它。地铁票的所有者可以回收它,因此失去拥有权。
/// Recycle the metro pass card.
public fun recycle(card: Card) {
assert!(card.uses == 0, ENoUses);
let Card { uses: _ } = card;
}
在 recycle
函数中,地铁票被 按值获取,可以解包并销毁。原始作用域无法再使用它。
完整示例
为了展示应用程序的完整流程,让我们将所有部分组合成一个测试。
#[test]
fun test_card_2024() {
// declaring variable as mutable because we modify it
let mut card = purchase();
card.enter_metro(); // modify the card but don't move it
assert!(card.is_valid(), 0); // read the card!
card.enter_metro(); // modify the card but don't move it
card.enter_metro(); // modify the card but don't move it
card.recycle(); // move the card out of the scope
}
泛型
泛型是一种定义可以适用于任何类型的类型或函数的方式。当你希望编写一个可以与不同类型一起使用的函数,或者当你希望定义一个可以容纳任何其他类型的类型时,泛型就非常有用。泛型是Move中许多高级功能的基础,例如集合、抽象实现等。
标准库中的泛型
在本章中,我们已经提到了 vector 类型,它是一个可以容纳任何其他类型的泛型类型。标准库中另一个泛型类型的例子是 Option 类型,它用于表示可能存在或不存在的值。
泛型语法
要定义一个泛型类型或函数,类型签名必须具有一个用尖括号(<
和>
)括起来的泛型参数列表。泛型参数之间用逗号分隔。
/// Container for any type `T`.
public struct Container<T> has drop {
value: T,
}
/// Function that creates a new `Container` with a generic value `T`.
public fun new<T>(value: T): Container<T> {
Container { value }
}
在上面的示例中,Container
是一个具有单个类型参数 T
的泛型类型,容器的 value
字段存储了 T
类型的值。new
函数是一个具有单个类型参数 T
的泛型函数,它返回一个带有给定值的 Container
。泛型类型必须使用具体类型进行初始化,而泛型函数必须使用具体类型进行调用。
#[test]
fun test_container() {
// these three lines are equivalent
let container: Container<u8> = new(10); // type inference
let container = new<u8>(10); // create a new `Container` with a `u8` value
let container = new(10u8);
assert!(container.value == 10, 0x0);
// Value can be ignored only if it has the `drop` ability.
let Container { value: _ } = container;
}
在测试函数 test_generic
中,我们展示了三种等效的方法来创建一个具有 u8
值的新 Container
。由于数字类型需要进行推断,我们指定了数字字面量的类型。
多个类型参数
可以定义具有多个类型参数的类型或函数。类型参数之间用逗号分隔。
/// A pair of values of any type `T` and `U`.
public struct Pair<T, U> {
first: T,
second: U,
}
/// Function that creates a new `Pair` with two generic values `T` and `U`.
public fun new_pair<T, U>(first: T, second: U): Pair<T, U> {
Pair { first, second }
}
在上面的示例中,Pair
是一个具有两个类型参数 T
和 U
的泛型类型,new_pair
函数是一个具有两个类型参数 T
和 U
的泛型函数。该函数返回一个带有给定值的 Pair
。类型参数的顺序很重要,它应该与类型签名中的类型参数顺序匹配。
#[test]
fun test_generic() {
// these three lines are equivalent
let pair_1: Pair<u8, bool> = new_pair(10, true); // type inference
let pair_2 = new_pair<u8, bool>(10, true); // create a new `Pair` with a `u8` and `bool` values
let pair_3 = new_pair(10u8, true);
assert!(pair_1.first == 10, 0x0);
assert!(pair_1.second, 0x0);
// Unpacking is identical.
let Pair { first: _, second: _ } = pair_1;
let Pair { first: _, second: _ } = pair_2;
let Pair { first: _, second: _ } = pair_3;
}
如果我们在 new_pair
函数中添加了另一个实例,交换类型参数,并尝试比较两个类型,我们会发现类型签名是不同的,无法进行比较。
#[test]
fun test_swap_type_params() {
let pair1: Pair<u8, bool> = new_pair(10u8, true);
let pair2: Pair<bool, u8> = new_pair(true, 10u8);
// this line will not compile
// assert!(pair1 == pair2, 0x0);
let Pair { first: pf1, second: ps1 } = pair1; // first1: u8, second1: bool
let Pair { first: pf2, second: ps2 } = pair2; // first2: bool, second2: u8
assert!(pf1 == ps2, 0x0); // 10 == 10
assert!(ps1 == pf2, 0x0); // true == true
}
变量 pair1
和 pair2
的类型是不同的,因此无法进行比较。
为什么使用泛型?
在上面的示例中,我们专注于实例化泛型类型和调用泛型函数,以创建这些类型的实例。然而,泛型的真正威力在于能够为基础泛型类型定义共享行为,然后独立于具体类型使用它。这在处理集合、抽象实现和其他Move中的高级功能时特别有用。
/// A user record with name, age, and some generic metadata
public struct User<T> {
name: String,
age: u8,
/// Varies depending on application.
metadata: T,
}
在上面的示例中,User
是一个具有单个类型参数 T
的泛型类型,具有共享的 name
和 age
字段,以及可以存储任何类型的泛型 metadata
字段。无论 metadata
是什么,User
的所有实例都具有相同的字段和方法。
/// Updates the name of the user.
public fun update_name<T>(user: &mut User<T>, name: String) {
user.name = name;
}
/// Updates the age of the user.
public fun update_age<T>(user: &mut User<T>, age: u8) {
user.age = age;
}
##虚拟类型参数
在某些情况下,您可能希望定义一个具有未在类型的字段或方法中使用的类型参数的泛型类型。这被称为 虚拟类型参数。当您希望定义一个可以容纳任何其他类型的类型,但希望对类型参数施加一些约束时,虚拟类型参数非常有用。
/// A generic type with a phantom type parameter.
public struct Coin<phantom T> {
value: u64
}
这里的 Coin
类型不包含使用类型参数 T
的字段或方法。它用于区分不同类型的硬币,并对类型参数 T
施加一些约束。
public struct USD {}
public struct EUR {}
#[test]
fun test_phantom_type() {
let coin1: Coin<USD> = Coin { value: 10 };
let coin2: Coin<EUR> = Coin { value: 20 };
// Unpacking is identical because the phantom type parameter is not used.
let Coin { value: _ } = coin1;
let Coin { value: _ } = coin2;
}
在上面的示例中,我们演示了如何创建具有不同虚拟类型参数 USD
和 EUR
的两个不同的 Coin
实例。类型参数 T
在 Coin
类型的字段或方法中没有使用,但它用于区分不同类型的硬币。它将确保 USD
和 EUR
硬币不会混淆。
对类型参数的约束
类型参数可以限制为具有某些功能。当您需要内部类型允许某些行为(例如 复制 或 丢弃)时,这非常有用。约束类型参数的语法是 T: <ability> + <ability>
。
/// A generic type with a type parameter that has the `drop` ability.
public struct Droppable<T: drop> {
value: T,
}
/// A generic struct with a type parameter that has the `copy` and `drop` abilities.
public struct CopyableDroppable<T: copy + drop> {
value: T, // T must have the `copy` and `drop` abilities
}
Move 编译器将强制要求类型参数 T
具有指定的功能。如果类型参数不具备指定的功能,代码将无法编译。
/// Type without any abilities.
public struct NoAbilities {}
#[test]
fun test_constraints() {
// Fails - `NoAbilities` does not have the `drop` ability
// let droppable = Droppable<NoAbilities> { value: 10 };
// Fails - `NoAbilities` does not have the `copy` and `drop` abilities
// let copyable_droppable = CopyableDroppable<NoAbilities> { value: 10 };
}
进一步阅读
- 泛型 在 Move 参考文档中。
类型反射
在编程语言中,反射 是指程序能够检查和修改其自身结构和行为的能力。 在 Move 中,有一种有限的反射形式,允许你在运行时检查值的类型。 当你需要在同类集合中存储类型信息,或者需要检查某个类型是否属于某个包时,这非常有用。
类型反射在标准库模块 std::type_name
中实现。
简而言之,它提供了一个 get<T>()
函数,该函数返回类型 T
的名称。
实践
该模块相对直观,对结果的操作仅限于获取字符串表示形式以及提取类型的模块和地址。
module book::type_reflection {
use std::ascii::String;
use std::type_name::{Self, TypeName};
/// A function that returns the name of the type `T` and its module and address.
public fun do_i_know_you<T>(): (String, String, String) {
let type_name: TypeName = type_name::get<T>();
// there's a way to borrow
let str: &String = type_name.borrow_string();
let module_name: String = type_name.get_module();
let address_str: String = type_name.get_address();
// and a way to consume the value
let str = type_name.into_string();
(str, module_name, address_str)
}
#[test_only]
public struct MyType {}
#[test]
fun test_type_reflection() {
let (type_name, module_name, _address_str) = do_i_know_you<MyType>();
//
assert!(module_name == b"type_reflection".to_ascii_string(), 1);
}
}
进一步阅读
类型反射是编程语言中的重要组成部分,也是一些更高级模式中的关键部分。
测试
测试是软件开发中至关重要的一环,尤其是在区块链开发中更为重要。 在这里,我们将介绍 Move 语言的测试基础知识,并讲解如何编写和组织 Move 代码的测试。
#[test]
属性
在 Move 中,测试是使用 #[test]
属性标记的函数。
这个属性告诉编译器该函数是一个测试函数,应该在执行测试时运行。
测试函数是常规函数,但不能接受任何参数,也不能有返回值。它们不会被编译成字节码,也不会被发布。
module book::testing {
// `#[test]` 属性放置在 `fun` 关键字之前。
// 可以放在函数签名的上方或紧挨着 `fun` 关键字:`#[test] fun my_test() { ... }`
// 测试的名称将会是 `book::testing::simple_test`。
#[test]
fun simple_test() {
let sum = 2 + 2;
assert!(sum == 4, 1);
}
// 测试的名称将会是 `book::testing::more_advanced_test`.
#[test] fun more_advanced_test() {
let sum = 2 + 2 + 2;
assert!(sum == 4, 1);
}
}
运行测试
要运行测试,可以使用 sui move test
命令。
该命令首先会在 测试模式 下构建包,然后运行包中所有找到的测试。
在测试模式下,sources/
和 tests/
目录中的模块都会被处理,测试也会被执行。
$ sui move test
> 包含依赖项 Sui
> 包含依赖项 MoveStdlib
> 构建 book
> 运行 Move 单元测试
> ...
使用 #[expected_failure]
处理测试失败的情况
针对失败情况的测试可以使用 #[expected_failure]
标记。
将此属性放在 #[test]
函数上,告知编译器该测试预期会失败。
当你想测试某个函数在特定条件下会失败时,这个功能非常有用。
该属性只能放在
#[test]
函数上。
该属性可以接受一个终止码作为参数,即测试失败时预期的终止码。 如果测试以不同的终止码失败,测试将失败。 如果执行过程中没有终止,测试同样会失败。
module book::testing_failure {
const EInvalidArgument: u64 = 1;
#[test]
#[expected_failure(abort_code = 0)]
fun test_fail() {
abort 0 // 以终止码 0 终止
}
// 属性可以组合在一起使用。
#[test, expected_failure(abort_code = EInvalidArgument)]
fun test_fail_1() {
abort 1 // 以终止码 1 终止
}
}
abort_code
参数可以使用在测试模块中定义的常量,也可以从其他模块导入的常量。
在这种情况下,是唯一可以在其他模块中使用和“访问”常量的场景。
使用 #[test_only]
标记的工具函数
在某些情况下,让测试环境访问一些内部函数或特性是有帮助的。
它可以简化测试过程,并允许更全面的测试。
然而,需要注意的是,这些函数不应包含在最终的包中。
这时,#[test_only]
属性就派上用场了。
module book::testing {
// 使用 `secret` 函数的公共函数
public fun multiply_by_secret(x: u64): u64 {
x * secret()
}
/// 私有函数不能被公共函数使用
fun secret(): u64 { 100 }
#[test_only]
/// 此函数仅用于测试中的测试目的以及其他仅限测试的函数。
/// 注意可见性——对于 `#[test_only]`,
/// 通常使用 `public` 可见性。
public fun secret_for_testing(): u64 {
secret()
}
#[test]
// 在测试环境中,我们可以访问 `secret_for_testing` 函数。
fun test_multiply_by_secret() {
let expected = secret_for_testing() * 2;
assert!(multiply_by_secret(2) == expected, 1);
}
}
使用 #[test_only]
标记的函数将在测试环境中可用,
如果它们的可见性设置允许,其他模块也可以访问这些函数。
进一步阅读
- 单元测试 在 Move 参考手册中.
对象模型
本章介绍了 Sui 的对象模型。它将重点放在对象模型背后的理论和概念,为深入学习 Sui 存储操作和资源所有权做好准备。为了方便理解和查阅,我们将本章分为几个部分,每个部分涵盖对象模型的一个特定方面。
本章并非对象模型的综合指南,仅是对对象模型背后的概念和原理的高层次概述。
有关更详细的描述,请参阅 Sui Documentation。
Move——数字资产的语言
智能合约编程语言一直以来都专注于定义和管理数字资产。例如,Ethereum(以太坊)中的 ERC-20 标准开创了一套与数字货币代币互动的标准,为在区块链上创建和管理数字货币建立了蓝图。随后,ERC-721 标准的引入标志着一个重要的改革,普及了非同质化代币(NFTs)的概念,代表独特且不可分割的资产。这些标准为我们今天看到的复杂数字资产奠定了基础。
然而,Ethereum 的编程模型缺乏对资产的原生表示。换句话说,从外部看,智能合约的行为类似于资产,但语言本身并没有一种固有的方式来表示资产。Move 从一开始就致力于为资产提供一流的抽象,使得资产(如代币、NFT 等)能够以与基本数据类型相同的方式被操作和编程,开辟了思考和编程资产的新途径。
资产的关键属性
-
所有权: 每个资产都与一个或多个所有者相关联,反映了现实世界中所有权的简单概念——就像你拥有一辆车一样,你也可以拥有一个数字资产。Move 强制执行所有权机制,一旦资产被“移动”,前任所有者将完全失去对其的控制。这种机制确保了所有权的明确和安全的变更。
-
不可复制: 在现实世界中,独特的物品不能被轻易复制。Move 将这一原则应用于数字资产,确保它们不能在程序中被任意复制。这一属性对于保持数字资产的稀缺性和独特性至关重要,类似于物理资产的内在价值。
-
不可丢弃: 就像你不能无意中丢失一所房子或一辆车一样,Move 确保在程序中没有资产可以被丢弃或遗失。相反,资产必须被明确地转移或销毁。这一属性保证了数字资产的谨慎处理,防止意外丢失,并确保在资产管理中的责任。
Move 在其设计中成功地封装了这些属性,成为一个理想的数字资产语言。
总结
- Move 旨在为数字资产提供一流的抽象,使开发者能够原生地创建和管理资产。
- 数字资产的关键属性包括所有权、不可复制性和不可丢弃性,Move 在其设计中强制执行这些属性。
- Move 的资产模型反映了现实世界的资产管理,确保了资产所有权和转移的安全性和责任性。
延伸阅读
- Move: A Language With Programmable Resources (pdf) 作者:Sam Blackshear、Evan Cheng、David L. Dill、Victor Gao、Ben Maurer、Todd Nowacki、Alistair Pott、Shaz Qadeer、Rain、Dario Russi、Stephane Sezer、Tim Zakian、Runtian Zhou
Move的演进
虽然Move被创建用于管理数字资产,但其最初的存储模型庞大且不适用于许多用例。例如,如果Alice想将资产X转让给Bob,Bob必须创建一个新的“空”资源,然后Alice才能将资产X转移到Bob那里。这个过程不直观,并且实现上也存在挑战,部分原因是由于Diem的限制性设计。原始设计的另一个缺点是缺乏内置的“转让”操作支持,导致每个模块都需要实现自己的存储转让逻辑。此外,在单个账户中管理异构资产集合也是一项特别具有挑战性的任务。
Sui通过重新设计对象的存储和所有权模型来解决这些问题,以更接近现实世界对象交互的方式进行。通过原生“所有权”和“转让”的概念,Alice可以直接将资产X转让给Bob。此外,Bob可以在没有任何准备步骤的情况下维护不同资产的集合。这些改进为Sui中的对象模型奠定了基础。
总结
- Move的初始存储模型不适合管理数字资产,需要复杂且受限制的转让操作。
- Sui引入了对象模型,提供了原生所有权概念,简化了资产管理并支持异构集合。
进一步阅读
- 为什么我们创建了Sui Move,作者:Sam Blackshear
什么是对象?
Sui 的对象模型可以看作是一个高层次的抽象,将数字资产表示为“对象”。这些对象具有自己的类型和相关行为、唯一标识符,并支持诸如“转移”和“共享”等原生存储操作。设计直观且易于使用,对象模型使得实现各种用例变得轻而易举。
Sui 中的对象具有以下属性:
-
类型: 每个对象都有一个类型,定义了对象的结构和行为。不同类型的对象不能混合使用或互换使用,确保对象根据其类型系统正确使用。
-
唯一ID: 每个对象都有一个唯一标识符,区分它与其他对象。这一ID在对象创建时生成并且不可更改,用于在系统内跟踪和识别对象。
-
所有者: 每个对象都与一个所有者相关联,所有者对对象具有控制权。Sui 上的所有权可以是专属于某个账户的、在网络中共享的或被冻结的,允许只读访问而不能修改或转移。在后续部分中,我们将详细讨论所有权。
-
数据: 对象封装其数据,简化了管理和操作。数据结构和操作由对象的类型定义。
-
版本: 从账户到对象的过渡通过对象版本化实现。传统上,区块链使用“nonce”来防止重放攻击。在Sui中,对象的版本充当nonce,防止每个对象的重放攻击。
-
摘要: 每个对象都有一个摘要,它是对象数据的哈希值。摘要用于加密验证对象数据的完整性,确保数据未被篡改。摘要在对象创建时计算,并在对象数据更改时更新。
总结
- Sui 中的对象是表示数字资产的高层次抽象。
- 对象具有类型、唯一ID、所有者、数据、版本和摘要。
- 对象模型简化了资产管理并支持广泛的用例。
延伸阅读
- 对象模型 在 Sui 文档中。
所有权
Sui引入了四种不同的对象所有权类型:单一所有者、共享状态、不可变共享状态和对象所有者。每种模型都具有独特的特点,适用于不同的用例,增强了对象管理的灵活性和控制性。
账户所有者(或单一所有者)
账户所有者,也称为“单一所有者”模型,在Sui中是最基础的所有权类型。在这里,一个对象由单个账户拥有,授予该账户对该对象在其类型相关的行为中的独占控制权。这个模型体现了“真正的所有权”的概念,其中账户对对象拥有完全的权威,使得其他人在未经许可的情况下无法修改或转让该对象。这种所有权的清晰度是Sui与其他区块链系统相比的重要优势,在其他区块链中,所有权定义可能更加模糊,智能合约可能具有在不经所有者同意的情况下修改或转让资产的能力。
共享状态
单一所有者模型存在一些限制:例如,在没有共享状态的情况下,实现数字资产市场非常棘手。对于一个通用的市场场景,想象一下Alice拥有一个资产X,并且她想通过将其放入共享市场来出售。然后Bob可以直接从市场购买该资产。之所以棘手的原因是无法编写一个智能合约,可以在Bob购买时将资产“锁定”在Alice的账户中并取出。首先,这将违反单一所有者模型,其次,它需要对资产进行共享访问。
为了解决共享数据访问问题,Sui引入了共享所有权模型。在这个模型中,一个对象可以通过网络共享。共享对象可以被网络上的任何账户读取和修改,并且对象的交互规则由对象的实现定义。共享对象的典型用途包括市场、共享资源、托管和其他多个账户需要访问相同状态的场景。
不可变(冻结)状态
Sui还提供了“冻结对象”模型,其中对象变为永久只读。这些不可变对象虽然可读,但无法修改或移动,为所有网络参与者提供了稳定且恒定的状态。冻结对象非常适用于公共数据、参考资料和其他需要状态永久性的用例。
对象所有者
Sui中的最后一个所有权模型是“对象所有者”。在这个模型中,一个对象由另一个对象拥有。这个特性允许在对象之间创建复杂的关系,存储大型异构集合,并实现可扩展和模块化的系统。从实际角度来看,由于交易是由账户发起的,交易仍然可以访问父对象,同时可以通过父对象访问子对象。
我们喜欢提到的一个用例是游戏角色。Alice可以拥有游戏中的英雄对象,而英雄可以拥有物品:也被表示为对象,如“地图”或“指南针”。Alice可以从“英雄”对象中取出“地图”,然后将其发送给Bob,或者在市场上出售。通过对象所有者,可以很自然地想象资产如何在彼此之间进行结构化和管理。
总结
- 单一所有者: 对象由单个账户拥有,授予对对象的独占控制权。
- 共享状态: 对象可以与网络共享,允许多账户读取和修改对象。
- 不可变状态: 对象变为永久只读,提供稳定且恒定的状态。
- 对象所有者: 对象可以拥有其他对象,实现复杂的关系和模块化系统。
下一步
在接下来的部分,我们将讨论Sui中的事务执行路径以及所有权模型对事务执行的影响。
Sui Move 的快速通道与共识机制
Sui Move 的对象模型允许根据对象的所有权类型执行不同的交易路径。 交易执行路径决定了网络如何处理和验证交易。 在这一部分,我们将探讨 Sui 中不同的交易执行路径以及它们如何与共识机制交互。
并发挑战
区块链技术的核心面临一个基本的并发挑战:在去中心化环境中,多个参与者可能会同时尝试修改或访问相同的数据。 这就需要一个对交易进行排序和验证的系统来支持网络的一致性。 Sui 通过共识机制来解决这一挑战,确保所有节点都对交易的顺序和状态达成一致。
考虑一个市场场景,Alice 和 Bob 同时尝试购买相同的资产。 网络必须解决这个冲突以防止双重花费,确保至多一笔交易成功,另一笔交易被合理拒绝。
快速通道
然而,并非所有的交易都需要相同的验证和共识级别。 例如,如果 Alice 想把一个她拥有的对象转移给 Bob,网络可以处理这个交易,而不必将其与网络中的所有其他交易进行排序,因为只有 Alice 有权访问这个对象。 这被称为 快速通道 执行,即访问账户拥有对象的交易可以快速处理,而无需复杂的共识过程。 没有并发数据访问 -> 挑战更简单 -> 快速通道。
另一种允许快速通道执行的所有权模型是 不可变状态。 由于不可变对象不可改变,因此涉及它们的交易可以快速处理,而无需对它们进行排序。
共识路径
访问共享状态的交易 - 在 Sui 中用共享对象表示 - 需要排序,以确保状态在所有节点上被更新并保持一致。 这被称为通过 共识 执行,即访问共享对象的交易需要经过共识过程来维护网络的一致性。
被对象拥有的对象
最后,重要的是提到被其他对象拥有的对象遵循与父对象相同的规则。 如果父对象是 共享的,则子对象也被传递共享。 如果父对象是不可变的,则子对象也是不可变的。
总结
- 快速通道: 涉及账户拥有对象或不可变共享状态的交易可以快速处理,而无需复杂的共识过程。
- 共识路径: 涉及共享对象的交易需要排序和共识来确保网络完整性。
- 被对象拥有的对象: 子对象继承父对象的所有权模型。
使用对象
在对象模型章节中,我们简要介绍了Move语言从基于账户的模型演变为基于对象的模型。在本章中,我们将深入探讨对象模型,并探索如何在你的Sui应用程序中使用对象。如果你还没有阅读过对象模型章节,我们建议你在继续本章之前先阅读该章节。
关键能力
在基本语法章节中,我们已经涵盖了四种能力中的两种 - Drop 和 Copy。它们影响值在作用域中的行为,并且与存储没有直接关系。现在是介绍 key
能力的时候了,这种能力允许结构体被存储。
历史上,key
能力被创建用来标记类型作为存储中的“关键”。具有 key
能力的类型可以在存储的顶层存储,并且可以被账户或地址直接拥有。随着对象模型的引入,key
能力自然成为对象的定义能力。
对象定义
具有 key
能力的结构体被认为是一个对象,并且可以在存储函数中使用。Sui 验证器要求结构体的第一个字段必须命名为 id
,并且类型为 UID
。
public struct Object has key {
id: UID, // 必需
name: String,
}
/// 使用唯一ID创建一个新对象
public fun new(name: String, ctx: &mut TxContext): Object {
Object {
id: object::new(ctx), // 创建一个新的UID
name,
}
}
具有 key
能力的结构体仍然是一个结构体,可以拥有任意数量的字段和关联函数。对于打包、访问或解包结构体,并没有特殊的处理或语法要求。
然而,由于对象结构体的第一个字段必须是类型为 UID
的字段 - 一个不可复制也不可丢弃的类型(我们很快会深入讨论它!),因此结构体本身在设计上是不可丢弃的。
具有 key
能力的类型
由于具有 key
能力的类型需要 UID
,因此 Move 中的原生类型和标准库中的类型都无法具有 key
能力。key
能力只存在于Sui Framework和自定义类型中。
下一步
关键能力定义了在 Move 中的对象,并且这些对象意图上是用来“存储”的。在下一节中,我们将介绍 sui::transfer
模块,该模块提供了对象的本地存储功能。
进一步阅读
- 在 Move 参考手册中的类型能力。
存储功能
定义主要存储操作的模块是 sui::transfer
。它在所有依赖Sui 框架的包中都是隐式导入的,因此,像其他隐式导入的模块(例如 std::option
或 std::vector
)一样,不需要添加 use
声明。
概述
transfer
模块提供了执行所有三种与所有权类型匹配的存储操作的函数,我们之前已经解释过:
在本页面中,我们只讨论所谓的 限制性 存储操作,稍后我们将介绍 公共 操作,之后会引入
store
能力。
- Transfer - 将对象发送到地址,将其置于 账户所有 状态;
- Share - 将对象置于 共享 状态,因此可供所有人访问;
- Freeze - 将对象置于 不可变 状态,因此成为公共常量,永远无法更改。
transfer
模块是大多数存储操作的首选,除了在下一章节中我们将讨论的 动态字段 特殊情况。
所有权与引用:快速回顾
在所有权与作用域和引用章节中,我们介绍了 Move 中所有权和引用的基础知识。当您使用存储函数时,理解这些概念非常重要。以下是最重要的几点回顾:
- Move 语义意味着值从一个作用域 移动 到另一个作用域。换句话说,如果通过按值传递类型的实例给函数,它会 移动 到函数作用域,并且在调用者作用域中无法访问。
- 要维护值的所有权,可以通过引用方式传递它。可以是 不可变引用
&T
或 可变引用&mut T
。然后该值被 借用,并且可以在调用者作用域中访问,但所有者保持不变。
/// 通过值移动
public fun take<T>(value: T) { /* value 在此处被移动! */ abort 0 }
/// 对于不可变引用
public fun borrow<T>(value: &T) { /* 在此处借用 value 可以读取 */ abort 0 }
/// 对于可变引用
public fun borrow_mut<T>(value: &mut T) { /* 在此处可变借用 value */ abort 0 }
转移
transfer::transfer
函数是用于将对象转移到另一个地址的公共函数。其签名如下,只接受具有 key
能力 和收件人地址的类型。请注意,对象通过值传递到函数中,因此它被移动到函数作用域,然后移动到接收者地址:
// 文件:sui-framework/sources/transfer.move
public fun transfer<T: key>(obj: T, recipient: address);
在下面的示例中,您可以看到如何在定义并发送对象到事务发送者的模块中使用它。
module book::transfer_to_sender {
/// 具有 `key` 的结构体是一个对象。第一个字段是 `id: UID`!
public struct AdminCap has key { id: UID }
/// `init` 函数是发布模块时调用的特殊函数。这是创建应用对象的好地方。
fun init(ctx: &mut TxContext) {
// 在此作用域中创建一个新的 `AdminCap` 对象。
let admin_cap = AdminCap { id: object::new(ctx) };
// 将对象转移到事务发送者。
transfer::transfer(admin_cap, ctx.sender());
// admin_cap 已经消失!无法再访问它了。
}
/// 将 `AdminCap` 对象转移到 `recipient`。因此,接收者成为对象的所有者,只有他们可以访问它。
public fun transfer_admin_cap(cap: AdminCap, recipient: address) {
transfer::transfer(cap, recipient);
}
}
当模块发布时,init
函数将被调用,并且我们在其中创建的 AdminCap
对象将被转移到事务发送者。ctx.sender()
函数返回当前事务的发送者地址。
一旦 AdminCap
被转移给发送者,例如 0xa11ce
,发送者(仅发送者)将能够访问对象。对象现在是 账户所有 的。
账户所有的对象受 真正的所有权 影响 - 只有账户所有者才能访问它们。这是 Sui 存储模型中的基本概念。
让我们通过一个函数扩展示例,该函数使用 AdminCap
授权新对象的铸造并将其转移到另一个地址:
/// 一些 `Gift` 对象,管理员可以 `铸造和转移`。
public struct Gift has key { id: UID }
/// 创建一个新的 `Gift` 对象并将其转移到 `recipient`。
public fun mint_and_transfer(
_: &AdminCap, recipient: address, ctx: &mut TxContext
) {
let gift = Gift { id: object::new(ctx) };
transfer::transfer(gift, recipient);
}
mint_and_transfer
函数是一个公共函数,任何人都可以“可能”调用它,但是需要通过引用传递 AdminCap
对象作为第一个参数。如果没有它,函数将无法调用。这是限制访问特权功能的一种简单方式,称为 能力。因为 AdminCap
对象是 账户所有 的,只有 0xa11ce
才能调用 mint_and_transfer
函数。
发送给接收者的 Gift
将同样是 账户所有 的,每个礼物都是独特的,且仅由接收者独占所有。
快速总结:
transfer
函数用于将对象发送到地址;- 对象变为 账户所有,只有接收者可以访问;
- 可以通过要求将对象作为参数传递来限制函数,从而创建 能力。
冻结
transfer::freeze_object
函数是用于将对象置于 不可变 状态的公共函数。一旦对象被 冻结,它永远无法更改,且可以通过不可变引用被任何人访问。
该函数的签名如下,只接受具有 key
能力 的类型。与所有其他存储函数一样,它通过值接受对象:
// 文件:sui-framework/sources/transfer.move
public fun freeze_object<T: key>(obj: T);
让我们扩展之前的示例,添加一个函数,允许管理员创建一个 Config
对象并将其冻结:
/// 一些管理员可以 `创建和冻结` 的 `Config` 对象。
public struct Config has key {
id: UID,
message: String
}
/// 创建一个新的 `Config` 对象并将其冻结。
public fun create_and_freeze(
_: &AdminCap,
message: String,
ctx: &mut TxContext
) {
let config = Config
{
id: object::new(ctx),
message
};
// 冻结对象,使其变为不可变。
transfer::freeze_object(config);
}
/// 返回 `Config` 对象中的消息。
/// 可以通过不可变引用访问对象!
public fun message(c: &Config): String { c.message }
Config 是一个具有 message
字段的对象,create_and_freeze
函数创建一个新的 Config
并将其冻结。一旦对象被冻结,可以通过不可变引用被任何人访问。message
函数是一个公共函数,返回 Config
对象中的消息。Config 现在通过其 ID 公开可用,任何人都可以读取消息。
函数定义与对象的状态无关。可以定义一个接受可变引用的函数用于冻结对象。但是,它无法在冻结对象上调用。
message
函数可以在不可变的 Config
对象上调用,但是以下两个函数不能在冻结对象上调用:
// === 以下函数不能在冻结对象上调用! ===
/// 该函数可以定义,但不能在冻结对象上调用。
/// 仅允许不可变引用。
public fun message_mut(c: &mut Config): &mut String { &mut c.message }
/// 删除 `Config` 对象,按值接受它。
/// 不能在冻结对象上调用!
public fun delete_config(c: Config) {
let Config { id, message: _ } = c;
id.delete();
}
总结:
transfer::freeze_object
函数用于将对象置于 不可变 状态;- 一旦对象被冻结,它永远无法更改、删除或转移,可以通过不可变引用被任何人访问;
账户拥有 -> 冻结
由于 transfer::freeze_object
的签名接受任何具有 key
能力的类型,它可以接受在同一作用域中创建的对象,但也可以接受账户拥有的对象。这意味着 freeze_object
函数可以用来冻结转移给发送者的对象。出于安全考虑,我们不希望冻结 AdminCap
对象——允许任何人访问它是一个安全风险。然而,我们可以冻结铸造并转移给接收者的 Gift
对象:
单一所有者 -> 不可变转换是可能的!
/// 冻结 `Gift` 对象,使其变为不可变。
public fun freeze_gift(gift: Gift) {
transfer::freeze_object(gift);
}
共享
transfer::share_object
函数是用于将对象置于 共享 状态的公共函数。一旦对象被 共享,它可以通过可变引用(当然,也可以通过不可变引用)被任何人访问。该函数的签名如下,只接受具有 key
能力 的类型:
// 文件:sui-framework/sources/transfer.move
public fun share_object<T: key>(obj: T);
一旦对象被 共享,它可以通过可变引用公开访问。
特殊情况:共享对象删除
虽然共享对象通常不能按值获取,但有一种特殊情况可以——如果获取它的函数删除对象。这是 Sui 存储模型中的一个特殊情况,用于允许删除共享对象。为了展示它如何工作,我们将创建一个函数,创建并共享一个 Config 对象,然后再创建一个删除它的函数:
/// 创建一个新的 `Config` 对象并共享它。
public fun create_and_share(message: String, ctx: &mut TxContext) {
let config = Config {
id: object::new(ctx),
message
};
// 共享对象,使其变为共享。
transfer::share_object(config);
}
create_and_share
函数创建一个新的 Config
对象并共享它。对象现在可以通过可变引用公开访问。让我们创建一个删除共享对象的函数:
/// 删除 `Config` 对象,按值获取它。
/// 可以在共享对象上调用!
public fun delete_config(c: Config) {
let Config { id, message: _ } = c;
id.delete();
}
delete_config
函数按值获取 Config
对象并删除它,Sui 验证器会允许此调用。然而,如果函数返回 Config
对象或试图 freeze
或 transfer
它,Sui 验证器将拒绝事务。
// 不会成功!
public fun transfer_shared(c: Config, to: address) {
transfer::transfer(c, to);
}
总结:
share_object
函数用于将对象置于 共享 状态;- 一旦对象被共享,它可以通过可变引用被任何人访问;
- 共享对象可以删除,但不能转移或冻结。
后续步骤
现在您已经了解了 transfer
模块的主要功能,您可以开始在 Sui 上构建更复杂的涉及存储操作的应用程序。在下一章中,我们将介绍 存储能力,它允许在对象内部存储数据,并放宽我们在这里触及的转移限制。之后,我们将介绍 Sui 存储模型中最重要的类型 UID 和 ID。
能力:Store
现在你已经了解了通过key
能力启用的顶层存储函数,我们可以讨论列表中的最后一个能力 - store
。
定义
store
是一种特殊的能力,允许将类型存储在对象中。该能力是字段可以在具有key
能力的结构体中使用的必需条件。换句话说,store
能力允许值被包装在对象中。
store
能力还放宽了转移操作的限制。我们在受限和公共转移部分中会详细讨论。
示例
在之前的章节中,我们已经使用了具有key
能力的类型:所有对象必须有一个UID
字段,我们在示例中使用了它;我们还将Storable
类型作为Config
结构体的一部分使用。Config
类型也具有store
能力。
/// 这个类型具有`store`能力。
public struct Storable has store {}
/// Config 包含一个具有`store`能力的`Storable`字段。
public struct Config has key, store {
id: UID,
stores: Storable,
}
/// MegaConfig 包含一个具有`store`能力的`Config`字段。
public struct MegaConfig has key {
id: UID,
config: Config, // 在这里!
}
具有store
能力的类型
在Move中,除了引用之外,所有原生类型都具有store
能力。包括:
标准库中定义的所有类型也具有store
能力。包括:
进一步阅读
- Move参考中的类型能力。
UID和ID
UID
类型在sui::object
模块中定义,是围绕ID
类型包装而成,而ID
类型则是围绕address
类型包装。在Sui中,UID保证是唯一的,且在对象被删除后不可再次使用。
// 文件:sui-framework/sources/object.move
/// UID是对象的唯一标识符
public struct UID has store {
id: ID
}
/// ID是地址的包装器
public struct ID has store, drop {
bytes: address
}
新UID的生成:
- UID是从
tx_hash
和递增的index
派生而来的。 derive_id
函数在sui::tx_context
模块中实现,因此生成UID需要TxContext。- Sui验证器不允许使用未在同一函数中创建的UID。这防止了在对象被解包后预先生成和重复使用UID。
使用object::new(ctx)
函数可以创建新的UID,它接受对TxContext的可变引用,并返回一个新的UID。
let ctx = &mut tx_context::dummy();
let uid = object::new(ctx);
在Sui中,UID
充当对象的表征,并允许定义对象的行为和特征。其中一个关键特性是动态字段,这得益于UID
类型的显式定义。此外,它还允许进行后文将要解释的对象转移(TTO)。
UID的生命周期
UID
类型通过object::new(ctx)
函数创建,并通过object::delete(uid)
函数销毁。object::delete
函数通过值消耗UID,并且除非值是从对象中解包出来的,否则不可能删除它。
let ctx = &mut tx_context::dummy();
let char = Character {
id: object::new(ctx)
};
let Character { id } = char;
id.delete();
保持UID
在对象结构解包后,并不需要立即删除UID
。有时它可能携带动态字段或通过对象转移传输到它的对象。在这种情况下,UID可以被保留并存储在一个单独的对象中。
删除的证明
返回对象的UID的能力可以用于所谓的“删除证明”模式。虽然这是一个不常用的技术,但在某些情况下很有用,例如,创建者或应用程序可以通过交换已删除ID来激励对象的删除。
在框架开发中,这种方法可以用来忽略或绕过对“获取”对象施加的某些限制。例如,如果有一个强制执行传输逻辑的容器,类似于Kiosk,通过提供删除证明就可以特殊情况下跳过检查。
这是一个值得探索和研究的开放主题,可以以各种方式使用。
ID
谈到UID
时,我们也应该提到ID
类型。它是围绕address
类型的包装器,用于表示地址指针。通常,ID
用于指向一个对象,但没有限制,也没有保证ID
指向一个现有对象。
ID可以作为事务参数在事务块中接收。此外,可以使用
to_id()
函数从一个address
值创建ID。
fresh_object_address
TxContext提供了fresh_object_address
函数,可以用于创建唯一的地址和ID。这在一些应用程序中分配唯一标识符给用户行为,例如市场中的order_id,可能非常有用。
受限与公开转移
我们在前面章节中描述的存储操作默认是受限的——它们只能在定义对象的模块中调用。 换句话说,类型必须是模块的 内部 类型,才能用于存储操作。此限制由 Sui Verifier 实现,并在字节码层面强制执行。
然而,为了允许对象在其他模块中转移和存储,这些限制可以放宽。
sui::transfer
模块提供了一组 public_* 函数,允许在其他模块中调用存储操作。
这些函数以 public_
为前缀,适用于所有模块和交易。
公开存储操作
sui::transfer
模块提供了以下公开函数。它们与我们之前讨论的函数几乎相同,但可以从任何模块中调用。
// 文件: sui-framework/sources/transfer.move
/// `transfer` 函数的公开版本。
public fun public_transfer<T: key + store>(object: T, to: address) {}
/// `share_object` 函数的公开版本。
public fun public_share_object<T: key + store>(object: T) {}
/// `freeze_object` 函数的公开版本。
public fun public_freeze_object<T: key + store>(object: T) {}
为了说明这些函数的使用,考虑以下示例:
模块 A 定义了具有 key
能力的 ObjectK 和具有 key + store
能力的 ObjectKS,
而模块 B 尝试为这些对象实现一个 transfer
函数。
在此示例中,我们使用了
transfer::transfer
, 但对于share_object
和freeze_object
函数,行为是相同的。
/// 为 `ObjectK` 和 `ObjectKS` 分别定义 `key` 和 `key + store`的能力
module book::transfer_a {
public struct ObjectK has key { id: UID }
public struct ObjectKS has key, store { id: UID }
}
/// 从 `transfer_a` 导入 `ObjectK` 和 `ObjectKS` 类型,
/// 并尝试为它们实现不同的 `transfer` 函数
module book::transfer_b {
// 类型并非此模块的内部类型
use book::transfer_a::{ObjectK, ObjectKS};
// 失败!ObjectK 没有 `store` 能力,并且 ObjectK 不是此模块的内部类型。
public fun transfer_k(k: ObjectK, to: address) {
sui::transfer::transfer(k, to);
}
// 失败!ObjectKS 具有 `store` 能力,但该函数不是公开的。
public fun transfer_ks(ks: ObjectKS, to: address) {
sui::transfer::transfer(ks, to);
}
// 失败!ObjectK 没有 `store` 能力,而 `public_transfer` 需要 `store` 能力。
public fun public_transfer_k(k: ObjectK) {
sui::transfer::public_transfer(k);
}
// 成功!ObjectKS 具有 `store` 能力,并且该函数是公开的。
public fun public_transfer_ks(y: ObjectKS, to: address) {
sui::transfer::public_transfer(y, to);
}
}
进一步扩展上述示例:
-
transfer_k
失败,因为 ObjectK 不是模块transfer_b
的内部类型 -
transfer_ks
失败,因为 ObjectKS 不是模块transfer_b
的内部类型 -
public_transfer_k
失败,因为 ObjectK 没有store
能力 -
public_transfer_ks
成功,因为 ObjectKS 具有store
能力,并且转移是公开的
store
能力的影响
是否为一个类型添加 store
能力需要谨慎决策。
一方面,store
能力实际上是让该类型可以被其他应用程序 使用 的必要条件。
另一方面,它允许类型被 封装 并改变原有的存储模型。
例如,一个角色可能是设计为由账户持有的,但如果具有 store
能力,该角色可以被冻结(无法被共享——此转换是受限的)。
高级可编程性
在之前的章节中,我们介绍了Move的基础知识和Sui存储模型。现在是时候深入探讨Sui可编程性的高级主题了。
本章将介绍更复杂的概念、实践和特性,这些对于构建更复杂的应用程序至关重要。本章旨在帮助那些已经熟悉Move和Sui基础知识,并希望扩展他们的知识和技能的开发人员。
事务上下文
每个事务都有执行上下文。上下文是在执行过程中程序可以访问的一组预定义变量。例如,每个事务都有一个发送者地址,事务上下文包含一个保存发送者地址的变量。
事务上下文可以通过TxContext
结构体在程序中访问。该结构体定义在sui::tx_context
模块中,包含以下字段:
// File: sui-framework/sources/tx_context.move
/// Information about the transaction currently being executed.
/// This cannot be constructed by a transaction--it is a privileged object created by
/// the VM and passed in to the entrypoint of the transaction as `&mut TxContext`.
struct TxContext has drop {
/// The address of the user that signed the current transaction
sender: address,
/// Hash of the current transaction
tx_hash: vector<u8>,
/// The current epoch number
epoch: u64,
/// Timestamp that the epoch started at
epoch_timestamp_ms: u64,
/// Counter recording the number of fresh id's created while executing
/// this transaction. Always 0 at the start of a transaction
ids_created: u64
}
事务上下文不能手动构造或直接修改。它由系统创建,并作为引用传递给事务中的函数。在Transaction中调用的任何函数都可以访问上下文并将其传递给嵌套调用。
TxContext
必须是函数签名中的最后一个参数。
读取事务上下文
除了ids_created
字段外,TxContext
中的所有字段都有对应的getter方法。这些getter方法在sui::tx_context
模块中定义,并对程序可用。这些getter方法不需要&mut
,因为它们不修改上下文。
public fun some_action(ctx: &TxContext) {
let me = ctx.sender();
let epoch = ctx.epoch();
let digest = ctx.digest();
// ...
}
可变性
TxContext
用于在系统中创建新对象(或仅仅是UID
)。新的UID是从事务摘要派生的,为了使摘要唯一,需要一个变化的参数。Sui使用ids_created
字段来实现这一点。每次创建新的UID时,ids_created
字段会增加一。这样,摘要始终是唯一的。
在内部,它由derive_id
函数表示:
// File: sui-framework/sources/tx_context.move
native fun derive_id(tx_hash: vector<u8>, ids_created: u64): address;
生成唯一地址
底层的derive_id
函数也可以在你的程序中用于生成唯一地址。该函数本身没有暴露出来,但在sui::tx_context
模块中提供了一个包装函数fresh_object_address
。如果你需要在程序中生成唯一标识符,它可能会很有用。
// File: sui-framework/sources/tx_context.move
/// Create an `address` that has not been used. As it is an object address, it will never
/// occur as the address for a user.
/// In other words, the generated address is a globally unique object ID.
public fun fresh_object_address(ctx: &mut TxContext): address {
let ids_created = ctx.ids_created;
let id = derive_id(*&ctx.tx_hash, ids_created);
ctx.ids_created = ids_created + 1;
id
}
模块初始化器
在许多应用程序中,一个常见的用例是仅在发布包时运行某些代码。想象一个简单的商店模块,需要在发布时创建主商店对象。在Sui中,这可以通过在模块中定义一个init
函数来实现。当模块发布时,该函数将自动被调用。
所有模块的
init
函数都会在发布过程中被调用。目前,这种行为仅限于发布命令,不包括包升级。
module book::shop {
/// The Capability which grants the Shop owner the right to manage
/// the shop.
public struct ShopOwnerCap has key, store { id: UID }
/// The singular Shop itself, created in the `init` function.
public struct Shop has key {
id: UID,
/* ... */
}
// Called only once, upon module publication. It must be
// private to prevent external invocation.
fun init(ctx: &mut TxContext) {
// Transfers the ShopOwnerCap to the sender (publisher).
transfer::transfer(ShopOwnerCap {
id: object::new(ctx)
}, ctx.sender());
// Shares the Shop object.
transfer::share_object(Shop {
id: object::new(ctx)
});
}
}
在同一个包中,另一个模块可以有自己的init
函数,封装不同的逻辑。
// In the same package as the `shop` module
module book::bank {
public struct Bank has key {
id: UID,
/* ... */
}
fun init(ctx: &mut TxContext) {
transfer::share_object(Bank {
id: object::new(ctx)
});
}
}
init
功能
如果模块中存在init
函数并遵循以下规则,则在发布时会调用该函数:
fun init(ctx: &mut TxContext) { /* ... */ }
fun init(otw: OTW, ctx: &mut TxContext) { /* ... */ }
TxContext也可以作为不可变引用传递:&TxContext
。然而,实际上,它应该始终是&mut TxContext
,因为init
函数不能访问链上状态,并且要创建新对象需要上下文的可变引用。
fun init(ctx: &TxContext) { /* ... */ }
fun init(otw: OTW, ctx: &TxContext) { /* ... */ }
信任与安全
虽然init
函数可以用于创建一次性对象,但重要的是要知道同样的对象(例如第一个示例中的StoreOwnerCap
)仍然可以在另一个函数中创建。特别是考虑到在升级过程中可以向模块添加新函数。因此,init
函数是设置模块初始状态的好地方,但它本身不是一种安全措施。
有一些方法可以保证对象只创建一次,例如一次性见证。还有一些方法可以限制或禁用模块的升级,我们将在包升级章节中进行讨论。
下一步
根据定义,init
函数保证在模块发布时只调用一次。因此,它是放置初始化模块对象、设置环境和配置的代码的好地方。
例如,如果有一个Capability,需要它来执行某些操作,那么它应该在init
函数中创建。在下一章节中,我们将更详细地讨论Capability
模式。
模式:能力(Capability)
在编程中,**能力(capability)**是指授予所有者执行特定操作权利的令牌。它是一种用于控制对资源和操作的访问的模式。一个简单的能力示例是门的钥匙。如果你有钥匙,你就可以打开门;如果没有钥匙,你就不能打开门。一个更实际的例子是“管理员能力”,它允许所有者执行常规用户无法执行的管理操作。
能力是一个对象
在Sui对象模型中,能力被表示为对象。一个对象的所有者可以将该对象传递给函数,以证明他们有执行特定操作的权利。由于严格的类型检查,只能使用正确的能力调用接受能力作为参数的函数。
有一个约定,将能力命名为
Cap
后缀,例如AdminCap
或KioskOwnerCap
。
module book::capability {
use std::string::String;
use sui::event;
/// The capability granting the application admin the right to create new
/// accounts in the system.
public struct AdminCap has key, store { id: UID }
/// The user account in the system.
public struct Account has key, store {
id: UID,
name: String
}
/// A simple `Ping` event with no data.
public struct Ping has copy, drop { by: ID }
/// Creates a new account in the system. Requires the `AdminCap` capability
/// to be passed as the first argument.
public fun new(_: &AdminCap, name: String, ctx: &mut TxContext): Account {
Account {
id: object::new(ctx),
name,
}
}
/// Account, and any other objects, can also be used as a Capability in the
/// application. For example, to emit an event.
public fun send_ping(acc: &Account) {
event::emit(Ping {
by: acc.id.to_inner()
})
}
/// Updates the account name. Can only be called by the `Account` owner.
public fun update(account: &mut Account, name: String) {
account.name = name;
}
}
使用init
创建管理员能力
一种常见的做法是在包发布时创建一个单独的AdminCap
对象。这样,应用程序可以有一个设置阶段,管理员账户可以准备应用程序的状态。
module book::admin_cap {
/// The capability granting the admin privileges in the system.
/// Created only once in the `init` function.
public struct AdminCap has key { id: UID }
/// Create the AdminCap object on package publish and transfer it to the
/// package owner.
fun init(ctx: &mut TxContext) {
transfer::transfer(
AdminCap { id: object::new(ctx) },
ctx.sender()
)
}
}
地址检查与能力
在区块链编程中,将对象用作能力是一个相对较新的概念。在其他智能合约语言中,授权通常是通过检查发送方地址来执行的。这种模式在Sui上仍然可行,但总体建议是使用能力以获得更好的安全性、可发现性和代码组织性。
让我们看一下如果使用地址检查的方式来实现创建用户的new
函数会是什么样子:
/// Error code for unauthorized access.
const ENotAuthorized: u64 = 0;
/// The application admin address.
const APPLICATION_ADMIN: address = @0xa11ce;
/// Creates a new user in the system. Requires the sender to be the application
/// admin.
public fun new(ctx: &mut TxContext): User {
assert!(ctx.sender() == APPLICATION_ADMIN, ENotAuthorized);
User { id: object::new(ctx) }
}
现在,让我们看看如果使用能力的方式来实现相同的函数会是什么样子:
/// Grants the owner the right to create new users in the system.
public struct AdminCap {}
/// Creates a new user in the system. Requires the `AdminCap` capability to be
/// passed as the first argument.
public fun new(_: &AdminCap, ctx: &mut TxContext): User {
User { id: object::new(ctx) }
}
与地址检查相比,使用能力具有以下几个优势:
- 对于能力来说,迁移管理员权限更加容易,因为它们是对象。如果使用地址,则如果管理员地址发生更改,所有检查地址的函数都需要更新,因此需要升级包。
- 使用能力时,函数签名更具描述性。很明显,
new
函数需要传递AdminCap
作为参数。而且,没有这个能力就无法调用该函数。 - 对象能力不需要在函数体中进行额外的检查,因此减少了开发人员错误的机会。
- 拥有的能力还可以用于发现。AdminCap的所有者可以在其账户中看到该对象(通过钱包或浏览器),并知道他们拥有管理员权限。使用地址检查就没有这么直观。
然而,地址方法也有其优势。例如,如果地址是多签名的,并且事务构建变得更复杂,使用地址检查可能更容易。此外,如果应用程序中有一个在每个函数中都使用的中央对象,它可以存储管理员地址,并简化迁移。中央对象的方法在可撤销的能力中也很有价值,管理员可以从用户那里收回能力。
纪元和时间
Sui 框架提供了两种访问当前时间的方式:Epoch
和 Time
。前者表示系统中的操作期,大约每24小时更改一次。后者表示自 Unix 纪元以来的当前时间(以毫秒为单位)。在程序中可以自由访问这两个时间。
纪元
纪元用于将系统分隔成操作期。在一个纪元内,验证人集合是固定的;然而,在纪元边界处,验证人集合可以改变。纪元在共识算法中起着关键作用,并用于确定当前的验证人集合。它们也在质押机制中使用。
可以从事务上下文中读取纪元:
public fun current_epoch(ctx: &TxContext) {
let epoch = ctx.epoch();
// ...
}
还可以获取纪元开始时的 Unix 时间戳:
public fun current_epoch_start(ctx: &TxContext) {
let epoch_start = ctx.epoch_timestamp_ms();
// ...
}
通常情况下,纪元用于质押和系统操作,然而,在自定义场景中可以使用它们来模拟24小时的周期。如果应用程序依赖于质押逻辑或需要知道当前的验证人集合,纪元非常重要。
时间
为了更精确地测量时间,Sui 提供了 Clock
对象。它是一个系统对象,在检查点期间由系统更新,它以毫秒为单位存储自 Unix 纪元以来的当前时间。Clock
对象在 sui::clock
模块中定义,并具有保留地址 0x6
。
Clock 是一个共享对象,但是试图以可变方式访问它的事务将失败。这种限制允许对 Clock
对象进行并行访问,这对于保持性能是重要的。
// 文件:sui-framework/clock.move
/// 单例共享对象,向 Move 调用公开时间。此对象位于地址 0x6 处,只能通过不可变引用访问(以只读方式)。
///
/// 如果尝试以可变引用或值接受 `Clock` 的入口函数将无法通过验证,而诚实的验证人将不会签署或执行使用 `Clock` 作为输入参数的事务,除非以不可变引用方式传递它。
struct Clock has key {
id: UID,
/// 时钟的时间戳,由系统事务在每次共识提交计划时或在测试期间通过 `sui::clock::increment_for_testing` 设置。
timestamp_ms: u64,
}
sui::clock
模块中只有一个公共函数可用,即 timestamp_ms
。它以毫秒为单位返回当前时间自 Unix 纪元以来的时间。
use sui::clock::Clock;
/// Clock needs to be passed as an immutable reference.
public fun current_time(clock: &Clock) {
let time = clock.timestamp_ms();
// ...
}
集合
集合类型是任何编程语言的基础部分。它们用于存储数据集合,例如项目列表。vector
类型已经在
向量部分 中介绍过,本章我们将介绍 Sui 框架 提供
的基于 vector 的集合类型。
向量
虽然我们之前已经在 向量部分 中介绍过 vector
类型,但在新的上下文中再
次回顾它还是很有意义的。这一次,我们将介绍如何在对象中使用 vector
类型以及如何在应用程序中使用它。
module book::collections_vector {
use std::string::String;
/// The Book that can be sold by a `BookStore`
public struct Book has key, store {
id: UID,
name: String
}
/// The BookStore that sells `Book`s
public struct BookStore has key, store {
id: UID,
books: vector<Book>
}
}
VecSet
VecSet
是一种用于存储一组唯一项目的集合类型。它类似于 vector
,但它不允许重复的项目。这使得它非常
适合存储一组唯一的项目,例如唯一 ID 或地址列表。
VecSet 在尝试插入已存在于集合中的项时将失败。
VecMap
VecMap
是一种用于存储键值对映射的集合类型。它类似于 VecSet
,但它允许您将一个值与集合中的每个项目
相关联。这使得它非常适合存储键值对集合,例如地址及其余额列表,或者用户 ID 及其关联数据列表。
VecMap
中的键是唯一的,每个键只能与单个值相关联。如果您尝试插入一个键值对,其中键已经存在于映射中
,则旧值将被新值替换。
module book::collections {
use std::string::String;
use sui::vec_map::{Self, VecMap};
public struct Metadata has drop {
name: String,
/// `VecMap` used in the struct definition
attributes: VecMap<String, String>
}
#[test]
fun vec_map_playground() {
let mut map = vec_map::empty(); // create an empty map
map.insert(2, b"two".to_string()); // add a key-value pair to the map
map.insert(3, b"three".to_string());
assert!(map.contains(&2), 0); // check if a key is in the map
map.remove(&2); // remove a key-value pair from the map
}
}
限制
标准集合类型是一种存储类型数据并保证安全性和一致性的好方法。但是,它们受到可以存储的数据类型的限制( 类型系统不允许您在集合中存储错误的类型);并且它们受到大小的限制(对象大小限制)。它们适用于相对较小 的集合和列表,但对于更大的集合,您可能需要使用不同的方法。
集合类型的另一个限制是无法比较它们。因为插入顺序不是确定的,所以尝试将一个 VecSet
与另一个
VecSet
进行比较可能不会产生预期的结果。
此行为会由 linter 捕获并发出警告:比较类型为 'sui::vec_set::VecSet' 的集合可能会产生意外的结果
let mut set1 = vec_set::empty();
set1.insert(1);
set1.insert(2);
let mut set2 = vec_set::empty();
set2.insert(2);
set2.insert(1);
assert!(set1 == set2, 0);
在上面的例子中,比较会失败,因为插入顺序不是确定的,并且两个 VecSet
实例可能具有不同的元素顺序。即
使两个 VecSet
实例包含相同的元素,比较也会失败。
总结
- 向量是一种原生类型,允许存储项目列表。
- VecSet 基于向量构建,允许存储唯一项目的集合。
- VecMap 用于以类似映射的结构存储键值对。
- 基于 vector 的集合严格类型化,并受对象大小限制,最适合小型集合和列表。
后续
下一节我们将介绍 动态字段 - 这是一种重要的原语,允许使用 动态集合 - 以更灵活但更昂贵的方式存储大型数据集合。
动态字段
Sui对象模型允许对象作为_动态字段_附加到其他对象上。这种行为类似于其他编程语言中的Map
。然而,与Move中的严格类型化Map
不同(我们在集合部分中已介绍),动态字段允许附加任意类型的对象。在前端开发中,类似的方法是JavaScript对象类型,它允许动态存储任何类型的数据。
动态字段可以附加到一个对象上的数量没有限制。因此,动态字段可以用于存储大量数据,这些数据不适合对象大小限制。
动态字段允许广泛的应用,从将数据拆分成更小的部分以避免对象大小限制,到将对象作为应用程序逻辑的一部分附加。
定义
动态字段在Sui框架的sui::dynamic_field
模块中定义。它们通过一个_名称_附加到对象的UID
上,可以使用该名称进行访问。每个对象只能附加一个给定名称的字段。
文件:sui-framework/sources/dynamic_field.move
/// 用于存储字段和值的内部对象
public struct Field<Name: copy + drop + store, Value: store> has key {
/// 由对象ID、字段名称和类型的哈希值决定,即hash(parent.id || name || Name)
id: UID,
/// 该字段名称的值
name: Name,
/// 绑定到该字段的值
value: Value,
}
如定义所示,动态字段存储在一个内部Field
对象中,该对象的UID
是基于对象ID、字段名称和字段类型的哈希值生成的。Field
对象包含字段名称和绑定到该字段的值。Name
和Value
类型参数上的约束定义了键和值必须具备的能力。
用法
动态字段的方法很简单:可以用add
添加字段,用remove
删除字段,并用borrow
和borrow_mut
读取字段。此外,exists_
方法可以用来检查字段是否存在(对于更严格的类型检查,有一个exists_with_type
方法)。
module book::dynamic_collection {
// a very common alias for `dynamic_field` is `df` since the
// module name is quite long
use sui::dynamic_field as df;
use std::string::String;
/// The object that we will attach dynamic fields to.
public struct Character has key {
id: UID
}
// List of different accessories that can be attached to a character.
// They must have the `store` ability.
public struct Hat has key, store { id: UID, color: u32 }
public struct Mustache has key, store { id: UID }
#[test]
fun test_character_and_accessories() {
let ctx = &mut tx_context::dummy();
let mut character = Character { id: object::new(ctx) };
// Attach a hat to the character's UID
df::add(
&mut character.id,
b"hat_key",
Hat { id: object::new(ctx), color: 0xFF0000 }
);
// Similarly, attach a mustache to the character's UID
df::add(
&mut character.id,
b"mustache_key",
Mustache { id: object::new(ctx) }
);
// Check that the hat and mustache are attached to the character
//
assert!(df::exists_(&character.id, b"hat_key"), 0);
assert!(df::exists_(&character.id, b"mustache_key"), 1);
// Modify the color of the hat
let hat: &mut Hat = df::borrow_mut(&mut character.id, b"hat_key");
hat.color = 0x00FF00;
// Remove the hat and mustache from the character
let hat: Hat = df::remove(&mut character.id, b"hat_key");
let mustache: Mustache = df::remove(&mut character.id, b"mustache_key");
// Check that the hat and mustache are no longer attached to the character
assert!(!df::exists_(&character.id, b"hat_key"), 0);
assert!(!df::exists_(&character.id, b"mustache_key"), 1);
sui::test_utils::destroy(character);
sui::test_utils::destroy(mustache);
sui::test_utils::destroy(hat);
}
}
在上面的例子中,我们定义了一个Character
对象和两种不同类型的配件,这些配件无法一起放入一个向量中。然而,动态字段允许我们在单个对象中存储它们。两个对象通过vector<u8>
(字节字符串字面量)附加到Character
,并可以使用各自的键进行访问。
如你所见,当我们将配件附加到Character
时,是通过_值_传递的。换句话说,两个值都被移动到一个新的作用域,它们的所有权被转移到Character
对象。如果我们改变Character
对象的所有权,配件也会随之移动。
我们应该强调的动态字段的最后一个重要属性是它们_通过其父对象进行访问_。这意味着Hat
和Mustache
对象不能直接访问,并遵循与父对象相同的规则。
外部类型作为动态字段
动态字段允许对象携带任何类型的数据,包括其他模块中定义的那些。这是由于它们的泛型性质和对类型参数的相对弱约束。让我们通过将一些不同的值附加到一个Character
对象来说明这一点。
let mut character = Character { id: object::new(ctx) };
// Attach a `String` via a `vector<u8>` name
df::add(&mut character.id, b"string_key", b"Hello, World!".to_string());
// Attach a `u64` via a `u32` name
df::add(&mut character.id, 1000u32, 1_000_000_000u64);
// Attach a `bool` via a `bool` name
df::add(&mut character.id, true, false);
在这个例子中,我们展示了如何为动态字段的_名称_和_值_使用不同的类型。String
通过vector<u8>
名称附加,u64
通过u32
名称附加,bool
通过bool
名称附加。使用动态字段可以实现任何可能性!
孤立的动态字段
为防止孤立的动态字段,请使用动态集合类型,如
Bag
,它们会跟踪动态字段,并在有附加字段时不允许解包。
用于删除UID的object::delete()
函数不跟踪动态字段,不能防止动态字段变成孤立字段。一旦父UID被删除,动态字段不会自动删除,它们会变成孤立字段。这意味着动态字段仍然存储在区块链中,但它们将永远无法再次访问。
let hat = Hat { id: object::new(ctx), color: 0xFF0000 };
let mut character = Character { id: object::new(ctx) };
// Attach a `Hat` via a `vector<u8>` name
df::add(&mut character.id, b"hat_key", hat);
// ! DO NOT do this in your code
// ! Danger - deleting the parent object
let Character { id } = character;
id.delete();
// ...`Hat` is now stuck in a limbo, it will never be accessible again
孤立的对象不属于存储回扣的范畴,存储费用将保持未认领状态。在解包对象时避免孤立动态字段的一种方法是返回UID
并将其临时存储在某处,直到动态字段被删除并得到适当处理。
自定义类型作为字段名称
在上面的例子中,我们使用原始类型作为字段名称,因为它们具有所需的能力。但使用自定义类型作为字段名称时,动态字段变得更加有趣。这允许更结构化地存储数据,并且还允许保护字段名称不被其他模块访问。
/// A custom type with fields in it.
public struct AccessoryKey has copy, drop, store { name: String }
/// An empty key, can be attached only once.
public struct MetadataKey has copy, drop, store {}
我们在上面定义的两个字段名称是AccessoryKey
和MetadataKey
。AccessoryKey
有一个String
字段,因此可以使用不同的name
值多次使用。MetadataKey
是一个空键,只能附加一次。
let mut character = Character { id: object::new(ctx) };
// Attaching via an `AccessoryKey { name: b"hat" }`
df::add(
&mut character.id,
AccessoryKey { name: b"hat".to_string() },
Hat { id: object::new(ctx), color: 0xFF0000 }
);
// Attaching via an `AccessoryKey { name: b"mustache" }`
df::add(
&mut character.id,
AccessoryKey { name: b"mustache".to_string() },
Mustache { id: object::new(ctx) }
);
// Attaching via a `MetadataKey`
df::add(&mut character.id, MetadataKey {}, 42);
如你所见,自定义类型确实可以作为字段名称,但只要它们可以由模块构造,换句话说 - 如果它们是模块的_内部_并在其中定义。这种对结构打包的限制可以在应用程序设计中开辟新的途径。
这种方法在对象能力模式中使用,其中应用程序可以授权外部对象在其中执行操作,同时不向其他模块公开能力。
暴露UID
由于动态字段附加到UID
上,它们在其他模块中的使用取决于UID
是否可以访问。默认情况下,结构可见性保护id
字段,不允许其他模块直接访问它。然而,如果有一个返回UID
引用的公共访问器方法,动态字段可以在其他模块中读取。
/// Exposes the UID of the character, so that other modules can read
/// dynamic fields.
public fun uid(c: &Character): &UID {
&c.id
}
在上面的例子中,我们展示了如何暴露Character
对象的UID
。此解决方案可能适用于某些应用程序,但请记住,暴露的UID
允许读取附加到对象的_任何_动态字段。
如果你只需要在包内暴露UID
,请使用限制性可见性,如public(package)
,或者更好的是 - 使用更具体的访问器方法,只允许读取特定字段。
/// Only allow modules in the same package to access the UID.
public(package) fun uid_package(c: &Character): &UID {
&c.id
}
/// Allow borrowing dynamic fields from the character.
public fun borrow<Name: copy + store + drop, Value: store>(
c: &Character,
n: Name
): &Value {
df::borrow(&c.id, n)
}
动态字段与常规字段
动态字段比常规字段更昂贵,因为它们需要额外的存储和访问成本。它们的灵活性是有代价的,在决定使用动态字段还是常规字段时,重要的是理解其影响。
限制
动态字段不受对象大小限制的约束,可以用于存储大量数据。然而,它们仍然受动态字段创建限制的约束,每个事务的字段数量限制为1000个。
应用
动态字段在任何复杂度的应用程序中都可以发挥关键作用。它们打开了各种不同的用例,从存储异构数据到将对象作为应用程序逻辑的一部分附加。基于定义它们_稍后_并更改字段类型的能力,它们允许某些可升级性实践。
下一步
在下一节中,我们将介绍动态对象字段,并解释它们与动态字段的区别,以及使用它们的影响。
动态对象字段
本节扩展了动态字段。请先阅读它,以了解动态字段的基本知识。
动态字段的另一种变体是_动态对象字段_,它与常规动态字段有一些不同之处。在本节中,我们将介绍动态对象字段的具体细节,并解释它们与常规动态字段的区别。
一般建议是尽量避免使用动态对象字段,除非确实需要通过ID进行直接发现。动态对象字段的额外成本可能无法被其提供的好处所证明。
定义
动态对象字段在Sui框架的sui::dynamic_object_fields
模块中定义。它们在许多方面与动态字段相似,但与动态字段不同,动态对象字段对Value
类型有额外的约束。Value
必须具有key
和store
的组合,而不仅仅是动态字段中的store
。
它们在框架定义中不那么明确,因为这个概念本身更为抽象:
文件:sui-framework/sources/dynamic_object_fields.move
/// 用于存储字段和值的内部对象
public struct Wrapper<Name> has copy, drop, store {
name: Name,
}
与动态字段部分中的Field
类型不同,Wrapper
类型仅存储字段的名称。值是对象本身,未被包装。
Value
类型的约束在动态对象字段可用的方法中变得明显。以下是add
函数的签名:
/// 将动态对象字段添加到对象`object: &mut UID`上的由`name: Name`指定的字段中。
/// 如果对象已经具有该名称的字段,则中止并返回`EFieldAlreadyExists`。
public fun add<Name: copy + drop + store, Value: key + store>(
// 我们在多个地方使用&mut UID进行访问控制
object: &mut UID,
name: Name,
value: Value,
) { /* 实现省略 */ }
其余与动态字段部分中相同的方法对Value
类型有相同的约束。我们列出它们以供参考:
add
- 向对象添加动态对象字段remove
- 从对象中删除动态对象字段borrow
- 从对象中借用动态对象字段borrow_mut
- 从对象中借用动态对象字段的可变引用exists_
- 检查动态对象字段是否存在exists_with_type
- 检查特定类型的动态对象字段是否存在
此外,还有一个id
方法,它返回Value
对象的ID
,而不指定其类型。
用法及与动态字段的区别
动态字段和动态对象字段之间的主要区别在于后者只允许将_对象_作为值进行存储。这意味着你不能存储u64
或bool
等原始类型。尽管如此,动态对象字段并未被包装成一个单独的对象,这种约束可以被看作一种限制。
放宽包装的要求使对象可通过其ID进行链外发现。然而,如果实现了包装对象索引,这一特性可能不再出色,从而使动态对象字段成为冗余特性。
module book::dynamic_object_field {
use std::string::String;
// there are two common aliases for the long module name: `dof` and
// `ofield`. Both are commonly used and met in different projects.
use sui::dynamic_object_field as dof;
use sui::dynamic_field as df;
/// The `Character` that we will use for the example
public struct Character has key { id: UID }
/// Metadata that doesn't have the `key` ability
public struct Metadata has store, drop { name: String }
/// Accessory that has the `key` and `store` abilities.
public struct Accessory has key, store { id: UID }
#[test]
fun equip_accessory() {
let ctx = &mut tx_context::dummy();
let mut character = Character { id: object::new(ctx) };
// Create an accessory and attach it to the character
let hat = Accessory { id: object::new(ctx) };
// Add the hat to the character. Just like with `dynamic_fields`
dof::add(&mut character.id, b"hat_key", hat);
// However for non-key structs we can only use `dynamic_field`
df::add(&mut character.id, b"metadata_key", Metadata {
name: b"John".to_string()
});
// Borrow the hat from the character
let hat_id = dof::id(&character.id, b"hat_key").extract(); // Option<ID>
let hat_ref: &Accessory = dof::borrow(&character.id, b"hat_key");
let hat_mut: &mut Accessory = dof::borrow_mut(&mut character.id, b"hat_key");
let hat: Accessory = dof::remove(&mut character.id, b"hat_key");
// Clean up, Metadata is an orphan now.
sui::test_utils::destroy(hat);
sui::test_utils::destroy(character);
}
}
定价差异
动态对象字段比动态字段稍微昂贵一些。由于其内部结构,它们需要两个对象:名称的包装器和值。因此,添加和访问对象字段的成本(加载2个对象相比于动态字段的1个对象)更高。
下一步
动态字段和动态对象字段都是强大的功能,允许在应用程序中实现创新的解决方案。然而,它们相对低级,需要仔细处理以避免孤立字段。在下一节中,我们将介绍一个更高级别的抽象 - 动态集合,它可以更有效地管理动态字段和对象。
动态集合
Sui 框架提供了多种集合类型,基于动态字段和动态对象字段的概念构建。这些集合类型旨在以更安全和更易理解的方式存储和管理动态字段和对象。
对于每种集合类型,我们将指定它们使用的基本类型和它们提供的特定功能。
与操作 UID 的动态(对象)字段不同,集合类型具有自己的类型,并允许调用关联函数。
公共概念
所有集合类型共享相同的一组方法,包括:
add
- 将字段添加到集合中remove
- 从集合中移除字段borrow
- 从集合中借用字段borrow_mut
- 从集合中借用可变引用字段contains
- 检查集合中是否存在字段length
- 返回集合中字段的数量is_empty
- 检查length
是否为0
所有集合类型都支持对borrow
和borrow_mut
方法使用索引语法。如果在示例中看到方括号,它们将被转换为对borrow
和borrow_mut
的调用。
let hat: &Hat = &bag[b"key"];
let hat_mut: &mut Hat = &mut bag[b"key"];
// 等同于
let hat: &Hat = bag.borrow(b"key");
let hat_mut: &mut Hat = bag.borrow_mut(b"key");
在示例中,我们不会专注于这些函数,而是关注集合类型之间的区别。
Bag
正如其名,Bag 表示一组异构值的“袋子”。它是一个简单的非泛型类型,可以存储任何数据。Bag 永远不会允许存在孤立的字段,因为它会跟踪字段的数量,如果不是空的,则不能销毁它。
// 文件:sui-framework/sources/bag.move
public struct Bag has key, store {
/// 此 Bag 的 ID
id: UID,
/// Bag 中键值对的数量
size: u64,
}
由于 Bag 存储任何类型,它提供了额外的方法:
contains_with_type
- 检查是否存在特定类型的字段
作为结构字段使用:
/// Imported from the `sui::bag` module.
use sui::bag::{Self, Bag};
/// An example of a `Bag` as a struct field.
public struct Carrier has key {
id: UID,
bag: Bag
}
使用 Bag:
let mut bag = bag::new(ctx);
// bag has the `length` function to get the number of elements
assert!(bag.length() == 0, 0);
bag.add(b"my_key", b"my_value".to_string());
// length has changed to 1
assert!(bag.length() == 1, 1);
// in order: `borrow`, `borrow_mut` and `remove`
// the value type must be specified
let field_ref: &String = &bag[b"my_key"];
let field_mut: &mut String = &mut bag[b"my_key"];
let field: String = bag.remove(b"my_key");
// length is back to 0 - we can unpack
bag.destroy_empty();
ObjectBag
在sui::object_bag
模块中定义。与 Bag 相同,但在内部使用动态对象字段。只能存储对象作为值。
Table
Table 是一个具有固定键和值类型的类型化动态集合。它在sui::table
模块中定义。
// 文件:sui-framework/sources/table.move
public struct Table<phantom K: copy + drop + store, phantom V: store> has key, store {
/// 此 Table 的 ID
id: UID,
/// Table 中键值对的数量
size: u64,
}
作为结构字段使用:
/// Imported from the `sui::table` module.
use sui::table::{Self, Table};
/// Some record type with `store`
public struct Record has store { /* ... */ }
/// An example of a `Table` as a struct field.
public struct UserRegistry has key {
id: UID,
table: Table<address, Record>
}
使用 Table:
#[test] fun test_table() {
let ctx = &mut tx_context::dummy();
// Table requires explicit type parameters for the key and value
// ...but does it only once in initialization.
let mut table = table::new<address, String>(ctx);
// table has the `length` function to get the number of elements
assert!(table.length() == 0, 0);
table.add(@0xa11ce, b"my_value".to_string());
table.add(@0xb0b, b"another_value".to_string());
// length has changed to 2
assert!(table.length() == 2, 2);
// in order: `borrow`, `borrow_mut` and `remove`
let addr_ref = &table[@0xa11ce];
let addr_mut = &mut table[@0xa11ce];
// removing both values
let _addr = table.remove(@0xa11ce);
let _addr = table.remove(@0xb0b);
// length is back to 0 - we can unpack
table.destroy_empty();
ObjectTable
在sui::object_table
模块中定义。与 Table 相同,但在内部使用动态对象字段。只能存储对象作为值。
概要
- Bag - 一个简单的集合,可以存储任何类型的数据
- ObjectBag - 一个只能存储对象的集合
- Table - 一个具有固定键和值类型的类型化动态集合
- ObjectTable - 与 Table 相同,但只能存储对象
LinkedTable
此部分即将推出!
模式:证明者
证明者是通过构建证据来证明存在的一种模式。在编程的背景下,证明者是通过提供一个只有在属性成立时才能构建的值来证明系统的某个属性。
在 Move 中的证明者模式
在 结构体 部分中,我们展示了结构体只能由定义它的模块创建或打包。因此,在 Move 中,模块通过构建类型来证明对其的所有权。这是 Move 中最重要的模式之一,广泛用于泛型类型的实例化和授权。
从实际角度来看,为了使用证明者,必须有一个期望证明者作为参数的函数。在下面的示例中,new
函数期望一个 T
类型的证明者来创建 Instance<T>
实例。
通常情况下,证明者结构体不会被存储,因此函数可能需要该类型的 Drop 能力。
module book::witness {
/// 需要证明者才能创建的结构体。
public struct Instance<T> { t: T }
/// 使用提供的 T 创建 `Instance<T>` 的新实例。
public fun new<T>(witness: T): Instance<T> {
Instance { t: witness }
}
}
构造 Instance<T>
的唯一方法是调用 new
函数,并提供类型 T
的一个实例。这是 Move 中证明者模式的基本示例。提供证明者的模块通常会有相应的实现,例如下面的 book::witness_source
模块:
module book::witness_source {
use book::witness::{Self, Instance};
/// 作为证明者使用的结构体。
public struct W {}
/// 创建 `Instance<W>` 的新实例。
public fun new_instance(): Instance<W> {
witness::new(W {})
}
}
将结构体 W
的实例传递给 new_instance
函数可以创建一个 Instance<W>
,从而证明了模块 book::witness_source
拥有类型 W
。
实例化泛型类型
证明者允许使用具体类型实例化泛型类型。这对于从类型继承关联行为,并有选择地扩展这些行为(如果模块提供了这样的能力)非常有用。
// 文件:sui-framework/sources/balance.move
/// 供应类型为 T。用于铸造和销毁。
public struct Supply<phantom T> has key, store {
id: UID,
value: u64
}
/// 使用提供的证明者为类型 T 创建新的供应。
public fun create_supply<T: drop>(_w: T): Supply<T> {
Supply { value: 0 }
}
/// 获取 `Supply` 的值。
public fun supply_value<T>(supply: &Supply<T>): u64 {
supply.value
}
上面的示例来自于 Sui Framework 的 balance
模块,其中 Supply
是一个泛型结构体,只能通过提供类型 T
的证明者来构建。证明者是按值获取并且被丢弃 - 因此 T
必须具有 drop 能力。
然后可以使用实例化的 Supply<T>
来铸造新的 Balance<T>
,其中 T
是供应的类型。
// 文件:sui-framework/sources/balance.move
/// 可存储的余额。
struct Balance<phantom T> has store {
value: u64
}
/// 增加供应量 `value` 并创建具有此值的新 `Balance<T>`。
public fun increase_supply<T>(self: &mut Supply<T>, value: u64): Balance<T> {
assert!(value < (18446744073709551615u64 - self.value), EOverflow);
self.value = self.value + value;
Balance { value }
}
一次性证明者
虽然结构体可以任意次数地创建,但有些情况下需要确保结构体只能创建一次。为此,Sui 提供了“一次性证明者” - 一种特殊的证明者,只能使用一次。我们将在下一节中详细解释。
总结
- 证明者是通过构建证据来证明某个属性的模式。
- 在 Move 中,通过构建类型来证明模块对其的所有权。
- 证明者经常用于泛型类型的实例化和授权。
下一步
在下一节中,我们将学习关于一次性证明者模式。
一次性见证(One Time Witness)
尽管常规的见证(Witness)是一种静态证明类型拥有权的好方法,但在某些情况下,我们需要确保见证仅被实例化一次。这就是一次性见证(One Time Witness,简称 OTW)的目的。
定义
一次性见证(OTW)是一种特殊类型的见证,只能使用一次。它不能手动创建,且每个模块中拥有唯一的实例。Sui 适配器将类型视为 OTW,如果满足以下规则:
- 只具有
drop
能力。 - 没有字段。
- 不是泛型类型。
- 模块名称为大写字母。
以下是 OTW 的示例:
module book::one_time {
/// The OTW for the `book::one_time` module.
/// Only `drop`, no fields, no generics, all uppercase.
public struct ONE_TIME has drop {}
/// Receive the instance of `ONE_TIME` as the first argument.
fun init(otw: ONE_TIME, ctx: &mut TxContext) {
// do something with the OTW
}
}
OTW 不能手动构造,任何试图这样做的代码都会导致编译错误。OTW 可以作为模块初始化器的第一个参数进行接收。由于 init
函数每个模块只调用一次,因此 OTW 保证只被实例化一次。
强制使用 OTW
要检查一个类型是否为 OTW,可以使用Sui 框架的 sui::types
模块提供的特殊函数 is_one_time_witness
。
use sui::types;
const ENotOneTimeWitness: u64 = 1;
/// Takes an OTW as an argument, aborts if the type is not OTW.
public fun takes_witness<T: drop>(otw: T) {
assert!(types::is_one_time_witness(&otw), ENotOneTimeWitness);
}
总结
OTW 模式是确保类型仅使用一次的强大工具。大多数开发者应该理解如何定义和接收 OTW,而 OTW 的检查和强制主要在库和框架中需要。例如,sui::coin
模块要求在 coin::create_currency
方法中使用 OTW,从而确保只创建一个 coin::TreasuryCap
。
OTW 是为接下来我们将要介绍的发布者(Publisher)对象打下基础的强大工具。
发布者权限
在应用程序设计和开发中,证明发布者的权限往往是必要的。这在数字资产的上下文中特别重要,因为发布者可能会为其资产启用或禁用某些功能。发布者对象是一个对象,在Sui Framework中定义,允许发布者证明其对类型的_权威_。
定义
发布者对象在Sui框架的sui::package
模块中定义。它是一个非常简单的、非泛型对象,可以每个模块初始化一次(每个包多次),用于证明发布者对类型的权威。为了声明一个发布者对象,发布者必须向package::claim
函数提供一个一次性见证。
// File: sui-framework/sources/package.move
public struct Publisher has key, store {
id: UID,
package: String,
module_name: String,
}
如果您不熟悉一次性见证,可以在这里阅读更多信息。
下面是一个在模块中声明Publisher
对象的简单示例:
module book::publisher {
/// Some type defined in the module.
public struct Book {}
/// The OTW for the module.
public struct PUBLISHER has drop {}
/// Uses the One Time Witness to claim the Publisher object.
fun init(otw: PUBLISHER, ctx: &mut TxContext) {
// Claim the Publisher object.
let publisher = sui::package::claim(otw, ctx);
// Usually it is transferred to the sender.
// It can also be stored in another object.
transfer::public_transfer(publisher, ctx.sender())
}
}
使用
发布者对象有两个关联的函数,用于证明发布者对类型的权威:
// Checks if the type is from the same module, hence the `Publisher` has the
// authority over it.
assert!(publisher.from_module<Book>(), 0);
// Checks if the type is from the same package, hence the `Publisher` has the
// authority over it.
assert!(publisher.from_package<Book>(), 0);
发布者作为管理员角色
对于小型应用程序或简单的用例,发布者对象可以用作管理员能力。虽然在更广泛的上下文中,发布者对象对系统配置具有控制权,但它也可以用于管理应用程序的状态。
/// Some action in the application gated by the Publisher object.
public fun admin_action(cap: &Publisher, /* app objects... */ param: u64) {
assert!(cap.from_module<Book>(), ENotAuthorized);
// perform application-specific action
}
然而,发布者对象缺少一些能力的本地属性,如类型安全和表达性。admin_action
的签名不是很明确,可以被其他人调用。由于Publisher
对象是标准的,如果不执行from_module
检查,现在存在未经授权访问的风险。因此,在将Publisher
对象用作管理员角色时需要谨慎。
在Sui中的角色
在Sui上,发布者对于某些功能是必需的。对象展示只能由发布者创建,TransferPolicy——Kiosk系统的重要组成部分——也需要发布者对象来证明类型的所有权。
下一步
在下一章中,我们将介绍需要发布者对象的第一个功能——对象展示——一种描述客户端对象并标准化元数据的方法。这是用户友好应用程序的必备功能。
对象显示
在Sui上,对象的结构和行为是明确的,可以以易于理解的方式显示。然而,为了支持客户机的丰富元数据,定义了一种标准且高效的方式来“描述”对象,即Sui框架中的Display
对象。
背景
历史上曾有不同的尝试来达成对象结构的标准,以便可以在用户界面中显示对象。其中一种方法是定义对象结构中的某些字段,当这些字段存在时,会在UI中使用。然而,这种方法不够灵活,需要开发人员在每个对象中定义相同的字段,有时这些字段对对象来说没有意义。
/// An attempt to standardize the object structure for display.
public struct CounterWithDisplay has key {
id: UID,
/// If this field is present it will be displayed in the UI as `name`.
name: String,
/// If this field is present it will be displayed in the UI as `description`.
description: String,
// ...
image: String,
/// Actual fields of the object.
counter: u64,
// ...
}
如果字段包含静态数据,每个对象中都会重复这些数据。而且,由于Move没有接口,无法知道一个对象是否有特定字段,这使得客户端的获取过程更复杂。
对象显示
为了解决这些问题,Sui引入了一种标准方式来描述对象的显示。与其在对象结构中定义字段,不如将显示元数据存储在一个单独的对象中,并与类型关联。这样,显示元数据不会重复,且易于扩展和维护。
Sui Display的另一个重要特性是能够定义模板并在这些模板中使用对象字段。这不仅允许更灵活的显示,还解放了开发人员不必在每个对象中定义相同的字段和类型。
Sui Fullnode本机支持对象显示,如果对象类型关联了Display,客户端可以获取显示元数据。
module book::arena {
use std::string::String;
use sui::package;
use sui::display;
/// The One Time Witness to claim the `Publisher` object.
public struct ARENA has drop {}
/// Some object which will be displayed.
public struct Hero has key {
id: UID,
class: String,
level: u64,
}
/// In the module initializer we create the `Publisher` object, and then
/// the Display for the `Hero` type.
fun init(otw: ARENA, ctx: &mut TxContext) {
let publisher = package::claim(otw, ctx);
let mut display = display::new<Hero>(&publisher, ctx);
display.add(
b"name".to_string(),
b"{class} (lvl. {level})".to_string()
);
display.add(
b"description".to_string(),
b"One of the greatest heroes of all time. Join us!".to_string()
);
display.add(
b"link".to_string(),
b"https://example.com/hero/{id}".to_string()
);
display.add(
b"image_url".to_string(),
b"https://example.com/hero/{class}.jpg".to_string()
);
// Update the display with the new data.
// Must be called to apply changes.
display.update_version();
transfer::public_transfer(publisher, ctx.sender());
transfer::public_transfer(display, ctx.sender());
}
}
创建者特权
虽然对象可以由账户拥有并可能属于真正的所有权范畴,但Display可以由对象的创建者拥有。这样,创建者可以更新显示元数据,并全局应用更改而不需要更新每个对象。创建者还可以将Display转移给其他账户,甚至围绕对象构建一个应用程序来管理元数据。
标准字段
最广泛支持的字段包括:
name
- 对象的名称。当用户查看对象时显示。description
- 对象的描述。当用户查看对象时显示。link
- 在应用程序中使用的对象链接。image_url
- 对象的图像的URL或Blob。thumbnail_url
- 在钱包、浏览器和其他产品中用作预览的小图像的URL。project_url
- 与对象或创建者相关的网站链接。creator
- 表示对象创建者的字符串。
请参考Sui文档获取最新支持的字段列表。
尽管有一套标准字段,Display对象并不强制执行这些字段。开发人员可以定义他们需要的任何字段,客户端可以根据需要使用它们。一些应用程序可能需要额外的字段,而忽略其他字段,Display足够灵活以支持这些需求。
使用Display
Display
对象在sui::display
模块中定义。它是一个泛型结构,接受一个虚拟类型作为参数。虚拟类型用于将Display
对象与其描述的类型关联。Display
对象的fields
是键值对的VecMap
,其中键是字段名,值是字段值。version
字段用于显示元数据的版本,并在update_display
调用时更新。
文件:sui-framework/sources/display.move
struct Display<phantom T: key> has key, store {
id: UID,
/// 包含显示字段。目前支持的字段有:name、link、image和description。
fields: VecMap<String, String>,
/// 版本只能由发布者手动更新。
version: u16
}
Publisher对象需要一个新的Display,因为它作为类型的所有权证明。
模板语法
目前,Display支持简单的字符串插值,并可以在其模板中使用结构字段(和路径)。语法很简单 - {path}
替换为该路径字段的值。路径是一个以点分隔的字段名列表,针对嵌套字段从根对象开始。
/// Some common metadata for objects.
public struct Metadata has store {
name: String,
description: String,
published_at: u64
}
/// The type with nested Metadata field.
public struct LittlePony has key, store {
id: UID,
image_url: String,
metadata: Metadata
}
上述LittlePony
类型的Display可以定义如下:
{
"name": "Just a pony",
"image_url": "{image_url}",
"description": "{metadata.description}"
}
多个Display对象
对于特定的T
类型,可以创建任意数量的Display<T>
对象。然而,fullnode将使用最近更新的Display<T>
。
进一步阅读
事件
事件是一种通知链下监听器关于链上事件的方式。它们用于发出关于交易的额外信息,这些信息不会存储在链上,因此无法在链上访问。事件由Sui框架中的sui::event
模块发出。
// 文件:sui-framework/sources/event.move
module sui::event {
/// 发出自定义的Move事件,将数据发送到链下。
///
/// 用于创建自定义索引并以最适合特定应用程序的方式跟踪链上活动。
///
/// 类型 `T` 是索引事件的主要方式,可以包含幻象参数,例如 `emit(MyEvent<phantom T>)`。
public native fun emit<T: copy + drop>(event: T);
}
发出事件
事件使用sui::event
模块中的emit
函数发出。该函数接受一个参数——要发出的事件。事件数据以值传递。
module book::events {
use sui::coin::Coin;
use sui::sui::SUI;
use sui::event;
/// The item that can be purchased.
public struct Item has key { id: UID }
/// Event emitted when an item is purchased. Contains the ID of the item and
/// the price for which it was purchased.
public struct ItemPurchased has copy, drop {
item: ID,
price: u64
}
/// A marketplace function which performs the purchase of an item.
public fun purchase(coin: Coin<SUI>, ctx: &mut TxContext) {
let item = Item { id: object::new(ctx) };
// Create an instance of `ItemPurchased` and pass it to `event::emit`.
event::emit(ItemPurchased {
item: object::id(&item),
price: coin.value()
});
// Omitting the rest of the implementation to keep the example simple.
abort 0
}
}
Sui Verifier要求传递给emit
函数的类型是_模块的内部类型_。因此,发出来自其他模块的类型将导致编译错误。尽管原始类型符合_copy_和_drop_要求,但它们不允许作为事件发出。
事件结构
事件是交易结果的一部分,存储在_交易效果_中。因此,它们本质上具有sender
字段,即发送交易的地址。因此,添加“sender”字段到事件中是不必要的。同样,事件元数据包含时间戳,但需要注意的是时间戳相对于节点,可能会因节点不同而有所变化。
Sui Framework
Sui Framework 是 Package Manifest 中的默认依赖集。它依赖于 标准库,提供了特定于 Sui 的功能,包括与存储的交互,以及 Sui 特定的本地类型和模块。
为方便起见,我们将 Sui Framework 中的模块分为多个类别。但它们仍然属于同一个框架。
核心模块
模块 | 描述 | 章节 |
---|---|---|
sui::address | 添加了对 地址类型 的转换方法 | 地址 |
sui::transfer | 实现了对象的存储操作 | 它以一个对象开始 |
sui::tx_context | 包含了 TxContext 结构体及其相关方法用于读取 | 交易上下文 |
sui::object | 定义了创建对象所需的 UID 和 ID 类型 | 它以一个对象开始 |
sui::clock | 定义了 Clock 类型及其方法 | 纪元和时间 |
sui::dynamic_field | 实现了添加、使用和移除动态字段的方法 | 动态字段 |
sui::dynamic_object_field | 实现了添加、使用和移除动态对象字段的方法 | 动态对象字段 |
sui::event | 允许为链下监听器发出事件 | 事件 |
sui::package | 定义了 Publisher 类型和包升级方法 | 发布者, 包升级 |
sui::display | 实现了 Display 对象及其创建和更新方法 | 显示 |
集合模块
模块 | 描述 | 章节 |
---|---|---|
sui::vec_set | 实现了集合类型 | 集合 |
sui::vec_map | 使用向量键实现了映射 | 集合 |
sui::table | 实现了 Table 类型及其与之交互的方法 | 动态集合 |
sui::linked_table | 实现了 LinkedTable 类型及其与之交互的方法 | 动态集合 |
sui::bag | 实现了 Bag 类型及其与之交互的方法 | 动态集合 |
sui::object_table | 实现了 ObjectTable 类型及其与之交互的方法 | 动态集合 |
sui::object_bag | 实现了 ObjectBag 类型及其与之交互的方法 | 动态集合 |
实用工具模块
导出地址
Sui Framework 导出了两个命名地址:sui = 0x2
和 std = 0x1
,来自于标准库依赖。
[addresses]
sui = "0x2"
# 从 MoveStdlib 依赖中导出
std = "0x1"
隐式导入
就像 标准库 一样,一些模块和类型在 Sui Framework 中是隐式导入的。以下是可以在没有显式 use
导入的情况下使用的模块和类型列表:
- sui::object
- sui::object::ID
- sui::object::UID
- sui::tx_context
- sui::tx_context::TxContext
- sui::transfer
源代码
Sui Framework 的源代码可以在 Sui 仓库 中找到。
模式:烫手山芋
在能力系统中,没有任何能力的结构体被称为 烫手山芋 。 它不能被存储(既不能作为对象, 也不能作为另一个结构体的字段), 也不能被复制或丢弃。 因此,一旦构造完成,它必须优雅地由其模块解包, 否则由于未使用的值无法丢弃,交易将中止。
如果你熟悉支持 回调 的编程语言,可以将“烫手山芋”理解为必须调用回调函数的义务。如果你不调用它,交易将中止。
这个名字源自儿童游戏“烫手山芋”,游戏中球在玩家之间快速传递,没有人希望在音乐停止时成为最后一个持球的人,否则他们会出局。 这正是该模式的最佳比喻——“烫手山芋”结构体的实例在函数调用之间传递,任何模块都不能保留它。
定义一个烫手山芋
没有任何能力的结构体都可以是“烫手山芋”。例如,以下结构体就是一个“烫手山芋”:
public struct Request {}
由于 Request
没有任何能力,不能被存储或忽略,因此模块必须提供一个解包它的函数。例如:
/// Constructs a new `Request`
public fun new_request(): Request { Request {} }
/// Unpacks the `Request`. Due to the nature of the hot potato, this function
/// must be called to avoid aborting the transaction.
public fun confirm_request(request: Request) {
let Request {} = request;
}
使用示例
在以下示例中,Promise
作为“烫手山芋”被用来确保从容器中借出的值在使用后被归还。
Promise
结构体包含了被借出对象的 ID 和容器的 ID,确保借出的值没有被替换成其他对象,
并且被正确归还到对应的容器中。
/// A generic container for any Object with `key + store`. The Option type
/// is used to allow taking and putting the value back.
public struct Container<T: key + store> has key {
id: UID,
value: Option<T>,
}
/// A Hot Potato struct that is used to ensure the borrowed value is returned.
public struct Promise {
/// The ID of the borrowed object. Ensures that there wasn't a value swap.
id: ID,
/// The ID of the container. Ensures that the borrowed value is returned to
/// the correct container.
container_id: ID,
}
/// A module that allows borrowing the value from the container.
public fun borrow_val<T: key + store>(container: &mut Container<T>): (T, Promise) {
assert!(container.value.is_some());
let value = container.value.extract();
let id = object::id(&value);
(value, Promise { id, container_id: object::id(container) })
}
/// Put the taken item back into the container.
public fun return_val<T: key + store>(
container: &mut Container<T>, value: T, promise: Promise
) {
let Promise { id, container_id } = promise;
assert!(object::id(container) == container_id);
assert!(object::id(&value) == id);
container.value.fill(value);
}
应用场景
以下是“烫手山芋”模式的一些常见应用场景:
借用
如上述示例所示,
“烫手山芋”模式在借用场景中非常有效,能够保证借出的值被正确归还到原始容器中。
虽然示例中聚焦于存储在 Option
中的值,
但同样的模式也可以应用于其他存储类型,比如动态字段。
闪电贷
“烫手山芋”模式的经典示例是闪电贷。闪电贷是一种在同一笔交易中借入并偿还的贷款。 借入的资金用于执行一些操作,之后归还给贷款方。 “烫手山芋”模式确保借入的资金会被归还给贷款方。
此模式的使用示例可能如下所示:
// 从贷款方借入资金。
let (asset_a, potato) = lender.borrow(amount);
// 使用借入的资金执行一些操作。
let asset_b = dex.trade(loan);
let proceeds = another_contract::do_something(asset_b);
// 保留佣金并将其余部分返还给贷款方。
let pay_back = proceeds.split(amount, ctx);
lender.repay(pay_back, potato);
transfer::public_transfer(proceeds, ctx.sender());
变量路径执行
“烫手山芋”模式可以用于引入执行路径中的变化。
例如,如果某个模块允许用“奖励积分”或美元购买一部 Phone
,那么可以使用“烫手山芋”模式将购买与支付解耦。
这种方式与某些商店的工作方式非常相似——你从货架上取下商品,然后到收银台付款。
/// A `Phone`. Can be purchased in a store.
public struct Phone has key, store { id: UID }
/// A ticket that must be paid to purchase the `Phone`.
public struct Ticket { amount: u64 }
/// Return the `Phone` and the `Ticket` that must be paid to purchase it.
public fun purchase_phone(ctx: &mut TxContext): (Phone, Ticket) {
(
Phone { id: object::new(ctx) },
Ticket { amount: 100 }
)
}
/// The customer may pay for the `Phone` with `BonusPoints` or `SUI`.
public fun pay_in_bonus_points(ticket: Ticket, payment: Coin<BONUS>) {
let Ticket { amount } = ticket;
assert!(payment.value() == amount);
abort 0 // omitting the rest of the function
}
/// The customer may pay for the `Phone` with `USD`.
public fun pay_in_usd(ticket: Ticket, payment: Coin<USD>) {
let Ticket { amount } = ticket;
assert!(payment.value() == amount);
abort 0 // omitting the rest of the function
}
这种解耦技术允许将购买逻辑与支付逻辑分离,使代码更加模块化并更易于维护。
Ticket
可以拆分为一个独立的模块,提供基础的支付接口,而商店的实现可以扩展以支持其他商品,而无需更改支付逻辑。
组合模式
“烫手山芋”模式可以用于以组合的方式将不同模块连接在一起。 模块可以定义与“烫手山芋”交互的方式,例如给它盖上类型签名,或从中提取一些信息。 这样,“烫手山芋”可以在不同模块之间传递,甚至在同一交易中传递到不同的包中。
最重要的组合模式是请求模式,我们将在下一节中介绍。
在 Sui 框架中的应用
该模式在 Sui 框架中以各种形式使用。以下是一些示例:
sui::borrow
- 使用“烫手山芋”模式确保借出的值被正确归还到原始容器。sui::transfer_policy
- 定义了TransferRequest
,一种只能在满足所有条件时才能被消耗的“烫手山芋”。sui::token
- 在闭环代币系统中,ActionRequest
携带已执行操作的信息,并像TransferRequest
一样收集批准。
总结
- “烫手山芋”是没有能力的结构体,它必须伴随有创建和销毁的方法。
- “烫手山芋”用于确保在交易结束前执行某些操作,类似于回调函数。
- “烫手山芋”最常见的应用场景包括借用、闪电贷、变量路径执行和组合模式。
二进制规范序列化(BCS)
二进制规范序列化(BCS)是一种用于结构化数据的二进制编码格式。最初在Diem中设计,现在已成为Move语言的标准序列化格式。BCS简单、高效、确定性强,并且容易在任何编程语言中实现。
完整的格式规范可以在BCS仓库中找到。
格式
BCS是一种支持无符号整数(最高256位)、选项、布尔值、单位(空值)、定长和变长序列以及映射的二进制格式。该格式设计为确定性,即相同的数据总是会序列化为相同的字节。
“BCS不是自描述格式。因此,要反序列化消息,必须预先知道消息类型和布局。”——来自README
整数以小端格式存储,变长整数使用变长编码方案。序列前缀为其长度(ULEB128),枚举存储为变体的索引加数据,映射存储为有序的键值对序列。
结构体被视为字段的序列,字段按定义顺序序列化,使用与顶层数据相同的规则。
使用BCS
Sui框架包含sui::bcs
模块,用于编码和解码数据。编码函数是虚拟机的原生函数,解码函数在Move中实现。
编码
要编码数据,可以使用bcs::to_bytes
函数,将数据引用转换为字节向量。该函数支持编码任何类型,包括结构体。
// 文件: move-stdlib/sources/bcs.move
public native fun to_bytes<T>(t: &T): vector<u8>;
以下示例展示了如何使用BCS编码结构体。to_bytes
函数可以接收任何值并将其编码为字节向量。
use sui::bcs;
// 0x01 - a single byte with value 1 (or 0 for false)
let bool_bytes = bcs::to_bytes(&true);
// 0x2a - just a single byte
let u8_bytes = bcs::to_bytes(&42u8);
// 0x2a00000000000000 - 8 bytes
let u64_bytes = bcs::to_bytes(&42u64);
// address is a fixed sequence of 32 bytes
// 0x0000000000000000000000000000000000000000000000000000000000000002
let addr = bcs::to_bytes(&@sui);
编码结构体
结构体的编码类似于简单类型。以下是如何使用BCS编码结构体:
let data = CustomData {
num: 42,
string: b"hello, world!".to_string(),
value: true
};
let struct_bytes = bcs::to_bytes(&data);
let mut custom_bytes = vector[];
custom_bytes.append(bcs::to_bytes(&42u8));
custom_bytes.append(bcs::to_bytes(&b"hello, world!".to_string()));
custom_bytes.append(bcs::to_bytes(&true));
// struct is just a sequence of fields, so the bytes should be the same!
assert!(&struct_bytes == &custom_bytes, 0);
解码
由于BCS不是自描述的且Move是静态类型的,解码需要预先了解数据类型。sui::bcs
模块提供了各种函数来帮助这个过程。
包装API
BCS在Move中实现为一个包装器。解码器按值接收字节,然后调用不同的解码函数(以peel_*
为前缀)来“剥离”数据。数据从字节中分离,剩余字节保存在包装器中,直到调用into_remainder_bytes
函数。
use sui::bcs;
// BCS instance should always be declared as mutable
let mut bcs = bcs::new(x"010000000000000000");
// Same bytes can be read differently, for example: Option<u64>
let value: Option<u64> = bcs.peel_option_u64();
assert!(value.is_some(), 0);
assert!(value.borrow() == &0, 1);
let remainder = bcs.into_remainder_bytes();
assert!(remainder.length() == 0, 2);
在解码过程中,通常在单个let
语句中使用多个变量。这使代码更具可读性,并有助于避免不必要的数据复制。
let mut bcs = bcs::new(x"0101010F0000000000F00000000000");
// mind the order!!!
// handy way to peel multiple values
let (bool_value, u8_value, u64_value) = (
bcs.peel_bool(),
bcs.peel_u8(),
bcs.peel_u64()
);
解码向量
虽然大多数基本类型有专门的解码函数,但向量需要特殊处理,具体取决于元素类型。对于向量,首先需要解码向量的长度,然后在循环中解码每个元素。
let mut bcs = bcs::new(x"0101010F0000000000F00000000000");
// bcs.peel_vec_length() peels the length of the vector :)
let mut len = bcs.peel_vec_length();
let mut vec = vector[];
// then iterate depending on the data type
while (len > 0) {
vec.push_back(bcs.peel_u64()); // or any other type
len = len - 1;
};
assert!(vec.length() == 1, 0);
对于最常见的场景,bcs
模块提供了一组基本的函数来解码向量:
peel_vec_address(): vector<address>
peel_vec_bool(): vector<bool>
peel_vec_u8(): vector<u8>
peel_vec_u64(): vector<u64>
peel_vec_u128(): vector<u128>
peel_vec_vec_u8(): vector<vector<u8>>
- 字节向量的向量
解码选项
Option 表示为一个0或1个元素的向量。要读取一个选项,可以将其视为向量并检查其长度(第一个字节 - 1或0)。
let mut bcs = bcs::new(x"00");
let is_some = bcs.peel_bool();
assert!(is_some == false, 0);
let mut bcs = bcs::new(x"0101");
let is_some = bcs.peel_bool();
let value = bcs.peel_u8();
assert!(is_some == true, 1);
assert!(value == 1, 2);
如果需要解码自定义类型的选项,请使用上面的代码片段中的方法。
对于最常见的场景,bcs
模块提供了一组基本的函数来解码选项:
peel_option_address(): Option<address>
peel_option_bool(): Option<bool>
peel_option_u8(): Option<u8>
peel_option_u64(): Option<u64>
peel_option_u128(): Option<u128>
解码结构体
结构体按字段逐个解码,没有标准函数可以自动将字节解码为Move结构体,因为这会违反Move的类型系统。相反,需要手动解码每个字段。
// some bytes...
let mut bcs = bcs::new(x"0101010F0000000000F00000000000");
let (age, is_active, name) = (
bcs.peel_u8(),
bcs.peel_bool(),
bcs.peel_vec_u8().to_string()
);
let user = User { age, is_active, name };
总结
二进制规范序列化是一种高效的结构化数据二进制格式,确保跨平台的一致序列化。Sui框架提供了全面的BCS工具,通过内置函数实现了广泛的功能。
Move 2024 迁移指南
Move 2024 是由 Mysten Labs 维护的新版本 Move 语言。本指南旨在帮助您了解 2024 版与之前版本的区别。
本指南提供了新版本中变化的高级概述。有关更详细和详尽的更改列表,请参阅 Sui Documentation。
使用新版
要使用新版,您需要在 move
文件中指定版本。版本在 move
文件中使用 edition
关键字来指定。目前,唯一可用的版本是 2024.beta
。
edition = "2024.beta";
迁移工具
Move CLI 提供了一个迁移工具,可以将代码更新到新版本。要使用迁移工具,请运行以下命令:
$ sui move migrate
迁移工具将更新代码以使用 let mut
语法、新的 public
修饰符用于结构体,以及用 public(package)
函数可见性代替 friend
声明。
使用 let mut
声明可变变量
Move 2024 引入了 let mut
语法来声明可变变量。let mut
语法用于声明一个可以在声明后更改的可变变量。
现在,声明可变变量必须使用
let mut
。如果尝试在没有mut
关键字的情况下重新赋值变量,编译器将发出错误。
// Move 2020
let x: u64 = 10;
x = 20;
// Move 2024
let mut x: u64 = 10;
x = 20;
此外,mut
关键字在元组解构和函数参数中用于声明可变变量。
// 通过值传递并修改
fun takes_by_value_and_mutates(mut v: Value): Value {
v.field = 10;
v
}
// `mut` 应放在变量名之前
fun destruct() {
let (x, y) = point::get_point();
let (mut x, y) = point::get_point();
let (mut x, mut y) = point::get_point();
}
// 在结构体解包中
fun unpack() {
let Point { x, mut y } = point::get_point();
let Point { mut x, mut y } = point::get_point();
}
friend
已被弃用
在 Move 2024 中,friend
关键字已被弃用。相反,您可以使用 public(package)
可见性修饰符使函数对同一包中的其他模块可见。
// Move 2020
friend book::friend_module;
public(friend) fun protected_function() {}
// Move 2024
public(package) fun protected_function_2024() {}
结构体可见性
在 Move 2024 中,结构体具有可见性修饰符。目前,唯一可用的可见性修饰符是 public
。
// Move 2020
struct Book {}
// Move 2024
public struct Book {}
方法语法
在新版本中,以结构体作为第一个参数的函数与结构体关联。这意味着可以使用点表示法调用函数。在与类型定义在同一模块中的方法会自动导出。
如果类型在与方法相同的模块中定义,则方法会自动导出。无法为在其他模块中定义的类型导出方法。但是,您可以在模块范围内创建自定义别名来代替。
public fun count(c: &Counter): u64 { /* ... */ }
fun use_counter() {
// move 2020
let count = counter::count(&c);
// move 2024
let count = c.count();
}
内置类型的方法
在 Move 2024 中,一些原生和标准类型具有关联方法。例如,vector
类型具有 to_string
方法,可以将向量转换为 UTF8 字符串。
fun aliases() {
// 向量转字符串和 ASCII 字符串
let str: String = b"Hello, World!".to_string();
let ascii: ascii::String = b"Hello, World!".to_ascii_string();
// 地址转字节
let bytes = @0xa11ce.to_bytes();
}
有关内置别名的完整列表,请参阅标准库和Sui 框架的源代码。
借用操作符
一些内置类型支持借用操作符。借用操作符用于获取指定索引处元素的引用。借用操作符定义为 []
。
fun play_vec() {
let v = vector[1,2,3,4];
let first = &v[0]; // 调用 vector::borrow(v, 0)
let first_mut = &mut v[0]; // 调用 vector::borrow_mut(v, 0)
let first_copy = v[0]; // 调用 *vector::borrow(v, 0)
}
支持借用操作符的类型有:
vector
sui::vec_map::VecMap
sui::table::Table
sui::bag::Bag
sui::object_table::ObjectTable
sui::object_bag::ObjectBag
sui::linked_table::LinkedTable
要为自定义类型实现借用操作符,需要在方法上添加 #[syntax(index)]
属性。
#[syntax(index)]
public fun borrow(c: &List<T>, key: String): &T { /* ... */ }
#[syntax(index)]
public fun borrow_mut(c: &mut List<T>, key: String): &mut T { /* ... */ }
方法别名
在 Move 2024 中,方法可以与类型关联。可以为本地模块中的任何类型定义别名;如果类型在同一模块中定义,则可以公开定义别名。
// my_module.move
// 本地:类型对模块来说是外来的
use fun my_custom_function as vector.do_magic;
// sui-framework/kiosk/kiosk.move
// 导出:类型在同一模块中定义
public use fun kiosk_owner_cap_for as KioskOwnerCap.kiosk;
这就是 Move 2024 迁移指南的主要内容。如果有更多问题或需要进一步指导,请随时询问!
可升级性实践
为了讨论可升级性的最佳实践,我们首先需要了解包中哪些部分可以升级。可升级性的基本前提是,升级不应破坏与先前版本的公共兼容性。模块中可以在依赖包中使用的部分不应更改其静态签名。这适用于模块——模块不能从包中删除,公共结构体——它们可以在函数签名中使用,以及公共函数——它们可以从其他包中调用。
// 模块不能从包中删除
module book::upgradable {
// 依赖项可以更改(如果它们未在公共签名中使用)
use std::string::String;
use sui::event; // 可以删除
// 公共结构体不能删除且不能更改
public struct Book has key {
id: UID,
title: String,
}
// 公共结构体不能删除且不能更改
public struct BookCreated has copy, drop {
/* ... */
}
// 公共函数不能删除且其签名不能更改,但实现可以更改
public fun create_book(ctx: &mut TxContext): Book {
create_book_internal(ctx)
// 可以删除和更改
event::emit(BookCreated {
/* ... */
})
}
// 包可见性函数可以删除和更改
public(package) fun create_book_package(ctx: &mut TxContext): Book {
create_book_internal(ctx)
}
// 入口函数可以删除和更改,只要它们不是公共的
entry fun create_book_entry(ctx: &mut TxContext): Book {
create_book_internal(ctx)
}
// 私有函数可以删除和更改
fun create_book_internal(ctx: &mut TxContext): Book {
abort 0
}
}
对象版本控制
要放弃包的先前版本,可以对对象进行版本控制。只要对象包含一个版本字段,并且使用对象的代码期望并断言特定版本,代码就可以强制迁移到新版本。通常,在升级后,可以使用管理员函数来更新共享状态的版本,以便可以使用新版本的代码,而旧版本由于版本不匹配而中止。
module book::versioned_state {
const EVersionMismatch: u64 = 0;
const VERSION: u8 = 1;
/// 共享状态(也可以是所有的)
public struct SharedState has key {
id: UID,
version: u8,
/* ... */
}
public fun mutate(state: &mut SharedState) {
assert!(state.version == VERSION, EVersionMismatch);
// ...
}
}
使用动态字段进行配置版本控制
在 Sui 中有一种常见模式,允许更改对象的存储配置,同时保留相同的对象签名。这是通过保持基础对象简单且有版本,并将实际配置对象作为动态字段添加来实现的。使用这种 锚 模式,可以通过包升级更改配置,同时保持相同的基础对象签名。
module book::versioned_config {
use sui::vec_map::VecMap;
use std::string::String;
/// 基础对象
public struct Config has key {
id: UID,
version: u16
}
/// 实际配置
public struct ConfigV1 has store {
data: Bag,
metadata: VecMap<String, String>
}
// ...
}
模块化架构
此部分即将推出!
通过这些实践,你可以确保你的 Move 包在升级时保持兼容性,同时利用灵活的版本控制机制来管理对象和配置的变化。
构建时的限制
为了保证网络的安全和稳定,Sui 设置了一些限制和约束。这些限制旨在防止滥用,确保网络保持稳定和高效。本指南概述了这些限制和约束,以及如何在这些限制内构建应用程序。
这些限制在协议配置中定义,并由网络强制执行。如果任何限制被超出,交易将被拒绝或中止。作为协议的一部分,这些限制只能通过网络升级进行更改。
交易大小
交易的大小限制为 128KB。这包括交易负载的大小、交易签名的大小和交易元数据的大小。如果交易超过这个限制,将被网络拒绝。
对象大小
对象的大小限制为 256KB。这包括对象数据的大小。如果对象超过这个限制,将被网络拒绝。虽然单个对象不能绕过这个限制,但可以通过使用动态字段(例如 Bag)将一个基础对象与其他对象组合来实现更大存储需求。
单个纯参数大小
单个纯参数的大小限制为 16KB。超过这个限制的交易参数将导致执行失败。因此,要创建超过 ~500 个地址的向量(假设单个地址为 32 字节),需要在交易块或 Move 函数中动态拼接。标准函数如 vector::append()
可以将两个 ~16KB 的向量拼接成一个 ~32KB 的单一值。
创建的最大对象数
单笔交易中最多可以创建 2048 个对象。如果交易尝试创建超过 2048 个对象,将被网络拒绝。这也影响了动态字段,因为键和值都是对象。所以单笔交易中最多可以创建 1024 个动态字段。
创建的最大动态字段数
单个对象中最多可以创建 1024 个动态字段。如果对象尝试创建超过 1024 个动态字段,将被网络拒绝。
最大事件数
单笔交易中最多可以发出 1024 个事件。如果交易尝试发出超过 1024 个事件,将被中止。
了解并遵守这些限制对于开发稳定和高效的应用程序至关重要。通过合理的设计和优化,可以在这些限制内构建出功能强大的应用程序。
更好的错误处理
当执行过程中遇到中止时,交易将失败,并将中止代码返回给调用者。Move VM 返回中止交易的模块名称和中止代码。此行为对交易的调用者并不完全透明,尤其是在单个函数包含对可能中止的相同函数的多个调用时。在这种情况下,调用者将不知道哪个调用中止了交易,并且难以调试问题或向用户提供有意义的错误消息。
module book::module_a {
use book::module_b;
public fun do_something() {
let field_1 = module_b::get_field(1); // 可能中止并返回 0
/* ... 许多逻辑 ... */
let field_2 = module_b::get_field(2); // 可能中止并返回 0
/* ... 更多逻辑 ... */
let field_3 = module_b::get_field(3); // 可能中止并返回 0
}
}
上面的例子说明了单个函数包含多个可能中止的调用的情况。如果 do_something
函数的调用者收到中止代码 0
,将很难理解是哪个调用中止了交易。为了解决这个问题,可以使用一些常见的模式来改进错误处理。
规则 1:处理所有可能的情况
提供一个安全的“检查”函数以返回布尔值,指示操作是否可以安全执行是一种良好的实践。如果 module_b
提供了一个返回布尔值指示字段是否存在的函数 has_field
,则可以将 do_something
函数重写如下:
module book::module_a {
use book::module_b;
const ENoField: u64 = 0;
public fun do_something() {
assert!(module_b::has_field(1), ENoField);
let field_1 = module_b::get_field(1);
/* ... */
assert!(module_b::has_field(2), ENoField);
let field_2 = module_b::get_field(2);
/* ... */
assert!(module_b::has_field(3), ENoField);
let field_3 = module_b::get_field(3);
}
}
通过在每次调用 module_b::get_field
之前添加自定义检查,module_a
的开发者控制了错误处理。这也使得实现第二条规则成为可能。
规则 2:使用不同的代码中止
一旦调用者模块处理了中止代码,使用不同的中止代码针对不同的情况。这使得调用者模块可以向用户提供有意义的错误消息。可以将 module_a
重写如下:
module book::module_a {
use book::module_b;
const ENoFieldA: u64 = 0;
const ENoFieldB: u64 = 1;
const ENoFieldC: u64 = 2;
public fun do_something() {
assert!(module_b::has_field(1), ENoFieldA);
let field_1 = module_b::get_field(1);
/* ... */
assert!(module_b::has_field(2), ENoFieldB);
let field_2 = module_b::get_field(2);
/* ... */
assert!(module_b::has_field(3), ENoFieldC);
let field_3 = module_b::get_field(3);
}
}
现在,调用者模块可以向用户提供有意义的错误消息。如果调用者收到中止代码 0
,可以将其翻译为“字段 1 不存在”。如果调用者收到中止代码 1
,可以将其翻译为“字段 2 不存在”,依此类推。
规则 3:返回布尔值而不是断言
开发人员常常倾向于添加一个公共函数来断言所有条件并中止执行。然而,更好的做法是创建一个返回布尔值的函数。这样,调用者模块可以处理错误并向用户提供有意义的错误消息。
module book::some_app_assert {
const ENotAuthorized: u64 = 0;
public fun do_a() {
assert_is_authorized();
// ...
}
public fun do_b() {
assert_is_authorized();
// ...
}
/// 不要这样做
public fun assert_is_authorized() {
assert!(/* 一些条件 */ true, ENotAuthorized);
}
}
此模块可以重写如下:
module book::some_app {
const ENotAuthorized: u64 = 0;
public fun do_a() {
assert!(is_authorized(), ENotAuthorized);
// ...
}
public fun do_b() {
assert!(is_authorized(), ENotAuthorized);
// ...
}
public fun is_authorized(): bool {
/* 一些条件 */ true
}
// 私有函数仍可以用于避免代码重复的情况
// 当相同的条件与相同的中止代码在多个地方使用时
fun assert_is_authorized() {
assert!(is_authorized(), ENotAuthorized);
}
}
利用这三个规则可以使交易调用者的错误处理更加透明,并允许其他开发人员在其模块中使用自定义中止代码。
编码规范
命名
模块
- 模块名称应使用
snake_case
。 - 模块名称应具有描述性,不应过长。
module book::conventions { /* ... */ }
module book::common_practices { /* ... */ }
常量
- 常量名称应使用
SCREAMING_SNAKE_CASE
。 - 错误常量应使用
EPascalCase
。
const MAX_PRICE: u64 = 1000;
const EInvalidInput: u64 = 0;
函数
- 函数名称应使用
snake_case
。 - 函数名称应具有描述性。
public fun add(a: u64, b: u64): u64 { a + b }
public fun create_if_not_exists() { /* ... */ }
结构体
- 结构体名称应使用
PascalCase
。 - 结构体字段应使用
snake_case
。 - 能力结构体名称应以
Cap
结尾。
public struct Hero has key {
id: UID,
value: u64,
another_value: u64,
}
public struct AdminCap has key { id: UID }
结构体方法
- 结构体方法应使用
snake_case
。 - 如果多个结构体具有相同的方法名称,方法名称应以结构体名称为前缀。在这种情况下,可以使用
use fun
为方法添加别名。
public fun value(h: &Hero): u64 { h.value }
public use fun hero_health as Hero.health;
public fun hero_health(h: &Hero): u64 { h.another_value }
public use fun boar_health as Boar.health;
public fun boar_health(b: &Boar): u64 { b.another_value }
附录 A: 术语表
- 快速路径 (Fast Path) - 用于描述不涉及共享对象且无需共识即可执行的交易。
- 并行执行 (Parallel Execution) - 用于描述 Sui 运行时能够并行执行交易的能力,包括涉及共享对象的交易。
- 内部类型 (Internal Type) - 在模块内定义的类型。此类型的字段不能在模块外访问,并且在仅具有 "key" 能力的情况下,不能在
public_*
转移函数中使用。
能力 (Abilities)
- key - 允许结构体在存储中用作键的能力。在 Sui 上,key 能力标记一个对象 (object),并要求第一个字段为
id: UID
。 - store - 允许结构体存储在其他对象内的能力。此能力放宽了对内部结构体的限制,允许
public_*
转移函数接受它们作为参数。它还使对象 (object) 能够作为动态字段存储。 - copy - 允许结构体被复制的能力。在 Sui 上,
copy
能力与key
能力冲突,不能同时使用。 - drop - 允许结构体被忽略或丢弃的能力。在 Sui 上,
drop
能力不能与key
能力一起使用,因为对象 (object) 不允许被忽略。
附录 B: 预留地址
保留地址是指在 Sui 上具有特定用途的特殊地址。它们在不同环境之间保持不变,并用于特定的原生操作。
0x1
- 标准库 的地址(别名std
)0x2
- Sui 框架 的地址(别名sui
)0x5
-SuiSystem
对象的地址0x6
- 系统Clock
对象 的地址0x8
- 系统Random
对象的地址0x403
-DenyList
系统对象的地址
附录 C: 出版物
本节列出了与 Move 和 Sui 相关的论文。
- The Move Borrow Checker 作者:Sam Blackshear, John Mitchell, Todd Nowacki, Shaz Qadeer。
- Resources: A Safe Language Abstraction for Money 作者:Sam Blackshear, David L. Dill, Shaz Qadeer, Clark W. Barrett, John C. Mitchell, Oded Padon, Yoni Zohar。
- Robust Safety for Move 作者:Marco Patrignani, Sam Blackshear。
附录 D: 贡献
如果你想为这本书做出贡献,请提交一个 pull request 到 GitHub 仓库。该仓库包含用 mdBook 格式编写的书籍源文件。
附录 E: 致谢
《Rust 程序设计语言》 对这本书有很大的启发。我个人非常感谢该书的作者 Steve Klabnik 和 Carol Nichols,因为我从中学到了很多。这本书是对他们工作的一个小小的致敬,也是尝试为 Move 社区带来类似学习体验的一种努力。