Tipos Avanzados
El sistema de tipos de Rust tiene algunas características que hemos mencionado
hasta ahora, pero que aún no hemos discutido. Comenzaremos discutiendo los
newtypes en general mientras examinamos por qué los newtypes son útiles
como tipos. Luego pasaremos a los type aliases, una característica similar a
los newtypes pero con semántica ligeramente diferente. También discutiremos
el tipo !
y los tipos de tamaño dinámico.
Usando el Newtype Pattern para Seguridad de Tipos y Abstracción
Nota: Esta sección asume que has leído la sección anterior “Usando el Pattern Newtype para Implementar Traits Externos en Tipos Externos.”
El newtype pattern también es útil para tareas más allá de las que hemos
discutido hasta ahora, incluyendo hacer cumplir estáticamente que los valores
nunca se confundan e indicar las unidades de un valor. Viste un ejemplo de
usar newtypes para indicar unidades en el Listado 20-16: recuerda que los
structs Millimeters
y Meters
envolvieron valores u32
en un newtype. Si
escribiéramos una función con un parámetro de tipo Millimeters
, no podríamos
compilar un programa que accidentalmente intentara llamar a esa función con
un valor de tipo Meters
o un u32
simple.
También podemos usar el pattern 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 un string de nombre a la colección People
; ese código
no necesitaría saber que asignamos un ID i32
a los nombres internamente. El
newtype pattern es una forma ligera de lograr la encapsulación para ocultar
los detalles de implementación, que discutimos en la sección “Encapsulación
que Oculta los Detalles de
Implementación”
del Capítulo 18.
Creando Type Synonyms con Type Aliases
Rust proporciona la capacidad de declarar un type alias para darle a un
tipo existente otro nombre. Para esto usamos la palabra clave type
. Por
ejemplo, podemos crear el alias Kilometers
a i32
de la siguiente manera:
fn main() { type Kilometers = i32; let x: i32 = 5; let y: Kilometers = 5; println!("x + y = {}", x + y); }
Ahora, el alias Kilometers
es un sinónimo para i32
; a diferencia de los
tipos Millimeters
y Meters
que creamos en el Listado 20-16, Kilometers
no es un tipo nuevo y separado. Los valores que tienen el tipo Kilometers
se tratarán de la misma manera que los valores del tipo i32
:
fn main() { type Kilometers = i32; let x: i32 = 5; let y: Kilometers = 5; println!("x + y = {}", x + y); }
Debido a que Kilometers
e i32
son el mismo tipo, podemos agregar valores
de ambos tipos y podemos pasar valores Kilometers
a funciones que toman
parámetros i32
. Sin embargo, usando este método, no obtenemos los beneficios
de verificación de tipos que obtenemos del newtype pattern discutido
anteriormente. En otras palabras, si mezclamos valores Kilometers
e i32
en algún lugar, el compilador no nos dará un error.
El caso de uso principal para 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 extenso en firmas de función 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 del Listado 20-25.
fn main() { 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-- Box::new(|| ()) } }
Un type alias hace que este código sea más manejable al reducir la repetición.
En el Listado 20-26, hemos introducido un alias llamado Thunk
para el tipo
extenso y podemos reemplazar todos los usos del tipo con el alias más corto
Thunk
.
fn main() { 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-- Box::new(|| ()) } }
¡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 código que se evaluará en un momento posterior, por lo que es un nombre apropiado para un cierre que se almacena).
Los type alias también se utilizan con frecuencia con el tipo Result<T, E>
para reducir la repetición. Considera el módulo std::io
en 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. 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
E
es std::io::Error
, como estas funciones en el trait 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>;
}
Él Result<..., Error>
se repite mucho. Como tal, std::io
tiene esta
declaración de alias de tipo:
use std::fmt;
type Result<T> = std::result::Result<T, std::io::Error>;
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<()>;
}
Dado que esta declaración está en el módulo std::io
, podemos usar el alias
completamente calificado std::io::Result<T>
; es decir, un Result<T, E>
con E
lleno como std::io::Error
. Las firmas de las funciones del trait
Write
terminan viéndose así:
use std::fmt;
type Result<T> = std::result::Result<T, std::io::Error>;
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 type alias 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 solo 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 que nunca retorna
Rust tiene un tipo especial llamado !
, conocido en la jerga de la teoría de
tipos como tipo vacío porque no tiene valores. Preferimos llamarlo tipo
never porque se encuentra en el lugar del tipo de retorno cuando una función
nunca retornará. Aquí hay un ejemplo:
fn bar() -> ! {
// --snip--
panic!();
}
Este código se lee como “la función bar
devuelve never”. Las funciones que
devuelven never se llaman funciones divergentes. No podemos crear valores
del tipo !
por lo que bar
nunca puede devolver.
Pero, ¿qué utilidad tiene un tipo del que nunca se pueden crear valores? Recuerda el código del Juego de Adivinar el Número mostrado en el Listado 2-5; hemos reproducido parte de él aquí en el Listado 20-27.
use rand::Rng;
use std::cmp::Ordering;
use std::io;
fn main() {
println!("Guess the number!");
let secret_number = rand::thread_rng().gen_range(1..=100);
println!("The secret number is: {secret_number}");
loop {
println!("Please input your guess.");
let mut guess = String::new();
// --snip--
io::stdin()
.read_line(&mut guess)
.expect("Failed to read line");
let guess: u32 = match guess.trim().parse() {
Ok(num) => num,
Err(_) => continue,
};
println!("You guessed: {guess}");
// --snip--
match guess.cmp(&secret_number) {
Ordering::Less => println!("Too small!"),
Ordering::Greater => println!("Too big!"),
Ordering::Equal => {
println!("You win!");
break;
}
}
}
}
En ese momento, omitimos algunos detalles en este código. En el Capítulo 6 en
la sección “El operador de control de flujo
match
”
discutimos que las opciones de match
deben devolver todos el mismo tipo. Por
lo tanto, por ejemplo, el siguiente código no funciona:
fn main() {
let guess = "3";
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 un string, y
Rust requiere que guess
tenga solo un tipo. Entonces, ¿qué devuelve
continue
? ¿Cómo se nos permitió devolver un u32
de una opción y tener otra
opción que termina con continue
en el Listado 20-27?
Como habrás adivinado, continue
tiene un valor !
. Es decir, cuando Rust
calcula el tipo de guess
, mira ambas opciones de 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 convertir en cualquier otro tipo. Se nos permite terminar esta
opción de match
con continue
porque continue
no devuelve un valor; en
cambio, mueve el control de nuevo a la parte superior del bucle, por lo que en
el caso de Err
, nunca asignamos un valor a guess
.
El tipo never también es útil con la macro panic!
. Recordemos la función
unwrap
que llamamos en valores Option<T>
para producir un valor o
generar un panic con esta definición:
enum Option<T> {
Some(T),
None,
}
use crate::Option::*;
impl<T> Option<T> {
pub fn unwrap(self) -> T {
match self {
Some(val) => val,
None => panic!("called `Option::unwrap()` on a `None` value"),
}
}
}
En este código, ocurre lo mismo que en el match
del Listado 20-27: Rust ve
que val
tiene el tipo T
y panic!
tiene el tipo !
, por lo que el
resultado de la expresión match
es T
. Este código funciona porque panic!
no produce un valor; termina el programa. En el caso de None
, no devolveremos
un valor de unwrap
, por lo que este código es válido.
Una expresión final que tiene el tipo !
es un loop
:
fn main() {
print!("forever ");
loop {
print!("and ever ");
}
}
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 tamano dinamico y el trait Sized
Rust necesita conocer ciertos detalles sobre sus tipos, como la cantidad de espacio para asignar a un valor de un tipo particular. Esto deja una esquina de su sistema de tipos un poco confusa al principio: el concepto de tipos de tamaño dinámico. A veces se refiere como DST o tipos no dimensionados, estos tipos nos permiten escribir código usando valores cuyo tamaño solo podemos conocer en tiempo de ejecución.
Profundicemos en los detalles de un tipo de tamaño dinámico llamado str
, que
hemos estado usando a lo largo del libro. Así es, no &str
, sino str
por sí
solo, es un DST. No podemos saber cuánto tiempo 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
. Considera el siguiente código, que
no funciona:
fn main() {
let s1: str = "Hello there!";
let s2: str = "How's it going?";
}
Rust necesita conocer 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 str
necesitarían ocupar el mismo espacio. Pero tienen longitudes diferentes: s1
necesita 12 bytes de almacenamiento y s2
necesita 15. Es por eso que no es
posible crear una variable que contenga un tipo de tamaño dinámico.
Entonces, ¿qué hacemos en este caso? En este caso, como ya sabes, la solución
es hacer que los tipos de s1
y s2
sean &str
en lugar de str
. Recuerda
de la sección “String Slices” del
Capítulo 4 que la estructura de datos de slice solo almacena la posición de
inicio y la longitud del slice. Por lo tanto, aunque un &T
es un solo
valor que almacena la dirección de memoria de donde se encuentra el T
, un
&str
son dos valores: la dirección del str
y su longitud. Como tal,
podemos conocer el tamaño de un valor &str
en tiempo de compilación: es dos
veces la longitud de un usize
. Es decir, siempre conocemos el tamaño de un
&str
, sin importar cuán larga 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
bit adicional de metadatos que almacena el tamaño de la información dinámica.
La regla de oro de los tipos de tamaño dinámico es que debemos envolverlos en
algún tipo de puntero.
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 traits. Cada trait es un tipo de tamaño dinámico al que
podemos referirnos usando el nombre del trait. En el Capítulo 18 en la
sección “Usando trait objects que permiten valores de diferentes
tipos”, mencionamos que para usar traits como objetos de trait, debemos
ponerlos detrás de un puntero, como &dyn Trait
o Box<dyn Trait>
(Rc<dyn Trait>
también funcionaría).
Para trabajar con DST, Rust proporciona el trait Sized
para determinar si el
tamaño de un tipo es conocido en tiempo de compilación o no. Este trait se
implementa automáticamente para todo lo 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 generic. Es decir, una definición de función generic como esta:
fn generic<T>(t: T) {
// --snip--
}
en realidad se trata como si hubiéramos escrito esto:
fn generic<T: Sized>(t: T) {
// --snip--
}
De forma predeterminada, las funciones generic solo funcionarán en tipos que tienen un tamaño conocido en tiempo de compilación. Sin embargo, puede usar la siguiente sintaxis especial para relajar esta restricción:
fn generic<T: ?Sized>(t: &T) {
// --snip--
}
Un trait bound en ?Sized
significa “T
puede o no ser Sized
” y esta
notación anula el valor predeterminado de que los tipos generic deben tener un
tamaño conocido en tiempo de compilación. La sintaxis ?Trait
con este
significado solo está disponible para Sized
, no para ningún otro trait.
También debes tener en cuenta que hemos cambiado 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!