Generic Data Types

Usamos genéricos para crear definiciones de elementos, como estructuras y funciones, que luego podemos utilizar con muchos tipos de datos concretos diferentes. En Cairo podemos usar genéricos al definir funciones, structs, enums, traits, implementaciones y métodos. En este capítulo vamos a ver cómo utilizar de manera efectiva los tipos genéricos con todos ellos.

Generic Functions

Cuando definimos una función que utiliza genéricos, colocamos los genéricos en la firma de la función, donde normalmente especificaríamos los tipos de datos del parámetro y del valor de retorno. Por ejemplo, imaginemos que queremos crear una función que, dados dos Array de elementos, devuelva el mayor de ellos. Si necesitamos realizar esta operación para listas de distintos tipos, tendríamos que redefinir la función cada vez. Por suerte podemos implementar la función una vez usando genéricos y pasar a otras tareas.


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

La función largest_list compara dos listas del mismo tipo y devuelve la que tiene más elementos y elimina la otra. Si compilas el código anterior, notarás que fallará con un error diciendo que no hay traits definidos para soltar un array de un tipo genérico. Esto ocurre porque el compilador no tiene forma de garantizar que un Array<T> es soltable al ejecutar la función main. Para poder soltar un array de T, el compilador debe saber primero como soltar T. Esto puede solucionarse especificando en la firma de la función largest_list que T debe implementar el rasgo drop. La definición correcta de la función largest_list es la siguiente:

#![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
    }
}
}

The new largest_list function includes in its definition the requirement that whatever generic type is placed there, it must be droppable. 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 allow us to use them more effectively in a functions 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.

Imagine that we want, given a list of elements of some generic type T, to find the smallest element among them. Initially, we know that for an element of type T to be comparable, it must implement the PartialOrd trait. The resulting function would be:


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

La función smallest_element utiliza un tipo genérico T que implementa el trait PartialOrd, toma una instantánea de un Array<T> como parámetro y devuelve una copia del elemento más pequeño. Debido a que el parámetro es de tipo @Array<T>, ya no necesitamos soltarlo al final de la ejecución y por lo tanto no necesitamos implementar el trait Drop para T también. ¿Por qué entonces no compila?

Cuando hacemos indexación en list, el valor resultante es una instantánea del elemento indexado, a menos que PartialOrd esté implementado para @T necesitamos deshacer la instantánea del elemento usando *. La operación * requiere una copia de @T a T, lo que significa que T necesita implementar el trait Copy. Después de copiar un elemento de tipo @T a T, ahora hay variables con tipo T que necesitan ser soltadas, lo que requiere que T implemente también el trait Drop. Debemos entonces agregar la implementación de los traits Drop y Copy para que la función sea correcta. Después de actualizar la función smallest_element, el código resultante sería:

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

Structs

También podemos definir estructuras que usen un parámetro de tipo genérico para uno o más campos usando la sintaxis <>, similar a las definiciones de funciones. Primero declaramos el nombre del parámetro de tipo dentro de los corchetes angulares justo después del nombre de la estructura. Luego usamos el tipo genérico en la definición de la estructura donde de otra manera especificaríamos tipos de datos concretos. El siguiente ejemplo de código muestra la definición de Wallet<T> que tiene un campo balance de tipo T.

#[derive(Drop)]
struct Wallet<T> {
    balance: T
}


fn main() {
    let w = Wallet { balance: 3 };
}

El código anterior deriva el trait Drop para el tipo Wallet automáticamente. Es equivalente a escribir el siguiente código:

struct Wallet<T> {
    balance: T
}

impl WalletDrop<T, +Drop<T>> of Drop<Wallet<T>>;

fn main() {
    let w = Wallet { balance: 3 };
}

Evitamos el uso de la macro derive para la implementación de Drop de Wallet y en su lugar definimos nuestra propia implementación de WalletDrop. Nótese que debemos definir, al igual que en las funciones, un tipo genérico adicional para WalletDrop diciendo que T también implementa el trait Drop. Básicamente estamos diciendo que la estructura Wallet<T> es dropeable siempre y cuando T también lo sea.

Finalmente, si queremos añadir un campo a Wallet que represente su dirección y queremos que ese campo sea diferente de T pero genérico también, podemos simplemente añadir otro tipo genérico entre el <>:

#[derive(Drop)]
struct Wallet<T, U> {
    balance: T,
    address: U,
}

fn main() {
    let w = Wallet { balance: 3, address: 14 };
}

Añadimos a la definición de la estructura Wallet un nuevo tipo genérico U y asignamos este tipo al nuevo campo miembro address. Observa que el atributo derive del rasgo Drop también funciona para U.

Enums

Como hicimos con las estructuras, podemos definir enumeraciones para contener tipos de datos genéricos en sus variantes. Por ejemplo, la enumeración Option<T> proporcionada por la biblioteca central de Cairo:

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

El enum Option<T> es genérico sobre un tipo T y tiene dos variantes: Some, que contiene un valor de tipo T, y None, que no contiene ningún valor. Al utilizar el enum Option<T>, es posible expresar el concepto abstracto de un valor opcional y debido a que el valor tiene un tipo genérico T, podemos utilizar esta abstracción con cualquier tipo.

Los Enums también pueden utilizar múltiples tipos genéricos, como la definición del enum Result<T, E> que proporciona la biblioteca estándar:

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

El enum Result<T, E> tiene dos tipos genéricos, T y E, y dos variantes: Ok que tiene el valor de tipo T y Err que tiene el valor de tipo E. Esta definición hace que sea conveniente usar el enum Result en cualquier lugar donde tengamos una operación que pueda tener éxito (devolviendo un valor de tipo T) o fallar (devolviendo un valor de tipo E).

Generic Methods

También podemos implementar métodos en structs y enums, y usar los tipos genéricos en su definición. Utilizando nuestra definición anterior de la struct Wallet<T>, definimos un método balance para ella:

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

Primero definimos la clase WalletTrait<T> usando un tipo genérico T que define un método que devuelve una instantánea del campo address de Wallet. Luego, damos una implementación de la clase en WalletImpl<T>. Ten en cuenta que debes incluir un tipo genérico en ambas definiciones de la clase y la implementación.

También podemos especificar restricciones en los tipos genéricos al definir métodos en la clase. Por ejemplo, podríamos implementar métodos solo para instancias de Wallet<u128> en lugar de Wallet<T>. En el ejemplo de código, definimos una implementación para carteras que tienen un tipo concreto de u128 para el campo balance.

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

El nuevo método receive incrementa el tamaño del saldo de cualquier instancia de una Wallet<u128>. Observe que se cambió la función main haciendo que w sea una variable mutable para que pueda actualizar su saldo. Si cambiáramos la inicialización de w cambiando el tipo de balance, el código anterior no se compilaría.

Cairo nos permite definir métodos genéricos dentro de traits genéricos también. Usando la implementación previa de Wallet<U, V>, vamos a definir un trait que tome dos wallets de diferentes tipos genéricos y cree uno nuevo con un tipo genérico de cada uno. Primero, reescribamos la definición de la estructura:

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

A continuación vamos a definir ingenuamente el rasgo mixup y su implementación:

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

Estamos creando un trait WalletMixTrait<T1, U1> con el método mixup<T2, U2> que, dada una instancia de Wallet<T1, U1> y Wallet<T2, U2>, crea un nuevo Wallet<T1, U2>. Como especifica la firma de mixup, tanto self como other se están eliminando al final de la función, lo que hace que este código no se compile. Si has estado siguiendo desde el principio hasta ahora, sabrás que debemos agregar un requisito para todos los tipos genéricos especificando que implementarán el trait Drop para que el compilador sepa cómo eliminar las instancias de Wallet<T, U>. La implementación actualizada es la siguiente:

#![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 }
    }
}
}

Sí, agregamos los requisitos para que T1 y U1 sean droppables en la declaración de WalletMixImpl. Luego hacemos lo mismo para T2 y U2, esta vez como parte de la firma de mixup. Ahora podemos probar la función 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);
}

Primero creamos dos instancias: una de Wallet<bool, u128> y la otra de Wallet<felt252, u8>. Luego, llamamos a mixup y creamos una nueva instancia de Wallet<bool, u8>.

Last change: 2023-12-09, commit: acd03a1