Generic Data Types

We use generics to create definitions for item declarations, such as structs and functions, which we can then use with many different concrete data types. In Cairo, we can use generics when defining functions, structs, enums, traits, implementations and methods. In this chapter, we are going to take a look at how to effectively use generic types with all of them.

Generics allow us to write reusable code that works with many types, thus avoiding code duplication, while enhancing code maintainability.

在函数定义中使用泛型

Making a function generic means it can operate on different types, avoiding the need for multiple, type-specific implementations. This leads to significant code reduction and increases the flexibility of the code.

当定义一个使用泛型的函数时,本来在函数签名中指定参数和返回值的类型的地方,会改用泛型来表示。例如,假设我们想创建一个函数,给定两个 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 = array![1, 2];
    let mut l2 = array![3, 4, 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);
}

The largest_list function compares two lists of the same type and returns the one with more elements and drops the other. If you compile the previous code, you will notice that it will fail with an error saying that there are no traits defined for dropping an array of a generic type. This happens because the compiler has no way to guarantee that an Array<T> is droppable when executing the main function. In order to drop an array of T, the compiler must first know how to drop T. This can be fixed by specifying in the function signature of largest_list that T must implement the Drop trait. The correct function definition of largest_list is as follows:

fn largest_list<T, impl TDrop: Drop<T>>(l1: Array<T>, l2: Array<T>) -> Array<T> {
    if l1.len() > l2.len() {
        l1
    } else {
        l2
    }
}

The new largest_list function includes in its definition the requirement that whatever generic type is placed there, it must be droppable. This is what we call trait bounds. The main function remains unchanged, the compiler is smart enough to deduce which concrete type is being used and if it implements the Drop trait.

Constraints for Generic Types

When defining generic types, it is useful to have information about them. Knowing which traits a generic type implements allows us to use it more effectively in a function's logic at the cost of constraining the generic types that can be used with the function. We saw an example of this previously by adding the TDrop implementation as part of the generic arguments of largest_list. While TDrop was added to satisfy the compiler's requirements, we can also add constraints to benefit our function logic.

想象一下,我们想,给定一个通用类型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, impl TPartialOrd: 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
    while index < list.len() {
        if *list[index] < smallest {
            smallest = *list[index];
        }
        index = index + 1;
    };

    smallest
}

fn main() {
    let list: Array<u8> = array![5, 3, 10];

    // We need to specify that we are passing a snapshot of `list` as an argument
    let s = smallest_element(@list);
    assert!(s == 3);
}

The smallest_element function uses a generic type T that implements the PartialOrd trait, takes a snapshot of an Array<T> as a parameter and returns a copy of the smallest element. Because the parameter is of type @Array<T>, we no longer need to drop it at the end of the execution and so we are not required to implement the Drop trait for T as well. Why does it not compile then?

When indexing on list, the value results in a snap of the indexed element, and unless PartialOrd is implemented for @T we need to desnap the element using *. The * operation requires a copy from @T to T, which means that T needs to implement the Copy trait. After copying an element of type @T to T, there are now variables with type T that need to be dropped, requiring T to implement the Drop trait as well. We must then add both Drop and Copy traits implementation for the function to be correct. After updating the smallest_element function the resulting code would be:

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;

    while index < list.len() {
        if *list[index] < smallest {
            smallest = *list[index];
        }
        index = index + 1;
    };

    smallest
}

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:

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;
    }
}

Structs

We can also define structs to use a generic type parameter for one or more fields using the <> syntax, similar to function definitions. First, we declare the name of the type parameter inside the angle brackets just after the name of the struct. Then we use the generic type in the struct definition where we would otherwise specify concrete data types. The next code example shows the definition Wallet<T> which has a balance field of type 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 };
}

We add to the Wallet struct definition a new generic type U and then assign this type to the new field member address. Notice that the derive attribute for the Drop trait works for U as well.

枚举的定义

和结构体类似,枚举也可以在成员中存放泛型数据类型。例如,Cairo核心库提供的Option<T>枚举:

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

如你所见 Option<T> 是一个拥有泛型 T 的枚举,它有两个成员:Some,它存放了一个类型 T 的值,和不存在任何值的None。通过 Option<T> 枚举可以表达有一个可能的值的抽象概念,同时因为 Option<T> 是泛型的,无论这个可能的值是什么类型都可以使用这个抽象。

Enums can use multiple generic types as well, like the definition of the Result<T, E> enum that the core library provides:

enum Result<T, E> {
    Ok: T,
    Err: E,
}

Result<T, E>枚举有两个泛型类型,TE,以及两个成员:Ok,存放T类型的值,Err,存放E类型的值。这个定义使得我们可以在任何地方使用Result枚举,该操作可能成功(返回T类型的值)或失败(返回E类型的值)。

Generic Methods

We can implement methods on structs and enums, and use the generic types in their definitions, too. Using our previous definition of Wallet<T> struct, we define a balance method for it:

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

We first define WalletTrait<T> trait using a generic type T which defines a method that returns the value of the field balance from Wallet. Then we give an implementation for the trait in WalletImpl<T>. Note that you need to include a generic type in both definitions of the trait and the implementation.

We can also specify constraints on generic types when defining methods on the type. We could, for example, implement methods only for Wallet<u128> instances rather than Wallet<T>. In the code example, we define an implementation for wallets which have a concrete type of u128 for the balance field.

#[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);

    w.receive(100);
    assert!(w.balance() == 150);
}

The new method receive increments the size of balance of any instance of a Wallet<u128>. Notice that we changed the main function making w a mutable variable in order for it to be able to update its balance. If we were to change the initialization of w by changing the type of balance the previous code wouldn't compile.

Cairo allows us to define generic methods inside generic traits as well. Using the past implementation from Wallet<U, V> we are going to define a trait that picks two wallets of different generic types and creates a new one with a generic type of each. First, let's rewrite the struct definition:

struct Wallet<T, U> {
    balance: T,
    address: U,
}

Next, we are going to naively define the mixup trait and implementation:

// 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 }
    }
}

We are creating a trait WalletMixTrait<T1, U1> with the mixup<T2, U2> method which given an instance of Wallet<T1, U1> and Wallet<T2, U2> creates a new Wallet<T1, U2>. As mixup signature specifies, both self and other are getting dropped at the end of the function, which is why this code does not compile. If you have been following from the start until now you would know that we must add a requirement for all the generic types specifying that they will implement the Drop trait for the compiler to know how to drop instances of Wallet<T, U>. The updated implementation is as follows:

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<bool, u128> = Wallet { balance: true, address: 10 };
    let w2: Wallet<felt252, u8> = Wallet { balance: 32, address: 100 };

    let w3 = w1.mixup(w2);

    assert!(w3.balance);
    assert!(w3.address == 100);
}

我们首先创建两个实例:一个是 Wallet<bool, u128>,另一个是Wallet<felt252, u8>。然后,我们调用mixup并创建一个新的Wallet<bool, u8>实例。