跳到主要内容

02 Cairo

Starknet 中文训练营:Cairo 基础教程

在由 WTF Academy 举办的 Starknet 中文训练营(Starknet Basecamp Chinese)中,由我为大家讲解 Cairo 基础教程。通过本节学习,你可以对 Cairo 语言有一个大致的了解,并了解 ScarbStarklings 的安装配置,学习 Cairo 的编程概念、变量类型、控制流、数据集合、所有权等知识。在练习环节,我们将使用 Starklings 通过交互式的学习来进一步掌握 Cairo 语言。

一、Cairo 简介

Cairo 文档 中所言,Cairo 是第一个图灵完备的,可用于创建可证明的通用计算程序的语言。它是一种类似 Rust 的高级语言。与 Rust 一样,它旨在让开发人员轻松编写高效且安全的代码。(作者注:这里所讲的 Cairo 是指 Cairo 1.0 及之后的版本,其与初代的 Cairo 0 相比,语法上存在很大差异。

Cairo 1.0 还引入了 Sierra,这是一种新的中间表示形式,可确保每次 Cairo 运行都能得到验证。这使得 Cairo 1.0 特别适合在 Starknet 这样的无需许可的网络中使用,它可以提供强大的 DoS 保护和审查抵抗能力。您可以在此处阅读有关 Sierra 的更多信息。

简单来说,Cairo 就是一种编写 Starknet 智能合约 的高级语言,如同 Solidity 之于 Ethereum 智能合约。从实践中来看,如果你有 Rust 基础的话,将会比较容易上手 Cairo。不过如果你之前没接触过 Rust 也没关系,本文将尽量用最通俗易懂的语言来讲解 Cairo 。

二、环境配置

开发环境(Scarb)

编译型的语言需要编译器。正如 C 语言与其编译器 gcc , Rust 语言与其编译器 rustc 一样, Cairo 也有属于它的编译工具, 我们可以直接下载这些二进制程序,使用命令 cairo-run --single-file HellWorld.cairo来编译和运行源文件 HellWorld.cairo 。

但以上只是 Cairo 的编译及运行的最小环境,为了更方便地进行项目开发,本文更推荐使用 Scarb 作为代码构建及项目管理的工具:

其文档中所说,Scarb 是一款 Cairo 包管理器。可以方便地下载 Cairo 包的依赖项,编译 Cairo 程序 或 StarkNet 合约,并作为其他工具(例如 Starknet Foundry 或 IDE)处理代码的入口。 作者注:Scarb和 Cargo (用于 Rust 的构建工具和包管理器) 很相似。安装方式 如下 : (通过安装脚本进行安装,此方法仅适用于 macOS 和 Linux。如果你用的是 Windows10及以上系统,建议开启 WSL2 以获得 Linux 运行环境)

curl --proto '=https' --tlsv1.2 -sSf https://docs.swmansion.com/scarb/install.sh | sh

安装完毕后,通过命令

scarb new hello_world

就可以创建出一个名为 hello_world 的 Cairo 项目了。

注:以上示例创建了一个 Cairo 本地程序项目。如果项目要作为 Starknet 合约进行编译的话,需要在Scarb.toml中添加如下依赖:

[dependencies]
starknet = ">=2.1.0"

[[target.starknet-contract]]

想要了解更多的Scarb用法,可以查阅官网文档

还可以通过 Remix + Starknet 插件进行开发。这个组合的优点是十分便捷,无需本地安装,打开网页即可开始编程。

练习环境(Starklings)

为了更好地巩固和应用我们所学的内容,我们介绍一款可以交互练习 Cairo 和 Starknet 的工具: Starklings 并将其安装到本地,用来巩固我们所学的知识。

由于这款工具是用 Rust 编写的而且需要在本地编译运行,所以我们首先要安装 Rust 及其包管理器 Cargo :

curl https://sh.rustup.rs -sSf | sh -s

然后 Git Clone 这个项目,并进入 starklings-cairo1 目录下( 请确保你安装了 Git ):

git clone https://github.com/shramee/starklings-cairo1.git && cd starklings-cairo1

在 starklings-cairo1 目录下执行以下命令,以编译并运行 Starklings 。首次运行需要下载很多依赖,所以用时会比较长:

cargo run -r --bin starklings

编译完成后,就可以执行以下命令开始练习了:

cargo run -r --bin starklings watch

更多帮助说明可以查看 Starklings 主页。

三、Cairo 基础

0.初识 Cairo 程序和 Starknet 合约

先看一个最简单的 Cairo 程序:

use debug::PrintTrait;

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

再看一个简单的 Starknet 合约:

#[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()
}
}
}

可以发现, 常规的 Cairo 程序需要要有一个 main() 函数;而Starknet 合约则不需要 main() 函数,但却多了 #[storage] 一类的宏定义。(作者注:实质上, Starknet 合约就是一个 Cairo module)。

我们就先以 Cairo 程序作为学习切入点,了解 Cairo 的基本语法和数据类型,直到最终搭建起一个 Starknet 合约。

  • 小练习 可以用上面的 Cairo 程序覆盖掉 hello_world/src/lib.cairo里的内容,然后在 hello_world/ 目录下执行
scarb cairo-run

看看这个 Cairo 程序的运行结果。

1.变量与可变性

出于安全原因,与 Rust 类似,Cairo 中的变量默认是不可变的,一旦将值绑定到变量,就不能再更改。如果想要让变量的值可变,则需要加上 mut 关键字。

const TOTAL_SUPPLY: u32 = 1000; // 常量,必须注明类型如 u32
fn main() {
let x = 1; //不可变变量
x = 2; // 赋值报错 Cannot assign to an immutable variable
let mut y = 333; //可变变量
y = 888; //可以赋值

}

2.数据类型

felt 是 Cairo 中最基本的数据类型,也是其他数据类型的构建基石。它可以表示 252位(31字节)的数据,支持加法、减法、乘法和除法等基本运算。

Cairo支持长度少于 31 个字符的短字符串。然而,它们实际上以 felt 的形式进行存储。

布尔数据类型有两种可能的值:true 或 false。

Cairo支持不同大小的无符号整数,包括 u8(uint8,无符号 8 位整数)、u16、u32、u64 和 u128。uint256 不是原生支持的,可以通过 use integer::u256_from_felt252 导入它。

元组是由不同类型的值组成的集合。使用圆括号 () 来定义。

fn main() {
let score = 100; // 默认类型 felt252
let name = 'LiLei'; // 默认类型 felt252

let height: u64 = 30; // Int 类型
let width: u64 = 20; // Int 类型
let area = height * width; // 数值运算

let is_night = true; // Bool 类型

let cat = ('MiMi',3 , true); // 元组类型
let blank = (); // uint类型,大小总是为零,不存在于编译后的代码中
}

3.函数

Cairo 与 Rust 类似,函数使用 fn 关键字声明。参数类型像声明变量一样需要标明,当函数返回一个值时,必须在箭头 -> 之后指定返回类型。

fn main() {
is_even(9);// 语句带分号,不会返回值
}

// 在函数签名中,必须声明参数的类型
fn is_even(num: u32) -> bool { // -> 表示函数有返回值
num % 2 == 0 // 表达式不带分号,会返回一个值
}

4.控制流 if

if-else 表达式允许你根据特定条件执行代码逻辑:如果满足特定条件,则执行一段代码,否则将执行另一段代码。

可以使用else-if表达式创建多个条件,这对于处理复杂逻辑非常有用。

use debug::PrintTrait;

fn main() {
let a :u64 = 10;
let b :u64 = 10;

if a > b {
'Big'.print();
}else if a < b { // 用 else if 处理多个条件
'Less'.print();
}else{
'equal'.print();
}
}

5.控制流 loop

循环允许你在特定条件下反复执行代码。与其他具有多种循环类型(for,while等)的编程语言不同,Cairo目前仅支持一种循环类型:loop。

loop关键字将反复执行一段代码,直到见到break关键字才停止。可以通过在break关键字后添加表达式从循环返回值。

// Cairo 目前只有 loop 一种循环:
fn main() {
let mut counter = 0;
loop {
counter += 1; // 想要循环执行的代码

if counter == 10{ // 终止执行的条件
break (); // 终止循环的语句
}
};
}

6.数组

数组是相同类型(如 u32, felt252 等)的对象的集合,存储在连续的内存中,并可使用索引进行访问。数组在Cairo中并非原生支持,需要导入ArrayTrait库来使用它。

use debug::PrintTrait;
use array::ArrayTrait; //导入数组特性

fn main(){
let mut a = ArrayTrait::new(); // 初始化数组
a.append(0x111); // 新增数组元素
a.append(0x222);
a.append(0x333);

let num_1 = *a.get(1).unwrap().unbox(); //获取下标1的值
let num_2 = *a.at(2); //获取下标2的值,(备注:desnap 操作符 * 可将快照转换回值)
num_1.print(); // 0x222
num_2.print(); // 0x333
}

7.字典

字典类型表示键值对的集合,其中每个键都是唯一的,并与相应的值相关联。这种类型的数据结构在其它编程语言中可能有不同的名称,如映射、哈希表、关联数组等。

use dict::Felt252DictTrait; // 导入字典特性

fn main(){
let mut menu = felt252_dict_new::<felt252>();
menu.insert('fish', 1024); // 写入键对应的值
let price_1 = menu['fish']; // 方式1 读取键值,1024
let price_2 = menu.get('meat'); // 方式2 读取键值,初始值为 0
}

8.结构体的定义及实例化

结构体是一种自定义的数据类型,可以将多个相关值组合成一个有意义的数据类型。

//定义一种结构体
#[derive(Copy, Drop)]
struct PlayerStruct {
id: u64,
name: felt252,
level: u64,
}

fn main(){
// 实例化结构体
let player_1 = PlayerStruct{
id:9527,
name:'HA',
level:1
};
}

9.结构体的方法

Cairo 中没有类和对象的概念,但可以用结构体 + 方法 来实现类似功能。结构体方法与函数类似:使用 fn 关键字和名称来声明它们,它们可以具有参数和返回值。用 trait 关键字来定义函数签名,用 impl 关键字来完成函数的具体实现。

//定义一种结构体
#[derive(Copy, Drop)]
struct PlayerStruct {
id: u64,
name: felt252,
level: u64,
}
//在结构体的 trait 里,定义函数签名
trait PlayerTrait { // 函数参数:该结构体快照
fn attack(self: @PlayerStruct) -> u64;
}

//在 trait 的 impl 里,完善函数体
impl PlayerImpl of PlayerTrait {
fn attack(self: @PlayerStruct) -> u64 {
(*self.level) * (0x100)
}
}

fn main(){
// 实例化结构体
let player_1 = PlayerStruct{
id:9527,
name:'HA',
level:5
};
//获取结构体的字段的值
let name = player_1.name;
// 调用结构体方法,返回 0x500
let attack_point = player_1.attack();
}

10.枚举和模式匹配

Cairo 中的枚举是一种定义一组命名值(变量)的方法,每个值都有一个关联的数据类型。使用枚举可以提高代码的可读性并减少错误。

match 属于控制流运算符,可以以清晰、简洁的方式处理枚举的不同可能值。类似于其他语言中的 switch 语句,但表达能力更强,也更安全。

//示例10 枚举和模式匹配

use array::ArrayTrait; //导入数组特性

// 定义一个枚举类型
enum Direction {
Left,
Right,
Stay,
}

fn main(){
let mut position = 100;
// 创建枚举类型的变量
let move = Direction::Left;
// 使用 match 表达式对 move 进行模式匹配
match move {
Direction::Left => position + 1, // 分支1
Direction::Right => position - 1, // 分支2
Direction::Stay => position + 0, // 分支3
};
}

11.所有权

在 Cairo 中,当数据从一个变量分配到另一个变量或作为函数参数传递时,该数据的所有权会被转移。所有权一旦转移,在当前作用域中将无法再使用该数据。

use array::ArrayTrait; //导入数组特性

fn read_0(arr: Array<u64>) { arr.get(0); }
fn read_1(arr: Array<u64>) { arr.get(1); }

fn main(){
// 创建 数组_1。此时其所有权属于 main()
let mut arr_1 = ArrayTrait::<u64>::new();
arr_1.append(111);
arr_1.append(222);

read_0(arr_1); //正常执行。此后 数组_1 的所有权将转移给 read_0()
read_1(arr_1); //无法执行。因为 main() 已失去 数组_1 的所有权
}

12.快照

Cairo 中的快照(snapshot)为某个值提供了一个不可变的视图。当一个函数接受一个快照作为参数时,它并不接管底层值的所有权。

可以使用快照操作符 @ 创建快照。

use array::ArrayTrait; //导入数组特性
// “ @ ” 表明函数接收的数据类型为 “ 快照 ”
fn read_0(arr: @Array<u64>) { arr.get(0); }
fn read_1(arr: @Array<u64>) { arr.get(1); }

fn main(){
// 创建 数组_1。此时其所有权属于 main()
let mut arr_1 = ArrayTrait::<u64>::new();
arr_1.append(111);
arr_1.append(222);

read_0(@arr_1); //正常执行。 “ @ ” 表明传入的数据类型为“ 快照 ”
read_1(@arr_1); //正常执行。因为 传给 read_0()的只是 “复印件”
// 数组_1 的 “原件” 还保留在 main() 函数里。
}

13.引用

在 Cairo 中,如果我们希望一个函数更改参数的值同时保留其所有权,就可以使用可变引用(mutable reference)。可变引用在函数执行结束时会被隐式返回,允许函数修改它的值,并且该值在调用函数的作用域中仍可使用。

use debug::PrintTrait;
use array::ArrayTrait; //导入数组特性
// “ ref ” 表明函数接收的数据类型为 “ 引用 ”
fn increase(ref arr: Array<u64>) { arr.append(0x333); }
fn decrease(ref arr: Array<u64>) { arr.pop_front(); }

fn main(){
// 创建 数组_1。此时其所有权属于 main()
//只有使用 mut 声明变量为可变,才能使用 ref 修饰符。
let mut arr_1 = ArrayTrait::<u64>::new();
arr_1.append(0x111);
arr_1.append(0x222);

increase(ref arr_1); //正常执行。 “ ref ” 表明传入的数据类型为“ 引用 ”
decrease(ref arr_1); //正常执行。传给 read_0()执行后,所有权又返还了。
arr_1.len().print(); //正常执行。
}

Reference:

  1. Cairo
  2. Cairo Book
  3. WTF-Cairo
  4. Scarb Docs
  5. Starkling Cairo1