Práctica de Tipos Avanzados de Rust

RustRustBeginner
Practicar Ahora

This tutorial is from open-source community. Access the source code

💡 Este tutorial está traducido por IA desde la versión en inglés. Para ver la versión original, puedes hacer clic aquí

Introducción

Bienvenido a Tipos Avanzados. Esta práctica es parte del Rust Book. Puedes practicar tus habilidades de Rust en LabEx.

En esta práctica, discutiremos newtypes, alias de tipo, el tipo !, y los tipos de tamaño dinámico en el sistema de tipos de Rust.

Tipos Avanzados

El sistema de tipos de Rust tiene algunas características que hemos mencionado hasta ahora pero que aún no hemos discutido. Empezaremos por hablar en general de los newtypes mientras examinamos por qué son útiles como tipos. Luego pasaremos a los alias de tipo, una característica similar a los newtypes pero con una semántica ligeramente diferente. También discutiremos el tipo ! y los tipos de tamaño dinámico.

Uso del patrón newtype para seguridad y abstracción de tipos

Nota: Esta sección asume que has leído la sección anterior "Uso del patrón newtype para implementar rasgos externos".

El patrón newtype también es útil para tareas más allá de las que hemos discutido hasta ahora, incluyendo la aplicación de una restricción estática para evitar confusiones entre valores y la indicación de las unidades de un valor. Viste un ejemplo de uso de newtypes para indicar unidades en la Lista 19-15: recuerda que las estructuras Millímetros y Metros envolvían valores de u32 en un newtype. Si escribimos una función con un parámetro de tipo Millímetros, no podremos compilar un programa que accidentalmente intente llamar a esa función con un valor de tipo Metros o un u32 simple.

También podemos usar el patrón newtype para abstraer algunos detalles de implementación de un tipo: el nuevo tipo puede exponer una API pública que es diferente de la API del tipo interno privado.

Los newtypes también pueden ocultar la implementación interna. Por ejemplo, podríamos proporcionar un tipo People para envolver un HashMap<i32, String> que almacena el ID de una persona asociado con su nombre. El código que usa People solo interactuaría con la API pública que proporcionamos, como un método para agregar una cadena de nombre a la colección People; ese código no necesitaría saber que asignamos un ID de i32 a los nombres internamente. El patrón newtype es una forma ligera de lograr la encapsulación para ocultar detalles de implementación, que discutimos en "Encapsulación que oculta detalles de implementación".

Creación de sinónimos de tipo con alias de tipo

Rust permite declarar un alias de tipo para dar a un tipo existente otro nombre. Para hacer esto, usamos la palabra clave type. Por ejemplo, podemos crear el alias Kilómetros para i32 de la siguiente manera:

type Kilómetros = i32;

Ahora, el alias Kilómetros es un sinónimo de i32; a diferencia de los tipos Millímetros y Metros que creamos en la Lista 19-15, Kilómetros no es un tipo separado y nuevo. Los valores que tienen el tipo Kilómetros se tratarán de la misma manera que los valores de tipo i32:

type Kilómetros = i32;

let x: i32 = 5;
let y: Kilómetros = 5;

println!("x + y = {}", x + y);

Debido a que Kilómetros e i32 son el mismo tipo, podemos sumar valores de ambos tipos y podemos pasar valores de Kilómetros a funciones que tomen parámetros de tipo i32. Sin embargo, con este método, no obtenemos los beneficios de comprobación de tipos que obtenemos del patrón newtype discutido anteriormente. En otras palabras, si mezclamos valores de Kilómetros e i32 en algún lugar, el compilador no nos dará un error.

El principal caso de uso de los sinónimos de tipo es reducir la repetición. Por ejemplo, podríamos tener un tipo largo como este:

Box<dyn Fn() + Send + 'static>

Escribir este tipo largo en firmas de funciones y como anotaciones de tipo en todo el código puede ser tedioso y propenso a errores. Imagina tener un proyecto lleno de código como el de la Lista 19-24.

let f: Box<dyn Fn() + Send + 'static> = Box::new(|| {
    println!("hi");
});

fn takes_long_type(f: Box<dyn Fn() + Send + 'static>) {
    --snip--
}

fn returns_long_type() -> Box<dyn Fn() + Send + 'static> {
    --snip--
}

Lista 19-24: Uso de un tipo largo en muchos lugares

Un alias de tipo hace que este código sea más manejable al reducir la repetición. En la Lista 19-25, hemos introducido un alias llamado Thunk para el tipo verboso y podemos reemplazar todos los usos del tipo con el alias más corto Thunk.

type Thunk = Box<dyn Fn() + Send + 'static>;

let f: Thunk = Box::new(|| println!("hi"));

fn takes_long_type(f: Thunk) {
    --snip--
}

fn returns_long_type() -> Thunk {
    --snip--
}

Lista 19-25: Introducción de un alias de tipo Thunk para reducir la repetición

¡Este código es mucho más fácil de leer y escribir! Elegir un nombre significativo para un alias de tipo también puede ayudar a comunicar tu intención (thunk es una palabra para el código que se evaluará en un momento posterior, por lo que es un nombre adecuado para una clausura que se almacena).

Los alias de tipo también se usan comúnmente con el tipo Result<T, E> para reducir la repetición. Considere el módulo std::io de la biblioteca estándar. Las operaciones de E/S a menudo devuelven un Result<T, E> para manejar situaciones en las que las operaciones no funcionan correctamente. Esta biblioteca tiene una estructura std::io::Error que representa todos los posibles errores de E/S. Muchas de las funciones en std::io devolverán Result<T, E> donde el E es std::io::Error, como estas funciones en el trato Write:

use std::fmt;
use std::io::Error;

pub trait Write {
    fn write(&mut self, buf: &[u8]) -> Result<usize, Error>;
    fn flush(&mut self) -> Result<(), Error>;

    fn write_all(&mut self, buf: &[u8]) -> Result<(), Error>;
    fn write_fmt(
        &mut self,
        fmt: fmt::Arguments,
    ) -> Result<(), Error>;
}

El Result<..., Error> se repite mucho. Por lo tanto, std::io tiene esta declaración de alias de tipo:

type Result<T> = std::result::Result<T, std::io::Error>;

Debido a que esta declaración está en el módulo std::io, podemos usar el alias calificado std::io::Result<T>; es decir, un Result<T, E> con el E rellenado como std::io::Error. Las firmas de funciones del trato Write terminan siendo así:

pub trait Write {
    fn write(&mut self, buf: &[u8]) -> Result<usize>;
    fn flush(&mut self) -> Result<()>;

    fn write_all(&mut self, buf: &[u8]) -> Result<()>;
    fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<()>;
}

El alias de tipo ayuda de dos maneras: hace que el código sea más fácil de escribir y nos da una interfaz consistente en todo std::io. Debido a que es un alias, es simplemente otro Result<T, E>, lo que significa que podemos usar cualquier método que funcione en Result<T, E> con él, así como la sintaxis especial como el operador ?.

El tipo nunca que nunca devuelve

Rust tiene un tipo especial llamado ! que en el vocabulario de la teoría de tipos se conoce como el tipo vacío porque no tiene valores. Prefirimos llamarlo el tipo nunca porque ocupa el lugar del tipo de retorno cuando una función nunca devuelve. Aquí hay un ejemplo:

fn bar() ->! {
    --snip--
}

Este código se lee como "la función bar devuelve nunca". Las funciones que devuelven nunca se llaman funciones divergentes. No podemos crear valores del tipo !, por lo que bar nunca puede devolver.

Pero ¿qué utilidad tiene un tipo para el que nunca se pueden crear valores? Recuerda el código de la Lista 2-5, parte del juego de adivinar el número; hemos reproducido un poco de él aquí en la Lista 19-26.

let guess: u32 = match guess.trim().parse() {
    Ok(num) => num,
    Err(_) => continue,
};

Lista 19-26: Un match con un brazo que termina en continue

En aquel momento, saltamos algunos detalles de este código. En "La construcción de flujo de control match", discutimos que los brazos de match deben todos devolver el mismo tipo. Entonces, por ejemplo, el siguiente código no funciona:

let guess = match guess.trim().parse() {
    Ok(_) => 5,
    Err(_) => "hello",
};

El tipo de guess en este código tendría que ser un entero y una cadena, y Rust requiere que guess tenga solo un tipo. Entonces, ¿qué devuelve continue? ¿Cómo nos permitieron devolver un u32 desde un brazo y tener otro brazo que termina con continue en la Lista 19-26?

Como probablemente hayas adivinado, continue tiene un valor de !. Es decir, cuando Rust calcula el tipo de guess, mira ambos brazos del match, el primero con un valor de u32 y el segundo con un valor de !. Debido a que ! nunca puede tener un valor, Rust decide que el tipo de guess es u32.

La forma formal de describir este comportamiento es que las expresiones de tipo ! se pueden forzar a cualquier otro tipo. Estamos permitidos terminar este brazo de match con continue porque continue no devuelve un valor; en cambio, mueve el control de vuelta al principio del bucle, por lo que en el caso Err, nunca le asignamos un valor a guess.

El tipo nunca también es útil con la macro panic!. Recuerda la función unwrap que llamamos en valores de Option<T> para producir un valor o generar un error con esta definición:

impl<T> Option<T> {
    pub fn unwrap(self) -> T {
        match self {
            Some(val) => val,
            None => panic!(
                "llamado `Option::unwrap()` en un valor `None`"
            ),
        }
    }
}

En este código, sucede lo mismo que en el match de la Lista 19-26: Rust ve que val tiene el tipo T y panic! tiene el tipo !, por lo que el resultado de la expresión match en general es T. Este código funciona porque panic! no produce un valor; termina el programa. En el caso None, no devolveremos un valor desde unwrap, por lo que este código es válido.

Una última expresión que tiene el tipo ! es un loop:

print!("por siempre ");

loop {
    print!("y para siempre ");
}

Aquí, el bucle nunca termina, por lo que ! es el valor de la expresión. Sin embargo, esto no sería cierto si incluyéramos un break, porque el bucle terminaría cuando llegara al break.

Tipos de tamaño dinámico y el trato Sized

Rust necesita conocer ciertos detalles sobre sus tipos, como cuánto espacio asignar para un valor de un tipo particular. Esto deja un rincón de su sistema de tipos un poco confuso al principio: el concepto de tipos de tamaño dinámico. A veces se les llama DST o tipos sin tamaño, estos tipos nos permiten escribir código usando valores cuyo tamaño solo podemos conocer en tiempo de ejecución.

Vamos a profundizar en los detalles de un tipo de tamaño dinámico llamado str, que hemos estado usando en todo el libro. Eso's correcto, no &str, sino str por sí mismo, es un DST. No podemos saber cuánto largo es la cadena hasta el tiempo de ejecución, lo que significa que no podemos crear una variable de tipo str, ni podemos tomar un argumento de tipo str. Considere el siguiente código, que no funciona:

let s1: str = "Hello there!";
let s2: str = "How's it going?";

Rust necesita saber cuánta memoria asignar para cualquier valor de un tipo particular, y todos los valores de un tipo deben usar la misma cantidad de memoria. Si Rust nos permitiera escribir este código, estos dos valores de str necesitarían ocupar la misma cantidad de espacio. Pero tienen longitudes diferentes: s1 necesita 12 bytes de almacenamiento y s2 necesita 15. Por eso no es posible crear una variable que contenga un tipo de tamaño dinámico.

Entonces, ¿qué hacemos? En este caso, ya sabes la respuesta: hacemos que los tipos de s1 y s2 sean un &str en lugar de un str. Recuerda de "Fragmentos de cadena" que la estructura de datos de fragmento solo almacena la posición de inicio y la longitud del fragmento. Entonces, aunque un &T es un solo valor que almacena la dirección de memoria donde se encuentra el T, un &str es dos valores: la dirección del str y su longitud. Como tal, podemos conocer el tamaño de un valor de &str en tiempo de compilación: es el doble de la longitud de un usize. Es decir, siempre conocemos el tamaño de un &str, sin importar cuánto largo sea la cadena a la que se refiere. En general, esta es la forma en que se usan los tipos de tamaño dinámico en Rust: tienen un poco adicional de metadatos que almacenan el tamaño de la información dinámica. La regla aureola de los tipos de tamaño dinámico es que siempre debemos poner valores de tipos de tamaño dinámico detrás de un puntero de algún tipo.

Podemos combinar str con todo tipo de punteros: por ejemplo, Box<str> o Rc<str>. De hecho, ya has visto esto antes pero con un tipo de tamaño dinámico diferente: los rasgos. Cada rasgo es un tipo de tamaño dinámico al que podemos referirnos usando el nombre del rasgo. En "Usando objetos de rasgo que permiten valores de diferentes tipos", mencionamos que para usar los rasgos como objetos de rasgo, debemos ponerlos detrás de un puntero, como &dyn Trait o Box<dyn Trait> (Rc<dyn Trait> también funcionaría).

Para trabajar con DSTs, Rust proporciona el trato Sized para determinar si el tamaño de un tipo es conocido en tiempo de compilación. Este trato se implementa automáticamente para todo aquello cuyo tamaño es conocido en tiempo de compilación. Además, Rust agrega implícitamente un límite en Sized a cada función genérica. Es decir, una definición de función genérica como esta:

fn genérico<T>(t: T) {
    --snip--
}

en realidad se trata como si hubiéramos escrito esto:

fn genérico<T: Sized>(t: T) {
    --snip--
}

Por defecto, las funciones genéricas solo funcionarán con tipos que tienen un tamaño conocido en tiempo de compilación. Sin embargo, puedes usar la siguiente sintaxis especial para relajar esta restricción:

fn genérico<T:?Sized>(t: &T) {
    --snip--
}

Un límite de rasgo en ?Sized significa "T puede o no ser Sized" y esta notación anula el predeterminado de que los tipos genéricos deben tener un tamaño conocido en tiempo de compilación. La sintaxis ?Rasgo con este significado solo está disponible para Sized, no para ningún otro rasgo.

También ten en cuenta que cambiamos el tipo del parámetro t de T a &T. Debido a que el tipo puede no ser Sized, necesitamos usarlo detrás de algún tipo de puntero. En este caso, hemos elegido una referencia.

A continuación, hablaremos sobre funciones y closures!

Resumen

¡Felicitaciones! Has completado el laboratorio de Tipos Avanzados. Puedes practicar más laboratorios en LabEx para mejorar tus habilidades.