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>
.