El Lenguaje de Programación Cairo

Creado por la Comunidad Cairo y sus colaboradores. Un agradecimiento especial a Starkware a través de OnlyDust, y Voyager por apoyar la creación de este libro.

Esta versión del texto asume que estás usando Cairo v1.0.0-alpha.7 (publicado el 2023-04-13). Consulte la sección "Instalación" del Capítulo 1 para instalar o actualizar Cairo.

En 2020, StarkWare lanzó Cairo 0, un lenguaje de programación Turing completo que admite cálculo verificable. Cairo comenzó como un lenguaje ensamblador y gradualmente se volvió más expresivo. La curva de aprendizaje fue inicialmente pronunciada, ya que Cairo 0.x era un lenguaje de bajo nivel que no abstraía por completo las primitivas criptográficas subyacentes requeridas para construir una prueba para la ejecución de un programa.

Con el lanzamiento de Cairo 1, la experiencia del desarrollador ha mejorado considerablemente, abstrayendo el modelo de memoria inmutable subyacente de la arquitectura de Cairo siempre que sea posible. Inspirado en Rust, Cairo 1 ha sido construido para ayudarte a crear programas comprobables sin conocimientos específicos de su arquitectura subyacente, para que puedas concentrarte en el programa en sí, aumentando la seguridad general de los programas de Cairo. Alimentado por una máquina virtual Rust, la ejecución de los programas de Cairo es ahora extremadamente rápida, lo que te permite construir una amplia suite de pruebas sin comprometer el rendimiento.

Los desarrolladores de blockchain que desean implementar contratos en Starknet utilizarán el lenguaje de programación Cairo para codificar sus contratos inteligentes. Esto permite al sistema operativo Starknet generar trazas de ejecución para transacciones que deben ser demostradas por un probador, que luego se verifica en Ethereum L1 antes de actualizar la raíz del estado de Starknet.

Sin embargo, Cairo no es solo para desarrolladores de blockchain. Como lenguaje de programación de propósito general, se puede utilizar para cualquier cálculo que se beneficie de ser demostrado en una computadora y verificado en otras máquinas con requisitos de hardware más bajos.

Este libro está diseñado para desarrolladores con una comprensión básica de los conceptos de programación. Es un texto amigable y accesible destinado a ayudarte a mejorar tus conocimientos de Cairo, pero también a ayudarte a desarrollar tus habilidades de programación en general. ¡Así que sumérgete y prepárate para aprender todo lo que hay que saber sobre Cairo!

  • La comunidad de Cairo

Introducción

¿Qué es Cairo?

Cairo es un lenguaje de programación diseñado para una CPU virtual del mismo nombre. El aspecto único de este procesador es que no fue creado para las restricciones físicas de nuestro mundo, sino para las criptográficas, lo que lo hace capaz de probar eficientemente la ejecución de cualquier programa que se ejecute en él. Esto significa que puedes realizar operaciones que consumen mucho tiempo en una máquina en la que no confías, y comprobar el resultado muy rápidamente en una máquina más barata.

Mientras que Cairo 0 solía compilarse directamente a CASM, el ensamblador de CPU de Cairo, Cairo 1 es un lenguaje de más alto nivel. Primero compila a Sierra, una representación intermedia de Cairo que compilará más tarde a un subconjunto seguro de CASM. El objetivo de Sierra es garantizar que tu CASM sea siempre demostrable, incluso cuando falle el cálculo.

¿Qué puedes hacer con Cairo?

Cairo te permite calcular valores confiables en máquinas no confiables. Un caso de uso importante es Starknet, una solución para la escalabilidad de Ethereum. Ethereum es una plataforma blockchain descentralizada que permite la creación de aplicaciones descentralizadas donde cada interacción entre un usuario y una d-app es verificada por todos los participantes. Starknet es una capa 2 construida sobre Ethereum. En lugar de que todos los participantes de la red verifiquen todas las interacciones del usuario, solo un nodo, llamado el probador, ejecuta los programas y genera pruebas de que los cálculos se realizaron correctamente. Estas pruebas luego son verificadas por un contrato inteligente de Ethereum, lo que requiere significativamente menos potencia de cálculo en comparación con la ejecución de las interacciones mismas. Este enfoque permite un mayor rendimiento y una reducción en los costos de transacción, pero preservando la seguridad de Ethereum.

¿Cuáles son las diferencias con otros lenguajes de programación?

Cairo es bastante diferente de los lenguajes de programación tradicionales, especialmente en cuanto a los costos generales y sus ventajas principales. Tu programa se puede ejecutar de dos formas diferentes:

  • Cuando se ejecuta por el probador, es similar a cualquier otro lenguaje. Debido a que Cairo está virtualizado y porque las operaciones no se diseñaron específicamente para la máxima eficiencia, esto puede llevar a una sobrecarga de rendimiento, pero no es la parte más relevante para optimizar.

  • Cuando el probador genera la prueba, es un poco diferente. Esto tiene que ser lo más barato posible, ya que potencialmente se podría verificar en muchas máquinas muy pequeñas. Afortunadamente, verificar es más rápido que computar y Cairo tiene algunas ventajas únicas para mejorar aún más. Uno notable es la no determinación. Este es un tema que cubrirás con más detalle más adelante en este libro, pero la idea es que teóricamente puedes usar un algoritmo diferente para verificar que para calcular. Actualmente, escribir código personalizado no determinista no está soportado para los desarrolladores, pero la biblioteca estándar aprovecha la no determinación para mejorar el rendimiento. Por ejemplo, ordenar una matriz en Cairo cuesta lo mismo que copiarla. Debido a que el verificador no ordena la matriz, simplemente verifica que está ordenada, lo que es más barato.

Otro aspecto que diferencia al lenguaje es su modelo de memoria. En Cairo, el acceso a la memoria es inmutable, lo que significa que una vez que se escribe un valor en la memoria, no se puede cambiar. Cairo 1 proporciona abstracciones que ayudan a los desarrolladores a trabajar con estas limitaciones, pero no simula completamente la mutabilidad. Por lo tanto, los desarrolladores deben pensar cuidadosamente en cómo administran la memoria y las estructuras de datos en sus programas para optimizar el rendimiento.

Referencias

Instalación

El primer paso es instalar Cairo. Descargaremos Cairo manualmente, utilizando el repositorio de Cairo o un script de instalación. Necesitará una conexión a Internet para la descarga.

Requisitos previos

Primero deberá tener Rust y Git instalados.

# Install stable Rust
rustup override set stable && rustup update

Instalar Git.

Instalando Cairo con un Script (Instalador por Fran)

Instalación

Si deseas instalar una versión específica de Cairo en lugar de la última versión, debes establecer la variable de entorno CAIRO_GIT_TAG (por ejemplo,export CAIRO_GIT_TAG=v1.0.0-alpha.6).

curl -L https://github.com/franalgaba/cairo-installer/raw/main/bin/cairo-installer | bash

Tras la instalación, sigue estas instrucciones para configurar tu entorno shell.

Actualización

rm -fr ~/.cairo
curl -L https://github.com/franalgaba/cairo-installer/raw/main/bin/cairo-installer | bash

Desinstalación

Cairo se instala dentro de $CAIRO_ROOT (por defecto: ~/.cairo). Para desinstalarlo, basta con eliminarlo:

rm -fr ~/.cairo

luego elimina estas tres líneas de .bashrc:

export PATH="$HOME/.cairo/target/release:$PATH"

y, por último, reinicia tu shell:

exec $SHELL

Configure su entorno shell para Cairo

  • Define la variable de entorno CAIRO_ROOT para apuntar a la ruta donde Cairo almacenará sus datos. Por defecto es $HOME/.cairo. Si instaló Cairo a través de Git checkout, recomendamos la misma ubicación donde lo clonaste.
  • Añade los ejecutables de cairo-* a tu PATH si no están ya allí.

La siguiente configuración debería funcionar para la gran mayoría de usuarios en casos de uso comunes.

  • Para bash:

Los archivos de inicio de Bash predeterminados varían ampliamente entre las distribuciones, en cuanto a la fuente de ellos, las circunstancias, el orden y la configuración adicional que realizan.

Como tal, la forma más confiable de obtener Cairo en todos los entornos es agregar los comandos de configuración de Cairo tanto en .bashrc (para shells interactivos) como en el archivo de perfil que Bash utilizaría (para shells de inicio de sesión).

En primer lugar, agrega los comandos a ~/.bashrc ejecutando lo siguiente en tu terminal:

~~~ bash
echo 'export CAIRO_ROOT="$HOME/.cairo"' >> ~/.bashrc
echo 'command -v cairo-compile >/dev/null || export PATH="$CAIRO_ROOT/target/release:$PATH"' >> ~/.bashrc
~~~

Entonces, si tiene `~/.profile`, `~/.bash_profile` o `~/.bash_login`, añada también allí los comandos.
Si no tienes ninguno de estos, añádelos a `~/.profile`.

* Para añadir a `~/.profile`:
  ~~~ bash
  echo 'export CAIRO_ROOT="$HOME/.cairo"' >> ~/.profile
  echo 'command -v cairo-compile >/dev/null || export PATH="$CAIRO_ROOT/target/release:$PATH"' >> ~/.profile
  ~~~

* Para añadir a `~/.bash_profile`:
  ~~~ bash
  echo 'export CAIRO_ROOT="$HOME/.cairo"' >> ~/.bash_profile
  echo 'command -v cairo-compile >/dev/null || export PATH="$CAIRO_ROOT/target/release:$PATH"' >> ~/.bash_profile
  ~~~
  • Para Zsh:

    echo 'export CAIRO_ROOT="$HOME/.cairo"' >> ~/.zshrc
    echo 'command -v cairo-compile >/dev/null || export PATH="$CAIRO_ROOT/target/release:$PATH"' >> ~/.zshrc
    

    Si también desea obtener Cairo en shells de inicio de sesión no interactivos, añada también los comandos a ~/.zprofile or ~/.zlogin.

  • Para Fish shell:

    Si tiene Fish 3.2.0 o posterior, ejecútelo interactivamente:

    set -Ux CAIRO_ROOT $HOME/.cairo
    fish_add_path $CAIRO_ROOT/target/release
    

    De lo contrario, ejecute el siguiente fragmento:

    set -Ux CAIRO_ROOT $HOME/.cairo
    set -U fish_user_paths $CAIRO_ROOT/target/release $fish_user_paths
    

En MacOS, es posible que también desee instalar Fig que proporciona complementos de shell alternativos para muchas herramientas de línea de comandos con una interfaz emergente similar a IDE en la ventana de terminal. (Tenga en cuenta que sus complementos son independientes del código base de Cairo por lo que pueden estar ligeramente desincronizadas para cambios de interfaz de última generación).

Reinicia tu shell

para que los cambios en PATH surtan efecto.

exec "$SHELL"

Instalando Cairo Manualmente (Guía por Abdel)

Paso 1: Instalar Cairo 1.0

Si utiliza un sistema Linux x86 y puede utilizar el binario de lanzamiento, descargue Cairo aquí: https://github.com/starkware-libs/cairo/releases.

Para todos los demás, recomendamos compilar Cairo desde el código fuente de la siguiente manera:

# Start by defining environment variable CAIRO_ROOT
export CAIRO_ROOT="${HOME}/.cairo"

# Create .cairo folder if it doesn't exist yet
mkdir $CAIRO_ROOT

# Clone the Cairo compiler in $CAIRO_ROOT (default root)
cd $CAIRO_ROOT && git clone git@github.com:starkware-libs/cairo.git .

# OPTIONAL/RECOMMENDED: If you want to install a specific version of the compiler
# Fetch all tags (versions)
git fetch --all --tags
# View tags (you can also do this in the cairo compiler repository)
git describe --tags `git rev-list --tags`
# Checkout the version you want
git checkout tags/v1.0.0-alpha.6

# Generate release binaries
cargo build --all --release

.

NOTA: Mantener Cairo actualizado

Ahora que tu compilador Cairo está en un repositorio clonado, todo lo que necesitas hacer es extraer los últimos cambios y reconstruir como sigue:

cd $CAIRO_ROOT && git fetch && git pull && cargo build --all --release

Paso 2: Añade los ejecutables de Cairo 1.0 a tu ruta

export PATH="$CAIRO_ROOT/target/release:$PATH"

**NOTA: Si instala desde un binario Linux, adapte la ruta de destino en consecuencia.

Paso 3: Configurar el servidor de idiomas

Extensión VS Code

  • Deshabilite la extensión anterior Cairo 0.x
  • Instale la extensión Cairo 1 para un correcto resaltado de sintaxis y navegación por el código. Simplemente siga los pasos indicados aquí.

Servidor de Lenguaje de Cairo

Desde Paso 1, el binario cairo-language-server debe ser construido y ejecutando este comando se copiará su ruta en el portapapeles.

which cairo-language-server | pbcopy

Actualiza el languageServerPath de la extensión Cairo 1.0 pegando la ruta.

Hola, Mundo

Ahora que has instalado Cairo, es hora de escribir tu primer programa Cairo. Es tradicional cuando se aprende un nuevo lenguaje escribir un pequeño programa que imprima el texto ¡Hola, mundo! en la pantalla, ¡así que haremos lo mismo aquí!

Nota: Este libro asume una familiaridad básica con la línea de comandos. Cairo no hace ninguna demanda específica sobre su edición o herramientas o donde vive su código, así que si prefiere usar un entorno de desarrollo integrado (IDE) en lugar de la línea de comandos, > siéntase libre de hacerlo. la línea de comandos, siéntase libre de usar su IDE favorito. El equipo de Cairo ha desarrollado una extensión VSCode para el lenguaje Cairo que puedes usar para obtener las características de el servidor de lenguajes y el resaltado de código. Ver Apéndice A para más detalles.

Creando un Directorio de Proyecto

Empezarás creando un directorio para almacenar tu código de Cairo. A Cairo no le importa a Cairo dónde vive tu código, pero para los ejercicios y proyectos de este libro, sugerimos hacer un directorio cairo_projects en su directorio home y mantener todos tus proyectos allí.

Abre un terminal e introduce los siguientes comandos para crear un directorio cairo_projects. y un directorio para el proyecto "¡Hola, mundo!" dentro del directorio cairo_projects.

Para Linux, macOS y PowerShell en Windows, introduce esto:

mkdir ~/cairo_projects
cd ~/cairo_projects
mkdir hello_world
cd hello_world

Para Windows CMD, introduzca esto:

> mkdir "%USERPROFILE%\projects"
> cd /d "%USERPROFILE%\projects"
> mkdir hello_world
> cd hello_world

Escribiendo y Ejecutando un Programa Cairo

A continuación, crea un nuevo archivo fuente y llámalo main.cairo. Los archivos Cairo siempre terminan con la extensión .cairo. Si usas más de una palabra en tu nombre de archivo, la convención es usar un guión bajo para separarlas. Por ejemplo_, hello_mundo.cairo_ en lugar de helloworld.cairo.

Ahora abre el archivo main.cairo que acabas de crear e introduce el código del Listado 1-1.

Filename: main.cairo

use debug::PrintTrait;
fn main() {
    'Hello, world!'.print();
}

Listing 1-1: A program that prints Hello, world!

Guarde el archivo y vuelva a su ventana de terminal en el directorio ~/cairo_projects/hello_world. Introduzca los siguientes comandos para compilar y ejecutar el archivo:

$ cairo-run main.cairo
Hello, world!

Independientemente de su sistema operativo, la cadena Hello, world! debería imprimirse en el terminal.

Si Hello, world! se imprime, ¡enhorabuena! Has escrito oficialmente un programa en Cairo. Eso te convierte en un programador de Cairo ¡Bienvenido!

Anatomía de un Programa Cairo

Revisemos este programa "¡Hola, mundo!" en detalle. Aquí está la primera pieza del puzzle:

fn main() {

}

Estas líneas definen una función llamada main. La función main es especial: es es siempre el primer código que se ejecuta en cada programa ejecutable de El Cairo. Aquí, la primera línea declara una función llamada main que no tiene parámetros y devuelve nada. Si hubiera parámetros, irían dentro de los paréntesis ().

El cuerpo de la función está envuelto en {}. Cairo requiere llaves alrededor de todos los cuerpos de función. Es de buen estilo colocar la llave de apertura en la misma línea que la declaración de la función, añadiendo un espacio en medio.

Nota: Si quieres mantener un estilo estándar en todos los proyectos de Cairo, puedes usar la herramienta de formateo automático llamada cairo-format para formatear tu código en un estilo particular (más sobre cairo-format en Apéndice A). El equipo de Cairo ha incluido esta herramienta con la distribución estándar de Cairo, como lo es cairo-run, así que ya debería estar ¡instalada en tu ordenador!

Antes de la declaración de la función principal, la línea use debug::PrintTrait; es responsable de importar un elemento definido en otro módulo. En este caso, estamos importando el elemento PrintTrait de la biblioteca central de Cairo. Haciendo esto, ganamos la habilidad de usar el método print() en tipos de datos que son compatibles con la impresión.

El cuerpo de la función main contiene el siguiente código:

    'Hello, world!'.print();

Esta línea hace todo el trabajo en este pequeño programa: imprime texto en la pantalla. pantalla. Hay cuatro detalles importantes a tener en cuenta aquí.

En primer lugar, el estilo de Cairo es hacer sangrías con cuatro espacios, no con una tabulación.

Segundo, la función print() es un método del trait PrintTrait. Este trait se importa de la librería del núcleo de Cairo, y define cómo imprimir valores en la pantalla para diferentes tipos de datos. En nuestro caso, nuestro texto está definido como una "cadena corta", que es una cadena ASCII que puede caber en el tipo de datos básico de Cairo, que es el tipo felt252. Al llamar a Hello, world!'.print(), estamos llamando al método print() de la implementación felt252 del trait PrintTrait.

En tercer lugar, ves la cadena corta 'Hello, world!' Pasamos esta cadena corta como argumento a print(), y la cadena corta se imprime en la pantalla.

Cuarto, terminamos la línea con un punto y coma (;), que indica que esta expresión ha terminado y la siguiente está lista para comenzar. La mayoría de las líneas de código de Cairo terminan con punto y coma.

Sólo ejecutar con cairo-run está bien para programas simples, pero a medida que tu proyecto proyecto crezca, querrá manejar todas las opciones y hacer fácil compartir su código. código. A continuación, te presentaremos la herramienta Scarb, que te ayudará a escribir programas Cairo del mundo real.

Hola, Scarb

Scarb es el gestor de paquetes de Cairo y está fuertemente inspirado en Cargo, el sistema de construcción y gestor de paquetes de Rust.

Scarb maneja muchas tareas por ti, como construir tu código (ya sea Cairo puro o contratos Starknet), descargar las librerías de las que depende tu código, y construir esas librerías.

Si fuéramos a construir el proyecto Hello, world! usando Scarb, sólo la parte de Scarb que maneja la construcción del código sería utilizada, ya que el programa no requiere ninguna dependencia externa. A medida que escriba programas Cairo más complejos, agregará dependencias, y si inicia un proyecto utilizando Scarb, agregar dependencias será mucho más fácil de hacer.

Empecemos instalando Scarb.

Instalando Scarb

Requisitos

Scarb requiere un ejecutable Git disponible en la variable de entorno PATH.

Instalación

Por ahora, Scarb necesita instalación manual con los siguientes pasos:

  • Descargue el archivo de versiones correspondiente a su sistema operativo y arquitectura de CPU desde Scarb releases on GitHub

  • Extráigalo a una ubicación en la que desee tener Scarb instalado, por ejemplo. ~/scarb

  • Añada la ruta al directorio scarb/bin a su variable de entorno PATH.

    Esto depende del shell que esté utilizando. Tomemos el ejemplo de zsh y has extraido Scarb a ~/scarb:

    • Abra el archivo ~/.zshrc en su editor favorito -Añada la siguiente línea al final del archivo: export PATH="$PATH:~/scarb/bin"
  • Verifique la instalación ejecutando el siguiente comando en una nueva sesión de terminal, debería imprimir las versiones en Scarb y Cairo, por ejemplo:

    $ scarb --version
    scarb 0.1.0 (289137c24 2023-03-28)
    cairo: 1.0.0-alpha.6
    

Creando un Proyecto con Scarb

Vamos a crear un nuevo proyecto utilizando Scarb y ver en qué se diferencia de nuestro proyecto original “Hello, world!”.

Navegue de nuevo a su directorio de proyectos (o donde haya decidido almacenar su código). Luego ejecute lo siguiente:

$ scarb new hello_scarb

Crea un nuevo directorio y proyecto llamado hello_scarb. Hemos llamado a nuestro proyecto hello_scarb, y Scarb crea sus archivos en un directorio con el mismo nombre.

Entre en el directorio hello_scarb con el comando cd hello_scarb.Verás que Scarb ha generado dos archivos y un directorio para nosotros: un archivo Scarb.toml y un directorio src con un archivo lib.cairo dentro.

También ha inicializado un nuevo repositorio Git junto con un archivo .gitignore.

Nota: Git es un sistema de control de versiones común. Puede dejar de usar el sistema de control de versiones usando la bandera --vcs. Ejecute scarb new -help para ver las opciones disponibles.

Abra Scarb.toml en su editor de texto preferido. Debería parecerse al código del Listado 1-2.

Filename: Scarb.toml

[package]
name = "hello_scarb"
version = "0.1.0"

# Vea más claves y sus definiciones en https://docs.swmansion.com/scarb/docs/reference/manifest

[dependencies]
# foo = { path = "vendor/foo" }

Listing 1-2: Contents of Scarb.toml generated by scarb new

Este archivo se encuentra en formato TOML (Tom’s Obvious, Minimal Language), que es el formato de configuración de Scarb.

La primera línea, [package], es un encabezado de sección que indica que las siguientes sentencias están configurando un paquete. A medida que agreguemos más información a este archivo, agregaremos otras secciones.

Las siguientes dos líneas establecen la información de configuración que Scarb necesita para compilar su programa: el nombre y la versión de Scarb a utilizar.

La última línea, [dependencies], es el comienzo de una sección para que puedas listar cualquiera de las dependencias de tu proyecto. En Cairo, los paquetes de código se conocen como crates. No necesitaremos ninguna otra crate para este proyecto.

El otro archivo creado por Scarb es src/lib.cairo, borremos todo el contenido y pongamos el siguiente contenido, explicaremos la razón más adelante.

mod hello_scarb;

A continuación, cree un nuevo archivo llamado src/hello_scarb.cairo y ponle el siguiente código:

Filename: src/hello_scarb.cairo

use debug::PrintTrait;
fn main() {
    'Hello, Scarb!'.print();
}

Acabamos de crear un archivo llamado lib.cairo, que contiene una declaración de módulo que hace referencia a otro módulo llamado "hello_scarb", así como el archivo hello_scarb.cairo, que contiene los detalles de implementación del módulo "hello_scarb".

Scarb requiere que sus archivos fuente se encuentren dentro del directorio src.

El directorio de proyecto de nivel superior está reservado para los archivos README, información de licencia, archivos de configuración, y cualquier otro contenido no relacionado con el código. Scarb asegura una ubicación designada para todos los componentes del proyecto, manteniendo una organización estructurada.

Si ha iniciado un proyecto que no utiliza Scarb, como hicimos con el proyecto “Hello, world!”, puede convertirlo en un proyecto que utilice Scarb. Mueva el código del proyecto al directorio src y cree un archivo Scarb.toml apropiado.

Creación de un proyecto Scarb

Desde su directorio hello_scarb, construya su proyecto introduciendo el siguiente comando:

$ scarb build
   Compiling hello_scarb v0.1.0 (file:///projects/Scarb.toml)
    Finished release target(s) in 0 seconds

Este comando crea un archivo sierra en target/release, ignoremos el archivo sierra por ahora.

Si has instalado Cairo correctamente, deberías ser capaz de ejecutarlo y ver la siguiente salida:

$ cairo-run src/lib.cairo
[DEBUG] Hello, Scarb!                   (raw: 5735816763073854913753904210465)

Run completed successfully, returning []

Nota: Notarás aquí que no usamos un comando de Scarb, sino un comando de los binarios de Cairo directamente. Como Scarb no tiene un comando para ejecutar código de Cairo, tenemos que usar el comando cairo-run directamente. Usaremos este comando en el resto del tutorial, pero también usaremos comandos de Scarb para inicializar proyectos.

Definición de scripts personalizados

Podemos definir scripts scarb en el archivo Scarb.toml, que puede ser usado para ejecutar scripts shell personalizados.

Añada la siguiente línea a su fichero Scarb.toml:

[scripts]
run-lib = "cairo-run src/lib.cairo"

Ahora puede ejecutar el siguiente comando para ejecutar el proyecto:

$ scarb run run-lib
[DEBUG] Hello, Scarb!                   (raw: 5735816763073854913753904210465)

Run completed successfully, returning []

Usar scarb run es una forma conveniente de ejecutar scripts de shell personalizados que pueden ser útiles para ejecutar archivos y probar su proyecto.

Recapitulemos lo que hemos aprendido hasta ahora sobre Scarb:

  • Podemos crear un proyecto usando scarb new.
  • Podemos construir un proyecto usando scarb build para generar el código Sierra compilado.
  • Podemos definir scripts personalizados en Scarb.toml y llamarlos con el comando scarb run.

Una ventaja adicional de usar Scarb es que los comandos son los mismos sin importar el sistema operativo en el que estemos trabajando. Así que, en este punto, ya no proporcionaremos instrucciones específicas para Linux y macOS frente a Windows.

Resumen

Ya has empezado con buen pie tu viaje en Cairo. En este capítulo, has aprendido cómo:

  • Instalar la última versión estable de Cairo
  • Escribir y ejecutar un programa " Hello, world!" usando cairo-run directamente
  • Crear y ejecutar un nuevo proyecto usando las convenciones de Scarb

Este es un buen momento para construir un programa más sustancial para acostumbrarte a leer y escribir código de Cairo.

Conceptos comunes de programación

Este capítulo cubre conceptos que aparecen en casi todos los lenguajes de programación y cómo funcionan en Cairo. Muchos lenguajes de programación tienen mucho en común en su núcleo. Ninguno de los conceptos presentados en este capítulo son exclusivos de Cairo, pero los discutiremos en el contexto de Cairo y explicaremos las convenciones sobre el uso de estos conceptos.

Específicamente, aprenderás sobre variables, tipos básicos, funciones, comentarios y flujo de control. Estos fundamentos estarán en cada programa de Cairo, y aprenderlos desde el principio te dará un núcleo fuerte desde el que empezar.

Variables y mutabilidad

Cairo usa un modelo de memoria inmutable, lo que significa que una vez que se escribe en una celda de memoria, no puede ser sobrescrita sino sólo leída. Para reflejar este modelo de memoria inmutable, las variables en Cairo son inmutables por defecto. Sin embargo, el lenguaje abstrae este modelo y te da la opción de hacer tus variables mutables. Exploremos cómo y por qué Cairo impone la inmutabilidad, y cómo puedes hacer tus variables mutables.

Cuando una variable es inmutable, una vez que un valor está ligado a un nombre, no puedes cambiar ese valor. Para ilustrar esto, genera un nuevo proyecto llamado variables en tu directorio cairo_projects usando scarb new variables.

A continuación, en su nuevo directorio variables, abra src/lib.cairo y sustituya su código por el siguiente, que todavía no compilará:

Filename: src/lib.cairo

use debug::PrintTrait;
fn main() {
    let x = 5;
    x.print();
    x = 6;
    x.print();
}

Guarde y ejecute el programa utilizando cairo-run src/lib.cairo. Debería recibir un mensaje de error relativo a un error de inmutabilidad, como se muestra en esta salida:

error: Cannot assign to an immutable variable.
 --> lib.cairo:5:5
    x = 6;
    ^***^

Error: failed to compile: src/lib.cairo

Este ejemplo muestra cómo el compilador te ayuda a encontrar errores en tus programas. Los errores del compilador pueden ser frustrantes, pero en realidad sólo significan que su programa todavía no está haciendo con seguridad lo que usted quiere que haga; ¡no significan que usted no sea un buen programador! Los Caironautas experimentados siguen teniendo errores de compilador.

Recibiste el mensaje de error Cannot assign to an immutable variable. porque intentaste asignar un segundo valor a la variable inmutable x.

Es importante que obtengamos errores en tiempo de compilación cuando intentamos cambiar un valor designado como inmutable porque esta situación específica puede conducir a errores. Si una parte de nuestro código opera bajo la suposición de que un valor nunca cambiará y otra parte de nuestro código cambia ese valor, es posible que la primera parte del código no haga lo que fue diseñada para hacer. La causa de este tipo de error puede ser difícil de rastrear después de los hechos, especialmente cuando la segunda parte del código cambia el valor sólo a veces. El compilador Cairo garantiza que cuando dices que un valor no cambiará, realmente no cambiará, por lo que no tiene que hacer un seguimiento. Su código es así más fácil de razonar.

Pero la mutabilidad puede ser muy útil, y puede hacer que el código sea más cómodo de escribir. Aunque las variables son inmutables por defecto, puedes hacerlas mutables añadiendo mut delante del nombre de la variable. Añadir mut también transmite intención a los futuros lectores del código indicando que otras partes del código cambiarán el valor de esta variable.

Sin embargo, puede que en este punto te estés preguntando qué ocurre exactamente cuando una variable es declarada como mut, ya que previamente mencionamos que la memoria de Cairo es inmutable. La respuesta es que la memoria de Cairo es inmutable, pero la dirección de memoria a la que apunta la variable puede ser cambiada. Al examinar el código ensamblador de bajo nivel de Cairo, queda claro que la mutación de variables se implementa como azúcar sintáctico, que traduce las operaciones de mutación en una serie de pasos equivalentes al shadowing de variables. La única diferencia es que en el nivel la variable no se vuelve a declarar, por lo que su tipo no puede cambiar.

Por ejemplo, cambiemos src/lib.cairo por lo siguiente:

Filename: src/lib.cairo

use debug::PrintTrait;
fn main() {
    let mut x = 5;
    x.print();
    x = 6;
    x.print();
}

Cuando ejecutamos el programa ahora, obtenemos esto:

❯ cairo-run src/lib.cairo
[DEBUG]	                              	(raw: 5)

[DEBUG]	                              	(raw: 6)

Run completed successfully, returning []

Se nos permite cambiar el valor ligado a x de 5 a 6 cuando se usa mut. En última instancia, la decisión de utilizar la mutabilidad o no es suya y depende de lo que usted piensa que es más claro en esa situación particular.

Constantes

Al igual que las variables inmutables, las constantes son valores que están vinculados a un nombre y no se les permite cambiar, pero hay algunas diferencias entre las constantes y las variables.

En primer lugar, no se permite el uso de mut con las constantes. Las constantes no son solo inmutables por defecto, sino que siempre son inmutables. Se declaran constantes usando la palabra clave const en lugar de la palabra clave let, y el tipo de valor debe ser anotado. Cubriremos los tipos y las anotaciones de tipo en la próxima sección, [“Tipos de datos”][tipos-de-datos], así que no se preocupe por los detalles por ahora. Solo sepa que siempre debe anotar el tipo.

Las constantes solo se pueden declarar en el ámbito global, lo que las hace útiles para valores que muchas partes del código deben conocer.

La última diferencia es que las constantes solo pueden ser asignadas a una expresión constante, no al resultado de un valor que solo se podría calcular en tiempo de ejecución. Actualmente, solo se admiten constantes literales.

Aquí hay un ejemplo de declaración de constante:

const ONE_HOUR_IN_SECONDS: u32 = 3600_u32;

La convención de nomenclatura de Cairo para las constantes es usar todas las mayúsculas con guiones bajos entre palabras.

La convención de nombres de constantes en Cairo es utilizar todo en mayúsculas con guiones bajos entre palabras.

Las constantes son válidas durante todo el tiempo que se ejecuta un programa, dentro del ámbito en el que fueron declaradas. Esta propiedad hace que las constantes sean útiles para los valores en el dominio de su aplicación que varias partes del programa podrían necesitar conocer, como el número máximo de puntos que cualquier jugador de un juego puede ganar o la velocidad de la luz.

Nombrar los valores codificados en duro utilizados en todo el programa como constantes es útil para transmitir el significado de ese valor a los futuros mantenedores del código. También ayuda a tener solo un lugar en su código donde tendría que cambiar si el valor codificado en duro necesitara ser actualizado en el futuro.

Shadowing

La sombra de una variable se refiere a la declaración de una nueva variable con el mismo nombre que una variable anterior. Los caironautas dicen que la primera variable está sombreada por la segunda, lo que significa que el compilador verá la segunda variable cuando use el nombre de la variable. En efecto, la segunda variable oculta la primera, tomando cualquier uso del nombre de la variable para sí misma hasta que ella misma sea sombreada o que el ámbito termine. Podemos sombrear una variable usando el mismo nombre de la variable y repitiendo el uso de la palabra clave let de la siguiente manera:

Filename: src/lib.cairo

use debug::PrintTrait;
fn main() {
    let x = 5;
    let x = x + 1;
    {
        let x = x * 2;
        'Inner scope x value is:'.print();
        x.print()
    }
    'Outer scope x value is:'.print();
    x.print();
}

Este programa primero asigna un valor de 5 a x. Luego crea una nueva variable x repitiendo let x =, tomando el valor original y sumando 1, por lo que el valor de x es ahora 6. Luego, dentro de un ámbito interno creado con llaves, la tercera instrucción let también sombrea x y crea una nueva variable, multiplicando el valor anterior por 2 para darle a x un valor de 12. Cuando ese ámbito termina, la sombra interna termina y x vuelve a ser 6. Al ejecutar este programa, se mostrará lo siguiente:

cairo-run src/lib.cairo
[DEBUG]	Inner scope x value is:        	(raw: 7033328135641142205392067879065573688897582790068499258)

[DEBUG]
                                      	(raw: 12)

[DEBUG]	Outer scope x value is:        	(raw: 7610641743409771490723378239576163509623951327599620922)

[DEBUG]	                              	(raw: 6)

Run completed successfully, returning []

El sombreado es diferente de marcar una variable como mut, porque obtendremos un error en tiempo de compilación si intentamos reasignar a esta variable sin usar la palabra clave let. Al usar let, podemos realizar algunas transformaciones en un valor pero hacer que la variable sea inmutable después de que se hayan completado esas transformaciones.

Otra diferencia entre mut y el sombreado es que al usar la palabra clave let nuevamente, estamos creando efectivamente una nueva variable, lo que nos permite cambiar el tipo del valor mientras reutilizamos el mismo nombre. Como se mencionó antes, el sombreado de variables y las variables mutables son equivalentes a un nivel más bajo. La única diferencia es que al sombrear una variable, el compilador no se quejará si cambia su tipo. Por ejemplo, digamos que nuestro programa realiza una conversión de tipo entre los tipos u64 y felt252.

use debug::PrintTrait;
use traits::Into;
fn main() {
    let x = 2_u64;
    x.print();
    let x = x.into(); // converts x to a felt.
    x.print()
}

LEl primer variable x tiene un tipo u64, mientras que la segunda variable x tiene un tipo felt252. Por lo tanto, el shadowing nos ahorra tener que inventar diferentes nombres, como x_u64 y x_felt252; en su lugar, podemos reutilizar el nombre más simple x. Sin embargo, si intentamos usar mut para esto, como se muestra aquí, obtendremos un error en tiempo de compilación:

use debug::PrintTrait;
use traits::Into;
fn main() {
    let mut x = 2_u64;
    x.print();
    x = x.into();
    x.print()
}

El error dice que se esperaba un u64 (el tipo original) pero se obtuvo un tipo diferente:

❯ cairo-run src/lib.cairo
error: Unexpected argument type. Expected: "core::integer::u64", found: "core::felt252".
 --> lib.cairo:6:9
    x = x.into();
        ^******^

Error: failed to compile: src/lib.cairo

Ahora que hemos explorado cómo funcionan las variables, veamos otros tipos de datos que pueden tener.

Tipos de datos

Cada valor en Cairo tiene un cierto tipo de dato, lo que le dice a Cairo qué tipo de datos se están especificando para que sepa cómo trabajar con esos datos. Esta sección cubre dos subconjuntos de tipos de datos: escalares y compuestos.

Tenga en cuenta que Cairo es un lenguaje de tipado estático, lo que significa que debe conocer los tipos de todas las variables en tiempo de compilación. El compilador suele inferir el tipo deseado en función del valor y su uso. En casos en que pueden ser posibles varios tipos, podemos utilizar un método de conversión donde especificamos el tipo de salida deseado.

use traits::TryInto;
use option::OptionTrait;
fn main(){
    let x = 3;
    let y:u32 = x.try_into().unwrap();
}

Verá diferentes anotaciones de tipo para otros tipos de datos.

Tipos escalares

Un tipo scalar representa un único valor. Cairo tiene tres tipos escalares primarios: fieltros, enteros y booleanos. Puede que reconozca de otros lenguajes de programación. Veamos cómo funcionan en Cairo.

Tipo Felt

En Cairo, si no especificas el tipo de una variable o argumento, su tipo por defecto es un elemento de campo, representado por la palabra clave felt252. En el contexto de Cairo, cuando decimos "un elemento de campo" nos referimos a un entero en el rango 0 <= x < P, donde P es un número primo muy grande actualmente igual a P = 2^{251} + 17 * 2^{192}+1. Al sumar, restar o multiplicar, si el resultado queda fuera del rango especificado del número primo, se produce un desbordamiento y se suma o resta un múltiplo apropiado de P para que el resultado vuelva a estar dentro del rango (es decir, el resultado se calcula módulo P).

La diferencia más importante entre los números enteros y los elementos de campo es la división: La división de elementos de campo (y, por tanto, la división en Cairo) es distinta de la división normal de las CPU, en la que la división entera x / y se define como [x/y] donde se devuelve la parte entera del cociente (por lo que se obtiene 7 / 3 = 2) y puede o no satisfacer la ecuación (x / y) * y == x, dependiendo de la divisibilidad de x por y.

En Cairo, el resultado de x/y está definido para satisfacer siempre la ecuación (x / y) * y == x. Si y divide a x entre enteros, obtendrás el resultado esperado en Cairo (por ejemplo 6 / 2 dará como resultado 3). Pero cuando y no divide a x, puedes obtener un resultado sorprendente: Por ejemplo, como 2 * ((P+1)/2) = P+1 ≡ 1 mod[P], el valor de 1 / 2 en Cairo es (P+1)/2 (y no 0 ó 0,5), ya que satisface la ecuación anterior.

Tipos enteros

El tipo felt252 es un tipo fundamental que sirve como base para la creación de todos los tipos en la librería central. Sin embargo, se recomienda encarecidamente a los programadores que utilicen los tipos enteros en lugar del tipo felt252 siempre que sea posible, ya que los tipos integer vienen con características de seguridad añadidas que proporcionan protección extra contra posibles vulnerabilidades en el código, como comprobaciones de desbordamiento. Utilizando estos tipos de enteros, los programadores pueden asegurarse de que sus programas son más seguros y menos susceptibles a ataques u otras amenazas de seguridad.

Un integer es un número sin componente fraccionario. Esta declaración de tipo indica el número de bits que el programador puede utilizar para almacenar el entero.

La Tabla 3-1 muestra los tipos enteros incorporados en Cairo. Podemos usar cualquiera de estas variantes para declarar el tipo de un valor entero.

Table 3-1: Integer Types in Cairo

LengthUnsigned
8-bitu8
16-bitu16
32-bitu32
64-bitu64
128-bitu128
256-bitu256
32-bitusize

Cada variante tiene un tamaño explícito. Tenga en cuenta que por ahora, el tipo usize es sólo un alias para u32; sin embargo, podría ser útil cuando en el futuro Cairo pueda ser compilado a MLIR. Como las variables son sin signo, no pueden contener un número negativo. Este código hará que el programa entre en pánico:

fn sub_u8s(x: u8, y: u8) -> u8 {
    x - y
}

fn main() {
    sub_u8s(1,3);
}

Puede escribir literales enteros en cualquiera de las formas mostradas en la Tabla 3-2. Observe que los literales numéricos que pueden ser múltiples tipos numéricos permiten un sufijo de tipo como 57_u8, para designar el tipo.

Table 3-2: Integer Literals in Cairo

Numeric literalsExample
Decimal98222
Hex0xff
Octal0o04321
Binary0b01

Entonces, ¿cómo saber qué tipo de entero utilizar? Intenta estimar el valor máximo que puede tener tu int y elige un buen tamaño. La principal situación en la que usarías usize es al indexar algún tipo de colección.

Operaciones numéricas

Cairo soporta las operaciones matemáticas básicas que esperarías para todos los tipos de enteros: suma, resta, multiplicación y resto (u256 no soporta división y resto todavía). Entero trunca hacia cero al entero más cercano. El siguiente código muestra cómo utilizar cada operación numérica en una sentencia let:

fn main() {
     // addition
    let sum = 5_u128 + 10_u128;

    // subtraction
    let difference = 95_u128 - 4_u128;

    // multiplication
    let product = 4_u128 * 30_u128;

    // division
    let quotient = 56_u128 / 32_u128; //result is 1
    let quotient = 64_u128 / 32_u128; //result is 2

    // remainder
    let remainder = 43_u128 % 5_u128; // result is 3
}

Cada expresión de estas sentencias utiliza un operador matemático y se evalúa a un único valor, que se asigna a una variable.

El tipo Booleano

Como en la mayoría de los lenguajes de programación, un tipo booleano en Cairo tiene dos posibles valores: true y false. Los booleanos tienen un byte de tamaño. El tipo booleano en Cairo se especifica usando bool. Por ejemplo:

fn main() {
    let t = true;

    let f: bool = false; // with explicit type annotation
}

La principal forma de utilizar valores booleanos es a través de condicionales, como una expresión if expresión. Cubriremos cómo funcionan las expresiones if en Cairo en la sección. “Control de flujo”

El tipo de Short String

Cairo no tiene un tipo nativo para strings, pero puedes almacenar caracteres formando lo que llamamos un "short string" dentro de felt252. Aquí hay algunos ejemplos de declaración de valores entre comillas simples:

let my_first_char = 'C';
let my_first_string = 'Hello world';

Conversión de Tipos

En Cairo, puedes convertir valores entre tipos escalares comunes y felt252 usando los métodos try_into e into proporcionados por los traits TryInto e Into, respectivamente.

El método try_into permite una conversión de tipos segura cuando el tipo de destino puede no encajar con el valor de origen. Ten en cuenta que try_into devuelve un tipo Option<T>, que tendrás que desenvolver para acceder al nuevo valor.

Por otro lado, el método into se puede utilizar para la conversión de tipos cuando el éxito está garantizado, como cuando el tipo de destino es más pequeño que el tipo de origen.

Para realizar la conversión, llame a var.into() o var.try_into() sobre el valor fuente para convertirlo a otro tipo. El tipo de la nueva variable debe definirse explícitamente, como se muestra en el siguiente ejemplo.

use traits::TryInto;
use traits::Into;
use option::OptionTrait;

fn main(){
    let my_felt = 10;
    let my_u8: u8 = my_felt.try_into().unwrap(); // Since a felt252 might not fit in a u8, we need to unwrap the Option<T> type
    let my_u16: u16 = my_felt.try_into().unwrap();
    let my_u32: u32 = my_felt.try_into().unwrap();
    let my_u64: u64 = my_felt.try_into().unwrap();
    let my_u128: u128 = my_felt.try_into().unwrap();
    let my_u256: u256 = my_felt.into(); // As a felt252 is smaller than a u256, we can use the into() method
    let my_usize: usize = my_felt.try_into().unwrap();
    let my_felt2: felt252 = my_u8.into();
    let my_felt3: felt252 = my_u16.into();
}

Tipos compuestos

Los tipos compuestos pueden agrupar varios valores en un tipo. Cairo tiene dos tipos compuestos primitivos: Tuplas y Matrices.

El tipo tupla

Una tupla es una forma general de agrupar un número de valores con una variedad de tipos en un tipo compuesto. Las tuplas tienen una longitud fija: una vez declaradas, no pueden aumentar ni disminuir de tamaño.

Se crea una tupla escribiendo una lista de valores separados por comas entre paréntesis. Cada posición de la tupla tiene un tipo, y los tipos de los distintos valores de la tupla no tienen por qué ser iguales. Hemos añadido anotaciones opcionales de tipo en este ejemplo:

fn main() {
    let tup: (u32,u64,bool) = (10,20,true);
}

La variable tup se vincula a toda la tupla porque una tupla se considera un único elemento compuesto. Para obtener los valores individuales de una tupla, podemos utilizar la concordancia de patrones para desestructurar un valor de tupla, así:

use debug::PrintTrait;
fn main() {
    let tup = (500, 6, true);

    let (x, y, z) = tup;

    if y == 6 {
        'y is six!'.print();
    }
}

Este programa crea primero una tupla y la asocia a la variable tup. A continuación, utiliza un patrón con let para tomar tup y convertirla en tres variables separadas, x, y, y z. Esto se llama desestructuración porque divide la tupla en tres partes. Finalmente, el programa imprime y es seis ya que el valor de y es 6.

También podemos declarar la tupla con valor y nombre al mismo tiempo. Por ejemplo:

fn main() {
    let tup: (x: felt, y: felt) = (2,3);
}

El tipo Array

Otra forma de tener una colección de múltiples valores es con un array. A diferencia de cada elemento de un array debe tener el mismo tipo. Puedes crear y utilizar métodos de array importando el trait array::ArrayTrait.

Una cosa importante a tener en cuenta es que los arrays son append-only. Esto significa que sólo puedes añadir elementos al final de un array. Los arrays son, de hecho, colas cuyos valores no se pueden saltar ni modificar. Esto tiene que ver con el hecho de que una vez que se escribe en un espacio de memoria, no se puede sobrescribir, sino sólo leer de él.

He aquí un ejemplo de creación de un array con 3 elementos:

use array::ArrayTrait;

fn main() {
    let mut a = ArrayTrait::new();
    a.append(0);
    a.append(1);
    a.append(2);
}

Es posible eliminar un elemento de la parte frontal de un array llamando al método pop_front():


use option::OptionTrait;
use array::ArrayTrait;
use debug::PrintTrait;

fn main() {
    let mut a = ArrayTrait::new();
    a.append(10);
    a.append(1);
    a.append(2);

    let first_value = a.pop_front().unwrap();
    first_value.print();

}

El código anterior imprimirá 10 cuando eliminemos el primer elemento añadido.

Puedes pasar el tipo esperado de elementos dentro del array al instanciar el array así

let mut arr = ArrayTrait::<u128>::new();
Acceso a los elementos de una matriz

Para acceder a los elementos de un array, puedes utilizar los métodos get() o at() que devuelven diferentes tipos. Utilizar arr.at(index) es equivalente a utilizar el operador de subíndice arr[index].

La función get devuelve una Option<Box<@T>>, lo que significa que devuelve una opción a un tipo Box (el tipo smart-pointer de Cairo) que contiene una instantánea al elemento en el índice especificado si ese elemento existe en el array. Si el elemento no existe, get devuelve None. Este método es útil cuando esperas acceder a índices que pueden no estar dentro de los límites del array y quieres manejar tales casos con gracia sin pánicos. Las instantáneas se explicarán con más detalle en el capítulo Referencias y Snapshots.

La función at, por otro lado, devuelve directamente una instantánea al elemento en el índice especificado utilizando el operador unbox() para extraer el valor almacenado en una caja. Si el índice está fuera de los límites, se produce un error de pánico. Sólo debe utilizar at cuando desee que el programa entre en pánico si el índice proporcionado está fuera de los límites del array, lo que puede evitar comportamientos inesperados.

En resumen, usa at cuando quieras que el programa entre en pánico ante intentos de acceso fuera de los límites, y usa get cuando prefieras manejar estos casos con gracia sin entrar en pánico.

fn main() {
    let mut a = ArrayTrait::new();
    a.append(0);
    a.append(1);

    let first = *a.at(0_usize);
    let second = *a.at(1_usize);
}

En este ejemplo, la variable llamada first obtendrá el valor 0 porque es el valor del índice 0 del array. La variable llamada second obtendrá el valor 1 del índice 1 del array.

use array::ArrayTrait;
use box::BoxTrait;
fn main() -> u128 {
    let mut arr = ArrayTrait::<u128>::new();
    arr.append(100_u128);
    let length = arr.len();
    match arr.get(length - 1_usize) {
        Option::Some(x) => {
            *x.unbox()
        },
        Option::None(_) => {
            let mut data = ArrayTrait::new();
            data.append('out of bounds');
            panic(data)
        }
    } // returns 100
}

El ejemplo anterior muestra cómo podemos hacer una gestión de errores utilizando el método get en lugar del método at.

Funciones

Las funciones son frecuentes en el código de Cairo. Ya has visto una de las funciones más importantes del lenguaje: la función main, que es el punto de entrada de muchos programas. También has visto la palabra clave fn, que te permite declarar nuevas funciones.

El código de Cairo usa snake case como estilo convencional para los nombres de funciones y variables, en el que todas las letras están en minúsculas y los guiones bajos separan las palabras. Aquí hay un programa que contiene un ejemplo de definición de función:

use debug::PrintTrait;

fn another_function() {
    'Another function.'.print();
}

fn main() {
    'Hello, world!'.print();
    another_function();
}

Definimos una función en Cairo introduciendo fn seguido de un nombre de función y un conjunto de paréntesis. Las llaves indican al compilador dónde empieza y termina el cuerpo de la función.

Podemos llamar a cualquier función que hayamos definido introduciendo su nombre seguido de un conjunto de paréntesis. Como another_function está definida en el programa, puede ser llamada desde dentro de la función main. Ten en cuenta que hemos definido "another_function" antes de la función main en el código fuente; también podríamos haberla definido después. A Cairo no le importa dónde definas tus funciones, sólo que estén definidas en algún lugar en un ámbito que pueda ser visto por quien las llama.

Empecemos un nuevo proyecto con Scarb llamado functions para explorar las funciones. Coloque el ejemplo another_function en src/lib.cairo y ejecútelo. Usted Debería ver la siguiente salida:

$ cairo-run src/lib.cairo
[DEBUG] Hello, world!                (raw: 5735816763073854953388147237921)
[DEBUG] Another function.            (raw: 22265147635379277118623944509513687592494)

Las líneas se ejecutan en el orden en que aparecen en la función main. Primero se imprime el mensaje " Hello, world!", y luego se llama a another_function y se imprime su mensaje.

Parámetros

Podemos definir funciones para que tengan parámetros, que son variables especiales que forman parte de la firma de una función. Cuando una función tiene parámetros, puede proporcionarle valores concretos para esos parámetros. Técnicamente, los valores se llaman argumentos, pero en una conversación informal, la gente tiende a usar las palabras parámetro y argumento indistintamente para las variables en la definición de una función o los valores concretos pasados cuando se llama a una función.

En esta versión de another_function añadimos un parámetro:

use debug::PrintTrait;

fn main() {
    another_function(5);
}

fn another_function(x: felt252) {
    x.print();
}

Intente ejecutar este programa; debería obtener la siguiente salida:

$ cairo-run src/lib.cairo
[DEBUG]                                 (raw: 5)

La declaración de another_function tiene un parámetro llamado x. El tipo de x se especifica como felt252. Cuando pasamos 5 a another_function, la función print() muestra 5 en la consola.

En las firmas de función, debes declarar el tipo de cada parámetro. Esta es una decisión deliberada en el diseño de Cairo: requerir anotaciones de tipo en las significa que el compilador casi nunca necesita usarlas en otra parte del código el código para averiguar a qué tipo se refiere. El compilador también es capaz de dar mensajes de error más útiles si sabe qué tipos espera la función.

Cuando defina múltiples parámetros, separe las declaraciones de parámetros con comas, así:

use debug::PrintTrait;

fn main() {
    another_function(5,6);
}

fn another_function(x: felt252, y:felt252) {
    x.print();
    y.print();
}

Este ejemplo crea una función llamada another_function con dos parámetros. El primer parámetro se llama x y es un felt252. El segundo se llama y y también es del tipo felt252. La función luego imprime el contenido del x y luego el contenido del y.

Intentemos ejecutar este código. Reemplaza el programa actualmente en el archivo src/lib.cairo de tu proyecto functions con el ejemplo anterior y ejecútalo usando cairo-run src/lib.cairo.

$ cairo-run src/lib.cairo
[DEBUG]                                 (raw: 5)
[DEBUG]                                 (raw: 6)

Debido a que llamamos a la función con 5 como valor para x y 6 como valor para y, la salida del programa contiene esos valores.

Sentencias y expresiones

Los cuerpos de las funciones están compuestos por una serie de sentencias que terminan opcionalmente en una expresión. Hasta ahora, las funciones que hemos cubierto no han incluido una expresión final, pero ya has visto una expresión como parte de una sentencia. Como Cairo es un lenguaje basado en expresiones, esta es una distinción importante que debemos entender. Otros lenguajes no tienen las mismas distinciones, así que veamos qué son las sentencias y expresiones y cómo sus diferencias afectan los cuerpos de las funciones.

  • Sentencias son instrucciones que realizan alguna acción y no devuelven un valor.
  • Expresiones se evalúan para producir un valor resultante. Veamos algunos ejemplos.

De hecho, ya hemos utilizado sentencias y expresiones. Crear una variable y asignarle un valor con la palabra clave let es una sentencia. En el Listado 3-1, let y = 6; es una sentencia.

fn main() {
    let y = 6;
}

Listado 3-1: Una declaración de función main que contiene una sentencia

Las definiciones de funciones también son sentencias; todo el ejemplo anterior es una sentencia en sí misma.

Las sentencias no devuelven valores. Por lo tanto, no se puede asignar una sentencia let a otra variable, como intenta hacer el siguiente código; se producirá un error:

fn main() {
    let x = (let y = 6);
}

Cuando ejecutes este programa, el error que obtendrás se verá así:

$ cairo-run src/lib.cairo
error: Missing token TerminalRParen.
 --> src/lib.cairo:2:14
    let x = (let y = 6);
             ^

error: Missing token TerminalSemicolon.
 --> src/lib.cairo:2:14
    let x = (let y = 6);
             ^

error: Missing token TerminalSemicolon.
 --> src/lib.cairo:2:14
    let x = (let y = 6);
                      ^

error: Skipped tokens. Expected: statement.
 --> src/lib.cairo:2:14
    let x = (let y = 6);

La declaración let y = 6 no devuelve un valor, por lo que no hay nada a lo que x pueda enlazar. Esto es diferente de lo que sucede en otros lenguajes, como C y Ruby, donde la asignación devuelve el valor de la asignación. En esos lenguajes, puedes escribir x = y = 6 y tanto x como y tendrán el valor 6; esto no es así en Cairo.

Las expresiones evalúan a un valor y componen la mayor parte del código que escribirás en Cairo. Considera una operación matemática, como 5 + 6, que es una expresión que evalúa al valor 11. Las expresiones pueden formar parte de las declaraciones: en el Listado 3-1, el 6 en la declaración let y = 6; es una expresión que evalúa al valor 6. Llamar a una función es una expresión. Un bloque de ámbito nuevo creado con llaves es una expresión, por ejemplo:

use debug::PrintTrait;
fn main() {
    let y = {
        let x = 3;
        x + 1
    };

    y.print();
}

Esta expresión:

{
    let x = 3;
    x + 1
}

Este bloque de código, en este caso, se evalúa como 4. Ese valor se asigna a y como parte de la declaración let. Ten en cuenta que la línea x + 1 no tiene un punto y coma al final, lo que es diferente a la mayoría de las líneas que has visto hasta ahora. Las expresiones no incluyen un punto y coma al final. Si agregas un punto y coma al final de una expresión, la conviertes en una declaración, y en ese caso no se devolverá ningún valor. Tenlo en cuenta mientras exploras los valores de retorno de las funciones y las expresiones a continuación.

Funciones con valores de retorno

Las funciones pueden devolver valores al código que las llama. No nombramos los valores de retorno, pero debemos declarar su tipo después de una flecha (->). En Cairo, el valor de retorno de la función es sinónimo del valor de la última expresión en el bloque del cuerpo de una función. Puede salir temprano de una función usando la palabra clave return y especificando un valor, pero la mayoría de las funciones devuelven la última expresión implícitamente. Aquí hay un ejemplo de una función que devuelve un valor:

use debug::PrintTrait;

fn five() -> u32 {
    5_u32
}

fn main() {
    let x = five();
    x.print();
}

No hay llamadas a funciones ni declaraciones let en la función five, solo el número 5 por sí mismo. Esa es una función perfectamente válida en Cairo. Observa que se especifica el tipo de retorno de la función como -> u32. Intenta ejecutar este código; la salida debería verse así:

$ cairo-run src/lib.cairo
[DEBUG]                                 (raw: 5)

El 5 en five es el valor de retorno de la función, por eso el tipo de retorno es u32. Vamos a examinar esto con más detalle. Hay dos partes importantes: en primer lugar, la línea let x = five(); muestra que estamos usando el valor de retorno de una función para inicializar una variable. Debido a que la función five devuelve un 5, esa línea es lo mismo que:

let x = 5;

Segundo, la función five no tiene parámetros y define el tipo del valor de retorno, pero el cuerpo de la función es simplemente 5 sin un punto y coma porque es una expresión cuyo valor queremos retornar.

Veamos otro ejemplo:

use debug::PrintTrait;

fn main() {
    let x = plus_one(5_u32);

    x.print();
}

fn plus_one(x: u32) -> u32 {
    x + 1_u32
}

Al ejecutar este código se imprimirá [DEBUG] (raw: 6). Pero si agregamos un punto y coma al final de la línea que contiene x + 1, cambiándola de una expresión a una declaración, obtendremos un error:

use debug::PrintTrait;

fn main() {
    let x = plus_one(5_u32);

    x.print();
}

fn plus_one(x: u32) -> u32 {
    x + 1_u32;
}

La compilación de este código produce un error, como se muestra a continuación:

error: Unexpected return type. Expected: "core::integer::u32", found: "()".

El mensaje principal de error, Unexpected return type, revela el problema principal con este código. La definición de la función plus_one dice que devolverá un u32, pero las sentencias no se evalúan a un valor, lo cual se expresa por (), el tipo unit. Por lo tanto, no se devuelve nada, lo que contradice la definición de la función y resulta en un error.

Comentarios

En programas de Cairo, puedes incluir texto explicativo dentro del código mediante comentarios. Para crear un comentario, usa la sintaxis //, después de lo cual cualquier texto en la misma línea será ignorado por el compilador.

Nombre de archivo: comments.cairo

fn main() -> felt252 {
    // start of the function
    1 + 4 // return the sum of 1 and 4
}

Control de flujo

La capacidad de ejecutar cierto código dependiendo de si una condición es verdadera y de ejecutar código repetidamente mientras una condición es verdadera son bloques de construcción básicos en la mayoría de los lenguajes de programación. Las construcciones más comunes que le permiten controlar el flujo de ejecución del código en Cairo son las expresiones if y los bucles.

Expresiones if

Una expresión if le permite ramificar su código según condiciones. Proporciona una condición y luego establece: "Si se cumple esta condición, ejecute este bloque de código. Si no se cumple la condición, no ejecute este bloque de código".

Nombre de archivo: main.cairo

use debug::PrintTrait;

fn main() {
    let number = 3;

    if number == 5 {
        'condition was true'.print();
    } else {
        'condition was false'.print();
    }
}

Todos las expresiones if comienzan con la palabra clave if, seguido de una condición. En este caso, la condición verifica si la variable number tiene un valor igual a 5. Colocamos el bloque de código a ejecutar si la condición es true inmediatamente después de la condición dentro de llaves.

Opcionalmente, también podemos incluir una expresión else, que elegimos hacer aquí, para dar al programa un bloque de código alternativo para ejecutar si la condición se evalúa como false. Si no proporciona una expresión else y la condición es false, el programa simplemente omitirá el bloque if y pasará al siguiente fragmento de código.

Intente ejecutar este código; debería ver la siguiente salida:

$ cairo-run main.cairo
[DEBUG]	condition was false

Intentaré cambiar el valor de number por uno que haga que la condición sea verdadera para ver qué sucede:

    let number = 5;
$ cairo-run main.cairo
condition was true

También vale la pena señalar que la condición en este código debe ser un bool. Si la condición no es un bool, obtendremos un error.

$ cairo-run main.cairo
thread 'main' panicked at 'Failed to specialize: `enum_match<felt252>`. Error: Could not specialize libfunc `enum_match` with generic_args: [Type(ConcreteTypeId { id: 1, debug_name: None })]. Error: Provided generic argument is unsupported.', crates/cairo-lang-sierra-generator/src/utils.rs:256:9

Manejando múltiples condiciones con else if

Puede usar múltiples condiciones combinando if y else en una expresión else if. Por ejemplo:

Nombre del archivo: main.cairo

use debug::PrintTrait;

fn main() {
    let number = 3;

    if number == 12 {
        'number is 12'.print();
    } else if number == 3 {
        'number is 3'.print();
    } else if number - 2 == 1 {
        'number minus 2 is 1'.print();
    } else {
        'number not found'.print();
    }
}

Este programa tiene cuatro posibles caminos que puede seguir. Después de ejecutarlo, debería ver la siguiente salida:

[DEBUG]	number is 3

Cuando este programa se ejecuta, verifica cada expresión if en orden y ejecuta el primer cuerpo para el cual la condición se evalúa como verdadera. Es importante destacar que aunque number - 2 == 1 es verdadero, no vemos la salida number minus 2 is 1'.print(), ni tampoco vemos el texto number is not divisible by 4, 3, or 2 del bloque else. Esto se debe a que Cairo solo ejecuta el bloque correspondiente a la primera condición verdadera que encuentra, y una vez que la encuentra, no verifica las demás. Usar demasiadas expresiones else if puede desordenar el código, por lo que si tienes más de una, es posible que desees refactorizar el código. El capítulo 5 describe una poderosa estructura de control de flujo de Cairo llamada match para estos casos.

Usando if en una declaración let

Dado que if es una expresión, podemos usarla en el lado derecho de una declaración let para asignar el resultado a una variable.

Nombre del archivo: main.cairo

use debug::PrintTrait;

fn main() {
    let condition = true;
    let number = if condition { 5 } else { 6 };

    if number == 5 {
        'condition was true'.print();
    }
}
$ cairo-run main.cairo
[DEBUG]	condition was true

La variable number quedará ligada a un valor basado en el resultado de la expresión if. En este caso, será 5.

Entendiendo el Ownership de Cairo

Cairo es un lenguaje construido alrededor de un sistema de tipos lineales que nos permite asegurarnos estáticamente de que en cada programa de Cairo, un valor se utiliza exactamente una vez. Este sistema de tipos lineales ayuda a prevenir errores en tiempo de ejecución asegurando que las operaciones que podrían causar dichos errores, como escribir dos veces en una celda de memoria, se detecten en tiempo de compilación. Esto se logra implementando un Ownership y prohibiendo la copia y eliminación de valores por defecto. En este capítulo, hablaremos sobre el Ownership de Cairo, así como sobre las referencias y las instantáneas.

¿Qué es Ownership?

Cairo implementa un sistema de propiedad para garantizar la seguridad y corrección de su código compilado. El mecanismo de propiedad complementa el sistema de tipos lineales, que obliga a que los objetos se usen exactamente una vez. Esto ayuda a prevenir operaciones comunes que pueden producir errores en tiempo de ejecución, como referencias ilegales de direcciones de memoria o múltiples escrituras en la misma dirección de memoria, y garantiza la corrección de los programas de Cairo comprobando en tiempo de compilación que todos los diccionarios están aplastados.

Ahora que hemos pasado la sintaxis básica de Cairo, no incluiremos todo el código fn main() { en los ejemplos, así que si estás siguiendo, asegúrate de colocar los siguientes ejemplos dentro de una función main manualmente. Como resultado, nuestros ejemplos serán un poco más concisos, lo que nos permitirá enfocarnos en los detalles reales en lugar del código de plantilla.

Reglas de Ownership

En primer lugar, echemos un vistazo a las reglas de propiedad. Mantenga estas reglas en mente mientras trabajamos a través de los ejemplos que las ilustran:

  • Cada valor en Cairo tiene un propietario.
  • Solo puede haber un propietario a la vez.
  • Cuando el propietario sale del ámbito, el valor será descartado.

Ámbito de Variables

Como primer ejemplo de propiedad, veremos el ámbito de algunas variables. Un ámbito es el alcance dentro de un programa para el cual un elemento es válido. Tomemos la siguiente variable:

let s = 'hello';

La variable s hace referencia a una cadena corta, donde el valor de la cadena está codificado en el texto de nuestro programa. La variable es válida desde el momento en que se declara hasta el final del ámbito actual. La Lista 3-1 muestra un programa con comentarios que anotan dónde sería válida la variable s.

    {                      // s is not valid here, it’s not yet declared
        let s = 'hello';   // s is valid from this point forward

        // do stuff with s
    }                      // this scope is now over, and s is no longer valid

Lista 3-1: Una variable y el ámbito en el que es válida

En otras palabras, hay dos puntos importantes en el tiempo aquí:

  • Cuando s entra en el ámbito, es válida.
  • Permanece válida hasta que sale del ámbito.

En este punto, la relación entre los ámbitos y cuándo las variables son válidas es similar a la de otros lenguajes de programación. Ahora construiremos sobre esta comprensión introduciendo el tipo de datos Array.

El tipo Array

Para ilustrar las reglas de propiedad, necesitamos un tipo de datos que sea más complejo que los que cubrimos en la sección de Tipos de Datos del Capítulo 3. Los tipos cubiertos anteriormente tienen un tamaño conocido, se pueden copiar rápida y trivialmente para crear una nueva instancia independiente si otra parte del código necesita usar el mismo valor en un ámbito diferente, y se pueden descartar fácilmente cuando ya no se usan. Pero queremos examinar datos cuyo tamaño es desconocido en tiempo de compilación y que no se pueden copiar trivialmente: el tipo Array.

En Cairo, cada celda de memoria solo se puede escribir una vez. Los arrays se representan en memoria mediante un segmento de celdas de memoria contiguas, y el sistema de tipos lineales de Cairo se utiliza para garantizar que cada celda nunca se escriba más de una vez. Considere el siguiente código, en el que definimos una variable arr de tipo Array que contiene valores u128:

use array::ArrayTrait;
...

let arr = ArrayTrait::<u128>::new();

Puede agregar valores a un Array utilizando el método append:

let mut arr = ArrayTrait::<u128>::new();
arr.append(1);
arr.append(2);

Entonces, ¿cómo garantiza el sistema de propiedad que cada celda nunca se escriba más de una vez? Considere el siguiente código, en el que intentamos pasar la misma instancia de un array en dos llamadas de función consecutivas:

use array::ArrayTrait;
fn foo(arr: Array<u128>) {
}

fn bar(arr:Array<u128>){
}

fn main() {
    let mut arr = ArrayTrait::<u128>::new();
    foo(arr);
    bar(arr);
}

En este caso, intentamos pasar la misma instancia de matriz arr por valor a las funciones foo y bar, lo que significa que el parámetro utilizado en ambas llamadas de función es la misma instancia de la matriz. Si agrega un valor a la matriz en foo y luego intenta agregar otro valor a la misma matriz en bar, lo que sucederá es que intentará escribir en la misma celda de memoria dos veces, lo que no está permitido en Cairo. Para evitar esto, la propiedad de la variable arr se mueve de la función main a la función foo. Cuando se intenta llamar a bar con arr como parámetro, la propiedad de arr ya se movió a la primera llamada. El sistema de propiedad nos impide usar la misma instancia de arr en foo.

Ejecutar el código anterior resultará en un error en tiempo de compilación:

error: Variable was previously moved. Trait has no implementation in context: core::traits::Copy::<core::array::Array::<core::integer::u128>>
 --> array.cairo:6:9
    let mut arr = ArrayTrait::<u128>::new();
        ^*****^

El Trait Copy

Si un tipo implementa el trait Copy, pasar su valor a una función no moverá la propiedad del valor a la función llamada, sino que pasará una copia del valor. Puedes implementar el trait Copy en tu tipo agregando la anotación #[derive(Copy)] a la definición de tu tipo. Sin embargo, Cairo no permitirá que un tipo sea anotado con Copy si el tipo en sí mismo o cualquiera de sus componentes no implementan el trait Copy. Mientras que los Arrays y Diccionarios no pueden ser copiados, los tipos personalizados que no los contienen sí pueden serlo.

#[derive(Copy, Drop)]
struct Point {
    x: u128,
    y: u128,
}

fn main() {
    let p1 = Point { x: 5, y: 10 };
    foo(p1);
    foo(p1);
}

fn foo(p: Point) {
    // do something with p
}

En este ejemplo, podemos pasar p1 dos veces a la función foo porque el tipo Point implementa el trait Copy. Esto significa que cuando pasamos p1 a foo, en realidad estamos pasando una copia de p1, y la propiedad de p1 permanece en la función principal.

Si eliminamos la derivación del trait Copy del tipo Point, obtendremos un error en tiempo de compilación al intentar compilar el código.

El Trait Drop

Es posible que hayas notado que el tipo Point en el ejemplo anterior también implementa el trait Drop. En Cairo, un valor no puede salir del ámbito a menos que se haya movido previamente. Por ejemplo, el siguiente código no se compilará porque la estructura A no se mueve antes de que salga del ámbito:

struct A {}

fn main() {
    A {}; // error: Value not dropped.
}

En Cairo, esto se hace para garantizar la solidez de los programas. La solidez se refiere al hecho de que si una declaración durante la ejecución del programa es falsa, ningún probador deshonesto puede convencer a un verificador honesto de que es verdadera. En nuestro caso, queremos asegurar la consistencia de las actualizaciones consecutivas de claves de un diccionario durante la ejecución del programa, lo cual solo se verifica cuando los diccionarios se "aplastan" - lo que mueve la propiedad del diccionario al método squash, permitiendo que el diccionario salga de ámbito. Los diccionarios no "aplastados" son peligrosos, ya que un probador malintencionado podría probar la corrección de actualizaciones inconsistentes.

Sin embargo, los tipos que implementan el trait Drop se permiten que salgan de ámbito sin ser movidos explícitamente. Cuando un valor de un tipo que implementa el trait Drop sale de ámbito, se llama a la implementación Drop en el tipo, lo que mueve el valor a la función drop, permitiendo que salga de ámbito: esto es lo que llamamos "eliminar" un valor. Es importante tener en cuenta que la implementación de Drop es una "operación nula", lo que significa que no realiza ninguna acción aparte de permitir que el valor salga de ámbito.

La implementación de Drop se puede derivar para todos los tipos, lo que les permite eliminarse al salir de ámbito, excepto para los diccionarios (Felt252Dict) y los tipos que contienen diccionarios. Por ejemplo, el siguiente código compila:

#[derive(Drop)]
struct A {}

fn main() {
    A {}; // Now there is no error.
}

El trait Destruct

Llamar manualmente al método squash en un diccionario no es muy conveniente y es fácil de olvidar hacerlo. Para facilitar el uso de los diccionarios, Cairo proporciona el trait Destruct, que te permite especificar el comportamiento de un tipo cuando sale del ámbito. Si bien los diccionarios no implementan el trait Drop, sí implementan el trait Destruct, lo que les permite ser aplastados automáticamente cuando salen del ámbito. Esto significa que puedes usar diccionarios sin tener que llamar manualmente al método squash.

Considera el siguiente ejemplo, en el que definimos un tipo personalizado que contiene un diccionario:

use dict::Felt252DictTrait;

struct A {
    dict: Felt252Dict<u128>
}

fn main() {
    A {
        dict: Felt252DictTrait::new()
    };
}

Si intenta ejecutar este código, obtendrá un error de tiempo de compilación:

error: Variable not dropped. Trait has no implementation in context: core::traits::Drop::<temp7::temp7::A>. Trait has no implementation in context: core::traits::Destruct::<temp7::temp7::A>.
 --> temp7.cairo:7:5
    A {
    ^*^

Cuando A sale del alcance, no puede ser liberado ya que no implementa ni el Drop (ya que contiene un diccionario y no puede derive(Drop)) ni el trait Destruct. Para solucionar esto, podemos derivar la implementación del trait Destruct para el tipo A:

use dict::Felt252DictTrait;

#[derive(Destruct)]
struct A {
    dict: Felt252Dict<u128>
}

fn main() {
    A {
        dict: Felt252DictTrait::new()
    }; // No error here
}

Copiar datos de un Array con Clone

Si queremos copiar profundamente los datos de un Array, podemos utilizar un método común llamado clone. Discutiremos la sintaxis de los métodos en el Capítulo 5, pero como los métodos son una característica común en muchos lenguajes de programación, es probable que ya los hayas visto antes.

Aquí hay un ejemplo del método clone en acción.

Nota: en el siguiente ejemplo, necesitamos importar el rasgo Clone del módulo clone de la biblioteca estándar, y su implementación para el tipo array del módulo array.

use array::ArrayTrait;
use clone::Clone;
use array::ArrayTCloneImpl;
...
let arr1 = ArrayTrait::new::<u128>();
let arr2 = arr1.clone();

Nota: necesitarás ejecutar cairo-run con la opción --available-gas=2000000 para ejecutar este ejemplo, ya que utiliza un bucle y debe ser ejecutado con un límite de gas.

Cuando ves una llamada a clone, sabes que se está ejecutando algún código arbitrario y ese código puede ser costoso. Es un indicador visual de que algo diferente está sucediendo.

Propiedad y Funciones

Pasar una variable a una función puede moverla o copiarla. Como se vio en la sección de Array, pasar un Array como parámetro de función transfiere su propiedad; veamos qué sucede con otros tipos.

El Listado 3-3 tiene un ejemplo con algunas anotaciones que muestran dónde las variables entran y salen de ámbito.

Nombre de archivo: src/main.cairo

#[derive(Drop)]
struct MyStruct{}

fn main() {
    let my_struct = MyStruct{};  // my_struct comes into scope

    takes_ownership(my_struct);             // my_struct's value moves into the function...
                                    // ... and so is no longer valid here

    let x = 5_u128;                 // x comes into scope

    makes_copy(x);                  // x would move into the function,
                                    // but u128 implements Copy, so it's okay to still
                                    // use x afterward

} // Here, x goes out of scope and is dropped. But because my_struct's value was moved, nothing
// special happens.

fn takes_ownership(some_struct: A) { // some_struct comes into scope
} // Here, some_struct goes out of scope and `drop` is called.

fn makes_copy(some_uinteger: u128) { // some_uinteger comes into scope
} // Here, some_integer goes out of scope and is dropped.

Listado 3-3: Funciones con propiedad y alcance anotados

Si intentamos usar my_struct después de la llamada a takes_ownership, Cairo lanzará un error en tiempo de compilación. Estas verificaciones estáticas nos protegen de errores. Intenta agregar código a main que use my_struct y x para ver dónde puedes usarlos y dónde las reglas de propiedad te impiden hacerlo.

Valores de retorno y alcance

La devolución de valores también puede transferir la propiedad. El Listado 3-4 muestra un ejemplo de una función que devuelve algún valor, con anotaciones similares a las del Listado 3-3.

Nombre de archivo: src/main.cairo

#[derive(Drop)]
struct A{}

fn main() {
    let a1 = gives_ownership();         // gives_ownership moves its return
                                        // value into a1

    let a2 = A{};     // a2 comes into scope

    let a3 = takes_and_gives_back(a2);  // a2 is moved into
                                        // takes_and_gives_back, which also
                                        // moves its return value into a3
} // Here, a3 goes out of scope and is dropped. a2 was moved, so nothing
  // happens. a1 goes out of scope and is dropped.

fn gives_ownership() -> A {             // gives_ownership will move its
                                             // return value into the function
                                             // that calls it

    let some_a = A{}; // some_a comes into scope

    some_a                              // some_a is returned and
                                             // moves out to the calling
                                             // function
}

// This function takes an instance of A and returns one
fn takes_and_gives_back(some_a: A) -> A { // some_a comes into
                                                      // scope

    some_a  // some_a is returned and moves out to the calling function
}

Listado 3-4: Transferencia de propiedad de valores devueltos

Cuando una variable sale del ámbito, su valor se elimina, a menos que la propiedad del valor se haya transferido a otra variable.

Si bien esto funciona, tomar propiedad y luego devolver la propiedad con cada función es un poco tedioso. ¿Qué sucede si queremos permitir que una función use un valor pero no tome posesión de él? Es bastante molesto que todo lo que pasemos también deba ser devuelto si queremos usarlo nuevamente, además de cualquier dato que resulte del cuerpo de la función que también podríamos querer devolver.

Cairo nos permite devolver varios valores usando una tupla, como se muestra en el Listado 3-5.

Nombre de archivo: src/main.cairo

use array::ArrayTrait;
fn main() {
    let arr1 = ArrayTrait::<u128>::new();

    let (arr2, len) = calculate_length(arr1);
}

fn calculate_length(arr: Array<u128>) -> (Array<u128>, usize) {
    let length = arr.len(); // len() returns the length of an array

    (arr, length)
}

Listado 3-5: Devolviendo propiedad de los parámetros

Pero esto es demasiado ceremonioso y mucho trabajo para un concepto que debería ser común. Afortunadamente, Cairo tiene dos características para usar un valor sin transferir la propiedad, llamadas referencias y snapshots.

Referencias y Snapshots

El problema con el código de tupla en el Listado 3-5 es que tenemos que devolver el Array a la función de llamada para que podamos seguir usando el Array después de la llamada a calculate_length, ya que el Array se movió a calculate_length.

Snapshots

En su lugar, podemos proporcionar una instantánea del valor Array. En Cairo, una instantánea es una vista inmutable de un valor en un cierto momento en el tiempo. En el capítulo anterior, hablamos de cómo el sistema de propiedad de Cairo nos impide usar un valor después de haberlo movido, protegiéndonos de escribir potencialmente dos veces en la misma celda de memoria al agregar valores a los arreglos. Sin embargo, no es muy conveniente. Veamos cómo podemos mantener la propiedad del valor en la función de llamada usando instantáneas.

Aquí es cómo definiría y usaría una función calculate_length que toma una instantánea de un arreglo como parámetro en lugar de tomar propiedad del valor subyacente. En este ejemplo, la función calculate_length devuelve la longitud del arreglo pasado como parámetro. Como lo estamos pasando como una instantánea, que es una vista inmutable del arreglo, podemos estar seguros de que la función calculate_length no mutará el arreglo, y la propiedad del arreglo se mantiene en la función principal.

Nombre de archivo: src/lib.cairo

use array::ArrayTrait;
use debug::PrintTrait;

fn main() {
    let mut arr1 = ArrayTrait::<u128>::new();
    let first_snapshot = @arr1; // Take a snapshot of `arr1` at this point in time
    arr1.append(1_u128); // Mutate `arr1` by appending a value
    let first_length = calculate_length(first_snapshot); // Calculate the length of the array when the snapshot was taken
    let second_length = calculate_length(@arr1); // Calculate the current length of the array
    first_length.print();
    second_length.print();
}

fn calculate_length(arr: @Array<u128>) -> usize {
    arr.len()
}

Nota: Solo es posible llamar al método len() en un snapshot de un array porque está definido así en el trait ArrayTrait. Si intentas llamar a un método que no está definido para snapshots en un snapshot, obtendrás un error de compilación. Sin embargo, puedes llamar a métodos que esperan un snapshot en tipos que no son snapshots.

La salida de este programa es:

[DEBUG]	                               	(raw: 0)

[DEBUG]	                              	(raw: 1)

Run completed successfully, returning []

La primera observación es que todo el código de tuplas en la declaración de variables y en el valor de retorno de la función ha desaparecido. La segunda observación es que pasamos @arr1 a calculate_length y, en su definición, tomamos @Array<u128> en lugar de Array<u128>.

Veamos más de cerca la llamada a la función aquí:

let mut arr1 = ArrayTrait::<u128>::new();
let second_length = calculate_length(@arr1); // Calculate the current length of the array

La sintaxis @arr1 nos permite crear una instantánea (snapshot) del valor en arr1. Como una instantánea es una vista inmutable de un valor, el valor al que apunta no puede ser modificado a través de la instantánea y el valor al que se refiere no será eliminado una vez que la instantánea deje de ser usada.

De manera similar, la firma de la función utiliza @ para indicar que el tipo del parámetro arr es una instantánea. Añadamos algunas anotaciones explicativas:

fn calculate_length(array_snapshot: @Array<u128>) -> usize { // array_snapshot is a snapshot of an Array
    array_snapshot.len()
} // Here, array_snapshot goes out of scope and is dropped.
// However, because it is only a view of what the original array `arr` contains, the original `arr` can still be used.

El alcance en el que la variable array_snapshot es válida es el mismo que el alcance de cualquier parámetro de función, pero el valor subyacente del snapshot no se eliminará cuando array_snapshot deje de usarse. Cuando las funciones tienen snapshots como parámetros en lugar de los valores reales, no necesitamos devolver los valores para devolver la propiedad del valor original, porque nunca la tuvimos.

Los snapshots se pueden convertir de nuevo en valores regulares usando el operador desnap *, siempre y cuando el tipo de valor sea copiable (lo cual no es el caso para los Arrays, ya que no implementan Copy). En el siguiente ejemplo, queremos calcular el área de un rectángulo, pero no queremos tomar la propiedad del rectángulo en la función calculate_area, porque podríamos querer usar el rectángulo de nuevo después de la llamada a la función. Dado que nuestra función no muta la instancia del rectángulo, podemos pasar el snapshot del rectángulo a la función, y luego transformar los snapshots de nuevo en valores usando el operador desnap *.

El tipo de snapshot siempre es copiable y eliminable, para que pueda usarlo varias veces sin preocuparse por las transferencias de propiedad.

#[derive(Copy,Drop)]
struct Rectangle {
    height: u64,
    width: u64,
}

fn main(){
    let rec = Rectangle{height:3_u64, width:10_u64};
}

fn calculate_area(rec: @Rectangle) -> u64 {
    // As rec is a snapshot to a Rectangle, its fields are also snapshots of the fields types.
    // We need to transform the snapshots back into values using the desnap operator `*`.
    // This is only possible if the type is copyable, which is the case for u64.
    // Here, `*` is used for both multiplying the height and width and for desnapping the snapshots.
    *rec.height * *rec.width
}

Pero, ¿qué sucede si intentamos modificar algo que estamos pasando como instantánea? Prueba el código en la Lista 3-6. ¡Alerta de spoiler: no funciona!

Nombre de archivo: src/lib.cairo

#[derive(Copy,Drop)]
struct Rectangle {
    height: u64,
    width: u64,
}

fn main(){
    let rec = Rectangle{height:3_u64, width:10_u64};
    flip(@rec);
}

fn flip(rec: @Rectangle) {
    let temp = rec.height;
    rec.height = rec.width;
    rec.width = temp;
}

Listado 3-6: Intentando modificar un valor de snapshot

Aquí está el error:

error: Invalid left-hand side of assignment.
 --> ownership.cairo:15:5
    rec.height = rec.width;
    ^********^

Referencias mutables

Podemos lograr el comportamiento que queremos en el Listado 3-6 utilizando una referencia mutable en lugar de un snapshot. Las referencias mutables son valores mutables pasados a una función que se devuelven implícitamente al final de la función, devolviendo la propiedad al contexto de llamada. Al hacerlo, le permiten mutar el valor pasado y mantener su propiedad devolviéndolo automáticamente al final de la ejecución.

En Cairo, se puede pasar un parámetro como referencia mutable utilizando el modificador ref.

Nota: En Cairo, un parámetro solo se puede pasar como referencia mutable utilizando el modificador ref si la variable se declara como mutable con mut.

En el Listado 3-7, usamos una referencia mutable para modificar el valor del campo height de la instancia de Rectangle en la función flip.

use debug::PrintTrait;
#[derive(Copy,Drop)]
struct Rectangle {
    height: u64,
    width: u64,
}

fn main(){
    let mut rec = Rectangle{height:3_u64, width:10_u64};
    flip(ref rec);
    rec.height.print();
    rec.width.print();
}

fn flip(ref rec: Rectangle) {
    let temp = rec.height;
    rec.height = rec.width;
    rec.width = temp;
}

Primero, cambiamos rec a mut. Luego, pasamos una referencia mutable de rec a flip con ref rec y actualizamos la firma de la función para aceptar una referencia mutable con ref rec: Rectangle. Esto deja muy claro que la función flip modificará el valor de la instancia de Rectangle pasada como parámetro.

La salida del programa es:

[DEBUG]
                                (raw: 10)

[DEBUG]	                        (raw: 3)

Como resumen, lo que hemos discutido acerca de ownership, snapshots y las referencias es:

  • En un momento dado, una variable solo puede tener un propietario.
  • Puedes pasar una variable por valor, por instantánea o por referencia a una función.
  • Si pasas una variable por valor, la propiedad de la variable se transfiere a la función.
  • Si quieres mantener la propiedad de la variable y sabes que tu función no la va a modificar, puedes pasarla como instantánea con @.
  • Si quieres mantener la propiedad de la variable y sabes que tu función la modificará, puedes pasarla como referencia mutable con ref.

Usando structs para estructurar datos relacionados

Un struct, o estructura, es un tipo de datos personalizado que te permite empaquetar y nombrar múltiples valores relacionados que conforman un grupo significativo. Si estás familiarizado con un lenguaje orientado a objetos, un struct es como los atributos de datos de un objeto. En este capítulo, compararemos y contrastaremos las tuplas con los structs para construir sobre lo que ya sabes y demostrar cuándo los structs son una mejor manera de agrupar datos.

Demostraremos cómo definir e instanciar structs. Discutiremos cómo definir funciones asociadas, especialmente el tipo de funciones asociadas llamadas métodos, para especificar el comportamiento asociado con un tipo de struct. Los structs y los enums (discutidos en el Capítulo 6) son los bloques de construcción para crear nuevos tipos en el dominio de tu programa para aprovechar al máximo la verificación de tipos en tiempo de compilación de Cairo.

Definiendo e instanciando una estructura

Las estructuras son similares a las tuplas, discutidas en la sección Tipos de datos, en el sentido de que ambas contienen múltiples valores relacionados. Al igual que las tuplas, las piezas de una estructura pueden ser de diferentes tipos. A diferencia de las tuplas, en una estructura se nombra cada dato para que quede claro lo que significan los valores. Agregar estos nombres significa que las estructuras son más flexibles que las tuplas: no se tiene que depender del orden de los datos para especificar o acceder a los valores de una instancia.

Para definir una estructura, usamos la palabra reservada struct y nombramos la estructura completa. El nombre de una estructura debe describir la importancia de los datos que se agrupan. Luego, dentro de corchetes, definimos los nombres y tipos de los datos, a los que llamamos campos. Por ejemplo, el Listado 4-1 muestra una estructura que almacena información sobre una cuenta de usuario (User).

Filename: structs.cairo

#[derive(Copy, Drop)]
struct User {
    active: bool,
    username: felt252,
    email: felt252,
    sign_in_count: u64,
}

Listado 4-1: Definición de la estructura User

Para usar una estructura después de haberla definido, creamos una instancia de esa estructura especificando valores concretos para cada uno de los campos. Creamos una instancia indicando el nombre de la estructura y luego agregamos corchetes que contienen pares de clave: valor, donde las claves son los nombres de los campos y los valores son los datos que queremos almacenar en esos campos. No tenemos que especificar los campos en el mismo orden en que los declaramos en la estructura. En otras palabras, la definición de una estructura es como una plantilla general para el tipo y las instancias completan esa plantilla con datos particulares para crear valores del tipo.

Por ejemplo, podemos declarar un usuario (User) en particular como se muestra en el Listado 4-2.

Filename: structs.cairo

#[derive(Copy, Drop)]
struct User {
    active: bool,
    username: felt252,
    email: felt252,
    sign_in_count: u64,
}
fn main() {
    let user1 = User {
        active: true,
        username: 'someusername123',
        email: 'someone@example.com',
        sign_in_count: 1_u64,
    };
}

Listado 4-2: Creando una instancia de la estructura User

Para obtener un valor específico de una estructura, usamos la notación punto. Por ejemplo, para acceder a la dirección de correo electrónico de este usuario, usamos user1.email. Si la instancia es mutable, podemos cambiar un valor usando la notación punto y asignándolo a un campo en particular. El listado 4-3 muestra cómo cambiar el valor en el campo emailde una instancia mutable de User.

Filename: structs.cairo

fn main() {
    let mut user1 = User {
        active: true,
        username: 'someusername123',
        email: 'someone@example.com',
        sign_in_count: 1_u64,
    };
    user1.email = 'anotheremail@example.com';
}

Listado 4-3: Cambiando el valor del campo email de la instancia User

Tenga en cuenta que toda la instancia debe ser mutable; Cairo no nos permite marcar solo ciertos campos como mutables.

Como con cualquier expresión, podemos construir una nueva instancia de la estructura como la última expresión en el cuerpo de la función para devolver implícitamente esa nueva instancia.

Listado 4-4 muestra la función build_user que retorna una instancia de la estructura User con el email y el username. Al campo active se le asigna el valor true,y el campo sign_in_count obtiene el valor de 1.

Filename: structs.cairo

fn build_user(email: felt, username: String) -> User {
    User {
        active: true,
        username: username,
        email: email,
        sign_in_count: 1,
    }
}

Listado 4-4: Función build_user que toma los argumentos email y username, y retorna una instancia de la estructura User

Tiene sentido nombrar los parámetros de la función con el mismo nombre que los campos de la estructura, porque tener que repetir los nombres y variables de los campos emaily username es un poco tedioso. Si la estructura tuviera más campos, repetir cada nombre sería aún más molesto. ¡Afortunadamente, hay una forma abreviada!

Usando la abreviatura Field Init

Debido a que los nombres de los parámetros y los nombres de los campos de la estructura son exactamente iguales en el Listado 4-4, podemos usar la sintaxis abreviada de Field Init para reescribir la función build_user y que se comporte exactamente igual, pero no tenga la repetición de username y email, como se muestra en el Listado 4-5.

Filename: structs.cairo

fn build_user(email: felt252, username: felt252) -> User {
    User {
        active: true,
        username,
        email,
        sign_in_count: 1_u64,
    }
}

Listado 4-5: Función build_user que usa la abreviatura field init porque los parámetros username y email tienen el mismo nombre que los campos de la estructura

Aquí, estamos creando una nueva instancia de la estructura User, que tiene un campo llamado email. Queremos establecer el valor del campo email con el valor del parámetro email de la función build_user. Debido a que el campo email y el parámetro email tienen el mismo nombre, solo necesitamos escribir email en lugar de email: email.

Un programa de ejemplo usando estructuras

Para entender cuándo podríamos usar estructuras, escribamos un programa que calcule el área de un rectángulo. Comenzaremos usando variables individuales y luego reescribiremos el programa hasta que estemos usando estructuras en su lugar.

Hagamos un nuevo proyecto con Scarb llamado rectangles que tomará el ancho y la altura de un rectángulo en píxeles y calculará el área del rectángulo. El Listado 4-6 muestra un pequeño programa con una forma de hacer exactamente eso en el src/lib.cairo de nuestro proyecto.

Filename: src/lib.cairo

use debug::PrintTrait;
fn main() {
    let width1 = 30_u64;
    let height1 = 10_u64;
    let area = area(width1, height1);
    area.print();
}

fn area(width: u64, height: u64) -> u64 {
    width * height
}

Listado 4-6: Cálculo del área de un rectángulo especificado por variables separadas de ancho y alto

Para compilar el programa usamos cairo-run src/lib.cairo:

$ cairo-run src/lib.cairo
[DEBUG] ,                               (raw: 300)

Run completed successfully, returning []

Este código logra calcular el área del rectángulo llamando a la función area con cada dimensión, pero podemos hacer más para que este código sea claro y legible.

El problema con este código es evidente en la declaración de la función area:

fn area(width: u64, height: u64) -> u64 {

Se supone que la función area calcula el área de un rectángulo, pero la función que escribimos tiene dos parámetros, y no está claro en ninguna parte de nuestro programa que los parámetros estén relacionados. Sería más legible y manejable agrupar el ancho y el alto juntos. Ya discutimos una forma en que podríamos hacer eso en el Capítulo 3: usando tuplas.

Reescribiendo con tuplas

El listado 4-7 muestra otra versión de nuestro programa usando tuplas.

Filename: src/lib.cairo

use debug::PrintTrait;
fn main() {
    let rectangle = (30_u64, 10_u64);
    let area = area(rectangle);
    area.print(); // print out the area
}

fn area(dimension: (u64, u64)) -> u64 {
    let (x,y) = dimension;
    x * y
}

Listing 4-7: Especificando el ancho y alto de un rectangulo con una tupla

En cierto modo, este programa es mejor. Las tuplas nos permiten agregar un poco de estructura y ahora estamos pasando solo un argumento. Pero en otro sentido, esta versión es menos clara: las tuplas no nombran sus elementos, por lo que tenemos que indexar las partes de la tupla, lo que hace que nuestro cálculo sea menos obvio.

Mezclar el ancho y la altura no importaría para el cálculo del área, pero si queremos calcular la diferencia, ¡sería importante! Tendríamos que tener en cuenta que width es el índice de tupla 0 y height es el índice de tupla 1. Esto sería aún más difícil de entender y tener en cuenta para otra persona si usara nuestro código. Debido a que no hemos transmitido el significado de nuestros datos en nuestro código, ahora es más fácil introducir errores.

Reescribiendo con struct: agrega más significado

Usamos estructuras para agregar significado al etiquetar los datos. Podemos transformar la tupla que estamos usando en una estructura con un nombre para el todo y nombres para las partes.

Filename: src/lib.cairo

use debug::PrintTrait;

struct Rectangle {
    width: u64,
    heigh: u64,
}

fn main() {
    let rectangle = Rectangle {
        width: 30_u64,
        heigh: 10_u64,
    };
    let area = area(rectangle);
    area.print(); // print out the area
}

fn area(rectangle: Rectangle) -> u64 {
    rectangle.width * rectangle.heigh
}

Listado 4-8: Definición de una estructura llamada Rectangle

Aquí hemos definido una estructura y la hemos llamado Rectangle. Dentro de las llaves, definimos los campos como width y height, los cuales tienen el tipo u64. Luego, en main, creamos una instancia particular de Rectangle que tiene un ancho de 30 y una altura de 10. Nuestra función area ahora está definida con un parámetro, al que hemos llamado rectangle que es de tipo de la estructura Rectangle. Luego podemos acceder a los campos de la instancia con notación de punto, y dar nombres descriptivos a los valores en lugar de usar los valores de índice de tupla de 0 y 1.

Agregando funcionalidades útiles con trait

Sería útil poder imprimir una instancia de Rectangle mientras estamos depurando nuestro programa y ver los valores de todos sus campos. El Listado 4-9 intenta usar print como lo hemos usado en capítulos anteriores. Esto no funcionará.

Filename: src/lib.cairo

use debug::PrintTrait;

struct Rectangle {
    width: u64,
    heigh: u64,
}

fn main() {
    let rectangle = Rectangle {
        width: 30_u64,
        heigh: 10_u64,
    };
    rectangle.print();
}

Listado 4-9: Intentando imprimir una instancia de Rectangle

Cuando compilamos este código, obtenemos un error con el siguiente mensaje:

$ cairo-compile src/lib.cairo
error: Method `print` not found on type "../src::Rectangle". Did you import the correct trait and impl?
 --> lib.cairo:16:15
    rectangle.print();
              ^***^

Error: Compilation failed.

El trait print está implementado para muchos tipos de datos, pero no para la estructura Rectangle. Podemos arreglar esto implementando el trait PrintTrait en la estructura Rectangle como se muestra en el Listado 4-10.

Para aprender más sobre traits,Traits en Cairo.

Filename: src/lib.cairo

use debug::PrintTrait;

struct Rectangle {
    width: u64,
    heigh: u64,
}

fn main() {
    let rectangle = Rectangle {
        width: 30_u64,
        heigh: 10_u64,
    };
    rectangle.print();
}

impl RectanglePrintImpl of PrintTrait<Rectangle> {
    fn print(self: Rectangle) {
        self.width.print();
        self.heigh.print();
    }
}

Listado 4-10: Implementación del trait PrintTrait en Rectangle

¡Bien! No es el resultado más bonito, pero muestra los valores de todos los campos para esta instancia, lo que definitivamente ayudaría durante la depuración.

Sintaxis de métodos

Los métodos son similares a las funciones: los declaramos con la palabra clave fn y un nombre, pueden tener parámetros, retornar un valor, y contener código que se ejecuta cuando el método es llamado desde otro lugar. A diferencia de las funciones, los métodos se definen dentro del contexto de un tipo y su primer parámetro siempre es self, que representa la instancia del tipo al que se llama el método. Para aquellos familiarizados con Rust, el enfoque de Cairo puede resultar confuso, ya que los métodos no se pueden definir directamente en los tipos. En su lugar, debe definir un trait y una implementación asociados con el tipo para el que está destinado el método.

Definición de métodos

Cambiemos la función area que tiene una instancia de Rectangle como parámetro y en su lugar crea un método area definido en el trait RectangleTrait, como se muestra en el Listado 4-13.

Filename: src/lib.cairo

use debug::PrintTrait;
#[derive(Copy, Drop)]
struct Rectangle {
    width: u64,
    height: u64,
}

trait RectangleTrait {
    fn area(self: @Rectangle) -> u64;
}

impl RectangleImpl of RectangleTrait {
    fn area(self: @Rectangle) -> u64 {
        (*self.width) * (*self.height)
    }
}

fn main() {
    let rect1 = Rectangle { width: 30_u64, height: 50_u64,  };

    rect1.area().print();
}

Listado 4-13: Definiendo el método area para usar en la estructura Rectangle

Para definir la función dentro del contexto de Rectangle, comenzamos definiendo un trait con la declaración del método que queremos implementar. Los Traits no están vinculados a un tipo específico; solo el parámetro self del método define qué tipo se puede usar con dicho trait. Luego, definimos un bloque con la palabra clave impl para RectangleTrait, que define el comportamiento de los métodos implementados. Todo dentro de este bloque impl será asociado con el tipo del parámetro self del método llamado. Si bien es técnicamente posible definir métodos para múltiples tipos dentro del mismo bloque impl, no es una práctica recomendada, ya que puede producir una confusión. Recomendamos que el tipo del parámetro self permanece consistente dentro del mismo bloque impl. Luego movemos la función area dentro de los corchetes impl y cambiamos el primer (y en este caso, único) parámetro para ser self en la declaración y en todas partes dentro del cuerpo. En main, donde llamamos a la función area y pasamos rect1 como argumento, en su lugar, podemos usar la sintaxis del método para llamar al método area en nuestra instancia del Rectangle. La sintaxis del método va después de una instancia: agregamos un punto seguido del nombre del método, los paréntesis y los argumentos.

Los métodos deben tener un parámetro llamado self del tipo al que se aplicarán para su primer parámetro. Tenga en cuenta que usamos el operador @ (snapshot) delante del tipo Rectangle en la declaración de la función. Al hacerlo, indicamos que este método toma un snapshot inmutable de la instancia de Rectangle, que es creado automáticamente por el compilador al pasar la instancia al método. Los métodos pueden tomar posesión de self, usar self con snapshot como lo hemos hecho aquí, o usar una referencia mutable a self utilizando la sintaxis ref self: T.

Elegimos self: @Rectangle por la misma razón que usamos @Rectangle en la función versión: no queremos tomar posesión, y solo queremos leer los datos en la estructura, no escribir en ella. Si quisiéramos cambiar la instancia que hemos llamado al método como parte de lo que hace el método, usaríamos ref self: Rectangle como el primer parámetro. Tener un método que tome posesión de la instancia por usar solo self como primer parámetro es raro; esta técnica suele ser usada cuando el método transforma self en otra cosa y desea evitar que la persona que llama use la instancia original después de la transformación.

Observe el uso del operador desnap * dentro del método area cuando accede a los miembros de la estructura. Esto es necesario porque la estructura se pasa como una snapshot y todos sus valores de campo son del tipo @T, requiriendo que sean desnapped para poder manipularlos.

La principal razón para usar métodos en lugar de funciones es la organización y la claridad del código. Hemos puesto todas las cosas que podemos hacer con una instancia de un tipo en una combinación de bloques de tipo trait & impl, en lugar de hacer que los futuros usuarios de nuestro código busquen capacidades de Rectangle en varios lugares en la biblioteca que ofrecemos. Sin embargo, podemos definir múltiples combinaciones de trait & impl para el mismo tipo en diferentes lugares, lo que puede ser útil para organizar nuestro código. Por ejemplo, podría implementar el trait Add para su tipo en un bloque impl, y el trait Sub en otro bloque.

Tenga en cuenta que podemos optar por dar a un método el mismo nombre que uno de los campos de la estructura. Por ejemplo, podemos definir un método en Rectangle que también se llama width:

Filename: src/lib.cairo

use debug::PrintTrait;
#[derive(Copy, Drop)]
struct Rectangle {
    width: u64,
    height: u64,
}

trait RectangleTrait {
    fn width(self: @Rectangle) -> bool;
}

impl RectangleImpl of RectangleTrait {
    fn width(self: @Rectangle) -> bool {
        (*self.width) > 0_u64
    }
}

fn main() {
    let rect1 = Rectangle { width: 30_u64, height: 50_u64,  };
    rect1.width().print();
}

Aquí, elegimos hacer que el método width devuelva true si el valor en el campo width de la instancia es mayor que 0 y false si el valor es 0: podemos usar un campo dentro de un método del mismo nombre para cualquier propósito. En main, cuando colocamos rect1.width entre paréntesis, Cairo sabe que nos referimos al método width. Cuando no usamos paréntesis, Cairo sabe que nos referimos al campo widht.

Métodos con más parámetros

Practiquemos el uso de métodos implementando un segundo método en la estructura Rectangle. Esta vez queremos que una instancia de Rectangle tome otra instancia de Rectangle y devolver true si el segundo Rectangle puede caber completamente dentro de self (el primer Rectangle); de lo contrario, debería devolver false. Es decir, una vez que hemos definido el método can_hold, queremos poder escribir el programa que se muestra en el Listado 4-14.

Filename: src/lib.cairo

use debug::PrintTrait;
#[derive(Copy, Drop)]
struct Rectangle {
    width: u64,
    height: u64,
}


fn main() {
    let rect1 = Rectangle {
        width: 30_u64,
        height: 50_u64,
    };
    let rect2 = Rectangle {
        width: 10_u64,
        height: 40_u64,
    };
    let rect3 = Rectangle {
        width: 60_u64,
        height: 45_u64,
    };

    'Can rect1 hold rect2?'.print();
    rect1.can_hold(@rect2).print();

    'Can rect1 hold rect?'.print();
    rect1.can_hold(@rect3).print();
}

Listing 4-14: Usando el método todavia no escrito can_hold

La salida esperada sería similar a la siguiente porque ambas dimensiones de rect2 son más pequeñas que las dimensiones de rect1, pero rect3 es más ancha que rect1:

❯ cairo-run src/lib.cairo
[DEBUG]	Can rec1 hold rect2?           	(raw: 384675147322001379018464490539350216396261044799)

[DEBUG]	true                           	(raw: 1953658213)

[DEBUG]	Can rect1 hold rect?           	(raw: 384675147322001384331925548502381811111693612095)

[DEBUG]	false                          	(raw: 439721161573)

We know we want to define a method, so it will be within the trait RectangleTrait and impl RectangleImpl of RectangleTrait blocks. The method name will be can_hold, and it will take a snapshot of another Rectangle as a parameter. We can tell what the type of the parameter will be by looking at the code that calls the method: rect1.can_hold(@rect2) passes in @rect2, which is a snapshot to rect2, an instance of Rectangle. This makes sense because we only need to read rect2 (rather than write, which would mean we’d need a mutable borrow), and we want main to retain ownership of rect2 so we can use it again after calling the can_hold method. The return value of can_hold will be a Boolean, and the implementation will check whether the width and height of self are greater than the width and height of the other Rectangle, respectively. Let’s add the new can_hold method to the trait and impl blocks from Listing 4-13, shown in Listing 4-15.

Sabemos que queremos definir un método, por lo que estará dentro del bloque trait RectangleTrait e impl RectangleImpl of RectangleTrait. El nombre del método será can_hold, y tomará una snapshot de otro Rectangle como parámetro. Podemos decir cuál será el tipo de parámetro si miramos el código que llama al método: rect1.can_hold(@rect2) pasa @rect2, que es un snapshot para rect2, una instancia de Rectangle. Esto tiene sentido porque solo necesitamos leer rect2 (en lugar de escribir, lo que significaría que necesitaríamos un préstamo mutable), y queremos que main conserve la propiedad de rect2 para que podamos usarlo nuevamente después llamando al método can_hold. El valor de retorno de can_hold será un Boolean, y la implementación verificará si el ancho y la altura de self son mayores que el ancho y alto del otro Rectangle, respectivamente. Agreguemos el nuevo método can_hold a los bloques trait y impl del Listado 4-13, mostrado en el Listado 4-15.

Filename: src/lib.cairo

trait RectangleTrait {
    fn area(self: @Rectangle) -> u64;
    fn can_hold(self: @Rectangle, other: @Rectangle) -> bool;
}

impl RectangleImpl of RectangleTrait {
    fn area(self: @Rectangle) -> u64 {
        *self.width * *self.height
    }

    fn can_hold(self: @Rectangle, other: @Rectangle) -> bool {
        *self.width > *other.width & *self.height > *other.height
    }
}

Listing 4-15: Implementación del método can_hold en Rectangle que recibe una instancia de Rectangle como parámetro

When we run this code with the main function in Listing 5-14, we’ll get our desired output. Methods can take multiple parameters that we add to the signature after the self parameter, and those parameters work just like parameters in functions.

Cuando ejecutamos este código con la función main en el Listado 4-14, obtendremos nuestra salida deseada. Los métodos pueden tomar múltiples parámetros que agregamos a su definición después del parámetro self, y esos parámetros funcionan como parámetros en las funciones.

Acceso a las funciones de implementación

Todas las funciones definidas dentro de un bloque trait e impl se pueden llamar directamente utilizando el operador :: en el nombre de la implementación. Las funciones en los trait que no son métodos a menudo se usan para constructores que devolverá una nueva instancia de la estructura. Estos a menudo se denominan new, pero new no es un nombre especial y no está integrado en el lenguaje de Cairo. Por ejemplo, nosotros podriamos optar por proporcionar una función asociada llamada square que tendría un parámetro de dimensión y usarlo como ancho y alto, haciéndolo así más fácil para crear un cuadrado con Rectangle en lugar de tener que especificar el mismo valor dos veces:

Filename: src/lib.cairo

trait RectangleTrait {
    fn square(size:u64) -> Rectangle;
}

impl RectangleImpl of RectangleTrait {
    fn square(size: u64) -> Rectangle {
        Rectangle { width: size, height: size }
    }
}

Para llamar a esta función, usamos la sintaxis :: con el nombre de implementación; por ejemplo, let square = RectangleImpl::square(10_u64);. Esta función está espaciada por la implementación: la sintaxis :: se usa tanto para funciones del trait y espacios de nombres creados por módulos. Lo discutiremos en el [Capítulo 7][modules].

Note: It is also possible to call this function using the trait name, with RectangleTrait::square(10_u64).

Nota: También es posible llamar a esta función usando el nombre del trait, con RectangleTrait::square(10_u64).

Multiples bloques con impl

Cada estructura tiene permitido tener múltiples bloques con trait e impl. Por ejemplo, en el Listado 4-15 es equivalente al código mostrado en el Listado 4-16, que tiene cada método en su propio bloque de trait e impl.

trait RectangleCalc {
    fn area(self: @Rectangle) -> u64;
}
impl RectangleCalcImpl of RectangleCalc {
    fn area(self: @Rectangle) -> u64 {
        (*self.width) * (*self.height)
    }
}

trait RectangleCmp {
    fn can_hold(self: @Rectangle, other: @Rectangle) -> bool;
}

impl RectangleCmpImpl of RectangleCmp {
    fn can_hold(self: @Rectangle, other: @Rectangle) -> bool {
        *self.width > *other.width & *self.height > *other.height
    }
}

Listado 4-16: Reescribiendo el Listado 4-15 usando múltiples bloques de impl

No hay razón para separar estos métodos en múltiples bloques de trait e impl, pero es una sintaxis válida. Veremos un caso en el que usar múltiples bloques es adecuado en el Capítulo 7, donde discutimos tipos y traits genéricos.

Resumen

Las estructuras permiten crear tipos personalizados que son significativos para su dominio. Usando estructuras, puede mantener partes de datos asociadas conectadas entre sí y nombra cada pieza para que tu código quede claro. En los bloques trait e impl, puedes definir métodos, que son funciones asociadas a un tipo y le permiten especificar el comportamiento que las instancias de su tipo pueden tener.

Pero las estructuras (struct) no son la única manera de crear tipos personalizados: pasemos a la función de enumeración (enum) de Cairo para agregar otra herramienta.

Enums y Coincidencia de Patrones

Enums y Coincidencia de Patrones

Los enums, abreviatura de "enumeraciones", son una forma de definir un tipo de datos personalizado que consiste en un conjunto fijo de valores nombrados, llamados variantes. Los enums son útiles para representar una colección de valores relacionados donde cada valor es distinto y tiene un significado específico.

Variantes y Valores de Enum

Aquí hay un ejemplo sencillo de un enum:

#[derive(Drop)]
enum Direction {
    North : (),
    East : (),
    South : (),
    West : (),
}

A diferencia de otros lenguajes como Rust, cada variante tiene un tipo. En este ejemplo, hemos definido un enum llamado Direction con cuatro variantes: North, East, South y West. La convención de nomenclatura es utilizar PascalCase para las variantes del enum. Cada variante representa un valor distinto del tipo Direction y está asociada con un tipo unitario (). Una variante puede ser instanciada utilizando esta sintaxis:

let direction = Direction::North(());

Es fácil escribir código que se comporte de manera diferente según la variante de una instancia de un enum, como en este ejemplo, donde se ejecuta un código específico según una dirección. Puedes obtener más información sobre esto en la página The Match Control Flow Construct.

Enums combinados con tipos personalizados

Los enums también pueden ser utilizados para almacenar datos más interesantes asociados con cada variante. Por ejemplo:

#[derive(Drop)]
enum Message {
    Quit : (),
    Echo : (felt252),
    Move : (u128, u128),
}

En este ejemplo, el enum Message tiene tres variantes: Quit, Echo y Move, todas con tipos diferentes:

  • Quit no tiene datos asociados en absoluto.
  • Echo incluye un solo campo.
  • Move incluye dos valores u128.

Incluso puedes usar una estructura o otro enum que hayas definido dentro de una de las variantes de tu enum.

Implementaciones de Traits para Enums

En Cairo, puedes definir traits e implementarlos para tus enums personalizados. Esto te permite definir métodos y comportamientos asociados con el enum. Aquí hay un ejemplo de cómo definir un trait e implementarlo para el enum Message anterior:

trait Processing {
    fn process(self: Message);
}

impl ProcessingImpl of Processing {
    fn process(self: Message) {
        match self {
            Message::Quit(()) => {
                'quitting'.print();
            },
            Message::Echo(value) => {
                value.print();
            },
            Message::Move((x, y)) => {
                'moving'.print();
            },
        }
    }
}

En este ejemplo, implementamos el trait Processing para Message. Así es cómo podría ser utilizado para procesar un mensaje Quit:

let msg: Message = Message::Quit(());
msg.process();

Al ejecutar este código se imprimiría quitting.

El Enum Option y sus ventajas

El enum Option es un enum estándar en Cairo que representa el concepto de un valor opcional. Tiene dos variantes: Some: T y None: (). Some: T indica que hay un valor de tipo T, mientras que None representa la ausencia de un valor.

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

El enum Option es útil porque te permite representar explícitamente la posibilidad de que un valor esté ausente, lo que hace que tu código sea más expresivo y fácil de entender. Usar Option también puede ayudar a prevenir errores causados por el uso de valores null no inicializados o inesperados.

Para darte un ejemplo, aquí hay una función que devuelve el índice del primer elemento de un arreglo con un valor dado, o None si el elemento no está presente.

Nota: en el futuro sería bueno reemplazar este ejemplo con algo más simple que use un ciclo y sin código relacionado con el gas.

use array::ArrayTrait;
use debug::PrintTrait;
fn find_value_recursive(
    arr: @Array<felt252>, value: felt252, index: usize
) -> Option<usize> {

    match gas::withdraw_gas() {
        Option::Some(_) => {},
        Option::None(_) => {
            let mut data = ArrayTrait::new();
            data.append('OOG');
            panic(data);
        },
    }

    if index >= arr.len() {
        return Option::None(());
    }

    if *arr.at(index) == value {
        return Option::Some(index);
    }

    find_value_recursive(arr, value, index + 1_usize)
}

#[test]
#[available_gas(999999)]
fn test_increase_amount() {
    let mut my_array = ArrayTrait::new();
    my_array.append(3);
    my_array.append(7);
    my_array.append(2);
    my_array.append(5);

    let value_to_find = 7;
    let result = find_value_recursive(@my_array, value_to_find, 0_usize);

    match result {
        Option::Some(index) => {
            if index == 1_usize {
                'it worked'.print();
            }
        },
        Option::None(()) => {
            'not found'.print();
        },
    }
}

Al ejecutar este código se imprimiría it worked.

La construcción de control de flujo Match

Cairo tiene una construcción de control de flujo extremadamente poderosa llamada match que te permite comparar un valor con una serie de patrones y luego ejecutar código basado en el patrón que coincide. Los patrones pueden estar compuestos por valores literales, nombres de variables, comodines y muchas otras cosas. El poder de match proviene de la expresividad de los patrones y del hecho de que el compilador confirma que se manejan todos los casos posibles.

Piensa en una expresión match como una máquina clasificadora de monedas: las monedas se deslizan por una pista con agujeros de diferentes tamaños a lo largo de ella, y cada moneda cae por el primer agujero que encuentra en el que encaja. De la misma manera, los valores pasan por cada patrón en un match, y en el primer patrón en el que el valor "encaja", el valor cae en el bloque de código asociado para ser utilizado durante la ejecución.

Hablando de monedas, ¡usemoslas como ejemplo con match! Podemos escribir una función que toma una moneda de EE. UU. desconocida y, de manera similar a la máquina de contar, determina qué moneda es y devuelve su valor en centavos, como se muestra en el Listado 5-3.

enum Coin {
    Penny: (),
    Nickel: (),
    Dime: (),
    Quarter: (),
}

fn value_in_cents(coin: Coin) -> felt252 {
    match coin {
        Coin::Penny(_) => 1,
        Coin::Nickel(_) => 5,
        Coin::Dime(_) => 10,
        Coin::Quarter(_)=> 25,
    }
}

Listado 5-3: Un enum y una expresión match que tiene las variantes del enum como sus patrones.

Desglosemos el match en la función value_in_cents. Primero enumeramos la palabra clave match seguida de una expresión, que en este caso es el valor coin. Esto parece muy similar a una expresión condicional utilizada con if, pero hay una gran diferencia: con if, la condición debe evaluarse a un valor booleano, pero aquí puede ser de cualquier tipo. El tipo de moneda en este ejemplo es el enum Coin que definimos en la primera línea.

A continuación, están los brazos del match. Un brazo tiene dos partes: un patrón y algún código. El primer brazo aquí tiene un patrón que es el valor Coin::Penny(_) y luego el operador => que separa el patrón y el código a ejecutar. El código en este caso es simplemente el valor 1. Cada brazo está separado del siguiente con una coma.

Cuando se ejecuta la expresión match, compara el valor resultante con el patrón de cada brazo, en orden. Si un patrón coincide con el valor, se ejecuta el código asociado con ese patrón. Si ese patrón no coincide con el valor, la ejecución continúa con el siguiente brazo, como en una máquina clasificadora de monedas. Podemos tener tantos brazos como necesitemos: en el ejemplo anterior, nuestro match tiene cuatro brazos.

En Cairo, el orden de los brazos debe seguir el mismo orden que el enum.

El código asociado con cada brazo es una expresión, y el valor resultante de la expresión en el brazo coincidente es el valor que se devuelve para toda la expresión match.

Normalmente no usamos llaves si el código del brazo del match es corto, como en nuestro ejemplo donde cada brazo simplemente devuelve un valor. Si desea ejecutar varias líneas de código en un brazo del match, debe usar llaves, con una coma después del brazo. Por ejemplo, el siguiente código imprime "¡Moneda de la suerte!" cada vez que se llama al método con una Coin::Penny(()), pero aún devuelve el último valor del bloque, 1:

fn value_in_cents(coin: Coin) -> felt252 {
    match coin {
        Coin::Penny(_) => {
            ('Lucky penny!').print();
            1
        },
        Coin::Nickel(_) => 5,
        Coin::Dime(_) => 10,
        Coin::Quarter(_)=> 25,
    }
}

Patrones que se vinculan con valores

Otra característica útil de los brazos de coincidencia es que pueden vincularse con las partes de los valores que coinciden con el patrón. Así es como podemos extraer valores de las variantes de una enum.

Como ejemplo, cambiemos una de nuestras variantes de enum para que contenga datos en su interior. Desde 1999 hasta 2008, la Casa de la Moneda de los Estados Unidos acuñó monedas de 25 centavos con diseños diferentes para cada uno de los 50 estados en un lado. Ninguna otra moneda tenía diseños estatales, por lo que solo los cuartos tienen este valor adicional. Podemos agregar esta información a nuestra enum cambiando la variante Quarter para incluir un valor UsState almacenado en su interior, lo cual hemos hecho en la Lista 5-4.

#[derive(Drop)]
enum UsState {
    Alabama: (),
    Alaska: (),
}

#[derive(Drop)]
enum Coin {
    Penny: (),
    Nickel: (),
    Dime: (),
    Quarter: (UsState),
}

Listado 5-4: Un enum Coin en el que la variante Quarter también tiene un valor UsState

Imaginemos que un amigo está tratando de recolectar todas las 50 monedas de cuarto de estado. Mientras clasificamos nuestro cambio suelto por tipo de moneda, también llamaremos el nombre del estado asociado con cada cuarto para que si es uno que nuestro amigo no tiene, puedan agregarlo a su colección.

En la expresión match de este código, agregamos una variable llamada state al patrón que coincide con los valores de la variante Coin::Quarter. Cuando se hace una coincidencia de Coin::Quarter, la variable state se vinculará al valor del estado de ese cuarto. Luego podemos usar state en el código para ese brazo, así:

fn value_in_cents(coin: Coin) -> felt252 {
    match coin {
        Coin::Penny(_) => 1,
        Coin::Nickel(_) => 5,
        Coin::Dime(_) => 10,
        Coin::Quarter(state)=> {
            state.print();
            25
        },
    }
}

Para imprimir el valor de una variante de un enum en Cairo, necesitamos agregar una implementación para la función print de debug::PrintTrait:

impl UsStatePrintImpl of PrintTrait::<UsState> {
    fn print(self: UsState) {
        match self {
            UsState::Alabama(_) => ('Alabama').print(),
            UsState::Alaska(_) => ('Alaska').print(),
        }
    }
}

Si llamáramos a value_in_cents(Coin::Quarter(UsState::Alaska(()))), coin sería Coin::Quarter(UsState::Alaska()). Cuando comparamos ese valor con cada uno de los brazos del match, ninguno coincide hasta que llegamos a Coin::Quarter (state). En ese momento, la asignación para state será el valor UsState::Alaska(). Luego podemos usar esa asignación en el PrintTrait, obteniendo así el valor interno de estado fuera de la variante Coin para Quarter.

Coincidencia Con Opciones

En la sección anterior, queríamos obtener el valor interno T fuera del caso Some al usar Option<T>; ¡también podemos manejar Option<T> usando match, como lo hicimos con el enum Coin! En lugar de comparar monedas, compararemos las variantes de Option<T>, pero la forma en que funciona la expresión match sigue siendo la misma. Puedes usar opciones importando el trait option::OptionTrait.

Digamos que queremos escribir una función que tome una Option<u8> y, si hay un valor dentro, agregue 1_u8 a ese valor. Si no hay un valor dentro, la función debería devolver el valor None y no intentar realizar ninguna operación.

Esta función es muy fácil de escribir, gracias a match, y se verá como en el listado 5-5.

use option::OptionTrait;
use debug::PrintTrait;

fn plus_one(x: Option<u8>) -> Option<u8> {
    match x {
        Option::Some(val) => Option::Some(val + 1_u8),
        Option::None(_) => Option::None(()),
    }
}

fn main() {
    let five : Option<u8> = Option::Some(5_u8);
    let six : Option<u8> = plus_one(five);
    six.unwrap().print();
    let none = plus_one(Option::None(()));
    none.unwrap().print();
}

Listado 5-5: Una función que usa una expresión match en un Option<u8>

Tenga en cuenta que los brazos (arms) deben respetar el mismo orden que el enum definido en OptionTrait de la librería central de Cairo.

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

Estudiemos con más detalle la primera ejecución de plus_one. Cuando llamamos a plus_one(five), la variable x en el cuerpo de plus_one tendrá el valor Some(5_u8). Luego, lo comparamos con cada rama del match:

    Option::Some(val) => Option::Some(val + 1_u8),

¿El valor Option::Some(5_u8) coincide con el patrón Option::Some(val)? ¡Sí! Tenemos la misma variante. val se vincula al valor contenido en Option::Some, por lo que val toma el valor 5_u8. Luego se ejecuta el código en el brazo del match, por lo que agregamos 1_u8 al valor de val y creamos un nuevo valor Option::Some con nuestro total 6_u8 en su interior. Debido a que se ha realizado la primera coincidencia, no se comparan otros brazos.

Ahora consideremos la segunda llamada de plus_one en nuestra función principal, donde x es Option::None(()). Entramos en el match y comparamos con el primer brazo:

    Option::Some(val) => Option::Some(val + 1_u8),

El valor Option::Some(5_u8) no coincide con el patrón Option::None, así que continuamos con el siguiente brazo:

    Option::None(_) => Option::None(()),

¡Coincide! No hay valor al que agregar, por lo que el programa se detiene y devuelve el valor Option::None(()) en el lado derecho de =>.

Combinar match y enumeraciones es útil en muchas situaciones. Verás este patrón mucho en el código de Cairo: match contra una enumeración, enlaza una variable con los datos internos y luego ejecuta código basado en ella. Es un poco complicado al principio, pero una vez que te acostumbras, desearás tenerlo en todos los lenguajes. Es consistentemente favorito de los usuarios.

Los Matches Son Exhaustivos

Hay otro aspecto de los matches que necesitamos discutir: los patrones de los brazos deben cubrir todas las posibilidades. Considera esta versión de nuestra función plus_one, que tiene un error y no se compilará:

$ cairo-run src/test.cairo
    error: Unsupported match. Currently, matches require one arm per variant,
    in the order of variant definition.
    --> test.cairo:34:5
        match x {
        ^*******^
    Error: failed to compile: ./src/test.cairo

Cairo sabe que no cubrimos todos los casos posibles, ¡e incluso sabe qué patrón olvidamos! Los matches en Cairo son exhaustivos: debemos cubrir todas las posibilidades para que el código sea válido. Especialmente en el caso de Option<T>, cuando Cairo nos impide olvidar manejar explícitamente el caso None, nos protege de asumir que tenemos un valor cuando podríamos tener nulo, lo que hace imposible el error de mil millones de dólares discutido anteriormente.

Match 0 y el Comodín _

Usando enums, también podemos tomar acciones especiales para algunos valores particulares, pero para todos los demás valores tomar una acción predeterminada. Actualmente solo se admiten 0 y el operador _.

Imaginemos que estamos implementando un juego en el que obtienes un número aleatorio entre 0 y 7. Si tienes 0, ganas. Para todos los demás valores pierdes. Aquí hay un match que implementa esa lógica, con el número codificado en lugar de un valor aleatorio.

fn did_i_win(nb: felt252) {
    match nb {
        0 => ('You won!').print(),
        _ => ('You lost...').print(),
    }
}

La primera armadura tiene un patrón de valores literales 0. Para la última armadura que cubre todos los demás posibles valores, el patrón es el carácter _. Este código se compila, aunque no hayamos listado todos los posibles valores que un felt252 puede tener, ya que el último patrón coincidirá con todos los valores que no estén específicamente enumerados. Este patrón de captura de todo cumple con el requisito de que match debe ser exhaustivo. Tenga en cuenta que debemos poner la armadura de captura de todo al final porque los patrones se evalúan en orden. Si ponemos la armadura de captura de todo antes, las otras armaduras nunca se ejecutarán, por lo que Cairo nos advertirá si agregamos armaduras después de una de captura de todo.

Gestión de proyectos Cairo con Paquetes, Crates y Módulos

A medida que escriba programas grandes, la organización de su código se volverá cada vez más importante. Al agrupar funcionalidades relacionadas y separar el código con características distintas, aclarará dónde encontrar el código que implementa una característica en particular y dónde ir para cambiar cómo funciona una característica.

Los programas que hemos escrito hasta ahora han estado en un módulo en un solo archivo. A medida que un proyecto crece, debe organizar el código dividiéndolo en múltiples módulos y luego en múltiples archivos. A medida que un paquete crece, puede extraer partes en Crates separados que se convierten en dependencias externas. Este capítulo cubre todas estas técnicas.

También discutiremos la encapsulación de detalles de implementación, lo que le permite reutilizar el código a un nivel superior: una vez que ha implementado una operación, otro código puede llamar a su código sin tener que saber cómo funciona la implementación.

Un concepto relacionado es el ámbito: el contexto anidado en el que se escribe el código tiene un conjunto de nombres que se definen como "en ámbito". Al leer, escribir y compilar código, los programadores y compiladores deben saber si un nombre particular en un lugar particular se refiere a una variable, función, estructura, enumeración, módulo, constante u otro elemento y qué significa ese elemento. Puede crear ámbitos y cambiar qué nombres están dentro o fuera de ámbito. No puede tener dos elementos con el mismo nombre en el mismo ámbito.

Cairo tiene varias características que le permiten gestionar la organización de su código. Estas características, a veces denominadas colectivamente el sistema de módulos, incluyen:

  • Paquetes: Una característica de Scarb que le permite construir, probar y compartir Crates.
  • Crates: Un árbol de módulos que corresponde a una única unidad de compilación. Tiene un directorio raíz y un módulo raíz definido en el archivo lib.cairo bajo este directorio.
  • Módulos y use: le permiten controlar la organización y el ámbito de los elementos.
  • Rutas: una forma de nombrar un elemento, como una estructura, función o módulo.

En este capítulo, cubriremos todas estas características, discutiremos cómo interactúan y explicaremos cómo usarlas para gestionar el ámbito. Al final, debería tener una comprensión sólida del sistema de módulos y ser capaz de trabajar con ámbitos como un profesional.

Paquetes y Crates

¿Qué es un Crate?

Un crate es la cantidad más pequeña de código que el compilador de Cairo considera a la vez. Incluso si ejecuta cairo-compile en lugar de scarb build y pasa un solo archivo de código fuente, el compilador considera que ese archivo es un crate. Los crates pueden contener módulos, y los módulos pueden estar definidos en otros archivos que se compilan junto con el crate, como se discutirá en las secciones siguientes.

¿Qué es la Raíz del Crate?

La raíz del crate es el archivo de origen lib.cairo desde el cual el compilador de Cairo comienza y forma el módulo raíz de su crate (explicaremos los módulos en profundidad en la sección "Definición de módulos para controlar el alcance").

¿Qué es un Paquete?

Un paquete de Cairo es un conjunto de uno o más crates con un archivo Scarb.toml que describe cómo construir esos crates. Esto permite la división del código en partes más pequeñas y reutilizables, y facilita la gestión de dependencias más estructurada.

Creación de un Paquete con Scarb

Puede crear un nuevo paquete de Cairo utilizando la herramienta de línea de comandos scarb. Para crear un nuevo paquete, ejecute el siguiente comando: "

scarb new my_crate

Este comando generará un nuevo directorio de paquete llamado my_crate con la siguiente estructura:

my_crate/
├── Scarb.toml
└── src
    └── lib.cairo
  • src/ es el directorio principal donde se almacenarán todos los archivos de origen de Cairo para el paquete.
  • lib.cairo es el módulo raíz predeterminado del crate, que también es el punto de entrada principal del paquete. Por defecto, está vacío.
  • Scarb.toml es el archivo de manifiesto del paquete, que contiene metadatos y opciones de configuración para el paquete, como dependencias, nombre del paquete, versión y autores. Puede encontrar documentación al respecto en la referencia de Scarb.
[package]
name = "my_crate"
version = "0.1.0"

[dependencies]
# foo = { path = "vendor/foo" }

A medida que desarrolla su paquete, es posible que desee organizar su código en varios archivos de origen de Cairo. Puede hacer esto creando archivos .cairo adicionales dentro del directorio src o sus subdirectorios.

Definición de módulos para controlar el ámbito

En esta sección, hablaremos sobre los módulos y otras partes del sistema de módulos, como las rutas que le permiten nombrar elementos y la palabra clave use que introduce una ruta en el ámbito.

Primero, vamos a comenzar con una lista de reglas para su fácil referencia cuando esté organizando su código en el futuro. Luego explicaremos cada una de las reglas en detalle.

Hoja de trucos de módulos

Aquí proporcionamos una referencia rápida sobre cómo funcionan los módulos, las rutas y la palabra clave use en el compilador, y cómo la mayoría de los desarrolladores organizan su código. Iremos a través de ejemplos de cada una de estas reglas a lo largo de este capítulo, pero este es un buen lugar para consultar como recordatorio de cómo funcionan los módulos. Puede crear un nuevo proyecto Scarb con scarb new backyard para seguir adelante.

  • Comience desde la raíz del crate: Al compilar un crate, el compilador primero busca código para compilar en el archivo raíz del crate (src/lib.cairo).

  • Declaración de módulos: En el archivo raíz del crate, puede declarar nuevos módulos; digamos que declara un módulo "garden" con mod garden;. El compilador buscará el código del módulo en estos lugares:

    • En línea, dentro de llaves que reemplazan al punto y coma que sigue a mod garden;.

        // crate root file (lib.cairo)
          mod garden {
          // code defining the garden module goes here
          }
      
  • En el archivo src/garden.cairo

  • Declarando submódulos: En cualquier archivo que no sea la raíz del paquete, puede declarar submódulos. Por ejemplo, podría declarar mod vegetables; en el archivo src/garden.cairo. El compilador buscará el código del submódulo dentro del directorio nombrado por el módulo padre en estos lugares:

    • En línea, directamente después de mod vegetables, dentro de llaves en lugar del punto y coma.

      // src/garden.cairo file
      mod vegetables {
          // code defining the vegetables submodule goes here
      }
      
    • En el archivo src/garden/vegetables.cairo

  • Rutas a código en módulos: Una vez que un módulo forma parte de su paquete, puede hacer referencia al código de ese módulo desde cualquier otro lugar en ese mismo paquete, utilizando la ruta al código. Por ejemplo, un tipo Asparagus en el módulo de vegetales del jardín se encontraría en backyard::garden::vegetables::Asparagus.

  • La palabra clave use: Dentro de un alcance, la palabra clave use crea atajos a elementos para reducir la repetición de rutas largas. En cualquier alcance que pueda hacer referencia a backyard::garden::vegetables::Asparagus, puede crear un atajo con use backyard::garden::vegetables::Asparagus; y a partir de entonces solo necesita escribir Asparagus para usar ese tipo en el alcance.

Aquí creamos un paquete llamado backyard que ilustra estas reglas. El directorio del paquete, también llamado backyard, contiene estos archivos y directorios:

backyard/
├── Scarb.toml
├── cairo_project.toml
└── src
    ├── garden
    │   └── vegetables.cairo
    ├── garden.cairo
    └── lib.cairo

Nota: Aquí se observa un archivo cairo_project.toml. Este es el archivo de configuración para proyectos "vanilla" de Cairo (es decir, no gestionados por Scarb), que se requiere para ejecutar el comando cairo-run . y ejecutar el código del crate. Es necesario hasta que Scarb implemente esta función. El contenido del archivo es:

[crate_roots]
backyard = "src"

y indica que la caja llamada "backyard" se encuentra en el directorio src.

El archivo raíz de la caja en este caso es src/lib.cairo, y contiene:

Nombre de archivo: src/lib.cairo

use garden::vegetables::Asparagus;

mod garden;

fn main(){
    let Asparagus = Asparagus{};
}


La línea mod garden; le indica al compilador que incluya el código que encuentra en src/garden.cairo, que es:

Nombre del archivo: src/garden.cairo

mod vegetables;

Aquí, mod vegetables; significa que el código en src/garden/vegetables.cairo también está incluido. Ese código es:

#[derive(Copy,Drop)]
struct Asparagus{}

La línea use garden::vegetables::Asparagus; nos permite traer el tipo Asparagus al ámbito de alcance, para que podamos usarlo en la función main.

¡Ahora vamos a entrar en los detalles de estas reglas y demostrarlas en acción!

Agrupando el Código Relacionado en Módulos

Los módulos nos permiten organizar el código dentro de un paquete para hacerlo más legible y fácil de reutilizar. Como ejemplo, escribiremos un paquete de biblioteca que proporcione la funcionalidad de un restaurante. Definiremos las firmas de las funciones pero dejaremos sus cuerpos vacíos para concentrarnos en la organización del código, en lugar de en la implementación de un restaurante.

En la industria de la restauración, algunas partes de un restaurante se denominan front of house (delante de la casa) y otras como back of house (detrás de la casa). Front of house es donde están los clientes; esto abarca desde donde los anfitriones sientan a los clientes, los servidores toman órdenes y pagos, y los barman hacen bebidas. Back of house es donde los chefs y cocineros trabajan en la cocina, los lavaplatos limpian y los gerentes hacen trabajo administrativo.

Para estructurar nuestro paquete de esta manera, podemos organizar sus funciones en módulos anidados. Cree un nuevo paquete llamado restaurant ejecutando el comando scarb new restaurant; luego ingrese el código en el Listado 6-1 en src/lib.cairo para definir algunos módulos y firmas de funciones. Aquí está la sección de front of house:

Nombre del archivo: src/lib.cairo

mod front_of_house {
    mod hosting {
        fn add_to_waitlist() {}

        fn seat_at_table() {}
    }

    mod serving {
        fn take_order() {}

        fn serve_order() {}

        fn take_payment() {}
    }
}

Listado 6-1: Un módulo front_of_house que contiene otros módulos que a su vez contienen funciones

Definimos un módulo con la palabra clave mod seguida del nombre del módulo (en este caso, front_of_house). El cuerpo del módulo va entre llaves. Dentro de los módulos, podemos colocar otros módulos, como en este caso con los módulos hosting y serving. Los módulos también pueden contener definiciones de otros elementos, como structs, enums, constantes, traits y, como en el Listado 6-1, funciones.

Al utilizar módulos, podemos agrupar las definiciones relacionadas y darles un nombre que indique por qué están relacionadas. Los programadores que usan este código pueden navegar por el código en función de los grupos en lugar de tener que leer todas las definiciones, lo que hace que sea más fácil encontrar las definiciones relevantes para ellos. Los programadores que agregan nueva funcionalidad a este código sabrían dónde colocar el código para mantener el programa organizado.

Anteriormente, mencionamos que src/lib.cairo se llama raíz de la caja. La razón de este nombre es que el contenido de este archivo forma un módulo con el nombre de la caja en la raíz de la estructura de módulos de la caja, conocido como el árbol de módulos.

El Listado 6-2 muestra el árbol de módulos para la estructura en el Listado 6-1.

restaurant
 └── front_of_house
     ├── hosting
     │   ├── add_to_waitlist
     │   └── seat_at_table
     └── serving
         ├── take_order
         ├── serve_order
         └── take_payment

Listing 6-2: El árbol de módulos para el código en el Listado 6-1

Este árbol muestra cómo algunos módulos se anidan dentro de otros; por ejemplo, hosting se anida dentro de front_of_house. El árbol también muestra que algunos módulos son hermanos entre sí, lo que significa que están definidos en el mismo módulo; hosting y serving son hermanos definidos dentro de front_of_house. Si el módulo A está contenido dentro del módulo B, decimos que el módulo A es el hijo del módulo B y que el módulo B es el padre del módulo A. Observa que todo el árbol de módulos está enraizado en el nombre explícito del paquete restaurant.

El árbol de módulos podría recordarte al árbol de directorios del sistema de archivos en tu computadora; ¡esta es una comparación muy adecuada! Al igual que los directorios en un sistema de archivos, utilizamos los módulos para organizar nuestro código. Y al igual que los archivos en un directorio, necesitamos una manera de encontrar nuestros módulos.

Caminos para hacer referencia a un elemento en el árbol de módulos

Para indicarle a Cairo dónde encontrar un elemento en el árbol de módulos, usamos un camino de la misma forma que usamos una ruta al navegar por un sistema de archivos. Para llamar a una función, necesitamos conocer su camino.

Un camino puede tomar dos formas:

  • Un camino absoluto es la ruta completa que comienza desde la raíz del crate. El camino absoluto comienza con el nombre del crate.

  • Un camino relativo comienza desde el módulo actual.

    Tanto los caminos absolutos como los relativos son seguidos por uno o más identificadores separados por dos puntos dobles (::).

Para ilustrar esta noción, tomemos de nuevo nuestro ejemplo del restaurante que usamos en el último capítulo. Tenemos un crate llamado restaurant en el cual tenemos un módulo llamado front_of_house que contiene un módulo llamado hosting. El módulo hosting contiene una función llamada add_to_waitlist. Queremos llamar a la función add_to_waitlist desde la función eat_at_restaurant. Necesitamos decirle a Cairo el camino hacia la función add_to_waitlist para que pueda encontrarla.

Nombre del archivo: src/lib.cairo

mod front_of_house {
    mod hosting {
        fn add_to_waitlist() {}

        fn seat_at_table() {}
    }

    mod serving {
        fn take_order() {}

        fn serve_order() {}

        fn take_payment() {}
    }
}


pub fn eat_at_restaurant() {
    // Absolute path
    restaurant::front_of_house::hosting::add_to_waitlist(); // ✅ Compiles

    // Relative path
    front_of_house::hosting::add_to_waitlist(); // ✅ Compiles
}

La primera vez que llamamos a la función add_to_waitlist en eat_at_restaurant, usamos una ruta absoluta. La función add_to_waitlist está definida en la misma caja que eat_at_restaurant. En Cairo, las rutas absolutas comienzan desde la raíz de la caja, a la cual se refiere usando el nombre de la caja.

La segunda vez que llamamos a add_to_waitlist, usamos una ruta relativa. La ruta comienza con front_of_house, el nombre del módulo definido en el mismo nivel del árbol de módulos que eat_at_restaurant. Aquí, el equivalente en el sistema de archivos sería usar la ruta ./front_of_house/hosting/add_to_waitlist. Comenzar con un nombre de módulo significa que la ruta es relativa al módulo actual.

Comenzando rutas relativas con super

Elegir si usar o no super es una decisión que tomarás basada en tu proyecto y dependerá de si es más probable que muevas el código de definición de elementos por separado o junto con el código que usa el elemento.

Nombre de archivo: src/lib.cairo

fn deliver_order() {}

mod back_of_house {
    fn fix_incorrect_order() {
        cook_order();
        super::deliver_order();
    }

    fn cook_order() {}
}

Aquí se puede ver directamente que se accede fácilmente a un módulo padre usando super, lo que no era el caso anteriormente.

Tipos Genéricos y Traits

Cada lenguaje de programación tiene herramientas para manejar eficazmente la duplicación de conceptos. En Cairo, una de esas herramientas son los genéricos: sustitutos abstractos de tipos concretos u otras propiedades. Podemos expresar el comportamiento de los genéricos o cómo se relacionan con otros genéricos sin saber qué habrá en su lugar al compilar y ejecutar el código.

Las funciones, estructuras, enumeraciones y traits pueden incorporar tipos genéricos como parte de su definición en lugar de tipos concretos como u32 o ContractAddress.

Los genéricos nos permiten reemplazar tipos específicos con un marcador de posición que representa múltiples tipos para eliminar la duplicación de código.

Para cada tipo concreto que reemplaza a un tipo genérico, el compilador crea una nueva definición, reduciendo el tiempo de desarrollo para el programador, pero la duplicación de código a nivel de compilación todavía existe. Esto puede ser importante si estás escribiendo contratos Starknet y usando un genérico para múltiples tipos que hará que el tamaño del contrato aumente.

Luego aprenderás cómo usar traits para definir comportamientos de manera genérica. Puedes combinar traits con tipos genéricos para restringir un tipo genérico para que acepte solo aquellos tipos que tienen un comportamiento particular, en lugar de cualquier tipo.

Tipos de datos genéricos

Usamos genéricos para crear definiciones de declaraciones de elementos, como structs y funciones, que luego podemos usar 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 usar efectivamente tipos genéricos con todos ellos.

Funciones genéricas

Al definir 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 el valor de retorno. Por ejemplo, imaginemos que queremos crear una función que, dadas dos matrices (Array) de elementos, devolverá la más grande. Si necesitamos realizar esta operación para listas de diferentes tipos, tendríamos que redefinir la función cada vez. Afortunadamente, podemos implementar la función una vez usando genéricos y seguir adelante con otras tareas.

// This code does not compile!

use array::ArrayTrait;

// 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 aquella con más elementos y elimina la otra. Si se compila el código anterior, se notará que fallará con un error diciendo que no se han definido traits para eliminar un array de un tipo genérico. Esto sucede porque el compilador no tiene forma de garantizar que un Array<T> sea eliminable al ejecutar la función main. Para eliminar un array de T, el compilador primero debe saber cómo eliminar T. Esto se puede solucionar especificando en la firma de la función largest_list que T debe implementar el trait de eliminación. La definición correcta de la función largest_list es la siguiente:

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

La nueva función largest_list incluye en su definición el requisito de que cualquier tipo genérico que se coloque allí debe poder eliminarse. La función main sigue sin cambios, el compilador es lo suficientemente inteligente como para deducir qué tipo concreto se está utilizando y si implementa el trait Drop.

Restricciones para tipos genéricos

Al definir tipos genéricos, es útil tener información sobre ellos. Saber qué traits implementa un tipo genérico nos permite usarlos de manera más efectiva en la lógica de una función a costa de limitar los tipos genéricos que se pueden usar con la función. Vimos un ejemplo de esto anteriormente al agregar la implementación de TDrop como parte de los argumentos genéricos de largest_list. Si bien TDrop se agregó para cumplir con los requisitos del compilador, también podemos agregar restricciones para beneficiar nuestra lógica de función.

Imaginemos que queremos, dado una lista de elementos de algún tipo genérico T, encontrar el elemento más pequeño entre ellos. Inicialmente, sabemos que para que un elemento de tipo T sea comparable, debe implementar el trait PartialOrd. La función resultante sería:

// This code does not compile!
use array:ArrayTrait;

// 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_usize];

    // The index we will use to move through the list
    let mut index = 1_usize;

    // 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 = ArrayTrait::new();
    list.append(5_u8);
    list.append(3_u8);
    list.append(10_u8);

    // We need to specify that we are passing a snapshot of `list` as an argument
    let s = smallest_element(@list);
    assert(s == 3_u8, 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:

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_usize];
    let mut index = 1_usize;
    loop {
        if index >= list.len(){
            break smallest;
        }
        if *list[index] < smallest {
            smallest = *list[index];
        }
        index = index + 1;
    }
}

Estructuras

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.

// This code does not compile!

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


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

La compilación del código anterior daría un error debido a que la macro derive no funciona bien con tipos genéricos. Cuando se usan tipos genéricos, es mejor escribir directamente los traits que se quieren utilizar:

struct Wallet<T> {
    balance: T,
}

impl WalletDrop<T, impl TDrop : Drop<T>> of Drop<Wallet<t>>;

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

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 agregar un campo a Wallet que represente su dirección de Cairo y queremos que ese campo sea diferente a T pero también genérico, simplemente podemos agregar otro tipo genérico entre los <>:

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

impl WalletDrop<T, impl TDrop: Drop<T>, U, impl UDrop: Drop<U>> of Drop<Wallet<T, U>>;


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

Agregamos a la definición de la estructura Wallet un nuevo tipo genérico U y luego asignamos este tipo al nuevo miembro del campo address. Luego adaptamos el trait WalletDrop para que funcione con el nuevo tipo genérico U. ¡Observa que al inicializar la estructura dentro de main, automáticamente infiere que T es un u128 y U es un felt252 y como ambos son droppable, Wallet también lo es!

Enumeraciones

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

Métodos Genéricos

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:

struct Wallet<T> {
    balance: T,
}

impl WalletDrop<T, impl TDrop: Drop<T>> of Drop<Wallet<T, U>>;

trait WalletTrait<T> {
    fn balance(self: @Wallet<T>) -> @T;
}

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

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_u128};
    assert(w.balance() == 50_u128, 0);

    w.receive(100_u128)
    assert(w.balance() == 150_u128, 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 de forma ingenua el trait y la implementación de mixup:

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

trait WalletMixTrait<T1, U1> {
    fn mixup<T2, impl T2Drop: Drop<T2>, U2, impl U2Drop: Drop<U2>>(self: Wallet<T1, U1>, other: Wallet<T2, U2>) -> Wallet<T1, U2>;
}

impl WalletMixImpl<T1, impl T1Drop: Drop<T1>,  U1, impl U1Drop: Drop<U1>> of WalletMixTrait<T1, U1> {
    fn mixup<T2, impl T2Drop: Drop<T2>, U2, impl U2Drop: 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_u128};
   let w2 = Wallet{ balance: 32, address: 100_u8};

   let w3 = w1.mixup(w2);

   assert(w3.balance == true, 0);
   assert(w3.address == 100_u8, 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>.

Traits en Cairo

Los traits especifican plantillas de funcionalidad que pueden ser implementadas. La especificación de la plantilla incluye un conjunto de firmas de funciones que contienen anotaciones de tipos para los parámetros y el valor de retorno. Esto establece un estándar para implementar la funcionalidad específica.

Definiendo un Trait

Para definir un trait, se utiliza la palabra clave trait seguida del nombre del trait en PascalCase y luego las firmas de funciones dentro de un par de llaves.

Por ejemplo, supongamos que tenemos múltiples estructuras que representan formas. Queremos que nuestra aplicación pueda realizar operaciones de geometría en estas formas, por lo que definimos un trait ShapeGeometry que contiene una plantilla para implementar operaciones de geometría en una forma de esta manera:

trait ShapeGeometry {
    fn boundary( self: Rectangle ) -> u64;
    fn area( self: Rectangle ) -> u64;
}

Aquí nuestro trait ShapeGeometry declara las firmas de dos métodos boundary y area. Cuando se implementen, ambas funciones deben devolver un u64 y aceptar parámetros tal como se especifica en el trait.

Implementando un Trait

Un trait puede ser implementado usando la palabra clave impl seguida del nombre de la implementación y la palabra of, seguida del nombre del trait que está siendo implementado. Aquí hay un ejemplo de cómo implementar el trait ShapeGeometry.

impl RectangleGeometry of ShapeGeometry {
	fn boundary( self: Rectangle ) -> u64 {
        2_u64 * (self.height + self.width)
    }
	fn area( self: Rectangle ) -> u64 {
		self.height * self.width
	}
}

En el código anterior, RectangleGeometry implementa el trait ShapeGeometry definiendo lo que deben hacer los métodos boundary y area. Note que los tipos de los parámetros de las funciones y los valores de retorno son idénticos a los especificados en el trait.

Parámetro self

En el ejemplo anterior, self es un parámetro especial. Cuando se usa un parámetro con el nombre self, las funciones implementadas también se adjuntan a las instancias del tipo como métodos. Aquí hay una ilustración,

Cuando se implementa el trait ShapeGeometry, la función area del trait ShapeGeometry se puede llamar de dos maneras:

let rect = Rectangle { ... }; // Rectangle instantiation

// First way, as a method on the struct instance
let area1 = rect.area();
// Second way, from the implementation
let area2 = RectangleGeometry::area(rect);
// `area1` has same value as `area2`
area1.print();
area2.print();

Traits con tipos genéricos

Por lo general, queremos escribir un trait cuando queremos que múltiples tipos implementen una funcionalidad de una manera estándar. Sin embargo, en el ejemplo anterior, las firmas son estáticas y no se pueden usar para múltiples tipos. Para hacer esto, usamos tipos genéricos al definir traits.

En el siguiente ejemplo, usamos el tipo genérico T y nuestras firmas de métodos pueden usar este alias que se puede proporcionar durante la implementación.

use debug::PrintTrait;

// Here T is an alias type which will be provided buring implementation
trait ShapeGeometry<T> {
    fn boundary( self: T ) -> u64;
    fn area( self: T ) -> u64;
}

// Implementation RectangleGeometry passes in <Rectangle>
// to implement the trait for that type
impl RectangleGeometry of ShapeGeometry::<Rectangle> {
    fn boundary( self: Rectangle ) -> u64 {
        2_u64 * (self.height + self.width)
    }
    fn area( self: Rectangle ) -> u64 {
        self.height * self.width
    }
}

// We might have another struct Circle
// which can use the same trait spec
impl CircleGeometry of ShapeGeometry::<Circle> {
    fn boundary( self: Circle ) -> u64 {
        (2_u64 * 314_u64 * self.radius) / 100_u64
    }
    fn area( self: Circle ) -> u64 {
       (314_u64 * self.radius * self.radius) / 100_u64
    }
}

fn main() {
    let rect = Rectangle { height: 5_u128, width: 7_u128 };
    rect.area().print(); // 35
    rect.boundary().print(); // 24

    let circ = Circle { radius: 5_u128 };
    circ.area().print(); // 78
    circ.boundary().print(); // 31
}

Administrando y usando implementaciones de traits externos

Para usar los métodos de traits, es necesario asegurarse de que los traits/implementaciones correctos estén importados. En el código anterior, importamos PrintTrait desde debug con use debug::PrintTrait; para usar el método print().

En algunos casos, puede ser necesario importar no solo el trait, sino también la implementación si están declarados en módulos separados. Si CircleGeometry estuviera en un módulo/archivo separado circle, entonces para usar boundary en circ: Circle, necesitaríamos importar CircleGeometry además de ShapeGeometry.

Si el código estuviera organizado en módulos de esta manera,

use debug::PrintTrait;

// struct Circle { ... } and struct Rectangle { ... }

mod geometry {
    use super::Rectangle;
    trait ShapeGeometry<T> {
        // ...
    }

    impl RectangleGeometry of ShapeGeometry::<Rectangle> {
        // ...
    }
}

// Could be in a different file
mod circle {
    use super::geometry::ShapeGeometry;
    use super::Circle;
    impl CircleGeometry of ShapeGeometry::<Circle> {
        // ...
    }
}

fn main() {
    let rect = Rectangle { height: 5_u64, width: 7_u64 };
    let circ = Circle { radius: 5_u64 };
    // Fails with this error
    // Method `area` not found on... Did you import the correct trait and impl?
    rect.area().print();
    circ.area().print();
}

Para hacer que funcione, además de,

use geometry::ShapeGeometry;

para hacerlo funcionar, también tendrías que usar CircleGeometry,

use circle::CircleGeometry

Testing de programas en Cairo

Cómo escribir Test

La Anatomía de una Función de Testing

Las pruebas son funciones en Cairo que verifican que el código no relacionado con las pruebas está funcionando de la manera esperada. Los cuerpos de las funciones de prueba típicamente realizan estas tres acciones:

  • Configuran cualquier dato o estado necesario.
  • Ejecutan el código que se desea probar.
  • Verifican que los resultados sean los esperados.

Veamos las características específicas que Cairo proporciona para escribir pruebas que realizan estas acciones, que incluyen el atributo test, la función assert y el atributo should_panic.

La anatomía de una función de prueba

En su forma más simple, una prueba en Cairo es una función que está anotada con el atributo test. Los atributos son metadatos sobre piezas de código en Cairo; un ejemplo es el atributo derive que usamos con estructuras en el capítulo 4. Para convertir una función en una función de prueba, agrega #[test] en la línea antes de fn. Cuando se ejecutan las pruebas con el comando cairo-test, Cairo construye un binario de ejecución de pruebas que ejecuta las funciones anotadas y reporta si cada función de prueba pasa o falla.

Creemos un nuevo proyecto llamado adder que sumará dos números usando Scarb con el comando scarb new adder: "

adder
├── cairo_project.toml
├── Scarb.toml
└── src
    └── lib.cairo

Nota: Aquí notarás un archivo cairo_project.toml. Este es el archivo de configuración para proyectos Cairo "vanilla" (es decir, no administrados por Scarb), que se requiere para ejecutar el comando cairo-test . para ejecutar el código del crate. Es necesario hasta que Scarb implemente esta característica. El contenido del archivo es:

[crate_roots]
adder = "src"

e indica que el crate llamado "adder" se encuentra en el directorio src.

En lib.cairo, agreguemos una primera prueba, como se muestra en el Listado 8-1.

Nombre de archivo: lib.cairo

#[cfg(test)]
mod tests {
    #[test]
    fn it_works() {
        let result = 2 + 2;
        assert(result == 4, 'result is not 4');
    }
}

Listado 8-1: Un módulo y función de prueba

Por ahora, ignoraremos las dos primeras líneas y nos centraremos en la función. Observa la anotación #[test]: este atributo indica que esta es una función de prueba, por lo que el runner de pruebas sabe que debe tratar esta función como una prueba. También podríamos tener funciones que no son de prueba en el módulo de pruebas para ayudar a configurar escenarios comunes o realizar operaciones comunes, por lo que siempre debemos indicar qué funciones son pruebas.

El cuerpo de la función de ejemplo utiliza la función assert, que comprueba que el resultado de sumar 2 y 2 es igual a 4. Esta afirmación sirve como ejemplo del formato de una prueba típica. Ejecutémoslo para ver que esta prueba pasa.

El comando cairo-test . ejecuta todas las pruebas en nuestro proyecto, como se muestra en el Listado 8-2.

$ cairo-test .
running 1 tests
test adder::lib::tests::it_works ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 filtered out;

"Listado 8-2: La salida al ejecutar una prueba

cairo-test compiló y ejecutó la prueba. Vemos la línea running 1 tests. La siguiente línea muestra el nombre de la función de prueba generada, llamada it_works, y que el resultado de ejecutar esa prueba es ok. El resumen general test result: ok. significa que todas las pruebas pasaron, y la porción que lee 1 passed; 0 failed totaliza el número de pruebas que pasaron o fallaron.

Es posible marcar una prueba como ignorada para que no se ejecute en una instancia particular; cubriremos eso en la sección Ignorando algunas pruebas a menos que se soliciten específicamente más adelante en este capítulo. Debido a que no hemos hecho eso aquí, el resumen muestra 0 ignoradas. También podemos pasar un argumento al comando cairo-test para ejecutar solo una prueba cuyo nombre coincida con una cadena; esto se llama filtrado y lo cubriremos en la sección Ejecución de pruebas individuales. Tampoco hemos filtrado las pruebas que se ejecutan, por lo que el final del resumen muestra 0 filtradas.

Comencemos a personalizar la prueba según nuestras necesidades. Primero, cambie el nombre de la función it_works a un nombre diferente, como exploration, así:

Nombre de archivo: lib.cairo"

#[cfg(test)]
mod tests {
    #[test]
    fn exploration() {
        let result = 2 + 2;
        assert(result == 4, 'result is not 4');
    }
}

Luego vuelva a ejecutar cairo-test -- --path src. La salida ahora muestra exploration en lugar de it_works:

$ cairo-test .
running 1 tests
test adder::lib::tests::exploration ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 filtered out;

Ahora agregaremos otra prueba, ¡pero esta vez haremos una prueba que falla! Las pruebas fallan cuando algo en la función de prueba causa un pánico. Cada prueba se ejecuta en un hilo nuevo y cuando el hilo principal ve que un hilo de prueba ha muerto, la prueba se marca como fallida. Agregue la nueva prueba como una función llamada another, de modo que su archivo src/lib.cairo se vea como en el Listado 8-3.

#[cfg(test)]
mod tests{
    #[test]
    fn another() {
        let result = 2 + 2;
        assert(result == 6, 'Make this test fail');
    }
}

Lista 8-3: Agregando una segunda prueba que fallará.

$ cairo-test .
running 2 tests
test adder::lib::tests::exploration ... ok
test adder::lib::tests::another ... fail
failures:
    adder::lib::tests::another - panicked with [1725643816656041371866211894343434536761780588 ('Make this test fail'), ].
Error: test result: FAILED. 1 passed; 1 failed; 0 ignored

Listing 8-4: Resultados de las pruebas cuando una pasa y otra falla

En lugar de ok, la línea adder::lib::tests::another muestra fail. Aparece una nueva sección entre los resultados individuales y el resumen. Muestra la razón detallada de cada falla de prueba. En este caso, obtenemos los detalles de que another falló porque falló con un pánico con [1725643816656041371866211894343434536761780588 ('Make this test fail'), ] en el archivo src/lib.cairo.

La línea de resumen se muestra al final: en general, nuestro resultado de prueba es FAILED. Tuvimos una prueba que pasó y otra que falló.

Ahora que ha visto cómo son los resultados de las pruebas en diferentes escenarios, veamos algunas funciones que son útiles en las pruebas.

Verificar resultados con la función assert

La función assert, proporcionada por Cairo, es útil cuando desea asegurarse de que alguna condición en una prueba se evalúe como verdadera. Le damos a la función assert un primer argumento que se evalúa como un valor booleano. Si el valor es true, no sucede nada y la prueba pasa. Si el valor es false, la función assert llama a panic() para hacer que la prueba falle con un mensaje que definimos como segundo argumento de la función assert. Usar la función assert nos ayuda a verificar que nuestro código funciona de la manera que pretendemos.

En Capítulo 4, Lista 5-15, usamos una estructura Rectangle y un método can_hold, que se repiten aquí en la Lista 8-5. Colocaremos este código en el archivo src/lib.cairo, luego escribiremos algunas pruebas para él usando la función assert.

Nombre de archivo: lib.cairo

trait RectangleTrait {
    fn area(self: @Rectangle) -> u64;
    fn can_hold(self: @Rectangle, other: @Rectangle) -> bool;
}

impl RectangleImpl of RectangleTrait {
    fn area(self: @Rectangle) -> u64 {
        *self.width * *self.height
    }
    fn can_hold(self: @Rectangle, other: @Rectangle) -> bool {
        *self.width > *other.width & *self.height > *other.height
    }
}

Lista 8-5: Uso de la estructura Rectangle y su método can_hold del Capítulo 5

El método can_hold devuelve un valor booleano, lo que significa que es un caso de uso perfecto para la función assert. En el Listado 8-6, escribimos una prueba que ejerce el método can_hold creando una instancia de Rectangle que tiene un ancho de 8_u64 y una altura de 7_u64 y asegurando que puede contener otra instancia de Rectangle que tiene un ancho de 5_u64 y una altura de 1_u64.

Nombre de archivo: lib.cairo

#[cfg(test)]
mod tests {
    use super::Rectangle;
    use super::RectangleTrait;

    #[test]
    fn larger_can_hold_smaller() {
        let larger = Rectangle {
            height: 7_u64,
            width: 8_u64,
        };
        let smaller = Rectangle {
            height: 1_u64,
            width: 5_u64,
        };

        assert(larger.can_hold(@smaller), 'rectangle cannot hold');
    }
}

Lista 8-6: Un test para can_hold que verifica si un rectángulo más grande realmente puede contener un rectángulo más pequeño

Note que hemos agregado dos nuevas líneas dentro del módulo de pruebas: use super::Rectangle; y use super::RectangleTrait;. El módulo de pruebas es un módulo regular que sigue las reglas normales de visibilidad. Debido a que el módulo de pruebas es un módulo interno, necesitamos traer el código bajo prueba en el módulo externo al ámbito del módulo interno.

Hemos nombrado nuestro test larger_can_hold_smaller, y hemos creado los dos instancias de Rectangle que necesitamos. Luego llamamos a la función assert y le pasamos el resultado de llamar a larger.can_hold(@smaller). Esta expresión se supone que devuelve true, por lo que nuestra prueba debería pasar. ¡Descubramoslo!

$ cairo-test .
running 1 tests
test adder::lib::tests::larger_can_hold_smaller ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 filtered out;

¡Pasó la prueba! Ahora agreguemos otra prueba, esta vez afirmamos que un rectángulo más pequeño no puede contener un rectángulo más grande:

Nombre de archivo: lib.cairo

#[cfg(test)]
mod tests {
    use super::Rectangle;
    use super::RectangleTrait;

    #[test]
    fn larger_can_hold_smaller() {
        // --snip--
    }

    #[test]
    fn smaller_cannot_hold_larger() {
        let larger = Rectangle {
            height: 7_u64,
            width: 8_u64,
        };
        let smaller = Rectangle {
            height: 1_u64,
            width: 5_u64,
        };

        assert(!smaller.can_hold(@larger), 'rectangle cannot hold');
    }
}

Como el resultado correcto de la función can_hold en este caso es false, debemos negar ese resultado antes de pasarlo a la función assert. Como resultado, nuestro test pasará si can_hold devuelve false:

$ cairo-test .
    running 2 tests
    test adder::lib::tests::smaller_cannot_hold_larger ... ok
    test adder::lib::tests::larger_can_hold_smaller ... ok
    test result: ok. 2 passed; 0 failed; 0 ignored; 0 filtered out;

¡Dos pruebas que pasan! Ahora veamos qué sucede con los resultados de nuestras pruebas cuando introducimos un error en nuestro código. Cambiaremos la implementación del método can_hold reemplazando el signo mayor que (>) por un signo menor que (<) cuando compara los anchos:

// --snip--
impl RectangleImpl of RectangleTrait {
    fn can_hold(self: @Rectangle, other: @Rectangle) -> bool {
        *self.width < *other.width & *self.height > *other.height
    }
}

Ejecutando los test ahora produce lo siguiente:

$ cairo-test .
running 2 tests
test adder::lib::tests::smaller_cannot_hold_larger ... ok
test adder::lib::tests::larger_can_hold_smaller ... fail
failures:
   adder::lib::tests::larger_can_hold_smaller - panicked with [167190012635530104759003347567405866263038433127524 ('rectangle cannot hold'), ].

Error: test result: FAILED. 1 passed; 1 failed; 0 ignored

Nuestros tests detectaron el error! Debido a que larger.width es 8_u64 y smaller.width es 5_u64, la comparación de anchuras en can_hold ahora devuelve false: 8_u64 no es menor que 5_u64.

Comprobando los pánicos con should_panic

Además de verificar los valores de retorno, es importante verificar que nuestro código maneje las condiciones de error como esperamos. Por ejemplo, consideremos el tipo Guess en el Listing 8-8. Otro código que usa Guess depende de la garantía de que las instancias de Guess contengan solo valores entre 1_u64 y 100_u64. Podemos escribir un test que asegure que al intentar crear una instancia de Guess con un valor fuera de ese rango, el programa entra en pánico.

Lo hacemos agregando el atributo should_panic a nuestra función de prueba. La prueba pasa si el código dentro de la función entra en pánico; la prueba falla si el código dentro de la función no entra en pánico.

El Listing 8-8 muestra una prueba que verifica que las condiciones de error de GuessTrait::new ocurren cuando esperamos que sucedan.

Nombre de archivo: lib.cairo

use array::ArrayTrait;

#[derive(Copy, Drop)]
struct Guess {
    value: u64,
}

trait GuessTrait {
    fn new(value: u64) -> Guess;
}

impl GuessImpl of GuessTrait {
    fn new(value: u64) -> Guess {
        if value < 1_u64 | value > 100 {
            let mut data = ArrayTrait::new();
            data.append('Guess must be >= 1 and <= 100');
            panic(data);
        }
        Guess { value }
    }
}

#[cfg(test)]
mod tests {
    use super::Guess;
    use super::GuessTrait;

    #[test]
    #[should_panic]
    fn greater_than_100() {
        GuessTrait::new(200_u64);
    }
}

Listing 8-8: Probando que una condición causará un pánico

Colocamos el atributo #[should_panic] después del atributo #[test] y antes de la función de prueba a la que se aplica. Veamos el resultado cuando esta prueba pasa:

Nombre de archivo: lib.cairo

$ cairo-test .
running 1 tests
test adder::lib::tests::greater_than_100 ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 filtered out;

Looks good! Now let’s introduce a bug in our code by removing the condition that the new function will panic if the value is greater than 100_u64:

// --snip--
impl GuessImpl of GuessTrait {
    fn new(value: u64) -> Guess {
        if value < 1_u64 {
            let mut data = ArrayTrait::new();
            data.append('Guess must be >= 1 and <= 100');
            panic(data);
        }

        Guess { value, }
    }
}

Cuando ejecutamos la prueba en el Listado 8-8, fallará:

$ cairo-test .
running 1 tests
test adder::lib::tests::greater_than_100 ... fail
failures:
   adder::lib::tests::greater_than_100 - expected panic but finished successfully.
Error: test result: FAILED. 0 passed; 1 failed; 0 ignored

En este caso, no obtenemos un mensaje muy útil, pero cuando miramos la función de prueba, vemos que está anotada con #[should_panic]. La falla que obtuvimos significa que el código en la función de prueba no causó un pánico.

Las pruebas que usan should_panic pueden ser imprecisas. Una prueba con should_panic pasaría incluso si la prueba produce un pánico por una razón diferente a la que esperábamos. Para hacer que las pruebas con should_panic sean más precisas, podemos agregar un parámetro opcional expected al atributo should_panic. El sistema de pruebas se asegurará de que el mensaje de error contenga el texto proporcionado. Por ejemplo, considere el código modificado para Guess en el Listado 8-9, donde la nueva función genera un pánico con mensajes diferentes dependiendo de si el valor es demasiado pequeño o demasiado grande.

Nombre del archivo: lib.cairo

// --snip--
impl GuessImpl of GuessTrait {
    fn new(value: u64) -> Guess {
        if value < 1_u64 {
            let mut data = ArrayTrait::new();
            data.append('Guess must be >= 1');
            panic(data);
        } else if value > 100_u64 {
            let mut data = ArrayTrait::new();
            data.append('Guess must be <= 100');
            panic(data);
        }

        Guess { value, }
    }
}

#[cfg(test)]
mod tests {
    use super::Guess;
    use super::GuessTrait;

    #[test]
    #[should_panic(expected: ('Guess must be <= 100', ))]
    fn greater_than_100() {
        GuessTrait::new(200_u64);
    }
}

Listado 8-9: Prueba para una excepción con un mensaje de excepción que contiene la cadena del mensaje de error

Esta prueba pasará porque el valor que ponemos en el parámetro esperado del atributo should_panic es la matriz de cadenas del mensaje con el que la función Guess::new genera la excepción. Necesitamos especificar el mensaje completo de la excepción que esperamos.

Para ver qué sucede cuando una prueba should_panic con un mensaje esperado falla, introduzcamos de nuevo un error en nuestro código cambiando los cuerpos de los bloques if value < 1_u64 y else if value > 100_u64:

if value < 1_u64 {
    let mut data = ArrayTrait::new();
    data.append('Guess must be <= 100');
    panic(data);
} else if value > 100_u64 {
    let mut data = ArrayTrait::new();
    data.append('Guess must be >= 1');
    panic(data);
}

Esta vez, cuando ejecutamos la prueba should_panic, fallará:

$ cairo-test .
running 1 tests
test adder::lib::tests::greater_than_100 ... fail
failures:
   adder::lib::tests::greater_than_100 - panicked with [6224920189561486601619856539731839409791025 ('Guess must be >= 1'), ].

Error: test result: FAILED. 0 passed; 1 failed; 0 ignored

El mensaje de fallo indica que este test realmente causó un pánico como esperábamos, pero el mensaje de pánico no incluyó la cadena esperada. El mensaje de pánico que obtuvimos en este caso fue Guess must be >= 1. ¡Ahora podemos comenzar a descubrir dónde está nuestro error!

Ejecución de pruebas individuales

A veces, ejecutar un conjunto completo de pruebas puede llevar mucho tiempo. Si está trabajando en código en un área particular, es posible que desee ejecutar solo las pruebas relacionadas con ese código. Puede elegir qué pruebas ejecutar pasando el nombre de la prueba que desea ejecutar como argumento a cairo-test.

Para demostrar cómo ejecutar una sola prueba, primero crearemos dos funciones de prueba, como se muestra en el Listado 8-10, y elegiremos cuáles ejecutar.

Nombre de archivo: src/lib.cairo

#[cfg(test)]
mod tests {
    #[test]
    fn add_two_and_two() {
        let result = 2 + 2;
        assert(result == 4, 'result is not 4');
    }

    #[test]
    fn add_three_and_two() {
        let result = 3 + 2;
        assert(result == 5, 'result is not 5');
    }
}

Listado 8-10: Dos pruebas con dos nombres diferentes

Podemos pasar el nombre de cualquier función de prueba a cairo-test para ejecutar solo esa prueba usando la bandera -f:

$ cairo-test . -f add_two_and_two
running 1 tests
test adder::lib::tests::add_two_and_two ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 1 filtered out;

Solo se ejecutó la prueba con el nombre add_two_and_two; la otra prueba no coincidía con ese nombre. La salida de la prueba nos indica que tuvimos una prueba más que no se ejecutó al mostrar "1 filtrado" al final.

También podemos especificar parte del nombre de una prueba y se ejecutarán todas las pruebas cuyo nombre contenga ese valor.

Ignorar algunas pruebas a menos que se soliciten específicamente

A veces, algunas pruebas específicas pueden ser muy lentas de ejecutar, por lo que es posible que desee excluirlos durante la mayoría de las ejecuciones de cairo-test. En lugar de enumerar como argumentos todas las pruebas que desea ejecutar, puede anotar las pruebas que consumen mucho tiempo utilizando el atributo ignore para excluirlos, como se muestra aquí:

Nombre de archivo: src/lib.cairo

#[cfg(test)]
mod tests {
    #[test]
    fn it_works() {
        let result = 2 + 2;
        assert(result == 4, 'result is not 4');
    }

    #[test]
    #[ignore]
    fn expensive_test() {
        // code that takes an hour to run
    }
}

Después de #[test] agregamos la línea #[ignore] al test que queremos excluir. Ahora, cuando ejecutamos nuestros tests, it_works se ejecuta pero expensive_test no lo hace:

$ cairo-test .
running 2 tests
test adder::lib::tests::expensive_test ... ignored
test adder::lib::tests::it_works ... ok
test result: ok. 1 passed; 0 failed; 1 ignored; 0 filtered out;

La función expensive_test está listada como ignorada.

Cuando esté en un punto en el que tenga sentido verificar los resultados de las pruebas ignoradas y tenga tiempo para esperar los resultados, puede ejecutar cairo-test --include-ignored para ejecutar todas las pruebas, ya sea que estén ignoradas o no.

Organización de Test

Pensaremos en las pruebas en términos de dos categorías principales: pruebas unitarias y pruebas de integración. Las pruebas unitarias son pequeñas y más enfocadas, probando un módulo a la vez en aislamiento, y pueden probar funciones privadas. Las pruebas de integración utilizan su código de la misma manera que cualquier otro código externo, utilizando solo la interfaz pública y potencialmente ejercitando varios módulos por prueba.

Escribir ambos tipos de pruebas es importante para asegurarse de que las piezas de su biblioteca estén haciendo lo que se espera de ellas, tanto separadas como juntas.

Test Unitarios

El propósito de las pruebas unitarias es probar cada unidad de código en aislamiento del resto del código para identificar rápidamente dónde el código funciona y dónde no lo hace como se esperaba. Colocará las pruebas unitarias en el directorio src en cada archivo con el código que están probando.

La convención es crear un módulo llamado tests en cada archivo para contener las funciones de prueba y anotar el módulo con cfg(test).

El Módulo de Test y #[cfg(test)]

La anotación #[cfg(test)] en el módulo de pruebas indica a Cairo que compile y ejecute el código de prueba solo cuando se ejecuta cairo-test, no cuando se ejecuta cairo-run. Esto ahorra tiempo de compilación cuando solo desea compilar la biblioteca y ahorra espacio en el artefacto compilado resultante porque las pruebas no están incluidas. Verá que debido a que las pruebas de integración van en un directorio diferente, no necesitan la anotación #[cfg(test)]. Sin embargo, debido a que las pruebas unitarias van en los mismos archivos que el código, usará #[cfg(test)] para especificar que no deben incluirse en el resultado compilado.

Recuerde que cuando creamos el nuevo proyecto adder en la primera sección de este capítulo, escribimos esta primera prueba:

Nombre de archivo: lib.cairo

#[cfg(test)]
mod tests {
    #[test]
    fn it_works() {
        let result = 2 + 2;
        assert(result == 4, 'result is not 4');
    }
}

El atributo cfg significa "configuración" y le indica a Cairo que el siguiente elemento solo debe incluirse dado una cierta opción de configuración. En este caso, la opción de configuración es test, que es proporcionada por Cairo para compilar y ejecutar pruebas. Al usar el atributo cfg, Cairo compila nuestro código de prueba solo si ejecutamos activamente las pruebas con cairo-test. Esto incluye cualquier función de ayuda que pueda estar dentro de este módulo, además de las funciones anotadas con #[test].

Test de Integración

Las pruebas de integración usan su biblioteca de la misma manera que cualquier otro código. Su propósito es probar si muchas partes de su biblioteca funcionan correctamente juntas. Las unidades de código que funcionan correctamente por sí mismas podrían tener problemas cuando se integran, por lo que también es importante tener cobertura de prueba del código integrado. Para crear pruebas de integración, primero necesita un directorio de tests.

Directorio tests

adder
├── cairo_project.toml
├── src
    ├── lib.cairo
│   └── main.cairo
└── tests
    ├── lib.cairo
    └── integration_test.cairo

Para ejecutar correctamente tus pruebas con cairo-test, deberás actualizar tu archivo cairo_project.toml para agregar la declaración de tu crate tests.

[crate_roots]
adder = "src"
tests = "tests"

Cada archivo de prueba se compila como una entidad separada, por eso cada vez que agregas un nuevo archivo de prueba debes agregarlo a tu archivo tests/lib.cairo.

Nombre de archivo: tests/lib.cairo

#[cfg(tests)]
mod integration_tests;

Ingrese el código del Listado 11-13 en el archivo tests/integration_test.cairo:

Nombre de archivo: tests/integration_test.cairo

use adder::main;

#[test]
fn internal() {
    assert(main::internal_adder(2_u32, 2_u32) == 4_u32, 'internal_adder failed');
}

Cada archivo en el directorio de pruebas es una creación separada, por lo que debemos incluir nuestra biblioteca en el alcance de cada creación de prueba. Por esa razón, agregamos use adder::main en la parte superior del código, lo cual no necesitábamos en las pruebas unitarias.

$ cairo-test tests/
running 1 tests
test tests::tests_integration::it_adds_two ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 filtered out;

El resultado de las pruebas es el mismo que hemos estado viendo: una línea por cada prueba.

Contratos Inteligentes en Starknet

En todas las secciones anteriores, principalmente ha escrito programas con un punto de entrada main. En las próximas secciones, aprenderá a escribir e implementar contratos inteligentes en Starknet.

Un contrato inteligente en Starknet en términos simples, es un programa que puede ejecutarse en la VM de Starknet. Dado que se ejecutan en la VM, tienen acceso al estado persistente de Starknet, pueden modificar variables en los estados de Starknet, comunicarse con otros contratos e interactuar sin problemas con la L1 subyacente.

Los contratos inteligentes en Starknet se denotan por el atributo #[contract]. Profundizaremos más en esto en las próximas secciones.

Contratos de Starknet: ABIs e interacciones entre contratos multicapa

La capacidad de los contratos para interactuar con otros contratos inteligentes en la cadena de bloques es un patrón común en el desarrollo de contratos inteligentes.

Este capítulo cubre cómo se pueden lograr interacciones entre contratos multicapa en Starknet. Específicamente, aprenderá sobre ABIs, interfaces de contrato, los despachadores de contratos y bibliotecas y sus equivalentes de llamadas al sistema de nivel inferior.

ABIs e Interfaces de Contrato

Las interacciones entre contratos inteligentes en una cadena de bloques, también conocidas como "cross-contract", son una práctica común que nos permite construir contratos flexibles que puedan comunicarse entre sí.

Para lograr esto en Starknet, se requiere algo que llamamos una interfaz.

Interfaz

Una interfaz es una lista de definiciones de funciones de un contrato sin implementaciones. En otras palabras, una interfaz especifica las declaraciones de función (nombre, parámetros, visibilidad y valor de retorno) contenidas en un contrato inteligente sin incluir el cuerpo de la función.

Las interfaces en Cairo son traits con el atributo [abi]. Si eres nuevo en los traits, consulta el capítulo dedicado a traits.

Para que tu código de Cairo califique como una interfaz, debe cumplir con los siguientes requisitos:

  1. Debe estar marcado con el atributo [abi].
  2. Las funciones de tu interfaz no deben tener implementaciones.
  3. Debes declarar explícitamente el decorador de la función.
  4. Tu interfaz no debe declarar un constructor.
  5. Tu interfaz no debe declarar variables de estado.

Aquí hay un ejemplo de una interfaz para un contrato de token ERC20:

use starknet::ContractAddress;

#[abi]
trait IERC20 {
    #[view]
    fn name() -> felt252;

    #[view]
    fn symbol() -> felt252;

    #[view]
    fn decimals() -> u8;

    #[view]
    fn total_supply() -> u256;

    #[view]
    fn balance_of(account: ContractAddress) -> u256;

    #[view]
    fn allowance(owner: ContractAddress, spender: ContractAddress) -> u256;

    #[external]
    fn transfer(recipient: ContractAddress, amount: u256) -> bool;

    #[external]
    fn transfer_from(sender: ContractAddress, recipient: ContractAddress, amount: u256) -> bool;

    #[external]
    fn approve(spender: ContractAddress, amount: u256) -> bool;
}

Listado 9-1: Una interfaz simple de ERC20

ABIs

ABI significa Interfaz Binaria de Aplicaciones. Los ABI dan a un contrato inteligente la capacidad de comunicarse e interactuar con aplicaciones externas u otros contratos inteligentes. Los ABI se pueden comparar con las API en el desarrollo web tradicional, que ayudan al flujo de datos entre aplicaciones y servidores.

Si bien escribimos nuestras lógicas de contrato inteligente en Cairo de alto nivel, se almacenan en la VM como bytecodes ejecutables que están en formatos binarios. Dado que este bytecode no es legible por humanos, requiere interpretación para ser entendido. Aquí es donde entran en juego los ABI, definiendo métodos específicos que se pueden llamar a un contrato inteligente para su ejecución.

Cada contrato en Starknet tiene una Interfaz Binaria de Aplicaciones (ABI) que define cómo codificar y decodificar datos al llamar a los métodos del contrato inteligente.

En el próximo capítulo, veremos cómo podemos llamar a otros contratos inteligentes utilizando un Contract Dispatcher, un Library Dispatcher, y System calls.

Despachador de Contratos, Despachador de Bibliotecas y Llamadas del Sistema

Cada vez que se crea una interfaz de contrato en Starknet, se crean automáticamente y exportan dos despachadores:

  1. El Despachador de Contratos
  2. El Despachador de Bibliotecas

En este capítulo, discutiremos en detalle cómo funcionan estos despachadores y su uso.

Para desglosar efectivamente los conceptos en este capítulo, utilizaremos la interfaz IERC20 del capítulo anterior (consulte la Lista 9-1):

Despachador de Contratos

Los contratos anotados con el atributo abi están programados para generar automáticamente y exportar la lógica de despachador relevante durante la compilación. El compilador también genera un nuevo trait, dos nuevas estructuras (una para llamadas de contrato y otra para llamadas de biblioteca) y su implementación de este trait. Nuestra interfaz se expande en algo como esto:

trait IERC20DispatcherTrait<T> {
    fn get_name(self: T) -> felt252;
    fn transfer(self: T, recipient: ContractAddress, amount: u256);
}

#[derive(Copy, Drop)]
struct IERC20Dispatcher {
    contract_address: starknet::ContractAddress,
}

impl IERC20DispatcherImpl of IERC20DispatcherTrait::<IERC20Dispatcher> {
    fn get_name(self: IERC20Dispatcher) -> felt252 {
        // starknet::call_contract_syscall is called in here
    }
    fn transfer(self: IERC20Dispatcher, recipient: ContractAddress, amount: u256) {
        // starknet::call_contract_syscall is called in here
    }
}

Listado 9-2: Una forma expandida de la interfaz IERC20

Nota: El código expandido para nuestra interfaz IERC20 es mucho más robusto, pero para mantener este capítulo conciso y al grano, nos enfocamos en una función de vista get_name y una función externa transfer.

También es digno de mención que todo esto se abstrae detrás de escena, gracias al poder de los complementos de Cairo.

Llamando contratos usando el Dispatcher de Contrato

Llamar a otro contrato, digamos ContractA usando el dispatcher de interfaz de contrato, llama la lógica de ContractA en su contexto, y en la mayoría de los casos puede alterar el estado de ContractA. Aquí hay un ejemplo:

//**** Specify interface here ****//

#[contract]
mod Dispatcher {
    use super::IERC20DispatcherTrait;
    use super::IERC20Dispatcher;
    use starknet::ContractAddress;

    #[view]
    fn token_name(
        _contract_address: ContractAddress
    ) -> felt252 {
        IERC20Dispatcher {contract_address: _contract_address }.name()
    } 

    #[external]
    fn transfer_token(
        _contract_address: ContractAddress, recipient: ContractAddress, amount: u256
    ) -> bool {
        IERC20Dispatcher {contract_address: _contract_address }.transfer(recipient, amount)
    } 
}

Listado 9-3: Un contrato de muestra que utiliza el Dispatcher de Contratos

Como se puede observar, primero tuvimos que importar IERC20DispatcherTrait e IERC20Dispatcher, los cuales fueron generados y exportados al compilar nuestra interfaz. Luego realizamos llamadas a los métodos implementados para la estructura IERC20Dispatcher (name, transfer, etc.), pasando el parámetro contract_address que representa la dirección del contrato que queremos llamar.

Dispatcher de Biblioteca

La principal diferencia entre el Dispatcher de Contratos y el Dispatcher de Biblioteca es que, mientras que el Dispatcher de Contratos llama a la lógica de un contrato externo en el contexto del contrato externo, el Dispatcher de Biblioteca llama al hash de clase del contrato objetivo mientras ejecuta la llamada en el contexto del contrato que llama. Por lo tanto, a diferencia del Dispatcher de Contratos, las llamadas realizadas utilizando el Dispatcher de Biblioteca no tienen la posibilidad de manipular el estado del contrato objetivo.

Como se indicó en el capítulo anterior, los contratos anotados con la macro #[abi] en la compilación generan un nuevo trait, dos nuevas estructuras (una para llamadas a contratos y otra para llamadas a bibliotecas) y su implementación de este trait. La forma expandida de los traits de biblioteca se ve así:

trait IERC20DispatcherTrait<T> {
    fn get_name(self: T) -> felt252;
    fn transfer(self: T, recipient: ContractAddress, amount: u256);
}

#[derive(Copy, Drop)]
struct IERC20LibraryDispatcher {
    class_hash: starknet::ClassHash,
}

impl IERC20LibraryDispatcherImpl of IERC20DispatcherTrait::<IERC20LibraryDispatcher> {
    fn get_name(self: IERC20LibraryDispatcher) -> felt252 {
        // starknet::syscalls::library_call_syscall  is called in here
    }
    fn transfer(self: IERC20LibraryDispatcher, recipient: ContractAddress, amount: u256) {
        // starknet::syscalls::library_call_syscall  is called in here
    }
}

Listado 9-4: Una forma expandida del trait IERC20

Llamando a Contratos usando el Dispatcher de Biblioteca

A continuación se muestra un código de muestra sobre cómo llamar a contratos utilizando el Dispatcher de Biblioteca:

//**** Specify interface here ****//

use super::IERC20DispatcherTrait;
use super::IERC20LibraryDispatcher;
use starknet::ContractAddress;

#[view]
fn token_name() -> felt252 {
    IERC20LibraryDispatcher { class_hash: starknet::class_hash_const::<0x1234>() }.name()
} 

#[external]
fn transfer_token(
    recipient: ContractAddress, amount: u256
) -> bool {
    IERC20LibraryDispatcher { class_hash: starknet::class_hash_const::<0x1234>() }.transfer(recipient, amount)
} 

Listado 9-4: Un contrato de muestra que utiliza el Dispatcher de Biblioteca

Como se puede ver, primero tuvimos que importar IERC20DispatcherTrait e IERC20LibraryDispatcher, los cuales fueron generados y exportados al compilar nuestra interfaz. Luego realizamos llamadas a los métodos implementados para la estructura IERC20LibraryDispatcher (name, transfer, etc.), pasando el parámetro class_hash que representa la clase del contrato que queremos llamar.

Llamando a Contratos usando llamadas de sistema de bajo nivel

Otra forma de llamar a otros contratos es mediante la llamada de sistema starknet::call_contract_syscall. Los Dispatchers que describimos en las secciones anteriores son sintaxis de alto nivel para esta llamada de sistema de bajo nivel.

El uso de la llamada de sistema starknet::call_contract_syscall puede ser útil para la personalización del manejo de errores o para tener más control sobre la serialización/deserialización de los datos de llamada y los datos devueltos. Aquí hay un ejemplo que demuestra una llamada de transfer de bajo nivel:

#[external]
fn transfer_token(
    address: starknet::ContractAddress, selector: felt252, calldata: Array<felt252>
) -> Span::<felt252> {
    starknet::call_contract_syscall(
        :address, entry_point_selector: selector, calldata: calldata.span()
    ).unwrap_syscall()
} 

Listado 9-5: Un contrato de muestra que implementa llamadas de sistema

Como se puede ver, en lugar de pasar nuestros argumentos de función directamente, pasamos la dirección del contrato, el selector de la función (que es un hash keccak del nombre de la función) y los datos de llamada (argumentos de función). Al final, se nos devuelve un valor serializado que tendremos que deserializar nosotros mismos.

Apéndice:

Las siguientes secciones contienen material de referencia que puede resultarle útil en su viaje a Cairo.

Apéndice A - Herramientas de desarrollo útiles

En este apéndice, hablamos de algunas herramientas de desarrollo útiles que el proyecto Cairo proporciona. Veremos el formateo automático, formas rápidas de aplicar correcciones de advertencias, un linter, y la integración con IDEs.

Formateo automático con cairo-format.

La herramienta cairo-format reformatea tu código de acuerdo con el estilo de código de la comunidad. Muchos proyectos colaborativos usan cairo-format para evitar discusiones sobre qué estilo usar al escribir Cairo: todo el mundo formatea su código usando la herramienta.

Para formatear cualquier proyecto de Cairo, introduce lo siguiente:

cairo-format -r

Ejecutando este comando reformateará todo el código de Cairo en el directorio actual de forma recursiva. Esto solo cambiará el estilo de código, no la semántica del código.

Integración del IDE usando cairo-language-server

Para ayudar con la integración del IDE, la comunidad de Cairo recomienda el uso de cairo-language-server. Esta herramienta es un conjunto de utilidades centradas en el compilador que utiliza el Protocolo del Servidor de Lenguaje, que es una especificación para que los IDE y los lenguajes de programación se comuniquen entre sí. Diferentes clientes pueden utilizar cairo-language-server, como la extensión de Cairo para Visual Studio Code.

Visita la página de vscode-cairo para obtener instrucciones de instalación. Obtendrás habilidades como autocompletado, saltar a la definición y errores en línea.