Estructura de Dato Personalizadas
Cuando empieces a programar en Cairo, probablemente querrás usar arrays (Array<T>
) para almacenar colecciones de datos. Sin embargo, rápidamente te darás cuenta de que los arrays tienen una gran limitación - los datos almacenados en ellos son inmutables. Una vez que añades un valor a un array, no puedes modificarlo.
Esto puede resultar frustrante cuando se desea utilizar una estructura de datos mutable. Por ejemplo, digamos que estás haciendo un juego donde los jugadores tienen un nivel, y pueden subir de nivel. Podrías intentar almacenar el nivel de los jugadores en un array:
let mut level_players = array![5, 1, 10];
Pero entonces te das cuenta de que no puedes aumentar el nivel en un índice específico una vez que está fijado. Si un jugador muere, no puedes eliminarlo de la matriz a menos que casualmente esté en la primera posición.
Fortunately, Cairo provides a handy built-in dictionary type called Felt252Dict<T>
that allows us to simulate the behavior of mutable data structures. Let's first explore how to create a struct that contains, among others, a Felt252Dict<T>
.
Note: Several concepts used in this chapter were already presented earlier in the book. We recommend checking out the following chapters if you need to revise them: Structs, Methods, Generic types, Traits.
Diccionarios como miembros del Struct
Definir diccionarios como miembros de struct es posible en Cairo, pero interactuar correctamente con ellos puede no ser del todo fluido. Intentemos implementar una user database personalizada que nos permita añadir usuarios y consultarlos. Necesitaremos definir un struct para representar el nuevo tipo y un trait para definir su funcionalidad:
struct UserDatabase<T> {
users_updates: u64,
balances: Felt252Dict<T>,
}
trait UserDatabaseTrait<T> {
fn new() -> UserDatabase<T>;
fn update_user<+Drop<T>>(ref self: UserDatabase<T>, name: felt252, balance: T);
fn get_balance<+Copy<T>>(ref self: UserDatabase<T>, name: felt252) -> T;
}
Nuestro nuevo tipo UserDatabase<T>
representa una base de datos de usuarios. Es genérico sobre los saldos de los usuarios, dando mayor flexibilidad a quien utilice nuestro tipo de datos. Sus dos miembros son:
users_updates
, the number of users updates in the dictionary.balances
, una asignación de cada usuario a su balance.
La funcionalidad central de la base de datos está definida por UserDatabaseTrait
. Donde se definen los siguientes métodos:
new
para crear fácilmente nuevos tipos deUserDatabase
.update_user
to update the balance of users in the database.get_balance
para encontrar el saldo del usuario en la base de datos.
The only remaining step is to implement each of the methods in UserDatabaseTrait
, but since we are working with Generic types we also need to correctly establish the requirements of T
so it can be a valid Felt252Dict<T>
value type:
T
debería implementar elCopy<T>
ya que es necesario para obtener valores de unFelt252Dict<T>
.- Todos los tipos de valor de un diccionario implementan el
Felt252DictValue<T>
, nuestro tipo genérico debería hacerlo también. - To insert values,
Felt252DictTrait<T>
requires all value types to be droppable (implement theDrop<T>
trait).
The implementation, with all restrictions in place, would be as follows:
impl UserDatabaseImpl<T, +Felt252DictValue<T>> of UserDatabaseTrait<T> {
// Creates a database
fn new() -> UserDatabase<T> {
UserDatabase { users_updates: 0, balances: Default::default() }
}
// Get the user's balance
fn get_balance<+Copy<T>>(ref self: UserDatabase<T>, name: felt252) -> T {
self.balances.get(name)
}
// Add a user
fn update_user<+Drop<T>>(ref self: UserDatabase<T>, name: felt252, balance: T) {
self.balances.insert(name, balance);
self.users_updates += 1;
}
}
Our database implementation is almost complete, except for one thing: the compiler doesn't know how to make a UserDatabase<T>
go out of scope, since it doesn't implement the Drop<T>
trait, nor the Destruct<T>
trait. Since it has a Felt252Dict<T>
as a member, it cannot be dropped, so we are forced to implement the Destruct<T>
trait manually (refer to the Ownership chapter for more information). Using #[derive(Destruct)]
on top of the UserDatabase<T>
definition won't work because of the use of Generic types in the struct definition. We need to code the Destruct<T>
trait implementation by ourselves:
impl UserDatabaseDestruct<T, +Drop<T>, +Felt252DictValue<T>> of Destruct<UserDatabase<T>> {
fn destruct(self: UserDatabase<T>) nopanic {
self.balances.squash();
}
}
Implementar Destruct<T>
para UserDatabase
fue nuestro último paso para conseguir una base de datos completamente funcional. Ahora podemos probarla:
use core::dict::Felt252Dict;
struct UserDatabase<T> {
users_updates: u64,
balances: Felt252Dict<T>,
}
trait UserDatabaseTrait<T> {
fn new() -> UserDatabase<T>;
fn update_user<+Drop<T>>(ref self: UserDatabase<T>, name: felt252, balance: T);
fn get_balance<+Copy<T>>(ref self: UserDatabase<T>, name: felt252) -> T;
}
impl UserDatabaseImpl<T, +Felt252DictValue<T>> of UserDatabaseTrait<T> {
// Creates a database
fn new() -> UserDatabase<T> {
UserDatabase { users_updates: 0, balances: Default::default() }
}
// Get the user's balance
fn get_balance<+Copy<T>>(ref self: UserDatabase<T>, name: felt252) -> T {
self.balances.get(name)
}
// Add a user
fn update_user<+Drop<T>>(ref self: UserDatabase<T>, name: felt252, balance: T) {
self.balances.insert(name, balance);
self.users_updates += 1;
}
}
impl UserDatabaseDestruct<T, +Drop<T>, +Felt252DictValue<T>> of Destruct<UserDatabase<T>> {
fn destruct(self: UserDatabase<T>) nopanic {
self.balances.squash();
}
}
fn main() {
let mut db = UserDatabaseTrait::<u64>::new();
db.update_user('Alex', 100);
db.update_user('Maria', 80);
db.update_user('Alex', 40);
db.update_user('Maria', 0);
let alex_latest_balance = db.get_balance('Alex');
let maria_latest_balance = db.get_balance('Maria');
assert!(alex_latest_balance == 40, "Expected 40");
assert!(maria_latest_balance == 0, "Expected 0");
}
Simulating a Dynamic Array with Dicts
En primer lugar, pensemos en cómo queremos que se comporte nuestro array dinámico mutable. ¿Qué operaciones debería soportar?
Deberia:
- Allow us to append items at the end.
- Let us access any item by index.
- Allow setting the value of an item at a specific index.
- Return the current length.
Podemos definir esta interfaz en Cairo como:
trait MemoryVecTrait<V, T> {
fn new() -> V;
fn get(ref self: V, index: usize) -> Option<T>;
fn at(ref self: V, index: usize) -> T;
fn push(ref self: V, value: T) -> ();
fn set(ref self: V, index: usize, value: T);
fn len(self: @V) -> usize;
}
This provides a blueprint for the implementation of our dynamic array. We named it MemoryVec as it is similar to the Vec<T>
data structure in Rust.
Note: The core library of Cairo already includes a
Vec<T>
data structure, strictly used as a storage type in smart contracts. To differentiate our data structure from the core library's one, we named our implementation MemoryVec.
Implementing a Dynamic Array in Cairo
Para almacenar nuestros datos, utilizaremos un Felt252Dict<T>
que asigna números de índice (felts) a valores. También almacenaremos un campo len
separado para rastrear la longitud.
Este es el aspecto de nuestra estructura. Envolvemos el tipo T
dentro del puntero Nullable
para permitir el uso de cualquier tipo T
en nuestra estructura de datos, como se explica en la sección Dictionaries:
use core::dict::Felt252Dict;
use core::nullable::NullableTrait;
use core::num::traits::WrappingAdd;
trait MemoryVecTrait<V, T> {
fn new() -> V;
fn get(ref self: V, index: usize) -> Option<T>;
fn at(ref self: V, index: usize) -> T;
fn push(ref self: V, value: T) -> ();
fn set(ref self: V, index: usize, value: T);
fn len(self: @V) -> usize;
}
struct MemoryVec<T> {
data: Felt252Dict<Nullable<T>>,
len: usize,
}
impl DestructMemoryVec<T, +Drop<T>> of Destruct<MemoryVec<T>> {
fn destruct(self: MemoryVec<T>) nopanic {
self.data.squash();
}
}
impl MemoryVecImpl<T, +Drop<T>, +Copy<T>> of MemoryVecTrait<MemoryVec<T>, T> {
fn new() -> MemoryVec<T> {
MemoryVec { data: Default::default(), len: 0 }
}
fn get(ref self: MemoryVec<T>, index: usize) -> Option<T> {
if index < self.len() {
Option::Some(self.data.get(index.into()).deref())
} else {
Option::None
}
}
fn at(ref self: MemoryVec<T>, index: usize) -> T {
assert!(index < self.len(), "Index out of bounds");
self.data.get(index.into()).deref()
}
fn push(ref self: MemoryVec<T>, value: T) -> () {
self.data.insert(self.len.into(), NullableTrait::new(value));
self.len.wrapping_add(1_usize);
}
fn set(ref self: MemoryVec<T>, index: usize, value: T) {
assert!(index < self.len(), "Index out of bounds");
self.data.insert(index.into(), NullableTrait::new(value));
}
fn len(self: @MemoryVec<T>) -> usize {
*self.len
}
}
Since we again have Felt252Dict<T>
as a struct member, we need to implement the Destruct<T>
trait to tell the compiler how to make MemoryVec<T>
go out of scope.
use core::dict::Felt252Dict;
use core::nullable::NullableTrait;
use core::num::traits::WrappingAdd;
trait MemoryVecTrait<V, T> {
fn new() -> V;
fn get(ref self: V, index: usize) -> Option<T>;
fn at(ref self: V, index: usize) -> T;
fn push(ref self: V, value: T) -> ();
fn set(ref self: V, index: usize, value: T);
fn len(self: @V) -> usize;
}
struct MemoryVec<T> {
data: Felt252Dict<Nullable<T>>,
len: usize,
}
impl DestructMemoryVec<T, +Drop<T>> of Destruct<MemoryVec<T>> {
fn destruct(self: MemoryVec<T>) nopanic {
self.data.squash();
}
}
impl MemoryVecImpl<T, +Drop<T>, +Copy<T>> of MemoryVecTrait<MemoryVec<T>, T> {
fn new() -> MemoryVec<T> {
MemoryVec { data: Default::default(), len: 0 }
}
fn get(ref self: MemoryVec<T>, index: usize) -> Option<T> {
if index < self.len() {
Option::Some(self.data.get(index.into()).deref())
} else {
Option::None
}
}
fn at(ref self: MemoryVec<T>, index: usize) -> T {
assert!(index < self.len(), "Index out of bounds");
self.data.get(index.into()).deref()
}
fn push(ref self: MemoryVec<T>, value: T) -> () {
self.data.insert(self.len.into(), NullableTrait::new(value));
self.len.wrapping_add(1_usize);
}
fn set(ref self: MemoryVec<T>, index: usize, value: T) {
assert!(index < self.len(), "Index out of bounds");
self.data.insert(index.into(), NullableTrait::new(value));
}
fn len(self: @MemoryVec<T>) -> usize {
*self.len
}
}
La clave que hace que este vector sea mutable es que podemos insertar valores en el diccionario para establecer o actualizar valores en nuestra estructura de datos. Por ejemplo, para actualizar un valor en un índice específico, hacemos:
use core::dict::Felt252Dict;
use core::nullable::NullableTrait;
use core::num::traits::WrappingAdd;
trait MemoryVecTrait<V, T> {
fn new() -> V;
fn get(ref self: V, index: usize) -> Option<T>;
fn at(ref self: V, index: usize) -> T;
fn push(ref self: V, value: T) -> ();
fn set(ref self: V, index: usize, value: T);
fn len(self: @V) -> usize;
}
struct MemoryVec<T> {
data: Felt252Dict<Nullable<T>>,
len: usize,
}
impl DestructMemoryVec<T, +Drop<T>> of Destruct<MemoryVec<T>> {
fn destruct(self: MemoryVec<T>) nopanic {
self.data.squash();
}
}
impl MemoryVecImpl<T, +Drop<T>, +Copy<T>> of MemoryVecTrait<MemoryVec<T>, T> {
fn new() -> MemoryVec<T> {
MemoryVec { data: Default::default(), len: 0 }
}
fn get(ref self: MemoryVec<T>, index: usize) -> Option<T> {
if index < self.len() {
Option::Some(self.data.get(index.into()).deref())
} else {
Option::None
}
}
fn at(ref self: MemoryVec<T>, index: usize) -> T {
assert!(index < self.len(), "Index out of bounds");
self.data.get(index.into()).deref()
}
fn push(ref self: MemoryVec<T>, value: T) -> () {
self.data.insert(self.len.into(), NullableTrait::new(value));
self.len.wrapping_add(1_usize);
}
fn set(ref self: MemoryVec<T>, index: usize, value: T) {
assert!(index < self.len(), "Index out of bounds");
self.data.insert(index.into(), NullableTrait::new(value));
}
fn len(self: @MemoryVec<T>) -> usize {
*self.len
}
}
Esto sobrescribe el valor existente anteriormente en ese índice del diccionario.
Mientras que las arrays son inmutables, los diccionarios proporcionan la flexibilidad que necesitamos para las estructuras de datos modificables, como los vectores.
La implementación del resto de la interfaz es sencilla. La implementación de todos los métodos definidos en nuestra interfaz se puede hacer de la siguiente manera:
use core::dict::Felt252Dict;
use core::nullable::NullableTrait;
use core::num::traits::WrappingAdd;
trait MemoryVecTrait<V, T> {
fn new() -> V;
fn get(ref self: V, index: usize) -> Option<T>;
fn at(ref self: V, index: usize) -> T;
fn push(ref self: V, value: T) -> ();
fn set(ref self: V, index: usize, value: T);
fn len(self: @V) -> usize;
}
struct MemoryVec<T> {
data: Felt252Dict<Nullable<T>>,
len: usize,
}
impl DestructMemoryVec<T, +Drop<T>> of Destruct<MemoryVec<T>> {
fn destruct(self: MemoryVec<T>) nopanic {
self.data.squash();
}
}
impl MemoryVecImpl<T, +Drop<T>, +Copy<T>> of MemoryVecTrait<MemoryVec<T>, T> {
fn new() -> MemoryVec<T> {
MemoryVec { data: Default::default(), len: 0 }
}
fn get(ref self: MemoryVec<T>, index: usize) -> Option<T> {
if index < self.len() {
Option::Some(self.data.get(index.into()).deref())
} else {
Option::None
}
}
fn at(ref self: MemoryVec<T>, index: usize) -> T {
assert!(index < self.len(), "Index out of bounds");
self.data.get(index.into()).deref()
}
fn push(ref self: MemoryVec<T>, value: T) -> () {
self.data.insert(self.len.into(), NullableTrait::new(value));
self.len.wrapping_add(1_usize);
}
fn set(ref self: MemoryVec<T>, index: usize, value: T) {
assert!(index < self.len(), "Index out of bounds");
self.data.insert(index.into(), NullableTrait::new(value));
}
fn len(self: @MemoryVec<T>) -> usize {
*self.len
}
}
The full implementation of the MemoryVec
structure can be found in the community-maintained library Alexandria.
Simulating a Stack with Dicts
A continuación veremos un segundo ejemplo y sus detalles de implementación: un Stack.
Un Stack es una colección LIFO (último en entrar, primero en salir). La inserción de un nuevo elemento y la eliminación de un elemento existente tiene lugar en el mismo extremo, representado como la parte superior de la pila.
Let us define what operations we need to create a stack:
- Push an item to the top of the stack.
- Pop an item from the top of the stack.
- Check si todavía hay elementos en la stack.
A partir de estas especificaciones podemos definir la siguiente interfaz:
trait StackTrait<S, T> {
fn push(ref self: S, value: T);
fn pop(ref self: S) -> Option<T>;
fn is_empty(self: @S) -> bool;
}
Implementar un Stack Mutable en Cairo
Para crear una estructura de datos de stack en Cairo, podemos utilizar de nuevo un Felt252Dict<T>
para almacenar los valores de la pila junto con un campo usize
para llevar la cuenta de la longitud de la stack para iterar sobre ella.
La estructura Stack se define como:
struct NullableStack<T> {
data: Felt252Dict<Nullable<T>>,
len: usize,
}
A continuación, veamos cómo se implementan nuestras funciones principales push
y pop
.
use core::dict::Felt252Dict;
use core::nullable::{match_nullable, FromNullableResult, NullableTrait};
trait StackTrait<S, T> {
fn push(ref self: S, value: T);
fn pop(ref self: S) -> Option<T>;
fn is_empty(self: @S) -> bool;
}
struct NullableStack<T> {
data: Felt252Dict<Nullable<T>>,
len: usize,
}
impl DestructNullableStack<T, +Drop<T>> of Destruct<NullableStack<T>> {
fn destruct(self: NullableStack<T>) nopanic {
self.data.squash();
}
}
impl NullableStackImpl<T, +Drop<T>, +Copy<T>> of StackTrait<NullableStack<T>, T> {
fn push(ref self: NullableStack<T>, value: T) {
self.data.insert(self.len.into(), NullableTrait::new(value));
self.len += 1;
}
fn pop(ref self: NullableStack<T>) -> Option<T> {
if self.is_empty() {
return Option::None;
}
self.len -= 1;
Option::Some(self.data.get(self.len.into()).deref())
}
fn is_empty(self: @NullableStack<T>) -> bool {
*self.len == 0
}
}
The code uses the insert
and get
methods to access the values in the Felt252Dict<T>
. To push an element to the top of the stack, the push
function inserts the element in the dict at index len
and increases the len
field of the stack to keep track of the position of the stack top. To remove a value, the pop
function decreases the value of len
to update the position of the stack top and then retrieves the last value at position len
.
The full implementation of the Stack, along with more data structures that you can use in your code, can be found in the community-maintained Alexandria library, in the "data_structures" crate.
Resumen
Well done! Now you have knowledge of arrays, dictionaries and even custom data structures. While Cairo's memory model is immutable and can make it difficult to implement mutable data structures, we can fortunately use the Felt252Dict<T>
type to simulate mutable data structures. This allows us to implement a wide range of data structures that are useful for many applications, effectively hiding the complexity of the underlying memory model.