泛型数据类型
我们可以使用泛型为像函数签名或结构体这样的项创建定义,这样它们就可以用于多种不同的具体数据类型。在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特性。然后我们必须同时添加Drop和Copy特性的实现,以使该函数正确。在更新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宏来实现Wallet的Drop,而是定义我们自己的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>枚举有两个泛型类型,T和E,以及两个成员: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签名所指定的,self和other都在函数的结尾处被丢弃,这就是这段代码不能编译的原因。如果你从开始到现在都跟上了课程,你会知道我们必须为所有的泛型添加一个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"的声明中添加了 T1和 U1的可丢弃trait。然后我们对T2和U2做同样的处理,这次是作为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>实例。