|
| 1 | +# 派生对象(Derived Object) |
| 2 | + |
| 3 | +派生对象(Derived Object)是 Sui Framework 中用于**按父对象与键生成确定性地址**的机制。通过 `sui::derived_object`,你可以让某个对象的 ID 完全由「父对象 UID + 键」推导而出,从而实现可预测的地址、注册表去重以及按类型或键命名空间管理子对象。本节将详细介绍其 API、典型场景与注意事项。 |
| 4 | + |
| 5 | +## 为什么需要确定性地址 |
| 6 | + |
| 7 | +在默认情况下,`object::new(ctx)` 会为每个新对象分配一个**随机的**新 ID。但在以下场景中,我们更需要**确定性**的地址: |
| 8 | + |
| 9 | +- **注册表(Registry)**:例如「每种代币类型 T 在 CoinRegistry 下对应唯一一个 Currency\<T\>」,希望同一类型 T 永远映射到同一个地址,便于链下按地址查询。 |
| 10 | +- **命名空间**:父对象作为命名空间,不同键对应不同子对象地址,且同一键不能重复注册。 |
| 11 | +- **可预测的 object ID**:前端或索引器希望在不发起交易的前提下,仅根据父 ID 和键就能算出子对象的 ID。 |
| 12 | + |
| 13 | +`derived_object` 提供的正是:**由 (父 UID, Key) 确定性地推导出 address/UID**,并在父对象上记录「该键已被占用」,从而保证同一键只能被 claim 一次。 |
| 14 | + |
| 15 | +## 与动态字段的关系 |
| 16 | + |
| 17 | +`derived_object` 在实现上依赖 **动态字段**(`dynamic_field`): |
| 18 | +在父对象的 UID 上以 `Claimed(derived_id)` 为名存储一个标记,表示该派生 ID 已被占用。因此: |
| 19 | + |
| 20 | +- **claim** 时会向父对象写入一条动态字段,用于防止同一 key 被重复 claim。 |
| 21 | +- **exists** 时只是查询该动态字段是否存在,不创建新对象。 |
| 22 | +- 派生出的 UID 一旦被 **claim**,就与父对象解耦使用,子对象可以独立存在、转移或共享,不要求父对象在交易中一起被访问(仅首次 claim 时需要父对象可变引用)。 |
| 23 | + |
| 24 | +## 模块与导入 |
| 25 | + |
| 26 | +```move |
| 27 | +use sui::derived_object; |
| 28 | +``` |
| 29 | + |
| 30 | +## 核心 API |
| 31 | + |
| 32 | +| 函数 | 签名 | 说明 | |
| 33 | +|------|------|------| |
| 34 | +| **derive_address** | `fun derive_address<K: copy + drop + store>(parent: ID, key: K): address` | 根据父 ID 和键**计算**派生地址,不修改状态,不占用键。 | |
| 35 | +| **claim** | `fun claim<K: copy + drop + store>(parent: &mut UID, key: K): UID` | 在父对象上**占用**该键,返回对应的派生 UID;同一键重复 claim 会 abort。 | |
| 36 | +| **exists** | `fun exists<K: copy + drop + store>(parent: &UID, key: K): bool` | 查询该 (父, key) 是否已被 claim 过。 | |
| 37 | + |
| 38 | +### derive_address |
| 39 | + |
| 40 | +仅做**纯计算**:给定父对象的 `ID` 和键 `key`,返回一个确定的 `address`。不访问链上状态,不写入任何对象。可用于: |
| 41 | + |
| 42 | +- 在未 claim 之前就预先知道「若用该 key claim,对象会落在哪个地址」。 |
| 43 | +- 链下或前端用相同算法推算子对象 ID(需与框架实现保持一致)。 |
| 44 | + |
| 45 | +```move |
| 46 | +let parent_id = parent.id.to_inner(); |
| 47 | +let addr = derived_object::derive_address(parent_id, my_key); |
| 48 | +// addr 每次对同一 parent_id + my_key 都相同 |
| 49 | +``` |
| 50 | + |
| 51 | +### claim |
| 52 | + |
| 53 | +在**父对象的 UID** 上占用键 `key`,并返回一个**派生 UID**。内部会: |
| 54 | + |
| 55 | +1. 用 `derive_address(parent, key)` 得到地址并转成 ID; |
| 56 | +2. 检查父对象上是否已有 `Claimed(该 id)` 的动态字段; |
| 57 | +3. 若无,则添加该动态字段,并返回由该地址构造的 `UID`。 |
| 58 | + |
| 59 | +返回的 UID 可直接用于构造新对象,使该对象「诞生」在派生地址上: |
| 60 | + |
| 61 | +```move |
| 62 | +let derived_uid = derived_object::claim(&mut parent.id, key); |
| 63 | +let child = MyObject { |
| 64 | + id: derived_uid, |
| 65 | + field: value, |
| 66 | +}; |
| 67 | +// child 的地址 = derive_address(parent.id.to_inner(), key) |
| 68 | +``` |
| 69 | + |
| 70 | +同一 `(parent, key)` 只能 **claim 一次**;再次 claim 会触发 **EObjectAlreadyExists** 并 abort。 |
| 71 | + |
| 72 | +### exists |
| 73 | + |
| 74 | +查询在给定父对象上,某键是否已被 claim 过(即是否已存在对应的 `Claimed` 动态字段)。 |
| 75 | +注意:一旦 claim 过,即使之后把派生出的对象删掉(`object::delete`),**exists 仍为 true**,该键无法再次 claim。这样设计是为了避免「删掉子对象后重新 claim 同一键得到新对象」,保证派生地址的长期唯一性。 |
| 76 | + |
| 77 | +## Key 的类型约束与唯一性 |
| 78 | + |
| 79 | +键类型 `K` 必须满足 **`copy + drop + store`**。常见用法: |
| 80 | + |
| 81 | +- **简单类型**:`u64`、`address`、`bool` 等。 |
| 82 | +- **字符串**:`std::string::String`、`std::ascii::String`(注意 `String` 与 `vector<u8>`、`ascii::String` 类型不同,会得到不同地址)。 |
| 83 | +- **结构体**:如 `CurrencyKey<T>()` 这种单例式 key,用于「按类型 T 派生」。 |
| 84 | + |
| 85 | +不同**类型**或不同**值**的 key 会得到不同的派生地址。例如: |
| 86 | + |
| 87 | +- `derive_address(parent, b"foo".to_string())` 与 `derive_address(parent, b"foo")`(`vector<u8>`)**不等**; |
| 88 | +- `derive_address(parent, key1)` 与 `derive_address(parent, key2)` 在 `key1 != key2` 时**不等**。 |
| 89 | + |
| 90 | +因此设计注册表时,键的选取(类型 + 取值)要能唯一标识一个「槽位」。 |
| 91 | + |
| 92 | +## 典型场景 |
| 93 | + |
| 94 | +### 1. 按类型注册:每个 T 一个槽位 |
| 95 | + |
| 96 | +在类型注册表(如 CoinRegistry)中,希望「每种类型 T 对应一个对象」。可以用**类型相关的 key**(例如一个只包含类型的结构体)作为键: |
| 97 | + |
| 98 | +```move |
| 99 | +use sui::derived_object; |
| 100 | +
|
| 101 | +public struct Registry has key { id: UID } |
| 102 | +
|
| 103 | +/// 用作派生键:同一类型 T 总是同一个 Key |
| 104 | +public struct TypeKey<phantom T> has copy, drop, store {} |
| 105 | +
|
| 106 | +public fun register<T: key>( |
| 107 | + registry: &mut Registry, |
| 108 | + ctx: &mut TxContext, |
| 109 | +): UID { |
| 110 | + derived_object::claim(&mut registry.id, TypeKey<T>()) |
| 111 | +} |
| 112 | +
|
| 113 | +public fun exists<T: key>(registry: &Registry): bool { |
| 114 | + derived_object::exists(®istry.id, TypeKey<T>()) |
| 115 | +} |
| 116 | +``` |
| 117 | + |
| 118 | +这样每种 `T` 最多被注册一次,且对应地址唯一、可复现。 |
| 119 | + |
| 120 | +### 2. 按字符串键注册:命名槽位 |
| 121 | + |
| 122 | +用字符串(或其它业务键)做命名空间,每个键对应一个派生对象: |
| 123 | + |
| 124 | +```move |
| 125 | +public fun create_named_slot( |
| 126 | + registry: &mut Registry, |
| 127 | + name: std::string::String, |
| 128 | + ctx: &mut TxContext, |
| 129 | +): UID { |
| 130 | + derived_object::claim(&mut registry.id, name) |
| 131 | +} |
| 132 | +
|
| 133 | +public fun slot_exists(registry: &Registry, name: std::string::String): bool { |
| 134 | + derived_object::exists(®istry.id, name) |
| 135 | +} |
| 136 | +``` |
| 137 | + |
| 138 | +### 3. 先算地址再创建对象 |
| 139 | + |
| 140 | +若希望「先知道地址,再在后续逻辑里创建对象」,可以先用 `derive_address` 得到地址,再在需要时 `claim` 并用返回的 UID 构造对象: |
| 141 | + |
| 142 | +```move |
| 143 | +// 仅计算,不占用 |
| 144 | +let addr = derived_object::derive_address(registry.id.to_inner(), my_key); |
| 145 | +
|
| 146 | +// 需要时再占用并创建对象 |
| 147 | +let uid = derived_object::claim(&mut registry.id, my_key); |
| 148 | +let obj = MyRecord { id: uid, data: ... }; |
| 149 | +``` |
| 150 | + |
| 151 | +## 完整示例:简单类型注册表 |
| 152 | + |
| 153 | +下面示例实现一个「按类型 T 注册单例对象」的注册表,并用派生对象保证每种类型只有一个实例、地址确定: |
| 154 | + |
| 155 | +```move |
| 156 | +module examples::type_registry; |
| 157 | +
|
| 158 | +use sui::derived_object; |
| 159 | +use sui::transfer; |
| 160 | +use std::string::String; |
| 161 | +
|
| 162 | +public struct Registry has key { |
| 163 | + id: UID, |
| 164 | +} |
| 165 | +
|
| 166 | +/// 每种类型 T 对应一个「槽位」键 |
| 167 | +public struct TypeKey<phantom T> has copy, drop, store {} |
| 168 | +
|
| 169 | +/// 注册表中每类 T 存一条记录 |
| 170 | +public struct Record<T: store> has key { |
| 171 | + id: UID, |
| 172 | + name: String, |
| 173 | + value: T, |
| 174 | +} |
| 175 | +
|
| 176 | +public fun new_registry(ctx: &mut TxContext): Registry { |
| 177 | + Registry { id: object::new(ctx) } |
| 178 | +} |
| 179 | +
|
| 180 | +/// 为类型 T 注册一条记录;若 T 已注册则 abort |
| 181 | +public fun register<T: key + store>( |
| 182 | + registry: &mut Registry, |
| 183 | + name: String, |
| 184 | + value: T, |
| 185 | + ctx: &mut TxContext, |
| 186 | +) { |
| 187 | + assert!(!derived_object::exists(®istry.id, TypeKey<T>()), 0); |
| 188 | + let uid = derived_object::claim(&mut registry.id, TypeKey<T>()); |
| 189 | + let record = Record<T> { id: uid, name, value }; |
| 190 | + transfer::share_object(record); // 或 transfer::transfer(record, ctx.sender()) |
| 191 | +} |
| 192 | +
|
| 193 | +public fun is_registered<T: key>(registry: &Registry): bool { |
| 194 | + derived_object::exists(®istry.id, TypeKey<T>()) |
| 195 | +} |
| 196 | +``` |
| 197 | + |
| 198 | +要点: |
| 199 | + |
| 200 | +- 用 **TypeKey\<T\>** 做键,保证「每种 T 一个槽位」。 |
| 201 | +- **register** 中先 `exists` 再 `claim`,避免重复注册。 |
| 202 | +- **claim** 得到的 UID 直接用作 `Record` 的 `id`,这样 `Record<T>` 的 object ID 永远由 `(Registry.id, TypeKey<T>)` 确定。 |
| 203 | + |
| 204 | +## 在 CoinRegistry 中的用法 |
| 205 | + |
| 206 | +Sui 的 **CoinRegistry** 在 **finalize_registration** 中使用了派生对象: |
| 207 | +当一种新代币的 `Currency<T>` 被「注册」到链上时,会从 CoinRegistry 的 UID 和 **CurrencyKey\<T\>** 派生出该 Currency 的 UID,并作为共享对象发布。这样: |
| 208 | + |
| 209 | +- 每种代币类型 `T` 在全局只有一个 `Currency<T>` 对象; |
| 210 | +- 其地址由 `(CoinRegistry.id, CurrencyKey<T>)` 确定,索引器和前端可以稳定地按类型推算或查询。 |
| 211 | + |
| 212 | +你不需要自己实现该逻辑,但理解「派生对象 = 父 + 键 → 确定性 UID」有助于阅读框架中各类 Registry 的实现。 |
| 213 | + |
| 214 | +## 注意事项 |
| 215 | + |
| 216 | +1. **claim 不可逆**:一旦对某 (parent, key) 调用了 **claim**,该键就永远被视为已占用;即使之后用返回的 UID 创建的对象被删掉,**exists** 仍为 true,不能再次 claim 同一 key。 |
| 217 | +2. **键的类型与值都要一致**:链下或前端若想复现地址,键的类型和值必须与链上完全一致(例如都用 `String` 且内容相同)。 |
| 218 | +3. **父对象需可变**:只有 **claim** 需要 `&mut UID`;**derive_address** 和 **exists** 只需 `&UID` 或 `ID`。 |
| 219 | +4. **派生出的对象独立存在**:claim 返回的 UID 用于构造对象后,该对象与普通对象一样可以 transfer、share、freeze,不要求父对象同时存在或可访问(仅首次 claim 时需要父对象)。 |
| 220 | + |
| 221 | +## 小结 |
| 222 | + |
| 223 | +- **derived_object** 提供由 **(父 UID, Key)** 确定性地推导 **address/UID** 的能力,并保证同一键只能被 **claim** 一次。 |
| 224 | +- **derive_address** 只做计算;**claim** 占用键并返回 UID,用于在派生地址上创建对象;**exists** 查询键是否已被占用。 |
| 225 | +- 常用于**注册表**、**按类型或名称的命名空间**,以及需要**可预测 object ID** 的场景。 |
| 226 | +- 实现上依赖动态字段在父对象上记录「已占用的派生 ID」;派生出的对象之后可独立于父对象使用。 |
0 commit comments