泛型数据类型

我们可以使用泛型为像函数签名或结构体这样的项创建定义,这样它们就可以用于多种不同的具体数据类型。在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