Cairo编程语言

由Cairo社区和它的贡献者们创作。特别感谢Starkware通过OnlyDustVoyager支持这本书的创作。 中文版由StarknetAstro翻译。

本版本假设你使用的是 Cairo v2.2.0,请参阅第一章的 "安装 "部分来安装或更新 Cairo。

Last change: 2023-09-15, commit: c0f1233

前言

2020年,StarkWare发布了Cairo 0,这是一种支持可验证计算的图灵完备编程语言。Cairo最初是一种汇编语言,后来逐渐变得更具表现力。因为Cairo 0.x是一种低级语言,没有完全抽象出为程序的执行建立证明所需的底层加密原语,所以最初的学习曲线很陡峭。

随着Cairo 1的发布,由于尽可能地对Cairo架构底层的不可变内存模型进行了抽象,开发者的体验有了很大的改善。受到Rust的强烈启发,Cairo 1是为了帮助你无需具体了解其底层架构就创建可证明的程序而生,这样你就可以专注于程序本身,这提高Cairo程序的整体安全性。在Rust虚拟机的支持下,Cairo程序的执行速度现在快得惊人,允许你在不影响性能的情况下建立一个广泛的测试套件。

想在Starknet上部署合约的区块链开发者将使用Cairo编程语言来编写他们的智能合约。这允许Starknet操作系统生成交易的执行跟踪,以供证明者生成证明,然后在更新Starknet的状态根之前在Ethereum L1上由验证者验证该证明。

然而,Cairo不仅仅适用于区块链开发者。作为一种通用的编程语言,它可以用于任何需要在一台计算机上生成证明并在其他硬件要求较低的机器上验证的计算场景。

本书是为对编程概念有基本了解的开发人员设计的。它是一本友好而平易近人的书本,旨在帮助你提高你的Cairo知识水平,同时也帮助你提高你的通用编程技能。因此,请潜下心来,并准备好学习所有关于Cairo的知识!

— Cairo社区

Last change: 2023-04-16, commit: 4f921ff

介绍

什么是Cairo?

Cairo是一种为同名的虚拟CPU设计的编程语言。这种虚拟处理器的独特之处在于,它不是为我们世界的物理法则而创造的,而是为密码学法则而创造的,这使得它能够有效地证明在其上运行的任何程序。这意味着你可以在一台你不信任的机器上进行耗时的操作,而在一台更便宜的机器上非常迅速地检查结果。 虽然Cairo 0曾经直接编译成CASM,即Cairo CPU汇编,但Cairo 1是一种更高级的语言。它首先编译到Cairo的一个中间表示,Sierra,接着会编译成CASM的一个安全子集。Sierra的意义在于确保你的CASM总是可以证明的,即使计算失败。

你能用它做什么?

Cairo允许你在不被信任的机器上计算值得信任的值。一个主要的用例是Starknet,这是一个针对Ethereum扩展的解决方案。以太坊是一个去中心化的区块链平台,它可以创建去中心化的应用程序,用户和d-app之间的每一次交互都会被所有参与者验证。Starknet是一个建立在以太坊之上的Layer 2。不同于以太坊让网络的所有参与者来验证所有的用户的交互,Starknet只让一个被称为验证者(prover)的节点来执行程序,并生成计算正确的证明。这些证明再由以太坊智能合约来验证,与执行交互本身相比,需要的计算能力要少得多。这种方法增加了吞吐量和降低交易成本,但保留了以太坊的安全性。

与其他编程语言有什么区别?

Cairo与传统的编程语言,尤其是在额外的性能开销和语言的主要优势方面,有很大不同。你的程序可以通过两种不同的方式执行:

  • 当被证明器(prover)执行时,它与其他的编程语言类似。因为Cairo是虚拟化的,而且其操作并未设计为效率最大化,因此可能会导致一些额外性能开销,但这并不是最需要优化的部分。

  • 当生成的证明被验证器验证时,情况就有点不同了。这一步必须是尽可能的少消耗计算资源,因为它有可能需要在许多非常慢的机器上进行验证。幸运的是,验证比计算更快,而且Cairo有一些独特的优势,可以进一步提高验证速度。一个值得注意的是非确定性,这是一个将在本书后面详细介绍的话题。其设计理念是,理论上你无需在计算时和验证时使用同一种算法(译注:即在验证时你可以使用比生成证明时更快的算法来减少时间消耗)。目前开发者还不能编写自定义的非确定性代码,但标准库利用非确定性来提高性能。例如,在Cairo中对一个数组进行排序的成本与复制它的成本相同,这是因为验证器只是检查它是否被排序而不是真的对数组进行排序,所以可以减少计算资源消耗。

使该语言与众不同的另一个方面是其内存模型。在Cairo中,内存访问是不可改变的,这意味着一旦一个值被写入内存,它就不能被改变。Cairo 1提供了帮助开发者处理这些约束的抽象,但它并没有完全模拟可变性。因此,开发人员必须仔细考虑如何在他们的程序中管理内存和数据结构以优化性能。

参考文献

Last change: 2023-06-07, commit: 8361a46

入门

Last change: 2023-07-04, commit: f915c6c

安装

只需下载 Scarb,即可安装 Cairo。Scarb 将 Cairo 编译器和 Cairo 语言服务器捆绑在一个易于安装的软件包中,这样你就可以立即开始编写 Cairo 代码了。

Scarb同样是Cairo的软件包管理器,在很大程度上受到Cargo的启发,Rust的构建系统和软件包管理器。

Scarb会为你处理很多任务,比如构建你的代码(纯Cairo或Starknet合约),为你下载你的代码所依赖的库并构建他们,以及为VSCode Cairo 1扩展提供LSP支持。

如果你使用 Scarb 启动项目,管理外部代码和依赖关系就会容易得多。

让我们从安装Scarb开始。

安装Scarb

要求

Scarb需要PATH环境变量里有一个Git可执行文件。

安装

要安装 Scarb,请参阅 安装说明。我们强烈建议您通过 asdf 来安装Scarb 。 这一个 CLI 工具,可以按项目管理多个语言运行时版本。 这将确保您用于处理项目的 Scarb 版本始终与项目设置中定义的版本匹配,从而避免导致版本不匹配的问题。 否则,您只需在终端中运行以下命令,然后按照屏幕上的说明进行操作即可。这将安装 Scarb 的最新稳定版本。

curl --proto '=https' --tlsv1.2 -sSf https://docs.swmansion.com/scarb/install.sh | sh
  • 在新的终端Session里输入以下命令来验证是否安装成功, 终端应该同时打印出Scarb 和 Cairo 的版本号, 比如:

    $ scarb --version
    scarb 2.3.0-rc1 (58cc88efb 2023-08-23)
    cairo: 2.2.0 (https://crates.io/crates/cairo-lang-compiler/2.2.0)
    sierra: 1.3.0
    

安装 VSCode 扩展

Cairo 有一个 VSCode 扩展,它提供了语法突出显示、代码完成和其他有用的功能。您可以从 VSCode Marketplace安装它。 安装后,进入扩展设置,并确保勾选Enable Language ServerEnable Scarb选项。

Last change: 2023-11-05, commit: 0c9ab3d

Hello, World

现在你已经通过Scarb安装了Cairo,是时候编写你的第一个Cairo程序了。 在学习一门新语言时,传统的做法是写一个小程序 将文字Hello, world!打印到屏幕上,所以我们在这里也要这样做!

注意:本书假定对你命令行有基本的了解。Cairo对 对你用什么编辑代码或使用什么开发工具或把你的代码放在哪没有特殊要求,所以 如果你喜欢使用集成开发环境(IDE)而不是 命令行,你完全可以使用你喜欢的IDE。Cairo团队已经开发了 Cairo语言的VSCode扩展,你可以用它来获得来自 语言服务器和代码高亮。参见附录D来获取更多细节。

创建一个项目目录

你首先要做一个目录来存储你的Cairo代码。对于Cairo来说,你的代码放在哪里并不重要。 但对于本书中的练习和项目来说,我们建议在你的主目录下建立一个 cairo_projects 目录,并将你的所有项目存放在这里。

打开一个终端,输入以下命令,建立一个 cairo_projects 目录 并在 cairo_projects 目录下为 "Hello, world!"项目建立一个目录。

注意:从现在起,对于书中显示的每个示例,我们都假定 你都是在 Scarb 项目目录中进行编码。如果您没有使用 Scarb,并试图从其他目录运行示例,可能需要相应调整命令或创建一个 Scarb 项目。

对于Linux、macOS和Windows上的PowerShell,输入:

mkdir ~/cairo_projects
cd ~/cairo_projects

对于Windows CMD,请输入以下内容:

> mkdir "%USERPROFILE%\cairo_projects"
> cd /d "%USERPROFILE%\cairo_projects"

用Scarb创建一个项目

让我们使用 Scarb 创建一个新项目。

导航到你的项目目录(或你决定放代码的地方)。然后运行以下命令:

scarb new hello_world

它创建了一个新的目录和项目,名为hello_world。我们把我们的项目命名为hello_world,因此Scarb会在同名的目录下创建它的文件。

cd hello_world命令进入hello_world目录。你会看到Scarb已经为我们生成了两个文件和一个目录:一个Scarb.toml文件和一个src目录,里面有一个lib.cairo文件。

它还初始化了一个新的Git仓库和一个.gitignore文件

注意:Git是一个常见的版本控制系统。你可以通过使用--vcs标志停止使用版本控制系统。 运行scarb new -help以查看可用选项。

在你选择的文本编辑器中打开 Scarb.toml 。它看起来应该与示例1-2中的代码相似。

文件名:Scarb.toml

[package]
name = "hello_world"
version = "0.1.0"

# See more keys and their definitions at https://docs.swmansion.com/scarb/docs/reference/manifest

[dependencies]
# foo = { path = "vendor/foo" }

示例1-2:由scarb new生成的Scarb.toml的内容

这个文件是TOML(Tom's Obvious, Minimal Language)的格式,是Scarb的配置文件格式。

第一行,[package],是一个章节标题,表示下面的语句是在配置一个包。随着我们向这个文件添加更多的信息,我们将添加其他章节。

接下来的两行设置了Scarb在编译你的程序时需要的配置信息:名称和要使用的Scarb版本。

最后一行,[dependencies],是一个章节的开始,该章节供你列出你的项目的所有依赖。在Cairo中,代码包被称为crate。在这个项目中,我们不需要任何其他的crate。

注意:如果你要为Starknet构建合约,你需要添加Scarb文档中提到的starknet依赖关系。

Scarb创建的另一个文件是src/lib.cairo,让我们删除其中所有的内容,放入以下内容,我们将在后面解释原因。

mod hello_world;

然后创建一个名为src/hello_world.cairo的新文件,并将以下代码放入其中:

文件名: src/hello_world.cairo

use debug::PrintTrait;
fn main() {
    'Hello, World!'.print();
}

我们刚刚创建了一个名为lib.cairo的文件,其中包含一个模块声明,引用了另一个名为 hello_world的模块,以及包含 hello_world模块的实现细节的文件hello_world.cairo

Scarb要求你的源文件位于src目录中。

顶层项目目录是为README文件、许可证信息、配置文件和任何其他与代码无关的内容保留的。 Scarb确保所有项目组件都有一个指定的位置,维持一个结构化的组织架构。

如果你启动了一个不使用Scarb的项目,你可以把它转换成一个使用Scarb的项目。将项目代码移到src目录下,并创建一个适当的Scarb.toml文件。

编译Scarb项目

在你的hello_world目录中,通过输入以下命令来编译你的项目:

$ scarb build
   Compiling hello_world v0.1.0 (file:///projects/Scarb.toml)
    Finished release target(s) in 0 seconds

这个命令在target/dev中创建了一个sierra文件,现在我们先忽略sierra文件。

如果你正确安装了Cairo,你应该能够运行并看到以下输出:

$ scarb cairo-run
running hello_world ...
[DEBUG] Hello, World!                   (raw: 0x48656c6c6f2c20776f726c6421

Run completed successfully, returning []

无论你的操作系统如何,终端里都应该打印出字符串Hello, world!

如果 "Hello,world!"打印出来了,那么恭喜你!你已经正式写了一个Cairo程序! 你已经成为了一名Cairo程序员--欢迎!

解析Cairo程序

让我们详细回顾一下这个 "Hello,world!"程序。这里有拼图的第一部分:

fn main() {

}

这些代码定义了一个名为 main的函数。main函数很特别:它总是每个可执行的Cairo程序中运行的第一个代码。 这里,第一行声明了一个名为 main的函数,没有参数,也不返回。如果有参数,它们会被放在括号()里。

函数主体被包裹在"{}"中。Cairo要求在所有的函数体周围加上大括号 将开头的左大括号与函数声明放在同一行是很好的编码风格。 别忘了在它们中间加一个空格。

注意:如果你想在Cairo项目中使用一个统一代码风格标准,你可以 使用自动格式化工具scarb fmt来将你的代码格式化为 特定的代码风格(更多关于scarb fmt的信息见 附录D)。Cairo团队已经将这个工具 包含在标准的Cairo发行版中,就像cairo-run一样,所以它应该已经被 已经安装在你的计算机上了!

在主函数声明之前,use debug::PrintTrait;一行负责导入另一个模块中定义的项目。在这个例子中,我们从Cairo核心库中导入了PrintTrait项目。通过这样做,我们获得了在可以打印的数据类型上使用print()方法的能力。

main函数的主体包含以下代码:

    'Hello, World!'.print();

这一行完成了这个小程序的所有工作:将文本打印到屏幕上。这里有四个重要的细节需要注意。

首先,Cairo的风格是用四个空格缩进,而不是用制表符。

第二,调用的print()函数是来自traitPrintTrait的一个方法。这个trait是从Cairo核心库中导入的,它定义了如何将不同数据类型的值打印到屏幕上。在我们的例子中,我们的文本被定义为 "short string",这是一个ASCII字符串,可以适合Cairo的基本数据类型,即felt252类型。通过调用'Hello, world!'.print(),我们正在调用PrintTraittrait的felt252实现的print()方法。

第三,看见了'Hello, world!'短字符串么。我们把这个短字符串作为一个参数传递给print(),因此短字符串被打印到屏幕上。

第四,我们用分号(;)来结束这一行,这表示这个表达式已经结束,下一个表达式准备开始。大多数Cairo的代码行以分号结束。

运行测试

要运行与特定软件包相关的所有测试,可以使用scarb test命令。 它本身并不是一个测试运行工具,而是将测试工作委托给所选的测试工具。Scarb预装了scarb cairo-test扩展,它捆绑了Cairo的本地测试运行器。它也是scarb test默认使用的测试运行器。 要使用第三方测试运行器,请参考Scarb文档

测试函数用#[test]属性标记,运行scarb test将运行代码库中src/目录下的所有测试函数。

├── Scarb.toml
├── src
│   ├── lib.cairo
│   └── file.cairo

Scarb 项目结构示例

让我们回顾一下到目前为止我们所了解到的关于Scarb的情况:

  • 我们可以使用 scarb new 创建项目。
  • 我们可以使用 scarb build 生成编译后的 Sierra 代码。
  • 我们可以在 Scarb.toml 中定义自定义脚本,并使用 scarb run 命令调用它们。
  • 我们可以使用 scarb test 命令运行测试。

使用Scarb的另一个好处是,无论你在哪个操作系统上工作,命令都是一样的。所以我们将不再提供Linux和macOS与Windows的具体说明。

总结

你的Cairo之旅已经有了一个很好的开始!在本章中,你已经学会了如何:

  • 安装最新稳定版本的 Cairo
  • 直接使用 scarb 编写并运行 “Hello, world!”程序
  • 使用 Scarb 的默认设置创建并运行一个新项目
  • 使用 scarb test 命令执行测试

是时候通过建立更多实用程序来熟悉阅读和编写Cairo代码了。

Last change: 2023-10-03, commit: eafa093

常见的编程概念

本章涵盖了几乎所有编程语言中出现的概念,以及它们在Cairo的工作原理。许多编程语言的核心都有很多共同点。本章介绍的概念没有一个是Cairo独有的,但我们会在Cairo的背景下讨论它们,并解释使用这些概念的惯例。

具体来说,你将学习到变量、基本类型、函数、注释和控制流。这些基础将出现在每个Cairo程序中,尽早学习它们将给你一个强大的核心来开启旅程。

Last change: 2023-04-04, commit: 3efff5c

变量和可变性

Cairo使用一个不可改变的内存模型,这意味着一旦一个内存单元被写入就不能被覆盖, 只能被读出。为了反映这种不可变的内存模型,变量在Cairo中默认是不可变的。 然而,该语言对这种模式进行了抽象,让你可以选择变量是否可变。让我们来 探讨一下Cairo是如何以及为什么要强行规定变量不可变,以及如何使变量成为可变的。

When a variable is immutable, once a variable is bound to a value, you can’t change that variable. To illustrate this, generate a new project called variables in your cairo_projects directory by using scarb new variables.

然后,在你新的 variables 目录下,打开 src/lib.cairo 并将其替换为下面的代码,这段代码还不会被编译:

文件名: src/lib.cairo

use debug::PrintTrait;
fn main() {
    let x = 5;
    x.print();
    x = 6;
    x.print();
}

保存并使用scarb cairo-run运行该程序。你应该收到一条关于不可变性的错误,如下所示:

error: Cannot assign to an immutable variable.
 --> lib.cairo:5:5
    x = 6;
    ^***^

Error: failed to compile: src/lib.cairo

这个例子显示了编译器如何帮助你发现程序中的错误。 编译错误可能令人沮丧,但实际上它们只意味着你的程序 还没有安全地完成你想做的事情;它们并不意味着你不是一个好的程序员。 即使有经验的Caironautes仍然会遇到编译错误。

你收到的错误信息是 Cannot assign to an immutable variable.。 因为你试图给不可变的x变量分配第二个值。

It’s important that we get compile-time errors when we attempt to change a value that’s designated as immutable because this specific situation can lead to bugs. If one part of our code operates on the assumption that a value will never change and another part of our code changes that value, it’s possible that the first part of the code won’t do what it was designed to do. The cause of this kind of bug can be difficult to track down after the fact, especially when the second piece of code changes the value only sometimes.

Cairo, unlike most other languages, has immutable memory. This makes a whole class of bugs impossible, because values will never change unexpectedly. This makes code easier to reason about.

But mutability can be very useful, and can make code more convenient to write. Although variables are immutable by default, you can make them mutable by adding mut in front of the variable name. Adding mut also conveys intent to future readers of the code by indicating that other parts of the code will be changing the value associated to this variable.

However, you might be wondering at this point what exactly happens when a variable is declared as mut, as we previously mentioned that Cairo's memory is immutable. The answer is that the value is immutable, but the variable isn't. What value the variable points to can be changed. Assigning to a mutable variable in Cairo is essentially equivalent to redeclaring it to refer to another value in another memory cell, but the compiler handles that for you, and the keyword mut makes it explicit. Upon examining the low-level Cairo Assembly code, it becomes clear that variable mutation is implemented as syntactic sugar, which translates mutation operations into a series of steps equivalent to variable shadowing. The only difference is that at the Cairo level, the variable is not redeclared so its type cannot change.

例如,让我们把 src/lib.cairo 改为以下内容:

文件名: src/lib.cairo

use debug::PrintTrait;
fn main() {
    let mut x = 5;
    x.print();
    x = 6;
    x.print();
}

当我们现在运行该程序时,我们得到了这个结果:

$ scarb cairo-run
[DEBUG]                                (raw: 5)

[DEBUG]                                (raw: 6)

Run completed successfully, returning []

当使用 mut 时,我们将在x绑定的值从5改为 6 。 最终,决定是否使用可变性取决于你自己以及你认为在特定情况下什么是最清楚的。

常量

类似于不可变变量,常量 (constants) 是绑定到一个名称的不允许改变的值,不过常量与变量还是有一些区别。

首先,不允许对常量使用 mut。常量不仅仅是默认不可变—它总是不可变。声明常量使用 const 关键字而不是 let,并且 必须 注明值的类型。在下一部分,“数据类型”中会介绍类型和类型注解,现在无需关心这些细节,记住总是标注类型即可。

常量可以在任何作用域中声明,包括全局作用域,这在一个值需要被很多部分的代码用到时很有用。

最后一个区别是,常量只能被设置为常量表达式,而不可以是其他任何只能在运行时计算出的值。目前只支持字面常量。

下面是一个声明常量的例子:

const ONE_HOUR_IN_SECONDS: u32 = 3600;

Cairo的常量命名规则是使用所有大写字母,单词之间使用下划线。

在声明它的作用域之中,常量在整个程序生命周期中都有效, 此属性使得常量可以作为多处代码使用的全局范围的固定数值,例如一个游戏中玩家可以获取的最高分,或者光速。

将遍布于应用程序中的硬编码值声明为常量,能帮助后来的代码维护人员了解值的意图。 如果将来需要修改硬编码值,也只需修改汇此处的硬编码值。

隐藏

变量隐藏指的是声明一个与之前变量同名的新的变量。 当Caironautes 说第一个变量被第二个变量所 隐藏 时,这意味着当您使用变量的名称时,编译器看到的是第二个变量。 实际上,第二个变量“遮蔽”了第一个变量,此时任何使用该变量名的行为中都会视为是在使用第二个变量, 直到第二个变量自己也被隐藏或第二个变量的作用域结束。 可以用相同变量名称来隐藏一个变量,以及重复使用 let 关键字来多次隐藏,如下所示:

文件名: src/lib.cairo

use debug::PrintTrait;
fn main() {
    let x = 5;
    let x = x + 1;
    {
        let x = x * 2;
        'Inner scope x value is:'.print();
        x.print()
    }
    'Outer scope x value is:'.print();
    x.print();
}

这个程序首先将 x 绑定到值 5 上。接着通过 let x = 创建了一个新变量 x ,获取初始值并加 1 ,这样 x 的值就变成 6 了。然后,在使用花括号创建的内部作用域内,第三个 let 语句也隐藏了 x 并创建了一个新的变量,将之前的值乘以 2x 得到的值是 12。当该作用域结束时,内部 shadowing 的作用域也结束了, x 又返回到 6。运行这个程序,它会有如下输出:

scarb cairo-run
[DEBUG] Inner scope x value is:         (raw: 7033328135641142205392067879065573688897582790068499258)

[DEBUG]
                                       (raw: 12)

[DEBUG] Outer scope x value is:         (raw: 7610641743409771490723378239576163509623951327599620922)

[DEBUG]                                (raw: 6)

Run completed successfully, returning []

隐藏与将变量标记为 mut 是有区别的。 当不小心尝试对变量重新赋值时,如果没有使用 let 关键字,就会导致编译时错误。 通过使用 let,我们可以用这个新值进行一些计算,不过计算完之后变量仍然是不可变的。

mut 与隐藏的另一个区别是,当再次使用 let 时,实际上创建了一个新变量,我们可以改变值的类型,并且复用这个名字。 如前所述,变量隐藏和可变量在较低层次上是等同的。 唯一的区别是,通过隐藏变量,即使你改变它的类型。编译器也不会检测到错误 例如,假设我们的程序在u64felt252 类型之间进行转换。

use debug::PrintTrait;

fn main() {
    let x: u64 = 2;
    x.print();
    let x: felt252 = x.into(); // converts x to a felt, type annotation is required.
    x.print()
}

第一个 x 变量的类型是 u64,而第二个 x 变量的类型是 felt252。 因此,隐藏使我们不必想出不同的名字,例如 x_u64x_felt252; 相反,我们可以重新使用更简单的 x 名称。然而,如果我们试图使用 mut 来实现,我们会得到一个编译时错误,如下所示:

use debug::PrintTrait;

fn main() {
    let mut x: u64 = 2;
    x.print();
    x = 100_felt252;
    x.print()
}

这个错误表示我们预期得到一个 u64 类型(原始类型),但实际得到了不同的类型:

$ scarb cairo-run
error: Unexpected argument type. Expected: "core::integer::u64", found: "core::felt252".
 --> lib.cairo:9:9
    x = 100_felt252;
        ^*********^

Error: failed to compile: src/lib.cairo

现在我们已经了解了变量如何工作,让我们看看变量可以被赋予的其他数据类型。

Last change: 2023-12-08, commit: 7c6a72a

数据类型

在 Cairo 中,每一个值都属于某一个数据类型(data type), 这告诉 Cairo 它被指定为何种数据,以便明确数据处理方式。我们将看到两类数据类型子集:标量(scalar)和复合(compound)。

记住,Cairo 是静态类型(statically typed)语言,也就是说在编译时就必须知道所有变量的类型。根据值及其使用方式,编译器通常可以推断出我们想要用的类型。 在可能存在多种类型的情况下,我们可以使用一种称为“类型转换”的方法,在其中指定所需的输出类型。

fn main() {
    let x: felt252 = 3;
    let y: u32 = x.try_into().unwrap();
}

你会看到其它数据类型的各种类型注解。

标量类型

一个 标量 (scalar)类型表示一个单一的值。Cairo 有三种主要的标量类型: felts、整数(integers)和布尔值(booleans)。你可能在其他语言中见过它们。 让我们深入了解它们在 Cairo 中是如何工作的。

Felt 类型

在 Cairo 中,如果你没有指定一个变量或参数的类型,它的类型默认为一个字段元素,由关键字 felt252 表示。在 Cairo 中,当我们说 "一个字段元素 "时,我们指的是范围为 0 <= x < P 的整数、 其中 P 是一个非常大的素数,目前为 P = 2^{251} + 17 * 2^{192}+1。当加减乘时,如果结果超出了素数的指定范围,就会发生溢出,然后再加上或减去 P 的适当倍数,使结果回到范围内(也就是说,结果是以 P 为模数计算的)。

整数和字段元素之间最重要的区别是除法:字段元素的除法(以及 Cairo 的除法)与普通 CPU 的除法不同,其中整数除法 x / y 被定义为[x/y], 其中商的整数部分被返回(所以你得到7 / 3 = 2),它可能满足或不满足方程式 (x / y) * y == x,这取决于 x 是否能被 y 除。

在 Cairo,x/y 的结果被定义为总是满足方程式 (x / y) * y == x。如果 y 作为整数可以整除 x ,你将得到 Cairo 的预期结果(例如�����6 / 2 确实会得到3)。 但是当 y 不能整除 x 时,你可能会得到一个令人惊讶的结果: 例如,由于 2 * ((P+1)/2) = P+1 ≡ 1 mod[P],在 Cairo 中 1 / 2 的值是 (P+1)/2(而不是0或0.5),因为它满足上述公式。

整数类型

所谓的felt252 类型是一个基本类型,是创建核心库中所有类型的基础。 然而,我们强烈建议程序员尽可能使用整数类型而不是 felt252 类型,因为 integer 类型带有额外的安全功能, 对代码中的潜在漏洞提供额外保护,如溢出检查。通过使用这些整数类型,程序员可以确保他们的程序更加安全,不容易受到攻击或其他安全威胁。 一个 integer 是一个没有小数部分的数字。这个类型声明指出了该类型可以用来存储整数的比特位。 表3-1显示了Cairo中内建的整数类型。我们可以使用这些变体中的任何一种来声明一个整数值的类型。

表格3-1: Cairo 的整数类型

长度无符号
8-bitu8
16-bitu16
32-bitu32
64-bitu64
128-bitu128
256-bitu256
32-bitusize

每个变量都有一个明确的大小。注意,现在,usize 类型只是 u32 的别名;然而,当将来 Cairo 可以被编译为 MLIR 时,它可能会很有用。 由于变量是无符号的,它们不能包含一个负数。这段代码会引起程序出现错误:

fn sub_u8s(x: u8, y: u8) -> u8 {
    x - y
}

fn main() {
    sub_u8s(1, 3);
}

前面提到的所有整数类型都适合felt252,但u256除外,它需要存储 4 个以上的位。在内部原理中,u256基本上是一个包含 2 个字段的结构体:u256 {low: u128, high: u128}

你可以用表 3-2 中的任何一种形式编写数字字面值。 请注意可以是多种数字类型的数字字面值允许使用类型后缀, 例如像 57_u8 这样指定类型。

表3-2:Cairo 的整数类型字面值

数字字面值例子
Decimal(十进制)98222
Hex (十六进制)0xff
Octal (八进制)0o04321
Binary (二进制)0b01

那么,你如何知道要使用哪种类型的整数?试着估计你用的 int 的最大值,然后选择合适的大小。 usize 的主要是用在为某种集合做索引时。

数值运算

Cairo 支持所有整数类型的基本数学运算: 加法、减法、乘法、除法和取余。 整数除法将向最接近0的整数截断。以下代码展示了如何在 let 语句中使用它们:

fn main() {
    // addition
    let sum = 5_u128 + 10_u128;

    // subtraction
    let difference = 95_u128 - 4_u128;

    // multiplication
    let product = 4_u128 * 30_u128;

    // division
    let quotient = 56_u128 / 32_u128; //result is 1
    let quotient = 64_u128 / 32_u128; //result is 2

    // remainder
    let remainder = 43_u128 % 5_u128; // result is 3
}

这些语句中的每个表达式使用了一个数学运算符并计算出了一个值,然后绑定给一个变量。

附录 B 包含了一个Cairo中所有操作符的列表。

布尔类型

正如其他大部分编程语言一样,Cairo 中的布尔类型有两个可能的值:truefalse。布尔型的大小为一个 felt252。 布尔类型在 Cairo 中是用 bool 来指定的。例如:

fn main() {
    let t = true;

    let f: bool = false; // with explicit type annotation
}

使用布尔值的主要方式是通过条件语句,如 if 表达式。 我们将在"控制流”部分介绍 if 表达式在 Cairo 中的工作原理。

短字符串类型

Cairo 没有字符串(String)的原生类型,但你可以在 felt252 中存储短字符,形成我们所说的 "短字符串"。一个短字符串的最大长度为 31 个字符。这是为了确保它能装入一个 felt (一个 felt 是252位,一个 ASCII 字符是 8 位)。 可以通过把值放在单引号之间来声明短字符串,下面是一些例子:

fn main() {
    let my_first_char = 'C';
    let my_first_string = 'Hello world';
}

类型转换

在 Cairo 中,你可以使用 TryIntoInto 特性提供的 try_intointo 方法在标量类型之间进行显式类型转换。

try_into 方法允许在目标类型可能不适合源值时进行安全的类型转换。请记住,try_into 会返回一个 Option<T> 类型,你需要解开(unwrap)这个类型来访问新的值。

另一方面,当转换必然成功时,如源类型小于目标类型时,into 方法可用于类型转换。

为了进行转换,在源值上调用 var.into()var.try_into() 来将其转换为另一种类型。新变量的类型必须被明确定义,如下面的例子所示。

fn main() {
    let my_felt252 = 10;
    // Since a felt252 might not fit in a u8, we need to unwrap the Option<T> type
    let my_u8: u8 = my_felt252.try_into().unwrap();
    let my_u16: u16 = my_u8.into();
    let my_u32: u32 = my_u16.into();
    let my_u64: u64 = my_u32.into();
    let my_u128: u128 = my_u64.into();
    // As a felt252 is smaller than a u256, we can use the into() method
    let my_u256: u256 = my_felt252.into();
    let my_usize: usize = my_felt252.try_into().unwrap();
    let my_other_felt252: felt252 = my_u8.into();
    let my_third_felt252: felt252 = my_u16.into();
}

元组类型

元组 是一个将多个其他类型的值组合进一个复合类型的主要方式。 元组长度固定:一旦声明,其长度不会增大或缩小。

我们使用包含在圆括号中的逗号分隔的值列表来创建一个元组。 元组中的每一个位置都有一个类型,而且这些不同值的类型也不必是相同的。 这个例子中使用了可选的类型注解:

fn main() {
    let tup: (u32, u64, bool) = (10, 20, true);
}

tup 变量绑定到整个元组上,因为元组是一个单独的复合元素。 为了从元组中获取单个值,可以使用模式匹配(pattern matching) 来解构(destructure)元组值,像这样:

use debug::PrintTrait;
fn main() {
    let tup = (500, 6, true);

    let (x, y, z) = tup;

    if y == 6 {
        'y is six!'.print();
    }
}

程序首先创建了一个元组并绑定到 tup 变量上。 接着使用了 let 和一个模式将 tup 分成了三个不同的变量, xyz。这叫做解构(destructuring), 因为它将一个元组拆成了三个部分。最后,程序打印出了 y 的值, 也就是 6

我们也可以同时用 value 和 name 来声明元组。 比如说:

fn main() {
    let (x, y): (felt252, felt252) = (2, 3);
}

unit类型 ()

一个 unit 类型 是一个只有一个值 () 的类型。 它由一个没有元素的元组来表示。 它的大小总是为零,并且它在编译后的代码中一定不存在。

Last change: 2023-09-28, commit: f04c9d6

函数

函数在 Cairo 代码中非常普遍。你已经见过语言中最重要的函数之一: main 函数,它是很多程序的入口点。 你也见过 fn 关键字,它用来声明新函数。

Cairo代码使用 蛇形命名法( snake case 作为函数和变量名称的常规样式。 所有的字母都是小写的,并以下划线分隔单词。 这里有一个程序,包含一个函数定义的例子:

use debug::PrintTrait;

fn another_function() {
    'Another function.'.print();
}

fn main() {
    'Hello, world!'.print();
    another_function();
}

在 Cairo 中,我们通过输入 fn 和函数名称以及一组括号来定义一个函数。大括号告诉编译器函数体的开始和结束位置。

可以使用函数名后跟圆括号来调用我们定义过的任意函数。 因为程序中已定义 another_function 函数,所以可以在 main 函数中调用它。 注意,源码中 another_function 定义在 main 函数 之前 ;当然我们也可以定义在其之后。 Cairo 不关心函数定义所在的位置,只要函数被调用时出现在调用之处可见的作用域内就行。

让我们用 Scarb 启动一个名为 functions 的新项目来进一步探索函数。 更进一步。把 another_function 的例子写入 src/lib.cairo 中并运行它。你应该看到以下输出:

$ scarb cairo-run
[DEBUG] Hello, world!                (raw: 5735816763073854953388147237921)
[DEBUG] Another function.            (raw: 22265147635379277118623944509513687592494)

这几行是按照它们在 main 函数中出现的顺序执行的。 首先打印 "Hello, world!" 信息,然后调用 another_function,并打印其信息。

参数

我们可以定义为拥有 参数( parameters 的函数,参数是特殊变量,是函数签名的一部分。当函数拥有参数(形参)时,可以为这些参数提供具体的值(实参)。 技术上讲,这些具体值被称为 参数arguments ),但是在日常交流中,人们倾向于不区分使用 parametersarguments 来表示函数定义中的变量或调用函数时传入的具体值。

在这个版本的 another_function 中,我们添加了一个参数:

use debug::PrintTrait;

fn main() {
    another_function(5);
}

fn another_function(x: felt252) {
    x.print();
}

尝试运行这个程序;你应该得到以下输出:

$ scarb cairo-run
[DEBUG]                                 (raw: 5)

another_function 的声明有一个名为 x 的参数。 x 的类型被指定为 felt252。当我们把 5 传给 another_function 时,.print()函数在控制台中会输出 5

在函数签名中,你 必须 声明每个参数的类型。 这是 Cairo 设计中一个经过慎重考虑的决定: 要求在函数定义中提供类型注解,意味着编译器再也不需要你在代码的 其他地方注明类型来指出你的意图。而且,在知道函数需要什么类型后, 编译器就能够给出更有用的错误消息。

当定义多个参数时,用逗号分隔。 像这样:

use debug::PrintTrait;

fn main() {
    another_function(5, 6);
}

fn another_function(x: felt252, y: felt252) {
    x.print();
    y.print();
}

这个例子创建了一个名为 another_function 的函数,它有两个 参数。第一个参数被命名为 x,类型是 felt252。第二个参数被命名为 y,也是 felt252 类型。然后,该函数打印了 x 的内容,然后打印 y 的内容。

让我们试着运行这段代码。用前面的示例替换当前在你的 functions 项目的 src/lib.cairo 文件中的程序,然后使用 scarb cairo-run 运行它:

$ scarb cairo-run
[DEBUG]                                 (raw: 5)
[DEBUG]                                 (raw: 6)

因为我们在调用函数时,将 5 作为 x 的值,将 6 作为 y 的值。 程序输出包含这些值。

命名参数

在 Cairo 中,命名参数允许您在调用函数时指定参数的名称。这使得函数调用更具可读性和自描述性。 如果你想使用命名参数,你需要指定参数的名称和你想传递给它的值。语法是 parameter_name: value。如果你传递的变量与参数名称相同,你可以简写为 :parameter_name,而不是 parameter_name: variable_name

下面是一个例子:

fn foo(x: u8, y: u8) {}

fn main() {
    let first_arg = 3;
    let second_arg = 4;
    foo(x: first_arg, y: second_arg);
    let x = 1;
    let y = 2;
    foo(:x, :y)
}

语句和表达式

函数体由一系列的语句和一个可选的结尾表达式构成。 目前为止,我们提到的函数还不包含结尾表达式, 不过你已经见过作为语句一部分的表达式。因为 Cairo 是一门基于表达式 (expression-based)的语言,这是一个需要理解的(不同于其他语言) 重要区别。其他语言并没有这样的区别,所以让我们看看语句与表达式有什么区别以及这些区别是如何影响函数体的。

  • 语句(Statements)是执行一些操作但不返回值的指令。
  • 表达式(Expressions)计算并产生一个值。让我们看一些例子。

实际上,我们已经使用过语句和表达式。 使用 let 关键字创建变量并绑定一个值是一个语句。在示例 2-1 中,let y = 6;; 是一个语句。

fn main() {
    let y = 6;
}

示例2-1:一个包含一条语句的 main 函数声明

函数定义也是语句,上面整个例子本身就是一个语句。

语句不返回值。因此,不能把 let 语句赋值给另一个变量,比如下面的例子尝试做 的,会产生一个错误:

fn main() {
    let x = (let y = 6);
}

当你运行这个程序时,你将得到的错误看起来是这样:

$ scarb cairo-run
error: Missing token TerminalRParen.
 --> src/lib.cairo:2:14
    let x = (let y = 6);
             ^

error: Missing token TerminalSemicolon.
 --> src/lib.cairo:2:14
    let x = (let y = 6);
             ^

error: Missing token TerminalSemicolon.
 --> src/lib.cairo:2:14
    let x = (let y = 6);
                      ^

error: Skipped tokens. Expected: statement.
 --> src/lib.cairo:2:14
    let x = (let y = 6);

语句 let y = 6 没有返回一个值,所以没有任何东西让 x 与之绑定。这与其他语言 中的情况不同,比如说 C 和 Ruby,其中赋值会返回赋值的值。 在这些语言中,你可以写 x = y = 6,让 xy 都有值 6;但在 Cairo 中不是这样的。

表达式会计算出一个值,并且你编写的大部分 Cairo 代码将是由表达式组成的。 考虑一个数学运算,比如 5 + 6 ,这是一个表达式并计算出值 11。 表达式可以是语句的一部分:在示例 2-1 中,语句 let y = 6; 中的 6 是一个表达式,它计算出的值是 6 。 函数调用是一个表达式。宏调用也是一个表达式。用大括号创建的一个新的块作用域同样也是一个表达式,例如:

use debug::PrintTrait;
fn main() {
    let y = {
        let x = 3;
        x + 1
    };

    y.print();
}

这个表达式:

    let y = {
        let x = 3;
        x + 1
    };

是一个代码块,它的值是 4。这个值作为 let 语句的一部分被绑定到 y 上。注意 x + 1 这一行在结尾没有分号,与你见过的大部分代码行不同。表达式的结尾没有 分号。如果在表达式的结尾加上分号,它就变成了语句,而语句不会返回值。 在接下来学习具有返回值的函数和表达式时要谨记这一点。

具有返回值的函数

函数可以向调用它的代码返回值。我们并不对返回值命名,但要在箭头(->)后声明它 的类型。在 Cairo 中,函数的返回值等同于函数体最后一个表达式的值。使用 return 关键字和指定值,可从函数中提前返回;但大部分函数隐式的返回最后的表达式。 这是一个有返回值的函数的例子:

use debug::PrintTrait;

fn five() -> u32 {
    5
}

fn main() {
    let x = five();
    x.print();
}

在 five 函数中没有函数调用、宏、甚至没有 let 语句 —— 只有数字 5。这在 Cairo 中是一个完全有效的函数。注意,也指定了函数返回值的类型,就是 -> u32。尝试运行代码;输出应该看起来像这样:

$ scarb cairo-run
[DEBUG]                                 (raw: 5)

five 函数的返回值是 5 ,所以返回值类型是 u32。让我们仔细检查一下这段代 码。有两个重要的部分:首先,let x = five(); 这一行表明我们使用函数的返回值初 始化一个变量。因为 five 函数返回 5,这一行与如下代码相同:

let x = 5;

第二,five 函数没有参数并定义了返回值的类型。不过函数体只有单单一个 5 也没 有分号,因为这是一个表达式,我们想要返回它的值。 让我们看看另一个例子:

use debug::PrintTrait;

fn main() {
    let x = plus_one(5);

    x.print();
}

fn plus_one(x: u32) -> u32 {
    x + 1
}

运行这段代码将打印 [DEBUG] (raw: 6)。但是如果在包含 x + 1 的行尾放置一个 分号,把它从一个表达式变成一个语句,我们会看到一个错误:

use debug::PrintTrait;

fn main() {
    let x = plus_one(5);

    x.print();
}

fn plus_one(x: u32) -> u32 {
    x + 1;
}

编译这段代码会产生一个错误,如下所示:

error: Unexpected return type. Expected: "core::integer::u32", found: "()".

主要的错误信息 Unexpected return type 揭示了该代码的核心问题。 函数plus_one 的定义说它将返回一个 u32 类型的值,但是语句并不会返回一个值,而是给出了一个 ()unit 类型。 因此,没有返回值与函数的定义相矛盾,导致了错误发生。

Last change: 2023-09-18, commit: 98c29c3

注释

在 Cairo 程序中,你可以使用注释在代码中加入解释性的文本。 要创建一个注释,请使用 // 语法,之后同一行的任何文本都会被编译器忽略。

fn main() -> felt252 {
    // start of the function
    1 + 4 // return the sum of 1 and 4
}
Last change: 2023-08-10, commit: a3bc10b

控制流

根据条件是否为真来决定是否执行某些代码,以及根据条件是否为真来重复运行一段 代码的能力是大部分编程语言的基本组成部分。Cairo 代码中最常见的用来控制执行 流的结构是 if 表达式和循环。

if表达式

if 表达式允许根据条件执行不同的代码分支。你提供一个条件并表示 “如果条件满 足,运行这段代码;如果条件不满足,不运行这段代码。”

文件名: src/lib.cairo

use debug::PrintTrait;

fn main() {
    let number = 3;

    if number == 5 {
        'condition was true'.print();
    } else {
        'condition was false'.print();
    }
}

所有的 if 表达式都以关键字 if 开始,其后跟一个条件。在这个例子中,条件检查 变量 number 的值是否等于 5。在条件为 true 时希望执行的代码块位于紧跟条件 之后的大括号中。

另外,我们也可以包含一个可选的 else 表达式来提供一个在条件为 false 时应当执行的代码块,这里我们就这么做了。如果不提供 else 表达式并且条件为 false 时,程序会直接忽略 if 代码块并继续执行下面的代码。

尝试运行这段代码;你应该看到以下输出:

$ cairo-run main.cairo
[DEBUG]	condition was false

让我们试着改变 number 的值使条件为 true 时看看会发生什么:

    let number = 5;
$ cairo-run main.cairo
condition was true

还值得注意的是,这段代码中的条件必须是一个 bool 值。如果该条件不是 bool 值,我们会得到一个错误。

$ cairo-run main.cairo
thread 'main' panicked at 'Failed to specialize: `enum_match<felt252>`. Error: Could not specialize libfunc `enum_match` with generic_args: [Type(ConcreteTypeId { id: 1, debug_name: None })]. Error: Provided generic argument is unsupported.', crates/cairo-lang-sierra-generator/src/utils.rs:256:9

else if处理多个条件

你可以通过在一个 else if 表达式中结合 if 和 else 来使用多个条件。比如说:

文件名: src/lib.cairo

use debug::PrintTrait;

fn main() {
    let number = 3;

    if number == 12 {
        'number is 12'.print();
    } else if number == 3 {
        'number is 3'.print();
    } else if number - 2 == 1 {
        'number minus 2 is 1'.print();
    } else {
        'number not found'.print();
    }
}

这个程序有四种可能的路径。运行后,你应该看到以下输出:

[DEBUG]	number is 3

执行该程序时,它会依次检查每个 if 表达式,并执行条件求值为 true 的第一个体。请注意,即使 number - 2 == 1true,我们也看不到输出 number minus 2 is 1'.print() ,也看不到 else 块中的 number not found 文本。这是因为 Cairo 只执行第一个真条件的代码块,一旦找到一个真条件,就不会再检查其他条件。使用过多的 else if 表达式会使代码变得杂乱无章,所以如果你有一个以上的 else if 表达式,你可能需要重构你的代码。Chapter 6 介绍了一种强大的Cairo语言分支结构,称为 "match",用于处理这些情况。

let 语句中使用 if

因为 if 是一个表达式,我们可以在 let 语句的右边使用它,将结果分配给一个变量。

文件名: src/lib.cairo

use debug::PrintTrait;

fn main() {
    let condition = true;
    let number = if condition {
        5
    } else {
        6
    };

    if number == 5 {
        'condition was true'.print();
    }
}
$ cairo-run main.cairo
[DEBUG]	condition was true

number 变量将会绑定到表示 if 表达式结果的值上。这里将是 5。

使用循环重复执行

多次执行同一段代码是很常用的,Cairo 为此提供了多种 循环(loops)。一个循环执行循环体中的代码直到结尾并紧接着回到开头继续执行。为了实验一下循环,让我们新建一个叫做 loops 的项目。

Cairo 目前只有一种循环:loop

使用 loop 重复执行代码

loop 关键字告诉 Cairo 一遍又一遍地执行一段代码直到你明确要求停止。

作为一个例子,将你的_loops_目录下的_src/lib.cairo_文件修改如下:

文件名: src/lib.cairo

use debug::PrintTrait;
fn main() {
    let mut i: usize = 0;
    loop {
        if i > 10 {
            break ;
        }
        'again!'.print();
    }
}

当运行这个程序时,我们会看到程序不停的反复打印 again!,直到我们手动停止程序,因为程序从未达到停止条件。 虽然编译器阻止我们编写没有停止条件(break语句)的程序,但该停止条件可能永远不会达到,从而会程序导致无限循环。 大多数终端支持键盘快捷键 ctrl-c 来中断卡在无限循环的程序。试一试吧:

$ scarb cairo-run --available-gas=20000000
[DEBUG]	again                          	(raw: 418346264942)

[DEBUG]	again                          	(raw: 418346264942)

[DEBUG]	again                          	(raw: 418346264942)

[DEBUG]	again                          	(raw: 418346264942)

Run panicked with err values: [375233589013918064796019]
Remaining gas: 1050

注意:Cairo通过包含一个 gas 计量器来防止我们运行无限循环的程序。 gas 计量器是一种限制程序中可进行的计算量的机制。通过给 --available-gas 标志设置一个值,我们可以设置程序的最大可用 gas 量。gas 是一个计量单位,表示一条指令的计算成本。当设置的最大gas值耗尽时,程序将停止。在这种情况下,程序会抛出 gas 耗尽的错误(panic),尽管从未达到停止条件。 对于部署在 Starknet 上的智能合约,它特别重要,因为它可以防止在网络上运行无限循环。 如果你正在编写一个需要运行循环的程序,你需要在执行时将 --available-gas 标志设置为一个足够大的值来运行该程序。

要退出循环,您可以在循环内部放置 break 语句,告诉程序何时停止循环。 我们可以通过在这个程序里加入可达的停止条件 i > 10 ,来修复无限循环。

use debug::PrintTrait;
fn main() {
    let mut i: usize = 0;
    loop {
        if i > 10 {
            break;
        }
        'again'.print();
        i += 1;
    }
}

关键字 continue 告诉程序进入循环的下一个迭代,并跳过现在这个迭代中的其他代码。让我们给我们的循环添加一个continue语句,使得当i等于5时跳过print语句。

use debug::PrintTrait;
fn main() {
    let mut i: usize = 0;
    loop {
        if i > 10 {
            break;
        }
        if i == 5 {
            i += 1;
            continue;
        }
        i.print();
        i += 1;
    }
}

i等于5时,执行这个程序将不会打印i的值。

从循环中返回值

loop 的一个用例是重试可能会失败的操作,比如检查线程是否完成了任务。然而你 可能会需要将操作的结果传递给其它的代码。如果将返回值加入你用来停止循环的 break 表达式,它会被停止的循环返回,如下所示:

use debug::PrintTrait;
fn main() {
    let mut counter = 0;

    let result = loop {
        if counter == 10 {
            break counter * 2;
        }
        counter += 1;
    };

    'The result is '.print();
    result.print();
}

在循环之前,我们声明一个名为 counter 的变量,并将其初始化为 0。然后我们声明一个名为 result 的变量,用来保存从循环中返回的值。 在循环的每一次迭代中,我们检查 counter 是否等于 10,然后在 counter 变量中加 1。当条件得到满足时,我们使用 break 关键字,其值为 counter * 2。在循环之后,我们用一个 分号来结束给result赋值的语句。最后,我们打印result中的值,在本例中是20

总结

你成功了!这一章很重要:你学到了变量、数据类型、函数、注释、 if 表达式和循环!要练习本章讨论的概念、 尝试编写程序来完成下列操作:

  • 产生第 n 个斐波那契数。
  • 计算一个数字的阶乘 n

现在,我们将在下一章回顾 Cairo 中常见的集合类型。

Last change: 2023-11-19, commit: a15432b

常见集合

Cairo 提供了一组常用的集合类型,可用于存储和处理数据。这些集合设计得高效、灵活、易于使用。本节将介绍 Cairo 中可用的主要集合类型:数组和字典。

Last change: 2023-09-20, commit: cbb0049

数组

一个数组是相同类型元素的集合。你可以通过导入array::ArrayTraittrait来创建和使用数组方法。

需要注意的一个重要问题是,数组的修改选项有限。这里的数组实际上是以队列的形式存储的,其值不能被直接修改。 这与这样一个事实有关:一旦一个内存槽被写入,它就不能被覆盖,而只能从其中读出。你只能用pop_front将项目追加到数组的末端,并从前面删除项目。

创建一个数组

创建一个数组是通过调用ArrayTrait::new()完成的。下面是一个创建3个元素的数组的例子:

fn main() {
    let mut a = ArrayTrait::new();
    a.append(0);
    a.append(1);
    a.append(2);
}

需要时,你可以在实例化数组时像下面这样传递数组内部元素的预期类型,或者明确定义变量的类型。

let mut arr = ArrayTrait::<u128>::new();
let mut arr:Array<u128> = ArrayTrait::new();

更新一个数组

添加元素

要在一个数组的末尾添加一个元素,可以使用append()方法:

fn main() {
    let mut a = ArrayTrait::new();
    a.append(0);
    a.append(1);
    a.append(2);
}

移除元素

要从一个数组的前面移除一个元素,你可以使用pop_front()方法。 该方法返回一个包含被移除元素的Option。如果数组为空,则返回Option::None

use debug::PrintTrait;

fn main() {
    let mut a = ArrayTrait::new();
    a.append(10);
    a.append(1);
    a.append(2);

    let first_value = a.pop_front().unwrap();
    first_value.print(); // print '10'
}

上面的代码将打印10,因为我们删除了第一个被添加的元素。

在Cairo中,内存是不可改变的,这意味着一旦数组中的元素被添加,就不可能修改它们。你只能将元素添加到数组的末端,并从数组的前端移除元素。这些操作不需要内存突变,因为它们涉及到更新指针而不是直接修改内存单元。

从数组中读取元素

为了访问数组元素,你可以使用get()at()数组方法,它们返回不同的类型。使用arr.at(index)等同于使用下标操作符arr[index]

函数 get 返回一个 Option<Box<@T>> ,这意味着它返回一个 Box 类型(Cairo的智能指针类型)的选项,其中包含指定索引处元素的快照(如果该元素存在于数组中)。如果元素不存在,get返回None。当你希望访问的索引可能不在数组的边界内,并希望优雅地处理这种情况而不引起panic时,该方法就非常有用。快照将在引用和快照一章中详细解释。

另一方面,at函数直接返回一个快照到指定索引的元素,使用unbox()操作符来提取存储在一个盒子里的值。如果索引超出了范围,就会抛出错误(panic)。你应该只在希望索引超出数组的边界时抛出panic时,使用 at,这样可以防止意外的行为。

总之,当你想对越界访问尝试进行恐慌时,请使用at,而当你想优雅地处理这种情况而不恐慌时,请使用get

fn main() {
    let mut a = ArrayTrait::new();
    a.append(0);
    a.append(1);

    let first = *a.at(0);
    let second = *a.at(1);
}

在这个例子中,名为first'的变量将得到0'的值,因为那是数组中索引0'的值。 是数组中索引为0'的值。名为second'的变量将得到 从数组中的索引1处获得数值1'。

下面是一个使用get()方法的例子:

fn main() -> u128 {
    let mut arr = ArrayTrait::<u128>::new();
    arr.append(100);
    let index_to_access =
        1; // Change this value to see different results, what would happen if the index doesn't exist?
    match arr.get(index_to_access) {
        Option::Some(x) => {
            *x
                .unbox() // Don't worry about * for now, if you are curious see Chapter 4.2 #desnap operator
        // It basically means "transform what get(idx) returned into a real value"
        },
        Option::None => {
            let mut data = ArrayTrait::new();
            data.append('out of bounds');
            panic(data)
        }
    }
}

数组大小相关的方法

要确定一个数组中的元素数量,请使用len()方法。其返回值为usize类型。

如果你想检查一个数组是否为空,你可以使用is_empty()方法,如果数组为空,返回true,否则返回false

用Enums存储多种类型

如果你想在一个数组中存储不同类型的元素,你可以使用Enum来定义一个可以容纳多种类型的自定义数据类型。更多关于Enum的细节见 Enums and Pattern Matching 这一章节。

#[derive(Copy, Drop)]
enum Data {
    Integer: u128,
    Felt: felt252,
    Tuple: (u32, u32),
}

fn main() {
    let mut messages: Array<Data> = ArrayTrait::new();
    messages.append(Data::Integer(100));
    messages.append(Data::Felt('hello world'));
    messages.append(Data::Tuple((10, 30)));
}

Span

Span是一个结构,代表一个 "数组 "的快照(snapshot)。它被设计用来提供对数组元素的安全可控的访问,而不需要修改原始数组。Span对于确保数据的完整性和避免在函数间传递数组或执行只读操作时的借用问题特别有用(参见引用和快照)

除了 append()方法外,Array提供的其他所有方法都可以用于 Span

将一个数组变成span

要创建一个 ArraySpan ,请调用span()方法:

fn main() {
    let mut array: Array<u8> = ArrayTrait::new();
    array.span();
}
Last change: 2023-11-19, commit: a15432b

字典

Cairo在其核心库中提供了一个类似字典的类型。Felt252Dict<T> 数据类型表示键值对的集合,其中每个键都是唯一的,并与相应的值相关联。这种类型的数据结构在不同的编程语言中有不同的名称,如映射、哈希表、关联数组等。

Felt252Dict<T> 类型在你想以某种方式组织数据而使用Array<T>和索引不能满足要求时非常有用。Cairo字典还允许程序员在内存非可变的情况下轻松地模拟可变内存。

字典的基本用法

在其他语言中, 当创建一个新的字典时, 通常需要定义键和值的数据类型。在Cairo中,键类型被限制为felt252,你只能指定值数据的类型,这在Felt252Dict<T>中用T表示。

Felt252Dict<T>的核心功能在trait Felt252DictTrait中实现,它包括所有的基本操作。在其中我们可以看到:

  1. insert(felt252, T) -> ()向字典实例写入值,以及
  2. get(felt252) -> T 从字典中读取值。

这些函数允许我们使用其他语言一样的方法来操作字典。在下面的示例中,我们创建一个字典来表示个体及其余额之间的映射:

fn main() {
    let mut balances: Felt252Dict<u64> = Default::default();

    balances.insert('Alex', 100);
    balances.insert('Maria', 200);

    let alex_balance = balances.get('Alex');
    assert(alex_balance == 100, 'Balance is not 100');

    let maria_balance = balances.get('Maria');
    assert(maria_balance == 200, 'Balance is not 200');
}

我们做的第一件事是导入Felt252DictTrait,它将我们需要与字典交互的所有方法导入到作用域。接下来,我们使用Defaulttrait的default方法创建一个新的Felt252Dict<u64>实例,并使用insert方法添加两个个体,每个个体都有自己的余额。最后,我们使用get方法检查了用户的余额。

在整本书中,我们都在说Cairo的内存是不可变的,这意味着你只能向一个内存单元写入一次,但是 Felt252Dict<T> 类型代表了一种克服这一障碍的方法。我们将在后面的深入Cairo的字典中解释如何实现。

在前面示例的基础上,让我们展示一个同一用户的余额产生变化的代码示例:

fn main() {
    let mut balances: Felt252Dict<u64> = Default::default();

    // Insert Alex with 100 balance
    balances.insert('Alex', 100);
    // Check that Alex has indeed 100 associated with him
    let alex_balance = balances.get('Alex');
    assert(alex_balance == 100, 'Alex balance is not 100');

    // Insert Alex again, this time with 200 balance
    balances.insert('Alex', 200);
    // Check the new balance is correct
    let alex_balance_2 = balances.get('Alex');
    assert(alex_balance_2 == 200, 'Alex balance is not 200');
}

注意在这个示例中,我们是添加 Alex 这个个体两次,每次都使用了不同的余额,并且每次我们检查它的余额时,它都显示出了最新的值!Felt252Dict<T>使得我们可以 "重写 "任何给定的键中所存储值。

在继续解释字典是如何实现的之前,值得一提的是,一旦你实例化了一个 Felt252Dict<T>,其所有的键值都将被初始化为0。这意味着,例如,如果你试图获取一个不存在的用户的余额,你将得到0,而不是一个错误或未定义的值。这也意味着无法从字典中删除数据。在代码中使用这歌结构纳时你需要考虑到这一点。

到此为止,我们已经了解了 Felt252Dict<T> 的所有基本特性,以及它是如何在外部表现上模仿其他语言中相应数据结构的。Cairo的核心是一种非确定的图灵完备的编程语言,与其他任何流行的语言都有很大的不同,这意味着字典的实现也有很大的不同!

在下面的章节中,我们将深入介绍 Felt252Dict<T> 的内部机制以及为使其正常工作而做出的妥协。之后,我们将介绍如何将字典与其他数据结构一起使用,以及使用entry方法作为与字典交互的另一种方式。

深入Cairo的字典

Cairo的非确定性设计的限制之一是它的内存系统是不可变的,因此为了模拟可变性,语言将Felt252Dict<T>实现为一个条目(entry)列表。每个条目代表了字典被读取/更新/写入的时间。一个条目有三个字段:

1.一个 key字段,用于标识字典中键值对的值。 2.一个previous_value字段,表示key所拥有的前一个值。 3.一个new_value字段,用于指明key所拥有的新值。

如果我们尝试使用高级结构来实现 Felt252Dict<T>,我们将在内部把它定义为 Array<Entry<T>> 其中每个 Entry<T> 都有关于它代表的键值对的信息,以及它持有的前一个值和新值。Entry<T> 的定义如下:

struct Entry<T> {
    key: felt252,
    previous_value: T,
    new_value: T,
}

每次我们与Felt252Dict<T>交互时,都会产生一个新的Entry<T>并注册:

  • get 将注册一个状态没有发生变化的条目,以前的值和新值以相同的值存储。
  • 一个insert将注册一个新的Entry<T>,其中new_value是被插入的元素,previous_value是在此之前插入的最后一个元素。如果这是某个键的第一个条目,那么之前的值将为0。

条目列表的使用展示了这里没有任何值的覆盖,只是在每次Felt252Dict<T>交互中创建新的存储单元。让我们使用上一节中的 balances 字典并插入用户 'Alex' 和 'Maria' 来展示一个例子:

struct Entry<T> {
    key: felt252,
    previous_value: T,
    new_value: T,
}

fn main() {
    let mut balances: Felt252Dict<u64> = Default::default();
    balances.insert('Alex', 100_u64);
    balances.insert('Maria', 50_u64);
    balances.insert('Alex', 200_u64);
    balances.get('Maria');
}

这些指令将产生以下条目列表:

keypreviousnew
Alex0100
Maria050
Alex100200
Maria5050

注意,由于'Alex'被插入了两次,所以它出现了两次,并且'previous'和'current'值被正确的设置。从'Maria'中读取的数据也是一个条目,从前值到现值没有发生变化。

这种实现 Felt252Dict<T> 的方法意味着每一次读/写操作,都要扫描整个条目列表,寻找最后一个具有相同 key的条目。一旦条目被找到,它的new_value就会被提取出来,并作为previous_value添加到新的条目中。这意味着与 Felt252Dict<T> 交互的最坏情况下的时间复杂度为 O(n),其中 n 是列表中的条目数。

如果你花点心思,你肯定会找到其他实现Felt252Dict<T>的方法,其中一些方法甚至可能完全抛弃对previous_value字段的需求,然而,由于Cairo不是普通的编程语言,这是不可行的。 Cairo的目的之一是通过STARK证明系统来生成计算完整性的证明。这意味着你需要验证程序的执行是否正确,是否在Cairo的限制范围内。其中一个边界检查包括 "字典压缩",这需要每个条目的前值和新值的信息。

字典压缩

为了验证使用Felt252Dict<T>的Cairo程序执行所生成的证明是否正确,我们需要检查字典是否被非法篡改。这是通过一个名为squash_dict的方法来完成的,这个方法会审查条目列表中的每一个条目,并检查字典的访问在整个执行过程中是否保持一致。

压缩过程如下:给定所有具有特定键k的条目,按照它们被插入的相同顺序,验证第i个条目new_value是否等于第i+1个条目previous_value

例如,给定以下条目列表:

keypreviousnew
Alex0150
Maria0100
Charles070
Maria100250
Alex15040
Alex40300
Maria250190
Alex30090

压缩后,条目列表将缩减为:

keypreviousnew
Alex090
Maria0190
Charles070

如果第一张表中的任何值发生变化,则在运行期间压缩将会失败。

字典的析构

如果你运行字典的基本用法中的示例,你会发现那些例子从来没有调用过字典压缩,但程序还是编译成功了。这是因为在背后,squash通过Destruct<T> trait的Felt252Dict<T>实现了被自动调用。这个调用发生在balance字典离开作用域之前。

Drop<T> 之外,Destruct<T> trait代表了另一种将实例移出作用域的方法。这两者之间的主要区别在于 Drop<T> 被视为一个 no-op 操作,这意味着它不会产生新的 CASM,而 Destruct<T> 没有这个限制。唯一主动使用Destruct<T>特性的类型是Felt252Dict<T>,对于其他类型Destruct<T>Drop<T>是同义词。你可以在[Drop and Destruct](/appendix-03-derivable-traits.md#drop and-destruct)中阅读更多关于这些trait的信息。

Dictionaries as Struct Members后面,我们将有一个实践示例,我们将为自定义类型实现 Destruct<T> trait。

更多字典范例

到此为止,我们已经全面地介绍了Felt252Dict<T>的功能,以及它是如何和为什么以某种方式实现的。如果您还没有完全理解,请不要担心,因为在本节中我们将提供更多使用字典的示例。

我们将首先解释entry方法,它是Felt252DictTrait<T>中包含的字典基本功能的一部分,我们在开始时没有提到。很快,我们将看到Felt252Dict<T>如何与其他复杂类型如Array<T>交互的例子,以及如何实现一个以字典为成员的结构体。

条目(Entry)和最终确定(Finalize)

Dictionaries Underneath 部分,我们解释了 Felt252Dict<T> 内部是如何工作的。它是每次以任何方式访问字典时的条目列表。它会首先找到给定key的最后一个条目,然后根据执行的操作更新它。Cairo语言通过entryfinalize方法为我们提供了复制这种方式的工具。

entry 方法作为Felt252DictTrait<T> 的一部分,目的是在给定键的情况下创建一个新的条目。一旦被调用,该方法将获得字典的所有权并返回要更新的条目。方法签名如下:

fn entry(self: Felt252Dict<T>, key: felt252) -> (Felt252DictEntry<T>, T) nopanic

第一个输入参数获得字典的所有权,第二个参数用于创建相应的条目。它返回一个元组,包含一个Felt252DictEntry<T>,这是Cairo用来表示字典条目的类型,和一个T,代表之前持有的值。

接下来要做的是用新值更新条目。为此,我们使用finalize方法插入条目并返回字典的所有权:

fn finalize(self: Felt252DictEntry<T>, new_value: T) -> Felt252Dict<T> {

该方法接收条目和新值作为参数,并返回更新后的字典。

让我们看一个使用entryfinalize的例子。想象一下,我们想从字典中实现我们自己版本的get方法。我们应该这样做:

1.使用entry方法创建要添加的新条目 2.插入new_value等于previous_value的条目。 3.返回值。

实现我们的自定义get将如下所示:

use dict::Felt252DictEntryTrait;

fn custom_get<T, +Felt252DictValue<T>, +Drop<T>, +Copy<T>>(
    ref dict: Felt252Dict<T>, key: felt252
) -> T {
    // Get the new entry and the previous value held at `key`
    let (entry, prev_value) = dict.entry(key);

    // Store the value to return
    let return_value = prev_value;

    // Update the entry with `prev_value` and get back ownership of the dictionary
    dict = entry.finalize(prev_value);

    // Return the read value
    return_value
}

实现insert方法将遵循类似的工作流程,除了在最终确定时插入一个新值。如果我们要实现它,它将看起来像下面这样:

use dict::Felt252DictEntryTrait;

fn custom_insert<T, +Felt252DictValue<T>, +Destruct<T>, +PrintTrait<T>, +Drop<T>>(
    ref dict: Felt252Dict<T>, key: felt252, value: T
) {
    // Get the last entry associated with `key`
    // Notice that if `key` does not exists, _prev_value will
    // be the default value of T.
    let (entry, _prev_value) = dict.entry(key);

    // Insert `entry` back in the dictionary with the updated value,
    // and receive ownership of the dictionary
    dict = entry.finalize(value);
}

最后要说明的是,这两个方法的实现方式类似于Felt252Dict<T>insertget的实现方式。这段代码展示了一些使用示例:

use dict::Felt252DictEntryTrait;

use debug::PrintTrait;

fn custom_get<T, +Felt252DictValue<T>, +Drop<T>, +Copy<T>>(
    ref dict: Felt252Dict<T>, key: felt252
) -> T {
    // Get the new entry and the previous value held at `key`
    let (entry, prev_value) = dict.entry(key);

    // Store the value to return
    let return_value = prev_value;

    // Update the entry with `prev_value` and get back ownership of the dictionary
    dict = entry.finalize(prev_value);

    // Return the read value
    return_value
}

fn custom_insert<T, +Felt252DictValue<T>, +Destruct<T>, +PrintTrait<T>, +Drop<T>>(
    ref dict: Felt252Dict<T>, key: felt252, value: T
) {
    // Get the last entry associated with `key`
    // Notice that if `key` does not exists, _prev_value will
    // be the default value of T.
    let (entry, _prev_value) = dict.entry(key);

    // Insert `entry` back in the dictionary with the updated value,
    // and receive ownership of the dictionary
    dict = entry.finalize(value);
}

fn main() {
    let mut dict: Felt252Dict<u64> = Default::default();

    custom_insert(ref dict, '0', 100);

    let val = custom_get(ref dict, '0');

    assert(val == 100, 'Expecting 100');
}

非远胜支持类型的字典

Felt252Dict<T>的一个限制我们还没有讨论过,那就是 Felt252DictValue<T> trait。 该trait定义了 zero_default 方法,当字典中不存在某个值时,就会调用该方法。 一些常见的数据类型(如大多数无符号整数、boolfelt252)实现了该方法,但更复杂的数据类型,如数组、结构体(包括 u256)和核心库中的其他类型,则没有实现该方法。 这就意味着,将不受原生支持的类型封装成字典并不是一件简单的事情,因为您需要编写一些trait的实现,才能使这些数据类型成为有效的字典值类型。 为了弥补这一不足,您可以将您的类型封装在 Nullable<T>中。

Nullable<T> 是一种智能指针类型,既可以指向一个值,也可以在没有值的情况下为 null。当引用不指向任何地方时,它通常用于面向对象编程语言。与 Option 不同的是,封装的值存储在 Box<T> 数据类型中。受 Rust 的启发,Box<T> 类型允许我们为我们的类型分配一个新的内存段,并使用一个指针访问该内存段,该指针一次只能在一个地方操作。

让我们来举例说明。我们将尝试在字典中存储一个Span<felt252>。为此,我们将使用 Nullable<T>Box<T>。另外,我们要存储的是一个 Span<T> 而不是一个 Array<T> ,因为后者没有实现 Copy<T> 特性,而从字典中读取数据是需要这个特性的。

use dict::Felt252DictTrait;
use nullable::{nullable_from_box, match_nullable, FromNullableResult};

fn main() {
    // Create the dictionary
    let mut d: Felt252Dict<Nullable<Span<felt252>>> = Default::default();

    // Crate the array to insert
    let mut a = ArrayTrait::new();
    a.append(8);
    a.append(9);
    a.append(10);

    // Insert it as a `Span`
    d.insert(0, nullable_from_box(BoxTrait::new(a.span())));

//...

在这段代码中,我们首先创建了一个新的字典d。我们希望它保存一个Nullable<Span>。然后,我们创建了一个数组,并在其中填入值。

最后一步是在字典中插入数组。请注意,我们并没有直接这样做,而是在中间采取了一些步骤:

1.我们使用BoxTrait中的new方法将数组封装在Box中。 2.我们使用nullable_from_box函数将Box封装在nullable中。 3.最后,我们插入了上面的结果。

一旦元素存在字典中,并且我们想获取它,我们将遵循相同的步骤,但顺序相反。下面的代码展示了如何实现这一点:

//...

    // Get value back
    let val = d.get(0);

    // Search the value and assert it is not null
    let span = match match_nullable(val) {
        FromNullableResult::Null(()) => panic_with_felt252('No value found'),
        FromNullableResult::NotNull(val) => val.unbox(),
    };

    // Verify we are having the right values
    assert(*span.at(0) == 8, 'Expecting 8');
    assert(*span.at(1) == 9, 'Expecting 9');
    assert(*span.at(2) == 10, 'Expecting 10');
}

我们在这里:

1.使用get读取值。 2.使用match_nullable函数验证其为非空值。 3.解压缩Box内的值,并断言它是正确的。

完整的脚本如下:


use dict::Felt252DictTrait;
use nullable::{nullable_from_box, match_nullable, FromNullableResult};

fn main() {
    // Create the dictionary
    let mut d: Felt252Dict<Nullable<Span<felt252>>> = Default::default();

    // Crate the array to insert
    let mut a = ArrayTrait::new();
    a.append(8);
    a.append(9);
    a.append(10);

    // Insert it as a `Span`
    d.insert(0, nullable_from_box(BoxTrait::new(a.span())));

    // Get value back
    let val = d.get(0);

    // Search the value and assert it is not null
    let span = match match_nullable(val) {
        FromNullableResult::Null(()) => panic_with_felt252('No value found'),
        FromNullableResult::NotNull(val) => val.unbox(),
    };

    // Verify we are having the right values
    assert(*span.at(0) == 8, 'Expecting 8');
    assert(*span.at(1) == 9, 'Expecting 9');
    assert(*span.at(2) == 10, 'Expecting 10');
}

作为结构体成员的字典

在Cairo中可将字典定义为结构体成员,但正确地与它们交互可能不是完全无障碍的。让我们尝试实现一个自定义的_user数据库,它将允许我们添加用户并查询他们。我们需要定义一个struct来表示新的类型,并定义一个trait来定义其功能:

struct UserDatabase<T> {
    users_amount: u64,
    balances: Felt252Dict<T>,
}

trait UserDatabaseTrait<T> {
    fn new() -> UserDatabase<T>;
    fn add_user<+Drop<T>>(ref self: UserDatabase<T>, name: felt252, balance: T);
    fn get_balance<+Copy<T>>(ref self: UserDatabase<T>, name: felt252) -> T;
}

我们的新类型UserDatabase<T>表示用户数据库。它用泛型表示用户的余额,给使用我们的数据类型的人提���了很大的灵活性。它的两个成员是:

  • users_amount,当前插入的用户数量。
  • balances,每个用户与其余额的映射。

数据库核心功能由UserDatabaseTrait定义。定义了以下方法:

  • new用于方便创建新的UserDatabase类型。
  • add_user用于在数据库中插入用户。
  • get_balance用于在数据库中查找用户的余额。

剩下的步骤就是实现UserDatabaseTrait中的每一个方法,但是由于我们使用的是泛型,我们还需要正确地建立T的要求,这样它才能成为一个有效的Felt252Dict<T>值类型:

1.T 应该实现 Copy<T>,因为从 Felt252Dict<T> 获取值需要它。 2.所有字典的值类型都实现了 Felt252DictValue<T>,我们的泛型也应该实现。 3.为了插入值,Felt252DictTrait<T>要求所有的值类型都是可析构的。

在所有限制条件均已实现的情况下,执行情况如下:



impl UserDatabaseImpl<T, +Felt252DictValue<T>> of UserDatabaseTrait<T> {
    // Creates a database
    fn new() -> UserDatabase<T> {
        UserDatabase { users_amount: 0, balances: Default::default() }
    }

    // Get the user's balance
    fn get_balance<+Copy<T>>(ref self: UserDatabase<T>, name: felt252) -> T {
        self.balances.get(name)
    }

    // Add a user
    fn add_user<+Drop<T>>(ref self: UserDatabase<T>, name: felt252, balance: T) {
        self.balances.insert(name, balance);
        self.users_amount += 1;
    }
}

我们的数据库实现几乎已经完成,除了一点:编译器不知道如何让 UserDatabase<T> 脱离作用域,因为它没有实现 Drop<T> 特性,也没有实现 Destruct<T> 特性。 由于它有一个 Felt252Dict<T> 作为成员,它不能被丢弃,所以我们不得不手动实现 Destruct<T> 特性(更多信息请参阅 所有权 章节)。 在 UserDatabase<T> 定义之上使用 #[derive(Destruct)]是行不通的,因为在 struct 定义中使用了 泛型 。我们需要自己编写实现 Destruct<T> trait的代码:

impl UserDatabaseDestruct<T, +Drop<T>, +Felt252DictValue<T>> of Destruct<UserDatabase<T>> {
    fn destruct(self: UserDatabase<T>) nopanic {
        self.balances.squash();
    }
}

UserDatabase实现Destruct<T>是我们得到一个功能齐全的数据库的最后一步。现在我们可以试试了:

struct UserDatabase<T> {
    users_amount: u64,
    balances: Felt252Dict<T>,
}

trait UserDatabaseTrait<T> {
    fn new() -> UserDatabase<T>;
    fn add_user<+Drop<T>>(ref self: UserDatabase<T>, name: felt252, balance: T);
    fn get_balance<+Copy<T>>(ref self: UserDatabase<T>, name: felt252) -> T;
}

impl UserDatabaseImpl<T, +Felt252DictValue<T>> of UserDatabaseTrait<T> {
    // Creates a database
    fn new() -> UserDatabase<T> {
        UserDatabase { users_amount: 0, balances: Default::default() }
    }

    // Get the user's balance
    fn get_balance<+Copy<T>>(ref self: UserDatabase<T>, name: felt252) -> T {
        self.balances.get(name)
    }

    // Add a user
    fn add_user<+Drop<T>>(ref self: UserDatabase<T>, name: felt252, balance: T) {
        self.balances.insert(name, balance);
        self.users_amount += 1;
    }
}

impl UserDatabaseDestruct<T, +Drop<T>, +Felt252DictValue<T>> of Destruct<UserDatabase<T>> {
    fn destruct(self: UserDatabase<T>) nopanic {
        self.balances.squash();
    }
}

fn main() {
    let mut db = UserDatabaseTrait::new();

    db.add_user('Alex', 100);
    db.add_user('Maria', 80);

    db.add_user('Alex', 40);
    db.add_user('Maria', 0);

    let alex_latest_balance = db.get_balance('Alex');
    let maria_latest_balance = db.get_balance('Maria');

    assert(alex_latest_balance == 40, 'Expected 40');
    assert(maria_latest_balance == 0, 'Expected 0');
}

总结

干得好!你已经完成了Cairo中关于数组和字典的章节。要掌握这些数据结构可能有点难度,但它们确实非常有用。

当你准备好继续前进时,我们将讨论一个Cairo与Rust共有,而在其他编程语言中通常 不存在 的概念:所有权。

Last change: 2023-09-20, commit: cbb0049

自定义数据结构

当你第一次在开罗开始编程时,你可能想要使用数组('Array') 来存储数据集合。但是,您很快就会意识到 数组有一个很大的限制 - 存储在其中的数据是不可变的。一旦将值附加到数组中,则无法对其进行修改。

当您想要使用可变数据结构时,这可能会令人沮丧。 例如,假设你正在制作一个游戏,玩家有一个等级属性,他们可以升级。 您可以尝试将玩家的等级存储在数组中:

let mut level_players = Array::new();
level_players.append(5);
level_players.append(1);
level_players.append(10);

但是接下来你会发觉,一旦给某个特定的索引中玩家设置了等级,它的等级就无法再次提升。 如果玩家死亡,你也无法将它移出数组,除非他碰巧排在第一位。

幸运的是,Cairo 提供了一个方便的内置 [字典类型](./ch03-02-dictionaries.md) 称为 Felt252Dict<T>, 允许我们模拟可变数据结构的行为。我们先来探讨一下如何使用它来创建一个动态数组实现。

注意:本章中使用的几个概念将在后面几个章节里详细解释。 我们建议您先查看以下章节: [结构体](./ch05-00-using-structs-to-structure-related-data), [方法](./ch05-03-method-syntax.md)、 [泛型](./ch08-00-generic-types-and-traits.md), [Traits](./ch08-02-traits-in-cairo.md)

使用字典模拟动态数组

首先,让我们考虑一下我们希望可变动态数组的行为方式。它需要支持哪些操作?

它应该:

  • 允许我们在末尾附加项
  • 让我们按索引访问任何项
  • 允许在特定索引处设置项的值
  • 返回当前长度

我们可以在Cairo中定义这个接口,如下所示:

#![allow(unused)]
fn main() {
trait VecTrait<V, T> {
    fn new() -> V;
    fn get(ref self: V, index: usize) -> Option<T>;
    fn at(ref self: V, index: usize) -> T;
    fn push(ref self: V, value: T) -> ();
    fn set(ref self: V, index: usize, value: T);
    fn len(self: @V) -> usize;
}
}

这为我们的动态数组的实现提供了蓝图。 我们命名它为Vec,因为它类似于 Rust 中的Vec<T> 数据结构。

在Cairo中实现���态数组

为了存储我们的数据,我们将使用Felt252Dict<T> 来映射索引号(felt)到值。 我们还将存储一个单独的len字段来跟踪长度。

下面是我们的结构体的样子。我们将类型T包装在Nullable指针中,这使得在我们的数据结构中可以使用任何类型的 T , 如词典

#![allow(unused)]
fn main() {
struct NullableVec<T> {
    data: Felt252Dict<Nullable<T>>,
    len: usize
}
}

使这个向量可变的关键是我们可以将值插入到用于在数据结构中设置或更新值的字典。 例如,要更新特定索引处的值,我们执行以下操作:

    fn set(ref self: NullableVec<T>, index: usize, value: T) {
        assert(index < self.len(), 'Index out of bounds');
        self.data.insert(index.into(), nullable_from_box(BoxTrait::new(value)));
    }

这将覆盖字典中该索引处先前存在的值。

虽然数组是不可变的,但字典提供了我们的可修改的数据结构(如向量)所需要的灵活性。

接口其余部分的实现非常简单。 在我们的接口中定义的所有方法的实现可以按如下方式完成:

#![allow(unused)]
fn main() {
impl NullableVecImpl<T, +Drop<T>, +Copy<T>> of VecTrait<NullableVec<T>, T> {
    fn new() -> NullableVec<T> {
        NullableVec { data: Default::default(), len: 0 }
    }

    fn get(ref self: NullableVec<T>, index: usize) -> Option<T> {
        if index < self.len() {
            Option::Some(self.data.get(index.into()).deref())
        } else {
            Option::None
        }
    }

    fn at(ref self: NullableVec<T>, index: usize) -> T {
        assert(index < self.len(), 'Index out of bounds');
        self.data.get(index.into()).deref()
    }

    fn push(ref self: NullableVec<T>, value: T) -> () {
        self.data.insert(self.len.into(), nullable_from_box(BoxTrait::new(value)));
        self.len = integer::u32_wrapping_add(self.len, 1_usize);
    }
    fn set(ref self: NullableVec<T>, index: usize, value: T) {
        assert(index < self.len(), 'Index out of bounds');
        self.data.insert(index.into(), nullable_from_box(BoxTrait::new(value)));
    }
    fn len(self: @NullableVec<T>) -> usize {
        *self.len
    }
}
}

“Vec”结构的完整实现可以在社区维护的库 Alexandria里找到。

使用字典模拟堆栈

现在,我们将查看第二个示例及其实现细节:堆栈。

堆栈是 LIFO(后进先出)集合。插入新的元素和现有元素的删除发生在同一端, 表示为堆栈的顶部。

让我们定义创建堆栈所需的操作:

  • 将项推到堆栈的顶部
  • 从堆栈顶部弹出一个项
  • 检查堆栈中是否还有元素。

根据这些规则我们可以定义以下接口:

#![allow(unused)]
fn main() {
trait StackTrait<S, T> {
    fn push(ref self: S, value: T);
    fn pop(ref self: S) -> Option<T>;
    fn is_empty(self: @S) -> bool;
}
}

在Cairo实现可变堆栈

要在Cairo创建堆栈数据结构,我们可以再次使用Felt252Dict<T> 将堆栈的值与usize字段一起存储,以跟踪堆栈的长度来迭代它。

堆栈结构定义如下:

#![allow(unused)]
fn main() {
struct NullableStack<T> {
    data: Felt252Dict<Nullable<T>>,
    len: usize,
}
}

接下来,让我们看看我们的主要功能pushpop 是如何实现的。

#![allow(unused)]
fn main() {
impl NullableStackImpl<T, +Drop<T>, +Copy<T>> of StackTrait<NullableStack<T>, T> {
    fn push(ref self: NullableStack<T>, value: T) {
        self.data.insert(self.len.into(), nullable_from_box(BoxTrait::new(value)));
        self.len += 1;
    }

    fn pop(ref self: NullableStack<T>) -> Option<T> {
        if self.is_empty() {
            return Option::None;
        }
        self.len -= 1;
        Option::Some(self.data.get(self.len.into()).deref())
    }

    fn is_empty(self: @NullableStack<T>) -> bool {
        *self.len == 0
    }
}
}

该代码使用 insertget方法访问Felt252Dict<T>。 要将元素推送到堆栈顶部,使用push函数将元素插入字典中的索引 'len' - 并增加 堆栈的 'len' 字段来跟踪堆栈顶部的位置。 要删除一个值,使用pop函数检索位置len-1处的最后一个值 然后减小 'len' 的值以更新堆栈顶部的位置。

堆栈的完整实现,以及您拥有的更多数据结构 可以在你的代码中使用,可以在社区维护中的 Alexandria 库中的data_structures crate中找到。

总结

虽然 Cairo 的内存模型是不可变的,使其难以实现可变的数据结构, 但幸运的是,我们可以使用 'Felt252Dict' 类型来模拟可变数据结构。 这使我们能够实现通用的对应用程序有用的数据结构,有效地隐藏了底层内存模型的复杂性。

Last change: 2023-11-19, commit: a15432b

了解Cairo的所有权制度

Cairo是一种围绕着线性类型系统建立的语言,它允许我们 静态地确保在每个Cairo程序中,一个值只被使用一次。 这种线性类型系统有助于防止运行时错误,因为它可以确保在编译时检测到可能导致这种错误的操作,如向一个内存单元写两次。 这是通过实施一个所有权系统来实现的 并在默认情况下禁止复制和丢弃数值。在本章中,我们将 讨论Cairo的所有权系统以及引用和快照(snapshot)。

Last change: 2023-09-20, commit: cbb0049

Ownership Using a Linear Type System

Cairo uses a linear type system. In such a type system, any value (a basic type, a struct, an enum) must be used and must only be used once. 'Used' here means that the value is either destroyed or moved.

Destruction can happen in several ways:

  • a variable goes out of scope
  • a struct is destructured
  • explicit destruction using destruct()

Moving a value simply means passing that value to another function.

This results in somewhat similar constraints to the Rust ownership model, but there are some differences. In particular, the rust ownership model exists (in part) to avoid data races and concurrent mutable access to a memory value. This is obviously impossible in Cairo since the memory is immutable. Instead, Cairo leverages its linear type system for two main purposes:

  • Ensuring that all code is provable and thus verifiable.
  • Abstracting away the immutable memory of the Cairo VM.

Ownership

In Cairo, ownership applies to variables and not to values. A value can safely be referred to by many different variables (even if they are mutable variables), as the value itself is always immutable. Variables however can be mutable, so the compiler must ensure that constant variables aren't accidentally modified by the programmer. This makes it possible to talk about ownership of a variable: the owner is the code that can read (and write if mutable) the variable.

This means that variables (not values) follow similar rules to Rust values:

  • Each variable in Cairo has an owner.
  • There can only be one owner at a time.
  • When the owner goes out of scope, the variable is destroyed.

Now that we’re past basic Cairo syntax, we won’t include all the fn main() { examples inside a main function manually. As a result, our examples will be a code in examples, so if you’re following along, make sure to put the following bit more concise, letting us focus on the actual details rather than boilerplate code.

变量作用域

As a first example of the linear type system, we’ll look at the scope of some variables. A scope is the range within a program for which an item is valid. Take the following variable:

let s = 'hello';

The variable s refers to a short string. The variable is valid from the point at which it’s declared until the end of the current scope. Listing 4-1 shows a program with comments annotating where the variable s would be valid.

//TAG: ignore_fmt
fn main() {
    {                      // s is not valid here, it’s not yet declared
        let s = 'hello';   // s is valid from this point forward

        // do stuff with s
    }                      // this scope is now over, and s is no longer valid
}

Listing 4-1: A variable and the scope in which it is valid

换句话说,这里有两个重要的时间点:

  • s进入 进入作用域 时,它就是有效的。
  • 这一直持续到它 离开 作用域 为止。

At this point, the relationship between scopes and when variables are valid is similar to that in other programming languages. Now we’ll build on top of this understanding by using the Array type we introduced in the previous chapter.

Moving values - example with Array

As said earlier, moving a value simply means passing that value to another function. When that happens, the variable referring to that value in the original scope is destroyed and can no longer be used, and a new variable is created to hold the same value.

Arrays are an example of a complex type that is moved when passing it to another function. Here is a short reminder of what an array looks like:

fn main() {
    let mut arr = ArrayTrait::<u128>::new();
    arr.append(1);
    arr.append(2);
}

How does the type system ensure that the Cairo program never tries to write to the same memory cell twice? Consider the following code, where we try to remove the front of the array twice:


fn foo(mut arr: Array<u128>) {
    arr.pop_front();
}

fn main() {
    let mut arr = ArrayTrait::<u128>::new();
    foo(arr);
    foo(arr);
}

In this case, we try to pass the same value (the array in the arr variable) to both function calls. This means our code tries to remove the first element twice, which would try to write to the same memory cell twice - which is forbidden by the Cairo VM, leading to a runtime error. Thankfully, this code does not actually compile. Once we have passed the array to the foo function, the variable arr is no longer usable. We get this compile-time error, telling us that we would need Array to implement the Copy Trait:

error: Variable was previously moved. Trait has no implementation in context: core::traits::Copy::<core::array::Array::<core::integer::u128>>
 --> array.cairo:6:9
    let mut arr = ArrayTrait::<u128>::new();
        ^*****^

The Copy trait

If a type implements the Copy trait, passing a value of that type to a function does not move the value. Instead, a new variable is created, referring to the same value. The important thing to note here is that this is a completely free operation, because variables are a cairo abstraction only and because values in Cairo are always immutable. This, in particular, conceptually differs from the rust version of the Copy trait, where the value is potentially copied in memory.

You can implement the Copy trait on your type by adding the #[derive(Copy)] annotation to your type definition. However, Cairo won't allow a type to be annotated with Copy if the type itself or any of its components don't implement the Copy trait. While Arrays and Dictionaries can't be copied, custom types that don't contain either of them can be.

#[derive(Copy, Drop)]
struct Point {
    x: u128,
    y: u128,
}

fn main() {
    let p1 = Point { x: 5, y: 10 };
    foo(p1);
    foo(p1);
}

fn foo(p: Point) { // do something with p
}

In this example, we can pass p1 twice to the foo function because the Point type implements the Copy trait. This means that when we pass p1 to foo, we are actually passing a copy of p1, so p1 remains valid. In ownership terms, this means that the ownership of p1 remains with the main function. If you remove the Copy trait derivation from the Point type, you will get a compile-time error when trying to compile the code.

不要担心Struct关键字。我们将在第五章中介绍

Destroying values - example with FeltDict

The other way linear types can be used is by being destroyed. Destruction must ensure that the 'resource' is now correctly released. In rust for example, this could be closing the access to a file, or locking a mutex. In Cairo, one type that has such behaviour is Felt252Dict. For provability, dicts must be 'squashed' when they are destructed. This would be very easy to forget, so it is enforced by the type system and the compiler.

No-op destruction: the Drop Trait

You may have noticed that the Point type in the previous example also implements the Drop trait. For example, the following code will not compile, because the struct A is not moved or destroyed before it goes out of scope:

struct A {}

fn main() {
    A {}; // error: Value not dropped.
}

However, types that implement the Drop trait are automatically destroyed when going out of scope. This destruction does nothing, it is a no-op - simply a hint to the compiler that this type can safely be destroyed once it's no longer useful. We call this "dropping" a value.

At the moment, the Drop implementation can be derived for all types, allowing them to be dropped when going out of scope, except for dictionaries (Felt252Dict) and types containing dictionaries. For example, the following code compiles:

#[derive(Drop)]
struct A {}

fn main() {
    A {}; // Now there is no error.
}

Destruction with a side-effect: the Destruct trait

When a value is destroyed, the compiler first tries to call the drop method on that type. If it doesn't exist, then the compiler tries to call destruct instead. This method is provided by the Destruct trait.

As said earlier, dictionaries in Cairo are types that must be "squashed" when destructed, so that the sequence of access can be proven. This is easy for developers to forget, so instead dictionaries implement the Destruct trait to ensure that all dictionaries are squashed when going out of scope. As such, the following example will not compile:


struct A {
    dict: Felt252Dict<u128>
}

fn main() {
    A { dict: Default::default() };
}

如果你试图运行这段代码,你会得到一个编译时错误:

error: Variable not dropped. Trait has no implementation in context: core::traits::Drop::<temp7::temp7::A>. Trait has no implementation in context: core::traits::Destruct::<temp7::temp7::A>.
 --> temp7.cairo:7:5
    A {
    ^*^

当A超出作用域时,它不能被丢弃,因为它既没有实现Drop(因为它包含一个字典,不能派生derive(Drop))也没有实现Destruct trait。为了解决这个问题,我们可以为A类型派生出Destruct trait的实现:

#[derive(Destruct)]
struct A {
    dict: Felt252Dict<u128>
}

fn main() {
    A { dict: Default::default() }; // No error here
}

现在,当A超出作用域时,它的字典将被自动squashed,并且程序将被编译。

用Clone复制数组数据

If we do want to deeply copy the data of an Array, we can use a common method called clone. We’ll discuss method syntax in Chapter 6, but because methods are a common feature in many programming languages, you’ve probably seen them before.

下面是一个 clone 方法的实例。

use clone::Clone;
use array::ArrayTCloneImpl;
fn main() {
    let arr1 = ArrayTrait::<u128>::new();
    let arr2 = arr1.clone();
}

When you see a call to clone, you know that some arbitrary code is being executed and that code may be expensive. It’s a visual indicator that something different is going on. In this case, value is being copied, resulting in new memory cells being used, and the a new variable is created, referring to the new, copied value.

返回值与作用域

Returning values is equivalent to moving them. Listing 4-4 shows an example of a function that returns some value, with similar annotations as those in Listing 4-3.

文件名: src/lib.cairo

#[derive(Drop)]
struct A {}

fn main() {
    let a1 = gives_ownership();           // gives_ownership moves its return
                                          // value into a1

    let a2 = A {};                        // a2 comes into scope

    let a3 = takes_and_gives_back(a2);    // a2 is moved into
                                          // takes_and_gives_back, which also
                                          // moves its return value into a3

} // Here, a3 goes out of scope and is dropped. a2 was moved, so nothing
  // happens. a1 goes out of scope and is dropped.

fn gives_ownership() -> A {               // gives_ownership will move its
                                          // return value into the function
                                          // that calls it

    let some_a = A {};                    // some_a comes into scope

    some_a                                // some_a is returned and
                                          // moves ownership to the calling
                                          // function
}

// This function takes an instance some_a of A and returns it
fn takes_and_gives_back(some_a: A) -> A { // some_a comes into
                                          // scope

    some_a                               // some_a is returned and moves
                                         // ownership to the calling
                                         // function
}

Listing 4-4: Moving return values

While this works, moving into and out of every function is a bit tedious. What if we want to let a function use a value but not move the value? It’s quite annoying that anything we pass in also needs to be passed back if we want to use it again, in addition to any data resulting from the body of the function that we might want to return as well.

Cairo的确让我们可以使用一个元组返回多个值,如示例4-5所示。

文件名: src/lib.cairo

fn main() {
    let arr1 = ArrayTrait::<u128>::new();

    let (arr2, len) = calculate_length(arr1);
}

fn calculate_length(arr: Array<u128>) -> (Array<u128>, usize) {
    let length = arr.len(); // len() returns the length of an array

    (arr, length)
}

Listing 4-5: Returning many values

But this is too much ceremony and a lot of work for a concept that should be common. Luckily for us, Cairo has two features for passing a value without destroying or moving it, called references and snapshots.

Last change: 2023-12-09, commit: 5b91d5d

引用和快照

示例4-5中元组代码的问题是,因为 Array的所有权被移到了calculate_length中。 我们必须返回Array给调用的函数,这样我们在调用calculate_length后才仍然可以使用Array

快照(Snapshots)

In the previous chapter, we talked about how Cairo's ownership system prevents us from using a variable after we've moved it, protecting us from potentially writing twice to the same memory cell. However, it's not very convenient. Let's see how we can retain ownership of the variable in the calling function using snapshots.

In Cairo, a snapshot is an immutable view of a value at a certain point in time. Recall that memory is immutable, so modifying a value actually creates a new memory cell. The old memory cell still exists, and snapshots are variables that refer to that "old" value. In this sense, snapshots are a view "into the past".

下面是你如何定义和使用一个calculate_length 函数,它以一个快照作为参数,而不是获取底层值的所有权。在这个例子中、 calculate_length函数返回作为参数的数组的长度。 因为我们是以快照的形式传递的,这是一个不可改变的数组视图,我们可以确定 calculate_length函数不会改变数组,数组的所有权被保留在主函数中。

文件名: src/lib.cairo

use debug::PrintTrait;

fn main() {
    let mut arr1 = ArrayTrait::<u128>::new();
    let first_snapshot = @arr1; // Take a snapshot of `arr1` at this point in time
    arr1.append(1); // Mutate `arr1` by appending a value
    let first_length = calculate_length(
        first_snapshot
    ); // Calculate the length of the array when the snapshot was taken
    //ANCHOR: function_call
    let second_length = calculate_length(@arr1); // Calculate the current length of the array
    //ANCHOR_END: function_call
    first_length.print();
    second_length.print();
}

fn calculate_length(arr: @Array<u128>) -> usize {
    arr.len()
}

注意:只有在数组快照上才能调用 len() 方法,因为它在 ArrayTrait trait中被定义成这样。如果你试图在一个快照上调用一个没有为快照定义的方法,你会得到一个编译错误。然而,你可以在非快照类型上调用快照的方法。

这个程序的输出是:

[DEBUG]	                               	(raw: 0)

[DEBUG]	                              	(raw: 1)

Run completed successfully, returning []

首先,注意到变量声明和函数返回值中的所有元组代码都消失了。 第二,注意看我们把@arr1传入calculate_length,因此在它的定义中,我们采用@Array<u128>,而不是Array<u128>

让我们仔细看一下这里的函数调用:

use debug::PrintTrait;

fn main() {
    let mut arr1 = ArrayTrait::<u128>::new();
    let first_snapshot = @arr1; // Take a snapshot of `arr1` at this point in time
    arr1.append(1); // Mutate `arr1` by appending a value
    let first_length = calculate_length(
        first_snapshot
    ); // Calculate the length of the array when the snapshot was taken
    let second_length = calculate_length(@arr1); // Calculate the current length of the array
    first_length.print();
    second_length.print();
}

fn calculate_length(arr: @Array<u128>) -> usize {
    arr.len()
}

The @arr1 syntax lets us create a snapshot of the value in arr1. Because a snapshot is an immutable view of a value at a specific point in time, the usual rules of the linear type system are not enforced. In particular, snapshot variables are always Drop, never Destruct, even dictionary snapshots.

同样,函数的签名使用@来表示参数arr的类型是一个快照。让我们添加一些解释性的注解:

fn calculate_length(
    array_snapshot: @Array<u128>
) -> usize { // array_snapshot is a snapshot of an Array
    array_snapshot.len()
} // Here, array_snapshot goes out of scope and is dropped.
// However, because it is only a view of what the original array `arr` contains, the original `arr` can still be used.

变量array_snapshot的有效范围与任何函数参数的范围相同,但当array_snapshot停止使用时,快照的底层值不会被丢弃。当函数有快照作为参数而不是实际的值时,我们将不需要返回值以归还原始值的所有权,因为我们从未拥有过它。

Desnap 操作符

To convert a snapshot back into a regular variable, you can use the desnap operator *, which serves as the opposite of the @ operator.

Only Copy types can be desnapped. However, in the general case, because the value is not modified, the new variable created by the desnap operator reuses the old value, and so desnapping is a completely free operation, just like Copy.

在下面的示例中,我们要计算一个矩形的面积,但我们不想在calculate_area函数中取得矩形的所有权,因为我们可能想在函数调用后再次使用该矩形。由于我们的函数不会更改矩形实例,因此我们可以将矩形的快照传递给函数,然后使用 desnap 操作符 * 将快照转换回值。

use debug::PrintTrait;

#[derive(Copy, Drop)]
struct Rectangle {
    height: u64,
    width: u64,
}

fn main() {
    let rec = Rectangle { height: 3, width: 10 };
    let area = calculate_area(@rec);
    area.print();
}

fn calculate_area(rec: @Rectangle) -> u64 {
    // As rec is a snapshot to a Rectangle, its fields are also snapshots of the fields types.
    // We need to transform the snapshots back into values using the desnap operator `*`.
    // This is only possible if the type is copyable, which is the case for u64.
    // Here, `*` is used for both multiplying the height and width and for desnapping the snapshots.
    *rec.height * *rec.width
}

但是,如果我们试图修改我们作为快照传递的东西会发生什么?试试下面的代码 示例4-6。剧透一下:它不起作用!

文件名: src/lib.cairo

#[derive(Copy, Drop)]
struct Rectangle {
    height: u64,
    width: u64,
}

fn main() {
    let rec = Rectangle { height: 3, width: 10 };
    flip(@rec);
}

fn flip(rec: @Rectangle) {
    let temp = rec.height;
    rec.height = rec.width;
    rec.width = temp;
}

示例4-6:试图修改一个快照值

这里有一个错误:

error: Invalid left-hand side of assignment.
 --> ownership.cairo:15:5
    rec.height = rec.width;
    ^********^

编译器阻止我们修改与快照相关的值。

可变引用

在示例4-6中,我们也可以通过使用 mutable reference 而不是快照来实现我们想要的行为。可变引用实际上是传递给函数的可变值,在函数结束时被隐式返回,将所有权返回给调用的上下文。通过这样做,它们允许你对传递的值进行改变,同时通过在执行结束时自动返回来保持对它的所有权。 在Cairo中,一个参数可以使用ref修饰符作为 mutable reference 传递。

注意:在Cairo中,只有在变量用mut声明为可变的情况下,才能使用ref修饰符将参数作为可变的引用传递。

在示例4-7中,我们使用一个可变的引用来修改Rectangle实例在flip函数中的heightwidth字段的值。

use debug::PrintTrait;
#[derive(Copy, Drop)]
struct Rectangle {
    height: u64,
    width: u64,
}

fn main() {
    let mut rec = Rectangle { height: 3, width: 10 };
    flip(ref rec);
    rec.height.print();
    rec.width.print();
}

fn flip(ref rec: Rectangle) {
    let temp = rec.height;
    rec.height = rec.width;
    rec.width = temp;
}

示例 4-7:使用一个可变的引用来修改一个值

首先,我们把rec改成mut。然后我们用 ref recrec 的可变引用传入 flip ,并更新函数签名,用 ref rec: Rectangle接受可变引用。这很清楚地表明,flip函数将改变作为参数传递的Rectangle实例的值。

程序的输出是:

[DEBUG]
                                (raw: 10)

[DEBUG]	                        (raw: 3)

正如预期的那样, rec 变量的 heightwidth 字段被调换了。

小结

Let’s recap what we’ve discussed about the linear type system, ownership, snapshots, and references:

  • 在任何时候,一个变量只能有一个所有者。
  • 你可以将一个变量以值的方式、以快照的方式、或以引用的方式传递给一个函数。
  • 如果你按值传递,变量的所有权就会转移到函数中。
  • 如果你想保留变量的所有权,并且知道你的函数不会改变它,你可以用@把它作为一个快照传递。
  • 如果你想保留变量的所有权,并且知道你的函数会改变它,你可以用ref把它作为一个可改变的引用来传递。
Last change: 2023-12-08, commit: 7c6a72a

使用结构体组织相关联的数据

结构体( struct ),或称 structure ,是一种自定义的数据类型,允许你包装和命名多个相关的值,从而形成一个有意义的组合。如果你熟悉一门面向对象语言,struct 就像对象中的数据属性。在本章中,我们会对元组和结构体进行比较和对比,并演示什么时候结构体是一种更好的数据分组方式。

我们还将演示如何定义和实例化结构体,并讨论如何定义关联函数,特别是被称为 方法 的关联函数,以指定与结构体类型相关的行为。你可以在程序中基于结构体和枚举(enum)(将在下一章讨论)创建新类型,以充分利用 Cairo 的编译时类型检查。

Last change: 2023-09-20, commit: cbb0049

结构体的定义和实例化

结构体与数据类型一节中讨论的元组类似,它们都包含多个相关的值。和元组一样,结构体的每一部分可以是不同类型。但不同于元组,结构体需要命名各部分数据以便能清楚的表明其值的意义。由于有了这些命名,结构体比元组更灵活:不需要依赖顺序来指定或访问实例中的值。

定义结构体,需要使用 struct 关键字并为整个结构体提供一个名字。结构体的名字需要描述它所组合的数据的意义。接着,在大括号中,定义每一部分数据的名字和类型,我们称为 字段(field)。例如,示例 5-1 展示了一个存储用户账号信息的结构体。

文件名: src/lib.cairo

#[derive(Copy, Drop)]
struct User {
    active: bool,
    username: felt252,
    email: felt252,
    sign_in_count: u64,
}

示例5-1:一个 User 结构定义

一旦定义了结构体后,为了使用它,通过为每个字段指定具体值来创建这个结构体的 实例。 我们创建一个实例需要以结构体的名字开头,接着在大括号中使用 key: value 键 - 值对的形式提供字段,其中 key 是字段的名字,value 是需要存储在字段中的数据值。实例中字段的顺序不需要和它们在结构体中声明的顺序一致。换句话说,结构体的定义就像一个类型的通用模板,而实例则会在这个模板中放入特定数据来创建这个类型的值。

例如,我们可以如示例5-2所示声明一个特定的用户。

文件名: src/lib.cairo

#[derive(Copy, Drop)]
struct User {
    active: bool,
    username: felt252,
    email: felt252,
    sign_in_count: u64,
}
fn main() {
    let user1 = User {
        active: true, username: 'someusername123', email: 'someone@example.com', sign_in_count: 1
    };
}

示例5-2:创建一个User结构的实例

为了从结构体中获取某个特定的值,可以使用点号。举个例子,想要用户的邮箱地址,可以用 user1.email。如果结构体的实例是可变的,我们可以使用点号并为对应的字段赋值。示例 5-3 展示了如何改变一个可变的 User 实例中 email 字段的值。

文件名: src/lib.cairo

#[derive(Copy, Drop)]
struct User {
    active: bool,
    username: felt252,
    email: felt252,
    sign_in_count: u64,
}
fn main() {
    let mut user1 = User {
        active: true, username: 'someusername123', email: 'someone@example.com', sign_in_count: 1
    };
    user1.email = 'anotheremail@example.com';
}

fn build_user(email: felt252, username: felt252) -> User {
    User { active: true, username: username, email: email, sign_in_count: 1, }
}

fn build_user_short(email: felt252, username: felt252) -> User {
    User { active: true, username, email, sign_in_count: 1, }
}

示例5-3:改变User实例的电子邮件字段中的值

注意,整个实例必须是可变的;Cairo不允许我们只把某些字段标记为可变的。

与任何表达式一样,我们可以在函数主体的最后一个表达式中构造一个新的结构体实例,以隐式返回该新实例。

示例5-4显示了一个build_user函数,该函数返回一个User实例,并给出了电子邮件和用户名。active字段的值为truesign_in_count的值为1

文件名: src/lib.cairo

#[derive(Copy, Drop)]
struct User {
    active: bool,
    username: felt252,
    email: felt252,
    sign_in_count: u64,
}
fn main() {
    let mut user1 = User {
        active: true, username: 'someusername123', email: 'someone@example.com', sign_in_count: 1
    };
    user1.email = 'anotheremail@example.com';
}

fn build_user(email: felt252, username: felt252) -> User {
    User { active: true, username: username, email: email, sign_in_count: 1, }
}

fn build_user_short(email: felt252, username: felt252) -> User {
    User { active: true, username, email, sign_in_count: 1, }
}

示例5-4:一个build_user函数,接收电子邮件和用户名,并返回一个User实例

为函数参数起与结构体字段相同的名字是可以理解的,但必须重复emailusername字段的名称和变量就有点乏味了。如果结构体有更多字段,重复每个名称就更加烦人了。幸运的是,有一个方便的简写语法!

使用字段初始化简写语法

因为示例 5-4 中的参数名与字段名都完全相同,我们可以使用字段初始化简写语法(field init shorthand)来重写 build_user。如示例 5-5 所示,重写后其行为与之前完全相同,不过无需重复 usernameemail 了。

文件名: src/lib.cairo

#[derive(Copy, Drop)]
struct User {
    active: bool,
    username: felt252,
    email: felt252,
    sign_in_count: u64,
}
fn main() {
    let mut user1 = User {
        active: true, username: 'someusername123', email: 'someone@example.com', sign_in_count: 1
    };
    user1.email = 'anotheremail@example.com';
}

fn build_user(email: felt252, username: felt252) -> User {
    User { active: true, username: username, email: email, sign_in_count: 1, }
}

fn build_user_short(email: felt252, username: felt252) -> User {
    User { active: true, username, email, sign_in_count: 1, }
}

示例5-5: build_user函数使用了字段初始化简写语法,因为usernameemail参数与结构体字段同名,

这里,我们正在创建一个新的 User 结构体实例,它有一个名为 email的字段。我们希望将email字段的值设置为build_user函数的email参数中的值。因为email字段和email参数有相同的名字,我们只需要写email而不是email: email

Last change: 2023-09-20, commit: cbb0049

结构体示例程序

为了理解何时会需要使用结构体,让我们编写一个计算长方形面积的程序。我们会从单独的变量开始,接着重构程序直到使用结构体替代他们为止。

让我们用Scarb创建一个名为 rectangles 的新项目,它获取以像素为单位的长方形的宽度和高度,并计算出长方形的面积。示例5-6显示了位于项目中的 src/lib.cairo 中的小程序,它刚好实现此功能。

文件名: src/lib.cairo

use debug::PrintTrait;
fn main() {
    let width1 = 30;
    let height1 = 10;
    let area = area(width1, height1);
    area.print();
}

fn area(width: u64, height: u64) -> u64 {
    width * height
}

示例5-6:通过分别指定长方形的宽和高的变量来计算长方形面积

现在用scarb cairo-run运行该程序:

$ scarb cairo-run
[DEBUG] ,                               (raw: 300)

Run completed successfully, returning []

这段代码通过调用每个维度的area函数,成功地算出了矩形的面积,但我们仍然可以修改这段代码来使它的意义更加明确,并且增加可读性。

这段代码的问题在 area 的签名中很明显:

fn area(width: u64, height: u64) -> u64 {

area函数应该是计算一个矩形的面积,但是我们写的函数有两个参数,而且在我们的程序中没有任何地方明确说明这些参数的关系。如果把宽度和高度放在一起,会更有可读性,也更容易管理。我们已经在第二章中讨论了一种我们可以做到的方法:使用元组。

使用元组重构

示例5-7显示了我们使用元组的另一个程序版本。

文件名: src/lib.cairo

use debug::PrintTrait;
fn main() {
    let rectangle = (30, 10);
    let area = area(rectangle);
    area.print(); // print out the area
}

fn area(dimension: (u64, u64)) -> u64 {
    let (x, y) = dimension;
    x * y
}

示例5-7:用一个元组指定矩形的宽度和高度

在某种程度上说,这个程序更好一点了。元组帮助我们增加了一些结构性,并且现在只需传一个参数。不过在另一方面,这个版本却有一点不明确了:元组并没有给出元素的名称,所以计算变得更费解了,因为不得不使用索引来获取元组的每一部分。

混淆宽度和高度对于计算面积来说并不重要,但是如果我们想计算差值,那就很重要了。我们必须记住 width 是元组索引0height 是元组索引1。如果其他人要使用这些代码,他们必须要搞清楚这一点,并也要牢记于心。很容易忘记或者混淆这些值而造成错误,因为我们没有在代码中传达数据的意图。

使用结构体重构:赋予更多意义

我们使用结构体为数据命名来为其赋予意义。我们可以将我们正在使用的元组转换成一个有整体名称而且每个部分也有对应名字的结构体。

文件名: src/lib.cairo

use debug::PrintTrait;

struct Rectangle {
    width: u64,
    height: u64,
}

fn main() {
    let rectangle = Rectangle { width: 30, height: 10, };
    let area = area(rectangle);
    area.print(); // print out the area
}

fn area(rectangle: Rectangle) -> u64 {
    rectangle.width * rectangle.height
}

示例 5-8:定义一个Rectangle结构

这里我们定义了一个结构,并将其命名为 Rectangle。在大括号中,我们将字段定义为 widthheight,它们的类型都是 u64。然后,在main中,我们创建了一个Rectangle的特殊实例,它的宽度是30,高度是10。我们的 area函数现在定义了一个名为 rectangle参数,它是Rectangle结构类型。然后我们可以用点符号来访问实例的字段,它给这些值起了描述性的名字,而不是使用01的元组索引值。结构体胜在更清晰明了。

用Trait增加实用功能

在调试程序时打印出 Rectangle 实例来查看其所有字段的值非常有用。示例 5-9 像前面章节那样尝试使用 print。但这并不管用。

文件名: src/lib.cairo

use debug::PrintTrait;

struct Rectangle {
    width: u64,
    height: u64,
}

fn main() {
    let rectangle = Rectangle { width: 30, height: 10, };
    rectangle.print();
}

示例 5-9:试图打印一个 Rectangle实例

当我们编译这段代码时,我们得到了一个错误,有这样的信息:

$ cairo-compile src/lib.cairo
error: Method `print` not found on type "../src::Rectangle". Did you import the correct trait and impl?
 --> lib.cairo:16:15
    rectangle.print();
              ^***^

Error: Compilation failed.

许多数据类型都实现了 print trait,但 Rectangle 结构没有。我们可以通过在Rectangle上实现PrintTrait trait来解决这个问题,如示例5-10所示。 要了解更多关于traits的信息,请参阅Traits in Cairo

文件名: src/lib.cairo

use debug::PrintTrait;

struct Rectangle {
    width: u64,
    height: u64,
}

fn main() {
    let rectangle = Rectangle { width: 30, height: 10, };
    rectangle.print();
}

impl RectanglePrintImpl of PrintTrait<Rectangle> {
    fn print(self: Rectangle) {
        self.width.print();
        self.height.print();
    }
}

示例5-10:在Rectangle上实现PrintTrait trait

很好!这不是最漂亮的输出,但它显示了这个实例的所有字段的值,这在调试时肯定会有帮助。

Last change: 2023-09-20, commit: cbb0049

方法语法(Method Syntax)

Methods are similar to functions: we declare them with the fn keyword and a name, they can have parameters and a return value, and they contain some code that’s run when the method is called from somewhere else. Unlike functions, methods are defined within the context of a type and their first parameter is always self, which represents the instance of the type the method is being called on. For those familiar with Rust, Cairo's approach might be confusing, as methods cannot be defined directly on types. Instead, you must define a trait and an implementation associated with the type for which the method is intended.

定义方法

Let’s change the area function that has a Rectangle instance as a parameter and instead make an area method defined on the RectangleTrait trait, as shown in Listing 5-13.

文件名: src/lib.cairo

use debug::PrintTrait;
#[derive(Copy, Drop)]
struct Rectangle {
    width: u64,
    height: u64,
}

trait RectangleTrait {
    fn area(self: @Rectangle) -> u64;
}

impl RectangleImpl of RectangleTrait {
    fn area(self: @Rectangle) -> u64 {
        (*self.width) * (*self.height)
    }
}

fn main() {
    let rect1 = Rectangle { width: 30, height: 50, };

    rect1.area().print();
}

示例5-13:定义一个用在Rectangle 上的 area 方法

To define the function within the context of Rectangle, we start by defining a trait block with the signature of the method that we want to implement. Traits are not linked to a specific type; only the self parameter of the method defines which type it can be used with. Then, we define an impl (implementation) block for RectangleTrait, that defines the behavior of the methods implemented. Everything within this impl block will be associated with the type of the self parameter of the method called. While it is technically possible to define methods for multiple types within the same impl block, it is not a recommended practice, as it can lead to confusion. We recommend that the type of the self parameter stays consistent within the same impl block. Then we move the area function within the impl curly brackets and change the first (and in this case, only) parameter to be self in the signature and everywhere within the body. In main, where we called the area function and passed rect1 as an argument, we can instead use the method syntax to call the area method on our Rectangle instance. The method syntax goes after an instance: we add a dot followed by the method name, parentheses, and any arguments.

Methods must have a parameter named self of the type they will be applied to for their first parameter. Note that we used the @ snapshot operator in front of the Rectangle type in the function signature. By doing so, we indicate that this method takes an immutable snapshot of the Rectangle instance, which is automatically created by the compiler when passing the instance to the method. Methods can take ownership of self, use self with snapshots as we’ve done here, or use a mutable reference to self using the ref self: T syntax.

We chose self: @Rectangle here for the same reason we used @Rectangle in the function version: we don’t want to take ownership, and we just want to read the data in the struct, not write to it. If we wanted to change the instance that we’ve called the method on as part of what the method does, we’d use ref self: Rectangle as the first parameter. Having a method that takes ownership of the instance by using just self as the first parameter is rare; this technique is usually used when the method transforms self into something else and you want to prevent the caller from using the original instance after the transformation.

Observe the use of the desnap operator * within the area method when accessing the struct's members. This is necessary because the struct is passed as a snapshot, and all of its field values are of type @T, requiring them to be desnapped in order to manipulate them.

The main reason for using methods instead of functions is for organization and code clarity. We’ve put all the things we can do with an instance of a type in one combination of trait & impl blocks, rather than making future users of our code search for capabilities of Rectangle in various places in the library we provide. However, we can define multiple combinations of trait & impl blocks for the same type at different places, which can be useful for a more granular code organization. For example, you could implement the Add trait for your type in one impl block, and the Sub trait in another block.

请注意,我们可以选择将方法的名称与结构中的一个字段相同。例如,我们可以在 Rectangle 上定义一个方法,并命名为width

文件名: src/lib.cairo

use debug::PrintTrait;
#[derive(Copy, Drop)]
struct Rectangle {
    width: u64,
    height: u64,
}

trait RectangleTrait {
    fn width(self: @Rectangle) -> bool;
}

impl RectangleImpl of RectangleTrait {
    fn width(self: @Rectangle) -> bool {
        (*self.width) > 0
    }
}

fn main() {
    let rect1 = Rectangle { width: 30, height: 50, };
    rect1.width().print();
}

在这里,我们选择让width方法在实例的width字段中的值大于0时返回true,在值为0时返回false :我们可以在同名方法的字段内使用任何目的。在main中,当我们在rect1.width后面跟着括号时,Cairo知道我们意思是width方法。当我们不使用括号时,Cairo知道我们指的是width字段。

带有更多参数的方法

Let’s practice using methods by implementing a second method on the Rectangle struct. This time we want an instance of Rectangle to take another instance of Rectangle and return true if the second Rectangle can fit completely within self (the first Rectangle); otherwise, it should return false. That is, once we’ve defined the can_hold method, we want to be able to write the program shown in Listing 5-14.

文件名: src/lib.cairo

use debug::PrintTrait;
#[derive(Copy, Drop)]
struct Rectangle {
    width: u64,
    height: u64,
}

fn main() {
    let rect1 = Rectangle { width: 30, height: 50, };
    let rect2 = Rectangle { width: 10, height: 40, };
    let rect3 = Rectangle { width: 60, height: 45, };

    'Can rect1 hold rect2?'.print();
    rect1.can_hold(@rect2).print();

    'Can rect1 hold rect3?'.print();
    rect1.can_hold(@rect3).print();
}

示例5-14:使用尚未编写的can_hold方法

预期的输出结果如下,因为rect2的两个尺寸都小于rect1的尺寸。 但rect3的宽度大于rect1

$ scarb cairo-run
[DEBUG]	Can rec1 hold rect2?           	(raw: 384675147322001379018464490539350216396261044799)

[DEBUG]	true                           	(raw: 1953658213)

[DEBUG]	Can rect1 hold rect3?          	(raw: 384675147322001384331925548502381811111693612095)

[DEBUG]	false                          	(raw: 439721161573)

We know we want to define a method, so it will be within the trait RectangleTrait and impl RectangleImpl of RectangleTrait blocks. The method name will be can_hold, and it will take a snapshot of another Rectangle as a parameter. We can tell what the type of the parameter will be by looking at the code that calls the method: rect1.can_hold(@rect2) passes in @rect2, which is a snapshot to rect2, an instance of Rectangle. This makes sense because we only need to read rect2 (rather than write, which would mean we’d need a mutable borrow), and we want main to retain ownership of rect2 so we can use it again after calling the can_hold method. The return value of can_hold will be a Boolean, and the implementation will check whether the width and height of self are greater than the width and height of the other Rectangle, respectively. Let’s add the new can_hold method to the trait and impl blocks from Listing 5-13, shown in Listing 5-15.

文件名: src/lib.cairo

#![allow(unused)]
fn main() {
trait RectangleTrait {
    fn area(self: @Rectangle) -> u64;
    fn can_hold(self: @Rectangle, other: @Rectangle) -> bool;
}

impl RectangleImpl of RectangleTrait {
    fn area(self: @Rectangle) -> u64 {
        *self.width * *self.height
    }

    fn can_hold(self: @Rectangle, other: @Rectangle) -> bool {
        *self.width > *other.width && *self.height > *other.height
    }
}
}

示例5-15: 在Rectangle上实现can_hold方法,该方法接收另一个Rectangle实例作为参数

当我们在示例 5-14中的main函数中运行这段代码时,我们将得到我们想要的输出。 方法可以接收多个参数,我们可以在self参数之后在函数签名中添加这些参数,这些参数与函数中的参数工作原理相同。

访问实现里的函数

All functions defined within a trait and impl block can be directly addressed using the :: operator on the implementation name. Functions in traits that aren’t methods are often used for constructors that will return a new instance of the struct. These are often called new, but new isn’t a special name and isn’t built into the language. For example, we could choose to provide an associated function named square that would have one dimension parameter and use that as both width and height, thus making it easier to create a square Rectangle rather than having to specify the same value twice:

文件名: src/lib.cairo

trait RectangleTrait {
    fn square(size: u64) -> Rectangle;
}

impl RectangleImpl of RectangleTrait {
    fn square(size: u64) -> Rectangle {
        Rectangle { width: size, height: size }
    }
}

To call this function, we use the :: syntax with the implementation name; let square = RectangleImpl::square(10); is an example. This function is namespaced by the implementation; the :: syntax is used for both trait functions and namespaces created by modules. We’ll discuss modules in [Chapter 8][modules].

Note: It is also possible to call this function using the trait name, with RectangleTrait::square(10).

多个impl

Each struct is allowed to have multiple trait and impl blocks. For example, Listing 5-15 is equivalent to the code shown in Listing 5-16, which has each method in its own trait and impl blocks.

trait RectangleCalc {
    fn area(self: @Rectangle) -> u64;
}
impl RectangleCalcImpl of RectangleCalc {
    fn area(self: @Rectangle) -> u64 {
        (*self.width) * (*self.height)
    }
}

trait RectangleCmp {
    fn can_hold(self: @Rectangle, other: @Rectangle) -> bool;
}

impl RectangleCmpImpl of RectangleCmp {
    fn can_hold(self: @Rectangle, other: @Rectangle) -> bool {
        *self.width > *other.width && *self.height > *other.height
    }
}

示例5-16:使用多个impl重写示例5-15 块

There’s no reason to separate these methods into multiple trait and impl blocks here, but this is valid syntax. We’ll see a case in which multiple blocks are useful in Chapter 8, where we discuss generic types and traits.

总结

Structs let you create custom types that are meaningful for your domain. By using structs, you can keep associated pieces of data connected to each other and name each piece to make your code clear. In trait and impl blocks, you can define methods, which are functions associated to a type and let you specify the behavior that instances of your type have.

但结构体并不是创建自定义类型的唯一方法:让我们转向 Cairo 的枚举功能,为你的工具箱再添一个工具。

Last change: 2023-12-10, commit: 370d5b6

枚举和模式匹配

在本章中,我们将介绍 枚举(enumerations) ,也称为 enums 。 枚举允许您通过枚举其可能的 variants 来定义类型。 首先我们将定义并使用枚举来展示枚举如何对含义进行编码以及数据。 接下来,我们将探索一个特别有用的枚举,称为Option, 它的表示值可以是某物,也可以是虚无。 最后,我们来看看match表达式中,如何使用模式匹配, 使得我们可以很容易的通过不同的枚举值运行不同的代码。

Last change: 2023-10-13, commit: 78657f2

枚举

本章介绍 "枚举"(enumerations),也被称作 enums,是一种自定义数据类型的方式,它由一组固定的命名值成员组成,称为 variants 。枚举对于表示相关值的集合非常有用,其中每个值都是不同的,并且有特定的含义。

枚举成员和值

下面是一个枚举的简单例子:

#[derive(Drop)]
enum Direction {
    North,
    East,
    South,
    West,
}

在本例中,我们定义了一个名为 Direction 的枚举,它有四个变量:North, East, SouthWest。命名惯例是使用 PascalCase 来命名枚举变量。每个变量代表 Direction 类型的一个不同值。在本示例中,枚举成员没有任何关联值。使用此语法可以实例化一个变量:

#[derive(Drop)]
enum Direction {
    North,
    East,
    South,
    West,
}

fn main() {
    let direction = Direction::North;
}

我们可以很轻易的写出根据枚举的成员运行不同的流程的代码,在上面这个例子中,是根据方向来运行特定的代码。你可以在 Match 控制流结构页面上了解更多信息。

枚举与自定义类型相结合

枚举也可以用来存储与每个成员相关的更有趣的数据。比如说:

#[derive(Drop)]
enum Message {
    Quit,
    Echo: felt252,
    Move: (u128, u128),
}

在这个例子中,Message枚举有三个成员:QuitEchoMove,都有不同的类型:

  • Quit 没有任何相关值。
  • Echo 是一个单一的 felt。
  • Move是两个 u128 值组成的的元组。

你甚至可以在你的一个枚举成员中使用一个结构体或另一个你定义的枚举。

枚举的Trait实现

在Cairo中,你可以为你的自定义枚举定义trait并实现它们。这允许你定义与枚举相关的方法和行为。下面是一个定义trait并为之前的 Message 枚举实现的例子:

trait Processing {
    fn process(self: Message);
}

impl ProcessingImpl of Processing {
    fn process(self: Message) {
        match self {
            Message::Quit => { 'quitting'.print(); },
            Message::Echo(value) => { value.print(); },
            Message::Move((x, y)) => { 'moving'.print(); },
        }
    }
}

在这个例子中,我们为Message实现了Processing trait。下面是如何用它来处理一条退出消息:

use debug::PrintTrait;
#[derive(Drop)]
enum Message {
    Quit,
    Echo: felt252,
    Move: (u128, u128),
}

trait Processing {
    fn process(self: Message);
}

impl ProcessingImpl of Processing {
    fn process(self: Message) {
        match self {
            Message::Quit => { 'quitting'.print(); },
            Message::Echo(value) => { value.print(); },
            Message::Move((x, y)) => { 'moving'.print(); },
        }
    }
}
fn main() {
    let msg: Message = Message::Quit;
    msg.process();
}

运行这段代码会打印出 quitting

Option枚举及其优势

Option枚举是一个标准的Cairo枚举,表示一个可选值的概念。它有两个变量:Some: TNone: ()Some:T表示有一个T类型的值,而None表示没有值。

enum Option<T> {
    Some: T,
    None: (),
}

Option 枚举很有用,因为它允许你明确地表示一个值不存在的可能性,使你的代码更具表现力,更容易推理。使用 Option 也可以帮助防止因使用未初始化的或意外的 null 值而引起的错误。

为了给你一个例子,这里有一个函数,它返回一个给定值的数组中第一个元素的索引,如果该元素不存在则返回None。

我们为上述函数演示了两种方法:

  • 递归法 find_value_recursive
  • 迭代法 find_value_iterative

注意:将来最好能用循环和无需 gas 的相关代码的简单示例替换此示例。

fn find_value_recursive(arr: @Array<felt252>, value: felt252, index: usize) -> Option<usize> {
    if index >= arr.len() {
        return Option::None;
    }

    if *arr.at(index) == value {
        return Option::Some(index);
    }

    find_value_recursive(arr, value, index + 1)
}

fn find_value_iterative(arr: @Array<felt252>, value: felt252) -> Option<usize> {
    let length = arr.len();
    let mut index = 0;
    let mut found: Option<usize> = Option::None;
    loop {
        if index < length {
            if *arr.at(index) == value {
                found = Option::Some(index);
                break;
            }
        } else {
            break;
        }
        index += 1;
    };
    return found;
}

#[cfg(test)]
mod tests {
    use debug::PrintTrait;
    use super::{find_value_recursive, find_value_iterative};

    #[test]
    #[available_gas(999999)]
    fn test_increase_amount() {
        let mut my_array = ArrayTrait::new();
        my_array.append(3);
        my_array.append(7);
        my_array.append(2);
        my_array.append(5);

        let value_to_find = 7;
        let result = find_value_recursive(@my_array, value_to_find, 0);
        let result_i = find_value_iterative(@my_array, value_to_find);

        match result {
            Option::Some(index) => { if index == 1 {
                'it worked'.print();
            } },
            Option::None => { 'not found'.print(); },
        }
        match result_i {
            Option::Some(index) => { if index == 1 {
                'it worked'.print();
            } },
            Option::None => { 'not found'.print(); },
        }
    }
}

运行这段代码会打印出 it worked

Last change: 2023-09-20, commit: cbb0049

match控制流结构

Cairo 有一个叫做 match 的极为强大的控制流运算符,它允许我们将一个值与一系列的模式相比较,并根据相匹配的模式执行相应代码。模式可由字面值、变量、通配符和许多其他内容构成;第十八章会涉及到所有不同种类的模式以及它们的作用。match 的力量来源于模式的表现力以及编译器检查,它确保了所有可能的情况都得到处理。

可以把 match 表达式想象成某种硬币分类器:硬币滑入有着不同大小孔洞的轨道,每一个硬币都会掉入符合它大小的孔洞。同样地,值也会通过 match 的每一个模式,并且在遇到第一个 “符合” 的模式时,值会进入相关联的代码块并在执行中被使用。

因为刚刚提到了硬币,让我们用它们来作为一个使用 match 的例子!我们可以编写一个函数来获取一个未知的硬币,并以一种类似验钞机的方式,确定它是何种硬币,并返回其价值(美分),如示例6-3所示。

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter,
}

fn value_in_cents(coin: Coin) -> felt252 {
    match coin {
        Coin::Penny => 1,
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter => 25,
    }
}

示例6-3:一个枚举和一个将枚举成员作为其模式的 match 表达式

拆开 value_in_cents 函数中的 match 来看。首先,我们列出 match 关键字后跟一个表达式,在这个例子中是 coin 的值。这看起来非常像 if 所使用的条件表达式,不过这里有一个非常大的区别:对于 if,表达式必须返回一个布尔值,而这里它可以是任何类型的。本例中硬币的类型是我们在第一行定义的 Coin 枚举。

接下来是 match 分支。一个分支有两个部分:一个模式和一些代码。这里的第一个分支有一个模式,就是值 Coin::Penny(_),然后是=>操作符,把模式和要运行的代码分开。本例中的代码只是值 1。每个分支都用逗号与下一个分支隔开。

match 表达式执行时,它将结果值与每个分支的模式进行比较,依次进行。如果一个模式与值匹配,则执行与该模式相关的代码。如果该模式不匹配该值,则继续执行下一个分支,就像验钞机一样。我们可以根据自己的需要有多少个分支:在上面的例子中,我们的匹配有四个分支。

在 Cairo,分支的顺序必须遵循与枚举相同的顺序。

与每个 match 相关的代码是一个表达式,而 match 分支中表达式的结果值是整个 match 表达式被返回的值。

如果 match 分支的代码很短,我们通常不使用大括号,就像在我们的示例中一样,每个分支只是返回一个值。如果你想在一个 match 分支中运行多行代码,你必须使用大括号,在 match 分支后面加一个逗号。例如,下面的代码在每次调用 Coin::Penny 方法时都会打印出 “Lucky penny!”,但仍然返回区块的最后一个值 1

fn value_in_cents(coin: Coin) -> felt252 {
    match coin {
        Coin::Penny => {
            ('Lucky penny!').print();
            1
        },
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter => 25,
    }
}

绑定值的模式

另一个match分支的有用的特点是它们可以绑定到值中与模式相匹配的部分。这就是如何从枚举成员中提取数值的方法。

作为一个例子,让我们修改枚举的一个成员来存放数据。1999 年到 2008 年间,美国在 25 美分的硬币的一侧为 50 个州的每一个都印刷了不同的设计。其他的硬币都没有这种区分州的设计,所以只有这些 25 美分硬币有特殊的价值。我们可以通过改变 Quarter 变量,使其包含一个UsState 的值,从而将这个信息添加到我们的 enum 中,我们在示例6-4中已经这样做了。

#[derive(Drop)]
enum UsState {
    Alabama,
    Alaska,
}

#[derive(Drop)]
enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter: UsState,
}

列表6-4: 一个 Coin 枚举,其中 Quarter 变量也持有一个 UsState

想象一下我们的一个朋友尝试收集所有 50 个州的 25 美分硬币。在根据硬币类型分类零钱的同时,也可以报告出每个 25 美分硬币所对应的州名称,这样如果我们的朋友没有的话,他可以将其加入收藏。

在这段代码的match表达式中,我们在模式中添加了一个叫做 state 的变量,用来匹配变量 Coin::Quarter 的值。当 Coin::Quarter 匹配时,state 变量将与该季度的状态值绑定。然后我们可以在该分支的代码中使用 state,像这样:

fn value_in_cents(coin: Coin) -> felt252 {
    match coin {
        Coin::Penny => 1,
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter(state) => {
            state.print();
            25
        },
    }
}

为了在 Cairo 打印一个枚举的变量的值,我们需要为debug::PrintTrait 添加一个 print 函数的实现:

impl UsStatePrintImpl of PrintTrait<UsState> {
    fn print(self: UsState) {
        match self {
            UsState::Alabama => ('Alabama').print(),
            UsState::Alaska => ('Alaska').print(),
        }
    }
}

如果我们调用 value_in_cents(Coin::quarter(UsState::Alaska))coin 将是 Coin::quarter(UsState::Alaska)。当我们将该值与每个匹配臂进行比较时,没有一个匹配,直到我们到达 Coin::Quarter(state)。在这一点上,state 的绑定将是 UsState::Alaska 的值。然后我们可以在 PrintTrait 中使用该绑定,从而从 Coin 枚举变量中获得 Quarter 的内部状态值。

匹配 Option

在上一节中,我们想在使用 Option<T> 时从 Some 情况下得到内部的 T 值;我们也可以使用match 来处理 Option<T>,就像我们对 Coin 枚举所做的那样! 只不过这回比较的不再是硬币,而是比较 Option<T> 的成员,但 match 表达式的工作方式保持不变。你可以通过导入 option::OptionTrait trait来使用Option。

假设我们想写一个函数,接收一个 Option<u8>,如果里面有一个值,就把 1 加到这个值上。如果里面没有一个值,这个函数应该返回 None 值,并且不试图执行任何操作。

这个函数非常容易编写,这要归功于 match,它看起来像示例6-5。

use debug::PrintTrait;

fn plus_one(x: Option<u8>) -> Option<u8> {
    match x {
        Option::Some(val) => Option::Some(val + 1),
        Option::None => Option::None,
    }
}

fn main() {
    let five: Option<u8> = Option::Some(5);
    let six: Option<u8> = plus_one(five);
    six.unwrap().print();
    let none = plus_one(Option::None);
    none.unwrap().print();
}

示例6-5:一个在Option<u8>上使用匹配表达式的函数

注意,你的分支顺序与核心Cairo库的OptionTrait中定义的枚举顺序必须相同。

enum Option<T> {
    Some: T,
    None,
}

让我们更详细地看一下 plus_one 的第一次执行。当我们调用 plus_one(five) 时,plus_one 函数体中的变量 x 的值是 Some(5)。然后我们将其与每个匹配分支进行比较:

        Option::Some(val) => Option::Some(val + 1),

Option::Some(5)Option::Some(val)匹配吗?当然匹配!它们是相同的成员。valOption::Some 中包含的值绑定,所以 val 取值为 5 。接着匹配分支的代码被执行,所以我们在 val 的值上加上 1,并创建一个新的 Option::Some 值,里面有我们的和 6 。因为第一个分支就匹配到了,其他的分支将不再进行比较。

现在让我们考虑在我们的主函数中对 plus_one 的第二次调用,其中 xOption::None。我们进入匹配,并与第一个分支进行比较:

        Option::Some(val) => Option::Some(val + 1),

Option::Some(val) 的值不匹配 Option::None ,所以我们继续到下一个分支:

#![allow(unused)]
fn main() {
        Option::None => Option::None,
}

它是匹配的!没有值可以添加,所以程序停止,并返回 => 右边的 Option::None 值。

match 与枚举相结合在很多场景中都是有用的。你会在 Cairo 代码中看到很多这样的模式:match 一个枚举,绑定其中的值到一个变量,接着根据其值执行代码。这在一开始有点复杂,不过一旦习惯了,你会希望所有语言都拥有它!这一直是用户的最爱。

匹配是穷尽的

还有另一方面需要讨论:这些分支必须覆盖了所有的可能性。考虑一下 plus_one 函数的这个版本,它有一个 bug 并不能编译:

fn plus_one(x: Option<u8>) -> Option<u8> {
    match x {
        Option::Some(val) => Option::Some(val + 1),
    }
}
$ scarb cairo-run
    error: Unsupported match. Currently, matches require one arm per variant,
    in the order of variant definition.
    --> test.cairo:34:5
        match x {
        ^*******^
    Error: failed to compile: ./src/test.cairo

Rust 知道我们没有覆盖所有可能的情况甚至知道哪些模式被忘记了!Cairo 中的匹配是 穷尽的(exhaustive):必须穷举到最后的可能性来使代码有效。特别的在这个 Option<T> 的例子中,Cairo 防止我们忘记明确的处理 None 的情况,这让我们免于假设拥有一个实际上为空的值,从而使之前提到的价值亿万的错误不可能发生。

Match 0 与 _ 占位符

使用枚举,我们也可以对一些特定的值采取特殊的操作,但对所有其他的值采取一个默认的操作。目前只支持 0_ 操作符。

想象一下,我们正在实现一个游戏,你会获得一个 0 到 7 之间的随机数字。如果你有 0,你就赢了。若是任何其他的值,你就输了。这里有一个实现该逻辑的 match,数字是硬编码的,而不是随机值。

fn did_i_win(nb: felt252) {
    match nb {
        0 => ('You won!').print(),
        _ => ('You lost...').print(),
    }
}

第一条分支,模式是字面值 0。对于涵盖了所有其他可能的值的最后一条分支,其模式是字符 _。尽管我们没有列出 felt252 可能具有的所有值,但这段代码仍然可以编译,因为最后一个模式将匹配所有没有特别列出的值。这个通配模式满足了 match 必须被穷尽的要求。请注意,我们必须把通配分支放在最后,因为模式是按顺序评估的。如果我们把通配分支放在前面,其他的分支就不会运行,所以如果我们在通配分支之后添加分支,Cairo 会警告我们!

Last change: 2023-10-18, commit: 36d2b21

使用包、Crate 和模块管理Cairo项目

当你编写大型程序时,组织你的代码将变得越来越重要。 通过对相关的功能进行分组,并将具有不同功能的代码分开,你就可以清楚地知道在哪里可以找到实现某一特定的代码,以及到哪里去改变一个功能的工作方式。

到目前为止,我们所写的程序都是在一个文件中的一个模块中。 伴随着项目的增长,你应该通过将代码分解为多个模块和多个文件来组织代码。 伴随着包的增长,你可以将包中的部分代码提取出来,做成独立的 crate,这些 crate 则作为外部依赖项。 本章将会涵盖所有这些概念。

我们也会讨论封装来实现细节,这可以使你更高级地重用代码:你实现了一个操作后,其他的代码可以通过该代码的公共接口来进行调用,而不需要知道它是如何实现的。

这里有一个需要说明的概念 “作用域(scope)”:代码所在的嵌套上下文有一组定义为 “in scope” 的名称。当阅读、编写和编译代码时,程序员和编译器需要知道特定位置的特定名称是否引用了变量、函数、结构体、枚举、模块、常量或者其他有意义的项。你可以创建作用域,以及改变哪些名称在作用域内还是作用域外。同一个作用域内不能拥有两个相同名称的项。

Cairo有许多功能可以让你管理代码的组织。这些功能。这有时被称为 “模块系统(the module system)”,包括:

  • Packages: Scarb的一个功能,可以让你建立、测试和分享crates。
  • Crates: 一个模块的树形结构,对应于一个单一的编译单元。 它有一个根目录,并在该目录下的lib.cairo文件中定义了一个根模块。
  • Modulesuse: 允许你控制组织结构和作用域。
  • Paths: 一个命名例如结构体、函数或模块等项的方式

在这一章中,我们将介绍所有这些特性,讨论它们如何相互作用,以及解释如何使用它们来管理作用域。到最后,你应该对模块系统有一个扎实的理解,并且能够像专家一样使用作用域了!

Last change: 2023-09-20, commit: cbb0049

包和 Crate

什么是crate?

Crate是Cairo在编译时最小的代码单位。即使你运行 cairo-compile 而不是 scarb build 并传递一个源代码文件,编译器还是会将那个文件认作一个 crate。Crate可以包含模块,这些模块可以在其他文件中定义,并与Crate一起被编译,这将在后面的章节中讨论。

什么是crate root?

Crate root根是lib.cairo源文件,Cairo编译器从该文件开始,并构成你的crate的根模块(我们将在“定义模块来控制作用域”部分深入解释模块)。

什么是包?

一个cairo包是一个由一个或多个crate组成的集合,其中的Scarb.toml文件描述如何构建这些板块。这使得代码被分割成更小的、可重复使用的部分,并有利于更有条理的依赖管理。

用Scarb创建一个包

你可以使用scarb命令行工具创建一个新的Cairo包。要创建一个新的软件包,运行以下命令:

scarb new my_package

该命令将生成一个名为my_package的新软件包目录,其结构如下:

my_package/
├── Scarb.toml
└── src
    └── lib.cairo
  • src/是主目录,包的所有Cairo源代码文件将存放在这里。
  • lib.cairo是crate的默认根模块,也是包的主要入口点。
  • Scarb.toml是包示例文件,它包含包的元数据和配置选项,如依赖关系、包名称、版本和作者。你可以在scarb reference上找到关于它的文档。
[package]
name = "my_package"
version = "0.1.0"

[dependencies]
# foo = { path = "vendor/foo" }

当你开发你的包时,你可能想把你的代码组织成多个Cairo源文件。你可以通过在src目录或其子目录下创建额外的.cairo文件来做到这一点。

Last change: 2023-09-20, commit: cbb0049

定义模块以控制作用域

在本节,我们将讨论模块和其它一些关于模块系统的部分,如允许你命名项的 路径(paths);用来将路径引入作用域的use关键字。

首先,我们将从一系列的规则开始,在你未来组织代码的时候,这些规则可被用作简单的参考。接下来我们将会详细的解释每条规则。

模块小抄

这里我们提供一个简单的参考,用来解释模块、路径、 use关键词如何在编译器中工作,以及大部分开发者如何组织他们的代码。我们将在本章节中举例说明每条规则,不过这是一个解释模块工作方式的良好参考。你可以用scarb new backyard创建一个新的Scarb项目来跟随。

  • 从 crate 根节点开始:当编译一个 crate, 编译器首先在 crate 根文件(src/lib.cairo)中寻找要编译的代码。

  • 声明模块::在 crate 根文件中,你可以声明新的模块; 例如,你用mod garden;声明一个 “garden”模块。编译器会在下列路径中寻找模块代码:

    • 内联,在大括号中,当mod garden后方不是一个分号而是一个大括号

        // crate root file (src/lib.cairo)
          mod garden {
          // code defining the garden module goes here
          }
  • 在文件 src/garden.cairo

  • 声明子模块:在除了 crate 根节点以外的其他文件中,你可以定义子模块。例如,你可以在以下文件中声明 mod vegetables;src/garden.cairo 。编译器会在以父模块命名的目录中寻找子模块代码:

    • 内联,直接跟在mod vegetables后面,用大括号代替分号

      // src/garden.cairo file
      mod vegetables {
          // code defining the vegetables submodule goes here
      }
    • 在文件 src/garden/vegetables.cairo

  • 模块中的代码路径:一旦一个模块是你 crate 的一部分,你可以在隐私规则允许的前提下,从同一个 crate 内的任意地方,通过代码路径引用该模块的代码。举例而言,一个 garden vegetables 模块下的Asparagus类型可以在backyard::garden::vegetables::Asparagus被找到。

  • use关键字:在一个作用域内,use关键字创建了一个成员的快捷方式,用来减少长路径的重复。在任何可以引用backyard::garden::vegetables::Asparagus的作用域,你可以通过 use backyard::garden::vegetables::Asparagus;创建一个快捷方式,然后你就可以在作用域中只写Asparagus来使用该类型。

这里我们创建一个名为backyard的crate 来说明这些规则。该 crate 的路径同样命名为backyard,该路径包含了这些文件和目录:

backyard/
├── Scarb.toml
└── src
    ├── garden
    │   └── vegetables.cairo
    ├── garden.cairo
    └── lib.cairo

在这种情况下,crate根文件是 src/lib.cairo ,它包含:

文件名: src/lib.cairo

use garden::vegetables::Asparagus;

mod garden;

fn main() {
    let Asparagus = Asparagus {};
}

mod garden;行告诉编译器包括它在 src/garden.cairo 中发现的代码是:

文件名: src/garden.cairo

mod vegetables;

这里,mod vegetables;意味着 src/garden/vegetables.cairo 中的代码也被包括在内。这段代码是:

#[derive(Copy, Drop)]
struct Asparagus {}

这行use garden::vecants::Asparagus;让我们把Asparagus类型带入作用域、所以我们可以在main函数中使用它。

现在让我们深入了解这些规则的细节并在实际中演示它们!

在模块中对相关代码进行分组

模块 让我们可以将一个 crate 中的代码进行分组,以提高可读性与重用性。 作为一个例子,让我们写一个crate,提供一个餐馆的机能。我们将定义函数的签名,但将其主体留空,以专注于代码的组织,而不是餐馆的实现。

在餐饮业,餐馆中会有一些地方被称之为 前台(front of house),还有另外一些地方被称之为 后台(back of house)。 前台是招待顾客的地方,在这里,店主可以为顾客安排座位,服务员接受顾客下单和付款,调酒师会制作饮品。 后台则是由厨师工作的厨房,洗碗工的工作地点,以及经理做行政工作的地方组成。

我们可以将函数放置到嵌套的模块中,来使我们的 crate 结构与实际的餐厅结构相同。 通过运行 scarb new restaurant创建一个名为 restaurant的新包;然后将示例7-1中的代码输入 src/lib.cairo ,以定义一些模块和函数签名。这里是前台的部分:

文件名: src/lib.cairo

mod front_of_house {
    mod hosting {
        fn add_to_waitlist() {}

        fn seat_at_table() {}
    }

    mod serving {
        fn take_order() {}

        fn serve_order() {}

        fn take_payment() {}
    }
}

示例7-1:一个 front_of_house模块包含其他的模块,而这些模块又包含了函数

我们定义一个模块,是以 mod关键字为起始,然后指定模块的名字(本例中叫做 front_of_house),并且用花括号包围模块的主体。在模块内,我们还可以定义其他的模块,就像本例中的 hostingserving 模块。模块还可以保存一些定义的其他项,比如结构体、枚举、常量、特性、以及列表中6-1中展示的函数。

通过使用模块,我们可以将相关的定义分组到一起,并指出他们为什么相关。程序员可以通过使用这段代码,更加容易地找到他们想要的定义,因为他们可以基于分组来对代码进行导航,而不需要阅读所有的定义。 程序员向这段代码中添加一个新的功能时,他们也会知道代码应该放置在何处,可以保持程序的组织性。

在前面我们提到了,src/lib.cairo 叫做 crate 根。之所以这样叫它是因为这个文件的内容在 crate 模块结构的根组成了一个名为 crate 的模块,该结构被称为 模块树( module tree )。

示例7-2显示了示例7-1中结构的模块树。

restaurant
 └── front_of_house
     ├── hosting
     │   ├── add_to_waitlist
     │   └── seat_at_table
     └── serving
         ├── take_order
         ├── serve_order
         └── take_payment

示例7-2:示例6-1中代码的模块树

这个树展示了一些模块是如何被嵌入到另一个模块的(例如,hosting 嵌套在 front_of_house 中)。这个树还展示了一些模块是互为 兄弟( siblings )的,这意味着它们定义在同一模块中( hostingserving 被一起定义在 front_of_house 中)。继续沿用家庭关系的比喻,如果一个模块 A 被包含在模块 B 中,我们将模块 A 称为模块 B 的 子( child ),模块 B 则是模块 A 的 父( parent )。注意,整个模块树都植根于名为 restaurant crate的隐式模块下。

这个模块树可能会令你想起电脑上文件系统的目录树;这是一个非常恰当的类比!就像文件系统的目录,你可以使用模块来组织你的代码。并且,就像目录中的文件,我们需要一种方法来找到模块。

Last change: 2023-09-20, commit: cbb0049

引用模块项目的路径

为了告诉Cairo如何在模块树中找到一个项目,我们使用路径的方式,就像在文件系统使用路径一样。为了调用一个函数,我们需要知道它的路径。

路径可以有两种形式:

  • 绝对路径( absolute path )是以 crate 根(root)开头的全路径。绝对路径以 crate 名开头。
  • 相对路径( relative path )是从当前模块开始的。

绝对路径和相对路径后面都有一个或多个标识符用双冒号(::)分开。

为了说明这个概念,让我们回到我们在上一章使用的餐厅的例子示例7-1。我们有一个名为 restaurant的crate,其中有一个名为front_of_house的模块,包含一个名为 hosting的模块。hosting模块包含一个名为 add_to_waitlist的函数。我们想从eat_at_restaurant函数中调用add_to_waitlist函数。我们需要告诉Cairo add_to_waitlist函数的路径,以便它能找到它。

文件名: src/lib.cairo

mod front_of_house {
    mod hosting {
        fn add_to_waitlist() {}

        fn seat_at_table() {}
    }

    mod serving {
        fn take_order() {}

        fn serve_order() {}

        fn take_payment() {}
    }
}


fn eat_at_restaurant() {
    // Absolute path
    restaurant::front_of_house::hosting::add_to_waitlist(); // ✅ Compiles

    // Relative path
    front_of_house::hosting::add_to_waitlist(); // ✅ Compiles
}

示例7-3:使用绝对和相对路径调用add_to_waitlist函数

我们第一次调用eat_at_restaurant中的add_to_waitlist函数时、使用了一个绝对路径。add_to_waitlist函数与eat_at_restaurant定义在同一个crate中。在Cairo中,绝对路径从crate根开始,你需要用crate的名字来引用它。

第二次我们调用 add_to_waitlist时,使用的是相对路径。这个路径以 front_of_house 为起始,这个模块在模块树中,与 eat_at_restaurant 定义在同一层级。 与之等价的文件系统路径就是 ./front_of_house/hosting/add_to_waitlist。以模块名开头意味着该路径是相对路径。

使用 super 起始的相对路径

选择是否使用 super将根据你的项目具体情况来决定。 并取决于你是否更有可能将项目定义的代码是与使用该项目的代码分开还是放在一起。

文件名: src/lib.cairo

fn deliver_order() {}

mod back_of_house {
    fn fix_incorrect_order() {
        cook_order();
        super::deliver_order();
    }

    fn cook_order() {}
}

示例7-4:使用以super开头的相对路径调用一个函数

在这里你可以直接看到,和之前的例子不同,在这你可以使用super轻松地访问父级的模块。

Last change: 2023-09-20, commit: cbb0049

使用 use 关键字将路径引入作用域

不得不编写路径来调用函数显得不便且重复。幸运的是,有一种方法可以简化这个过程:我们可以用use关键字创建一个路径的快捷方式,然后在作用域内的其他地方使用这个较短的名字。

在示例7-5中,我们把restaurant::front_of_house::hosting模块带入到作用域内,所以我们只需要指定 hosting::add_to_waitlist 来调用eat_at_restaurant中的add_to_waitlist 函数。

文件名: src/lib.cairo

#![allow(unused)]
fn main() {
// Assuming "front_of_house" module is contained in a crate called "restaurant", as mentioned in the section "Defining Modules to Control Scope"
// If the path is created in the same crate, "restaurant" is optional in the use statement

mod front_of_house {
    mod hosting {
        fn add_to_waitlist() {}
    }
}

use restaurant::front_of_house::hosting;

fn eat_at_restaurant() {
    hosting::add_to_waitlist(); // ✅ Shorter path
}
}

示例 7-5: 用 "使用 "将一个模块带入范围。 使用

在作用域中添加 use 和路径类似于在文件系统中创建一个软连接(符号连接,symbolic link)。通过在 crate 根中添加 use restaurant::front_of_house::hosting,hosting 现在是该作用域中的一个有效名称,就像在 crate 根中定义了hosting模块一样。

注意 use 只能创建 use 所在的特定作用域内的短路径。示例 7-6 将 eat_at_restaurant 函数移到一个新的子模块中,这又是一个不同于 use 语句的作用域,所以函数体不能编译:

文件名: src/lib.cairo

#![allow(unused)]
fn main() {
mod front_of_house {
    mod hosting {
        fn add_to_waitlist() {}
    }
}

use restaurant::front_of_house::hosting;

mod customer {
    fn eat_at_restaurant() {
        hosting::add_to_waitlist();
    }
}
}

示例7-6:一个 use语句只适用于它所在的作用域

编译器错误显示短路径不在适用于 customer 模块中:

❯ scarb build
error: Identifier not found.
 --> lib.cairo:11:9
        hosting::add_to_waitlist();
        ^*****^

创建惯用的 use 路径

在示例6-5中,你可能想知道为什么我们指定restaurant::front_of_house::hosting,然后调用eat_at_restaurant中的hosting::add_to_waitlist,而不是通过指定一直到 add_to_waitlist函数的 use 路径来得到相同的结果,如示例7-7。

文件名: src/lib.cairo

#![allow(unused)]
fn main() {
mod front_of_house {
    mod hosting {
        fn add_to_waitlist() {}
    }
}

use restaurant::front_of_house::hosting::add_to_waitlist;

fn eat_at_restaurant() {
    add_to_waitlist();
}
}

示例7-7:使用 useadd_to_waitlist 函数引入作用域,这并不符合习惯

尽管示例7-5和7-7都完成了相同的任务,但示例 7-5 是使用 use 将函数引入作用域的习惯用法。要想使用 use 将函数的父模块引入作用域,我们必须在调用函数时指定父模块,这样可以清晰地表明函数不是在本地定义的,同时使完整路径的重复度最小化。示例 7-7 中的代码不清楚 add_to_waitlist 是在哪里被定义的。

另一方面,使用 use 引入结构体、枚举和其他项时,习惯是指定它们的完整路径。示例 7-8 展示了将核心库的 ArrayTrait trait带入作用域。

fn main() {
    let mut arr = ArrayTrait::new();
    arr.append(1);
}

示例7-8:将ArrayTrait引入作用域的习惯用法式

这种习惯用法背后没有什么硬性要求:它只是一种惯例,人们已经习惯了以这种方式阅读和编写 Rust 代码。它只是在Rust社区中出现的惯例。 由于Cairo与Rust共享许多惯例,我们也遵循这一惯例。

这个习惯用法有一个例外,那就是我们想使用 use 语句将两个具有相同名称的项带入作用域,因为Cairo不允许这样做。

使用 as 关键字提供新的名称

使用 use 将两个同名类型引入同一作用域这个问题还有另一个解决办法:在这个类型的路径后面,我们使用 as 指定一个新的本地名称或者别名( alias )。示例7-9显示了如何用as重命名一个导入:

文件名: src/lib.cairo

use array::ArrayTrait as Arr;

fn main() {
    let mut arr = Arr::new(); // ArrayTrait was renamed to Arr
    arr.append(1);
}

示例 7-9:使用 as 关键字重命名引入作用域的类型

在这里,我们用别名ArrArrayTrait带入作用域。现在我们可以用Arr标识符来访问该trait的方法。

从同一模块中导入多个项

当你想从同一个模块中导入多个项(如函数、结构体或枚举)时, 你可以使用大括号{}来列出所有你想导入的项目。 避免了一长串单独的use有助于保持你的代码整洁和便于阅读。

从同一模块导入多个项的常见语法是:

#![allow(unused)]
fn main() {
use module::{item1, item2, item3};
}

下面是一个从同一个模块导入三个结构体的例子:

// Assuming we have a module called `shapes` with the structures `Square`, `Circle`, and `Triangle`.
mod shapes {
    #[derive(Drop)]
    struct Square {
        side: u32
    }

    #[derive(Drop)]
    struct Circle {
        radius: u32
    }

    #[derive(Drop)]
    struct Triangle {
        base: u32,
        height: u32,
    }
}

// We can import the structures `Square`, `Circle`, and `Triangle` from the `shapes` module like this:
use shapes::{Square, Circle, Triangle};

// Now we can directly use `Square`, `Circle`, and `Triangle` in our code.
fn main() {
    let sq = Square { side: 5 };
    let cr = Circle { radius: 3 };
    let tr = Triangle { base: 5, height: 2 };
// ...
}

示例 7-10:从同一模块导入多个项

在模块文件中重导出名称

当我们用use关键字将一个名字带入作用域时,在新的作用域中也能够正常使用这个名称,就好像它本来就在当前作用域一样。 这种技术被称为 重导出( re-exporting ),因为我们将一个项目带入作用域、但同时也使这个项目可以被其他人带入他们的作用域。

下面这个例子,让我们重新导出餐厅例子中的add_to_waitlist函数:

文件名: src/lib.cairo

#![allow(unused)]
fn main() {
mod front_of_house {
    mod hosting {
        fn add_to_waitlist() {}
    }
}

use restaurant::front_of_house::hosting;

fn eat_at_restaurant() {
    hosting::add_to_waitlist();
}
}

示例 7-11: 通过 pub use 使名称可在新作用域中被导入至任何代码

在这个修改之前,外部代码需要使用路径 restaurant::front_of_house::hosting::add_to_waitlist() 来调用 add_to_waitlist 函数。 现在这个 use 从根模块重导出了 hosting 模块,外部代码现在可以使用路径 restaurant::hosting::add_to_waitlist()

当你代码的内部结构与调用你代码的程序员所想象的结构不同时,重导出会很有用。 例如,在这个餐馆的比喻中,经营餐馆的人会想到“前台”和“后台”。但顾客在光顾一家餐馆时,可能不会以这些术语来考虑餐馆的各个部分。 使用 use,我们可以使用一种结构编写代码,却将不同的结构形式暴露出来。这样做使我们的库井井有条,也使开发这个库的程序员和调用这个库的程序员都更加方便。

在Cairo使用外部包与Scarb

你可能需要使用外部包来利用社区提供的功能。要在你的项目中使用Scarb的外部包,请遵循以下步骤:

依赖关系系统仍然是一项正在进行的工作。你可以查看官方的文档

Last change: 2023-09-20, commit: cbb0049

将模块拆分成多个文件

到目前为止,本章所有的例子都在一个文件中定义多个模块。当模块变得更大时,你可能想要将它们的定义移动到单独的文件中,从而使代码更容易阅读。

例如,我们从示例7-11中的代码开始,我们会将模块的代码提取到各自的文件中,而不是将所有模块都定义到 crate 根文件中。在这里,crate 根文件是 src/lib.cairo

首先将 front_of_house 模块提取到其自己的文件中。删除 front_of_house 模块的大括号中的代码,只留下 mod front_of_house; 声明,这样 src/lib.cairo 就包含了代码 如示例7-12所示。注意直到创建示例 7-13 中的 src/front_of_house.cairo 文件之前代码都不能编译。

文件名: src/lib.cairo

mod front_of_house;

use restaurant::front_of_house::hosting;

fn eat_at_restaurant() {
    hosting::add_to_waitlist();
}

示例7-12:声明front_of_house 模块,其主体代码将存放在 src/front_of_house.cairo 中。

接下来将之前大括号内的代码放入一个名叫 src/front_of_house.cairo 的新文件中,如示例 7-13所示。因为编译器找到了 crate 根中名叫 front_of_house 的模块声明,它就知道去查看这个文件。

文件名: src/front_of_house.cairo

mod hosting {
    fn add_to_waitlist() {}
}

示例 7-13:在 src/front_of_house.cairo 中定义 front_of_house 模块。

注意你只需在模块树中的某处使用一次 mod 声明就可以加载这个文件。 一旦编译器知道了这个文件是项目的一部分(并且通过 mod 语句的位置知道了代码在模块树中的位置),项目中的其他文件应该使用其所声明的位置的路径来引用那个文件的代码, 这在 引用模块项目的路径部分有讲到。 换句话说,mod 不是 你可能会在其他编程语言中看到的 “include” 操作。

接下来我们同样将 hosting 模块提取到自己的文件中。这个过程会有所不同,因为 hostingfront_of_house 的子模块而不是根模块。我们将 hosting 的文件放在与模块树中它的父级模块同名的目录中,在这里是 src/front_of_house/

为了移动 hosting,修改 src/front_of_house.cairo 使之仅包含 hosting 模块的声明:

文件名: src/front_of_house.cairo

mod hosting;

接着我们创建一个 src/front_of_house 目录和一个包含 hosting 模块定义的 hosting.cairo 文件:

文件名: src/front_of_house/hosting.cairo

fn add_to_waitlist() {}

如果将 hosting.cairo 放在 src 目录,编译器会认为 hosting 模块中的 hosting.cairo 的代码声明于 crate 根,而不是声明为 front_of_house 的子模块。 编译器所遵循的哪些文件对应哪些模块的代码的规则,意味着目录和文件更接近于模块树。

我们将各个模块的代码移动到独立文件了,同时模块树依旧相同。 eat_at_restaurant 中的函数调用也无需修改继续保持有效,即便其定义存在于不同的文件中。 这个技巧让你可以在模块代码增长时,将它们移动到新文件中。

注意,_src/lib.cairo_中的 use restaurant::front_of_house::hosting 语句是没有改变的,在文件作为 crate 的一部分而编译时,use 不会有任何影响。 mod 关键字声明了模块,Cairo 会在与模块同名的文件中查找模块的代码。

总结

Cairo 提供了将包分成多个 crate,将 crate 分成模块,以及通过指定绝对或相对路径从一个模块引用另一个模块中定义的项的方式。 你可以通过使用 use 语句将路径引入作用域,这样在多次使用时可以使用更短的路径。模块定义的代码默认是公有的。

Last change: 2023-09-20, commit: cbb0049

泛型和Trait

每一个编程语言都有高效处理重复概念的工具。在 Cairo 中其工具之一就是 泛型(generics)。泛型是具体类型或其他属性的抽象替代。我们可以表达泛型的属性,比如他们的行为或如何与其他泛型相关联,而不需要在编写和编译代码时知道他们在这里实际上代表什么。

函数、结构体、枚举和trait可以将泛型作为其定义的一部分,而不是像u32ContractAddress这样的具体类型。

泛型允许我们用一个代表多种类型的占位符来替换特定的类型,以消除代码的重复。

对于每一个取代泛型的具体类型,编译器都会创建一个新的定义,从而减少程序员的开发时间,但在编译层面上的代码重复仍然存在。如果你正在编写Starknet合约,并为多个类型使用一个泛型,这将导致合约大小的增加,这可能是很重要的。

之后你将学习 trait,这是一个定义泛型行为的方法。trait 可以与泛型结合来将泛型限制为只接受拥有特定行为的类型,而不是任意类型。

Last change: 2023-11-19, commit: a15432b

泛型数据类型

我们可以使用泛型为像函数签名或结构体这样的项创建定义,这样它们就可以用于多种不同的具体数据类型。在Cairo中,我们可以在定义函数、结构、枚举、trait、实现和方法时使用泛型!在本章中,我们将看看在这些被提到的领域中如何有效地使用泛型。

在函数定义中使用泛型

当定义一个使用泛型的函数时,本来在函数签名中指定参数和返回值的类型的地方,会改用泛型来表示。例如,假设我们想创建一个函数,给定两个 Array 项,函数将返回最大的一个。如果我们需要对不同类型的列表进行这种操作,那么我们就必须每次都重新定义这个函数。幸运的是,我们可以使用泛型来实现这个函数,然后继续完成其他任务。


// Specify generic type T between the angulars
fn largest_list<T>(l1: Array<T>, l2: Array<T>) -> Array<T> {
    if l1.len() > l2.len() {
        l1
    } else {
        l2
    }
}

fn main() {
    let mut l1 = ArrayTrait::new();
    let mut l2 = ArrayTrait::new();

    l1.append(1);
    l1.append(2);

    l2.append(3);
    l2.append(4);
    l2.append(5);

    // There is no need to specify the concrete type of T because
    // it is inferred by the compiler
    let l3 = largest_list(l1, l2);
}

名为largest_list函数比较了两个相同类型的列表,返回具有更多元素的那一个,并丢弃另一个。如果你编译前面的代码,你会注意到它会出错,说没有为丢弃一个泛型的数组定义trait。这是因为编译器没有办法保证在执行main函数时,Array<T>是可以丢弃的。为了丢弃一个T的数组,编译器必须首先知道如何丢弃T。可以通过在largest_list的函数签名中规定T必须实现drop trait来解决这个问题。largest_list的正确函数定义如下:

#![allow(unused)]
fn main() {
fn largest_list<T, +Drop<T>>(l1: Array<T>, l2: Array<T>) -> Array<T> {
    if l1.len() > l2.len() {
        l1
    } else {
        l2
    }
}
}

新的largest_list函数在其定义中包含了一个要求,即无论什么泛型被放在那里,它都必须是可丢弃的。main函数保持不变,编译器足够聪明,可以得出正在使用的具体类型以及它是否实现了Drop这个trait。

范型的约束

在定义泛型的时候,掌握关于它们的信息是很有用的。知道一个泛型实现了哪些trait,可以让我们在函数逻辑中更有效地使用它们,代价是限制了可以与函数一起使用的泛型。我们之前看到了一个例子,就是将TDrop的实现作为largest_list的泛型参数的一部分。虽然 TDrop是为了满足编译器的要求而添加的,但我们也可以添加一些约束条件以有利于我们的函数逻辑。

想象一下,我们想,给定一个通用类型T的元素列表,找到其中最小的元素。首先,我们知道要使一个T类型的元素具有可比性,它必须实现PartialOrd这个trait。由此产生的函数将是:


// Given a list of T get the smallest one.
// The PartialOrd trait implements comparison operations for T
fn smallest_element<T, +PartialOrd<T>>(list: @Array<T>) -> T {
    // This represents the smallest element through the iteration
    // Notice that we use the desnap (*) operator
    let mut smallest = *list[0];

    // The index we will use to move through the list
    let mut index = 1;

    // Iterate through the whole list storing the smallest
    loop {
        if index >= list.len() {
            break smallest;
        }
        if *list[index] < smallest {
            smallest = *list[index];
        }
        index = index + 1;
    }
}

fn main() {
    let mut list: Array<u8> = ArrayTrait::new();
    list.append(5);
    list.append(3);
    list.append(10);

    // We need to specify that we are passing a snapshot of `list` as an argument
    let s = smallest_element(@list);
    assert(s == 3, 0);
}

名为smallest_element函数使用一个实现了PartialOrd的trait的通用类型T,接收一个Array<T>的快照作为参数并返回其中最小元素的拷贝。因为参数是@Array<T>的类型,我们不再需要在执行结束时丢弃它,所以我们不需要为T实现Drop特性。那为什么它不能编译呢?

当对list进行索引时,其结果是对被索引的元素进行快照,除非@T实现了PartialOrd,否则我们需要使用 * 对元素进行解快照。* 操作需要从@T复制到T,这意味着T需要实现Copy特性。在复制了一个@T类型的元素到T之后,现在有T类型的变量需要被删除,这就要求T也要实现Drop特性。然后我们必须同时添加DropCopy特性的实现,以使该函数正确。在更新smallest_element函数后,产生的代码将是:

#![allow(unused)]
fn main() {
fn smallest_element<T, impl TPartialOrd: PartialOrd<T>, impl TCopy: Copy<T>, impl TDrop: Drop<T>>(
    list: @Array<T>
) -> T {
    let mut smallest = *list[0];
    let mut index = 1;
    loop {
        if index >= list.len() {
            break smallest;
        }
        if *list[index] < smallest {
            smallest = *list[index];
        }
        index = index + 1;
    }
}
}

Anonymous Generic Implementation Parameter (+ operator)

Until now, we have always specified a name for each implementation of the required generic trait: TPartialOrd for PartialOrd<T>, TDrop for Drop<T>, and TCopy for Copy<T>.

However, most of the time, we don't use the implementation in the function body; we only use it as a constraint. In these cases, we can use the + operator to specify that the generic type must implement a trait without naming the implementation. This is referred to as an anonymous generic implementation parameter.

For example, +PartialOrd<T> is equivalent to impl TPartialOrd: PartialOrd<T>.

We can rewrite the smallest_element function signature as follows:

#![allow(unused)]
fn main() {
fn smallest_element<T, +PartialOrd<T>, +Copy<T>, +Drop<T>>(list: @Array<T>) -> T {
    let mut smallest = *list[0];
    let mut index = 1;
    loop {
        if index >= list.len() {
            break smallest;
        }
        if *list[index] < smallest {
            smallest = *list[index];
        }
        index = index + 1;
    }
}
}

结构体定义中的泛型

我们也可以使用类似于函数定义的<> 语法来定义结构,它包含一个或多个泛型参数类型字段。首先,必须在结构体名称后面的尖括号中声明泛型参数的名称,接着在结构体定义中可以指定具体数据类型的位置使用泛型类型。下一个代码示例显示了 Wallet<T> 的定义,它有一个 balance字段,类型为 T

#[derive(Drop)]
struct Wallet<T> {
    balance: T
}


fn main() {
    let w = Wallet { balance: 3 };
}

上述代码自动为Wallet类型派生Drop trait。这效果等同于手动编写以下代码:

struct Wallet<T> {
    balance: T
}

impl WalletDrop<T, +Drop<T>> of Drop<Wallet<T>>;

fn main() {
    let w = Wallet { balance: 3 };
}

应该避免使用derive宏来实现WalletDrop,而是定义我们自己的WalletDrop实现。注意,我们必须像定义函数一样,为WalletDrop定义一个额外的泛型T并且也实现了Drop特性。这基本上是在说,只要T也是可丢弃的,那么钱包<T>这个结构就是可丢弃的。

最后,如果我们想给Wallet添加一个代表其Cairo地址的字段,并且我们希望这个字段是与T不同的另一个泛型,我们可以简单地通过在<>之间添加另一个泛型来实现:

#[derive(Drop)]
struct Wallet<T, U> {
    balance: T,
    address: U,
}

fn main() {
    let w = Wallet { balance: 3, address: 14 };
}

我们在Wallet结构定义中添加一个新的泛型U,然后将这个类型分配给新的字段成员address。 注意派生属性的 Drop的trait在新的泛型 U 上同样起作用。

枚举定义中的泛型

和结构体类似,枚举也可以在成员中存放泛型数据类型。例如,Cairo核心库提供的Option<T>枚举:

enum Option<T> {
    Some: T,
    None,
}

如你所见 Option<T> 是一个拥有泛型 T 的枚举,它有两个成员:Some,它存放了一个类型 T 的值,和不存在任何值的None。通过 Option<T> 枚举可以表达有一个可能的值的抽象概念,同时因为 Option<T> 是泛型的,无论这个可能的值是什么类型都可以使用这个抽象。

枚举也可以拥有多个泛型类型,比如核心库提供的Result<T, E>枚举的定义:

enum Result<T, E> {
    Ok: T,
    Err: E,
}

Result<T, E>枚举有两个泛型类型,TE,以及两个成员:Ok,存放T类型的值,Err,存放E类型的值。这个定义使得我们可以在任何地方使用Result枚举,该操作可能成功(返回T类型的值)或失败(返回E类型的值)。

方法定义中的泛型

我们可以在结构和枚举上实现方法,也可以在其定义中使用泛型。在之前定义的Wallet<T>结构体上为其定义一个balance 方法:

#[derive(Copy, Drop)]
struct Wallet<T> {
    balance: T
}

trait WalletTrait<T> {
    fn balance(self: @Wallet<T>) -> T;
}

impl WalletImpl<T, +Copy<T>> of WalletTrait<T> {
    fn balance(self: @Wallet<T>) -> T {
        return *self.balance;
    }
}

fn main() {
    let w = Wallet { balance: 50 };
    assert(w.balance() == 50, 0);
}

我们首先定义了WalletTrait<T>trait,使用一个泛型T,它定义了一个方法,从Wallet中返回字段address的快照。然后我们在WalletImpl<T>中给出该trait的实现。请注意,你需要在trait的定义和实现中都包含一个泛型。

在定义类型上的方法时,我们也可以指定对泛型的约束。例如,我们可以只为Wallet<u128>实例而不是Wallet<T>实现方法。在代码示例中,我们为钱包定义了一个实现,这些钱包的balance字段的具体类型为u128

#[derive(Copy, Drop)]
struct Wallet<T> {
    balance: T
}

/// Generic trait for wallets
trait WalletTrait<T> {
    fn balance(self: @Wallet<T>) -> T;
}

impl WalletImpl<T, +Copy<T>> of WalletTrait<T> {
    fn balance(self: @Wallet<T>) -> T {
        return *self.balance;
    }
}

/// Trait for wallets of type u128
trait WalletReceiveTrait {
    fn receive(ref self: Wallet<u128>, value: u128);
}

impl WalletReceiveImpl of WalletReceiveTrait {
    fn receive(ref self: Wallet<u128>, value: u128) {
        self.balance += value;
    }
}

fn main() {
    let mut w = Wallet { balance: 50 };
    assert(w.balance() == 50, 0);

    w.receive(100);
    assert(w.balance() == 150, 0);
}

新的方法receive增加了Wallet<u128>的实例的余额大小。请注意,我们改变了main函数,使w成为一个可变的变量,以便它能够更新其余额。如果我们通过改变balance的类型来改变w的初始化,那么之前的代码就不能编译了。

Cairo也允许我们在泛型trait中定义泛型方法。在之前的 Wallet<U, V>的实现上定义一个trait,用来选取两个不同泛型的钱包,并创建一个拥有两者泛型新的钱包。首先,让我们重写结构体定义:

struct Wallet<T, U> {
    balance: T,
    address: U,
}

接下来,我们将初步地定义混合trait和其实现:

// This does not compile!
trait WalletMixTrait<T1, U1> {
    fn mixup<T2, U2>(self: Wallet<T1, U1>, other: Wallet<T2, U2>) -> Wallet<T1, U2>;
}

impl WalletMixImpl<T1, U1> of WalletMixTrait<T1, U1> {
    fn mixup<T2, U2>(self: Wallet<T1, U1>, other: Wallet<T2, U2>) -> Wallet<T1, U2> {
        Wallet { balance: self.balance, address: other.address }
    }
}

我们正在创建一个traitWalletMixTrait<T1, U1>,其中有mixup<T2, U2>方法,给定一个Wallet<T1, U1>Wallet<T2, U2>的实例,创建一个新的Wallet<T1, U2>。正如mixup签名所指定的,selfother都在函数的结尾处被丢弃,这就是这段代码不能编译的原因。如果你从开始到现在都跟上了课程,你会知道我们必须为所有的泛型添加一个Drop trait的实现,以便编译器知道如何丢弃Wallet<T, U>的实例。更新后的实现如下:

#![allow(unused)]
fn main() {
trait WalletMixTrait<T1, U1> {
    fn mixup<T2, +Drop<T2>, U2, +Drop<U2>>(
        self: Wallet<T1, U1>, other: Wallet<T2, U2>
    ) -> Wallet<T1, U2>;
}

impl WalletMixImpl<T1, +Drop<T1>, U1, +Drop<U1>> of WalletMixTrait<T1, U1> {
    fn mixup<T2, +Drop<T2>, U2, +Drop<U2>>(
        self: Wallet<T1, U1>, other: Wallet<T2, U2>
    ) -> Wallet<T1, U2> {
        Wallet { balance: self.balance, address: other.address }
    }
}
}

我们在 WalletMixImpl"的声明中添加了 T1U1的可丢弃trait。然后我们对T2U2做同样的处理,这次是作为mixup签名的一部分。现在我们可以尝试使用mixup 函数了:

fn main() {
    let w1 = Wallet { balance: true, address: 10 };
    let w2 = Wallet { balance: 32, address: 100 };

    let w3 = w1.mixup(w2);

    assert(w3.balance == true, 0);
    assert(w3.address == 100, 0);
}

我们首先创建两个实例:一个是 Wallet<bool, u128>,另一个是Wallet<felt252, u8>。然后,我们调用mixup并创建一个新的Wallet<bool, u8>实例。

Last change: 2023-12-09, commit: acd03a1

Cairo中的Trait

A trait defines a set of methods that can be implemented by a type. These methods can be called on instances of the type when this trait is implemented. A trait combined with a generic type defines functionality a particular type has and can share with other types. We can use traits to define shared behavior in an abstract way. We can use trait bounds to specify that a generic type can be any type that has certain behavior.

Note: Note: Traits are similar to a feature often called interfaces in other languages, although with some differences.

While traits can be written to not accept generic types, they are most useful when used with generic types. We already covered generics in the previous chapter, and we will use them in this chapter to demonstrate how traits can be used to define shared behavior for generic types.

定义一个Trait

A type’s behavior consists of the methods we can call on that type. Different types share the same behavior if we can call the same methods on all of those types. Trait definitions are a way to group method signatures together to define a set of behaviors necessary to accomplish some purpose.

For example, let’s say we have a struct NewsArticle that holds a news story in a particular location. We can define a trait Summary that describes the behavior of something that can summarize the NewsArticle type.

#[derive(Drop, Clone)]
struct NewsArticle {
    headline: ByteArray,
    location: ByteArray,
    author: ByteArray,
    content: ByteArray,
}

trait Summary {
    fn summarize(self: @NewsArticle) -> ByteArray;
}

impl NewsArticleSummary of Summary {
    fn summarize(self: @NewsArticle) -> ByteArray {
        format!("{:?} by {:?} ({:?})", self.headline, self.author, self.location)
    }
}

Here, we declare a trait using the trait keyword and then the trait’s name, which is Summary in this case.

Inside the curly brackets, we declare the method signatures that describe the behaviors of the types that implement this trait, which in this case is fn summarize(self: @NewsArticle) -> ByteArray. After the method signature, instead of providing an implementation within curly brackets, we use a semicolon.

Note: the ByteArray type is the type used to represent Strings in Cairo.

As the trait is not generic, the self parameter is not generic either and is of type @NewsArticle. This means that the summarize method can only be called on instances of NewsArticle.

Now, consider that we want to make a media aggregator library crate named aggregator that can display summaries of data that might be stored in a NewsArticle or Tweet instance. To do this, we need a summary from each type, and we’ll request that summary by calling a summarize method on an instance. By defining the Summary trait on generic type T, we can implement the summarize method on any type we want to be able to summarize.

use debug::PrintTrait;

mod aggregator {
    trait Summary<T> {
        fn summarize(self: @T) -> ByteArray;
    }

    #[derive(Drop, Clone)]
    struct NewsArticle {
        headline: ByteArray,
        location: ByteArray,
        author: ByteArray,
        content: ByteArray,
    }

    impl NewsArticleSummary of Summary<NewsArticle> {
        fn summarize(self: @NewsArticle) -> ByteArray {
            format!(
                "{} by {} ({})", self.headline.clone(), self.author.clone(), self.location.clone()
            )
        }
    }

    #[derive(Drop, Clone)]
    struct Tweet {
        username: ByteArray,
        content: ByteArray,
        reply: bool,
        retweet: bool,
    }

    impl TweetSummary of Summary<Tweet> {
        fn summarize(self: @Tweet) -> ByteArray {
            format!("{}: {}", self.username.clone(), self.content.clone())
        }
    }
}

use aggregator::{Summary, NewsArticle, Tweet};
fn main() {
    let news = NewsArticle {
        headline: "Cairo has become the most popular language for developers",
        location: "Worldwide",
        author: "Cairo Digger",
        content: "Cairo is a new programming language for zero-knowledge proofs",
    };

    let tweet = Tweet {
        username: "EliBenSasson",
        content: "Crypto is full of short-term maximizing projects. \n @Starknet and @StarkWareLtd are about long-term vision maximization.",
        reply: false,
        retweet: false
    }; // Tweet instantiation

    println!("New article available! {}", news.summarize());
    println!("1 new tweet: {}", tweet.summarize());
}

A Summary trait that consists of the behavior provided by a summarize method

Each generic type implementing this trait must provide its own custom behavior for the body of the method. The compiler will enforce that any type that has the Summary trait will have the method summarize defined with this signature exactly.

A trait can have multiple methods in its body: the method signatures are listed one per line and each line ends in a semicolon.

Implementing a Trait on a type

Now that we’ve defined the desired signatures of the Summary trait’s methods, we can implement it on the types in our media aggregator. The next code snippet shows an implementation of the Summary trait on the NewsArticle struct that uses the headline, the author, and the location to create the return value of summarize. For the Tweet struct, we define summarize as the username followed by the entire text of the tweet, assuming that tweet content is already limited to 280 characters.

use debug::PrintTrait;

mod aggregator {
    trait Summary<T> {
        fn summarize(self: @T) -> ByteArray;
    }

    #[derive(Drop, Clone)]
    struct NewsArticle {
        headline: ByteArray,
        location: ByteArray,
        author: ByteArray,
        content: ByteArray,
    }

    impl NewsArticleSummary of Summary<NewsArticle> {
        fn summarize(self: @NewsArticle) -> ByteArray {
            format!(
                "{} by {} ({})", self.headline.clone(), self.author.clone(), self.location.clone()
            )
        }
    }

    #[derive(Drop, Clone)]
    struct Tweet {
        username: ByteArray,
        content: ByteArray,
        reply: bool,
        retweet: bool,
    }

    impl TweetSummary of Summary<Tweet> {
        fn summarize(self: @Tweet) -> ByteArray {
            format!("{}: {}", self.username.clone(), self.content.clone())
        }
    }
}

use aggregator::{Summary, NewsArticle, Tweet};
fn main() {
    let news = NewsArticle {
        headline: "Cairo has become the most popular language for developers",
        location: "Worldwide",
        author: "Cairo Digger",
        content: "Cairo is a new programming language for zero-knowledge proofs",
    };

    let tweet = Tweet {
        username: "EliBenSasson",
        content: "Crypto is full of short-term maximizing projects. \n @Starknet and @StarkWareLtd are about long-term vision maximization.",
        reply: false,
        retweet: false
    }; // Tweet instantiation

    println!("New article available! {}", news.summarize());
    println!("1 new tweet: {}", tweet.summarize());
}

Implementing a trait on a type is similar to implementing regular methods. The difference is that after impl, we put a name for the implementation, then use the of keyword, and then specify the name of the trait we are writing the implementation for. If the implementation is for a generic type, we place the generic type name in the angle brackets after the trait name.

Within the impl block, we put the method signatures that the trait definition has defined. Instead of adding a semicolon after each signature, we use curly brackets and fill in the method body with the specific behavior that we want the methods of the trait to have for the particular type.

Now that the library has implemented the Summary trait on NewsArticle and Tweet, users of the crate can call the trait methods on instances of NewsArticle and Tweet in the same way we call regular methods. The only difference is that the user must bring the trait into scope as well as the types. Here’s an example of how a crate could use our aggregator crate:

use debug::PrintTrait;

mod aggregator {
    trait Summary<T> {
        fn summarize(self: @T) -> ByteArray;
    }

    #[derive(Drop, Clone)]
    struct NewsArticle {
        headline: ByteArray,
        location: ByteArray,
        author: ByteArray,
        content: ByteArray,
    }

    impl NewsArticleSummary of Summary<NewsArticle> {
        fn summarize(self: @NewsArticle) -> ByteArray {
            format!(
                "{} by {} ({})", self.headline.clone(), self.author.clone(), self.location.clone()
            )
        }
    }

    #[derive(Drop, Clone)]
    struct Tweet {
        username: ByteArray,
        content: ByteArray,
        reply: bool,
        retweet: bool,
    }

    impl TweetSummary of Summary<Tweet> {
        fn summarize(self: @Tweet) -> ByteArray {
            format!("{}: {}", self.username.clone(), self.content.clone())
        }
    }
}

use aggregator::{Summary, NewsArticle, Tweet};
fn main() {
    let news = NewsArticle {
        headline: "Cairo has become the most popular language for developers",
        location: "Worldwide",
        author: "Cairo Digger",
        content: "Cairo is a new programming language for zero-knowledge proofs",
    };

    let tweet = Tweet {
        username: "EliBenSasson",
        content: "Crypto is full of short-term maximizing projects. \n @Starknet and @StarkWareLtd are about long-term vision maximization.",
        reply: false,
        retweet: false
    }; // Tweet instantiation

    println!("New article available! {}", news.summarize());
    println!("1 new tweet: {}", tweet.summarize());
}

This code prints the following:

New article available! Cairo has become the most popular language for developers by Cairo Digger (Worldwide)

1 new tweet: EliBenSasson: Crypto is full of short-term maximizing projects.
 @Starknet and @StarkWareLtd are about long-term vision maximization.

Other crates that depend on the aggregator crate can also bring the Summary trait into scope to implement Summary on their own types.

trait的无声明实现。

你可以直接编写实现而不需要定义相应的trait。这可以通过在实现上使用 #[generate_trait]属性来实现,它将使编译器自动生成与实现相对应的trait。记住在你的 trait 的名称后添加 Trait 作为后缀,因为编译器会通过在实现名称后添加 Trait 后缀来创建 trait。

struct Rectangle {
    height: u64,
    width: u64,
}

#[generate_trait]
impl RectangleGeometry of RectangleGeometryTrait {
    fn boundary(self: Rectangle) -> u64 {
        2 * (self.height + self.width)
    }
    fn area(self: Rectangle) -> u64 {
        self.height * self.width
    }
}

在上述代码中,无需手动定义trait。编译器将自动处理它的定义,在引入新函数时动态生成和更新它。

管理和使用外部trait的实现

要使用trait的方法,你需要确保导入了正确的 traits以及它的实现。在上面的代码中,我们从debug中导入了PrintTrait,并使用use debug::PrintTrait;以在支持的类型上使用print()方法。

在某些情况下,如果它们被声明在不同的模块中,你可能不仅需要导入trait,还需要导入实现。 如果CircleGeometry是在一个单独的模块/文件circle中,那么要在circ: Circle上使用boundary,我们就需要在 ShapeGeometry之外再导入 CircleGeometry

如果代码被组织成类似这样的模块,其中trait的实现被定义在与trait本身不同的模块中时,则需要显式导入相关的实现。

use debug::PrintTrait;

// struct Circle { ... } and struct Rectangle { ... }

mod geometry {
    use super::Rectangle;
    trait ShapeGeometry<T> {
        // ...
    }

    impl RectangleGeometry of ShapeGeometry<Rectangle> {
        // ...
    }
}

// Could be in a different file
mod circle {
    use super::geometry::ShapeGeometry;
    use super::Circle;
    impl CircleGeometry of ShapeGeometry<Circle> {
        // ...
    }
}

fn main() {
    let rect = Rectangle { height: 5, width: 7 };
    let circ = Circle { radius: 5 };
    // Fails with this error
    // Method `area` not found on... Did you import the correct trait and impl?
    rect.area().print();
    circ.area().print();
}

为了使其发挥作用,除此之外、

#![allow(unused)]
fn main() {
use geometry::ShapeGeometry;
}

您需要明确地导入 CircleGeometry。请注意,您不需要导入 RectangleGeometry,因为它与导入的 trait 定义在同一个模块中,因此会自动解析。

#![allow(unused)]
fn main() {
use circle::CircleGeometry;
}
Last change: 2023-12-10, commit: 370d5b6

测试Cairo 程序

Last change: 2023-09-20, commit: cbb0049

如何编写测试

测试函数的剖析

测试是Cairo函数,用于验证非测试代码是否以预期方式运行。测试函数的主体通常执行这三个动作:

  • 设置任何需要的数据或状态。
  • 运行你想测试的代码。
  • 断言结果与你期望的一样。

让我们看看Cairo专门为编写执行这些动作的测试所提供的功能,其中包括test属性、assert函数和should_panic属性。

一个测试函数的剖析

最简单的Cairo中的测试是一个带有test属性注释的函数。属性是关于Cairo代码片段的元数据;一个例子是我们在第5章中对结构体使用的derive属性。要把一个函数变成测试函数,在 fn前的一行加上 #[test]。当你用cairo-test命令运行你的测试时,Cairo会建立一个测试运行器的二进制文件,运行被标注了的函数,并报告每个测试函数的通过或失败。

让我们创建一个名为 adder的将两个数字相加的新项目,用scarb new adder”命令:

adder
├── Scarb.toml
└── src
    └── lib.cairo

lib.cairo 中,让我们添加第一个测试,如示例9-1所示。

文件名: src/lib.cairo

#![allow(unused)]
fn main() {
    #[test]
    fn it_works() {
        let result = 2 + 2;
        assert(result == 4, 'result is not 4');
    }
}

示例9-1:一个测试模块和其函数

现在,让我们忽略最上面的两行,专注于这个函数。注意#[test]标注:这个属性表明这是一个测试函数,所以测试运行器知道要把这个函数当作一个测试。我们可能在测试模块中也有非测试函数,以帮助设置常见的场景或执行常见的操作,所以我们总是需要指出哪些函数是测试的。

这个例子的函数体使用了assert函数,它包含了2和2相加的结果,等于4。这个断言是一个典型测试格式范例。让我们运行它,看看这个测试是否通过。

scarb cairo-test命令运行我们项目中的所有测试,如示例9-2所示。

$ scarb cairo-test
testing adder...
running 1 tests
test adder::lib::tests::it_works ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 filtered out;

示例9-2:运行测试后的输出

scarb cairo-test编译并运行了测试。我们看到一行running 1 tests。下一行显示了生成的测试函数的名称,叫做it_works,运行该测试的结果是ok。总体摘要test result: ok.意味着所有的测试都通过了,1 passed; 0 failed 的部分展示了通过或失败的测试的总数。

我们可以把一个测试标记为忽略,这样它就不会在一个特定的实例中运行;我们将在本章后面的忽略一些测试,除非特别要求一节中介绍。因为我们在这里没有这样做,所以摘要中显示 0 ignored。我们也可以给cairo-test命令传递一个参数,只运行名称与某个字符串相匹配的测试;这叫做过滤,我们将在运行单个测试一节中介绍。我们也没有对正在运行的测试进行过滤,所以总结的最后显示0 filtered out

让我们开始根据我们自己的需要定制测试。首先将it_works函数的名称改为不同的名称,例如exploration,像这样:

文件名: src/lib.cairo

#![allow(unused)]
fn main() {
    #[test]
    fn exploration() {
        let result = 2 + 2;
        assert(result == 4, 'result is not 4');
    }
}

然后再次运行scarb cairo-test。现在输出显示的是 exploration而不是it_works

$ scarb cairo-test
running 1 tests
test adder::lib::tests::exploration … ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 filtered out;

现在我们将添加另一个测试,但这次我们要做一个失败的测试! 当测试函数中的某些东西发生panic时,测试就会失败。每个测试都在一个新的线程中运行,当主线程看到一个测试线程死亡时,该测试被标记为失败。将新的测试作为一个名为another的函数输入,因此你的 src/lib.cairo 文件看起来像示例9-3里一样。

#![allow(unused)]
fn main() {
    #[test]
    fn another() {
        let result = 2 + 2;
        assert(result == 6, 'Make this test fail');
    }

}

示例9-3:添加第二个测试(会失败的测试)

$ scarb cairo-test
running 2 tests
test adder::lib::tests::exploration … ok
test adder::lib::tests::another … fail
failures:
    adder::lib::tests::another - panicked with [1725643816656041371866211894343434536761780588 (‘Make this test fail’), ].
Error: test result: FAILED. 1 passed; 1 failed; 0 ignored

示例9-4:一个测试通过,一个测试失败时的测试结果

adder::lib::test::another这一行没有显示ok,而是显示fail。在单个结果和摘要之间出现了一个新的部分。它显示了每个测试失败的详细原因。在这个例子中,我们得到的细节在是 _src/lib.cairo_文件中another失败了,因为它发生了panic [1725643816656041371866211894343434536761780588 (‘Make this test fail’), ]

摘要行显示在最后:总的来说,我们的测试结果是FAILED。我们有一个测试通过,一个测试失败。

现在你已经看到了不同场景下的测试结果,让我们看看一些在测试中有用的函数。

用断言函数检查结果

Cairo提供的assert函数,在你想确保测试中的某些条件一定为true时非常有用。我们给assert函数的第一个参数是一个布尔值。如果该值为true,则不会发生任何事情,测试通过。如果值是 false,assert函数调用 panic(),导致测试失败,我们定义的信息是 assert函数的第二个参数。使用assert函数可以帮助我们检查我们的代码是否按照我们的意图运行。

第5章,示例5-15中,我们使用了一个Rectangle结构和一个can_hold方法,在示例9-5中重复了这些。让我们把这段代码放在_src/lib.cairo_文件中,然后用assert函数为它写一些测试。

文件名: src/lib.cairo

#![allow(unused)]
fn main() {
trait RectangleTrait {
    fn area(self: @Rectangle) -> u64;
    fn can_hold(self: @Rectangle, other: @Rectangle) -> bool;
}

impl RectangleImpl of RectangleTrait {
    fn area(self: @Rectangle) -> u64 {
        *self.width * *self.height
    }

    fn can_hold(self: @Rectangle, other: @Rectangle) -> bool {
        *self.width > *other.width && *self.height > *other.height
    }
}
}

示例9-5:使用第五章中的 Rectangle结构及其can_hold方法

返回值为boolcan_hold方法是assert函数的一个完美用例。在示例9-6中,我们写了一个测试,通过创建一个宽度为8、高度为7Rectangle实例,并断言它可以容纳另一个宽度为5、高度为1Rectangle实例,来测试can_hold方法。

文件名: src/lib.cairo

#![allow(unused)]
fn main() {
use debug::PrintTrait;
#[derive(Copy, Drop)]
struct Rectangle {
    width: u64,
    height: u64,
}

trait RectangleTrait {
    fn area(self: @Rectangle) -> u64;
    fn can_hold(self: @Rectangle, other: @Rectangle) -> bool;
}

impl RectangleImpl of RectangleTrait {
    fn area(self: @Rectangle) -> u64 {
        *self.width * *self.height
    }

    fn can_hold(self: @Rectangle, other: @Rectangle) -> bool {
        *self.width > *other.width && *self.height > *other.height
    }
}

#[cfg(test)]
mod tests {
    use super::Rectangle;
    use super::RectangleTrait;


    #[test]
    fn larger_can_hold_smaller() {
        let larger = Rectangle { height: 7, width: 8, };
        let smaller = Rectangle { height: 1, width: 5, };

        assert(larger.can_hold(@smaller), 'rectangle cannot hold');
    }

    #[test]
    fn smaller_cannot_hold_larger() {
        let larger = Rectangle { height: 7, width: 8, };
        let smaller = Rectangle { height: 1, width: 5, };

        assert(!smaller.can_hold(@larger), 'rectangle cannot hold');
    }
}


}

示例 9-6: can_hold的测试,检查一个较大的矩形是否真的可以容纳一个较小的矩形

注意,我们在测试模块中加入了两行新的内容:use super::Rectangle;use super::RectangleTrait;。测试模块是一个常规模块,遵循通常的可见性规则。因为测试模块是一个内部模块,我们需要将外部模块中的被测代码引入内部模块的范围。

我们将我们的测试命名为larger_can_hold_smaller,并且创建了我们需要的两个Rectangle实例。然后我们调用了assert函数,并将调用larger.can_hold(@smaller)的结果传给它。这个表达式应该返回 true,所以我们的测试应该通过。让我们拭目以待吧!

$ scarb cairo-test
running 1 tests
test adder::lib::tests::larger_can_hold_smaller ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 filtered out;

它确实通过了!让我们再增加���个测试,这次是断言一个较小的矩形不能容纳一个较大的矩形:

文件名: src/lib.cairo

#![allow(unused)]
fn main() {
use debug::PrintTrait;
#[derive(Copy, Drop)]
struct Rectangle {
    width: u64,
    height: u64,
}

trait RectangleTrait {
    fn area(self: @Rectangle) -> u64;
    fn can_hold(self: @Rectangle, other: @Rectangle) -> bool;
}

impl RectangleImpl of RectangleTrait {
    fn area(self: @Rectangle) -> u64 {
        *self.width * *self.height
    }

    fn can_hold(self: @Rectangle, other: @Rectangle) -> bool {
        *self.width > *other.width && *self.height > *other.height
    }
}

#[cfg(test)]
mod tests {
    use super::Rectangle;
    use super::RectangleTrait;


    #[test]
    fn larger_can_hold_smaller() {
        let larger = Rectangle { height: 7, width: 8, };
        let smaller = Rectangle { height: 1, width: 5, };

        assert(larger.can_hold(@smaller), 'rectangle cannot hold');
    }

    #[test]
    fn smaller_cannot_hold_larger() {
        let larger = Rectangle { height: 7, width: 8, };
        let smaller = Rectangle { height: 1, width: 5, };

        assert(!smaller.can_hold(@larger), 'rectangle cannot hold');
    }
}


}

因为在这种情况下,can_hold函数的正确结果是false,我们需要在传递给assert函数之前否定这个结果。因此,如果can_hold返回false,我们的测试将通过:

$ scarb cairo-test
    running 2 tests
    test adder::lib::tests::smaller_cannot_hold_larger … ok
    test adder::lib::tests::larger_can_hold_smaller … ok
    test result: ok. 2 passed; 0 failed; 0 ignored; 0 filtered out;

两个测试都通过了!现在让我们看看当我们在代码中引入一个错误时,我们的测试结果会怎样。我们将改变can_hold方法的实现,当它比较宽度时,将大于号替换为小于号:

#![allow(unused)]
fn main() {
impl RectangleImpl of RectangleTrait {
    fn area(self: @Rectangle) -> u64 {
        *self.width * *self.height
    }

    fn can_hold(self: @Rectangle, other: @Rectangle) -> bool {
        *self.width < *other.width && *self.height > *other.height
    }
}
}

现在运行测试产生以下结果:

$ scarb cairo-test
running 2 tests
test adder::lib::tests::smaller_cannot_hold_larger … ok
test adder::lib::tests::larger_can_hold_smaller … fail
failures:
   adder::lib::tests::larger_can_hold_smaller - panicked with [167190012635530104759003347567405866263038433127524 (‘rectangle cannot hold’), ].

Error: test result: FAILED. 1 passed; 1 failed; 0 ignored

我们的测试发现了这个错误! 因为larger.width8smaller.width5can_hold中的宽度比较现在返回false:因为8不比5小。

should_panic检查panic情况

除了检查返回值之外,检查我们的代码是否按照我们所期望的那样处理错误条件也很重要。例如,考虑示例9-8中的Guess类型。其他使用Guess的代码依赖于保证Guess实例只包含1100之间的值。我们可以写一个测试,以确保试图创建的Guess实例的值不在这个范围内时,会发生panic。

我们通过在我们的测试函数中添加属性should_panic来做到这一点。如果函数中的代码出现panic,则测试通过;如果函数中的代码没有出现panic,则测试失败。

示例9-8显示了一个测试,检查GuessTrait::new的错误条件是否在我们期望的时候发生。

文件名: src/lib.cairo

#![allow(unused)]
fn main() {
#[derive(Copy, Drop)]
struct Guess {
    value: u64,
}

trait GuessTrait {
    fn new(value: u64) -> Guess;
}

impl GuessImpl of GuessTrait {
    fn new(value: u64) -> Guess {
        if value < 1 || value > 100 {
            let mut data = ArrayTrait::new();
            data.append('Guess must be >= 1 and <= 100');
            panic(data);
        }
        Guess { value }
    }
}

#[cfg(test)]
mod tests {
    use super::Guess;
    use super::GuessTrait;

    #[test]
    #[should_panic]
    fn greater_than_100() {
        GuessTrait::new(200);
    }
}
}

示例9-8:测试一个条件是否会导致panic

我们把#[should_panic]属性放在#[test]属性之后和它适用的测试函数之前。让我们看一下这个测试通过后的结果:

$ scarb cairo-test
running 1 tests
test adder::lib::tests::greater_than_100 … ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 filtered out;

看起来不错! 现在让我们在代码中引入一个错误,删除新函数在值大于100时将发生panic的条件:

#![allow(unused)]
fn main() {
#[derive(Copy, Drop)]
struct Guess {
    value: u64,
}

trait GuessTrait {
    fn new(value: u64) -> Guess;
}

impl GuessImpl of GuessTrait {
    fn new(value: u64) -> Guess {
        if value < 1 {
            let mut data = ArrayTrait::new();
            data.append('Guess must be >= 1 and <= 100');
            panic(data);
        }

        Guess { value, }
    }
}


}

当我们运行示例9-8中的测试时,它将失败:

$ scarb cairo-test
running 1 tests
test adder::lib::tests::greater_than_100 ... fail
failures:
   adder::lib::tests::greater_than_100 - expected panic but finished successfully.
Error: test result: FAILED. 0 passed; 1 failed; 0 ignored

在这种情况下,我们没有得到一个非常有用的消息,但是当我们看测试函数时,我们看到它被注解为#[should_panic]。我们得到的失败意味着测试函数中的代码并没有引起panic。

使用should_panic的测试可能是不精确的。即使测试的panic原因与我们所期望的不同, 但只要发生了panic,一个should_panic测试就一定会通过。为了使should_panic测试更加精确,我们可以在should_panic属性中添加一个可选的预期参数。该测试限制将确保故障信息包含所提供的文本。例如,考虑示例9-9中Guess的修改后的代码,新函数根据数值过小或过大的情况,以不同的消息进行恐慌。

文件名: src/lib.cairo

#![allow(unused)]
fn main() {
#[derive(Copy, Drop)]
struct Guess {
    value: u64,
}

trait GuessTrait {
    fn new(value: u64) -> Guess;
}

impl GuessImpl of GuessTrait {
    fn new(value: u64) -> Guess {
        if value < 1 {
            panic_with_felt252('Guess must be >= 1');
        } else if value > 100 {
            panic_with_felt252('Guess must be <= 100');
        }

        Guess { value, }
    }
}

#[cfg(test)]
mod tests {
    use super::Guess;
    use super::GuessTrait;

    #[test]
    #[should_panic(expected: ('Guess must be <= 100',))]
    fn greater_than_100() {
        GuessTrait::new(200);
    }
}


}

示例9-9:用包含错误信息字符串的恐慌信息来测试panic

这个测试将通过,因为我们放在should_panic属性的预期参数中的值是Guess::new函数panic信息的字符串阵列。我们需要指定我们期望的整个panic信息。

为了看看当一个带有预期信息的 should_panic 测试失败时会发生什么,让我们再次把if value < 1和else if value > 100块的主体互换,从而在我们的代码中引入一个错误:

#![allow(unused)]
fn main() {
impl GuessImpl of GuessTrait {
    fn new(value: u64) -> Guess {
        if value < 1 {
            let mut data = ArrayTrait::new();
            data.append('Guess must be >= 1');
            panic(data);
        } else if value > 100 {
            let mut data = ArrayTrait::new();
            data.append('Guess must be <= 100');
            panic(data);
        }

        Guess { value, }
    }
}

#[cfg(test)]
mod tests {
    use super::Guess;
    use super::GuessTrait;

    #[test]
    #[should_panic(expected: ('Guess must be <= 100',))]
    fn greater_than_100() {
        GuessTrait::new(200);
    }
}
}

这一次,当我们运行should_panic测试时,它将失败:

$ scarb cairo-test
running 1 tests
test adder::lib::tests::greater_than_100 … fail
failures:
   adder::lib::tests::greater_than_100 - panicked with [6224920189561486601619856539731839409791025 (‘Guess must be >= 1’), ].

Error: test result: FAILED. 0 passed; 1 failed; 0 ignored

失败信息表明,这个测试确实像我们预期的那样发生了panic,但是panic信息不包括预期的字符串。在这种情况下,我们得到的panic信息是 Guess must be >= 1。现在我们可以开始找出我们的错误所在了!

运行单一测试

有时,运行一个完整的测试套件可能需要很长的时间。如果你正在处理某个特定领域的代码,你可能只想运行与该代码有关的测试。你可以通过传递scarb cairo-test以及-f(“filter”)你想运行的测试名称作为参数来选择运行哪些测试。

为了演示如何运行一个测试,我们将首先创建两个测试函数,如示例9-10所示,并选择运行哪一个。

文件名: src/lib.cairo

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    #[test]
    fn add_two_and_two() {
        let result = 2 + 2;
        assert(result == 4, ‘result is not 4’);
    }

    #[test]
    fn add_three_and_two() {
        let result = 3 + 2;
        assert(result == 5, ‘result is not 5’);
    }
}
}

示例9-10:两个不同名称的测试

我们可以将任何测试函数的名称传递给cairo-test,以使用-f 标志只运行该测试:

$ scarb cairo-test -f add_two_and_two
running 1 tests
test adder::lib::tests::add_two_and_two ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 1 filtered out;

只有名称为add_two_and_two 的测试被运行了,其他的测试不符合这个名称。测试输出最后显示了1个测试被过滤掉,让我们知道我们还有一个测试没有运行。

我们还可以指定测试名称的一部分,任何名称包含该值的测试都将被运行。

在非特别指定时,忽略一些测试

有时一些特定的测试执行起来非常耗时,所以你可能想在大多数scarb cairo-test的运行中排除它们。与其将所有你想运行的测试列为参数,不如使用ignore 属性对耗时的测试进行注释,将其排除在外,如图所示:

文件名: src/lib.cairo

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    #[test]
    fn it_works() {
        let result = 2 + 2;
        assert(result == 4, 'result is not 4');
    }

    #[test]
    #[ignore]
    fn expensive_test() { // code that takes an hour to run
    }
}
}

对于想要排除的测试,我们在 #[test]之后,添加了 #[ignore]行。现在,当我们运行我们的测试时,it_works会运行,但expensive_test不会:

$ scarb cairo-test
running 2 tests
test adder::lib::tests::expensive_test ... ignored
test adder::lib::tests::it_works ... ok
test result: ok. 1 passed; 0 failed; 1 ignored; 0 filtered out;

expensive_test函数被列为ignored

当你到了需要检查被忽略的测试结果的时候,而且你有时间等待测试结果,你可以运行scarb cairo-test --include-ignored来运行所有的测试,无论它们是否被标记忽略。

测试递归函数或循环

在测试递归函数或循环时,必须为测试提供可消耗的最大gas。这样可以防止运行无限循环或消耗过多gas,还可以帮助对您的代码实现效率进行基准测试。为此,必须在测试函数中添加 #[available_gas(<Number>)] 属性。下面的示例展示了如何使用该属性:

文件名: src/lib.cairo

#![allow(unused)]
fn main() {
fn sum_n(n: usize) -> usize {
    let mut i = 0;
    let mut sum = 0;
    loop {
        if i == n {
            sum += i;
            break;
        };
        sum += i;
        i += 1;
    };
    sum
}

#[cfg(test)]
mod test {
    use super::sum_n;
    #[test]
    #[available_gas(2000000)]
    fn test_sum_n() {
        let result = sum_n(10);
        assert(result == 55, 'result is not 55');
    }
}
}

测定特定操作的gas使用量

如果要对特定操作的gas用量进行基准测试,可以在测试函数中使用以下模式。

#![allow(unused)]
fn main() {
let initial = testing::get_available_gas();
gas::withdraw_gas().unwrap();
    /// code we want to bench.
(testing::get_available_gas() - x).print();
}

下面的示例展示了如何使用它来测试上述 sum_n 函数的gas用量。

#![allow(unused)]
fn main() {
fn sum_n(n: usize) -> usize {
    let mut i = 0;
    let mut sum = 0;
    loop {
        if i == n {
            sum += i;
            break;
        };
        sum += i;
        i += 1;
    };
    sum
}

#[cfg(test)]
mod test {
    use super::sum_n;
    use debug::PrintTrait;
    #[test]
    #[available_gas(2000000)]
    fn benchmark_sum_n_gas() {
        let initial = testing::get_available_gas();
        gas::withdraw_gas().unwrap();
        /// code we want to bench.
        let result = sum_n(10);
        (initial - testing::get_available_gas()).print();
    }
}
}

运行 "scarb cairo-test "时打印的值是基准运行所消耗的gas。

$ scarb cairo-test
testing no_listing_09_benchmark_gas ...
running 1 tests
[DEBUG]	                               	(raw: 0x179f8

test no_listing_09_benchmark_gas::benchmark_sum_n_gas ... ok (gas usage est.: 98030)
test result: ok. 1 passed; 0 failed; 0 ignored; 0 filtered out;

这里,"sum_n "函数的gas用量为 96760(十六进制数的十进制表示法)。由于运行整个测试功能需要一些额外步骤,因此测试消耗的gas总量略高,为 98030。

Last change: 2023-09-22, commit: 724b90c

测试的组织结构

我们把测试主要分成两类:单元测试和集成测试。单元测试小而专注,每次测试一个模块,可以测试私有函数。虽然 Cairo 还没有实现公有/私有函数/字段的概念,但像这样组织代码是一个很好的习惯。集成测试像其他任何外部代码一样使用您的代码,只使用公共接口,并在每个测试中可能使用多个模块。

为了保证你的库能够按照你的预期运行,从独立和整体的角度编写这两类测试都是非常重要的。

单元测试

单元测试的目的是在与其他部分隔离的环境中测试每一个单元的代码,以便于快速而准确地验证某个单元的代码功能是否符合预期。单元测试与他们要测试的代码共同存放在位于 src 目录下相同的文件中。

规范是在每个文件中创建包含测试函数的 tests 模块,并使用 cfg(test) 标注模块。

测试模块和#[cfg(test)]

测试模块的 #[cfg(test)]注解告诉 Cairo 只在执行scarb cairo-test 时才编译和运行测试代码,而在运行 cairo-run 时不这么做。这在只希望构建库的时候可以节省编译时间,并且因为它们并没有包含测试,所以能减少编译产生的文件的大小。与之对应的集成测试因为位于另一个文件夹,所以它们并不需要 #[cfg(test)]注解。然而单元测试位于与源码相同的文件中,所以你需要使用 #[cfg(test)] 来指定他们不应该被包含进编译结果中。

回顾一下,当我们在本章第一节创建新的adder项目时,我们写了这个测试:

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    #[test]
    fn it_works() {
        let result = 2 + 2;
        assert(result == 4, 'result is not 4');
    }
}
}

文件名: src/lib.cairo

cfg属性代表配置,告诉Cairo只有在给定的配置选项的情况下才应该包含下面的项目。在本例中,配置选项是test,它由Cairo提供,用于编译和运行测试。通过使用cfg属性,Cairo只有在我们用scarb cairo-test主动运行测试时才会编译我们的测试代码。这包括任何可能在这个模块中的辅助函数,以及用#[test]标注的函数。

集成测试

集成测试对于你需要测试的库来说完全是外部的。同其他使用库的代码一样使用库文件,也就是说它们只能调用一部分库中的公有 API。集成测试的目的是测试库的多个部分能否一起正常工作。一些单独能正确运行的代码单元集成在一起也可能会出现问题,所以集成测试的覆盖率也是很重要的。为了创建集成测试,你需要先创建一个 tests 目录。

tests目录

adder
├── Scarb.toml
├── src
│   ├── lib.cairo
│   ├── tests
│   │   └── integration_test.cairo
│   └── tests.cairo
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests;

fn it_adds_two(a: u8, b: u8) -> u8 {
    a + b
}
}

文件名: src/lib.cairo

#![allow(unused)]
fn main() {
#[cfg(tests)]
mod integration_tests;
}

文件名:src/tests.cairo

将示例9-11中的代码输入到 src/tests/integration_test.cairo 文件:

#![allow(unused)]
fn main() {
use adder::it_adds_two;

#[test]
#[available_gas(2000000)]
fn internal() {
    assert(it_adds_two(2, 2) == 4, 'internal_adder failed');
}
}

文件名:src/tests/integration_test.cairo

我们需要在每个测试文件的作用域中引入被测试的函数。出于这个原因,我们在代码的顶部添加了use adder::it_adds_two,在单元测试中我们不需要这个。

然后,为了运行我们所有的集成测试,我们可以添加一个过滤器来仅运行路径包含“integration_tests”的测试。

$ scarb test -f integration_tests
Running cairo-test adder
testing adder ...
running 1 tests
test adder::tests::integration_tests::internal ... ok (gas usage est.: 3770)
test result: ok. 1 passed; 0 failed; 0 ignored; 0 filtered out;

测试的结果与我们之前看到的相同:每个测试一行。

Last change: 2023-09-22, commit: 17537e2

错误处理

在本章中,我们将探讨Cairo提供的各种错误处理技术,这些技术不仅能让你解决代码中的潜在问题,还能让你更容易创建易适应和易维护的程序。通过研究管理错误的不同方法,如用Result枚举进行模式匹配,使用 ? 操作符进行更人性化的错误传播,以及采用unwrap或expect方法来处理可恢复的错误,你将对Cairo的错误处理功能有更深入的了解。这些概念对于构建强大的应用程序至关重要,它可以有效地处理意外情况,确保你的代码可以用于生产环境。

Last change: 2023-09-20, commit: cbb0049

无法恢复的错误与恐慌(panic)

在Cairo中,程序执行过程中可能会出现意外问题,导致运行时错误。虽然核心库中的panic函数并没有为这些错误提供解决方案,但它确实承认这些错误的发生并终止程序。在Cairo中,有两种主要的方式可以触发panic:无意地通过导致代码panic的行为(例如,访问一个超出其界限的数组),或故意地,通过调用panic函数。

当发生恐慌时,它会导致程序突然终止。panic 函数接受一个数组作为参数,可以用来提供错误消息,并执行一个解除过程,在这个过程中所有变量都会被丢弃,字典被压缩,以确保程序的健全性,安全地终止执行。

下面是我们如何在一个程序中panic并返回错误代码2

文件名: src/lib.cairo

use debug::PrintTrait;

fn main() {
    let mut data = ArrayTrait::new();
    data.append(2);
    if true == true {
        panic(data);
    }
    'This line isn\'t reached'.print();
}

运行该程序将产生以下输出:

$ scarb cairo-run
Run panicked with [2 (''), ].

正如你在输出中所注意到的,打印语句没有被执行,因为程序在遇到panic语句后就终止了。

Cairo 中处理恐慌的另一种更符合习惯的方法是使用 panic_with_felt252 函数。这个函数作为定义数组过程的抽象,通常更受欢迎,因为它表达意图更清晰、更简洁。通过使用 panic_with_felt252,开发者可以通过提供一个 felt252 类型的错误消息作为参数,在一行代码中实现恐慌,使代码更易读和可维护。

让我们来考察一个例子:

fn main() {
    panic_with_felt252(2);
}

执行这个程序会产生和之前一样的错误信息。在这种情况下,如果在返回错误是不需要一个数组和多个值,那么panic_with_felt252是一个更简洁的选择。

nopanic记号

你可以使用nopanic记号来表示一个函数永远不会恐慌。只有 nopanic函数可以在标注为 nopanic的函数中被调用。

例子:

fn function_never_panic() -> felt252 nopanic {
    42
}

错误的例子:

fn function_never_panic() nopanic {
    assert(1 == 1, 'what');
}

如果你写了以下函数,其中包括一个可能会panic的函数,你会得到以下错误:

error: Function is declared as nopanic but calls a function that may panic.
 --> test.cairo:2:12
    assert(1 == 1, 'what');
           ^****^
Function is declared as nopanic but calls a function that may panic.
 --> test.cairo:2:5
    assert(1 == 1, 'what');
    ^********************^

请注意,有两个函数可能会在这里发生panic,即断言和相等比较。

panic_with 属性

您可以使用 panic_with 属性来标记返回 OptionResult 的函数。该属性需要两个参数,即作为 panic 原因传递的数据以及包装函数的名称。它将为您标注的函数创建一个封装函数,如果函数返回 NoneErr,该封装函数将被调用,并使用给定的数据。

例子:

#[panic_with('value is 0', wrap_not_zero)]
fn wrap_if_not_zero(value: u128) -> Option<u128> {
    if value == 0 {
        Option::None
    } else {
        Option::Some(value)
    }
}

fn main() {
    wrap_if_not_zero(0); // this returns None
    wrap_not_zero(0); // this panic with 'value is 0'
}

使用断言(assert)

Cairo核心库中的assert函数实际上是一个基于panic的实用函数。它断言一个布尔表达式在运行时是真的,如果不是,它就会调用带有错误值的panic函数。assert函数需要两个参数:要验证的布尔表达式,以及错误值。错误值被指定为felt252,所以任何传递的字符串都必须能够容纳在felt252中。

下面是它的一个使用例子:

fn main() {
    let my_number: u8 = 0;

    assert(my_number != 0, 'number is zero');

    100 / my_number;
}

我们在main中断言my_number不是0,以确保我们没有进行除以0的操作。 在这个例子中,my_number是零,所以断言会失败,程序会panic, 并给出 'number is zero'的字符串结果(以felt252的形式),除法将不会被执行。

Last change: 2023-11-19, commit: a15432b

可恢复的错误与 Result


大多数错误并没有严重到需要程序完全停止的程度。有时,当一个函数失败时,它的原因是你可以很容易地解释和应对的。例如,如果你试图将两个大的整数相加,而操作溢出,因为总和超过了最大的可表示值,你可能想返回一个错误或一个包装好的结果,而不是引起未定义行为或终止程序。

Result枚举

回顾第8章中的“通用数据类型” 一节中,Result 枚举被定义为具有两个变体,即 OkErr,如下所示:

enum Result<T, E> {
    Ok: T,
    Err: E,
}

Result<T, E>枚举有两个泛型类型,TE,以及两个成员:Ok,存放T类型的值,Err,存放E类型的值。这个定义使得我们可以在任何地方使用Result枚举,该操作可能成功(返回T类型的值)或失败(返回E类型的值)。

ResultTrait

ResultTraittrait提供了处理Result<T, E>枚举的方法,例如解包值,检查ResultOk还是Err,以及用自定义的消息进行panic。ResultTraitImpl实现定义了这些方法的逻辑。

trait ResultTrait<T, E> {
    fn expect<+Drop<E>>(self: Result<T, E>, err: felt252) -> T;

    fn unwrap<+Drop<E>>(self: Result<T, E>) -> T;

    fn expect_err<+Drop<T>>(self: Result<T, E>, err: felt252) -> E;

    fn unwrap_err<+Drop<T>>(self: Result<T, E>) -> E;

    fn is_ok(self: @Result<T, E>) -> bool;

    fn is_err(self: @Result<T, E>) -> bool;
}

expectunwrap方法类似,它们都试图从Result<T, E>中提取T类型的值,当它处于Ok变体时。如果ResultOk(x),两个方法都返回值 "x"。然而,这两个方法的关键区别在于当ResultErr变量时的行为。expect方法允许你提供一个自定义的错误信息(作为felt252值)在panic时使用,从而让你获取更多对panic相关的控制和上下文。另一方面,unwrap方法用一个默认的错误信息进行panic,提供的关于panic原因的信息较少。

expect_errunwrap_err的行为完全相反。如果ResultErr(x),两个方法都返回值x。然而,这两个方法的关键区别是在Result::Ok()的情况下。expect_err方法允许你提供一个自定义的错误信息(作为felt252值),在panic时使用,从而让你获取更多对panic相关的控制和上下文。另一方面,unwrap_err方法用一个默认的错误信息进行panic,提供的关于panic原因的信息较少。

A careful reader may have noticed the <+Drop<T>> and <+Drop<E>> in the first four methods signatures. This syntax represents generic type constraints in the Cairo language. These constraints indicate that the associated functions require an implementation of the Drop trait for the generic types T and E, respectively.

最后,is_okis_err方法是ResultTraittrait提供的实用函数,用于检查Result枚举值的成员。

is_ok获取一个Result<T, E>值的快照,如果ResultOk成员,则返回true,意味着操作成功。如果ResultErr成员,则返回false

is_err接收一个对Result<T, E>值的引用,如果ResultErr成员,意味着操作遇到了错误,则返回true。如果 ResultOk成员,则返回 false

当你想在不消耗结果值的情况下检查一个操作的成功或失败时,这些方法很有帮助,允许你执行额外的操作或根据枚举成员做出决定,而不用解开(unwrap)它。

你可以在这里找到 ResultTrait 的实现。


有例子总是更容易理解。

请看一下这个函数签名:

fn u128_overflowing_add(a: u128, b: u128) -> Result<u128, u128>;

它接收两个u128整数,a和b,并返回一个Result<u128, u128>,如果加法没有溢出,Ok成员存储加法的和,如果加法溢出,`Err’成员存储溢出的值。

现在,我们可以在其他地方使用这个函数。比如说:

fn u128_checked_add(a: u128, b: u128) -> Option<u128> {
    match u128_overflowing_add(a, b) {
        Result::Ok(r) => Option::Some(r),
        Result::Err(r) => Option::None,
    }
}

这里,它接受两个 u128 整数,a 和 b,返回一个 Option<u128>。它使用 u128_overflowing_add 返回的 Result 来确定加法操作的成功或失败。匹配表达式检查 u128_overflowing_addResult。如果结果是 Ok(r),它返回 Option::Some(r),其中包含求和结果。如果结果是 Err(r),它返回 Option::None,表示操作因为溢出而失败。如果发生溢出,该函数不会引发恐慌。

让我们再举一个例子,演示一下unwrap的使用。 首先我们导入必要的模块:

use core::traits::Into;
use traits::TryInto;
use option::OptionTrait;
use result::ResultTrait;
use result::ResultTraitImpl;

在这个例子中,parse_u8函数接收一个felt252的整数,并尝试用try_into方法将其转换为u8的整数。如果成功,它返回Result::Ok(value),否则它返回Result::Err('Invalid integer')

fn parse_u8(s: felt252) -> Result<u8, felt252> {
    match s.try_into() {
        Option::Some(value) => Result::Ok(value),
        Option::None => Result::Err('Invalid integer'),
    }
}

示例10-1:使用Result 类型

我们的两个测试案例是:

fn parse_u8(s: felt252) -> Result<u8, felt252> {
    match s.try_into() {
        Option::Some(value) => Result::Ok(value),
        Option::None => Result::Err('Invalid integer'),
    }
}

#[cfg(test)]
mod tests {
    use super::parse_u8;
    #[test]
    fn test_felt252_to_u8() {
        let number: felt252 = 5_felt252;
        // should not panic
        let res = parse_u8(number).unwrap();
    }

    #[test]
    #[should_panic]
    fn test_felt252_to_u8_panic() {
        let number: felt252 = 256_felt252;
        // should panic
        let res = parse_u8(number).unwrap();
    }
}

第一个测试函数是测试从felt252u8的有效转换,期望unwrap方法不要panic。第二个测试函数试图转换一个超出u8范围的值,期望unwrap方法panic,错误信息是 'Invalid integer'。

我们也可以在这里使用 #[should_panic] 属性。

?运算符?

我们要谈的最后一个操作符是?操作符。?运算符用于更成文和简明的错误处理。当你在 ResultOption类型上使用?运算符时,它将做以下事情:

  • 如果值是Result::Ok(x)Option::Some(x),它将直接返回内部值x
  • 如果值是Result::Err(e)Option::None,它将通过立即从函数返回来传播错误或None

当你想隐式处理错误并让调用函数处理它们时,?操作符很有用。

下面是一个例子。

fn do_something_with_parse_u8(input: felt252) -> Result<u8, felt252> {
    let input_to_u8: u8 = parse_u8(input)?;
    // DO SOMETHING
    let res = input_to_u8 - 1;
    Result::Ok(res)
}

示例 10-1: 使用 ? 操作符

do_something_with_parse_u8函数接收一个felt252值作为输入并调用parse_u8?操作符用来传播错误,如果有的话,或者unwrap成功的值。

这里还有一个小的测试案例:

fn parse_u8(s: felt252) -> Result<u8, felt252> {
    match s.try_into() {
        Option::Some(value) => Result::Ok(value),
        Option::None => Result::Err('Invalid integer'),
    }
}

fn do_something_with_parse_u8(input: felt252) -> Result<u8, felt252> {
    let input_to_u8: u8 = parse_u8(input)?;
    // DO SOMETHING
    let res = input_to_u8 - 1;
    Result::Ok(res)
}

#[cfg(test)]
mod tests {
    use super::do_something_with_parse_u8;
    use debug::PrintTrait;
    #[test]
    fn test_function_2() {
        let number: felt252 = 258_felt252;
        match do_something_with_parse_u8(number) {
            Result::Ok(value) => value.print(),
            Result::Err(e) => e.print()
        }
    }
}

控制台将打印错误 “Invalid Integer”。


总结

我们看到,可恢复的错误可以在Cairo中使用结果枚举来处理,它有两个变体:OkErrResult<T, E>枚举是通用的,其类型TE分别代表成功和错误值。ResultTrait提供了处理Result<T, E>的方法,例如解包值,检查结果是Ok还是Err,以及用自定义消息进行panic。

为了处理可恢复的错误,一个函数可以返回一个Result类型,并使用模式匹配来处理操作的成功或失败。?操作符可用于通过传播错误或解包成功的值来隐含地处理错误。这使得错误处理更加简洁明了,调用者负责管理由被调用函数引发的错误。

Last change: 2023-12-09, commit: acd03a1

高级特性

现在,让我们来了解 Cairo 提供的更多高级功能。

Last change: 2023-09-20, commit: cbb0049

操作符重载

操作符重载是一些编程语言的一个特点,它允许在用户自定义的类型上重新定义标准操作符,如加法(+)、减法(-)、乘法(*)和除法(/)。这可以使代码的语法更加直观,因为它使对用户字定义类型的操作与对原始类型的操作表达方式相同。

在Cairo中,操作符重载是通过实现特定的trait来实现的。每个操作符都有一个相关的traits,操作符重载涉及到需为一个自定义类型提供该trait的实现。 然而,明智地使用操作符重载是非常重要的。误用会导致混乱,使代码更难维护,比如当被重载的操作符的语义与操作符原有的语义毫不相干的时候。

让我看一个例子,两个 Potions 需要合并。Potions 有两个数据字段,法力(mana)和健康(health)。合并两个Potions 应该是让它们各自的两个字段相加。

struct Potion {
    health: felt252,
    mana: felt252
}

impl PotionAdd of Add<Potion> {
    fn add(lhs: Potion, rhs: Potion) -> Potion {
        Potion { health: lhs.health + rhs.health, mana: lhs.mana + rhs.mana, }
    }
}

fn main() {
    let health_potion: Potion = Potion { health: 100, mana: 0 };
    let mana_potion: Potion = Potion { health: 0, mana: 100 };
    let super_potion: Potion = health_potion + mana_potion;
    // Both potions were combined with the `+` operator.
    assert(super_potion.health == 100, '');
    assert(super_potion.mana == 100, '');
}

在上面的代码中,我们为 Potion类型实现Add特性。Add函数需要两个参数:lhsrhs(左手端和右手端,分别表示在运算式的左边还是右边)。函数主体返回一个新的Potion实例,其字段值是lhsrhs的组合。

正如例子中所说明的,重载一个操作符需要指定被重载的具体类型。这里被重载用泛型表示的trait是 Add<Potion>,所以我们将用 Add<Potion>为 ‘Potion`类型定义一个具体的实现。

Last change: 2023-09-20, commit: cbb0049

Cairo语言有一些插件可以让开发人员简化代码。它们被称为 内联宏(inline_macros),是一种可以生成其他代码的代码编写方式。在Cairo语言中,只有两个 :"array![]"和 "consteval_int!()"。

让我们从 array! 宏开始

有时候,我们需要用在编译时已经知道的值来创建数组。这件事的基本做法是很不优雅且多余的。你需要首先声明数组,然后逐一为数组追加每个值。array! 是一种更简单的方法,它将这两个步骤合并在一起。 在编译时,编译器将创建一个数组,并按顺序追加传递给 array! 宏的所有值。

不使用 array!:

#![allow(unused)]
fn main() {
    let mut arr = ArrayTrait::new();
    arr.append(1);
    arr.append(2);
    arr.append(3);
    arr.append(4);
    arr.append(5);
}

使用 array!:

#![allow(unused)]
fn main() {
    let arr = array![1, 2, 3, 4, 5];
}

consteval_int!

在某些情况下,开发人员可能需要声明一个常量,该常量是整数计算的结果。为了在编译时计算常量表达式并使用其结果,需要使用consteval_int! 宏。

下面是 consteval_int!的示例:

#![allow(unused)]
fn main() {
const a: felt252 = consteval_int!(2 * 2 * 2);
}

编译器将解释为 const a: felt252 = 8

Last change: 2023-11-19, commit: a15432b

哈希

哈希本质上是一个将任意长度的输入数据(通常称为消息)转换为固定大小值的过程,该值通常称为“哈希”。这种转换是确定性的,这意味着相同的输入将始终生成相同的哈希值。哈希函数是各种领域的基石,包括数据存储、密码学和数据完整性验证 - 并且在开发智能合约时经常使用,尤其是在使用 Merkle 树时。

本章,我们将介绍 Cairo 库中原生实现的两种哈希函数:Poseidon 和 Pedersen。我们将讨论何时以及如何使用它们,并通过 Cairo 程序展示示例。

Cairo中的哈希函数

Cairo 核心库提供了两种哈希函数:Pedersen 和 Poseidon。

Pedersen 哈希函数是一种基于椭圆曲线密码学的加密算法。这些函数对椭圆曲线上的点进行操作——本质上是使用这些点的位置进行数学运算——这在一个方向上很容易做到,但却很难反过来操作。这种单向性是基于椭圆曲线离散对数问题 (ECDLP),它是一个很难的问题,可以确保哈希函数的安全性。无法逆转这些操作的困难性使 Pedersen 哈希函数在加密用途上安全可靠。

Poseidon 是一系列哈希函数,作为代数电路非常高效。它的设计特别适用于零知识证明系统,包括 STARKs (所以也适用于 Cairo)。Poseidon 使用一种称为“sponge construction”的方法,该方法吸收数据并使用称为 Hades 置换的过程安全地转换数据。Cairo 版本的 Poseidon 基于具有三元素状态置换和 特定参数的设计。

何时使用它们?

Pedersen 是 Starknet 上最初使用的哈希函数,现在仍用于计算存储中变量的地址(例如,LegacyMap 使用 Pedersen 对 Starknet 上存储映射的键进行哈希)。然而,由于 Poseidon 在处理 STARK 证明系统时比 Pedersen 更便宜,更快,因此现在它已成为 Cairo 程序中推荐使用的哈希函数。

使用哈希函数

核心库使哈希操作变得简单。Hash trait适用于所有可以转换为 felt252的类型,包括 felt252本身。对于像结构体这样更复杂的类型,派生 Hash允许它们使用您选择的哈希函数进行哈希 - 只要结构体的所有字段本身都是可哈希的。即使 T1 本身可哈希,您也无法在包含不可哈希值的结构体上派生 Hashtrait,例如 Array<T>Felt252Dict<T>

Hashtrait伴随着 HashStateTrait,它定义了用于处理哈希的基本方法。它们允许您初始化一个哈希状态,其中将包含哈希函数每次应用后哈希的临时值;更新哈希状态,并在计算完成后将其最终确定。HashStateTrait定义如下:

#![allow(unused)]

fn main() {
/// A trait for hash state accumulators.
trait HashStateTrait<S> {
    fn update(self: S, value: felt252) -> S;
    fn finalize(self: S) -> felt252;
}

/// A trait for values that can be hashed.
trait Hash<T, S, +HashStateTrait<S>> {
    /// Updates the hash state with the given value.
    fn update_state(state: S, value: T) -> S;
}
}

要在代码中使用哈希,您必须首先导入相关的trait和函数。在以下示例中,我们将演示如何使用 Pedersen 和 Poseidon 哈希函数对结构体进行哈希处理。

首先,需要根据我们想要使用的哈希函数,使用 PoseidonTrait::new() -> HashStatePedersenTrait::new(base: felt252) -> HashState 初始化哈希状态。然后,可以使用 update(self: HashState, value: felt252) -> HashStateupdate_with(self: S, value: T) -> S 函数多次更新哈希状态,具体更新次数取决于需要。最后,使用 finalize(self: HashState) -> felt252 函数完成哈希计算并返回哈希值。

#![allow(unused)]
fn main() {
use pedersen::PedersenTrait;
use poseidon::PoseidonTrait;
use hash::{HashStateTrait, HashStateExTrait};
}
#![allow(unused)]
fn main() {
#[derive(Drop, Hash)]
struct StructForHash {
    first: felt252,
    second: felt252,
    third: (u32, u32),
    last: bool,
}
}

由于我们的结构体派生了 HashTrait 这个trait,我们可以使用以下方式调用 Poseidon 哈希函数:

use pedersen::PedersenTrait;
use poseidon::PoseidonTrait;
use hash::{HashStateTrait, HashStateExTrait};

#[derive(Drop, Hash)]
struct StructForHash {
    first: felt252,
    second: felt252,
    third: (u32, u32),
    last: bool,
}

fn main() -> felt252 {
    let struct_to_hash = StructForHash { first: 0, second: 1, third: (1, 2), last: false };

    let hash = PoseidonTrait::new().update_with(struct_to_hash).finalize();
    hash
}

同样地,我们也可以使用以下方式调用 Pedersen 哈希函数:

use pedersen::PedersenTrait;
use poseidon::PoseidonTrait;
use hash::{HashStateTrait, HashStateExTrait};

#[derive(Drop, Hash)]
struct StructForHash {
    first: felt252,
    second: felt252,
    third: (u32, u32),
    last: bool,
}

fn main() -> felt252 {
    let struct_to_hash = StructForHash { first: 0, second: 1, third: (1, 2), last: false };

    let hash = PedersenTrait::new(0).update_with(struct_to_hash).finalize();
    hash
}


高级哈希:使用 Poseidon 哈希数组

让我们来看一个哈希包含 Span<felt252>.的函数的例子。 要哈希 Span<felt252> 或包含 Span<felt252> 的结构体,您可以使用 poseidon 中的内置函数 poseidon_hash_span(mut span: Span<felt252>) -> felt252。同样,您可以通过对其 span 调用 poseidon_hash_span 来哈希 Array<felt252>

首先,让我们引入以下trait和函数:

#![allow(unused)]
fn main() {
use poseidon::PoseidonTrait;
use poseidon::poseidon_hash_span;
use hash::{HashStateTrait, HashStateExTrait};
}

现在,我们来定义结构体。正如您可能注意到的,我们没有派生 Hash trait。如果您尝试在这个结构体上派生 Hash trait,它会引发错误,因为结构体包含一个不可哈希的字段。

#[derive(Drop)]
struct StructForHashArray {
    first: felt252,
    second: felt252,
    third: Array<felt252>,
}

在这个例子中,我们初始化了一个 HashState (hash) 并更新了它,然后在 HashState 上调用了函数 finalize() 以获得计算出的哈希 hash_felt252。我们使用 poseidon_hash_spanArray<felt252>Span 进行哈希计算,以计算其哈希值。

use poseidon::PoseidonTrait;
use poseidon::poseidon_hash_span;
use hash::{HashStateTrait, HashStateExTrait};

#[derive(Drop)]
struct StructForHashArray {
    first: felt252,
    second: felt252,
    third: Array<felt252>,
}

fn main() {
    let struct_to_hash = StructForHashArray { first: 0, second: 1, third: array![1, 2, 3, 4, 5] };

    let mut hash = PoseidonTrait::new().update(struct_to_hash.first).update(struct_to_hash.second);
    let hash_felt252 = hash.update(poseidon_hash_span(struct_to_hash.third.span())).finalize();
}


Last change: 2023-12-01, commit: f916800

Starknet智能合约

在前面的章节中,你主要是用main入口来编写程序。在接下来的章节中,你将学习如何编写和部署Starknet合约。

Cairo语言的一个应用是编写用于Starknet网络的智能合约。Starknet是一个无许可网络,利用zk-STARKs技术实现可扩展性。作为以太坊的二层可扩展性解决方案,Starknet的目标是提供快速、安全和低成本的交易。它作为一个有效性Rollup(通常称为零知识Rollup)运行,并构建在Cairo语言和Starknet虚拟机之上。

简单来说,Starknet合约就是可以在Starknet虚拟机上运行的程序。由于它们在虚拟机上运行,它们可以访问Starknet的持久性状态,可以改变或修改Starknet状态中的变量,与其他合约沟通,并与底层的L1无缝交互。

Starknet合约由#[contract]属性标记。我们将在接下来的部分对此进行深入探讨。如果你想了解更多关于Starknet网络本身,其架构以及可用的工具,你应该阅读Starknet Book。本节将只专注于如何使用Cairo编写智能合约。

Scarb

Scarb支持Starknet的智能合约开发。要启用此功能,您需要在 Scarb.toml 文件中进行一些配置(有关如何安装Scarb,请参阅安装)。 你需要添加 starknet 依赖项,并添加一个 [[target.starknet-contract]] 部分以启用合约编译。

下面是编译包含Starknet合约的crate所需的最小Scarb.toml文件示例:

[package]
name = "package_name"
version = "0.1.0"

[dependencies]
starknet = ">=2.4.0"

[[target.starknet-contract]]

有关外部合约依赖等更多配置相关的内容,请参阅 Scarb 文档

本章中的每个示例都可以与 Scarb 一起使用。

Last change: 2023-12-11, commit: ae4d02d

智能合约简介

本章是一个关于什么是智能合约,它们有什么用途,以及为什么区块链开发者会使用Cairo和Starknet的高度简介。 如果你已经熟悉了区块链编程,可以跳过这一章。不过最后一部分应该还是有点意思的。

智能合约

随着以太坊的诞生,智能合约得到了普及并变得更加广泛。智能合约本质上是部署在区块链上的程序。术语 "智能合约 "有些误导,因为它们既不 "智能 "也不是 "合约",而只是根据特定输入执行的代码和指令。它们主要由两部分组成:存储和函数。部署后,用户可以通过启动包含执行数据的区块链交易(调用哪个函数,输入什么参数)与智能合约互动。智能合约可以修改和读取底层区块链的存储。智能合约有自己的地址,因此它是一个区块链账户,意味着它可以持有代币。

用于编写智能合约的编程语言因区块链的不同而不同。例如,在以太坊和EVM兼容的生态系统生态系统上,最常用的语言是Solidity,而在Starknet上,是Cairo。代码的编译方式也根据区块链的不同而不同。在Ethereum上,Solidity被编译成字节码。在Starknet上,Cairo被编译成Sierra,然后再编译成Cairo Assembly(casm)。

智能合约具有几个独特特征。它们是 无需许可 的,意味着任何人都可以在网络上部署智能合约(当然是在去中心化的区块链上)。智能合约也是 透明 的;由智能合约存储的数据对任何人都是可访问的。构成合约的代码也可以是透明的,从而实现 可组合性。这使得开发人员可以编写使用其他智能合约的智能合约。智能合约只能访问和与其部署所在的区块链上的数据进行交互。它们需要第三方软件(被称为 oracle)来访问外部数据(例如代币的价格)。

对于开发者来说,要想建立能够相互互动的智能合约,就必须知道其他合约的是什么样。因此,以太坊开发者建立了一些智能合约开发的标准,即 ERCxx。两个最常用和最著名的标准是 ERC20,用于建立 USDCDAISTARK等代币,以及 ERC721,用于 CryptoPunksEverai等NFT(Non-fungible token)。

使用案例

智能合约有许多可能的用例。唯一的限制是区块链的技术限制和开发者的创造力。

DeFi

眼下,智能合约的主要用例与以太坊或比特币的用例类似,基本上是处理金钱。在比特币承诺的替代支付系统的背景下,我们在以太坊上可以使用智能合约创建去中心化的金融应用,不需要再依赖传统的金融中介机构。这就是我们所说的DeFi(去中心化金融)。DeFi由各种项目组成,如借贷应用、去中心化交易所(DEX)、链上衍生品、稳定币、去中心化对冲基金、保险等等。

代币化

智能合约可以促进现实世界资产的代币化,如房地产、艺术品或贵金属。代币化将资产划分为数字代币,可以在区块链平台上轻松交易和管理。这可以增加流动性,实现部分所有权,并简化购买和销售过程。

投票

智能合约可用于创建安全和透明的投票系统。投票可以记录在区块链上,确保不可更改性和透明度。然后,智能合约可以自动统计票数并宣布结果,将欺诈或操纵的可能性降到最低。

特许权使用费(版税)

智能合约可以为艺术家、音乐家和其他内容创作者自动支付版税。当一段内容被消费或出售时,智能合约可以自动计算并将版税分配给合法的所有者,确保公平的补偿并减少对中间人的需求。

去中心化身份 DIDs

智能合约可用于创建和管理数字身份,允许个人控制其个人信息,并与第三方安全地分享。智能合约可以验证用户身份的真实性,并根据用户的凭证自动授予或撤销对特定服务的访问。



随着以太坊的不断成熟,我们可以预期智能合约的用例和应用将进一步扩大,带来令人兴奋的新机会,并会更好地重塑传统系统。

Starknet和Cairo语言的崛起

以太坊作为应用最广泛、弹性最大的智能合约平台,成为了自身成功的牺牲品。随着前面提到的一些用例(主要是DeFi)的快速采用,执行交易的成本变得非常高,使得网络几乎无法使用。生态系统中的工程师和研究人员开始研究解决这一可扩展性问题的方案。

区块链领域有一个著名的不可能三角(The Blockchain Trilemma),即不可能同时实现高水平的可扩展性、去中心化和安全性;我们必须做出权衡。以太坊处于去中心化和安全性的交叉点。最终,人们决定以太坊的目的是作为一个安全的结算层,而复杂的计算将被卸载到建立在以太坊之上的其他网络。这些网络被称为二层网络(L2)。

L2的两种主要类型是乐观rollup和有效性rollup。这两种方法都涉及压缩和批量处理大量交易,计算新状态,并将结果结算在以太坊(L1)上。区别在于在L1上结算结果的方式。对于乐观rollup,默认情况下新状态被认为是有效的,但节点有7天的窗口期来识别恶意交易。

与此相反,有效性rollup(如Starknet)使用加密技术来证明新状态的计算是正确的。这就是STARKs的目的,这种加密技术可以使有效性rollup的扩展能力大大超过乐观rollup。你可以从Starkware的Medium文章中了解更多关于STARKs的信息,它是一个很好的入门读物。

Starknet的架构在Starknet Book中有详细描述,是了解Starknet的重要资源。

还记得Cairo吗?事实上,它是一种专门为STARKs开发的语言,并使其具有通用性。使用Cairo,我们可以编写可证明的代码。在 Starknet 中,这可以证明从一个状态到另一个状态的计算的正确性。

大多数(也许不是全部)Starknet的竞争对手都选择使用 EVM(原版或改版)作为基础层,而Starknet则不同,它采用了自己的虚拟机。这使开发人员摆脱了 EVM 的束缚,开辟了更广阔的可能性。加上交易成本的降低,Starknet 与 Cairo 的结合为开发人员创造了一个令人兴奋的乐园。原生账户抽象使我们称之为 "智能账户" 的账户和交易流的逻辑更加复杂。新兴用例包括 透明人工智能 和机器学习应用。最后,区块链游戏 可以完全在 链上 开发。Starknet 经过专门设计,可最大限度地发挥 STARK 证明的能力,实现最佳可扩展性。

Starknet Book中了解更多关于账户抽象的信息。

Cairo程序和Starknet合约:有何区别?

Starknet合约是Cairo程序的一个特殊子集,所以之前在本书中学到的概念仍然适用于编写Starknet合约。 你可能已经注意到,一个Cairo程序必须始终有一个函数main,作为这个程序的入口:

fn main() {}

Starknet合约本质上是可以在Starknet操作系统上运行的程序,因此可以访问Starknet的状态。要让编译器把一个模块当作合约来处理,就必须用 #[starknet::contract]属性来标注它。

Last change: 2023-11-21, commit: 2fbb62a

一个简单的合约

本章将通过一个基本合约的例子向您介绍Starknet合约的基础知识。您将学习如何编写一个在区块链上存储单个数字的简单合约。

一个简单Starknet合约剖析

让我们通过下面的合约来了解Starknet合约的基本内容。可能一下子很难理解,但是我们将一步一步地进行讲解:

#![allow(unused)]
fn main() {
#[starknet::interface]
trait ISimpleStorage<TContractState> {
    fn set(ref self: TContractState, x: u128);
    fn get(self: @TContractState) -> u128;
}

#[starknet::contract]
mod SimpleStorage {
    use starknet::get_caller_address;
    use starknet::ContractAddress;

    #[storage]
    struct Storage {
        stored_data: u128
    }

    #[external(v0)]
    impl SimpleStorage of super::ISimpleStorage<ContractState> {
        fn set(ref self: ContractState, x: u128) {
            self.stored_data.write(x);
        }
        fn get(self: @ContractState) -> u128 {
            self.stored_data.read()
        }
    }
}
}

示例99-1:一个简单的存储合约

注意:Starknet合约是在模块(modules)中被定义的。

这是什么合约?

在这个例子中,Storage 结构声明了一个名为 stored_data 的存储变量,类型为u128(128位无符号整数)。 你可以将它想象成数据库中的一个单独槽位,通过调用管理数据库的代码的函数来查询和修改它。 该合约定义并公开了 setget 函数,用于修改或检索该变量的值。

接口:合约的蓝图

#[starknet::interface]
trait ISimpleStorage<TContractState> {
    fn set(ref self: TContractState, x: u128);
    fn get(self: @TContractState) -> u128;
}

合约的接口代表了该合约向外界公开的函数。在这里,接口公开了两个函数:setget 。通过利用Cairo的traits & impls 机制,我们可以确保合约的实际实现与其接口匹配。事实上,如果你的合约与声明的接口不符合,将会得到编译错误。

    #[external(v0)]
    impl SimpleStorage of super::ISimpleStorage<ContractState> {
        fn set(ref self: ContractState) {}
        fn get(self: @ContractState) -> u128 {
            self.stored_data.read()
        }
    }

示例 99-1-bis: 合约接口的错误实现。无法编译。

在接口中,请注意self参数的通用类型TContractState,它通过引用传递给set函数。参数 self 代表合约状态。看到 self 参数传递给 set 告诉我们这个函数可能会访问合约的状态,因为它使我们能够访问合约的存储空间。ref 修饰符意味着 self 可以被修改,这意味着合约的存储变量可以在 set 函数中被修改。

另一方面,get获取TContractState的_snapshot_,这立即告诉我们它不会修改状态(事实上,如果我们试图在get函数中修改存储变量,编译器会报错)。

在实现里定义public函数

在我们进一步探讨之前,让我们先定义一些术语。

  • 在Starknet中,public function (公共函数)是一个对外公开的函数。在上面的例子中,setget是公共函数。公有函数可以被任何人调用,可以从合约外部调用,也可以从合约内部调用。在上面的示例中,setget是公共函数。

  • 我们所说的 external 函数是通过交易唤起的公共函数,它可以改变合约的状态。set就是一个外部函数。

  • 一个 view 函数是一个公共函数,它可以从合约外部调用,但不能改变合约的状态。get是一个视图函数。

    #[external(v0)]
    impl SimpleStorage of super::ISimpleStorage<ContractState> {
        fn set(ref self: ContractState, x: u128) {
            self.stored_data.write(x);
        }
        fn get(self: @ContractState) -> u128 {
            self.stored_data.read()
        }
    }

由于合约接口被定义为ISimpleStoragetrait,为了匹配接口,合约的外部函数必须在这个trait的实现中定义--这使我们能够确保合约的实现与其接口相匹配。

然而,仅仅在实现中定义函数是不够的。实现块必须标注上#[external(v0)]属性。如果忘记添加该属性,您的函数将无法从外部调用。所有在标记为 #[external(v0)]的代码块中定义的函数都是 public functions

当编写接口的实现时,在trait中对应于self参数的泛型参数必须是ContractStateContractState类型由编译器生成,它提供了对定义在 "Storage结构中的存储变量的访问。 此外,ContractState还提供了emit事件的能力。不要对ContractState 这个名字感到奇怪,因为它是合约状态的表示,也就是我们在合约接口trait中认为的 self

修改合约状态

正如你所注意到的,所有需要访问合约状态的函数都被定义在一个有TContractState泛型参数的trait的实现下,并接受一个self:ContractState参数。 这允许我们显式地将 self:ContractState参数给函数,允许访问合约的存储变量。 要访问当前合约的存储变量,可以在存储变量名后添加 self 前缀,这样就可以使用 readwrite 方法读取或写入存储变量的值。

        fn set(ref self: ContractState, x: u128) {
            self.stored_data.write(x);
        }

使用 selfwrite 方法修改存储变量的值

注意:如果合约状态是作为snapshot而不是ref传递的,尝试修改它将导致编译错误。

除了允许任何人存储世界上任何人都可以访问的单个号码外,该合约没做其他事。任何人都可以用不同的值再次调用set覆盖您的号码,但号码仍然存储在区块链的历史中。稍后,您将看到如何施加访问限制,以便只有您可以更改号码。

Last change: 2023-11-21, commit: 2fbb62a

深入了解合约

在上一节中,我们给出了一个用Cairo语言编写的智能合约的介绍性示例。在本节中,我们将逐步深入了解智能合约的所有组成部分。

在讨论interfaces时,我们明确了_public函数、external函数和view函数_ 之间的区别,并提到了如何与 storage 交互。

此时,您应该会想到很多问题:

  • 如何定义内部/私有函数?
  • 如何发出事件?如何索引事件?
  • 我应该在哪里定义不需要访问合约状态的函数?
  • 有办法减少冗余的模式代码吗?
  • 如何存储更复杂的数据类型?

幸运的是,我们将在本章中回答所有这些问题。下面是我们在本章中将使用的合约示例:


use starknet::ContractAddress;

#[starknet::interface]
trait INameRegistry<TContractState> {
    fn store_name(
        ref self: TContractState, name: felt252, registration_type: NameRegistry::RegistrationType
    );
    fn get_name(self: @TContractState, address: ContractAddress) -> felt252;
    fn get_owner(self: @TContractState) -> NameRegistry::Person;
}


#[starknet::contract]
mod NameRegistry {
    use starknet::{ContractAddress, get_caller_address};

    #[storage]
    struct Storage {
        names: LegacyMap::<ContractAddress, felt252>,
        registration_type: LegacyMap::<ContractAddress, RegistrationType>,
        total_names: u128,
        owner: Person
    }

    #[event]
    #[derive(Drop, starknet::Event)]
    enum Event {
        StoredName: StoredName,
    }

    #[derive(Drop, starknet::Event)]
    struct StoredName {
        #[key]
        user: ContractAddress,
        name: felt252
    }

    #[derive(Copy, Drop, Serde, starknet::Store)]
    struct Person {
        name: felt252,
        address: ContractAddress
    }

    #[derive(Drop, Serde, starknet::Store)]
    enum RegistrationType {
        finite: u64,
        infinite
    }

    #[constructor]
    fn constructor(ref self: ContractState, owner: Person) {
        self.names.write(owner.address, owner.name);
        self.total_names.write(1);
        self.owner.write(owner);
    }

    #[external(v0)]
    impl NameRegistry of super::INameRegistry<ContractState> {
        fn store_name(ref self: ContractState, name: felt252, registration_type: RegistrationType) {
            let caller = get_caller_address();
            self._store_name(caller, name, registration_type);
        }

        fn get_name(self: @ContractState, address: ContractAddress) -> felt252 {
            let name = self.names.read(address);
            name
        }
        fn get_owner(self: @ContractState) -> Person {
            let owner = self.owner.read();
            owner
        }
    }

    #[generate_trait]
    impl InternalFunctions of InternalFunctionsTrait {
        fn _store_name(
            ref self: ContractState,
            user: ContractAddress,
            name: felt252,
            registration_type: RegistrationType
        ) {
            let mut total_names = self.total_names.read();
            self.names.write(user, name);
            self.registration_type.write(user, registration_type);
            self.total_names.write(total_names + 1);
            self.emit(StoredName { user: user, name: name });

        }
    }

    fn get_contract_name() -> felt252 {
        'Name Registry'
    }

    fn get_owner_storage_address(self: @ContractState) -> starknet::StorageBaseAddress {
        self.owner.address()
    }
}

示例 99-1bis:本章的参考合约

Last change: 2023-08-10, commit: a3bc10b

合约存储

与合约存储进行交互的最常见方式是通过存储变量。正如前面所述,存储变量允许您存储将存储在合约的存储中的数据,而该存储本身存储在区块链上。这些数据是持久的,一旦合约部署后,可以随时访问和修改。

Starknet合约中的存储变量被存储在一个特殊的结构中,称为Storage


use starknet::ContractAddress;

#[starknet::interface]
trait INameRegistry<TContractState> {
    fn store_name(
        ref self: TContractState, name: felt252, registration_type: NameRegistry::RegistrationType
    );
    fn get_name(self: @TContractState, address: ContractAddress) -> felt252;
    fn get_owner(self: @TContractState) -> NameRegistry::Person;
}


#[starknet::contract]
mod NameRegistry {
    use starknet::{ContractAddress, get_caller_address};

    #[storage]
    struct Storage {
        names: LegacyMap::<ContractAddress, felt252>,
        registration_type: LegacyMap::<ContractAddress, RegistrationType>,
        total_names: u128,
        owner: Person
    }

    #[event]
    #[derive(Drop, starknet::Event)]
    enum Event {
        StoredName: StoredName,
    }

    #[derive(Drop, starknet::Event)]
    struct StoredName {
        #[key]
        user: ContractAddress,
        name: felt252
    }

    #[derive(Copy, Drop, Serde, starknet::Store)]
    struct Person {
        name: felt252,
        address: ContractAddress
    }

    #[derive(Drop, Serde, starknet::Store)]
    enum RegistrationType {
        finite: u64,
        infinite
    }

    #[constructor]
    fn constructor(ref self: ContractState, owner: Person) {
        self.names.write(owner.address, owner.name);
        self.total_names.write(1);
        self.owner.write(owner);
    }

    #[external(v0)]
    impl NameRegistry of super::INameRegistry<ContractState> {
        fn store_name(ref self: ContractState, name: felt252, registration_type: RegistrationType) {
            let caller = get_caller_address();
            self._store_name(caller, name, registration_type);
        }

        fn get_name(self: @ContractState, address: ContractAddress) -> felt252 {
            let name = self.names.read(address);
            name
        }
        fn get_owner(self: @ContractState) -> Person {
            let owner = self.owner.read();
            owner
        }
    }

    #[generate_trait]
    impl InternalFunctions of InternalFunctionsTrait {
        fn _store_name(
            ref self: ContractState,
            user: ContractAddress,
            name: felt252,
            registration_type: RegistrationType
        ) {
            let mut total_names = self.total_names.read();
            self.names.write(user, name);
            self.registration_type.write(user, registration_type);
            self.total_names.write(total_names + 1);
            self.emit(StoredName { user: user, name: name });

        }
    }

    fn get_contract_name() -> felt252 {
        'Name Registry'
    }

    fn get_owner_storage_address(self: @ContractState) -> starknet::StorageBaseAddress {
        self.owner.address()
    }
}

存储用结构体

Storage结构体 与其他结构体一样, 只是它 必须 带有 #[storage] 注解。这个注解告诉编译器生成与区块链状态交互所需的代码,并允许您从存储中读取和写入数据。此外,这还允许您使用 LegacyMap 类型定义存储映射。

存储结构中存储的每个变量都存储在合约存储的不同位置。变量的存储地址由变量名决定,如果是映射,则还由变量的最终键决定。

存储地址

存储变量的地址计算方式如下:

  • 如果变量是单个值(不是映射),则地址是变量名 ASCII 编码的 sn_keccak 哈希值。sn_keccak_是 Starknet 的 Keccak256 哈希函数版本,其输出被截断为 250 位。
  • 如果变量是映射,,则键为 k_1,...,k_n 的值的地址是 h(...h(h(sn_keccak(variable_name),k_1),k_2),...,k_n),其中 ℎ 是 Pedersen 哈希,最终值取 mod (2^251) − 256
  • 如果它是映射到复杂值(例如,元组或结构),则此复杂值位于从上一点计算的地址开始的连续段中。请注意,256 个域元素是当前复杂存储值最大大小的限制。

通过在变量上调用 address 函数,你可以访问存储变量的地址,该函数返回一个 StorageBaseAddress 值。


use starknet::ContractAddress;

#[starknet::interface]
trait INameRegistry<TContractState> {
    fn store_name(
        ref self: TContractState, name: felt252, registration_type: NameRegistry::RegistrationType
    );
    fn get_name(self: @TContractState, address: ContractAddress) -> felt252;
    fn get_owner(self: @TContractState) -> NameRegistry::Person;
}


#[starknet::contract]
mod NameRegistry {
    use starknet::{ContractAddress, get_caller_address};

    #[storage]
    struct Storage {
        names: LegacyMap::<ContractAddress, felt252>,
        registration_type: LegacyMap::<ContractAddress, RegistrationType>,
        total_names: u128,
        owner: Person
    }

    #[event]
    #[derive(Drop, starknet::Event)]
    enum Event {
        StoredName: StoredName,
    }

    #[derive(Drop, starknet::Event)]
    struct StoredName {
        #[key]
        user: ContractAddress,
        name: felt252
    }

    #[derive(Copy, Drop, Serde, starknet::Store)]
    struct Person {
        name: felt252,
        address: ContractAddress
    }

    #[derive(Drop, Serde, starknet::Store)]
    enum RegistrationType {
        finite: u64,
        infinite
    }

    #[constructor]
    fn constructor(ref self: ContractState, owner: Person) {
        self.names.write(owner.address, owner.name);
        self.total_names.write(1);
        self.owner.write(owner);
    }

    #[external(v0)]
    impl NameRegistry of super::INameRegistry<ContractState> {
        fn store_name(ref self: ContractState, name: felt252, registration_type: RegistrationType) {
            let caller = get_caller_address();
            self._store_name(caller, name, registration_type);
        }

        fn get_name(self: @ContractState, address: ContractAddress) -> felt252 {
            let name = self.names.read(address);
            name
        }
        fn get_owner(self: @ContractState) -> Person {
            let owner = self.owner.read();
            owner
        }
    }

    #[generate_trait]
    impl InternalFunctions of InternalFunctionsTrait {
        fn _store_name(
            ref self: ContractState,
            user: ContractAddress,
            name: felt252,
            registration_type: RegistrationType
        ) {
            let mut total_names = self.total_names.read();
            self.names.write(user, name);
            self.registration_type.write(user, registration_type);
            self.total_names.write(total_names + 1);
            self.emit(StoredName { user: user, name: name });

        }
    }

    fn get_contract_name() -> felt252 {
        'Name Registry'
    }

    fn get_owner_storage_address(self: @ContractState) -> starknet::StorageBaseAddress {
        self.owner.address()
    }
}

与存储变量交互

存储在存储结构中的变量可以使用 readwrite 函数进行访问和修改,还可以使用 addr 函数获取它们在存储中的地址。这些函数由编译器为每个存储变量自动生成。

要读取 owner 存储变量的值,该变量是一个单一值,我们调用 owner 变量上的 read 函数,不传入任何参数。


use starknet::ContractAddress;

#[starknet::interface]
trait INameRegistry<TContractState> {
    fn store_name(
        ref self: TContractState, name: felt252, registration_type: NameRegistry::RegistrationType
    );
    fn get_name(self: @TContractState, address: ContractAddress) -> felt252;
    fn get_owner(self: @TContractState) -> NameRegistry::Person;
}


#[starknet::contract]
mod NameRegistry {
    use starknet::{ContractAddress, get_caller_address};

    #[storage]
    struct Storage {
        names: LegacyMap::<ContractAddress, felt252>,
        registration_type: LegacyMap::<ContractAddress, RegistrationType>,
        total_names: u128,
        owner: Person
    }

    #[event]
    #[derive(Drop, starknet::Event)]
    enum Event {
        StoredName: StoredName,
    }

    #[derive(Drop, starknet::Event)]
    struct StoredName {
        #[key]
        user: ContractAddress,
        name: felt252
    }

    #[derive(Copy, Drop, Serde, starknet::Store)]
    struct Person {
        name: felt252,
        address: ContractAddress
    }

    #[derive(Drop, Serde, starknet::Store)]
    enum RegistrationType {
        finite: u64,
        infinite
    }

    #[constructor]
    fn constructor(ref self: ContractState, owner: Person) {
        self.names.write(owner.address, owner.name);
        self.total_names.write(1);
        self.owner.write(owner);
    }

    #[external(v0)]
    impl NameRegistry of super::INameRegistry<ContractState> {
        fn store_name(ref self: ContractState, name: felt252, registration_type: RegistrationType) {
            let caller = get_caller_address();
            self._store_name(caller, name, registration_type);
        }

        fn get_name(self: @ContractState, address: ContractAddress) -> felt252 {
            let name = self.names.read(address);
            name
        }
        fn get_owner(self: @ContractState) -> Person {
            let owner = self.owner.read();
            owner
        }
    }

    #[generate_trait]
    impl InternalFunctions of InternalFunctionsTrait {
        fn _store_name(
            ref self: ContractState,
            user: ContractAddress,
            name: felt252,
            registration_type: RegistrationType
        ) {
            let mut total_names = self.total_names.read();
            self.names.write(user, name);
            self.registration_type.write(user, registration_type);
            self.total_names.write(total_names + 1);
            self.emit(StoredName { user: user, name: name });

        }
    }

    fn get_contract_name() -> felt252 {
        'Name Registry'
    }

    fn get_owner_storage_address(self: @ContractState) -> starknet::StorageBaseAddress {
        self.owner.address()
    }
}

调用 owner 变量上的 read 函数

要读取存储变量 names 的值,这个变量是从 ContractAddress 映射到 felt252 的,我们在 names 变量上调用 read 函数,将键 address 作为参数传入。如果这个映射有多个键,我们也会将其他键作为参数传入。


use starknet::ContractAddress;

#[starknet::interface]
trait INameRegistry<TContractState> {
    fn store_name(
        ref self: TContractState, name: felt252, registration_type: NameRegistry::RegistrationType
    );
    fn get_name(self: @TContractState, address: ContractAddress) -> felt252;
    fn get_owner(self: @TContractState) -> NameRegistry::Person;
}


#[starknet::contract]
mod NameRegistry {
    use starknet::{ContractAddress, get_caller_address};

    #[storage]
    struct Storage {
        names: LegacyMap::<ContractAddress, felt252>,
        registration_type: LegacyMap::<ContractAddress, RegistrationType>,
        total_names: u128,
        owner: Person
    }

    #[event]
    #[derive(Drop, starknet::Event)]
    enum Event {
        StoredName: StoredName,
    }

    #[derive(Drop, starknet::Event)]
    struct StoredName {
        #[key]
        user: ContractAddress,
        name: felt252
    }

    #[derive(Copy, Drop, Serde, starknet::Store)]
    struct Person {
        name: felt252,
        address: ContractAddress
    }

    #[derive(Drop, Serde, starknet::Store)]
    enum RegistrationType {
        finite: u64,
        infinite
    }

    #[constructor]
    fn constructor(ref self: ContractState, owner: Person) {
        self.names.write(owner.address, owner.name);
        self.total_names.write(1);
        self.owner.write(owner);
    }

    #[external(v0)]
    impl NameRegistry of super::INameRegistry<ContractState> {
        fn store_name(ref self: ContractState, name: felt252, registration_type: RegistrationType) {
            let caller = get_caller_address();
            self._store_name(caller, name, registration_type);
        }

        fn get_name(self: @ContractState, address: ContractAddress) -> felt252 {
            let name = self.names.read(address);
            name
        }
        fn get_owner(self: @ContractState) -> Person {
            let owner = self.owner.read();
            owner
        }
    }

    #[generate_trait]
    impl InternalFunctions of InternalFunctionsTrait {
        fn _store_name(
            ref self: ContractState,
            user: ContractAddress,
            name: felt252,
            registration_type: RegistrationType
        ) {
            let mut total_names = self.total_names.read();
            self.names.write(user, name);
            self.registration_type.write(user, registration_type);
            self.total_names.write(total_names + 1);
            self.emit(StoredName { user: user, name: name });

        }
    }

    fn get_contract_name() -> felt252 {
        'Name Registry'
    }

    fn get_owner_storage_address(self: @ContractState) -> starknet::StorageBaseAddress {
        self.owner.address()
    }
}

调用names 变量上的read 函数

要将值写入存储变量,我们需要调用 write 函数,并将其最终键和值作为参数传递。与 read 函数一样,参数的数量取决于键的数量——这里,我们只需要将要写入 owner变量的值作为参数,因为它是一个简单的变量。


use starknet::ContractAddress;

#[starknet::interface]
trait INameRegistry<TContractState> {
    fn store_name(
        ref self: TContractState, name: felt252, registration_type: NameRegistry::RegistrationType
    );
    fn get_name(self: @TContractState, address: ContractAddress) -> felt252;
    fn get_owner(self: @TContractState) -> NameRegistry::Person;
}


#[starknet::contract]
mod NameRegistry {
    use starknet::{ContractAddress, get_caller_address};

    #[storage]
    struct Storage {
        names: LegacyMap::<ContractAddress, felt252>,
        registration_type: LegacyMap::<ContractAddress, RegistrationType>,
        total_names: u128,
        owner: Person
    }

    #[event]
    #[derive(Drop, starknet::Event)]
    enum Event {
        StoredName: StoredName,
    }

    #[derive(Drop, starknet::Event)]
    struct StoredName {
        #[key]
        user: ContractAddress,
        name: felt252
    }

    #[derive(Copy, Drop, Serde, starknet::Store)]
    struct Person {
        name: felt252,
        address: ContractAddress
    }

    #[derive(Drop, Serde, starknet::Store)]
    enum RegistrationType {
        finite: u64,
        infinite
    }

    #[constructor]
    fn constructor(ref self: ContractState, owner: Person) {
        self.names.write(owner.address, owner.name);
        self.total_names.write(1);
        self.owner.write(owner);
    }

    #[external(v0)]
    impl NameRegistry of super::INameRegistry<ContractState> {
        fn store_name(ref self: ContractState, name: felt252, registration_type: RegistrationType) {
            let caller = get_caller_address();
            self._store_name(caller, name, registration_type);
        }

        fn get_name(self: @ContractState, address: ContractAddress) -> felt252 {
            let name = self.names.read(address);
            name
        }
        fn get_owner(self: @ContractState) -> Person {
            let owner = self.owner.read();
            owner
        }
    }

    #[generate_trait]
    impl InternalFunctions of InternalFunctionsTrait {
        fn _store_name(
            ref self: ContractState,
            user: ContractAddress,
            name: felt252,
            registration_type: RegistrationType
        ) {
            let mut total_names = self.total_names.read();
            self.names.write(user, name);
            self.registration_type.write(user, registration_type);
            self.total_names.write(total_names + 1);
            self.emit(StoredName { user: user, name: name });

        }
    }

    fn get_contract_name() -> felt252 {
        'Name Registry'
    }

    fn get_owner_storage_address(self: @ContractState) -> starknet::StorageBaseAddress {
        self.owner.address()
    }
}

调用owner 变量上的write 函数


use starknet::ContractAddress;

#[starknet::interface]
trait INameRegistry<TContractState> {
    fn store_name(
        ref self: TContractState, name: felt252, registration_type: NameRegistry::RegistrationType
    );
    fn get_name(self: @TContractState, address: ContractAddress) -> felt252;
    fn get_owner(self: @TContractState) -> NameRegistry::Person;
}


#[starknet::contract]
mod NameRegistry {
    use starknet::{ContractAddress, get_caller_address};

    #[storage]
    struct Storage {
        names: LegacyMap::<ContractAddress, felt252>,
        registration_type: LegacyMap::<ContractAddress, RegistrationType>,
        total_names: u128,
        owner: Person
    }

    #[event]
    #[derive(Drop, starknet::Event)]
    enum Event {
        StoredName: StoredName,
    }

    #[derive(Drop, starknet::Event)]
    struct StoredName {
        #[key]
        user: ContractAddress,
        name: felt252
    }

    #[derive(Copy, Drop, Serde, starknet::Store)]
    struct Person {
        name: felt252,
        address: ContractAddress
    }

    #[derive(Drop, Serde, starknet::Store)]
    enum RegistrationType {
        finite: u64,
        infinite
    }

    #[constructor]
    fn constructor(ref self: ContractState, owner: Person) {
        self.names.write(owner.address, owner.name);
        self.total_names.write(1);
        self.owner.write(owner);
    }

    #[external(v0)]
    impl NameRegistry of super::INameRegistry<ContractState> {
        fn store_name(ref self: ContractState, name: felt252, registration_type: RegistrationType) {
            let caller = get_caller_address();
            self._store_name(caller, name, registration_type);
        }

        fn get_name(self: @ContractState, address: ContractAddress) -> felt252 {
            let name = self.names.read(address);
            name
        }
        fn get_owner(self: @ContractState) -> Person {
            let owner = self.owner.read();
            owner
        }
    }

    #[generate_trait]
    impl InternalFunctions of InternalFunctionsTrait {
        fn _store_name(
            ref self: ContractState,
            user: ContractAddress,
            name: felt252,
            registration_type: RegistrationType
        ) {
            let mut total_names = self.total_names.read();
            self.names.write(user, name);
            self.registration_type.write(user, registration_type);
            self.total_names.write(total_names + 1);
            self.emit(StoredName { user: user, name: name });

        }
    }

    fn get_contract_name() -> felt252 {
        'Name Registry'
    }

    fn get_owner_storage_address(self: @ContractState) -> starknet::StorageBaseAddress {
        self.owner.address()
    }
}

调用 names 变量上的 write 函数

存储自定义结构体

Store trait,定义在 starknet::storage_access 模块中,用于指定类型如何在存储中存储。为了将类型存储在存储中,它必须实现 Store trait。大多数来自核心库的类型,例如无符号整数 (u8, u128, u256...),、felt252boolContractAddress 等都实现了 Store trait,因此无需进一步操作即可存储。

但是,如果您想存储您自己定义的类型,例如枚举或结构,该怎么办?在这种情况下,您必须明确地告诉编译器如何存储这种类型。

在我们的例子中,我们想将 Person 结构存储在存储中,这可以通过为 Person 类型实现 Store 特性来实现。这可以通过在结构体定义顶部简单添加 #[derive(starknet::Store)] 属性来实现。


use starknet::ContractAddress;

#[starknet::interface]
trait INameRegistry<TContractState> {
    fn store_name(
        ref self: TContractState, name: felt252, registration_type: NameRegistry::RegistrationType
    );
    fn get_name(self: @TContractState, address: ContractAddress) -> felt252;
    fn get_owner(self: @TContractState) -> NameRegistry::Person;
}


#[starknet::contract]
mod NameRegistry {
    use starknet::{ContractAddress, get_caller_address};

    #[storage]
    struct Storage {
        names: LegacyMap::<ContractAddress, felt252>,
        registration_type: LegacyMap::<ContractAddress, RegistrationType>,
        total_names: u128,
        owner: Person
    }

    #[event]
    #[derive(Drop, starknet::Event)]
    enum Event {
        StoredName: StoredName,
    }

    #[derive(Drop, starknet::Event)]
    struct StoredName {
        #[key]
        user: ContractAddress,
        name: felt252
    }

    #[derive(Copy, Drop, Serde, starknet::Store)]
    struct Person {
        name: felt252,
        address: ContractAddress
    }

    #[derive(Drop, Serde, starknet::Store)]
    enum RegistrationType {
        finite: u64,
        infinite
    }

    #[constructor]
    fn constructor(ref self: ContractState, owner: Person) {
        self.names.write(owner.address, owner.name);
        self.total_names.write(1);
        self.owner.write(owner);
    }

    #[external(v0)]
    impl NameRegistry of super::INameRegistry<ContractState> {
        fn store_name(ref self: ContractState, name: felt252, registration_type: RegistrationType) {
            let caller = get_caller_address();
            self._store_name(caller, name, registration_type);
        }

        fn get_name(self: @ContractState, address: ContractAddress) -> felt252 {
            let name = self.names.read(address);
            name
        }
        fn get_owner(self: @ContractState) -> Person {
            let owner = self.owner.read();
            owner
        }
    }

    #[generate_trait]
    impl InternalFunctions of InternalFunctionsTrait {
        fn _store_name(
            ref self: ContractState,
            user: ContractAddress,
            name: felt252,
            registration_type: RegistrationType
        ) {
            let mut total_names = self.total_names.read();
            self.names.write(user, name);
            self.registration_type.write(user, registration_type);
            self.total_names.write(total_names + 1);
            self.emit(StoredName { user: user, name: name });

        }
    }

    fn get_contract_name() -> felt252 {
        'Name Registry'
    }

    fn get_owner_storage_address(self: @ContractState) -> starknet::StorageBaseAddress {
        self.owner.address()
    }
}

类似地,枚举也可以在实现 Store trait的情况下写入存储,只要所有关联类型也实现 Store trait,就可以简单地推导它。


use starknet::ContractAddress;

#[starknet::interface]
trait INameRegistry<TContractState> {
    fn store_name(
        ref self: TContractState, name: felt252, registration_type: NameRegistry::RegistrationType
    );
    fn get_name(self: @TContractState, address: ContractAddress) -> felt252;
    fn get_owner(self: @TContractState) -> NameRegistry::Person;
}


#[starknet::contract]
mod NameRegistry {
    use starknet::{ContractAddress, get_caller_address};

    #[storage]
    struct Storage {
        names: LegacyMap::<ContractAddress, felt252>,
        registration_type: LegacyMap::<ContractAddress, RegistrationType>,
        total_names: u128,
        owner: Person
    }

    #[event]
    #[derive(Drop, starknet::Event)]
    enum Event {
        StoredName: StoredName,
    }

    #[derive(Drop, starknet::Event)]
    struct StoredName {
        #[key]
        user: ContractAddress,
        name: felt252
    }

    #[derive(Copy, Drop, Serde, starknet::Store)]
    struct Person {
        name: felt252,
        address: ContractAddress
    }

    #[derive(Drop, Serde, starknet::Store)]
    enum RegistrationType {
        finite: u64,
        infinite
    }

    #[constructor]
    fn constructor(ref self: ContractState, owner: Person) {
        self.names.write(owner.address, owner.name);
        self.total_names.write(1);
        self.owner.write(owner);
    }

    #[external(v0)]
    impl NameRegistry of super::INameRegistry<ContractState> {
        fn store_name(ref self: ContractState, name: felt252, registration_type: RegistrationType) {
            let caller = get_caller_address();
            self._store_name(caller, name, registration_type);
        }

        fn get_name(self: @ContractState, address: ContractAddress) -> felt252 {
            let name = self.names.read(address);
            name
        }
        fn get_owner(self: @ContractState) -> Person {
            let owner = self.owner.read();
            owner
        }
    }

    #[generate_trait]
    impl InternalFunctions of InternalFunctionsTrait {
        fn _store_name(
            ref self: ContractState,
            user: ContractAddress,
            name: felt252,
            registration_type: RegistrationType
        ) {
            let mut total_names = self.total_names.read();
            self.names.write(user, name);
            self.registration_type.write(user, registration_type);
            self.total_names.write(total_names + 1);
            self.emit(StoredName { user: user, name: name });

        }
    }

    fn get_contract_name() -> felt252 {
        'Name Registry'
    }

    fn get_owner_storage_address(self: @ContractState) -> starknet::StorageBaseAddress {
        self.owner.address()
    }
}

结构体的存储布局

在 Starknet 上,结构会被存储为基本类型序列。结构的元素按照结构定义中的顺序进行存储。结构的第一个元素存储在结构的基地址,该地址根据 存储地址 中的说明计算,可以通过调用 var.address() 获取,后面的元素则存储在与第一个元素相邻的地址。

例如,类型为 Personowner 变量的存储布局将如下所示:

FieldsAddress
nameowner.address()
addressowner.address() +1

枚举的存储布局

当您存储一个枚举变体时,您实际上存储的是变体的索引和最终的关联值。这个索引从您的枚举的第一个变体开始为 0,并为每个后续变体增加 1。 如果您的变体具有关联值,它会从基地址之后的第一个地址开始存储。 例如,假设我们有一个带有 finite 变体的 RegistrationType 枚举,该变体带有关联的截止日期。存储布局将如下所示:

ElementAddress
Variant index (e.g. 1 for finite)registration_type.address()
Associated limit dateregistration_type.address() + 1

存储映射

存储映射类似于哈希表,它们允许将键映射到值。但是,与典型的哈希表不同,不存储键数据本身 - 仅使用其哈希码在合约的存储中查找关联的值。 映射没有长度概念,也没有键/值对是否设置的概念。删除映射的唯一方法是将其值设置为默认的零值。

映射只用于根据某些键计算合约存储中数据的位置。因此,它们只能作为存储变量使用。它们不能用作合约函数的参数或返回值参数,也不能用作结构体内的类型。

mappings
Mapping keys to storage values

要声明一个映射,请使用尖括号 <> 包围的 LegacyMap类型,指定键和值类型。

你还可以创建具有多个键的更复杂的映射。在示例 99-2bis 中可以找到一个示例,就像ERC20标准中的流行的 allowances 存储变量一样,它使用多个键将 owner 和允许的 spender 映射到其 allowance 金额,这些键被传递到一个元组中:

    #[storage]
    struct Storage {
        allowances: LegacyMap::<(ContractAddress, ContractAddress), u256>
    }

示例99-2bis:存储映射

存储在映射中的变量的存储地址根据 存储地址 部分的描述进行计算。

如果映射的键是一个结构体,则结构体的每个元素都构成一个键。此外,结构体应该实现 Hash trait,可以使用 #[derive(Hash)] 属性派生。例如,如果您的结构体有两个字段,则地址将是 h(h(sn_keccak(variable_name),k_1),k_2) - 其中 k_1k_2 是结构体这两个字段的值。

类似地,对于嵌套映射,例如 LegacyMap((ContractAddress, ContractAddress), u8),地址将以相同的方式计算:h(h(sn_keccak(variable_name),k_1),k_2)

如果你想了解更多关于合约存储布局的细节,可以访问 Starknet 文档

Last change: 2023-12-03, commit: 462ec1a

合约函数

在本节中,我们将了解合约中可能遇到的不同类型的函数:

1.构造器(Constructors)

构造函数是一种特殊类型的函数,只在部署合约时运行一次,可用于初始化合约的状态。

    #[constructor]
    fn constructor(ref self: ContractState, owner: Person) {
        self.names.write(owner.address, owner.name);
        self.total_names.write(1);
        self.owner.write(owner);
    }

一些需要注意的重要规则:

1.您的合约不能有一个以上的构造函数。 2.您的构造函数必须命名为 constructor。 3.它必须使用 #[constructor] 属性标注。

2.公共函数

如前所述,公有函数可以从合约外部访问。它们必须定义在带有#[external(v0)]属性注解的实现块中。这个属性只影响可见性(public 或 private/internal),但并不影响这些函数修改合约状态的能力。

    #[external(v0)]
    impl NameRegistry of super::INameRegistry<ContractState> {
        fn store_name(ref self: ContractState, name: felt252, registration_type: RegistrationType) {
            let caller = get_caller_address();
            self._store_name(caller, name, registration_type);
        }

        fn get_name(self: @ContractState, address: ContractAddress) -> felt252 {
            let name = self.names.read(address);
            name
        }
        fn get_owner(self: @ContractState) -> Person {
            let owner = self.owner.read();
            owner
        }
    }

外部函数

外部函数是可以修改合约状态的函数。它们是公共的,可以被任何其他合约或外部调用。 外部函数是 public 函数,其中的 self:ContractState 以引用的形式通过关键字 ref 传递,使得你可以修改合约的状态。

        fn store_name(ref self: ContractState, name: felt252, registration_type: RegistrationType) {
            let caller = get_caller_address();
            self._store_name(caller, name, registration_type);
        }

视图函数

视图函数是只读函数,允许你访问合约中的数据,同时确保合约的状态不会被修改。它们可以被其他合约或外部调用。 视图函数是 public 函数,其中 self.ContractState 被作为快照传入,这会防止你修改合约的状态。

        fn get_name(self: @ContractState, address: ContractAddress) -> felt252 {
            let name = self.names.read(address);
            name
        }

注意: 外部函数和视图函数都是公共函数,这一点很重要。要在合约中创建内部函数,您需要在使用 #[external(v0)]属性标注的实现块之外定义它。

3.私有函数

未在注有#[external(v0)]属性的代码块中定义的函数是私有函数(也称为内部函数)。它们只能在合约内部调用。

    #[generate_trait]
    impl InternalFunctions of InternalFunctionsTrait {
        fn _store_name(
            ref self: ContractState,
            user: ContractAddress,
            name: felt252,
            registration_type: RegistrationType
        ) {
            let mut total_names = self.total_names.read();
            self.names.write(user, name);
            self.registration_type.write(user, registration_type);
            self.total_names.write(total_names + 1);
            self.emit(StoredName { user: user, name: name });

        }
    }

等等,这个#[generate_trait]属性是什么?这个实现的trait定义在哪里?嗯,#[generate_trait]属性是一个特殊的属性,它告诉编译器为实现块生成一个trait定义。这允许你摆脱为实现块定义trait和实现trait的模板代码。我们将在下一节中看到更多关于这方面的内容。

说到这里,您可能还在想,如果您不需要在您的函数(例如,辅助函数/库函数)中访问合约的状态,所有这些是否真的有必要。事实上,您也可以在实现块之外定义内部函数。我们 需要 在实现块内部定义函数的唯一原因是我们想要访问合约的状态。

    fn get_contract_name() -> felt252 {
        'Name Registry'
    }

    fn get_owner_storage_address(self: @ContractState) -> starknet::StorageBaseAddress {
        self.owner.address()
    }
Last change: 2023-08-10, commit: a3bc10b

事件

事件是定制的数据结构,由智能合约在执行期间发出。 它们为智能合约提供了一种与外部世界沟通的方式,即记录合约中所发生的特定事情的信息。

事件在智能合约的创建中起着至关重要的作用。以Starknet上铸造的Non-Fungible Tokens(NFTs)为例。所有这些都被索引并存储在数据库中,然后通过使用这些事件显示给用户。忘记在你的NFT合约中编写一个事件可能会导致糟糕的用户体验。这是因为用户可能看不到他们的NFT出现在他们的钱包里(钱包使用这些索引器来显示用户的NFT)。

界定事件

合约中所有不同的事件都定义在 Event枚举中,它实现了starknet::Event trait,作为枚举变量。该trait在核心库中定义如下:

trait Event<T> {
    fn append_keys_and_data(self: T, ref keys: Array<felt252>, ref data: Array<felt252>);
    fn deserialize(ref keys: Span<felt252>, ref data: Span<felt252>) -> Option<T>;
}

属性 #[derive(starknet::Event)]会使得编译器为上述trait生成一个实现、 在我们的例子中,它是下面的枚举:

    #[event]
    #[derive(Drop, starknet::Event)]
    enum Event {
        StoredName: StoredName,
    }

    #[derive(Drop, starknet::Event)]
    struct StoredName {
        #[key]
        user: ContractAddress,
        name: felt252
    }

每个事件变体成员必须是一个与变体同名的结构体,并且每个变体本身需要实现starknet::Eventtrait。 此外,这些变体的成员必须实现Serdetrait(c.f. Appendix C: Serializing with Serde),因为键/数据是通过序列化过程添加到事件中的。

starknet::Eventtrait的自动实现将为我们的 Event枚举的每个变体成员实现 append_keys_and_data函数。生成的实现将根据变量名(StoredName)附加一个单独的键,然后递归调用Eventtrait的实现中的append_keys_and_data函数。

在我们的合约中,我们定义了一个名为 StoredName 的事件,该事件将调用者的合约地址和存储在合约中的名称发送出去,其中 user 字段序列化为键,而 name 字段序列化为数据。 要索引一个事件的键,只需用#[key]注释它,如user键的示例所示。

当使用 self.emit(StoredName { user: user, name: name }) 发布事件时,与名称 StoredName 相对应的键,特别是 sn_keccak(StoredName) 会被添加到键列表中。由于使用了 #[key]属性,user 被序列化为 key,而地址被序列化为数据。一切处理完毕后,我们将得到以下键和数据:keys = [sn_keccak("StoredName"),user]data = [address]

发射事件

定义事件后,我们可以使用self.emit来emit它们,语法如下:

            self.emit(StoredName { user: user, name: name });
Last change: 2023-09-06, commit: 5bf14bf

减少冗余模板代码

在上一节中,我们看到了这样一个示例:在一个没有任何相应trait的合约中,有一个实现块。

    #[generate_trait]
    impl InternalFunctions of InternalFunctionsTrait {
        fn _store_name(
            ref self: ContractState,
            user: ContractAddress,
            name: felt252,
            registration_type: RegistrationType
        ) {
            let mut total_names = self.total_names.read();
            self.names.write(user, name);
            self.registration_type.write(user, registration_type);
            self.total_names.write(total_names + 1);
            self.emit(StoredName { user: user, name: name });

        }
    }

这并不是我们第一次遇到这个属性,我们已经在在Cairo中使用 Traits(./ch08-02-traits-in-cairo.md) 中讨论过它。在本节中,我们将更深入地研究它,并看看它在合约中的使用方式。

回想一下,为了在函数中访问 ContractState,该函数必须定义在泛型参数为 ContractState 的实现块中。这意味着我们首先需要定义一个接受TContractState的泛型trait,然后为ContractState类型实现这个trait。 但是通过使用 #[generate_trait] 属性,我们可以跳过整个过程,从而简单地直接定义实现块,不需要任何泛型参数,并在我们的函数中使用 self.ContractState 属性。

如果我们必须手动定义 InternalFunctions 所实现的trait,它将看起来像这样:

    trait InternalFunctionsTrait<TContractState> {
        fn _store_name(ref self: TContractState, user: ContractAddress, name: felt252);
    }
    impl InternalFunctions of InternalFunctionsTrait<ContractState> {
        fn _store_name(ref self: ContractState, user: ContractAddress, name: felt252) {
            let mut total_names = self.total_names.read();
            self.names.write(user, name);
            self.total_names.write(total_names + 1);
            self.emit(Event::StoredName(StoredName { user: user, name: name }));

        }
    }
}

Last change: 2023-09-20, commit: cbb0049

使用StorePacking优化存储

位压缩是一个简单的概念:使用尽可能少的位数来存储数据。如果做得好,它可以显著减少需要存储的数据大小。这在智能合约中尤为重要,因为存储是昂贵的。

在编写Cairo智能合约时,优化存储使用以减少 gas 成本是非常重要的。事实上,与交易相关的大部分成本都与存储更新相关;而每个存储槽位写入都需要 gas 成本。 这意味着通过将多个值打包到较少的槽位中,你可以减少智能合约的用户所需的 gas 成本。

Cairo提供了 StorePacking trait,以便将结构体字段打包到较少的存储槽位中。例如,考虑一个具有3个不同类型字段的 Sizes 结构体。总大小为8 + 32 + 64 = 104位。这比单个 u128 的128位要小。这意味着我们可以将这3个字段都打包到一个单独的 u128 变量中。由于一个存储槽位最多可以容纳251位,我们打包后的值只需要一个存储槽位,而不是3个。

#![allow(unused)]
fn main() {
use starknet::{StorePacking};
use integer::{u128_safe_divmod, u128_as_non_zero};

#[derive(Drop, Serde)]
struct Sizes {
    tiny: u8,
    small: u32,
    medium: u64,
}

const TWO_POW_8: u128 = 0x100;
const TWO_POW_40: u128 = 0x10000000000;

const MASK_8: u128 = 0xff;
const MASK_32: u128 = 0xffffffff;


impl SizesStorePacking of StorePacking<Sizes, u128> {
    fn pack(value: Sizes) -> u128 {
        value.tiny.into() + (value.small.into() * TWO_POW_8) + (value.medium.into() * TWO_POW_40)
    }

    fn unpack(value: u128) -> Sizes {
        let tiny = value & MASK_8;
        let small = (value / TWO_POW_8) & MASK_32;
        let medium = (value / TWO_POW_40);

        Sizes {
            tiny: tiny.try_into().unwrap(),
            small: small.try_into().unwrap(),
            medium: medium.try_into().unwrap(),
        }
    }
}

#[starknet::contract]
mod SizeFactory {
    use super::Sizes;
    use super::SizesStorePacking; //don't forget to import it!

    #[storage]
    struct Storage {
        remaining_sizes: Sizes
    }

    #[external(v0)]
    fn update_sizes(ref self: ContractState, sizes: Sizes) {
        // This will automatically pack the
        // struct into a single u128
        self.remaining_sizes.write(sizes);
    }


    #[external(v0)]
    fn get_sizes(ref self: ContractState) -> Sizes {
        // this will automatically unpack the
        // packed-representation into the Sizes struct
        self.remaining_sizes.read()
    }
}


}

通过实现 StorePacking trait来优化存储

pack 函数通过位移和加法将所有三个字段合并为一个 u128值。unpack则将这一过程逆转,将原始字段提取回结构体中。

如果您对位运算不熟悉,这里将解释示例中执行的运算: 我们的目标是将 tiny, small, 还有 medium字段打包成一个 u128 值。 首先,打包时:

  • tiny 是一个 u8,因此我们只需用 .into() 将其直接转换为 u128。这会创建一个低 8 位设置为 tiny 值的 u128 值。
  • small 是一个 u32,因此我们首先将它左移 8 位(向左添加 8 位值为 0 的比特),为 tiny 占用的 8 位留出空间。然后,我们将 tiny 添加到 small 中,将它们合并成一个 u128 值。现在,tiny 的值占 0-7 位,small 的值占 8-39 位。
  • 同样,medium 是一个 u64,因此我们将其左移 40 (8 + 32) 位(TWO_POW_40),为前面的字段腾出空间。这需要占用 40-103 位。

当解包时:

  • 首先,我们通过与 8 个 1 的位掩码(& MASK_8)进行比特 AND(&)来提取tiny。这样就分离出打包值的最低 8 位,也就是 tiny 的值。
  • 对于 small,我们右移 8 位 (/TWO_POW_8),使其与位掩码对齐,然后与 32 个 1 的位掩码进行位和运算。
  • 对于 medium,我们右移 40 位。由于它是最后打包的值,我们不需要应用位掩码,因为高位已经为 0。

这种技术可用于任何一组适合打包存储类型位大小的字段。例如,如果一个结构体有多个字段,其位大小加起来为 256 位,那么可以将它们打包成一个 u256 变量。如果字段的位数加起来是 512 位,则可以将它们打包到一个 u512 变量中,依此类推。你可以定义自己的结构和逻辑来打包和解包它们。

其余的工作由编译器自动完成 - 如果一个类型实现了 StorePacking trait,那么编译器将知道它可以使用 Store trait 的 StoreUsingPacking 实现,在写入之前进行打包,在从存储中读取后进行解包。 然而,一个重要的细节是,StorePacking::pack 输出的类型也必须实现 Store,以使 StoreUsingPacking 正常工作。大多数情况下,我们希望打包到 felt252 或 u256 类型中 - 但是如果你想打包到自定义类型中,请确保该类型实现了 Store trait。

Last change: 2023-10-19, commit: 497bbcd

组件:智能合约的乐高式构建块

开发共享通用逻辑和存储的合约可能会很痛苦,而且容易出错, 因为这个逻辑很难重用,需要在每份合约中重新编写。 但是,如果有一种方法可以额外提供您在合约中需要的功能, 并与你的合约的核心逻辑是分离的会怎样呢?

组件正好提供了这一点。它们是封装可重复使用的 模块化附加逻辑、存储和事件,可以被整合到多个合约中。 它们可用于扩展合约的功能,而无需一遍又一遍地重新实现相同的逻辑。

将组件视为乐高积木。它们允许您通过插入您或其他人编写的模块方式增强您的合约。 这个模块可以是一个简单的组件,如所有权组件,或更复杂的,如成熟的 ERC20代币。

组件是一个单独的模块,可以包含存储、事件和功能。 与合约不同,你不能声明或部署组件。 其逻辑最终将成为它被嵌入的合约字节码的一部分。

组件里面有什么?

组件与合约非常相似。它可以包含:

  • 存储变量
  • 事件
  • 外部和内部函数

与合约不同,组件不能单独部署。组件的代码成为嵌入到的合约的一部分。

创建组件

要创建一个组件,首先在它自己的模块中定义它,并用#[starknet::component] 属性。 在此模块中,您可以声明一个 Storage 结构和 Event 枚举,这通常在合约中。

下一步是定义组件接口,其中包含允许从外部访问组件逻辑的函数。 您可以这样定义组件的接口,方法是使用#[starknet::interface]属性, 就像你使用合约一样。这接口通过Dispatcher模式, 用于启用对组件功能的外部访问。

组件外部逻辑的实际实现是在标记为#[embeddable_as(name)]impl代码块中完成的。 通常,这个impl块将是一个定义组件接口的trait的实现。

注意:name是我们将在合约中用来指代 组件的名称。它与 impl 的名称不同。

您还可以定义外部无法访问的内部函数, 只需省略内部impl块上方的 #[embeddable_as(name)] 属性即可。 您将能够在合约内部使用组件,但无法从外部与组件交互,因为它们不是合约 ABI 的一部分。

这些impl块中的函数需要类似ref self:ComponentState<TContractState>(用于状态修改函数)的参数 或self:@ComponentState<TContractState>(用于只读函数)。这使得 impl 基于泛型 TContractState,允许我们在任何合约中使用该组件。

示例:一个 Ownable 组件

⚠️ 下面显示的示例尚未经过审核,不应适用于 产品之中。作者对因 使用此代码产生的任何问题概不负责。

Ownable 组件的接口,定义的用于管理合约所有权外部可用方法如下所示:

#![allow(unused)]
fn main() {
#[starknet::interface]
trait IOwnable<TContractState> {
    fn owner(self: @TContractState) -> ContractAddress;
    fn transfer_ownership(ref self: TContractState, new_owner: ContractAddress);
    fn renounce_ownership(ref self: TContractState);
}
}

组件本身定义为:

#![allow(unused)]
fn main() {
#[starknet::component]
mod ownable_component {
    use starknet::ContractAddress;
    use starknet::get_caller_address;
    use super::Errors;

    #[storage]
    struct Storage {
        owner: ContractAddress
    }

    #[event]
    #[derive(Drop, starknet::Event)]
    enum Event {
        OwnershipTransferred: OwnershipTransferred
    }

    #[derive(Drop, starknet::Event)]
    struct OwnershipTransferred {
        previous_owner: ContractAddress,
        new_owner: ContractAddress,
    }

    #[embeddable_as(Ownable)]
    impl OwnableImpl<
        TContractState, +HasComponent<TContractState>
    > of super::IOwnable<ComponentState<TContractState>> {
        fn owner(self: @ComponentState<TContractState>) -> ContractAddress {
            self.owner.read()
        }

        fn transfer_ownership(
            ref self: ComponentState<TContractState>, new_owner: ContractAddress
        ) {
            assert(!new_owner.is_zero(), Errors::ZERO_ADDRESS_OWNER);
            self.assert_only_owner();
            self._transfer_ownership(new_owner);
        }

        fn renounce_ownership(ref self: ComponentState<TContractState>) {
            self.assert_only_owner();
            self._transfer_ownership(Zeroable::zero());
        }
    }

    #[generate_trait]
    impl InternalImpl<
        TContractState, +HasComponent<TContractState>
    > of InternalTrait<TContractState> {
        fn initializer(ref self: ComponentState<TContractState>, owner: ContractAddress) {
            self._transfer_ownership(owner);
        }

        fn assert_only_owner(self: @ComponentState<TContractState>) {
            let owner: ContractAddress = self.owner.read();
            let caller: ContractAddress = get_caller_address();
            assert(!caller.is_zero(), Errors::ZERO_ADDRESS_CALLER);
            assert(caller == owner, Errors::NOT_OWNER);
        }

        fn _transfer_ownership(
            ref self: ComponentState<TContractState>, new_owner: ContractAddress
        ) {
            let previous_owner: ContractAddress = self.owner.read();
            self.owner.write(new_owner);
            self
                .emit(
                    OwnershipTransferred { previous_owner: previous_owner, new_owner: new_owner }
                );
        }
    }
}
}

这种语法实际上与用于合约的语法非常相似。 唯一的差异与 impl 上方的 #[embeddable_as] 属性有关。 我们将详细剖析的 impl 块的泛用性。

正如你所看到的,我们的组件有两个 impl 块: 一个对应于接口trait的实现,另一个包含不暴露在外部,仅供内部使用方法。 把assert_only_owner暴露出来作为接口的一部分是没有意义的, 因为它只是旨在由嵌入组件的合约在内部使用。

进一步研究 impl

#![allow(unused)]
fn main() {
    #[embeddable_as(Ownable)]
    impl OwnableImpl<
        TContractState, +HasComponent<TContractState>
    > of super::IOwnable<ComponentState<TContractState>> {
}

#[embeddable_as]属性用于将 impl 标记为可嵌入到合约。 它允许我们指定将在合约中引用此组件。 在这种情况下,组件将在嵌入它的合约中被视为Ownable

实现本身是泛型的 ComponentState<TContractState>, 有着TContractState 必须实现HasComponent<T> trait的附加限制。 这允许我们在任何实现了HasComponent trait的合约中中使用该组件。 使用组件其实不需要你了解此机制的工作原理,但如果您对内部感到好奇,您可以阅读 组件工作原理 部分来学习更多。

这里与常规智能合约的主要区别之一是访问 存储和事件是通过通用的ComponentState<TContractState>类型完成的, 而不是ContractState。请注意,虽然类型不同,但访问 存储或发出事件都是是通过类似 self.storage_var_name.read()self.emit(...)实现的。

注意:为避免混淆嵌入的名称和 impl 名称,我们 建议在 impl 名称中保留后缀“Impl”。

将合约迁移到组件

由于合约和组件有很多相似之处,因此实际上 从合约迁移到组件非常容易。唯一需要的更改 是:

  • #[starknet::component] 属性添加到模块中。
  • #[embeddable_as(name)] 属性添加到 impl 块中,该块将嵌入到另一个合约中。
  • 将泛型参数添加到impl块:
  • 添加 'TContractState' 作为泛型参数。
  • 添加 ·+HasComponent· 作为 impl 限制。
  • impl块的函数中更改self参数的类型, 设置为ComponentState<TContractState>用以取代ContractState

对于那些没有明确定义且使用 #[generate_trait]的trait,逻辑是相同的 - 但这些trait是TContractState而不是ComponentState<TContractState>的泛型, 如上面例子中就带有InternalTrait

在合约中使用组件

组件的主要优势在于它允许你在合约中的重复使用已经构建的组件原语, 且只需要相当少的模版代码。 将组件集成到您的合约中,您需要:

  1. component!() 宏,指定

    1. 组件的路径 path::to::component
    2. 合约存储中变量的名称,引用此 组件的存储(例如ownable)。
    3. 合约事件枚举中变体的名称,引用此 组件的事件(例如OwnableEvent)。
  2. 将组件存储的路径和事件添加到合约的 StorageEvent中。它们必须与步骤 1 中提供的名称匹配(例如 ownable: ownable_component::Storage and OwnableEvent: ownable_component::Event)。

    存储变量 必须#[substorage(v0)]属性进行标注

  3. 要将组件的逻辑嵌入到合约中, 可以通过使用了impl别名的一个具体的 ContractState, 来实例化一个组件的泛型 impl 。 此别名必须用 #[abi(embed_v0)]标注,来将组件函数暴露给外部。

    如您所见,内部impl 未标有 #[abi(embed_v0)]。 事实上,我们不想从外部公开该impl其中定义的函数 但是,我们可能仍希望在内部访问它们。

例如,要嵌入上面定义的Ownable 组件,我们将执行:

#![allow(unused)]
fn main() {
#[starknet::contract]
mod OwnableCounter {
    use listing_01_ownable::component::ownable_component;

    component!(path: ownable_component, storage: ownable, event: OwnableEvent);

    #[abi(embed_v0)]
    impl OwnableImpl = ownable_component::Ownable<ContractState>;

    impl OwnableInternalImpl = ownable_component::InternalImpl<ContractState>;

    #[storage]
    struct Storage {
        counter: u128,
        #[substorage(v0)]
        ownable: ownable_component::Storage
    }


    #[event]
    #[derive(Drop, starknet::Event)]
    enum Event {
        OwnableEvent: ownable_component::Event
    }


    #[external(v0)]
    fn foo(ref self: ContractState) {
        self.ownable.assert_only_owner();
        self.counter.write(self.counter.read() + 1);
    }
}
}

组件的逻辑现在无缝地成为合约的一部分! 我们可以通过随着合约实例化的IOwnableDispatcher 与组件函数互动。

#![allow(unused)]
fn main() {
#[starknet::interface]
trait IOwnable<TContractState> {
    fn owner(self: @TContractState) -> ContractAddress;
    fn transfer_ownership(ref self: TContractState, new_owner: ContractAddress);
    fn renounce_ownership(ref self: TContractState);
}
}

堆叠组件以实现最大的可组合性

当组合多个组件时,组件的可组合性将大放异彩。 每个组件都将其功能添加到合约中。 以后您将可以通过依赖 Openzeppelin's 的 组件实现以快速插入你所需的合约所有常用功能。

开发人员可以专注于他们的核心合约逻辑, 同时在其他部分依靠经过实战考验的逻辑并经过审核的组件。

组件甚至可以 依赖 其他组件, 只要另一个组件通过了TContractstate这个泛型。 在我们深入研究这个机制之前,让我们先看看深入理解组件如何工作

疑难解答

尝试实现组件时可能会遇到一些错误。 不幸的是,其中一些错误缺少有意义的错误消息来帮助调试。 这部分旨在为您提供一些指导来帮助您调试代码。

  • Trait not found. Not a trait.

    当您未正确的在合约中导入组件的 impl 块时, 可能会发生此错误。请确保遵循了以下语法:

#![allow(unused)]
fn main() {
 #[abi(embed_v0)]
 impl IMPL_NAME = upgradeable::EMBEDDED_NAME<ContractState>
}

参考我们之前的示例,这里应该写成:

#![allow(unused)]
fn main() {
 #[abi(embed_v0)]
 impl OwnableImpl = upgradeable::Ownable<ContractState>
}
  • Plugin diagnostic: name is not a substorage member in the contract's Storage. Consider adding to Storage: (...)

    在您调试时,编译器给出的建议操作能够起到很大的作用。 基本上,这条错误表示您忘记将组件的存储添加到 合约的存储中。使用#[substorage(v0)]属性标注, 确保将其添加到到合约的存储中。

  • Plugin diagnostic: name is not a nested event in the contract's Event enum. Consider adding to the Event enum:

    与前面的错误类似,编译器提醒您忘记添加组件的 事件到合约的事件中。确保将组件事件路径添加到合约的事件中。

  • Components functions are not accessible externally

    这会发生在您忘了使用#[abi(embed_v0)]来标注impl块时。 请确保在嵌入组件时在合约的impl块上用此标注。

Last change: 2023-10-12, commit: 9085fd1

深入了解组件

组件为 Starknet 合约提供了强大的模块化。但在这个魔术的背后是什么原理呢?

本章将深入探讨编译器的内部结构,以解释 实现组件可组合性的机制。

嵌入式实现入门

在深入研究组件之前,我们需要了解 embeddable impls

一个实现了Starknet 接口trait(标有#[starknet::interface])的impl是可嵌入的。 可嵌入的impl可以注入到任何合约中,这会添加新的入口点以及改变合约的 ABI。

让我们看一个示例来看看这个实际运作的过程:

#![allow(unused)]
fn main() {
#[starknet::interface]
trait SimpleTrait<TContractState> {
    fn ret_4(self: @TContractState) -> u8;
}

#[starknet::embeddable]
impl SimpleImpl<TContractState> of SimpleTrait<TContractState> {
    fn ret_4(self: @TContractState) -> u8 {
        4
    }
}

#[starknet::contract]
mod simple_contract {
    #[storage]
    struct Storage {}

    #[abi(embed_v0)]
    impl MySimpleImpl = super::SimpleImpl<ContractState>;
}
}

通过嵌入 SimpleImpl,我们在合约的 ABI 中向外部公开 ret4

现在我们对嵌入机制更加熟悉了,我们可以看到组件如何在此基础上构建。

内部组件:泛型实现

回想一下组件中使用的 impl 块的语法:

#![allow(unused)]
fn main() {
    #[embeddable_as(Ownable)]
    impl OwnableImpl<
        TContractState, +HasComponent<TContractState>
    > of super::IOwnable<ComponentState<TContractState>> {
}

关键点:

  • OwnableImpl 要求底层合约实现HasComponent<TContractState> trait。 这个trait在合约中使用组件的时候,会由component!()宏自动生成。

    编译器将生成一个封装 OwnableImpl 中任何函数的 impl、 用self:TContractState替换掉中的 self: ComponentState<TContractState>参数,其中对组件状态的访问是通过 HasComponent<TContractState> trait中的 get_component 函数来进行的。

    编译器会为每个组件生成一个 HasComponent trait。该trait 定义了连接泛型合约与实际 TContractStateComponentState<TContractState> 之间的接口。

    #![allow(unused)]
    fn main() {
    // generated per component
    trait HasComponent<TContractState> {
        fn get_component(self: @TContractState) -> @ComponentState<TContractState>;
        fn get_component_mut(ref self: TContractState) -> ComponentState<TContractState>;
        fn get_contract(self: @ComponentState<TContractState>) -> @TContractState;
        fn get_contract_mut(ref self: ComponentState<TContractState>) -> TContractState;
        fn emit<S, impl IntoImp: traits::Into<S, Event>>(ref self: ComponentState<TContractState>, event: S);
    }
    }

    在我们的上下文中,ComponentState<TContractState> 是一个特定的ownable组件的类型。 它拥有着基于ownable_component::Storage定义的变量成员。 从泛型TContractState转移到ComponentState<TContractState> 使得我们可以将 Ownable 嵌入任意合约。反方向的转移(即ComponentState<TContractState> 转移到 ContractState)对于外部依赖是很有用的。 详细可见组件依赖 章节中的依赖于一个IOwnable实现的Upgradeable组件的例子。

    简而言之,我们应该考虑上述 HasComponent<T> 的实现: "合约的状态 T 具有可升级组件"。

  • Ownable被标注为 embeddable_as(<name>)属性:

    embeddable_asembeddable类似;它只适用于 starknet::interface trait的 "impls", 并允许将这个impl嵌入一个合约模块。也就是说,embeddable_as(<name>)在组件的上下文中还有另一个作用。 最终,当在某个契约中嵌入 OwnableImpl 时,我们希望得到一个具有以下函数的 impl:

    #![allow(unused)]
    fn main() {
        fn owner(self: @TContractState) -> ContractAddress;
      fn transfer_ownership(ref self: TContractState, new_owner: ContractAddress);
      fn renounce_ownership(ref self: TContractState);
    }

    请注意,在开始使用函数接收泛型 ComponentState<TContractState>时,我们期望的是一个接受ContractState的函数。 这就是 embeddable_as(<name>) 的用武之地。要了解全景, 我们需要知道编译器为embeddable_as(Ownable)注解生成的 impl 是什么:

    #![allow(unused)]
    fn main() {
    #[starknet::embeddable]
    impl Ownable<
              TContractState, +HasComponent<TContractState>
    , impl TContractStateDrop: Drop<TContractState>
    > of super::IOwnable<TContractState> {
    
      fn owner(self: @TContractState) -> ContractAddress {
          let component = HasComponent::get_component(self);
          OwnableImpl::owner(component, )
      }
    
      fn transfer_ownership(ref self: TContractState, new_owner: ContractAddress
    ) {
          let mut component = HasComponent::get_component_mut(ref self);
          OwnableImpl::transfer_ownership(ref component, new_owner, )
      }
    
      fn renounce_ownership(ref self: TContractState) {
          let mut component = HasComponent::get_component_mut(ref self);
          OwnableImpl::renounce_ownership(ref component, )
      }
    }
    }

    请注意,由于有了HasComponent<TContractState>的impl, 编译器才能够将我们的函数封装在一个不需要直接知道ComponentState的新的impl中。 在合约中写上embeddable_as(Ownable)时,就意味着Ownable将是被嵌入合约中来实现所有权功能的impl。

合约集成

我们已经了解了泛型impl如何实现组件的可重用性。接下来,让我们看看合约是如何集成组件的。

合约使用 impl alias 来实例化组件的泛型 impl 替换为该合约的具体ContractState

#![allow(unused)]
fn main() {
    #[abi(embed_v0)]
    impl OwnableImpl = ownable_component::Ownable<ContractState>;

    impl OwnableInternalImpl = ownable_component::InternalImpl<ContractState>;
}

上面的代码使用了 Cairo impl 嵌入机制和 impl 别名语法。 我们正在用具体类型的ContractState来实例化泛型 OwnableImpl<TContractState> 回想一下,OwnableImpl<TContractState> 具有HasComponent<TContractState>泛型 impl 参数。 此实现的trait 由 component! 宏生成。

请注意,这只可以使用在可以实现这个trati的合约上, 因为只有这种合约知晓合约state 和组件 state。

这会将所有内容粘合在一起,以将组件逻辑注入到合约中。

关键要点

  • 可嵌入的实现允许通过组件注入来添加入口点和改变合约 ABI。
  • 档组件被用于合约中时,编译器会自动生成HasComponent tarit实现 这在合约状态和组件状态搭了一座桥,以实现双方互动。
  • 组件以通用的、与合约无关的方式封装可重用的逻辑。 合约通过 impl 别名集成组件,并通过生成的HasComponent trait来访问组件。
  • 组件基于可嵌入的实现构建,通过定义通用组件逻辑 可以集成到任何想要使用该组件的合约中。 实现别名使用合约的具体存储来实例化这些泛型实现类型。
Last change: 2023-11-04, commit: c56dc86

组件依赖

Last change: 2023-10-11, commit: 7fa732e

测试组件

Testing components is a bit different than testing contracts. Contracts need to be tested against a specific state, which can be achieved by either deploying the contract in a test, or by simply getting the ContractState object and modifying it in the context of your tests.

Components are a generic construct, meant to be integrated in contracts, that can't be deployed on their own and don't have a ContractState object that we could use. So how do we test them?

Let's consider that we want to test a very simple component called "Counter", that will allow each contract to have a counter that can be incremented. The component is defined as follows:

#[starknet::component]
mod CounterComponent {
    #[storage]
    struct Storage {
        value: u32
    }

    #[embeddable_as(CounterImpl)]
    impl Counter<
        TContractState, +HasComponent<TContractState>
    > of super::ICounter<ComponentState<TContractState>> {
        fn get_counter(self: @ComponentState<TContractState>) -> u32 {
            self.value.read()
        }
        fn increment(ref self: ComponentState<TContractState>) {
            self.value.write(self.value.read() + 1);
        }
    }
}

Testing the component by deploying a mock contract

The easiest way to test a component is to integrate it within a mock contract. This mock contract is only used for testing purposes, and only integrates the component you want to test. This allows you to test the component in the context of a contract, and to use a Dispatcher to call the component's entry points.

We can define such a mock contract as follows:

#[starknet::contract]
mod MockContract {
    use super::counter::CounterComponent;
    component!(path: CounterComponent, storage: counter, event: CounterEvent);
    #[storage]
    struct Storage {
        #[substorage(v0)]
        counter: CounterComponent::Storage,
    }
    #[event]
    #[derive(Drop, starknet::Event)]
    enum Event {
        CounterEvent: CounterComponent::Event
    }

    #[abi(embed_v0)]
    impl CounterImpl = CounterComponent::CounterImpl<ContractState>;
}

This contract is entirely dedicated to testing the Counter component. It embeds the component with the component! macro, exposes the component's entry points by annotating the impl aliases with #[abi(embed_v0)].

We also need to define an interface that will be required to interact externally with this mock contract.

#[starknet::interface]
trait ICounter<TContractState> {
    fn get_counter(self: @TContractState) -> u32;
    fn increment(ref self: TContractState);
}

We can now write tests for the component by deploying this mock contract and calling its entry points, as we would with a typical contract.

use core::traits::TryInto;
use super::MockContract;
use super::counter::{ICounterDispatcher, ICounterDispatcherTrait};
use starknet::deploy_syscall;
use starknet::SyscallResultTrait;

fn setup_counter() -> ICounterDispatcher {
    let (address, _) = deploy_syscall(
        MockContract::TEST_CLASS_HASH.try_into().unwrap(), 0, array![].span(), false
    )
        .unwrap_syscall();
    ICounterDispatcher { contract_address: address }
}

#[test]
#[available_gas(20000000)]
fn test_constructor() {
    let counter = setup_counter();
    assert_eq!(counter.get_counter(), 0);
}

#[test]
#[available_gas(20000000)]
fn test_increment() {
    let counter = setup_counter();
    counter.increment();
    assert_eq!(counter.get_counter(), 1);
}

Testing components without deploying a contract

In Components under the hood, we saw that components leveraged genericity to define storage and logic that could be embedded in multiple contracts. If a contract embeds a component, a HasComponent trait is created in this contract, and the component methods are made available.

This informs us that if we can provide a concrete TContractState that implements the HasComponent trait to the ComponentState struct, should be able to directly invoke the methods of the component using this concrete ComponentState object, without having to deploy a mock.

Let's see how we can do that by using type aliases. We still need to define a mock contract - let's use the same as above - but this time, we won't need to deploy it.

First, we need to define a concrete implementation of the generic ComponentState type using a type alias. We will use the MockContract::ContractState type to do so.

use super::counter::{CounterComponent};
use super::MockContract;
use CounterComponent::{CounterImpl};

type TestingState = CounterComponent::ComponentState<MockContract::ContractState>;

// You can derive even `Default` on this type alias
impl TestingStateDefault of Default<TestingState> {
    fn default() -> TestingState {
        CounterComponent::component_state_for_testing()
    }
}

#[test]
#[available_gas(2000000)]
fn test_increment() {
    let mut counter: TestingState = Default::default();

    counter.increment();
    counter.increment();

    assert_eq!(counter.get_counter(), 2);
}

We defined the TestingState type as an alias of the CounterComponent::ComponentState<MockContract::ContractState> type. By passing the MockContract::ContractState type as a concrete type for ComponentState, we aliased a concrete implementation of the ComponentState struct to TestingState.

Because MockContract embeds CounterComponent, the methods of CounterComponent defined in the CounterImpl block can now be used on a TestingState object.

Now that we have made these methods available, we need to instantiate an object of type TestingState, that we will use to test the component. We can do so by calling the component_state_for_testing function, which automatically infers that it should return an object of type TestingState.

We can even implement this as part of the Default trait, which allows us to return an empty TestingState with the Default::default() syntax.

Let's summarize what we've done so far:

  • We defined a mock contract that embeds the component we want to test.
  • We defined a concrete implementation of ComponentState<TContractState> using a type alias with MockContract::ContractState, that we named TestingState.
  • We defined a function that uses component_state_for_testing to return a TestingState object.

We can now write tests for the component by calling its functions directly, without having to deploy a mock contract. This approach is more lightweight than the previous one, and it allows testing internal functions of the component that are not exposed to the outside world trivially.

use super::counter::{CounterComponent};
use super::MockContract;
use CounterComponent::{CounterImpl};

type TestingState = CounterComponent::ComponentState<MockContract::ContractState>;

// You can derive even `Default` on this type alias
impl TestingStateDefault of Default<TestingState> {
    fn default() -> TestingState {
        CounterComponent::component_state_for_testing()
    }
}

#[test]
#[available_gas(2000000)]
fn test_increment() {
    let mut counter: TestingState = Default::default();

    counter.increment();
    counter.increment();

    assert_eq!(counter.get_counter(), 2);
}

Last change: 2023-12-11, commit: ae4d02d

Starknet合约:ABI和跨合约交互

在创建复杂的去中心化应用程序时,智能合约之间的交互是一项重要功能,因为它可以实现可组合性和关注点分离。本章将介绍如何实现合约之间的交互。

具体来说,您将了解 ABI、合约接口、合约和库调度程序及其与底层系统调用等同程序!

Last change: 2023-07-22, commit: fd315e3

ABI和合约接口

区块链上的智能合约之间的跨合约互动是一种常见的做法,它使我们能够建立灵活的合约,相互对话。

在Starknet上实现这一点需要我们称之为接口的东西。

ABI - 应用二进制接口

在Starknet上,合约的 ABI 是合约函数和结构的 JSON 表示,使任何人(或任何其他合约)都能对其进行编码调用。它是一个蓝图,指示如何调用函数、以及它们所需的输入参数和格式。

虽然我们用高级Cairo语言编写智能合约逻辑,但它们在虚拟机上存储为二进制格式的可执行字节码。由于这种字节码不是人类可读的,因此需要解释才能理解。这就是 ABI 的作用所在,它定义了可以调用智能合约执行的特定方法。如果没有 ABI,外部参与者几乎不可能理解如何与合约交互。

ABI 通常用于 dApps 前端,使其能够正确格式化数据,使智能合约能够理解数据,反之亦然。当你通过 VoyagerStarkscan 等区块资源管理器与智能合约交互时,它们会使用合约的 ABI 来格式化你发送给合约的数据以及合约返回的数据。

接口

合约的接口是它公开的函数列表。 它指定了智能合约中包含的函数签名(名称、参数、可见性和返回值),但不包括函数体。

在Cairo中,合约接口是用 #[starknet::interface] 属性注解的 traits。如果你对 traits 还不熟悉,请查看专门介绍 traits 的章节。

一个重要的规范是,该trait必须是基于 TContractState 类型的泛型。函数访问想要合约存储空间必须基于此trait,这样函数才能读写合约。

注意:合约的构造函数不是接口的一部分。内部函数也不是接口的一部分。

下面是 ERC20 代币合约的接口示例。正如您所看到的,它是在 TContractState 类型上的泛型trait。view函数有一个类型为@TContractState的自参数,而 external 函数有一个类型为 ref self: TContractState.的自参数。

use starknet::ContractAddress;

#[starknet::interface]
trait IERC20<TContractState> {
    fn name(self: @TContractState) -> felt252;

    fn symbol(self: @TContractState) -> felt252;

    fn decimals(self: @TContractState) -> u8;

    fn total_supply(self: @TContractState) -> u256;

    fn balance_of(self: @TContractState, account: ContractAddress) -> u256;

    fn allowance(self: @TContractState, owner: ContractAddress, spender: ContractAddress) -> u256;

    fn transfer(ref self: TContractState, recipient: ContractAddress, amount: u256) -> bool;

    fn transfer_from(
        ref self: TContractState, sender: ContractAddress, recipient: ContractAddress, amount: u256
    ) -> bool;

    fn approve(ref self: TContractState, spender: ContractAddress, amount: u256) -> bool;
}

示例99-4:一个简单的ERC20接口

在下一章,我们将研究如何使用 合约调度器 ( dispatchers )、和 系统调用( syscalls ) 来调用其他智能合约。

Last change: 2023-11-15, commit: e18ad31

使用调度程序和系统调用与其他合约和类交互

每次定义合约接口时,编译器都会自动创建并导出两个调度程序。我们将一个接口命名为 IERC20,它们是:

  1. 合约调度器 IERC20Dispatcher
  2. 库调度器 IERC20LibraryDispatcher

编译器还会生成一个名为 "IERC20DispatcherTrait"的trait,这使得我们可以在调度器结构上调用接口中定义的函数。

在本章中,我们将讨论它们是什么、如何工作以及如何使用。

为了有效地拆解本章的概念,我们将使用前一章的IERC20接口(参考示例99-4):

合约调度器

如前所述,使用 #[starknet::interface] 属性注释的trait会在编译时自动生成一个调度器和一个trait。 我们的 IERC20 接口扩展至如下:

注: IERC20 界面的扩展代码较长,但为了使本章简明扼要,我们将重点放在一个视图函数 name 和一个外部函数 transfer上。

use starknet::{ContractAddress};

trait IERC20DispatcherTrait<T> {
    fn name(self: T) -> felt252;
    fn transfer(self: T, recipient: ContractAddress, amount: u256);
}

#[derive(Copy, Drop, starknet::Store, Serde)]
struct IERC20Dispatcher {
    contract_address: ContractAddress,
}

impl IERC20DispatcherImpl of IERC20DispatcherTrait<IERC20Dispatcher> {
    fn name(
        self: IERC20Dispatcher
    ) -> felt252 { // starknet::call_contract_syscall is called in here
    }
    fn transfer(
        self: IERC20Dispatcher, recipient: ContractAddress, amount: u256
    ) { // starknet::call_contract_syscall is called in here
    }
}

示例99-5:IERC20 trait的扩展

如你所见,"典型"的调度器只是一个结构体,它封装了一个合约地址,并实现了编译器生成的 DispatcherTrait,允许我们调用另一个合约的函数。这意味着我们可以用要调用的合约地址实例化一个结构体,然后简单地调用调度器结构体上的接口定义的函数,就像调用该类型的方法一样。

而且值得注意的是,所有这些都被Cairo插件在幕后抽象化了。

使用合约调度器调用合约

这是一个名为 TokenWrapper 的合约使用调度器调用定义在 ERC-20 令牌上的函数的示例。调用 transfer_token 将修改部署在 contract_address 的合约状态。

use starknet::ContractAddress;

#[starknet::interface]
trait IERC20<TContractState> {
    fn name(self: @TContractState) -> felt252;

    fn symbol(self: @TContractState) -> felt252;

    fn decimals(self: @TContractState) -> u8;

    fn total_supply(self: @TContractState) -> u256;

    fn balance_of(self: @TContractState, account: ContractAddress) -> u256;

    fn allowance(self: @TContractState, owner: ContractAddress, spender: ContractAddress) -> u256;

    fn transfer(ref self: TContractState, recipient: ContractAddress, amount: u256) -> bool;

    fn transfer_from(
        ref self: TContractState, sender: ContractAddress, recipient: ContractAddress, amount: u256
    ) -> bool;

    fn approve(ref self: TContractState, spender: ContractAddress, amount: u256) -> bool;
}

#[starknet::interface]
trait ITokenWrapper<TContractState> {
    fn token_name(self: @TContractState, contract_address: ContractAddress) -> felt252;

    fn transfer_token(
        ref self: TContractState,
        contract_address: ContractAddress,
        recipient: ContractAddress,
        amount: u256
    ) -> bool;
}


//**** Specify interface here ****//
#[starknet::contract]
mod TokenWrapper {
    use super::IERC20DispatcherTrait;
    use super::IERC20Dispatcher;
    use super::ITokenWrapper;
    use starknet::ContractAddress;

    #[storage]
    struct Storage {}

    impl TokenWrapper of ITokenWrapper<ContractState> {
        fn token_name(self: @ContractState, contract_address: ContractAddress) -> felt252 {
            IERC20Dispatcher { contract_address }.name()
        }

        fn transfer_token(
            ref self: ContractState,
            contract_address: ContractAddress,
            recipient: ContractAddress,
            amount: u256
        ) -> bool {
            IERC20Dispatcher { contract_address }.transfer(recipient, amount)
        }
    }
}

示例99-6:一个使用合约调度器的样本合约

正如您所看到的,我们必须首先导入编译器生成的 IERC20DispatcherTraitIERC20Dispatcher,这样我们就可以调用为 IERC20Dispatcher 结构实现的方法(nametransfer 等),并在 IERC20Dispatcher 结构中传入我们要调用的合约的 contract_address

库调度器

合约调度器和库调度器的主要区别在于类中定义的逻辑的执行上下文。普通调度程序用于调用来自 合约(有相关状态)的函数,而库调度程序则用于调用 (无状态)。

让我们设想两个合约 A 和 B。

当A使用 IBDispatcher 来调用 合约 B中的函数时,定义在B中的逻辑的执行上下文是B的。这意味着在B中由get_caller_address()返回的值将返回A的地址,并且在B中更新存储变量将更新B的存储。

当A使用 IBLibraryDispatcher 来调用B的 中的函数时,定义在B类中的逻辑的执行上下文是A的。这意味着在B中由 get_caller_address() 变量返回的值将返回A的调用者的地址,并且在B的类中更新存储变量将更新A的存储(请记住,B的类是无状态的;没有可以更新的状态!)

编译器生成的 struct 和 trait 的扩展形式如下:

use starknet::ContractAddress;

trait IERC20DispatcherTrait<T> {
    fn name(self: T) -> felt252;
    fn transfer(self: T, recipient: ContractAddress, amount: u256);
}

#[derive(Copy, Drop, starknet::Store, Serde)]
struct IERC20LibraryDispatcher {
    class_hash: starknet::ClassHash,
}

impl IERC20LibraryDispatcherImpl of IERC20DispatcherTrait<IERC20LibraryDispatcher> {
    fn name(
        self: IERC20LibraryDispatcher
    ) -> felt252 { // starknet::syscalls::library_call_syscall  is called in here
    }
    fn transfer(
        self: IERC20LibraryDispatcher, recipient: ContractAddress, amount: u256
    ) { // starknet::syscalls::library_call_syscall  is called in here
    }
}

请注意,普通合约调度程序与库调度程序的主要区别在于,前者是通过 call_contract_syscall 生成的,而后者则使用了 library_call_syscall

示例99-7:IERC20 trait的扩展

使用库调度器调用合约

下面是一个关于使用库调度器调用合约的示例代码。

use starknet::ContractAddress;
#[starknet::interface]
trait IContractB<TContractState> {
    fn set_value(ref self: TContractState, value: u128);

    fn get_value(self: @TContractState) -> u128;
}

#[starknet::contract]
mod ContractA {
    use super::{IContractBDispatcherTrait, IContractBLibraryDispatcher};
    use starknet::ContractAddress;

    #[storage]
    struct Storage {
        value: u128
    }

    #[generate_trait]
    #[external(v0)]
    impl ContractA of IContractA {
        fn set_value(ref self: ContractState, value: u128) {
            IContractBLibraryDispatcher { class_hash: starknet::class_hash_const::<0x1234>() }
                .set_value(value)
        }

        fn get_value(self: @ContractState) -> u128 {
            self.value.read()
        }
    }
}

示例99-8:一个使用库调度器的样本合约

正如你所看到的,我们必须首先在我们的合约中导入IContractBDispatcherTraitIContractBLibraryDispatcher,它们是由编译器从我们的接口中生成的。然后,我们可以创建一个 IContractBLibraryDispatcher 实例,并将我们要调用库的类的 class_hash 传递进去。在这里,我们可以调用该类中定义的函数,在我们的合约上下文中执行其逻辑。当我们在合约 A 上调用 set_value 时,它将对合约 B 中的 set_value 函数进行库调用,更新合约 A 中存储变量 value 的值。

使用底层系统调用来

调用其他合约和类的另一种方法是使用 starknet::call_contract_syscallstarknet::library_call_syscall 系统调用。我们在前几节中描述的调度器就是这些低级系统调用的高级语法。

使用这些系统调用可以方便地进行自定义错误处理,或对调用数据和返回数据的序列化/反序列化进行更多控制。下面的示例演示了如何使用 call_contract_sycall调用 ERC20 合约的 transfer函数:

use starknet::ContractAddress;
#[starknet::interface]
trait ITokenWrapper<TContractState> {
    fn transfer_token(
        ref self: TContractState,
        address: ContractAddress,
        sender: ContractAddress,
        recipient: ContractAddress,
        amount: u256
    ) -> bool;
}

#[starknet::contract]
mod TokenWrapper {
    use super::ITokenWrapper;
    use serde::Serde;
    use starknet::SyscallResultTrait;
    use starknet::ContractAddress;

    #[storage]
    struct Storage {}

    impl TokenWrapper of ITokenWrapper<ContractState> {
        fn transfer_token(
            ref self: ContractState,
            address: ContractAddress,
            sender: ContractAddress,
            recipient: ContractAddress,
            amount: u256
        ) -> bool {
            let mut call_data: Array<felt252> = ArrayTrait::new();
            Serde::serialize(@sender, ref call_data);
            Serde::serialize(@recipient, ref call_data);
            Serde::serialize(@amount, ref call_data);
            let mut res = starknet::call_contract_syscall(
                address, selector!("transferFrom"), call_data.span()
            )
                .unwrap_syscall();
            Serde::<bool>::deserialize(ref res).unwrap()
        }
    }
}

示例 99-9:使用系统调用的合约示例

为了使用这个系统调用,我们传入了合约地址、我们想要调用的函数的选择器以及调用参数。

调用参数必须以felt252 数组的形式提供。为了构建这个数组,我们使用 'Serde' trait 将预期的函数参数序列化为一个 Array<felt252>,然后将这个数组作为 calldata 传递。最后,我们返回一个序列化值,我们需要自己反序列化该值!

Last change: 2023-11-04, commit: aa501bc

其他例子

本节包含了Starknet智能合约的其他示例,利用了Cairo编程语言的各种功能。我们欢迎并鼓励大家贡献自己的代码,因为我们的目标是收集尽可能多的不同例子。

Last change: 2023-08-04, commit: 0df5596

部署表决合约并与之互动

Starknet的 Vote 合约首先要通过合约的构造函数注册投票人。在此阶段,三个投票人被初始化,他们的地址被传递给内部函数 _register_voters。该函数将选民添加到合约的状态中,标记为已注册并有资格投票。

在合约中,常量 YESNO 被定义为表决选项(分别为 1 和 0)。这些常量使输入值标准化,从而方便了投票过程。

注册完成后,投票者可使用 vote 函数进行投票,选择 1(YES)或 0(NO)作为其投票。投票时,合约状态会被更新,记录投票情况并标记投票人已投票。这样可以确保投票者无法在同一提案中再次投票。投票会触发 VoteCast 事件,记录投票行为。

该合约还会监控未经授权的投票尝试。如果检测到未经授权的行为,如非注册用户试图投票或用户试图再次投票,就会发出 UnauthorizedAttempt 事件。

这些功能、状态、常量和事件共同创建了一个结构化投票系统,在Starknet环境中管理着从注册到投票、事件记录、以及结果检索的投票生命周期。常量(如 YESNO )有助于简化投票流程,而事件则在确保透明度和可追溯性方面发挥着重要作用。

/// @dev Core Library Imports for the Traits outside the Starknet Contract
use starknet::ContractAddress;

/// @dev Trait defining the functions that can be implemented or called by the Starknet Contract
#[starknet::interface]
trait VoteTrait<T> {
    /// @dev Function that returns the current vote status
    fn get_vote_status(self: @T) -> (u8, u8, u8, u8);
    /// @dev Function that checks if the user at the specified address is allowed to vote
    fn voter_can_vote(self: @T, user_address: ContractAddress) -> bool;
    /// @dev Function that checks if the specified address is registered as a voter
    fn is_voter_registered(self: @T, address: ContractAddress) -> bool;
    /// @dev Function that allows a user to vote
    fn vote(ref self: T, vote: u8);
}

/// @dev Starknet Contract allowing three registered voters to vote on a proposal
#[starknet::contract]
mod Vote {
    use starknet::ContractAddress;
    use starknet::get_caller_address;

    const YES: u8 = 1_u8;
    const NO: u8 = 0_u8;

    /// @dev Structure that stores vote counts and voter states
    #[storage]
    struct Storage {
        yes_votes: u8,
        no_votes: u8,
        can_vote: LegacyMap::<ContractAddress, bool>,
        registered_voter: LegacyMap::<ContractAddress, bool>,
    }

    /// @dev Contract constructor initializing the contract with a list of registered voters and 0 vote count
    #[constructor]
    fn constructor(
        ref self: ContractState,
        voter_1: ContractAddress,
        voter_2: ContractAddress,
        voter_3: ContractAddress
    ) {
        // Register all voters by calling the _register_voters function
        self._register_voters(voter_1, voter_2, voter_3);

        // Initialize the vote count to 0
        self.yes_votes.write(0_u8);
        self.no_votes.write(0_u8);
    }

    /// @dev Event that gets emitted when a vote is cast
    #[event]
    #[derive(Drop, starknet::Event)]
    enum Event {
        VoteCast: VoteCast,
        UnauthorizedAttempt: UnauthorizedAttempt,
    }

    /// @dev Represents a vote that was cast
    #[derive(Drop, starknet::Event)]
    struct VoteCast {
        voter: ContractAddress,
        vote: u8,
    }

    /// @dev Represents an unauthorized attempt to vote
    #[derive(Drop, starknet::Event)]
    struct UnauthorizedAttempt {
        unauthorized_address: ContractAddress,
    }

    /// @dev Implementation of VoteTrait for ContractState
    #[external(v0)]
    impl VoteImpl of super::VoteTrait<ContractState> {
        /// @dev Returns the voting results
        fn get_vote_status(self: @ContractState) -> (u8, u8, u8, u8) {
            let (n_yes, n_no) = self._get_voting_result();
            let (yes_percentage, no_percentage) = self._get_voting_result_in_percentage();
            (n_yes, n_no, yes_percentage, no_percentage)
        }

        /// @dev Check whether a voter is allowed to vote
        fn voter_can_vote(self: @ContractState, user_address: ContractAddress) -> bool {
            self.can_vote.read(user_address)
        }

        /// @dev Check whether an address is registered as a voter
        fn is_voter_registered(self: @ContractState, address: ContractAddress) -> bool {
            self.registered_voter.read(address)
        }

        /// @dev Submit a vote
        fn vote(ref self: ContractState, vote: u8) {
            assert(vote == NO || vote == YES, 'VOTE_0_OR_1');
            let caller: ContractAddress = get_caller_address();
            self._assert_allowed(caller);
            self.can_vote.write(caller, false);

            if (vote == NO) {
                self.no_votes.write(self.no_votes.read() + 1_u8);
            }
            if (vote == YES) {
                self.yes_votes.write(self.yes_votes.read() + 1_u8);
            }

            self.emit(VoteCast { voter: caller, vote: vote, });
        }
    }

    /// @dev Internal Functions implementation for the Vote contract
    #[generate_trait]
    impl InternalFunctions of InternalFunctionsTrait {
        /// @dev Registers the voters and initializes their voting status to true (can vote)
        fn _register_voters(
            ref self: ContractState,
            voter_1: ContractAddress,
            voter_2: ContractAddress,
            voter_3: ContractAddress
        ) {
            self.registered_voter.write(voter_1, true);
            self.can_vote.write(voter_1, true);

            self.registered_voter.write(voter_2, true);
            self.can_vote.write(voter_2, true);

            self.registered_voter.write(voter_3, true);
            self.can_vote.write(voter_3, true);
        }
    }

    /// @dev Asserts implementation for the Vote contract
    #[generate_trait]
    impl AssertsImpl of AssertsTrait {
        // @dev Internal function that checks if an address is allowed to vote
        fn _assert_allowed(ref self: ContractState, address: ContractAddress) {
            let is_voter: bool = self.registered_voter.read((address));
            let can_vote: bool = self.can_vote.read((address));

            if (can_vote == false) {
                self.emit(UnauthorizedAttempt { unauthorized_address: address, });
            }

            assert(is_voter == true, 'USER_NOT_REGISTERED');
            assert(can_vote == true, 'USER_ALREADY_VOTED');
        }
    }

    /// @dev Implement the VotingResultTrait for the Vote contract
    #[generate_trait]
    impl VoteResultFunctionsImpl of VoteResultFunctionsTrait {
        // @dev Internal function to get the voting results (yes and no vote counts)
        fn _get_voting_result(self: @ContractState) -> (u8, u8) {
            let n_yes: u8 = self.yes_votes.read();
            let n_no: u8 = self.no_votes.read();

            (n_yes, n_no)
        }

        // @dev Internal function to calculate the voting results in percentage
        fn _get_voting_result_in_percentage(self: @ContractState) -> (u8, u8) {
            let n_yes: u8 = self.yes_votes.read();
            let n_no: u8 = self.no_votes.read();

            let total_votes: u8 = n_yes + n_no;

            if (total_votes == 0_u8) {
                return (0, 0);
            }
            let yes_percentage: u8 = (n_yes * 100_u8) / (total_votes);
            let no_percentage: u8 = (n_no * 100_u8) / (total_votes);

            (yes_percentage, no_percentage)
        }
    }
}

投票智能合约

部署、调用和唤起投票合约

Starknet 体验的一部分就是部署智能合约并与之交互。

一旦部署了合约,我们就可以通过调用合约的函数与之交互:

  • 调用合约:与只读取状态的外部函数交互。这些函数不会改变网络的状态,因此不需要付费或签署。
  • 唤起合约:与可以写入状态的外部函数交互。这些函数会改变网络状态,因此需要付费或签署。

我们将使用 katana 设置一个本地开发节点来部署投票合约。然后,我们将通过调用和调用其函数与合约进行交互。你也可以使用Goerli测试网络而不是 katana。然而,我们建议在本地开发和测试中使用 katana。你可以在Starknet Book的 Local Development with Katana 章节中找到有关 katana 的完整教程。

katana 本地Starknet节点

katana旨在支持Dojo 团队的本地开发。通过它,您可以在本地完成Starknet所需的一切工作。它是开发和测试的绝佳工具。

要从源代码安装 katana,请参考Starknet Book的 Local Development with Katana 章节。

一旦安装了 katana,就可以用以下命令启动本地Starknet节点:

katana --accounts 3 --seed 0 --gas-price 250

该命令将启动一个本地 Starknet 节点,并部署 3 个账户。我们将使用这些账户部署投票合约并与之交互:

...
PREFUNDED ACCOUNTS
==================

| Account address |  0x03ee9e18edc71a6df30ac3aca2e0b02a198fbce19b7480a63a0d71cbd76652e0
| Private key     |  0x0300001800000000300000180000000000030000000000003006001800006600
| Public key      |  0x01b7b37a580d91bc3ad4f9933ed61f3a395e0e51c9dd5553323b8ca3942bb44e

| Account address |  0x033c627a3e5213790e246a917770ce23d7e562baa5b4d2917c23b1be6d91961c
| Private key     |  0x0333803103001800039980190300d206608b0070db0012135bd1fb5f6282170b
| Public key      |  0x04486e2308ef3513531042acb8ead377b887af16bd4cdd8149812dfef1ba924d

| Account address |  0x01d98d835e43b032254ffbef0f150c5606fa9c5c9310b1fae370ab956a7919f5
| Private key     |  0x07ca856005bee0329def368d34a6711b2d95b09ef9740ebf2c7c7e3b16c1ca9c
| Public key      |  0x07006c42b1cfc8bd45710646a0bb3534b182e83c313c7bc88ecf33b53ba4bcbc
...

在我们与投票合约进行交互之前,我们需要在Starknet上准备选民和管理员账户。每个选民账户必须进行注册,并具备足够的资金进行投票。如果你想更详细地了解账户如何与账户抽象一起操作,请参考Starknet Book的 账户抽象 章节。

用于投票的智能钱包

除了Scarb之外,你还需要安装Starkli。Starkli是一个命令行工具,允许你与Starknet进行交互。你可以在Starknet Book的 环境初始化 章节中找到安装说明。

对于我们将使用的每个智能钱包,我们必须在加密的密钥库中创建一个签名者和一个账户描述符。这个过程也在Starknet Book的环境初始化 章节中详细介绍了。

我们可以为要用于投票的账户创建签名者和账户描述符。让我们在智能合约中创建一个用于投票的智能钱包。

首先,我们用私钥创建一个签名者:

starkli signer keystore from-key ~/.starkli-wallets/deployer/account0_keystore.json

然后,我们通过获取我们要使用的katana账户来创建账户描述符:

starkli account fetch <KATANA ACCOUNT ADDRESS> --rpc http://0.0.0.0:5050 --output ~/.starkli-wallets/deployer/account0_account.json

这个命令将创建一个新的 account0_account.json 文件,其中包含以下细节:

{
  "version": 1,
  "variant": {
        "type": "open_zeppelin",
        "version": 1,
        "public_key": "<SMART_WALLET_PUBLIC_KEY>"
  },
    "deployment": {
        "status": "deployed",
        "class_hash": "<SMART_WALLET_CLASS_HASH>",
        "address": "<SMART_WALLET_ADDRESS>"
  }
}

你可以用以下命令获取智能钱包的 class hash(所有智能钱包的class hash都一样)。注意使用了 --rpc 标志和 katana 提供的 RPC 端点:

starkli class-hash-at <SMART_WALLET_ADDRESS> --rpc http://0.0.0.0:5050

要获取公钥,可以使用 starkli signer keystore inspect 命令,并输入 keystore json 文件的目录:

starkli signer keystore inspect ~/.starkli-wallets/deployer/account0_keystore.json

如果您想拥有第二个和第三个投票人,用同样的过程对 account_1account_2 进行操作即可。

合约部署

在部署之前,我们需要声明合约。我们可以使用 starkli declare 命令来完成这项工作:

starkli declare target/dev/starknetbook_chapter_2_Vote.sierra.json --rpc http://0.0.0.0:5050 --account ~/.starkli-wallets/deployer/account0_account.json --keystore ~/.starkli-wallets/deployer/account0_keystore.json

如果你使用的编译器版本旧于Starkli使用的版本,并且在使用上述命令时遇到了 compiler-version 错误,你可以通过在命令中添加 --compiler-version x.y.z 标志来指定要使用的编译器版本。

如果你仍然遇到编译器版本的问题,请尝试使用以下命令来升级 Starkli:starkliup,以确保你正在使用 starkli 的最新版本。

合约的的class hash是0x06974677a079b7edfadcd70aa4d12aac0263a4cda379009fca125e0ab1a9ba52.您可以在 [任何区块浏览器] (https://goerli.voyager.online/class/0x06974677a079b7edfadcd70aa4d12aac0263a4cda379009fca125e0ab1a9ba52)中看到他。

--rpc标志指定要使用的 RPC 端点(由 katana提供)。--account 标志指定用于签署交易的账户。这里使用的账户是上一步创建的账户。 --keystore标记用于指定签署交易的密钥存储文件。

由于我们使用的是本地节点,因此交易将立即完成。如果您使用的是 Goerli Testnet,则需要等待交易最终完成,这通常需要几秒钟。

以下命令将部署投票合约,并将 voter_0、voter_1 和 voter_2 注册为合格投票人。这些是构造函数参数,因此请添加一个以后可以用来投票的选民账户。

starkli deploy <class_hash_of_the_contract_to_be_deployed> <voter_0_address> <voter_1_address> <voter_2_address> --rpc http://0.0.0.0:5050 --account ~/.starkli-wallets/deployer/account0_account.json --keystore ~/.starkli-wallets/deployer/account0_keystore.json

命令示例:

starkli deploy 0x06974677a079b7edfadcd70aa4d12aac0263a4cda379009fca125e0ab1a9ba52 0x03ee9e18edc71a6df30ac3aca2e0b02a198fbce19b7480a63a0d71cbd76652e0 0x033c627a3e5213790e246a917770ce23d7e562baa5b4d2917c23b1be6d91961c 0x01d98d835e43b032254ffbef0f150c5606fa9c5c9310b1fae370ab956a7919f5 --rpc http://0.0.0.0:5050 --account ~/.starkli-wallets/deployer/account0_account.json --keystore ~/.starkli-wallets/deployer/account0_keystore.json

在本例中,合约已部署到特定地址:0x05ea3a690be71c7fcd83945517f82e8861a97d42fca8ec9a2c46831d11f33349。您应该会看到不同的地址。我们将使用此地址与合约进行交互。

投票人资格验证

在我们的投票合约中,我们有两个函数来验证投票人的资格,即 voter_can_voteis_voter_registered。这些函数是外部只读函数,这意味着它们不会改变合约的状态,而只是读取当前状态。

is_voter_registered函数检查特定地址是否在合约中登记为合格投票人。另一方面,voter_can_vote函数会检查特定地址的投票人当前是否有资格投票,即他们是否已登记且尚未投票。

你可以使用 starkli call 命令来调用这些函数。请注意,call命令用于只读函数,而 invoke命令用于也可以写入存储空间的函数。调用 call 命令不需要签名,而 invoke命令需要签名。

starkli call 0x05ea3a690be71c7fcd83945517f82e8861a97d42fca8ec9a2c46831d11f33349 voter_can_vote 0x03ee9e18edc71a6df30ac3aca2e0b02a198fbce19b7480a63a0d71cbd76652e0 --rpc http://0.0.0.0:5050

首先,我们添加了合约的地址,然后是要调用的函数,最后是函数的输入。在本例中,我们要检查地址为 0x03ee9e18edc71a6df30ac3aca2e0b02a198fbce19b7480a63a0d71cbd76652e0 的投票人是否可以投票。

由于我们提供了已登记的投票人的地址作为输入,因此结果为 1(布尔值为 true),表明该选民有资格投票。

接下来,让我们使用一个未注册的账户地址调用 is_voter_registered 函数来观察输出结果:

starkli call 0x05ea3a690be71c7fcd83945517f82e8861a97d42fca8ec9a2c46831d11f33349 is_voter_registered 0x44444444444444444 --rpc http://0.0.0.0:5050

对于未注册的账户地址,终端输出为 0(即假),确认该账户没有投票资格。

投票

既然我们已经确定了如何验证选民资格,那么我们就可以投票了!投票时,我们要与vote函数交互,该函数被标记为外部函数,因此必须使用 starknet invoke命令。

invoke命令的语法与 call 命令类似,但在投票时,我们需要输入 "1"(表示 "是")或 "0"(表示 "否")。当我们唤起 vote 函数时,我们会被收取一定的费用,而且交易必须由投票人签署;我们正在向合约的存储空间写入内容。

//Voting Yes
starkli invoke 0x05ea3a690be71c7fcd83945517f82e8861a97d42fca8ec9a2c46831d11f33349 vote 1 --rpc http://0.0.0.0:5050 --account ~/.starkli-wallets/deployer/account0_account.json --keystore ~/.starkli-wallets/deployer/account0_keystore.json

//Voting No
starkli invoke 0x05ea3a690be71c7fcd83945517f82e8861a97d42fca8ec9a2c46831d11f33349 vote 0 --rpc http://0.0.0.0:5050 --account ~/.starkli-wallets/deployer/account0_account.json --keystore ~/.starkli-wallets/deployer/account0_keystore.json

系统将提示您输入签名者的密码。输入密码后,交易将被签署并提交到Starknet网络。你将收到交易哈希值作为输出。使用 starkli 交易命令,你可以获得更多关于交易的详细信息:

starkli transaction <TRANSACTION_HASH> --rpc http://0.0.0.0:5050

这个会返回:

{
  "transaction_hash": "0x5604a97922b6811060e70ed0b40959ea9e20c726220b526ec690de8923907fd",
  "max_fee": "0x430e81",
  "version": "0x1",
  "signature": [
    "0x75e5e4880d7a8301b35ff4a1ed1e3d72fffefa64bb6c306c314496e6e402d57",
    "0xbb6c459b395a535dcd00d8ab13d7ed71273da4a8e9c1f4afe9b9f4254a6f51"
  ],
  "nonce": "0x3",
  "type": "INVOKE",
  "sender_address": "0x3ee9e18edc71a6df30ac3aca2e0b02a198fbce19b7480a63a0d71cbd76652e0",
  "calldata": [
    "0x1",
    "0x5ea3a690be71c7fcd83945517f82e8861a97d42fca8ec9a2c46831d11f33349",
    "0x132bdf85fc8aa10ac3c22f02317f8f53d4b4f52235ed1eabb3a4cbbe08b5c41",
    "0x0",
    "0x1",
    "0x1",
    "0x1"
  ]
}

如果您尝试用同一个签名者投票两次,则会出现错误:

Error: code=ContractError, message="Contract error"

错误信息很简略,但你可以启动 katana(我们的本地Starknet节点)的终端中查看输出,获得更多细节:

...
Transaction execution error: "Error in the called contract (0x03ee9e18edc71a6df30ac3aca2e0b02a198fbce19b7480a63a0d71cbd76652e0):
    Error at pc=0:81:
    Got an exception while executing a hint: Custom Hint Error: Execution failed. Failure reason: \"USER_ALREADY_VOTED\".
    ...

错误的关键字是 USER_ALREADY_VOTED

assert(can_vote == true, 'USER_ALREADY_VOTED');

我们可以重复上述过程,为要将用于投票的账户创建签名者和账户描述符。请记住,每个签名者都必须用私钥创建,每个账户描述符都必须用公钥、智能钱包地址和智能钱包class hash (每个投票者的class hash 都一样)创建。

starkli invoke 0x05ea3a690be71c7fcd83945517f82e8861a97d42fca8ec9a2c46831d11f33349 vote 0 --rpc http://0.0.0.0:5050 --account ~/.starkli-wallets/deployer/account1_account.json --keystore ~/.starkli-wallets/deployer/account1_keystore.json

starkli invoke 0x05ea3a690be71c7fcd83945517f82e8861a97d42fca8ec9a2c46831d11f33349 vote 1 --rpc http://0.0.0.0:5050 --account ~/.starkli-wallets/deployer/account2_account.json --keystore ~/.starkli-wallets/deployer/account2_keystore.json

投票结果可视化

为了检查投票结果,我们通过 starknet call命令调用另一个视图函数 get_vote_status

starkli call 0x05ea3a690be71c7fcd83945517f82e8861a97d42fca8ec9a2c46831d11f33349 get_vote_status --rpc http://0.0.0.0:5050

输出结果显示了 "Yes"和 "No" 票数及其相对百分比。

Last change: 2023-12-03, commit: 462ec1a

L1-L2 间信息传递

Layer 2的一个重要特征是它能与Layer 1交互。

Starknet拥有自己的 L1-L2 消息传递系统,它与其共识机制和L1上状态更新的提交方式不同。消息传递是L1上的智能合约与L2上的智能合约(或反之亦然)进行交互的一种方式,允许我们进行”跨链”交易。例如,我们可以在一个链上进行一些计算,并在另一个链上使用这个计算的结果。

在Starknet上,所有的桥接都使用 L1-L2 消息传递。假设你想要将以太坊上的代币桥接到Starknet上。你只需要将代币存入L1桥接合约,这将自动触发在L2上铸造相同代币。L1-L2 消息传递的另一个很好的用例是DeFi池化

在Starknet上,重要的是要注意消息系统是异步非对称

  • 异步性:这意味着在你的合约代码(无论是Solidity还是Cairo)中,在合约代码执行期间,你不能等待另一个链上发送消息的结果。
  • 非对称性:从以太坊发送消息到Starknet(L1->L2)是由Starknet序列器完全自动化的,这意味着消息会自动传递到L2上的目标合约。然而,当从Starknet发送消息到以太坊(L2->L1)时,Starknet序列器仅在L1上发送消息的哈希。然后,你必须通过L1上的交易来手动消耗该消息。

让我们来详细了解一下。

Starknet消息传递合约

L1-L2 消息传递系统的关键组件是StarknetCore合约。它是部署在以太坊上的一组Solidity合约,用于使Starknet正常运行。StarknetCore 的合约之一被称为StarknetMessaging,它负责在Starknet和以太坊之间传递消息。StarknetMessaging遵循一个接口,其中包含了一些函数,允许向L2发送消息,在L1上从L2接收消息以及取消消息。

interface IStarknetMessaging is IStarknetMessagingEvents {

    function sendMessageToL2(
        uint256 toAddress,
        uint256 selector,
        uint256[] calldata payload
    ) external returns (bytes32);

    function consumeMessageFromL2(uint256 fromAddress, uint256[] calldata payload)
        external
        returns (bytes32);

    function startL1ToL2MessageCancellation(
        uint256 toAddress,
        uint256 selector,
        uint256[] calldata payload,
        uint256 nonce
    ) external;

    function cancelL1ToL2Message(
        uint256 toAddress,
        uint256 selector,
        uint256[] calldata payload,
        uint256 nonce
    ) external;
}

Starknet消息传送合约接口

对于 L1->L2 的消息,Starknet序列器不断监听以太坊上的 StarknetMessaging 合约发出的日志。 一旦在日志中检测到消息,序列器会准备并执行 L1HandlerTransaction,以调用目标L2合约上的函数。这个过程可能需要1-2分钟(几秒钟用于挖掘以太坊区块,然后序列器必须构建并执行事务)。

L2->L1 的消息是由在L2上执行的合约准备的,并且是生成的区块的一部分。当序列器生成一个区块时,序列器将把合约执行准备的每个消息的哈希发送到L1上的 StarknetCore 合约,一旦它们所属的区块在以太坊上被证明和验证(目前大约需要3-4小时),这些消息就可以被消耗。

从以太坊向Starknet发送信息

如果你想从 Ethereum 向 Starknet 发送消息,你的 Solidity 合约必须调用 StarknetMessaging 合约的 sendMessageToL2 函数。要在 Starknet 上接收这些消息,你需要用 #[l1_handler] 属性注解可从 L1 调用的函数。

让我们看一个简单的合约,来自这个教程 我们希望向Starknet发送一条消息。 _snMessaging 是一个已经初始化为 StarknetMessaging 合约地址的状态变量。你可以在这里检查这些地址。

// Sends a message on Starknet with a single felt.
function sendMessageFelt(
    uint256 contractAddress,
    uint256 selector,
    uint256 myFelt
)
    external
    payable
{
    // We "serialize" here the felt into a payload, which is an array of uint256.
    uint256[] memory payload = new uint256[](1);
    payload[0] = myFelt;

    // msg.value must always be >= 20_000 wei.
    _snMessaging.sendMessageToL2{value: msg.value}(
        contractAddress,
        selector,
        payload
    );
}

该函数向 StarknetMessaging 合约发送一个包含单个 felt 值的消息。 请注意,如果你想发送更复杂的数据,可以这样做。只是要注意,你的 Cairo 合约只能理解 felt252 数据类型。因此,你必须确保将数据序列化为 uint256 数组时,要按照 Cairo 的序列化方案进行操作。

这里需要注意的是我们有 {value: msg.value}。实际上,我们在这里必须发送的最小值是 20,000 wei ,因为 StarknetMessaging 合约会在以太坊的存储中注册我们消息的哈希。

除了这 20,000 wei 之外,由于由序列器执行的 L1HandlerTransaction 不绑定到任何账户(消息源自 L1),你还必须确保在 L1 上支付足够的费用,以便你的消息在 L2 上被反序列化和处理。

L1HandlerTransaction 的费用计算方式与 Invoke 交易的计算方式相同。为此,你可以使用 starklysnforge 对gas消耗进行分析,估算你的消息执行成本。

sendMessageToL2的签名是:

function sendMessageToL2(
        uint256 toAddress,
        uint256 selector,
        uint256[] calldata payload
    ) external override returns (bytes32);

参数如下:

  • toAddress : 在 L2 上将被调用的合约地址。
  • selector : 位于 toAddress 上此合约函数的选择器。这个选择器(函数)必须具有 #[l1_handler] 属性以便被调用。
  • payload : payload 始终是一个 felt252 数组(在 Solidity 中由 uint256 表示)。因此,我们将输入 myFelt 插入到数组中。这就是为什么我们需要将输入数据插入到数组中的原因。

在Starknet方面,要接收这条信息,我们需要:

#![allow(unused)]
fn main() {
    #[l1_handler]
    fn msg_handler_felt(ref self: ContractState, from_address: felt252, my_felt: felt252) {
        assert(from_address == self.allowed_message_sender.read(), 'Invalid message sender');

        // You can now use the data, automatically deserialized from the message payload.
        assert(my_felt == 123, 'Invalid value');
    }
}

我们需要为我们的函数添加 #[l1_handler] 属性。L1处理器是一种特殊的函数,只能由L1HandlerTransaction 执行。接收来自L1的事务时不需要特别处理,因为消息会自动由顺序器中继。在你的 #[l1_handler] 函数中,重要的是要验证L1消息的发送者,以确保我们的合约只能接收来自受信任的L1合约的消息。

从Starknet向以太坊发送信息

当从Starknet发送消息到以太坊时,你将需要在Cairo合约中使用 send_message_to_l1 系统调用。该系统调用允许您向L1上的 StarknetMessaging 合约发送消息。与 L1->L2 消息不同的是,L2->L1 消息必须手动消耗,这意味着你的Solidity合约需要显式调用 StarknetMessaging 合约的 consumeMessageFromL2 函数来消耗消息。

要从 L2 向 L1 发送信息,我们在Starknet上要做的是:

#![allow(unused)]
fn main() {
        fn send_message_felt(ref self: ContractState, to_address: EthAddress, my_felt: felt252) {
            // Note here, we "serialize" my_felt, as the payload must be
            // a `Span<felt252>`.
            starknet::send_message_to_l1_syscall(to_address.into(), array![my_felt].span())
                .unwrap();
        }
}

我们只需构建payload,并将其与 L1 合约地址一起传递给系统调用函数。

在L1上,重要的部分是构建与L2上相同的payload。然后,通过传递L2合约地址和payload来调用 consumeMessageFromL2。 请注意,consumeMessageFromL2 期望的L2合约地址是在L2上发送交易的账户的合约地址,而不是执行 send_message_to_l1_syscall 的合约的地址。

function consumeMessageFelt(
    uint256 fromAddress,
    uint256[] calldata payload
)
    external
{
    let messageHash = _snMessaging.consumeMessageFromL2(fromAddress, payload);

    // You can use the message hash if you want here.

    // We expect the payload to contain only a felt252 value (which is a uint256 in solidity).
    require(payload.length == 1, "Invalid payload");

    uint256 my_felt = payload[0];

    // From here, you can safely use `my_felt` as the message has been verified by StarknetMessaging.
    require(my_felt > 0, "Invalid value");
}

正如你所看到的,在这个上下文中,我们无需验证L2中的哪个合约正在发送消息。但实际上,我们使用 consumeMessageFromL2 来验证输入(在L2上的发送者地址和payload),以确保我们只处理有效的消息。

重要的是要记住,在L1上我们发送的有效载荷是 uint256,但是在Starknet上的基本数据类型是 felt252;然而,felt252uint256 大约小4位。因此,我们必须注意我们发送的消息有效载荷中包含的值。如果在L1上构建的消息的值超过了最大的felt252,该消息将被卡住,无法在L2上被消耗。

Cairo Serde

在L1和L2之间发送消息之前,你必须记住,用Cairo编写的Starknet合约只能理解序列化数据。而序列化数据始终是一个felt252数组。 在Solidity中,我们有 uint256 类型,而 felt252uint256 大约小4位。因此,我们必须注意我们发送的消息有效载荷中包含的值。 如果在L1上构建的消息的值超过了最大的 felt252,该消息将被卡住,无法在L2上被消耗。

例如,在Cairo中,一个实际的 uint256 值表示为类似以下的结构:

#![allow(unused)]
fn main() {
struct u256 {
    low: u128,
    high: u128,
}
}

这将被序列化为两个 felts ,一个用于 low ,另一个用于 high 。这意味着要向Cairo发送一个 u256,你需要从L1发送一个包含两个值的 payload。

uint256[] memory payload = new uint256[](2);
// Let's send the value 1 as a u256 in cairo: low = 1, high = 0.
payload[0] = 1;
payload[1] = 0;

如果你想了解更多关于消息机制的信息,可以访问 Starknet 文档.

你也可以在这里找到详细的教程 来在本地测试消息传递。

Last change: 2023-11-22, commit: 93e82f9

安全考量

在开发软件时,确保其按预期运行通常比较简单而直接。然而,防止非预期的使用和漏洞可能更具挑战性。

在智能合约的开发中,安全性非常重要。仅仅一个简单的错误就可能导致宝贵资产的损失或某些功能的错误运行。

智能合约在一个公开的环境中执行,任何人都可以检查代码并与之交互。代码中的任何错误或漏洞都可能被恶意行为者利用。

本章介绍了编写安全智能合约的一般建议。在开发过程中,通过融入这些概念,你可以创建健壮可靠的智能合约。这将减少出现意外行为或漏洞的机会。

免责声明

本章并未提供所有可能的安全问题的详尽列表,也不能保证您的合约完全安全。

如果您正在开发用于实际生产环境中的智能合约,强烈建议由安全专家对其进行第三方审计。

安全思维

Cairo 是一种受到 Rust 启发的高度安全的语言。它的设计方式强制你覆盖所有可能的情况。在 Starknet 上的安全问题主要源于智能合约流程的设计,而非语言本身。

采用安全思维是编写安全智能合约的第一步。在编写代码时,尽量始终考虑所有可能的场景。

将智能合约视为有限状态机

智能合约中的交易是原子性的,这意味着它们要么成功,要么失败且不发生任何变化。

将智能合约视为状态机:它们有一组由构造函数约束定义的初始状态,而外部函数表示一组可能的状态转换。所谓交易也不过是一个状态转换。

assertpanic 函数可以用于在执行特定操作之前验证条件。您可以在无法恢复的错误与panic页面上了解更多信息。

这些验证会包括:

  • 调用者提供的输入参数
  • 执行要求
  • 不变量(必须始终为真的条件)
  • 其他函数调用的返回值

例如,您可以使用 assert 函数来验证用户是否具有足够的资金执行提款交易。如果条件不满足,交易将失败,并且合约的状态不会发生变化。

    impl Contract of IContract<ContractState> {
        fn withdraw(ref self: ContractState, amount: u256) {
            let current_balance = self.balance.read();

            assert(self.balance.read() >= amount, 'Insufficient funds');

            self.balance.write(current_balance - amount);
        }

使用这些函数来进行条件检查,添加一���有助于清晰地定义智能合约中每个函数可能的状态转换的边界的约束。这些检查确保合约的行为保持在预期范围内。

建议

检查-效果-交互 模式

检查-效果-交互模式是一种常见的设计模式,用于防止以太坊上的重入攻击。尽管在 Starknet 上更难实现重入攻击,但仍建议在智能合约中使用这种模式。

该模式由在函数中按照特定的操作顺序进行操作来实现:

  1. Checks(检查): 在执行任何状态更改之前,验证所有条件和输入。
  2. Effects(效果): 执行所有状态更改。
  3. Interactions(交互): 所有对其他合约的外部调用应在函数的最后进行。

访问控制

访问控制是限制对特定功能或资源的访问的过程。它是一种常见的安全机制,用于防止未经授权的敏感信息访问或操作。在智能合约中,某些函数可能经常需要被限制为特定的用户或角色使用。

您可以使用访问控制模式来轻松管理权限。该模式包括定义一组不同权限角色,并给不同用户分配对应的角色。每个函数都可限制为特定的角色才可访问。

#[starknet::contract]
mod access_control_contract {
    use starknet::ContractAddress;
    use starknet::get_caller_address;

    trait IContract<TContractState> {
        fn is_owner(self: @TContractState) -> bool;
        fn is_role_a(self: @TContractState) -> bool;
        fn only_owner(self: @TContractState);
        fn only_role_a(self: @TContractState);
        fn only_allowed(self: @TContractState);
        fn set_role_a(ref self: TContractState, _target: ContractAddress, _active: bool);
        fn role_a_action(ref self: ContractState);
        fn allowed_action(ref self: ContractState);
    }

    #[storage]
    struct Storage {
        // Role 'owner': only one address
        owner: ContractAddress,
        // Role 'role_a': a set of addresses
        role_a: LegacyMap::<ContractAddress, bool>
    }

    #[constructor]
    fn constructor(ref self: ContractState) {
        self.owner.write(get_caller_address());
    }

    // Guard functions to check roles

    impl Contract of IContract<ContractState> {
        #[inline(always)]
        fn is_owner(self: @ContractState) -> bool {
            self.owner.read() == get_caller_address()
        }

        #[inline(always)]
        fn is_role_a(self: @ContractState) -> bool {
            self.role_a.read(get_caller_address())
        }

        #[inline(always)]
        fn only_owner(self: @ContractState) {
            assert(Contract::is_owner(self), 'Not owner');
        }

        #[inline(always)]
        fn only_role_a(self: @ContractState) {
            assert(Contract::is_role_a(self), 'Not role A');
        }

        // You can easily combine guards to perform complex checks
        fn only_allowed(self: @ContractState) {
            assert(Contract::is_owner(self) || Contract::is_role_a(self), 'Not allowed');
        }

        // Functions to manage roles

        fn set_role_a(ref self: ContractState, _target: ContractAddress, _active: bool) {
            Contract::only_owner(@self);
            self.role_a.write(_target, _active);
        }

        // You can now focus on the business logic of your contract
        // and reduce the complexity of your code by using guard functions

        fn role_a_action(ref self: ContractState) {
            Contract::only_role_a(@self);
        // ...
        }

        fn allowed_action(ref self: ContractState) {
            Contract::only_allowed(@self);
        // ...
        }
    }
}

静态分析工具

静态分析指的是在不执行代码的情况下对其进行检查的过程,重点关注其结构、语法和属性。它涉及到分析源代码,以识别潜在的问题、漏洞或违反特定规则的行为。

通过定义规则,例如编码规范或安全指南,开发人员可以使用静态分析工具自动检查代码是否符合这些标准。

参考文献:

Last change: 2023-11-08, commit: 2e0e51b

附录

以下章节包含的参考资料可能对您的Cairo之旅有所帮助。

Last change: 2023-04-01, commit: 2a5d272

附录 A:关键字

下面的列表包含了为当前或未来保留的Cairo 语言的关键字。

有两个关键字类别:

  • 严格(strict)关键字
  • 保留(reserved)关键字

还有第三类,是来自核心库的函数。虽然它们并不是保留关键字,但以遵循惯例不建议将其用作任何项的标识符。


严格关键字

这些关键词只能在其应该被使用的上下文中使用。 因此,这些关键字不能被用作标识符。

  • as - 重命名导入
  • break - 立即退出一个循环
  • const - 定义常量项
  • continue - 继续进行下一个循环迭代
  • else - ifif let控制流结构的 fallback
  • enum - 定义一个枚举项
  • extern - 于编译器层级使用此声明来表示此函数可使用Cairo1等级的hint
  • false - 布尔字面值假
  • fn - 定义一个函数
  • if - 基于条件表达式的结果分支
  • impl - 实现自有或 ‘trait’ 功能
  • implicits - 执行某些动作所需的特殊类型的函数参数
  • let - 绑定一个变量
  • loop -无条件地循环
  • match - 模式匹配
  • mod - 定义一个模块
  • mut - 表示变量的可变性
  • nopanic - 用这个符号标记的函数意味着该函数永远不会panic
  • of - 实现trait
  • ref- 通过引用绑定
  • return - 从函数返回
  • struct - 定义一个结构体
  • trait - 定义一个trait
  • true - 布尔字面值真
  • type - 定义一个类型的别名
  • use - 引入外部空间的符号

保留关键字

这些关键字还没有被使用,但它们被保留下来供将来使用。 它们与严格关键字有同样的限制。 禁止使用这些关键字的原因是为了可以使现在的程序向前兼容新版本的Cairo语言。

  • Self
  • assert
  • do
  • dyn
  • for
  • hint
  • in
  • macro
  • move
  • pub
  • static_assert
  • self
  • static
  • super
  • try
  • typeof
  • unsafe
  • where
  • while
  • with
  • yield

内置函数

Cairo编程语言提供了���个具有特殊用途的函数。我们不会在本书中介绍所有这些函数,但不建议使用这些函数的名称作为任何项的标识符。

-assert - 这个函数检查一个布尔表达式,如果它的值是假的,就会触发panic函数。 -panic - 这个函数终止程序。

Last change: 2023-09-18, commit: e5dde81

附录B:运算符和符号

本附录包含了Cairo语法的词汇表。

运算符

表B-1包含了开罗的运算符,运算符在上下文中出现的例子和简短的解释,以及该运算符是否可以重载。如果一个运算符是可重载的,则列出了用于重载该运算符的相关特性。

表B-1:运算符

运算符示例解释是否可重载?
!!expr位或逻辑补码Not
!=expr != expr不等于PartialEq
%expr % expr算数取余Rem
%=var %= expr算数取余并赋值RemEq
&expr & expr按位与BitAnd
&&expr && expr短路逻辑与
*expr * expr算数乘Mul
*=var *= expr算数乘并赋值MulEq
@@varSnapshot
**varDesnap
+expr + expr算术加Add
+=var += expr算数加并赋值AddEq
,expr, expr参数和元素分隔符
--expr算数负号Neg
-expr - expr算数减Sub
-=var -= expr算数减并赋值SubEq
->fn(...) -> type, |...| -> type函数与闭包的返回类型
.expr.ident成员访问
/expr / expr算数除Div
/=var /= expr算数除并赋值DivEq
:pat: type, ident: type约束条件
:ident: expr结构体字段初始化器
;expr;语句和条目结束符号
<expr < expr小于比较PartialOrd
<=expr <= expr小于等于比较PartialOrd
=var = expr赋值
==expr == expr等于比较PartialEq
=>pat => expr匹配分支的一部分语法
>expr > expr大于比较PartialOrd
>=expr >= expr大于等于比较PartialOrd
^expr ^ expr按位异或BitXor
|expr | expr按位或BitOr
||expr || expr短路逻辑或

非运算符符号

下面的列表包含了所有不作为运算符使用的符号;也就是说,他们并不像函数调用或方法调用一样表现。

表B-2 展示了以其自身出现以及出现在合法其他各个地方的符号。

表B-2:独立语法

符号解释
..._u8, ..._usize, 等等。指定类型的数值常量
'...'短字符串
_"“忽略” 模式绑定;也用于增强整型字面值的可读性

表B-3 展示了出现在从模块结构到项的路径上下文中的符号。

表B-3:路径相关语法

符号解释
ident::ident命名空间路径
super::path相对于当前模块的父级路径。
trait::method(...)通过命名定义该方法的trait来消除方法调用的二义性

表B-4 展示了出现在泛型类型参数上下文中的符号。

表 B-4:泛型

符号解释
path<...>为一个类型中的泛型指定具体参数(例如,Vec<u8>
path::<...>, method::<...>为一个泛型、函数或表达式中的方法指定具体参数; 通常被称为Turbofish。
fn ident<...>...泛型函数定义
struct ident<...>...泛型结构体定义
enum ident<...>...泛型枚举定义
impl<...>...定义泛型实现

表B-5展示了在调用或定义宏以及在其上指定属性时的上下文中出现的符号。

表B-5:宏和属性

符号解释
#[meta]外部属性

表B-6 展示了创建注释的符号。

表B-6:注释

符号解释
//行注释

表B-7 展示了出现在使用元组时上下文中的符号。

表B-7:元组

符号解释
()空元组(又称单元),空元组(亦称单元),即是字面值也是类型
(expr)括号表达式
(expr,)单元素元组表达式
(type,)单元素元组类型
(expr, ...)元组表达式
(type, ...)元组类型
expr(expr, ...)函数调用表达式;也用于初始化元组struct和元组enum变体

表B-8展示了使用大括号的上下文。

表B-8:大括号

上下文解释
{...}块表达式
Type {...}struct字面值
Last change: 2023-07-20, commit: e127cf5

附录C: 可派生的 Trait

在本书的各个部分中,我们讨论了可应用于结构体和枚举定义的 derive 属性。derive 属性会在使用 derive 语法标记的类型上生成对应 trait 的默认实现的代码。

在这个附录中,我们提供了一个全面的参考,详细介绍了标准库中所有与derive属性兼容的trait。

这里列出的 trait 是仅有的在标准库中定义且能通过 derive 在类型上实现。标准库中定义的其它 trait 不能通过 derive 在类型上实现。这些 trait 不存在有意义的默认行为,所以由你负责以合理的方式实现它们。

本附录所提供的可派生 trait 列表并不全面:库可以为其自己的 trait 实现 derive,可以使用 derive 的 trait 列表事实上是无限的。

等值比较的 PartialEq 和 Eq

PartialEq trait允许在一个类型的实例之间进行等值比较,从而实现 == 和 != 运算符。

PartialEq在结构上派生时,只有当所有字段都相等时,两个实例才相等,如果任何字段不相等,实例就不相等。当在枚举上派生时,每一个成员都和其自身相等,且和其他成员都不相等。

例子:

#[derive(PartialEq, Drop)]
struct A {
    item: felt252
}

fn main() {
    let first_struct = A {
        item: 2
    };
    let second_struct = A {
        item: 2
    };
    assert(first_struct == second_struct, 'Structs are different');
}

复制值的 Clone 和 Copy

Clone trait 提供了明确创建一个值的深度拷贝的功能。

派生 Clone 实现了 clone 方法,其为整个的类型实现时,在类型的每一部分上调用了clone 方法。这意味着类型中所有字段或值也必须实现了 Clone,这样才能够派生 Clone

例子:

use clone::Clone;

#[derive(Clone, Drop)]
struct A {
    item: felt252
}

fn main() {
    let first_struct = A {
        item: 2
    };
    let second_struct = first_struct.clone();
    assert(second_struct.item == 2, 'Not equal');
}

Copy trait 允许你复制值而不需要额外的代码。你可以在任何部分都实现了Copy的类型上派生Copy

例子:

#[derive(Copy, Drop)]
struct A {
    item: felt252
}

fn main() {
    let first_struct = A {
        item: 2
    };
    let second_struct = first_struct;
    assert(second_struct.item == 2, 'Not equal');
    assert(first_struct.item == 2, 'Not Equal'); // Copy Trait prevents firs_struct from moving into second_struct
}

用Serde进行序列化

Serde为你的crate中定义的数据结构提供serializedeserialize函数的trait实现。它允许你将你的结构体转化为数组(或相反)。

例子:

use serde::Serde;
use array::ArrayTrait;

#[derive(Serde, Drop)]
struct A {
    item_one: felt252,
    item_two: felt252,
}

fn main() {
    let first_struct = A {
        item_one: 2,
        item_two: 99,
    };
    let mut output_array = ArrayTrait::new();
    let serialized = first_struct.serialize(ref output_array);
    panic(output_array);
}

输出:

Run panicked with [2 (''), 99 ('c'), ].

我们在这里可以看到,我们的结构体A已经被序列化到输出数组中。

另外,我们可以使用deserialize函数将序列化的数组转换回我们的结构体A。

例子:

use serde::Serde;
use array::ArrayTrait;
use option::OptionTrait;

#[derive(Serde, Drop)]
struct A {
    item_one: felt252,
    item_two: felt252,
}

fn main() {
    let first_struct = A {
        item_one: 2,
        item_two: 99,
    };
    let mut output_array = ArrayTrait::new();
    let mut serialized = first_struct.serialize(ref output_array);
    let mut span_array = output_array.span();
    let deserialized_struct: A = Serde::<A>::deserialize(ref span_array).unwrap();
}

这里我们要把一个序列化的数组span转换回结构体A。deserialize返回一个Option,所以我们需要把它解包。当使用deserialize时,我们还需要指定我们想要反序列化的类型。

Drop 和 Destruct

当离开作用域时,需要先移动变量。这就是 Drop trait起作用的地方。你可以在这里找到更多关于它的用法的细节。

此外,字典在离开作用域之前需要被squash(压缩)。在每个字典上手动调用squash方法很快就不再是必须操作。Destruct 特性允许字典在超出范围时被自动压缩。你也可以在这里 找到更多关于Destruct的信息。

存储

在Starknet合约中的存储变量中存储用户定义的结构体需要为该类型实现 Store trait 。您可以为所有不包含字典或数组等复杂类型的结构体自动派生 Store trait 。

例子:

#[starknet::contract]
mod contract {
    #[derive(Drop, starknet::Store)]
    struct A {
        item_one: felt252,
        item_two: felt252,
    }

    #[storage]
    struct Storage {
        my_storage: A,
    }
}

在这里,我们演示了一个派生了Store trait的struct A的实现。这个 struct A随后被用作合约中的存储变量。

用于排序比较的PartialOrd和Ord

除了 PartialEq trait,标准库还提供了 PartialOrdOrd trait,用于值排序中的比较。

PartialOrdtrait允许在一个类型的实例之间进行排序比较,从而使得我们可以使用 <、<=、> 和 >= 操作符。

当在结构体上派生 PartialOrd 时,两个实例通过依次比较每个字段来排序。

Last change: 2023-09-20, commit: cbb0049

附录D - 实用开发工具

在本附录中,我们将提到由Cairo项目提供的一些有用的开发工具。 我们将看看自动格式化、快速应用警告修正,linter,以及与IDE的整合。

scarb fmt自动格式化

Scarb 项目可以使用 scarb fmt 命令进行格式化。 如果直接使用 cairo 二进制文件,可以运行 cairo-format 代替。 在大多多人合作项目里,每个成员都会使用scarb fmt 以防止在编写Cairo时争论使用哪种代码风格。

要格式化任何Cairo项目,请输入以下命令:

使用cairo-language-server的IDE集成

为了帮助IDE整合,Cairo社区建议使用 cairo-language-server。这是 Language Server Protocol的一套以编译器为中心的实用工具。 它是用于IDE和编程语言互相通信的规范。不同的客户端都可以使用cairo-language-server,例如 Visual Studio Code的Cairo扩展

请访问 vscode-cairo page 将其安装到 VSCode 上。您将获得自动完成、跳转到 定义和内联错误等功能。

注意:如果你已安装 Scarb,则无需手动安装语言服务器,即可使用Cairo VSCode 扩展。

Last change: 2023-08-10, commit: a3bc10b

附录 E - 编写合约所需最常见的类型 和Trait以及Cairo Prelude

Prelude

Cairo预设是一个常用模块、函数、数据类型和特性的集合,它们会自动引入到Cairo crate中每个模块的作用域中,无需显式的导入语句。Cairo的预设提供了开发者开始编写Cairo程序和智能合约所需的基本构建块。

核心库预设定义在 corelib crate 的lib.cairo文件中,包含了Cairo的基本数据类型、traits、操作符和实用函数。其中包括:

  • 数据类型:felts、bools、arrays、dicts等。
  • Traits:用于算术、比较、序列化的行为。
  • 操作符:算术、逻辑、位运算符。
  • 实用函数:用于数组、映射、包装等的辅助函数。 核心库预设提供了基本的Cairo程序所需的基本编程构造和操作,无需显式导入元素。由于核心库预设被自动导入,其内容可在任何Cairo crate中使用,无需显式导入。这避免了重复工作,提供了更好的开发体验。这就是为什么你可以在不显式引入的情况下使用 ArrayTrait::append()Default 特性的原因。

常见类型和trait列表

以下部分简要概述了在开发Cairo程序时常用的类型和特性。大多数这些都包含在预设中,无需显式导入 - 但并非全部。

ImportPathUsage
OptionTraitcore::option::OptionTraitOptionTrait<T> 定义了一组操作可选值所需的方法.
ResultTraitcore::result::ResultTraitResultTrait<T, E> 是用于表示Starknet合约地址的类型,其取值范围为[0, 2 ** 251).
ContractAddressstarknet::ContractAddressContractAddress 是一种用于表示智能合约地址的类型
ContractAddressZeroablestarknet::contract_address::ContractAddressZeroableContractAddressZeroable 是对 ContractAddress 类型实现的Zeroable trait。它用于检查t:ContractAddress 的值是否为零。
contract_address_conststarknet::contract_address_constcontract_address_const! 是一个函数,允许实例化常量合约地址值。
Intotraits::Into;Into<T> 是用于类型转换的 trait。如果对类型T和S存在Into<T,S>的实现,那么可以将T转换为S
TryIntotraits::TryInto;TryInto<T> 是用于类型转换的 trait。如果对类型T和S存在TryInto<T,S>的实现,那么可以将T转换为S.
get_caller_addressstarknet::get_caller_addressget_caller_address() 是一个函数,用于返回调用合约的地址。它可以用于识别合约函数的调用者
get_contract_addressstarknet::info::get_contract_addressget_contract_address() 是一个函数,用于返回当前合约的地址。它可以用于获取正在执行的合约的地址.

这并不是一个详尽的列表,但它涵盖了合约开发中一些常用的类型和trait。关于更多的细节,请参考官方文档并参考可用的库以及框架。

Last change: 2023-11-19, commit: a15432b

附录 F:安装Cairo二进制文件

如果你想访问Cairo二进制文件,以获取任何仅使用 Scarb 无法实现的功能时,可以按照下面的说明安装它们。

第一步是安装Cairo。我们可以手动下载Cairo(使用Cairo仓库)或使用安装脚本。下载过程需要连接互联网。 译注:如果你生活在中国大陆,你可能需要一些特殊方法来保证能够顺利安装所有的依赖包。

先决条件

首先,你需要安装Rust和Git。

# Install stable Rust
rustup override set stable && rustup update

安装Git

用脚本安装Cairo(Installer by Fran)

安装

如果你想安装一个特定的Cairo版本,而不是最新版本,可以设置CAIRO_GIT_TAG环境变量(比如执行 export CAIRO_GIT_TAG=v2.2.0 来设置)。

curl -L https://github.com/franalgaba/cairo-installer/raw/main/bin/cairo-installer | bash

安装完毕后,按照说明来设置你的shell环境。

更新

rm -fr ~/.cairo
curl -L https://github.com/franalgaba/cairo-installer/raw/main/bin/cairo-installer | bash

卸载

Cairo被安装在$CAIRO_ROOT(默认:~/.cairo)。要卸载它,只需删除它:

rm -fr ~/.cairo

然后从.bashrc中删除这三行:

export PATH=“$HOME/.cairo/target/release:$PATH”

最后,重新启动你的shell:

exec $SHELL

为Cairo设置你的shell环境

  • 定义环境变量CAIRO_ROOT,以指向Cairo存储自身数据的路径。默认为$HOME/.cairo。 如果你通过Git checkout安装Cairo,我们建议把它设置到与你Git clone它相同的位置。
  • cairo-*可执行文件添加到你的PATH中,如果还没被自动添加的话

下面的设置应该适用于绝大多数用户的一般使用情况。

  • 对于bash

各个发行版间的 Stock Bash 的启动文件在什么情况下调用什么样的文件,以什么顺序执行,并进行哪些额外的配置都存在很大的差异 因此,在所有环境中获得 Cairo 的最可靠方法是将 Cairo 配置命令附加到.bashrc(用于交互式shell)和Bash将使用的配置文件中。(用于登录shell)。

首先,通过在终端运行以下命令,将这些命令添加到~/.bashrc中:

echo 'export CAIRO_ROOT="$HOME/.cairo"' >> ~/.bashrc
echo 'command -v cairo-compile >/dev/null || export PATH="$CAIRO_ROOT/target/release:$PATH"' >> ~/.bashrc

然后,如果你有 ~/.profile~/.bash_profile~/.bash_login,也将这些命令添加到它们所对应的文件中。如果没有这些文件,则添加到 ~/.profile中。

  • 添加到 ~/.profile 中:

    echo ‘export CAIRO_ROOT=“$HOME/.cairo”’ >> ~/.profile
    echo ‘command -v cairo-compile >/dev/null || export PATH=“$CAIRO_ROOT/target/release:$PATH”’ >> ~/.profile
    
  • 添加到~/.bash_profile

    echo 'export CAIRO_ROOT="$HOME/.cairo"' >> ~/.bash_profile
    echo 'command -v cairo-compile >/dev/null || export PATH="$CAIRO_ROOT/target/release:$PATH"' >> ~/.bash_profile
    
  • 对于Zsh

    echo 'export CAIRO_ROOT="$HOME/.cairo"' >> ~/.zshrc
    echo 'command -v cairo-compile >/dev/null || export PATH="$CAIRO_ROOT/target/release:$PATH"' >> ~/.zshrc
    

    如果你希望在非交互式登录shell中也能得到Cairo,也可以将这些命令添加到~/.zprofile~/.zlogin

  • 对于Fish shell

    如果你有Fish 3.2.0或更新版本,请以交互方式执行:

    set -Ux CAIRO_ROOT $HOME/.cairo
    fish_add_path $CAIRO_ROOT/target/release
    

    否则,执行下面的片段:

    set -Ux CAIRO_ROOT $HOME/.cairo
    set -U fish_user_paths $CAIRO_ROOT/target/release $fish_user_paths
    

在 MacOS 中,你可能还想安装Fig。它为许多命令行工具提供了替 代性的 shell 补全功能,并在终端窗口有一个类似于 IDE 的弹出式界面。(注意,他们 的命令补全功能与Cairo的代码库无关,所以他们有可能有点跟不上Cairo命令行界面的更新)。

重新启动你的shell

以使 PATH的改变生效。

exec "$SHELL"

手动安装Cairo(指南Abdel提供)

第1步:安装Cairo 1.0

如果你使用的是 x86 Linux 系统,并且可以使用发布的二进制文件,请在这里下载Cairo:https://github.com/starkware-libs/cairo/releases

对于其他用户,我们建议从源码编译 Cairo,如下所示:

# Start by defining environment variable CAIRO_ROOT
export CAIRO_ROOT="${HOME}/.cairo"

# Create .cairo folder if it doesn't exist yet
mkdir $CAIRO_ROOT

# Clone the Cairo compiler in $CAIRO_ROOT (default root)
cd $CAIRO_ROOT && git clone git@github.com:starkware-libs/cairo.git .

# OPTIONAL/RECOMMENDED: If you want to install a specific version of the compiler
# Fetch all tags (versions)
git fetch --all --tags
# View tags (you can also do this in the cairo compiler repository)
git describe --tags `git rev-list --tags`
# Checkout the version you want
git checkout tags/v2.2.0

# Generate release binaries
cargo build --all --release

.

注意:保持Cairo已更新到最新版本

现在你的Cairo编译器已经在一个克隆的仓库里了,你所需要做的是拉取最新的修改, 并按如下方式重新编译:

cd $CAIRO_ROOT && git fetch && git pull && cargo build —all —release

第二步:将Cairo 1.0的可执行文件添加到你的路径中

export PATH="$CAIRO_ROOT/target/release:$PATH"

注意:如果你是在Linux编译的二进制文件,请相应调整目标路径

第三步:设置语言服务器

VS代码扩展

  • 如果你安装了以前的Cairo 0扩展,你���以禁用/卸载它。
  • 安装Cairo 1扩展以获得正确的语法高亮和代码导航。你可以通过这个链接下载扩展here,或者直接在VS Code市场上搜索 "Cairo 1.0"。
  • 一旦你安装了Scarb,该扩展就可以立即工作了。

Cairo语言服务器(不使用Scarb时)

如果你不想依赖Scarb,你仍然可以通过编译的二进制文件使用Cairo语言服务器。 在Step 1 中,cairo-language-server二进制文件应该已经编译完成,执行这个命令将复制其路径到你的剪贴板。

which cairo-language-server | pbcopy

将上面复制的路径更新到Cairo 1.0扩展的cairo1.languageServerPath中。

Last change: 2023-09-15, commit: c0f1233