Move语言入门资料

1.move编程语言

Move 是 Diem 项目 专门为区块链开发的一种安全可靠的智能合约编程语言。您可以在 Diem 开发者网站 找到它的白皮书,也可以在 开发者社区 找到更多内容,了解为什么 Move 更适合区块链。
作为一种刚刚诞生的语言,介绍它的信息不是很多。这篇博客主要系统整理了move的一些资料,方便更多的人使用这门语言,有些地方可能不如专业人员介绍的详细,希望大家多去支持官方的一些资料介绍,在此给大家整理如下:
move白皮书:https://developers.diem.com/docs/technical-papers/move-paper/
move源码:https://github.com/move-language/move
move英文介绍:https://move-book.com/
move中文介绍:https://move-book.com/cn/
英文文档整理者是 Damir Shamanaev
中文译者是 朱光宇,他来自 Westar 实验室。

站在巨人的肩膀上,所以才能看的更远。

2.前言

这篇博客只是对move现有资料的系统整理,方便于大家学习,不做盈利使用,此外大部分内容都是朱光宇完成,在结尾部分没有完成ERC20合约的介绍,在这篇博客里找到一个实例应用供大家参考学习。

3.快速入门

与任何编程语言一样,Move 应用程序也需要一组适当的工具来编译、运行和调试。由于 Move 语言是为区块链创建、并且仅在区块链中使用,因此在链下运行程序不是一件容易的事,因为每个应用都需要一个编辑环境、账户处理和编译-发布系统。
为了简化 Move 程序的开发,我在 Visual Studio Code 上开发了 Move IDE 扩展。该扩展可以满足开发者对开发环境的基本需求。它的功能除了程序执行外还包括 Move 语法高亮显示,可以更好的帮助开发者在发布之前调试应用程序。开发者只需专注于 Move 语言本身,而不必为客户端(CLI)苦苦挣扎。

安装 Move IDE

需要安装下面的软件:

VSCode (1.43.0 或者更高版本) - 可以在 这里 获取; 当然如果你的机器上已经安装了 VSCode,可以直接进入下一步;
Move IDE - 安装 VSCode 后,请单击 这里 安装最新版本的 IDE。

环境设置

Move IDE 提供了单一的方法来组织目录结构。只需要创建一个新目录,并在 VSCode 中打开它,就可以得到如下目录结构:

modules/ - directory for our modules
scripts/ - directory for transaction scripts
out/ - this directory will hold compiled sources

另外,还需要创建一个名为 .mvconfig.json 的文件,该文件将配置您的工作环境。下面这个配置指向了 Libra 网络:

{
“network”: “libra”,
“sender”: “0x1”
}

或者使用 dfinance 作为目标网络:

{
“network”: “dfinance”,
“sender”: “0x1”
}

第一个 Move 应用

Move IDE 使开发者可以在测试环境中运行程序。让我们通过一个例子来了解其工作原理:实现 gimme_five() 功能并在 VSCode 中运行它。

创建模块

在项目的目录 modules/ 内创建一个新文件 hello_world.move。

// modules/hello_world.move
address 0x1 {
module HelloWorld {
    public fun gimme_five(): u8 {
        5
    }
}
}

如果您想使用自己的地址(而非0x1),请确保更改此文件中的 0x1 以及下面文件中的地址。

知乎上也有人给出了如何创建第一个move脚本,我在这个模型上跑通了,提供链接:https://zhuanlan.zhihu.com/p/552309017

写脚本

然后在 scripts/ 目录中创建一个脚本 me.move,调用上面的模块:

// scripts/run_hello.move
script {
    use 0x1::HelloWorld;
    use 0x1::Debug;

    fun main() {
        let five = HelloWorld::gimme_five();
    
        Debug::print<u8>(&five);
    }
}

最后,在保持脚本打开的同时,执行以下步骤:

1.通过按⌘+Shift+P(在 Mac 上)或Ctrl+Shift+P(在Linux / Windows上)来切换 VSCode 的命令选项板 2.键入:>Move: Run Script并在看到正确的选项时按 Enter 或单击。 现在,你应该会看到执行结果,输出日志中有“5”信息。如果没有看到此窗口,请再次浏览上面部分,看看有没有漏掉什么。

目录结构应如下所示:

modules/
hello_world.move
scripts/
run_hello.move
out/
.mvconfig.json

modules 目录下可以包含任意多的模块;所有这些模块都可以被你的脚本访问到,只要它们都被定义在 .mvconfig.json 所指定的地址下即可。

4.语法基础

在本章中,我们将了解 Move 语言的基本语法。我们将从最基本的语法规则开始,逐渐增加难度。对于熟练的开发者,这些内容看上去会比较容易掌握,但是我还是会建议他花时间看一下。如果是初学者,那么这部分内容提供了掌握 Move 基础知识所需的一切。

4.1.基础概念

与其它智能合约编程语言(例如 Solidity)不同,Move 程序分为脚本和模块。前者可以让开发者在交易中加入更多逻辑,在更加灵活地同时节省时间和资源。后者允许开发人员更容易扩展区块链的功能,更加灵活地实现自定义智能合约。

下面,我们将从脚本开始学习,因为它对新手来说非常友好,然后我们再进一步介绍模块。

4.2.基本类型

Move 的基本数据类型包括: 整型 (u8, u64, u128)、布尔型 boolean 和地址 address。
Move 不支持字符串和浮点数。

整型

整型包括 u8、u64 和 u128,我们通过下面的例子来理解整型:

script {
    fun main() {
        // define empty variable, set value later
        let a: u8;
        a = 10;

        // define variable, set type
        let a: u64 = 10;
    
        // finally simple assignment
        let a = 10;
    
        // simple assignment with defined value type
        let a = 10u128;
    
        // in function calls or expressions you can use ints as constant values
        if (a < 10) {};
    
        // or like this, with type
        if (a < 10u8) {}; // usually you don't need to specify type
    }
}

运算符as

当需要比较值的大小或者当函数需要输入不同大小的整型参数时,你可以使用as运算符将一种整型转换成另外一种整型:

script {
    fun main() {
        let a: u8 = 10;
        let b: u64 = 100;

        // we can only compare same size integers
        if (a == (b as u8)) abort 11;
        if ((a as u64) == b) abort 11;
    }
}

布尔值

布尔类型就像编程语言那样,包含false和true两个值。

script {
    fun main() {
        // these are all the ways to do it
        let b : bool; b = true;
        let b : bool = true;
        let b = true
        let b = false; // here's an example with false
    }
}

地址

地址是区块链中交易发送者的标识符,转账和导入模块这些基本操作都离不开地址。

script {
    fun main() {
        let addr: address; // type identifier

        // in this bool I'll use {{sender}} notation;
        // always replace '{{sender}}' in examples with VM specific address!!!
        addr = {{sender}};
    
        // in Diem's Move VM and Starcoin - 16-byte address in HEX
        addr = 0x...;
    
        // in dfinance's DVM - bech32 encoded address with `wallet1` prefix
        addr = wallet1....;
    }
}

4.3.注释

需要对某些代码进行额外说明时,我们使用注释。注释是不参与执行的、旨在对相关代码进行描述和解释的文本块或文本行。

行注释

script {
    fun main() {
        // this is a comment line
    }
}

可以使用双斜杠“//”编写行注释。规则很简单,“//”之后到行尾的所有内容均视为注释。也可以使用行注释为其他开发人员留下简短消息,或者注释掉一些代码使之不参与执行。

script {
    // let's add a note to everything!
    fun main() {
        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!
    }
}

块注释

如果不想注释整行内容,或者想要注释掉多行,则可以使用块注释。
块注释以"/“开头,并包含第一个”/"之前的所有文本。块注释不受行的限制,代码中的任何位置都可以注释。

script {
    fun /* you can comment everywhere */ main() {
        /* 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() {

    }
    */
}

当然这个例子有点荒谬!但这也清楚地显示了块注释的功能,即随时随地添加说明。

4.4.表达式和作用域

在编程语言中,表达式是具有返回值的代码单元。有返回值的函数调用是一个表达式,它有返回值;整型常数也是一个表达式,它返回整数;其它表达式依此类推。

表达式必须用分号";"隔开

空表达式

类似于 Rust,Move 中的空表达式用空括号表示:

script {
    fun empty() {
        () // this is an empty expression
    }
}

文字(Literal)表达式

下面的代码,每行包含一个以分号结尾的表达式。最后一行包含三个表达式,由分号隔开。

script {
    fun main() {
        10;
        10 + 5;
        true;
        true != false;
        0x1;
        1; 2; 3
    }
}

现在我们已经知道了最简单的表达式。但是为什么我们需要它们?以及如何使用它们?这就需要介绍 let 关键字了。

变量和let关键字

关键字 let 用来将表达式的值存储在变量中,以便于将其传递到其它地方。我们曾经在基本类型章节中使用过 let,它用来创建一个新变量,该变量要么为空(未定义),要么为某表达式的值。

script {
    fun main() {
        let a;
        let b = true;
        let c = 10;
        let d = 0x1;
        a = c;
    }
}

关键字 let 会在当前作用域内创建新变量,并可以选择初始化此变量。该表达式的语法是:let : ;或let = 。

创建和初始化变量后,就可以使用变量名来修改或访问它所代表的值了。在上面的示例中,变量 a 在函数末尾被初始化,并被分配了一个值 c。

等号"="是赋值运算符。它将右侧表达式赋值给左侧变量。示例:a = 10 表示将整数10赋值给变量a。

整型运算符

Move具有多种用于修改整数值的运算符:

运算符|操作|类型|说明
-|-|-|-
+ |sum |uint |LHS加上RHS
- |sub |uint |从LHS减去RHS
/ |div |uint |用LHS除以RHS
* |mul |uint |LHS乘以RHS
% |mod |uint |LHS除以RHS的余数
<< |lshift |uint |LHS左移RHS位
>> |rshift |uint |LHS右移RHS位
& |and |uint |按位与
^ |xor |uint |按位异或
\ |or |uint |按位或

LHS - 左侧表达式, RHS - 右侧表达式; uint: u8, u64, u128.

下划线 “_” 表示未被使用

Move 中每个变量都必须被使用,否则代码编译不会通过, 因此我们不能初始化一个变量却不去使用它。但是你可以用下划线来告诉编译器,这个变量是故意不被使用的。

例如,下面的脚本在编译时会报错:

script {
    fun main() {
        let a = 1;
    }
}

报错:
┌── /scripts/script.move:3:13 ───

33 │ let a = 1;
│ ^ Unused assignment or binding for local ‘a’. Consider removing or replacing it with ‘_’

编译器给出明确提示:用下划线来代替变量名。

script {
    fun main() {
        let _ = 1;
    }
}

屏蔽

Move 允许两次定义同一个的变量,第一个变量将会被屏蔽。但有一个要求:我们仍然需要"使用"被屏蔽的变量。

script {
    fun main() {
        let a = 1;
        let a = 2;
        let _ = a;
    }
}

在上面的示例中,我们仅使用了第二个a。第一个a实际上未使用,因为a在下一行被重新定义了。所以,我们可以通过下面的修改使得这段代码正常运行。

script {
    fun main() {
        let a = 1;
        let a = a + 2;
        let _ = a;
    }
}

块表达式

块表达式用花括号"{}"表示。块可以包含其它表达式(和其它代码块)。函数体在某种意义上也是一个代码块。

script {
    fun block() {
        { };
        { { }; };
        true;
        {
            true;

            { 10; };
        };
        { { { 10; }; }; };
    }
}

作用域

如 Wikipedia 中所述,作用域是绑定生效的代码区域。换句话说,变量存在于作用域中。Move 作用域是由花括号扩起来的代码块,它本质上是一个块。

定义一个代码块,实际上是定义一个作用域。

script {
    fun scope_sample() {
        // this is a function scope
        {
            // this is a block scope inside function scope
            {
                // and this is a scope inside scope
                // inside functions scope... etc
            };
        };

        {
            // this is another block inside function scope
        };
    }
}

从该示例可以看出,作用域是由代码块(或函数)定义的。它们可以嵌套,并且可以定义多个作用域,数量没有限制。

变量的生命周期和可见性

我们前面已经介绍过关键字 let 的作用,它可以用来定义变量。有一点需要强调的是,该变量仅存在于变量所处的作用域内。也就是说,它在作用域之外不可访问,并在作用域结束后立即消亡。

script {
    fun let_scope_sample() {
        let a = 1; // we've defined variable A inside function scope

        {
            let b = 2; // variable B is inside block scope
    
            {
                // variables A and B are accessible inside
                // nested scopes
                let c = a + b;
    
            }; // in here C dies
    
            // we can't write this line
            // let d = c + b;
            // as variable C died with its scope
    
            // but we can define another C
            let c = b - 1;
    
        }; // variable C dies, so does C
    
        // this is impossible
        // let d = b + c;
    
        // we can define any variables we want
        // no name reservation happened
        let b = a + 1;
        let c = b + 1;
    
    } // function scope ended - a, b and c are dropped and no longer accessible
}

变量仅存在于其作用域(或代码块)内,当作用域结束时变量随之消亡。

块返回值

上面我们了解到代码块是一个表达式,但是我们没有介绍为什么它是一个表达式以及代码块的返回值是什么。
代码块可以返回一个值,如果它后面没有分号,则返回值为代码块内最后一个表达式的值。
听起来不好理解,我们来看下面的例子:

script {
    fun block_ret_sample() {

        // since block is an expression, we can
        // assign it's value to variable with let
        let a = {
    
            let c = 10;
    
            c * 1000  // no semicolon!
        }; // scope ended, variable a got value 10000
    
        let b = {
            a * 1000  // no semi!
        };
    
        // variable b got value 10000000
    
        {
            10; // see semi!
        }; // this block does not return a value
    
        let _ = a + b; // both a and b get their values from blocks
    }
}

代码块中的最后一个表达式(不带分号)是该块的返回值。

小结

我们来总结一下本章要点:

1.每个表达式都必须以分号结尾,除非它是 block 的返回值。
2.关键字 let 使用值或表达式创建新变量,该变量的生命周期与其作用域相同。
3.代码块是一个可能具有也可能没有返回值的表达式。

4.5.控制流

通过控制流表达式,我们可以选择运行某个代码块,或者跳过某段代码而运行另一个代码块。

Move 支持 if 表达式和循环表达式。

if 表达式

if 表达式允许我们在条件为真时运行代码块,在条件为假时运行另一个代码块。

script {
    use 0x1::Debug;

    fun main() {
    
        let a = true;
    
        if (a) {
            Debug::print<u8>(&0);
        } else {
            Debug::print<u8>(&99);
        };
    }
}

这个例子中,当a == true时打印0,当a是false时打印99,语法非常简单:
if (<布尔表达式>) <表达式> else <表达式>;
if是一个表达式,我们可以在 let 声明中使用它。但是像所有其他表达式一样,它必须以分号结尾。

script {
    use 0x1::Debug;

    fun main() {
    
        // try switching to false
        let a = true;
        let b = if (a) { // 1st branch
            10
        } else { // 2nd branch
            20
        };
    
        Debug::print<u8>(&b);
    }
}

现在,b 将根据 a 表达式为变量分配不同的值。但是 if 的两个分支必须返回相同的类型!否则,变量 b 将会具有不同类型,这在静态类型的语言中是不允许的。在编译器术语中,这称为分支兼容性 —— 两个分支必须返回兼容(相同)类型。

if 不一定非要和 else 一起使用,也可以单独使用。

script {
    use 0x1::Debug;

    fun main() {
    
        let a = true;
    
        // only one optional branch
        // if a = false, debug won't be called
        if (a) {
            Debug::print<u8>(&10);
        };
    }
}

但是请记住,不能在 let 赋值语句中使用不带分支的表达式!因为如果 if 不满足条件,就会导致变量未被定义,这同样是不允许的。

循环表达式

在 Move 中定义循环有两种方法:
while 条件循环
loop 无限循环
while 条件循环
while 是定义循环的一种方法:在条件为真时执行表达式。只要条件为 true,代码将一遍又一遍的执行。条件通常使用外部变量或计数器实现。

script {
    fun main() {

        let i = 0; // define counter
    
        // iterate while i < 5
        // on every iteration increase i
        // when i is 5, condition fails and loop exits
        while (i < 5) {
            i = i + 1;
        };
    }
}

需要指出的是,while 表达式就像 if 表达式一样,也需要使用分号结束。while 循环的通用语法是:
while (<布尔表达式>) <表达式>;
与 if 表达式不同的是,while 表达式没有返回值,因而也就不能像 if 那样把自己赋值给某变量。

无法访问的代码

安全是 Move 最显著的特性。出于安全考虑,Move 规定所有变量必须被使用。并且出于同样的原因,Move 禁止使用无法访问的代码。由于数字资产是可编程的,因此可以在代码中使用它们(我们将在 Resource 一章中对其进行介绍)。而将资产放置在无法访问的代码中可能会带来问题,并造成损失。
这就是为什么无法访问的代码如此重要的原因。

无限循环

Move 提供了一种定义无限循环的方法,它没有条件判断,会一直执行。一旦执行该代码将消耗所有给定资源(交易费),大多数情况下,编译器也无法判断循环是否是无限的,也就无法阻止无限循环代码的发布。因此,使用无限循环时一定要注意安全,通常情况下建议使用 while 条件循环。
无限循环用关键字 loop 定义。

script {
    fun main() {
        let i = 0;

        loop {
            i = i + 1;
        };
    
        // UNREACHABLE CODE
        let _ = i;
    }
}

下面的代码也是可以编译通过的:

script {
    fun main() {
        let i = 0;

        loop {
            if (i == 1) { // i never changed
                break // this statement breaks loop
            }
        };
    
        // actually unreachable
        0x1::Debug::print<u8>(&i);
    }
}

对于编译器而言,要了解循环是否真的是无限的,这是一项艰巨的任务,因此,就目前而言,只有开发者自己可以帮助自己发现循环错误,避免资产损失。

通过 continue 和 break 控制循环

continue 和 break 关键字,分别允许程序跳过一轮循环或中断循环,可以在两种类型的循环中同时使用它们。
例如,让我们在 loop 中添加两个条件,如果i是偶数,我们通过 continue 跳转到下一个迭代,而无需执行循环中 continue 之后的代码。当 i 等于 5 时,我们通过 break 停止迭代并退出循环。

script {
    fun main() {
        let i = 0;

        loop {
            i = i + 1;
    
            if (i / 2 == 0) continue;
            if (i == 5) break;
    
            // assume we do something here
         };
    
        0x1::Debug::print<u8>(&i);
    }
}

注意,如果 break 和 continue 是代码块中的最后一个关键字,则不能在其后加分号,因为后面的任何代码都不会被执行。请看这个例子:

script {
    fun main() {
        let i = 0;

        loop {
            i = i + 1;
    
            if (i == 5) {
                break; // will result in compiler error. correct is `break` without semi
                       // Error: Unreachable code
            };
    
            // same with continue here: no semi, never;
            if (true) {
                continue
            };
    
            // however you can put semi like this, because continue and break here
            // are single expressions, hence they "end their own scope"
            if (true) continue;
            if (i == 5) break;
        }
    }
}

有条件退出 abort

有时,当某些条件失败时,您需要中止程序的执行。对于这种情况,Move 提供了有键字 abort。

script {
    fun main(a: u8) {

        if (a != 10) {
            abort 0;
        }
    
        // code here won't be executed if a != 10
        // transaction aborted
    }
}

关键字 abort 允许程序中止执行的同时报告错误代码。

使用 assert 内置方法

内置方法 assert(, ) 对 abort和条件进行了封装,你可以在代码中任何地方使用它。

script {

    fun main(a: u8) {
        assert(a == 10, 0);
    
        // code here will be executed if (a == 10)
    }
}

assert() 在不满足条件时将中止执行,在满足条件时将不执行任何操作。

4.6.模块和导入

模块

模块是发布在特定地址下的打包在一起的一组函数和结构体。前几章里,我们已经使用了脚本,脚本需要与已发布的模块或标准库一起运行,而标准库本身就是在 0x1 地址下发布的一组模块。
模块在发布者的地址下发布。标准库在 0x1 地址下发布。
发布模块时,不会执行任何函数。要使用模块就得使用脚本。
模块以module关键字开头,后面跟随模块名称和大括号,大括号中放置模块内容。

module Math {

    // module contents
    
    public fun sum(a: u64, b: u64): u64 {
        a + b
    }
}

模块是发布代码供他人访问的唯一方法。新的类型和 Resource 也只能在模块中定义。

默认情况下,模块将在发布者的地址下进行编译和发布。但如果只是测试或开发,或者想要在模块中指定地址,请使用以下address {}语法:

address 0x1 {
module Math {
    // module contents

    public fun sum(a: u64, b: u64): u64 {
        a + b
    }
}
}

如示例所示:最佳实践是保持模块行不缩进

导入

Move 在默认上下文中只能使用基本类型,也就是整型、布尔型和地址,可以执行的有意义或有用的操作也就是操作这些基本类型,或者基于基本类型定义新的类型。
除此之外还可以导入已发布的模块(或标准库)。

直接导入

可以直接在代码中按其地址使用模块:

script {
    fun main(a: u8) {
        0x1::Debug::print(&a);
    }
}

在此示例中,我们从地址0x1(标准库)导入了 Debug 模块,并使用了它的 print 方法。

关键字 use

要使代码更简洁(注意,0x1 是特殊的地址,实际地址是很长的),可以使用关键字use:
use

::;
这里
是模块发布者的地址, 是模块的名字。非常简单,例如,我们可以像下面这样从 0x1 地址导入 Vector 模块。
use 0x1::Vector;

访问模块的内容

要访问导入的模块的方法(或类型),需要使用::符号。非常简单,模块中定义的所有公开成员都可以通过双冒号进行访问。

script {
    use 0x1::Vector;

    fun main() {
        // here we use method empty() of module Vector
        // the same way we'd access any other method of any other module
        let _ = Vector::empty<u64>();
    }
}
在脚本中导入

在脚本中,模块导入必须放在 script {} 块内:

script {
    use 0x1::Vector;

    // in just the same way you can import any
    // other module(s). as many as you want!
    
    fun main() {
        let _ = Vector::empty<u64>();
    }
}
在模块中导入

在模块中导入模块必须在 module {} 块内进行:

module Math {
    use 0x1::Vector;

    // the same way as in scripts
    // you are free to import any number of modules
    
    public fun empty_vec(): vector<u64> {
        Vector::empty<u64>();
    }
}
成员导入

导入语句还可以进一步被扩展,可以直接导入模块的成员:

script {
    // single member import
    use 0x1::Signer::address_of;

    // multi member import (mind braces)
    use 0x1::Vector::{
        empty,
        push_back
    };
    
    fun main(acc: &signer) {
        // use functions without module access
        let vec = empty<u8>();
        push_back(&mut vec, 10);
    
        // same here
        let _ = address_of(acc);
    }
}
使用 Self 来同时导入模块和模块成员

导入语句还可以进一步扩展,通过使用 Self 来同时导入模块和模块成员,这里 Self 代表模块自己。

script {
    use 0x1::Vector::{
        Self, // Self == Imported module
        empty
    };

    fun main() {
        // `empty` imported as `empty`
        let vec = empty<u8>();
    
        // Self means Vector
        Vector::push_back(&mut vec, 10);
    }
}
使用 use as

当两个或多个模块具有相同的名称时,可以使用关键字as更改导入的模块的名称,这样可以在解决命名冲突的同时缩短代码长度。

语法:

use

:: as ;
在脚本中:

script {
    use 0x1::Vector as V; // V now means Vector

    fun main() {
        V::empty<u64>();
    }
}

在模块中:

module Math {
    use 0x1::Vector as Vec;

    fun length(&v: vector<u8>): u64 {
        Vec::length(&v)
    }
}

脚本中的例子:

script {
    use 0x1::Vector::{
        Self as V,
        empty as empty_vec
    };

    fun main() {
        // `empty` imported as `empty_vec`
        let vec = empty_vec<u8>();
    
        // Self as V = Vector
        V::push_back(&mut vec, 10);
    }
}

4.7.常量

Move 支持模块或脚本级常量。常量一旦定义,就无法更改,所以可以使用常量为特定模块或脚本定义一些不变量,例如角色、标识符等。
常量可以定义为基本类型(比如整数,布尔值和地址),也可以定义为数组。我们可以通过名称访问常量,但是要注意,常量对于定义它们的脚本或模块来说是本地可见的。
我们无法从模块外部访问模块内部定义的常量

script {

    use 0x1::Debug;
    const RECEIVER : address = 0x999;
    
    fun main(account: &signer) {
        Debug::print<address>(&RECEIVER);
        // they can also be assigned to a variable
        let _ = RECEIVER;
        // but this code leads to compile error
        // RECEIVER = 0x800;
    }
}

一些用法:

module M {
    const MAX : u64 = 100;
    // however you can pass constant outside using a function
    public fun get_max(): u64 {
        MAX
    }
    // or using
    public fun is_max(num: u64): bool {
        num == MAX
    }
}

使用常量时应该注意:
一旦定义,常量是不可更改的。
常量在模块或脚本中是本地可见的,不能在外部使用。
可以将常量定义为一个表达式(带有花括号),但是此表达式的语法非常有限。

4.8.函数

Move 中代码的执行是通过调用函数实现的。函数以 fun 关键字开头,后跟函数名称、扩在括号中的参数,以及扩在花括号中的函数体。
fun function_name(arg1: u64, arg2: bool): u64 {
// function body
}
我们在前面的章节中已经看到过函数,现在我们来学习如何使用函数。
注意:Move 函数使用snake_case命名规则,也就是小写字母以及下划线作为单词分隔符。

脚本中的函数

脚本块只能包含一个被视为 main 的函数。它作为交易被执行,可以有参数,但是没有返回值。它可以操作其它已经发布的模块中的函数。
这里有一个简单的例子,用来检查地址是否存在:

script {
    use 0x1::Account;

    fun main(addr: address) {
        assert(Account::exists(addr), 1);
    }
}

脚本中的函数可以带有参数,本例中它是 address 类型的参数 addr。函数中操作了导入的模块 Account。
注意:由于只有一个函数,因此你可以按任意方式对它命名。一般情况下我们遵循惯用的编程概念将其称为 main。

模块中的函数

脚本中能使用的函数功能是相对有限的,函数的全部潜能只有在模块中才能展现。让我们再看一遍什么是模块:模块是一组函数和结构体(我们将在下一章中介绍结构体),它可以封装一项或多项功能。
这部分内容中,我们将创建一个简单的 Math 模块,它将为用户提供一组基本的数学函数和一些辅助方法。当然这里面大部分操作无需使用模块即可完成,但我们的目标是通过这个例子来理解函数。

module Math {
    fun zero(): u8 {
        0
    }
}

第一步:我们定义一个 Math 模块,它有一个函数:zero(),该函数返回 u8 类型的值 0。还记得我们之前介绍过的表达式吗?0 之后没有分号,因为它是函数的返回值。是的,就像块表达式一样,函数与块非常相似。

函数参数

关于参数其实大家都已经很清楚了,但是我们还是稍微啰嗦一下,函数可以根据需要接受任意多个参数(传递给函数的值)。就像 Move 中的其他任何变量一样,每个参数都有两个属性:参数名,也就是参数在函数体内的名称,以及参数类型。

像作用域中定义的任何其他变量一样,函数参数仅存在于函数体内。当函数块结束时,参数也会消亡。

module Math {
    public fun sum(a: u64, b: u64): u64 {
        a + b
    }

    fun zero(): u8 {
        0
    }
}

大家发现有什么不一样了么?Math 模块新增了 sum(a,b) 函数,该函数将两个 u64 值相加并作为 u64 结果返回。

关于参数的一些语法规则:
参数必须具有类型,并且必须用逗号分隔
函数返回值放在括号后,并且必须在冒号后面
下面我们如何在脚本中使用此函数呢?通过"导入"!

script {
    use 0x1::Math;  // used 0x1 here; could be your address
    use 0x1::Debug; // this one will be covered later!
    fun main(first_num: u64, second_num: u64) {
        // variables names don't have to match the function's ones
        let sum = Math::sum(first_num, second_num);
        Debug::print<u64>(&sum);
    }
}

关键字 return

关键字 return 允许函数结束执行并返回结果。它可以与 if 条件一起使用,这样可以根据条件返回不同结果。

module M {
    public fun conditional_return(a: u8): bool {
        if (a == 10) {
            return true // semi is not put!
        };

        if (a < 10) {
            true
        } else {
            false
        }
    }
}

多个返回值

在前面的示例中,我们尝试了没有返回值或返回单个值的函数。但是,要想返回任何类型的多个值应该怎么办呢?好的,让我们继续往下看!
要指定多个返回值,需要使用括号:

module Math {

    // ...
    
    public fun max(a: u8, b: u8): (u8, bool) {
        if (a > b) {
            (a, false)
        } else if (a < b) {
            (b, false)
        } else {
            (a, true)
        }
    }
}

该函数有两个参数:a 和 b,并返回两个值:第一个是两个输入参数中的较大的值,第二个是布尔类型,表示输入的参数是否相等。请仔细看一下语法,我们没有指定单个返回值,而是添加了括号并在其中列出了返回值类型。
现在让我们看看如何在另一个脚本中使用该函数的返回值。

script {
    use 0x1::Debug;
    use 0x1::Math;

    fun main(a: u8, b: u8)  {
        let (max, is_equal) = Math::max(99, 100);
        assert(is_equal, 1)
        Debug::print<u8>(&max);
    }
}

上面例子中,我们解构了一个二元组,用函数 max 的返回值创建了两个新变量。返回值的顺序保持不变,变量 max 用来存储 u8 类型的最大值,而 is_equal 用来存储 bool 类型。
返回值数量并没有限制,你可以根据需要决定元组的元素个数。下一章,我们还会介绍返回复杂数据的另一种方法,那就是结构体。

函数可见性

定义模块时,你可能希望其他开发人员可以访问某些函数,而某些函数则保持隐藏状态。这正是函数可见性修饰符发挥作用的时候。
默认情况下,模块中定义的每个函数都是私有的,无法在其它模块或脚本中访问。可能你已经注意到了,我们在 Math 模块中定义的某些函数前有关键字 public:

module Math {

    public fun sum(a: u64, b: u64): u64 {
        a + b
    }
    
    fun zero(): u8 {
        0
    }
}

例子中Math模块被其它模块导入后,sum() 函数可以从外部访问,但是 zero() 不能被访问,因为默认情况下它是私有的。
关键字 public 将更改函数的默认可见性并使其公开,即可以从外部访问。
基本上,如果不将 sum() 函数设为 public,从外部访问是不可能的:

script {
    use 0x1::Math;

    fun main() {
        Math::sum(10, 100); // won't compile!
    }
}

访问私有函数

如果根本无法访问,那么私有函数就没有任何意义了。调用 public 函数的同时,可以用私有函数来执行一些内部工作。
私有函数只能在定义它们的模块中访问。

那么如何访问同一模块中的函数?通过像导入一样简单地调用此函数!

module Math {
    public fun is_zero(a: u8): bool {
        a == zero()
    }

    fun zero(): u8 {
        0
    }
}

一个模块中定义的任何函数都可以被同一模块中的任何函数访问,无论它们的可见性修饰符是什么。这样,私有函数仍然可以在内部调用,而且不会暴露某些私有操作到模块外。

本地方法

有一种特殊的函数叫做"本地方法"。本地方法实现的功能超出了 Move 的能力,它可以提供了额外的功能。本地方法由 VM 本身定义,并且在不同的VM实现中可能会有所不同。这意味着它们没有用 Move 语法实现,没有函数体,直接以分号结尾。关键字 native 用于标记本地函数,它和函数可见性修饰符不冲突,native 和 public 可以同时使用。

这是 Diem 标准库中的示例。

module Signer {
    native public fun borrow_address(s: &signer): &address;
    // ... some other functions ...
}

5.进阶主题

本章中,我们来学习一些在 Move 中广泛使用的编程概念:abilities、所有权、泛型和向量。它们为 Move 语言的安全性和灵活性打下了坚实的基础。

5.1.结构体

结构体是自定义类型,它可以包含复杂数据,也可以不包含任何数据。结构体由字段组成,可以简单地理解成"key-value"存储,其中 key 是字段的名称,而 value 是存储的内容。结构体使用关键字 struct 定义。
结构体是在 Move 中创建自定义类型的唯一方法。

定义

结构体只能在模块内部定义,并且以关键字 struct 开头:

struct NAME {
    FIELD1: TYPE!,
    FILED2: TYPE2,
    ...
}

我们来看一些例子:

module M {
    // struct can be without fields
    // but it is a new type
    struct Empty {}

    struct MyStruct {
        field1: address,
        field2: bool,
        field3: Empty
    }
    
    struct Example {
        field1: u8,
        field2: address,
        field3: u64,
        field4: bool,
        field4: bool,
    
        // you can use another struct as type
        field6: MyStruct
    }
}

一个结构体最多可以有 65535 个字段。
被定义的结构体会成为新的类型,可以通过定义它的模块访问此类型:

M::MyStruct;
// or
M::Example;

定义递归结构体

定义递归结构体是不允许的
Move 允许使用其它结构作为成员,但不能递归使用相同的结构体。Move 编译器会检查递归定义,不允许下面这样的代码:

module M {
    struct MyStruct {

        // WON'T COMPILE
        field: MyStruct
    }
}

创建结构体实例

要使用某结构体类型,需要先创建其实例。
可以用结构体的定义来创建实例,不同的是传入具体的值而不是类型。

module Country {
    struct Country {
        id: u8,
        population:u64
    }

    // Contry is a return type of this function!
    public fun new_country(c_id: u8, c_population: u64): Country {
        // structure creation is an expression
        let country = Country {
            id:c_id,
            population:c_population
        };
        country
    }
}

还可以通过传递与结构体的字段名匹配的变量名来简化创建新实例的代码。下面的 new_country() 函数中使用了这个简化方法:

// ...
public fun new_country(id: u8, population: u64): Country {
    // id matches id: u8 field
    // population matches population field
    Country {
        id,
        population
    }

    // or even in one line: Country { id, population }
}

要创建一个空结构体(没有字段),只需使用花括号:

public fun empty(): Empty {
    Empty {}
}

访问结构体成员字段

如果我们没有办法访问结构体的字段,那么它几乎是无用的。
只有在模块内才可以访问其结构体的字段。在模块之外,该结构体字段是不可见的。
结构字段仅在其模块内部可见。在此模块之外(在脚本或其他模块中),它只是一种类型。要访问结构的字段,请使用"."符号:
// …
public fun get_country_population(country: Country): u64 {
country.population // .
}
如果在同一模块中定义了嵌套结构类型,则可以用类似的方式对其进行访问,通常可以将其描述为:
.
// and field can be another struct so
..<nested_struct_field>…

为结构体字段实现getter方法

为了使结构体字段在外部可读,需要实现一些方法,这些方法将读取这些字段并将它们作为返回值传递。通常,getter 方法的调用方式与结构体的字段相同,但是如果你的模块定义了多个结构体,则 getter 方法可能会带来不便。

module Country {

    struct Country {
        id: u8,
        population: u64
    }
    
    public fun new_country(id: u8, population: u64): Country {
        Country {
            id, population
        }
    }
    
    // don't forget to make these methods public!
    public fun id(country: &Country): u8 {
        country.id
    }
    
    // don't mind ampersand here for now. you'll learn why it's 
    // put here in references chapter 
    public fun population(country: &Country): u64 {
        country.population
    }
    
    // ... fun destroy ... 
}

通过 getter 方法,我们允许模块的使用者访问结构体的字段:

script {
    use {{sender}}::Country as C;
    use 0x1::Debug;

    fun main() {
        // variable here is of type C::Country
        let country = C::new_country(1, 10000000);
    
        Debug::print<u8>(
            &C::id(&country)
        ); // print id
    
        Debug::print<u64>(
            &C::population(&country)
        );
    
        // however this is impossible and will lead to compile error
        // let id = country.id;
        // let population = country.population.
    }
}

回收结构体

解构、或者销毁结构体需要使用语法 let = :

module Country {

    // ...
    
    // we'll return values of this struct outside
    public fun destroy(country: Country): (u8, u64) {
    
        // variables must match struct fields
        // all struct fields must be specified
        let Country { id, population } = country;
    
        // after destruction country is dropped
        // but its fields are now variables and
        // can be used
        (id, population)
    }
}

请注意,Move 中禁止定义不会被使用的变量,有时你可能需要在不使用其字段的情况下销毁该结构体。对于未使用的结构体字段,请使用下划线"_"表示:

module Country {
    // ...

    public fun destroy(country: Country) {
    
        // this way you destroy struct and don't create unused variables
        let Country { id: _, population: _ } = country;
    
        // or take only id and don't init `population` variable
        // let Country { id, population: _ } = country;
    }
}

销毁结构体并不是必须的。

5.2.Abilities

Move 的类型系统非常灵活,每种类型都可以被四种限制符所修饰。这四种限制符我们称之为 abilities,它们定义了类型的值是否可以被复制、丢弃和存储。

这四种 abilities 限制符分别是: Copy, Drop, Store 和 Key.

它们的功能分别是:
Copy - 被修饰的值可以被复制。
Drop - 被修饰的值在作用域结束时可以被丢弃。
Key - 被修饰的值可以作为键值对全局状态进行访问。
Store - 被修饰的值可以被存储到全局状态。
本章我们先介绍 Copy 和 Drop ability;关于 Key 和 Store ability 我们将会在 Resources 一章介绍.

Abilities 语法

基本类型和内建类型的 abilities 是预先定义好的并且不可改变: integers, vector, addresses 和 boolean 类型的值先天具有 copy,drop 和 store ability。
然而,结构体的 ability 可以由开发者按照下面的语法进行添加:

struct NAME has ABILITY [, ABILITY] { [FIELDS] }
下面是一些例子:

module Library {
    
    // each ability has matching keyword
    // multiple abilities are listed with comma
    struct Book has store, copy, drop {
        year: u64
    }
    
    // single ability is also possible
    struct Storage has key {
        books: vector<Book>
    }
    
    // this one has no abilities 
    struct Empty {}
}

不带 Abilities 限制符的结构体

在进入 abilities 的具体用法之前, 我们不妨先来看一下,如果结构体不带任何 abilities 会发生什么?

module Country {
    struct Country {
        id: u8,
        population: u64
    }
    
    public fun new_country(id: u8, population: u64): Country {
        Country { id, population }
    }
}

script {
    use {{sender}}::Country;

    fun main() {
        Country::new_country(1, 1000000);
    }   
}

运行上面的代码会报如下错误:
error:
┌── scripts/main.move:5:9 ───

5 │ Country::new_country(1, 1000000);
│ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Cannot ignore values without the ‘drop’ ability. The value must be used

方法 Country::new_country() 创建了一个值,这个值没有被传递到任何其它地方,所以它应该在函数结束时被丢弃。但是 Country 类型没有 Drop ability,所以运行时报错了。现在让我们加上 Drop 限制符试试看。

Drop

按照 abilities 语法我们为这个结构体增加 drop ability,这个结构体的所有实例将可以被丢弃。

module Country {
    struct Country has drop { // has <ability>
        id: u8,
        population:u64
    }
    // ...
}

现在,Country可以被丢弃了,代码也可以成功执行了

script {
    use {{sender}}::Country;
    fun main() {
        Country::new_country(1, 1000000)l // value is dropped
    }
}

注意 Destructuring 并不需要 Drop ability.

Copy

我们学习了如何构建一个结构体Country并在函数结束的时候丢弃它。但如果我们想要复制一个结构体呢?缺省情况下结构体是按值传递的,制造一个结构体的副本需要借助关键字copy

script {
    use {{sender}}::Country;

    fun main() {
        let country = Country::new_country(1, 1000000);
        let _ = copy country;
    }   
}

┌── scripts/main.move:6:17 ───

6 │ let _ = copy country;
│ ^^^^^^^^^^^^ Invalid ‘copy’ of owned value without the ‘copy’ ability

正如所料,缺少 copy ability 限制符的类型在进行复制时会报错:

module Country {
    struct Country has drop, copy { // see comma here!
        id: u8,
        population: u64
    }
    // ...
}

修改的代码就可以成功执行了。

小结
基本类型缺省具有store,copy和drop限制。
缺省情况下结构体不带任何限制符。
Copy和Drop限制符定义了一个值是否可以被复制和丢弃。
一个结构体有可能带有4种限制符。

所有权和引用

Move VM 实现了类似 Rust 的所有权功能。关于所有权的详细描述,可以参考 Rust Book 。
Rust 语法不同于 Move,某些示例可能不容易理解,但还是建议大家先阅读一下 Rust Book 中的所有权一章。当然,关于所有权的关键点本书也会逐一介绍。
每个变量只有一个所有者作用域。当所有者作用域结束时,变量将被删除。
变量的寿命与它的作用域一样长,我们曾经在表达式一章中看到过这种行为,大家还有没有印象?现在是了解其内部机制的绝佳时机了。
所有者是拥有某变量的作用域。变量可以在作用域内定义(例如,在脚本中使用关键字 let),也可以作为参数传递给作用域。由于 Move 中唯一的作用域是函数的作用域,所以除了这两种方法,没有其它方法可以将变量放入作用域。
每个变量只有一个所有者,这意味着当把变量作为参数传递给函数时,该函数将成为新所有者,并且第一个函数不再拥有该变量。或者可以说,第二个函数接管了变量的所有权。

script {
    use {{sender}}::M;

    fun main() {
        // Module::T is a struct
        let a : Module::T = Module::create(10);
    
        // here variable `a` leaves scope of `main` function
        // and is being put into new scope of `M::value` function
        M::value(a);
    
        // variable a no longer exists in this scope
        // this code won't compile
        M::value(a);
    }
}

让我们看一下将变量传递给 value() 函数时,Move 内部发生的情况:

module M {
    // create_fun skipped
    struct T { value: u8 }

    public fun create(value: u8): T {
        T { value }
    }
    
    // variable t of type M::T passed
    // `value()` function takes ownership
    public fun value(t: T): u8 {
        // we can use t as variable
        t.value
    }
    // function scope ends, t dropped, only u8 result returned
    // t no longer exists
}

我们可以看到,当函数 value() 结束时,t 将不复存在,返回的只是一个 u8 类型的值。如何让t仍然可用呢?当然,一种快速的解决方案是返回一个元组,该元组包含原始变量和其它结果,但是 Move 还有一个更好的解决方案。

move和copy

首先,我们了解一下 Move VM 的工作原理,以及将值传递给函数时会发生什么。Move VM 里有两个字节码指令:MoveLoc 和 CopyLoc,反映到 Move 语言层面,它们分别对应关键字move和copy。
将变量传递到另一个函数时,MoveLoc 指令被使用,它会被 move。我们可以像下面这样显式使用 move 关键字:

script {
    use {{sender}}::M;

    fun main() {
        let a : Module::T = Module::create(10);
    
        M::value(move a); // variable a is moved
    
        // local a is dropped
    }
}

这段代码是没有问题的,但是我们平常并不需要显示使用 move,缺省 a 会被 move。那么 copy 又是怎么回事呢?

关键字 copy

如果想保留变量的值,同时仅将值的副本传递给某函数,则可以使用关键字 copy。

script {
    use {{sender}}::M;

    fun main() {
        let a : Module::T = Module::create(10);
    
        // we use keyword copy to clone structure
        // can be used as `let a_copy = copy a`
        M::value(copy a);
        M::value(a); // won't fail, a is still here
    }
}

上例中,我们第一次调用函数 value() 时,将变量 a 的副本传递给函数,并保留 a 在本地作用域中,以便第二次调用函数时再次使用它。
使用 copy 后,我们实际上复制了变量值从而增加了程序占用内存的大小。但是如果复制数据数据量比较大,它的内存消耗可能会很高。这里要注意了,在区块链中,交易执行时占用的内存资源是消耗交易费的,每个字节都会影响交易执行费用。因此不加限制的使用 copy 会浪费很多交易费。
现在,是时候学习引用了,它可以帮助我们避免不必要的 copy 从而节省一些费用。

引用

许多编程语言都支持引用。引用是指向变量(通常是内存中的某个片段)的链接,你可以将其传递到程序的其他部分,而无需移动变量值。
引用(标记为&)使我们可以使用值而无需拥有所有权。
我们修改一下上面的示例,看看如何使用引用。

module M {
    struct T { value: u8 }
    // ...
    // ...
    // instead of passing a value, we'll pass a reference
    public fun value(t: &T): u8 {
        t.value
    }
}

我们在参数类型 T 前添加了&符号,这样就可以将参数类型T转换成了 T 的引用&T。
Move 支持两种类型的引用:不可变引用 &(例如&T)和可变引用 &mut(例如&mut T)。
不可变的引用允许我们在不更改值的情况下读取值。可变引用赋予我们读取和更改值的能力。

module M {
    struct T { value: u8 }

    // returned value is of non-reference type
    public fun create(value: u8): T {
        T { value }
    }
    
    // immutable references allow reading
    public fun value(t: &T): u8 {
        t.value
    }
    
    // mutable references allow reading and changing the value
    public fun change(t: &mut T, value: u8) {
        t.value = value;
    }
}

现在,让我们看看如何使用升级后的模块M

script {
    use {{sender}}::M;
    fun main() {
        let t = M::create(10);
        
        //create a reference directly
        M::change(&mut t, 20);
    
        // or write reference to a variable
        let mut_ref_t = &mut t;
    
        M::change(mut_ref_t, 100);
    
        // same with immutable ref
        let value = M::value(&t);
    
        // this method alse takes only references
        // printed value will be 100
        0x1::Debug::print<u8>(&value);
    }
}

使用不可变引用(&)从结构体读取数据,使用可变引用(&mut)修改它们。通过使用适当类型的引用,我们可以更加安全的读取模块,因为它能告诉代码的阅读者,该变量是否会被修改。

Borrow检查

Move通过“Borrow检查”来控制程序中“引用”的使用,这样有助于防止意外出错。为了理解这一点,我们看一个例子。

module Borrow {

    struct B { value: u64 }
    struct A { b: B }
    
    // create A with inner B
    public fun create(value: u64): A {
        A { b: B { value } }
    }
    
    // give a mutable reference to inner B
    public fun ref_from_mut_a(a: &mut A): &mut B {
        &mut a.b
    }
    
    // change B
    public fun change_b(b: &mut B, value: u64) {
        b.value = value;
    }
}

script {
    use {{sender}}::Borrow;

    fun main() {
        // create a struct A { b: B { value: u64 } }
        let a = Borrow::create(0);
    
        // get mutable reference to B from mut A
        let mut_a = &mut a;
        let mut_b = Borrow::ref_from_mut_a(mut_a);
    
        // change B
        Borrow::change_b(mut_b, 100000);
    
        // get another mutable reference from A
        let _ = Borrow::ref_from_mut_a(mut_a);
    }
}

上面代码可以成功编译运行,不会报错。这里究竟发生了什么呢?首先,我们使用 A 的可变引用(&mut A)来获取对其内部 struct B 的可变引用(&mut B)。然后我们改变 B。然后可以再次通过 &mut A 获取对 B 的可变引用。
但是,如果我们交换最后两个表达式,即首先尝试创建新的 &mut A,而 &mut B 仍然存在,会出现什么情况呢?

let mut_a = &mut a;
let mut_b = Borrow::ref_from_mut_a(mut_a);
let _ = Borrow::ref_from_mut_a(mut_a);
Borrow::change_b(mut_b, 100000);

编译器将会报错:
┌── /scripts/script.move:10:17 ───

10 │ let _ = Borrow::ref_from_mut_a(mut_a);
│ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Invalid usage of reference as function argument. Cannot transfer a mutable reference that is being borrowed
·
8 │ let mut_b = Borrow::ref_from_mut_a(mut_a);
│ ----------------------------- It is still being mutably borrowed by this reference

该代码不会编译成功。为什么?因为 &mut A 已经被 &mut B 借用。如果我们再将其作为参数传递,那么我们将陷入一种奇怪的情况,A 可以被更改,但 A 同时又被引用。而 mut_b 应该指向何处呢?

我们得出一些结论:
编译器通过所谓的"借用检查"(最初是Rust语言的概念)来防止上面这些错误。编译器通过建立"借用图",不允许被借用的值被"move"。这就是 Move 在区块链中如此安全的原因之一。
可以从引用创建新的引用,老的引用将被新引用"借用"。可变引用可以创建可变或者不可变引用,而不可变引用只能创建不可变引用。
当一个值被引用时,就无法"move"它了,因为其它值对它有依赖。

取值运算

可以通过取值运算*来获取引用所指向的值。
取值运算实际上是产生了一个副本,要确保这个值具有 Copy ability。

module M {
    struct T has copy {}

    // value t here is of reference type
    public fun deref(t: &T): T {
        *t
    }
}

取值运算不会将原始值 move 到当前作用域,实际上只是生成了一个副本。
有一个技巧用来复制一个结构体的字段:就是使用*&,引用并取值。我们来看一个例子:

module M {
    struct H has copy {}
    struct T { inner: H }

    // ...
    
    // we can do it even from immutable reference!
    public fun copy_inner(t: &T): H {
        *&t.inner
    }
}

通过使用*&(编译器会建议这样做),我们复制了结构体的内部值。

引用基本类型

基本类型非常简单,它们不需要作为引用传递,缺省会被复制。当基本类型的值被传给函数时,相当于使用了copy关键字,传递进函数的是它们的副本。当然你可以使用move关键字强制不产生副本,但是由于基本类型的大小很小,复制它们其实开销很小,甚至比通过引用或者"move"传递它们开销更小。

script {
    use {{sender}}::M;

    fun main() {
        let a = 10;
        M::do_smth(a);
        let _ = a;
    }
}

也就是说,即使我们没有将a作为引用传递,该脚本也会编译。我们也无需添加copy,因为 VM 已经帮组我们添加了。

5.4.泛型

泛型对于 Move 语言是必不可少的,它使得 Move 语言在区块链世界中如此独特,它是 Move 灵活性的重要来源。
首先,让我来引用 Rust Book 对于泛型得定义:泛型是具体类型或其他属性的抽象替代品。实际上,泛型允许我们只编写单个函数,而该函数可以应用于任何类型。这种函数也被称为模板 —— 一个可以应用于任何类型的模板处理程序。
Move 中泛型可以应用于结构体和函数的定义中。

结构体中的泛型

首先,我们将创建一个可容纳u64整型的 Box :

module Storage {
    struct Box {
        value: u64
    }
}

这个 Box 只能包含u64类型的值,这一点是非常清楚的。但是,如果我们想为u8类型或 bool类型创建相同的 Box 该怎么办呢?分别创建u8类型的 Box1 和bool型 Box2 吗?答案是否定的,因为可以使用泛型。

module Storage {
    struct Box<T> {
        value: T
    }
}

我们在结构体名字的后面增加。尖括号<…>里面用来定义泛型,这里T就是我们在结构体中模板化的类型。在结构体中,我们已经将T用作常规类型。类型T实际并不存在,它只是任何类型的占位符。

函数中的泛型

现在让我们为上面的结构体创建一个构造函数,该构造函数将首先使用u64类型。

module Storage {
    struct Box<T> {
        value: T
    }

    // type u64 is put into angle brackets meaning
    // that we're using Box with type u64
    public fun create_box(value: u64): Box<u64> {
        Box<u64>{ value }
    }
}

带有泛型的结构体的创建稍微有些复杂,因为它们需要指定类型参数,需要把常规结构体 Box 变为 Box。Move没有任何限制什么类型可以被放进尖括号中。但是为了让create_box方法更通用,有没有更简单的方法?有的,在函数中使用泛型!

module Storage {
    // ...
    public fun create_box<T>(value: T): Box<T> {
        Box<T> { value }
    }

    // we'll get to this a bit later, trust me
    public fun value<T: copy>(box: &Box<T>): T {
        *&box.value
    }
}

函数调用中使用泛型

上例中在定义函数时,我们像结构体一样在函数名之后添加了尖括号。如何使用它呢?就是在函数调用中指定类型。

script {
    use {{sender}}::Storage;
    use 0x1::Debug;

    fun main() {
        // value will be of type Storage::Box<bool>
        let bool_box = Storage::create_box<bool>(true);
        let bool_val = Storage::value(&bool_box);
    
        assert(bool_val, 0);
    
        // we can do the same with integer
        let u64_box = Storage::create_box<u64>(1000000);
        let _ = Storage::value(&u64_box);
    
        // let's do the same with another box!
        let u64_box_in_box = Storage::create_box<Storage::Box<u64>>(u64_box);
    
        // accessing value of this box in box will be tricky :)
        // Box<u64> is a type and Box<Box<u64>> is also a type
        let value: u64 = Storage::value<u64>(
            &Storage::value<Storage::Box<u64>>( // Box<u64> type
                &u64_box_in_box // Box<Box<u64>> type
            )
        );
    
        // you've already seed Debug::print<T> method
        // which also uses generics to print any type
        Debug::print<u64>(&value);
    }
}

这里我们用三种类型使用了 Box:bool, u64 和 Box。最后一个看起来有些复杂,但是一旦你习惯了,并且理解了泛型是如何工作的,它成为你日常工作的好帮手。
继续下一步之前,让我们做一个简单的回顾。我们通过将泛型添加到Box结构体中,使Box变得抽象了。与 Box 能提供的功能相比,它的定义相当简单。现在,我们可以使用任何类型创建Box,u64 或 address,甚至另一个 Box 或另一个结构体。

abilities 限制符

我们已经学习了 abilities,它们可以作为泛型的限制符来使用,限制符的名称和 ability 相同。
fun name<T: copy>() {} // allow only values that can be copied
fun name<T: copy + drop>() {} // values can be copied and dropped
fun name<T: key + store + drop + copy>() {} // all 4 abilities are present

也可以在结构体泛型参数中使用:
struct name<T: copy + drop> { value: T } // T can be copied and dropped
struct name<T: stored> { value: T } // T can be stored in global storage
请记住 + 这个语法符号,第一眼看上去可能不太适应,因为很少有语言在关键字列表中使用 +。

下面是一个使用限制符的例子:

module Storage {

    // contents of the box can be stored
    struct Box<T: store> has key, store {
        content: T
    }
}

另一个需要被提及的是结构体的成员必须和结构体具有相同的 abilities (除了key以外)。这个很容易理解,如果结构体具有 copy ability,那么它的成员也必须能被 copy,否则结构体作为一个整体不能被 copy。Move 编译器允许代码不遵守这样的逻辑,但是运行时会出问题。

module Storage {
    // non-copyable or droppable struct
    struct Error {}
    
    // constraints are not specified
    struct Box<T> has copy, drop {
        contents: T
    }
    
    // this method creates box with non-copyable or droppable contents
    public fun create_box(): Box<Error> {
        Box { contents: Error {} }
    }
}

这段代码可以成功编译和发布,但是如果你运行它就会出问题。

script {
    fun main() {
        {{sender}}::Storage::create_box() // value is created and dropped
    }   
}

运行结果是报错 Box 不能被 drop。

┌── scripts/main.move:5:9 ───

5 │ Storage::create_box();
│ ^^^^^^^^^^^^^^^^^^^^^ Cannot ignore values without the ‘drop’ ability. The value must be used

原因是创建结构体时所使用的成员值没有 drop ability。也就是 contents 不具备 Box 所要求的 abilities - copy 和 drop。
但是为了避免犯错,应该尽可能使泛型参数的限制符和结构体本身的 abilities 显式的保持一致。
所以下面这种定义的方法更安全:

// we add parent's constraints
// now inner type MUST be copyable and droppable
struct Box<T: copy + drop> has copy, drop {
    contents: T
}

泛型中包含多个类型

我们也可以在泛型中使用多个类型,像使用单个类型一样,把多个类型放在尖括号中,并用逗号分隔。我们来试着添加一个新类型Shelf,它将容纳两个不同类型的Box。

module Storage {

    struct Box<T> {
        value: T
    }
    
    struct Shelf<T1, T2> {
        box_1: Box<T1>,
        box_2: Box<T2>
    }
    
    public fun create_shelf<Type1, Type2>(
        box_1: Box<Type1>,
        box_2: Box<Type2>
    ): Shelf<Type1, Type2> {
        Shelf {
            box_1,
            box_2
        }
    }
}

Shelf的类型参数需要与结构体字段定义中的类型顺序相匹配,而泛型中的类型参数的名称则无需相同,选择合适的名称即可。正是因为每种类型参数仅仅在其作用域范围内有效,所以无需使用相同的名字。
多类型泛型的使用与单类型泛型相同:

script {
    use {{sender}}::Storage;

    fun main() {
        let b1 = Storage::create_box<u64>(100);
        let b2 = Storage::create_box<u64>(200);
    
        // you can use any types - so same ones are also valid
        let _ = Storage::create_shelf<u64, u64>(b1, b2);
    }
}

*你可以在函数或结构体定义中最多使用 18,446,744,073,709,551,615 (u64 最大值) 个泛型。你绝对不会达到此限制,因此可以随意使用。

未使用的类型参数

并非泛型中指定的每种类型参数都必须被使用。看这个例子:

module Storage {

    // these two types will be used to mark
    // where box will be sent when it's taken from shelf
    struct Abroad {}
    struct Local {}
    
    // modified Box will have target property
    struct Box<T, Destination> {
        value: T
    }
    
    public fun create_box<T, Dest>(value: T): Box<T, Dest> {
        Box { value }
    }
}

也可以在脚本中使用 :

script {
    use {{sender}}::Storage;

    fun main() {
        // value will be of type Storage::Box<bool>
        let _ = Storage::create_box<bool, Storage::Abroad>(true);
        let _ = Storage::create_box<u64, Storage::Abroad>(1000);
    
        let _ = Storage::create_box<u128, Storage::Local>(1000);
        let _ = Storage::create_box<address, Storage::Local>(0x1);
    
        // or even u64 destination!
        let _ = Storage::create_box<address, u64>(0x1);
    }
}

在这里,我们使用泛型标记类型,但实际上并没有真正使用它。当你了解resource概念后,就会知道为什么这种定义很重要。目前,就当这只是使用泛型的一种方法。

5.5.数组

我们已经非常熟悉结构体类型了,它使我们能够创建自己的类型并存储复杂数据。但是有时我们需要动态、可扩展和可管理的功能。为此,Move 提供了向量 Vector。
Vector 是用于存储数据集合的内置类型。集合的数据可以是任何类型(但仅一种)。Vector 功能实际上是由 VM 提供的,不是由 Move 语言提供的,使用它的唯一方法是使用标准库和 native 函数。

script {
    use 0x1::Vector;

    fun main() {
        // use generics to create an emtpy vector
        let a = Vector::empty<&u8>();
        let i = 0;
    
        // let's fill it with data
        while (i < 10) {
            Vector::push_back(&mut a, i);
            i = i + 1;
        }
    
        // now print vector length
        let a_len = Vector::length(&a);
        0x1::Debug::print<u64>(&a_len);
    
        // then remove 2 elements from it
        Vector::pop_back(&mut a);
        Vector::pop_back(&mut a);
    
        // and print length again
        let a_len = Vector::length(&a);
        0x1::Debug::print<u64>(&a_len);
    }
}

Vector 最多可以存储 18446744073709551615u64(u64最大值)个非引用类型的值。要了解它如何帮助我们管理大型数据,我们试着编写一个模块。

module Shelf {
    use 0x1::Vector;
    struct Box<T> {
        value: T
    }

    struct Shelf<T> {
        boxes: vector<Box<T>>
    }
    
    public fun create_box<T>(value: T): Box<T> {
        Box { value }
    }
    
    // this method will be inaccessible for non-copyable contents
    public fun value<T: copy>(box: &Box<T>): T {
        *&box.value
    }
    
    public fun create<T>(): Shelf<T> {
        Shelf {
            boxes: Vector::empty<Box<T>>()
        }
    }
    
    // box value is moved to the vector
    public fun put<T>(shelf: &mut Shelf<T>, box: Box<T>) {
        Vector::push_back<Box<T>>(&mut shelf.boxes, box);
    }
    
    public fun remove<T>(shelf: &mut Shelf<T>): Box<T> {
        Vector::pop_back<Box<T>>(&mut shelf.boxes)
    }
    
    public fun size<T>(shelf: &Shelf<T>): u64 {
        Vector::length<Box<T>>(&shelf.boxes)
    }
}

我们将创建一个Shelf,为其提供几个Box,并观察如何在模块中使用vector:

script {
    use {{sender}}::Shelf;

    fun main() {
        // create shelf and 2 boxes of type u64
        let shelf = Shelf::create<u64>();
        let box_1 = Shelf::create_box<u64>(99);
        let box_2 = Shelf::create_box<u64>(999);
    
        // put bot boxes to shelf
        Shelf::put(&mut shelf, box_1);
        Shelf::put(&mut shelf, box_2);
    
        // prints size -2
        0x1::Debug::print<u64>(&Shelf::size<u64>(&shelf));
    
        // then take one from shelf (last one pushed)
        let take_back = Shelf::remove(&mut shelf);
        let value = Shelf::value<u64>(&take_back);
    
        // verify that the box we took back is one with 999
        assert(value == 999, 1);
    
        // and print size again - 1
        0x1::Debug::print<u64>(&Shelf::size<u64>(&shelf));
    }
}

向量非常强大,它使我们可以存储大量数据,并可以在索引的存储中使用它。

内联Vector定义的十六进制数组和字符串

Vector也可以表示字符串。VM支持将vector作为参数传递给main脚本的函数。
也可以使用十六进制字面值(literal)在脚本或模块中定义vector:

script {
    use 0x1::Vector;

    // this is the way to accept arguments in main
    fun main(name: vector<u8>) {
        let _ = name;
    
        // and this is how you use literals
        // this is a "hello world" string!
        let str = x"68656c6c6f20776f726c64";
    
        // hex literal gives you vector<u8> as well
        Vector::length<u8>(&str);
    }
}

更见的方法是使用字符串字面值(literal):

script {
    fun main() {
        let _ = b"hello world";
    }
}

它们被视为ASCII字符串,也被解释为vector。

Vector速查表

这是标准库中Vector方法的简短列表:
创建一个类型为的空向量
Vector::empty(): vector;
获取向量的长度
Vector::length(v: &vector): u64;
将元素e添加到向量末尾
Vector::push_back(v: &mut vector, e: E);
获取对向量元素的可变引用。不可变引用可使用Vector::borrow()
Vector::borrow_mut(v: &mut vector, i: u64): &E;
从向量的末尾取出一个元素
Vector::pop_back(v: &mut vector): E;

标准库中的 Vector 模块:
Diem diem/diem
Starcoin starcoinorg/starcoin

6.可编程的Resource

这一章,我们终于要学习 Move 的关键功能 Resource 了。它使 Move 变得独一无二,安全且强大。
首先,让我们看一下 Diem 开发者网站上的关于 Resource 的要点(将 Libra 改名为 Diem 后,原页面已删除):
Move 的主要功能是提供了自定义 Resource 类型。Resource 类型为安全的数字资产编码具提供了丰富的可编程性。 Resource 在Move语言中就是普通的值。它们可以作为数据结构被存储,作为参数被传递给函数,也可以从函数中返回。
Resource 是一种特殊的结构体,可以在 Move 代码中定义和创建,也可以使用现有的 Resource。因此,我们可以像使用任何其它数据(比如向量或结构体)那样来管理数字资产。
Move 类型系统为 Resource 提供了特殊的安全保证。Resource 永远不能被复制,重用或丢弃。Resource 类型只能由定义该类型的模块创建或销毁。这些检查由 Move 虚拟机通过字节码校验强制执行。Move 虚拟机将拒绝运行任何尚未通过字节码校验的代码。
在所有权和引用一章中,我们学习了 Move 如何保护作用域以及如何控制变量的所有者作用域。在泛型一章,我们了解到有一种特殊的Kind匹配方式可以将可复制和不可复制类型分开。所有这些功能同时也为 Resource 类型提供了强大的安全性。
所有 Diem 货币都使用 Diem 类型实现。例如:假设的美元稳定币被表示为 Diem。Diem 在 Move 中并没有特殊地位,每个 Move Resource 都享有相同的安全保护。
就像 Diem 货币一样,其它代币或其它类型的资产也可以在 Move 中表示。

6.1.发送者和签署者

发送者作为Signer

在开始使用Resource之前,我们需要了解signer类型以及这种类型存在的原因。
Signer是一种原生的类似Resource的不可复制的类型,它包含了交易发送者的地址。
Signer 类型代表了发送者权限。换句话说,使用 signer 意味着可以访问发送者的地址和 Resource。它与signature没有直接关系,就 Move VM 而言,它仅表示发送者。
Signer只有一种ability:Drop

脚本的Signer

Signer是原声类型,使用前必须先创建。于vector这样的原声类型不同,signer不能直接在代码中创建,但是可以作为脚本参数传递:

script {
    // signer is an owned value
    fun main(account: signer) {
        let _ = account;
    }
}

Signer 参数无需手动将其传递到脚本中,客户端(CLI)会自动将它放入你的脚本中。而且,signer 自始至终都只是引用,虽然标准库中可以访问签名者的实际值,但使用此值的函数是私有的,无法在其他任何地方使用或传递 signer 值。
当前,约定俗成的 signer 类型的变量名是 account

标准库中的 Signer 模块

原生类型离不开原生方法, signer 的原生方法包含在0x1::Signer模块中。这个模块相对比较简单,具体可以参考 Diem 标准库 Signer 模块的实现:

module Signer {
    // Borrows the address of the signer
    // Conceptually, you can think of the `signer`
    // as being a resource struct wrapper arround an address
    // ```
    // resource struct Signer { addr: address }
    // ```
    // `borrow_address` borrows this inner field
    native public fun borrow_address(s: &signer): &address;

    // Copies the address of the signer
    public fun address_of(s: &signer): address {
        *borrow_address(s)
    }
}

模块中包含两个方法,一个是原生方法,另一个是 Move 方法。后者使用更方便,因为它使用了取值运算符来复制地址。

使用起来也非常简单:

script {
    fun main(account: signer) {
        let _ : address = 0x1::Signer::address_of(&account);
    }
}

模块中的 Signer

module M {
    use 0x1::Signer;

    // let's proxy Signer::address_of
    public fun get_address(account: signer): address {
        Signer::address_of(&account)
    }
}

使用&signer作为函数的参数说明该函数正在使用发送者的地址。
引入signer类型的原因之一是要明确显示哪些函数需要发送者权限,哪些不需要。因此,函数不能欺骗用户未经授权访问其 Resource。

6.2.Resource

Move 白皮书中详细描述了 Resource 这个概念。最初,它是作为一种名为 resource 的结构体类型被实现,自从引入 ability 以后,它被实现成拥有 Key 和 Store 两种 ability 的结构体。Resource 可以安全的表示数字资产,它不能被复制,也不能被丢弃或重新使用,但是它却可以被安全地存储和转移。

定义
Resource 是一种用 key 和 store ability 限制了的结构体:

module M {
    struct T has key, store {
        field: u8
    }
}

Resource 的限制

在代码中,Resource 类型有几个主要限制:
Resource 存储在帐户下。因此,只有在分配帐户后才会存在,并且只能通过该帐户访问。
一个帐户同一时刻只能容纳一个某类型的 Resource。
Resource 不能被复制;与它对应的是一种特殊的kind:resource,它与copyable不同,这一点在泛型章节中已经介绍。
Resource 必需被使用,这意味着必须将新创建的 Resource move到某个帐户下,从帐户移出的Resource 必须被解构或存储在另一个帐户下。
理论就这么多,下面让我们看看实际的例子!

6.3.Resource举例

本节中,我们将学习如何定义、使用 Resource。最终我们将得到一份完整的智能合约,可以作为模板供日后使用。
我们将创建一个集合合约,它的功能包括:
创建一个集合
集合中添加、取出元素
回收集合

6.3.1.创建和转移

首先,让我们创建模块:

// modules/Collection.move
module Collection {


    struct Item has store {
        // we'll think of the properties later
    }
    
    struct Collection has key {
        items: vector<Item>
    }
}

一个模块里最主要的 Resource 通常跟模块取相同的名称(例如这里的 Collection)。遵循这个惯例,你的模块将易于阅读和使用。

创建和移动

我们定义了一个 Resource 结构体T,该结构体将保存一个向量,向量里面存放Item类型的元素。现在,让我们看看如何创建新集合以及如何在 account 下存储 Resource。Resource 将永久保存在发送者的地址下,没有人可以从所有者那里修改或取走此 Resource。

// modules/Collection.move
module Collection {

    use 0x1::Vector;
    struct Item has store {}
    
    struct Collection has key {
        items: vector<Item>
    }
    
    /// note that &signer type is passed here!
    public fun start_collection(account: &signer) {
        move_to<Collection>(account, Collection {
            items: Vector::empty<Collection>()
        })
    }
}

还记得 signer 吗?现在,你将了解它的运作方式!移动 Resource 到 account 需要使用内建函数 move_to,需要 signer 作为第一个参数,T 作为第二个参数。move_to 函数的签名可以表示为:
native fun move_to<T: key>(account: &signer, value: T);
总结一下上面所学的内容:
你只能将 Resource 放在自己的帐户下。你无权访问另一个帐户的 signer 值,因此无法放置 Resource 到其它账户。
一个地址下最多只能存储一个同一类型的 Resource。两次执行相同的操作是不行的,比如第二次尝试创建已有 Resource 将会导致失败。

查看 Resource 是否存在

Move 提供 exists 函数来查看某 Resource 是否存在于给定地址下,函数签名如下:
native fun exists<T: key>(addr: address): bool;

通过使用泛型,此函数成为独立于类型的函数,你可以使用任何 Resource 类型来检查其是否存在于给定地址下。实际上,任何人都可以检查给定地址处是否存在 Resource。但是检查是否存在并不意味着能获取储 Resource !
让我们编写一个函数来检查用户是否已经拥有 resource T:

// modules/Collection.move
module Collection {

    struct Item has store, drop {}
    
    struct Collection has store, key {
        items: Item
    }
    
    // ... skipped ...
    
    /// this function will check if resource exists at address
    public fun exists_at(at: address): bool {
        exists<Collection>(at)
    }
}

现在你已经知道了如何创建 Resource,如何将其移动到发送者账户下以及如何检查 Resource 是否已经存在,现在是时候学习如何访问和修改 Resource 了。

6.3.2.创建和转移

Move 有两个内建函数用来读取和修改 Resource。它们的功能就像名字一样:borrow_global 和 borrow_global_mut。

不可变借用 borrow_global

在 所有权和引用 一章,我们已经了解了可变引用(&mut)和不可变的引用(&)。现在是时候实践这些知识了!

// modules/Collection.move
module Collection {

    // added a dependency here!
    use 0x1::Signer;
    use 0x1::Vector;
    
    struct Item has store, drop {}
    struct Collection has key, store {
        items: vector<Item>
    }
    
    // ... skipped ...
    
    /// get collection size
    /// mind keyword acquires!
    public fun size(account: &signer): u64 acquires Collection {
        let owner = Signer::address_of(account);
        let collection = borrow_global<Collection>(owner);
    
        Vector::length(&collection.items)
    }
}

这里发生了很多事情。首先,让我们看一下函数的签名。全局函数 borrow_global 返回了对 Resource T 的不可变引用。其签名如下:
native fun borrow_global<T: key>(addr: address): &T;
通过使用此功能,我们可以读取存储在特定地址的 Resource。这意味着该模块(如果实现了此功能)具有读取任何地址上任何 Resource 的能力,当然这里的 Resource 指的是该模块内定义的任何 Resource。
另一个结论:由于 Borrow 检查,你不能返回对 Resource 的引用或对其内容的引用(因为对 Resource 的引用将在函数作用域结束时消失)。
由于 Resource 是不可复制的类型,因此不能在其上使用取值运算符 “*”。

Acquires关键字

还有另一个值得解释的细节:关键字 acquires。该关键字放在函数返回值之后,用来显式定义此函数获取的所有 Resource。我们必须指定所有获取的 Resource,即使它实际上是子函数所获取的 Resource,即父函数必须在其获取列表中包含子函数的获取列表。
acquires 使用方法如下:
fun (<args…>): <ret_type> acquires T, T1 … {

可变借用 borrow_global_mut

要获得对 Resource 的可变引用,请添加 _mut 到 borrow_global 后,仅此而已。让我们添加一个函数,将新的 Item 添加到集合中。

module Collection {

    // ... skipped ...
    
    public fun add_item(account: &signer) acquires T {
        let collection = borrow_global_mut<T>(Signer::address_of(account));
    
        Vector::push_back(&mut collection.items, Item {});
    }
}

对 Resource 的可变引用允许创建对其内容的可变引用。这就是为什么我们可以在此示例中修改内部向量 items 的原因。
native fun borrow_global_mut<T: key>(addr: address): &mut T;

6.3.3 使用和销毁

本章的最后一个函数是 move_from,它用来将 Resource 从账户下取出。我们将实现 destroy 函数,将 Collection 的 T Resource 从账户取出并且销毁它的内容。

// modules/Collection.move
module Collection {

    // ... skipped ...
    
    public fun destroy(account: &signer) acquires Collection {
    
        // account no longer has resource attached
        let collection = move_from<Collection>(Signer::address_of(account));
    
        // now we must use resource value - we'll destructure it
        // look carefully - Items must have drop ability
        let Collection { items: _ } = collection;
    
        // done. resource destroyed
    }
}

Resource 必需被使用。因此,从账户下取出 Resource 时,要么将其作为返回值传递,要么将其销毁。但是请记住,即使你将此 Resource 传递到外部并在脚本中获取,接下来能做的操作也非常有限。因为脚本上下文不允许你对结构体或 Resource 做任何事情,除非 Resource 模块中定义了操作 Resource 公开方法,否则只能将其传递到其它地方。知道这一点,就要求我们在设计模块时,为用户提供操作 Resource 的函数。
move_from 函数签名:
native fun move_from<T: key>(addr: address): T;

7.实例

https://github.com/VishnuKMi/Sample-ERC20-token-using-MOVE

  • 0
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值