Skip to main content

基本概念和术语

Motoko 是为带有行为体(actor)的分布式编程而设计的。

在开始使用 actor 编写分布式处理程序之前,您应该熟悉任何编程语言的一些基本构建块,尤其是 Motoko。为了帮助您入门,本节介绍了以下在本文档的其余部分中使用的关键概念和术语,这些概念和术语对于学习 Motoko 编程至关重要:

  • program(程序)

  • declaration(聲明)

  • expression(表达式)

  • value(值)

  • variable(多变的)

  • type(类型)

如果你有用其他语言编程的经验,或者熟悉现代编程语言理论,你可能已经对这些术语和它们的使用方法感到熟悉。这些术语在Motoko中的使用方式并没有什么特别之处。然而,如果你是编程新手,本指南将逐步介绍这些术语,并使用简化的示例程序,避免使用错误的行为体(actors)或分布式编程。当你有了基本的术语作为基础,你就可以探索语言的更多高级方面。更高级的功能将通过相应的更复杂的例子来说明。

本节涵盖了以下主题:

Motoko程序的语法

打印数字和文本,并使用基础库

声明与表达式

变量的定义域范围

值和评估

变量的类型注释

类型健全性和类型安全的评估

Motoko程序的语法#

每个Motoko程序都是声明和表达式的自由组合,它们的语法类别是不同的,但又是相关的(关于精确的程序语法,请参见语言快速参考指南)。

对于我们在互联网计算机上部署的程序,一个有效的程序由一个actor表达式组成,用特定的语法(关键字actor)介绍,我们在actors和async数据中讨论。

在为该讨论做准备时,我们在本章和Mutable state中讨论了一些程序,这些程序并不是为了成为互联网计算机服务。相反,这些程序代码说明了用于编写这些服务的Motoko片段,并且每个程序都可以(通常)作为一个(非服务)Motoko程序单独运行,可能还有一些打印的终端输出。

本节中的例子使用简单的表达式来说明基本原理,例如数字计算。关于Motoko的全部表达式语法的概述,请参见Lanaguage快速参考。

作为一个起点,下面的代码片断包括两个声明--变量x和y--后面是一个表达式,形成一个单一的程序。

let x = 1;
let y = x + 1;
x * y + x

我们将在下面的讨论中使用这个程序代码的变化。

首先,这个程序的类型是Nat(自然数),当运行时,它的评估结果是(自然数)3的值。

引入一个带括号的块(do { and })和另一个变量(z),我们可以将原程序修改如下:

let z = do {
let x = 1;
let y = x + 1;
x * y + x
};

声明和表达式#

声明介绍了不可变的变量、可变的状态、行为体(actor)、对象、类和其他类型。表达式描述了涉及这些概念的计算。

现在,我们使用声明不可变的变量和计算简单数学的例子程序。

声明与表达式 回顾一下,每个Motoko程序都是声明和表达式的自由组合,它们的句法类别是不同的,但又是相关的。在这一节中,我们用例子来说明它们的区别并适应它们的混合。

回想一下我们的例子程序,首先在上面介绍:

let x = 1;
let y = x + 1;
x * y + x;

实际上,这个程序是一个声明列表,由三个声明组成。

不可变的变量x,通过声明let x = 1;。

不可变的变量y,通过声明let y = x + 1;。

和一个未命名的隐式变量,用来保存最终表达式的值,x * y + x。

这个表达式x * y + x说明了一个更普遍的原则:"每个表达式在必要时都可以被认为是一个声明,因为语言用该表达式的结果值隐含地声明了一个未命名的变量。"

当表达式作为最终声明出现时,这个表达式可以有任何类型。这里,表达式x * y + x的类型是Nat。

没有出现在最后,而是出现在声明列表中的表达式必须具有 unit type()类型。

忽略声明列表中的非单位类型的表达式

我们总是可以通过明确使用ignore来忽略任何未使用的结果值来克服这个单位类型的限制。例如:

let x = 1;
ignore(x + 42);
let y = x + 1;
ignore(y * 42);
x * y + x;

声明和变量替换#

声明可以是相互递归的,但在不是相互递归的情况下,它们允许有一个替换语义。

回顾我们最初的例子:

let x = 1;
let y = x + 1;
x * y + x;

我们可以手动重写上面的程序,将变量的声明值替换为它们各自出现的值。

这样一来,我们就产生了下面的表达式,这也是一个程序:

1 * (1 + 1) + 1

这也是一个有效的程序--类型相同,行为相同(结果值为3)--与原始程序相同。

我们也可以用一个块来形成一个单一的表达式。

从声明到块表达式

上面的许多程序都是由一列声明组成的,就像上面这个例子一样:

let x = 1;
let y = x + 1;
x * y + x

一个声明列表本身不是(立即)一个表达式,所以我们不能(立即)用它的最终值声明另一个变量(3)。

块状表达式。我们可以用匹配的大括号将这个声明列表括起来,形成一个块表达式。块只允许作为控制流表达式的子表达式,如if、循环、case等。在所有其他地方,我们用do { ... }来表示块表达式,以区分块和对象字面。例如,do {}是类型为()的空块,而{}是类型为{}的空记录。

do {
let x = 1;
let y = x + 1;
x * y + x
}

这也是一个程序,但其中声明的变量x和y被私下划入我们介绍的块级定义域中。

这种块级定义域的形式保留了声明列表的自主性和它对变量名称的选择。

声明遵循定义域范围

上面,我们看到嵌套块保留了每个单独的声明列表及其对变量名称的选择的自主性。语言理论家们把这种想法称为定义域范围(lexical scoping)。它意味着变量的作用域可以嵌套,但它们在嵌套的过程中不能相互干扰。

例如,下面这个(更大的、封闭的)程序的评估值是42,而不是2,因为最后一行出现的x和y是指最初的定义,而不是封闭块中后来的定义。

let x = 40; let y = 2;
ignore do {
let x = 1;
let y = x + 1;
x * y + x
};
x + y

其他缺乏定义域范围的语言可能会给这个程序一个不同的含义。然而,现代语言普遍赞成定义域范围,也就是这里给出的意思。

除了数学上的清晰性之外,定义域范围的主要实际好处是安全,以及它在构建组合安全系统中的应用。具体来说,Motoko给出了非常强的组合属性。例如,将你的程序嵌套在一个你不信任的程序中,就不能任意地将你的变量出现与不同的含义重新关联。

值和评估#

一旦一个Motoko表达式收到了程序的(单一)控制线程,它就会急切地进行评估,直到它减少到一个(结果)值。

在这样做的时候,它通常会把控制权传递给子表达式,以及在它放弃环境控制栈的控制权之前传递给子程序。

如果这个表达式从未达到一个值的形式,那么这个表达式就会无限期地进行评估。稍后我们将介绍递归函数和命令式控制流,它们都允许非终结。现在,我们只考虑产生值的终止程序。

在上面的材料中,我们着重于产生自然数的表达式。然而,作为一个更广泛的编程语言,我们在下面简要地总结了其他数值形式。

原始类型

Motoko允许以下的原始值形式:

布尔值(true和false)

整数(...,-2,-1,0,1,2,...);有界和无界的变体。

自然数(0, 1, 2, ...);有界和无界的变体。

文本值 --- 单一编码字符的字符串。

数字:默认情况下,整数和自然数是无界的,不会溢出。相反,它们所使用的表示方法会增长,以适应任何有限的数字。

由于实际的原因,Motoko也包括整数和自然数的有界类型,与默认版本不同。每个有界变量都有一个固定的宽度(8、16、32、64中的一个),并且每个变量都有可能发生 "溢出"。如果发生这种情况,它就是一个错误,会导致程序被捕获。在Motoko中没有未经检查的、未被捕获的溢出,除了在明确定义的情况下,对于明确的包装操作(在操作符中用%字符表示)。该语言提供了原始的内置程序来转换这些不同的数字表示法。

语言的快速参考包含一个完整的原始类型列表。

非原始值

在上述原始值和类型的基础上,该语言允许用户定义的类型,以及下列每一种非原始值形式和相关类型。

Tuples,包括单元值("空元组")。

Arrays,,包括不可变和可变的变体。

Objects,有命名的、无序的字段和方法

Variants,有命名的构造函数和可选的有效载荷值

Function values, 包括可共享的函数。

Async values, 也被称为promises 或 futures。

Error values ,捕捉携带异常和系统故障的有效拦截

我们在后面的章节中讨论这些形式的使用。关于原始值和非原始值的精确语言定义,请参见语言快速参考。

单位类型与无效类型

Motoko没有名为void的类型。在许多情况下,读者可能会因为使用Java或C++等语言而认为返回类型是 "void",我们鼓励他们改用单位类型,写成()。

在实际应用中,与void一样,单位值通常具有零表示成本。

与void类型不同,有一个单位值,但与void的返回值一样,单位值在内部不携带任何数值,因此,它总是携带零信息。

另一种数学方法是把单位值看作一个没有元素的元组--空元组("零元组")。只有一个值具有这些属性,所以它在数学上是唯一的,因此不需要在运行时表示。

自然数

这个类型的成员由通常的值组成---0,1,2,......----,但是,和数学一样,其成员没有被约束在一个特殊的最大值内。相反,这些值的运行时表示法可以容纳任意大小的数字,使其 "溢出"(几乎)不可能。几乎是因为它与程序内存耗尽是同一事件,在极端情况下,某些程序总是会发生这种情况)。

Motoko允许进行人们所期望的通常的算术操作。作为一个说明性的例子,考虑下面的程序:

let x = 42 + (1 * 37) / 12: Nat

该程序的计算结果为 45,也是 Nat 类型。

类型健全#

每个进行类型检查的Motoko表达式,我们都称之为类型检查。Motoko表达式的类型是语言对开发者的一个承诺,即如果执行的话,程序的未来行为。

首先,每个类型检查的程序在评估时都不会出现未定义的行为。也就是说,"类型检查的程序不会出错 "这句话适用于此。对于那些不熟悉这句话深层含义的人来说,这意味着有一个精确的有意义(不含糊)的程序空间,而类型系统强制我们留在这个空间内,所有类型检查的程序都有精确(不含糊)的意义。

此外,类型对程序的结果做出了精确的预测。如果它产生控制,程序将产生一个与原始程序一致的结果值。

在任何一种情况下,程序的静态和动态视图都是由静态类型系统联系起来的,并与静态类型系统相一致。这种一致是静态类型系统的核心原则,并由Motoko作为其设计的一个核心方面提供。

相同的类型系统还强制异步交互在程序的静态和动态视图之间达成一致,并且“幕后”生成的结果消息在运行时永远不会不匹配。这种协议在精神上类似于人们通常期望在类型化语言中的调用者/被调用者参数类型和返回类型协议。

类型注解和变量#

变量将(静态)名称和(静态)类型与(动态)数值联系起来,这些数值只在运行时出现。

在这个意义上,Motoko类型在程序源代码中提供了一种可信的、经编译器验证的文档形式。

考虑一下这个非常简短的程序。

let x : Nat = 1

在这个例子中,编译器推断出表达式1具有Nat类型,而x也具有同样的类型。

在这种情况下,我们可以省略这个注释而不改变程序的含义。

let x = 1

除了一些涉及运算符重载的深奥情况外,类型注释(通常)不会影响程序运行时的意义。

如果省略了类型注释,编译器接受了程序,就像上面的情况一样,程序的意义(行为)与原来一样。

然而,有时编译器需要类型注释来推断其他假设,并检查整个程序。

当它们被添加后,编译器仍然接受该程序,我们知道添加的注释与现有的注释是一致的。

例如,我们可以添加额外的(非必需的)注解,编译器会检查所有的注解和其他推断的事实是否整体上一致。

let x : Nat = 1 : Nat

然而,如果我们试图做一些与我们的注解类型不一致的事情,类型检查器将发出错误提示。

考虑一下这个程序,它没有良好的类型化。

let x : Text = 1 + 1

stdin:1.16-1.21: type error [M0096], expression of type
Nat
cannot produce expected type
Text

类型注释Text与程序的其余部分不一致,因为1+1的类型是Nat而不是Text,而这些类型通过子类型化是不相关的。因此,这个程序没有良好的类型,编译器会发出错误信号(有消息和位置),不会编译或执行它。

错误类型和信息提示#

在数学上,Motoko的类型系统是声明性的,这意味着它独立于任何实现而存在,完全是形式逻辑中的一个概念。同样地,语言定义的其他关键方面(例如,它的执行语义)也存在于实现之外。

然而,为了设计这个逻辑定义,为了实验它,为了练习犯错,我们希望与这个类型系统进行交互,并在这个过程中犯很多无害的错误。

类型检查器的错误信息试图在开发者误解或以其他方式误用类型系统的逻辑时帮助他们,这在本文档中是间接解释的。

这些错误信息会随着时间的推移而发展,为此,我们将不在本文中包括特定的错误信息。相反,我们将试图在其周围的散文中解释每个代码例子。

使用Motoko基础库#

由于各种实际的语言工程原因,Motoko的设计努力减少内置类型和操作。

相反,只要有可能,Motoko基础库就会提供使语言感觉完整的类型和操作。然而,这个基础库仍在开发中,而且仍不完整。

Motoko基础库列出了Motoko基础库中的一些模块,重点是例子中使用的核心功能,这些功能不太可能发生根本性的变化。然而,所有这些基础库的API肯定会随着时间的推移而发生变化(程度不同),特别是,它们的功能和数量会越来越大。

要从基础库中导入,使用import关键字。给出一个要引入的本地模块名称,在这个例子中,D代表 "Debug",以及一个导入声明可以定位导入模块的URL。

import D "mo:base/Debug";
D.print("hello world");

在这种情况下,我们用mo:前缀导入Motoko代码(而不是其他模块形式)。我们指定base/路径,后面是模块的文件名Debug.mo减去其扩展名。

使用Debug.print和debug_show打印#

上面,我们使用Debug.mo库中的函数print来打印文本字符串。

print: Text -> ()

函数print接受一个文本字符串(Text类型)作为输入,并产生单位值(单位类型,或())作为其输出。

因为单位值不携带任何信息,所有单位类型的值都是相同的,所以print函数实际上并没有产生一个有趣的结果。取而代之的是它的一个副作用。函数print的作用是将文本字符串以人类可读的形式输出到输出终端。有副作用的函数,如发射输出,或修改状态,通常被称为不纯函数。只有返回值,没有进一步副作用的函数被称为纯函数。我们将在下面详细讨论返回值(单元值),并将其与void类型联系起来,供更熟悉该概念的读者参考。

最后,我们可以为调试目的将大多数Motoko值转换为人类可读的文本字符串,而不需要手工编写这些转换结果。

debug_show基元允许将一大类值转换为Text类型的值。

例如,我们可以将一个三联体(类型为(Text, Nat, Text))转换为调试文本,而无需自己编写一个自定义的转换函数:

import D "mo:base/Debug";
D.print(debug_show(("hello", 42, "world"))

使用这些文本转换,我们可以在实验我们的程序时打印大多数Motoko数据。

适应不完整的代码#

有时,在编写程序的过程中,我们想运行一个不完整的版本,或者一个或多个执行路径丢失或根本无效的版本。

为了适应这些情况,我们使用基础Prelude库中的xxx、yi和unreachable函数,解释如下。每个函数都包裹着一个一般的陷阱机制,下面进一步解释。

使用短期漏洞#

短期漏洞永远不会被提交到源码库中,而且只存在于一个开发会话中,对于一个仍在编写程序的开发者来说。

假设早些时候,人们已经导入了Prelude库,如下所示:

import P "mo:base/Prelude";

开发者可以用下面的表达式来填补任何缺失。

P.xxx()

结果总是在编译时进行类型检查,并且总是在运行时进行捕获,如果以及当这个表达式曾经执行时。

记录长期漏洞#

根据惯例,长期的漏洞可以被认为是 "尚未实现"(yi)的特性,并通过Prelude模块的类似函数来标记。

P.nyi()

记录无法到达的代码路径#

与上述情况相比,有些代码将永远不会被填充,因为它永远不会被评估,假设程序的不变量的内部逻辑是一致的。

要记录一个逻辑上不可能的代码路径,或者说是不可达的,可以使用基础库函数unreachable。

P.unreachable()

就像上面的情况一样,这个函数在所有情况下都进行类型检查,当评估时,在所有情况下都进行陷阱。

执行陷阱停止程序#

上面的每一种形式都是对assert原语的总是失败的使用的一个简单包装:

assert false

动态地,我们把这种程序停止行为称为程序(生成的)陷阱,我们说当程序执行这段代码时就会出现陷阱。它将停止进一步的进展。

科普小知识#

有界函数