Move语言入门教程Part2:所有权、能力和数据类型详解

Move语言入门教程Part2:所有权、能力和数据类型详解

简介

本教程是Move语言入门系列的第二部分,重点介绍Move的核心概念和数据类型。Move作为区块链安全的编程语言,通过独特的所有权系统和能力机制确保资产安全。我们将详细讲解所有权规则、四大能力(copy、drop、store、key)、各种数据类型的使用方法,以及Move的控制流和表达式特性。

命名规则

别名必须遵循与模块成员同样的命名规则:对结构体(与常量)的别名必须以大写 A…Z 开头。

所有权

copy

任何具有 copy 能力(ability)的值都可以这样拷贝;若未显式写 move,编译器通常会隐式地执行拷贝。

let x = 0;
let y = copy x + 1;
let z = copy x + 2;

move

局部变量

move 则取走(移动)局部变量中的值,而不复制数据。发生 move 之后,即使该值的类型具备 copy 能力,该局部变量本身也不再可用,此时所有权已经被转移,因此该局部变量已经失效。

let x = 1;
let y = move x + 1;
//      ------ 此处发生了 move
let z = move x + 2; // 错误!
//      ^^^^^^ 局部变量 `x` 已被移动,无法再用
y + z

函数返回和入参

如果函数返回局部变量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被传值到另外的函数,所有权也会被转移

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();
    // `u8` 是可复制类型;调用时传 `move a` 可显式转移其所有权
    take_ownership(move a);
    // 此处 a 已无效
}

推断

未指明 copy / move,编译器会进行推断。规则很简单:

l 具有 copy 能力的值 → 推断为 copy;

l 所有引用(不可变 & 与可变 &mut)→ 推断为 copy;

n 但在某些特殊情形下,为避免可预期的借用检查错误,编译器会把该处改判为 move(发生在引用不再被使用之后)。

其他一切值 → 推断为 move。

能力

copy

允许具有该能力的类型的值被拷贝(copy)。

public struct Copyable has copy {}

let a = Copyable {}; // 允许:因为 Copyable 具有 `copy`
let b = a;           // 隐式拷贝:把 a 拷贝到 b
let c = *&b;         // 显式拷贝:通过解引用获得一份拷贝

// Copyable 不具有 `drop`,因此每个实例(a、b、c)都必须被使用
// 或显式析构;下面用解构来“消耗”它们(防止未使用报错)
let Copyable {} = a;
let Copyable {} = b;
let Copyable {} = c;

在 Move 中,使用“空花括号的解构”常被用来消费未使用变量,尤其是对不具备 drop 的类型,以避免值在离开作用域时未被显式使用而触发编译错误。同时,Move 在解构时要求写出类型名(如 let Copyable {} = a;),以强化严格的类型与所有权规则。

所有原生类型(native types)都具备 copy,包括:

  • bool
  • 无符号整数
  • vector
  • address

标准库(std)中的类型也具备 copy,包括:

  • Option
  • String
  • TypeName

drop

允许具有该能力的类型的值被丢弃/弹出(pop/drop)。

没有drop的话,如果你后续不使用这个结构的变量 ,后续就会报错。所以需要进行解包。

let Copyable {} = a; 
---

比如事件就需要

event::emit(BoxPurchased{
    buyer: tx_context::sender(ctx),
    nft_id,
    rarity: rarity,
});

store

允许具有该能力的类型的值存在于存储中的某个值内部。

结构体所有的字段都需要具备store能力,然后具有store能力的结构内所有字段也必须要具备store

key

具有 key 能力的 struct 被视为对象,可用于各类存储函数。

Sui Verifier 要求该 struct 的第一个字段必须命名为 id,类型为 UID。此外,它还要求所有字段都具备 store 能力

// `User` 对象定义。
public struct User has key {
    id: UID,      // Sui 字节码验证器要求
    name: String, // 各字段类型须具备 `store`
}

/// 创建一个新的 `User` 实例。
/// 使用特殊结构体 `TxContext` 派生唯一 ID(UID)。
public fun new(name: String, ctx: &mut TxContext): User {
    User {
        id: object::new(ctx), // 生成新的 UID
        name,
    }
}

UID 类型不具备 drop 或 copy。而任何具备 key 的类型都必须含有一个 UID 字段,因此:具有 key 的类型永远不可能拥有 drop 或 copy。这一性质可用于能力约束:

  • 若约束要求 drop 或 copy,就自动排除了 key;
  • 反之,若要求 key,就排除了具有 drop 或 copy 的类型。

由于 key 类型强制需要 UID 字段,所有原生类型与标准库类型都不可能具备 key。key 只出现在部分 Sui Framework 类型以及自定义类型中。

/// 盲盒系列
public struct Collection has key {
    id: UID,
    name: String,
    price: u64,              // 每个盲盒的价格(MIST)
    total_supply: u64,       // 总供应量
    minted: u64,             // 已售出数量
    balance: Balance<SUI>,   // 收益池
}

/// NFT(开盒后获得)
public struct NFT has key, store {
    id: UID,
    name: String,
    rarity: String,  // Common/Rare/Legendary
    image_url: String,
}

内建类型具有的能力如下所示:

  1. 所有原始/内建类型都具有 copy、drop、store:
  2. bool, u8, u16, u32, u64, u128, u256, address → 具备 copy、drop、store。
  3. vector 是否具备 copy、drop、store 取决于 T 的能力。
  4. 不可变引用 & 与可变引用 &mut 都具有 copy、drop(指拷贝/丢弃引用自身,而非其指向的值)。
  5. 引用不能出现在全局存储中,因此它们没有 store。
  6. 注意:无原始类型具有 key,也就是说它们不能直接参与顶层存储操作。

Struct/Enum

因此,当为 struct 声明能力时,所有字段都必须满足下列要求,以确保可达性(reachability)规则成立:

  • l 若声明 copy,则所有字段都必须具有 copy。
  • l 若声明 drop,则所有字段都必须具有 drop。
  • l 若声明 store,则所有字段都必须具有 store。
  • l 若声明 key,则所有字段都必须具有 store(注意 key 本身对字段不要求 key)。

对 enum,除 key 外,其余能力都可声明(enum 不能作为存储顶层值/对象,因此不能有 key)。同样的字段规则适用于枚举的各变体字段:

  • l 若枚举声明了 copy,则所有变体的所有字段都必须具有 copy;
  • l 声明了 drop → 所有字段都必须有 drop;
  • l 声明了 store → 所有字段都必须有 store;
  • l key → 不允许用于 enum。

泛型

当对泛型类型注解能力时,并不保证该类型的所有实例都具备这些能力。考虑如下定义:

public struct Cup has copy, drop, store, key { item: T }

我们希望 Cup 能容纳任意类型(不受其能力限制)。类型系统在实例化时可见类型参数,因此当遇到会破坏能力保证的类型实参时,应当“移除”相应能力。

数据类型

Integer

  • Move 不支持溢出/下溢
  • 数值字面量可以直接写成数字序列(如 112),也可以写成十六进制字面量(如 0xFF)。
    • 数字字面量可以使用下划线分组以增强可读性(例如 1_234_5678、1_000u128、0xAB_CD_12_35)。

Address

// address 字面量
let value: address = @0x1;

// 在 Move.toml 中注册过的命名地址
let value = @std;
let other = @sui;

Struct

定义结构体
结构体使用
  1. 销毁结构体:

    Move 的哲学是安全第一:如果你要拆开一个结构体,就默认是想移动(销毁)它,以确保原结构体不再持有任何悬空的数据。

    module a::m;
    
    public struct Foo { x: u64, y: bool }
    public struct Bar(Foo)
    public struct Baz {}
    public struct Qux()
    
    fun example_destroy_foo() {
        let foo = Foo { x: 3, y: false };
        let Foo { x, y: foo_y } = foo;
        //        ^ 等价于 `x: x`
        // 新绑定:x: u64 = 3, foo_y: bool = false
    }
    
    

    这里因为Foo没有实现copy能力,所以在这个解构的过程中会被move

  2. 字段读:

    let foo = Foo { x: 3, y: true };
    let bar = Bar(copy foo);
    let x: u64 = *&foo.x;
    let y: bool = *&foo.y;
    let foo2: Foo = *&bar.0;

    直接使用点号需要字段有copy能力,

    let foo = Foo { x: 3, y: true };
    let x = foo.x;
    let y = foo.y;

    没有或者字段是非原始类型(结构体或者vec),就会报错

    let foo = Foo { x: 3, y: true };
    let bar = Bar(foo);
    let foo2: Foo = *&bar.0; // OK
    let foo3: Foo = bar.0;   // 错误:应显式复制 *&
  3. 字段写:

    需要可变借用后赋值

    let mut foo = Foo { x: 3, y: true };
    *&mut foo.x = 42;     // Foo { x: 42, y: true }
    *&mut foo.y = !foo.y; // Foo { x: 42, y: false }
    let mut bar = Bar(foo);               // Bar(Foo { x: 42, y: false })
    *&mut bar.0.x = 52;                   // Bar(Foo { x: 52, y: false })
    *&mut bar.0 = Foo { x: 62, y: true }; // Bar(Foo { x: 62, y: true })

    直接使用点号修改需要字段有drop能力

    let mut foo = Foo { x: 3, y: true };
    foo.x = 42;
    foo.y = !foo.y;
    let mut bar = Bar(foo);
    bar.0.x = 52;
    bar.0 = Foo { x: 62, y: true };

    或者结构体的可变引用

    let mut foo = Foo { x: 3, y: true };
    let foo_ref = &mut foo;
    foo_ref.x = foo_ref.x +1;

枚举

枚举(enum)是包含一个或多个变体(variant)的用户自定义数据结构。每个变体可可选地携带带类型的字段;不同变体的字段数量与类型彼此可以不同。枚举中的字段可以存放除“引用”和“元组”以外的任何类型,包括其他结构体或枚举。

与结构体类似,枚举也可以带有能力(abilities)来控制其可执行的操作。但需要注意:枚举不能拥有 key 能力(因为它们不能作为顶层对象存在)。

枚举不允许递归

覆盖枚举值

只要枚举具备 drop 能力,就可以像其他值一样,用同类型的新值覆盖它:

module a::m;

public enum X has drop {
    A(u64),
    B(u64),
}

public fun overwrite_enum(x: &mut X) {
    *x = X::A(10);
}

let mut x = X::B(20);
overwrite_enum(&mut x);
assert!(x == X::A(10));

引用

不可变引用与可变引用都可以读取,读取将产生其指向值的一份拷贝。为了允许读取引用,底层类型必须具备 copy 能力,因为读取会创建一份新拷贝。该规则可防止资产被复制。

所有权与复制

不可变引用与可变引用都可以用来复制与拓展,只是已经存在其他副本或拓展

fun reference_copies(s: &mut S) {
  let s_copy1 = s;        // OK:复制引用
  let s_extension = &mut s.f; // OK:在引用上扩展字段引用
  let s_copy2 = s;        // 仍然 OK
  ...
}

引用不可存储

和rust不一样,引用和元祖是不能作为结构体字段存储的类型

Option

表示值可能存在也可能不存在的类型

String

Move 没有内建的字符串类型,但标准库提供了两种实现:

  • std::string 模块中的 String(UTF-8 字符串);
  • std::ascii 模块中的 ASCII 字符串。

在 Sui 执行环境中,交易输入里的字节向量会自动转换为 String。因此,在许多场景下无需在交易块中手动构造 String。

std::string

虽然有 string 与 ascii 两种字符串,默认推荐使用 std::string。它的许多操作由底层原生实现,性能更好;而 std::ascii 完全用 Move 实现,更偏高层抽象,不太适合性能敏感场景。

创建 String

常用操作

let mut str = b"Hello,".to_string();
let another = b" World!".to_string();

// 追加:把另一字符串接到末尾
str.append(another);

// 切片拷贝:sub_string(start, end)
str.sub_string(0, 5);         // "Hello"

// 字节长度(注意:是字节数而非“字符数”)
str.length();                  // 12

// 链式调用:取子串再看长度
str.sub_string(0, 5).length(); // 5

// 判空
str.is_empty();                // false

// 取底层字节向量做自定义处理
let bytes: &vector<u8> = str.bytes();

强制类型转换

move支持在整数类型之间进行显式类型转换:

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 将变量名与值绑定:不能以大写字母开头。局部变量的类型通常可由 Move 的类型系统自动推断。但你也可以显式标注以提升可读性/可调试性。

当类型实参无法被推断时需要注解。最常见的是泛型类型的类型参数无法推断:

let _v1 = vector[]; // ERROR!// ^^^^^^^^ 无法推断元素类型;需要添加注解let v2: vector = vector[]; // OK

解构

元组解构

let 可以通过元组一次引入多个局部变量。括号内的各局部变量会以元组右侧对应位置的值初始化:

常量

常量是在模块内部为共享的静态值起名的一种方式。

常量的取值必须在编译期可知。该值被存入已编译模块中;每次使用常量都会拷贝一份该值。

public 或 public(package) 暂不支持用于常量。const 值仅能在其声明的模块内使用。不过,作为便利,在单元测试属性(unit test attributes)中可以跨模块使用。

声明

const : = ;

流程控制

循环

循环允许你为函数体中的某个位置定义标签并转移控制流。例如,我们可以嵌套两个循环,并通过带标签的 break/continue 精确指定控制流。给任意 loop 或 while 前加 'label: 前缀,就可以直接跳转到该处进行 break 或 continue。

fun sum_until_threshold(input: &vector<vector<u64>>, threshold: u64): u64 {
    let mut sum = 0;
    let mut i = 0;
    let input_size = input.length();

    'outer: loop {
        // 跳出到 outer,因为它是最近的包围循环
        if (i >= input_size) break sum;

        let vec = &input[i];
        let size = vec.length();
        let mut j = 0;

        while (j < size) {
            let v_entry = vec[j];
            if (sum + v_entry < threshold) {
                sum = sum + v_entry;
            } else {
                // 下一个元素会使总和越过阈值,
                // 因此返回当前 sum
                break 'outer sum
            };
            j = j + 1;
        };
        i = i + 1;
    }
}

也适用于更大的代码中的多层循环

let x = 'outer: loop {
    ...
    'inner: while (cond) {
        ...
        if (cond0) { break 'outer value };
        ...
        if (cond1) { continue 'inner }
        else if (cond2) { continue 'outer }
        ...
    }
        ...
};

表达式

1. 表达式的本质与串联

  • Move 的哲学: 在 Move 中,几乎一切都是表达式let 语句除外,它是声明)。这意味着大多数代码片段都会产生一个值。
  • 代码块与分号: 代码块 {} 的值是块内 最后一个 表达式的值(不能以分号 ; 结尾)。如果用分号串联表达式,或块内以分号结束,则该表达式/块的值为 单位值 ()

2. 算术运算的安全性(Abort 优先)

  • 严格类型一致: 算术运算(+, -, *, %, /)的操作数必须是相同类型,否则需要显式转换(cast)。
  • 强制中止(Abort): Move 不会产生未定义或环绕结果。任何不符合“数学意义上的整数”的结果(例如 溢出、下溢、被零除)都会触发 中止(Abort),即交易失败并回滚。这是 Move 安全性的重要体现。

3. 位运算和位移运算的特点

  • 位运算(&, |, ^): 纯粹的比特序列操作,不会触发中止
  • 位移运算(<<, >>): 右操作数(位移位数)必须是 u8。当位移位数 大于等于 该整数类型的位宽时,会触发 中止(例如 u64 位移 ≥ 64 位时中止)。

4. 相等性(== / !=)的核心约束

  • 类型严格一致: 相等运算要求两侧操作数的 类型必须完全相同
  • 资源的“销毁”语义: 直接对 进行相等比较(例如 c1 == c2,而不是引用)会消耗掉这两个值(类似于被 销毁)。因此,被比较的类型必须具备 drop 能力,否则会报错。
  • 引用比较的宽松性: 在比较引用时,引用的 可变性&&mut)不影响比较结果,行为等价于对 &mut 显式应用 freeze(前提是底层类型一致)。
  • 自动借用(Move 2024+): 当一侧是引用而另一侧不是时,编译器会自动将非引用一侧 不可变借用(& 后再进行比较。

5. 地址字面量和数值推断

  • 地址字面量(@): 在表达式中使用地址时,必须以 @ 开头(例如 @0x1, @std)。在非表达式上下文(如模块声明)中则不用 @
  • 数值类型推断: 未显式标注类型后缀的数字字面量,编译器会尝试从上下文推断;如果无法推断,默认视为 u64

6. 避免不必要的拷贝(性能优化)

  • 最佳实践: 尽管拥有 drop 能力的值可以直接比较,但对于大型数据结构(如 vector<u8> 或大型 struct),为了避免昂贵的 copy 操作,强烈建议使用引用(&)进行比较

函数

若要允许从其他模块访问,需将函数声明为 public 或 public(package)。与可见性相关的,还有 entry 修饰符:它决定函数是否能作为执行入口被调用。

entry 修饰符

除了 public 函数外,有些函数希望作为执行入口触发。entry 允许模块函数作为程序的起始点,而无需对其他模块公开。

本质上,public 与 entry 的组合定义了模块的“主函数”(可作为程序起点)。注意:entry 函数依旧可以被其他 Move 函数调用,它并不只限于“起点”用途。

macro 修饰符

宏函数不同于普通函数:它们在编译期于调用点内联展开,不存在于运行时。

macro fun n_times($n: u64, $body: |u64| -> ()) {
    let n = $n;
    let mut i = 0;
    while (i < n) {
        $body(i);
        i = i + 1;
    }
}

fun example() {
    let mut sum = 0;
    n_times!(10, |x| sum = sum + x );
} 

方法函数

方法函数

索引语法