Capítulo 1. Tipos
Este trabajo se ha traducido utilizando IA. Agradecemos tus opiniones y comentarios: translation-feedback@oreilly.com
El primer capítulo de este libro abarca consejos que giran en torno al sistema de tipos de Rust. Este sistema de tipos es más expresivo que el de otros lenguajes corrientes; tiene más en común con lenguajes "académicos" como OCaml o Haskell.
Una parte fundamental de es el tipo enum
de Rust, que es considerablemente más expresivo que los tipos de enumeración de otros lenguajes y que permite tipos de datos algebraicos.
Los artículos de este capítulo cubren los tipos fundamentales que proporciona el lenguaje y cómo combinarlos en estructuras de datos que expresen con precisión la semántica de tu programa. Este concepto de codificar el comportamiento en el sistema de tipos ayuda a reducir la cantidad de código de comprobación y de ruta de errores que se necesita, porque los estados no válidos son rechazados por la cadena de herramientas en tiempo de compilación y no por el programa en tiempo de ejecución.
En este capítulo también se describen algunas de las estructuras de datos omnipresentes que proporciona la biblioteca estándar de Rust:Option
s, Result
s, Error
s y Iterator
s. Familiarizarte con estas herramientas estándar te ayudará a escribir Rust idiomático que sea eficiente y compacto; en particular, permiten utilizar el operador de signo de interrogación de Rust, que admite un manejo de errores discreto pero seguro.
Ten en cuenta que los elementos que implican rasgos de Rust se tratan en el capítulo siguiente, pero necesariamente hay cierto grado de solapamiento con los elementos de este capítulo, porque los rasgos describen el comportamiento de los tipos.
Tema 1: Utiliza el sistema de tipos para expresartus estructuras de datos
que los llamaban programadores y no tipógrafos
Este artículo proporciona un rápido recorrido por el sistema de tipos de Rust, empezando por los tipos fundamentales que el compilador pone a tu disposición, para pasar después a las diversas formas en que los valores pueden combinarse en estructuras de datos.
El tipo enum
de Rust adquiere entonces un papel protagonista. Aunque la versión básica es equivalente a la que proporcionan otros lenguajes, la posibilidad de combinar variantes de enum
con campos de datos permite una mayor flexibilidad y expresividad.
Tipos fundamentales
Los fundamentos del sistema de tipos de Rust son bastante familiares para cualquiera que venga de otro lenguaje de programación de tipado estático (como C++, Go o Java). Hay una colección de tipos enteros con tamaños específicos, tanto con signo (i8
, i16
, i32
, i64
, i128
) y sin signo (u8
, u16
, u32
, u64
, u128
).
También hay con signo (isize
) y sin signo (usize
) cuyos tamaños coinciden con el tamaño del puntero en el sistema de destino. Sin embargo, con Rust no harás muchas conversiones entre punteros y enteros, por lo que esa equivalencia de tamaño no es realmente relevante. Sin embargo, las colecciones estándar devuelven su tamaño como usize
(de .len()
), por lo que la indexación de colecciones significa que los valores de usize
son bastante comunes, lo que obviamente está bien desde el punto de vista de la capacidad, ya que no puede haber más elementos en una colección en memoria que direcciones de memoria en el sistema.
Los tipos integrales nos dan la primera pista de que Rust es un mundo más estricto que C++. En Rust, intentar meter un tipo entero mayor (i32
) en un tipo entero menor (i16
) genera un error de compilación:
error[E0308]: mismatched types --> src/main.rs:18:18 | 18 | let y: i16 = x; | --- ^ expected `i16`, found `i32` | | | expected due to this | help: you can convert an `i32` to an `i16` and panic if the converted value doesn't fit | 18 | let y: i16 = x.try_into().unwrap(); | ++++++++++++++++++++
Esto es tranquilizador: Rust no se va a quedar quieto mientras el programador hace cosas arriesgadas. Aunque podemos ver que los valores implicados en esta conversión concreta estarían bien, el compilador tiene que permitir la posibilidad de que haya valores en los que la conversión no esté bien:
La salida de error también da una primera indicación de que, aunque Rust tiene reglas más estrictas, también tiene mensajes útiles del compilador que indican cómo cumplir las reglas. La solución sugerida plantea la cuestión de cómo tratar las situaciones en las que la conversión tendría que alterar el valor para ajustarlo, y tendremos más que decir tanto sobre el tratamiento de errores(Tema 4) como sobre el uso de panic!
(Tema 18) más adelante.
Rust tampoco permite algunas cosas que podrían parecer "seguras", como poner un valor de un tipo entero menor en un tipo entero mayor:
error[E0308]: mismatched types --> src/main.rs:36:18 | 36 | let y: i64 = x; | --- ^ expected `i64`, found `i32` | | | expected due to this | help: you can convert an `i32` to an `i64` | 36 | let y: i64 = x.into(); | +++++++
En este caso, la solución propuesta no plantea el espectro de la gestión de errores, pero la conversión debe ser explícita. Hablaremos de las conversiones de tipos con más detalle más adelante(Tema 5).
Siguiendo con los tipos primitivos no sorprendentes, Rust tiene un tipo bool
tipos de coma flotante (f32
, f64
), y un tipo unitario ()
(como el de C void
).
Más interesante es el tipo char
que contiene unvalor Unicode (similar al tiporune
de Go). Aunque internamente se almacena como cuatro bytes, tampoco hay conversiones silenciosas a o desde un entero de 32 bits.
Esta precisión en el sistema de tipos te obliga a ser explícito sobre lo que intentas expresar: un valor u32
es diferente de un char
, que a su vez es diferente de una secuencia de bytes UTF-8, que a su vez es diferentede una secuencia de bytes arbitrarios, y depende de ti especificar exactamente a qué te refieres.1 La famosa entrada del blog de Joel Spolsky puede ayudarte a entender cuál necesitas.
Por supuesto, existen métodos de ayuda que te permiten convertir entre estos distintos tipos, pero sus firmas te obligan a manejar (o ignorar explícitamente) la posibilidad de fallo. Por ejemplo, un punto de código Unicode siempre puede representarse en 32 bits,2 por lo que 'a' as u32
está permitido, pero la otra dirección es más complicada (ya que hay algunos valores u32
que no son puntos de código Unicode válidos):
char::from_u32
-
Devuelve un
Option<char>
, obligando a la persona que llama a manejar el caso de fallo. char::from_u32_unchecked
-
Hace la suposición de validez, pero tiene el potencial de provocar un comportamiento indefinido si esa suposición resulta no ser cierta. Como resultado, la función se marca como
unsafe
, obligando a quien la llama a utilizar tambiénunsafe
(Tema 16).
Tipos de áridos
Pasando a los tipos agregados, Rust dispone de diversas formas de combinar valores relacionados. La mayoría de ellas son equivalentes familiares a los mecanismos de agregación disponibles en otros lenguajes:
- Matrices
-
Contiene varias instancias de un mismo tipo, cuyo número se conoce en tiempo de compilación. Por ejemplo,
[u32; 4]
son cuatro enteros de 4 bytes seguidos. - Tuplas
-
Contener instancias de múltiples tipos heterogéneos, donde el número de elementos y sus tipos se conocen en tiempo de compilación, por ejemplo,
(WidgetOffset, WidgetSize, WidgetColor)
. Si los tipos de la tupla no son distintivos -por ejemplo,(i32, i32, &'static str, bool)
-, es mejor dar un nombre a cada elemento y utilizar una estructura. - Estructuras
-
También mantienen instancias de tipos heterogéneos conocidos en tiempo de compilación, pero permiten referirse por su nombre tanto al tipo global como a los campos individuales.
Rust también incluye la estructura tupla, que es un cruce de struct
y tupla: hay un nombre para el tipo general, pero no para los campos individuales, a los que se hace referencia por números: s.0
s.1
, etc:
/// Struct with two unnamed fields.
struct
TextMatch
(
usize
,
String
);
// Construct by providing the contents in order.
let
m
=
TextMatch
(
12
,
"needle"
.
to_owned
());
// Access by field number.
assert_eq!
(
m
.
0
,
12
);
enum
s
Esto nos lleva a la joya de la corona del sistema de tipos de Rust, el enum
. Con la forma básica de un enum
, es difícil ver por qué hay que entusiasmarse. Como en otros lenguajes, el enum
te permite especificar un conjunto de valores mutuamente excluyentes, posiblemente con un valor numérico adjunto:
enum
HttpResultCode
{
Ok
=
200
,
NotFound
=
404
,
Teapot
=
418
,
}
let
code
=
HttpResultCode
::NotFound
;
assert_eq!
(
code
as
i32
,
404
);
Dado que cada definición de enum
crea un tipo distinto, esto puede utilizarse para mejorar la legibilidad y mantenibilidad de las funciones que toman argumentos de bool
. En lugar de
print_page
(
/* both_sides= */
true
,
/* color= */
false
);
una versión que utiliza un par de enum
s:
pub
enum
Sides
{
Both
,
Single
,
}
pub
enum
Output
{
BlackAndWhite
,
Color
,
}
pub
fn
print_page
(
sides
:Sides
,
color
:Output
)
{
// ...
}
es más seguro para los tipos y más fácil de leer en el punto de invocación:
print_page
(
Sides
::Both
,
Output
::BlackAndWhite
);
A diferencia de la versión bool
, si un usuario de la biblioteca invirtiera accidentalmente el orden de los argumentos, el compilador se quejaría inmediatamente:
error[E0308]: arguments to this function are incorrect --> src/main.rs:104:9 | 104 | print_page(Output::BlackAndWhite, Sides::Single); | ^^^^^^^^^^ --------------------- ------------- expected `enums::Output`, | | found `enums::Sides` | | | expected `enums::Sides`, found `enums::Output` | note: function defined here --> src/main.rs:145:12 | 145 | pub fn print_page(sides: Sides, color: Output) { | ^^^^^^^^^^ ------------ ------------- help: swap these arguments | 104 | print_page(Sides::Single, Output::BlackAndWhite); | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Utilizar el patrón newtype -ver punto 6- paraenvolver un bool
también consigue seguridad de tipos y facilidad de mantenimiento; por lo general, es mejor utilizar el patrón newtype si la semántica siempre será booleana, y utilizar un enum
si existe la posibilidad de que surja una nueva alternativa -por ejemplo, Sides::BothAlternateOrientation
- en el futuro.
La seguridad de tipos de enum
s de Rust continúa con la expresión match
:
error[E0004]: non-exhaustive patterns: `HttpResultCode::Teapot` not covered --> src/main.rs:44:21 |
44
|
let
msg
=
match
code
{
|
^^^^
pattern
`
HttpResultCode
::Teapot
`
not
covered
|
note
:`
HttpResultCode
`
defined
here
-
->src
/
main
.
rs
:10
:5
|
7
|
enum
HttpResultCode
{
|
--------------
..
.
10
|
Teapot
=
418
,
|
^^^^^^
not
covered
=
note
:the
matched
value
is
of
type
`
HttpResultCode
`
help
:ensure
that
all
possible
cases
are
being
handled
by
adding
a
match
arm
with
a
wildcard
pattern
or
an
explicit
pattern
as
shown
|
46
~
HttpResultCode
::NotFound
=>
"Not found"
,
47
~
HttpResultCode
::Teapot
=>
todo!
(),
|
El compilador obliga al programador a considerar todas las posibilidades que representa el enum
,3 aunque el resultado sea sólo añadir un brazo por defecto _ => {}
. (Ten en cuenta que los compiladores modernos de C++ también pueden advertir, y de hecho lo hacen, sobre la falta de brazos switch
para enum
s.)
enum
s con Campos
La verdadera potencia de la función enum
de Rust proviene del hecho de que cada variante puede tener datos que la acompañan, lo que la convierte en un tipo agregado que actúa como un tipo de datos algebraico (ADT). Esto resulta menos familiar a los programadores de lenguajes convencionales; en términos de C/C++, es como una combinación de un enum
con un union
-sólo de tipo seguro.
Esto significa que las invariantes de las estructuras de datos del programa pueden codificarse en el sistema de tipos de Rust; los estados que no cumplan esas invariantes ni siquiera compilarán. Un enum
bien diseñado deja clara la intención del creador tanto para los humanos como para el compilador:
use
std
::collections
::{
HashMap
,
HashSet
};
pub
enum
SchedulerState
{
Inert
,
Pending
(
HashSet
<
Job
>
),
Running
(
HashMap
<
CpuId
,
Vec
<
Job
>>
),
}
Sólo por la definición del tipo, es razonable suponer que los Job
se ponen en cola en el estado Pending
hasta que el programador está totalmente activo, momento en el que se asignan a algún pool por CPU.
Esto pone de relieve el tema central de este Tema, que es utilizar el sistema de tipos de Rust para expresar los conceptos asociados al diseño de tu software.
Una señal inequívoca de que esto no ocurre es un comentario que explique cuándo es válido algún campo o parámetro:
Es un candidato ideal para sustituirlo por un enum
que contenga datos:
pub
enum
Color
{
Monochrome
,
Foreground
(
RgbColor
),
}
pub
struct
DisplayProps
{
pub
x
:u32
,
pub
y
:u32
,
pub
color
:Color
,
}
Este pequeño ejemplo ilustra un consejo clave: haz que los estados no válidos sean inexpresables en tus tipos. Los tipos que sólo admiten combinaciones válidas de valores significan que clases enteras de errores son rechazadas por el compilador, lo que conduce a un código más pequeño y seguro.
Ubicua enum
Tipos
Volviendo al poder del enum
, hay dos conceptos que son tan comunes que la biblioteca estándar de Rust incluye tipos enum
incorporados para expresarlos; estos tipos son omnipresentes en el código Rust.
Option<T>
El primer concepto es el de Option
: o hay un valor de un tipo determinado (Some(T)
) o no lo hay (None
). Utiliza siempreOption
para los valores que pueden estar ausentes; no recurras nunca al uso de valores centinela (-1, nullptr
, ...) para intentar expresar el mismo concepto en banda.
Sin embargo, hay un punto sutil a tener en cuenta. Si estás tratando con una colección de cosas, tienes que decidir si tener cero cosas en la colección es lo mismo que no tener una colección. En la mayoría de las situaciones, la distinción no se plantea y puedes seguir adelante y utilizar (digamos) Vec<Thing>
: un recuento de cero cosas implica una ausencia de cosas.
Sin embargo, existen sin duda otros escenarios poco frecuentes en los que es necesario distinguir ambos casos conOption<Vec<Thing>>
-por ejemplo, un sistema criptográfico podría necesitar distinguir entre "carga útil transportada por separado" y "carga útil vacía proporcionada". (Esto está relacionado con los debates en torno al marcadorNULL
para las columnas en SQL).
Del mismo modo, ¿cuál es la mejor opción para un String
que podría estar ausente? ¿Tiene más sentido ""
o None
para indicar la ausencia de un valor? Cualquiera de las dos formas funciona, pero Option<String>
comunica claramente la posibilidad de que este valor pueda estar ausente.
Result<T, E>
El segundo concepto común surge del procesamiento de errores: si una función falla, ¿cómo debe informarse de ese fallo? Históricamente, se utilizaban valores centinela especiales (por ejemplo, -errno
valores de retorno de llamadas al sistema Linux) o variables globales (errno
para sistemas POSIX). Más recientemente, los lenguajes que admiten valores de retorno múltiples o tuplas (como Go) de funciones pueden tener la convención de devolver un par (result, error)
, asumiendo la existencia de algún valor "cero" adecuado para el result
cuando el error
no es "cero".
En Rust, existe un tipo enum
precisamente para esto: codifica siempre el resultado de una operación que pueda fallar como Result<T, E>
. El tipo T
contiene el resultado satisfactorio (en la variante Ok
), y el tipo E
contiene los detalles del error (en la variante Err
) en caso de fallo.
Utilizar el tipo estándar deja clara la intención del diseño. También permite el uso de transformaciones estándar(punto 3) y el tratamiento de errores(punto 4), lo que a su vez permite agilizar también el tratamiento de errores con el operador ?
.
Tema 2: Utilizar el sistema de tipos para expresar comportamientos comunes
En el punto1 se trató cómo expresar estructuras de datos en el sistema de tipos; en este punto se pasa a tratar la codificación delcomportamiento en el sistema de tipos de Rust.
En general, los mecanismos descritos en este Tema te resultarán familiares, ya que todos tienen análogos directos en otros idiomas:
- Funciones
-
El mecanismo universal para asociar un trozo de código con un nombre y una lista de parámetros.
- Métodos
-
Funciones que se asocian a una instancia de una determinada estructura de datos. Los métodos son habituales en los lenguajes de programación creados después de que surgiera la orientación a objetos como paradigma de programación.
- Punteros de función
-
Soportado por la mayoría de los lenguajes de la familia C, incluidos C++ y Go, como mecanismo que permite aun nivel extra de indirección al invocar otro código.
- Cierres
-
Originalmente eran más comunes en la familia de lenguajes Lisp, pero se han adaptado a muchos lenguajes de programación populares de, incluidos C++ (desde C++11) y Java (desde Java 8).
- Rasgos
-
Describen colecciones de funcionalidades relacionadas que se aplican todas al mismo elemento subyacente. Los rasgos tienen equivalentes aproximados en muchos otros lenguajes, como las clases abstractas en C++ y las interfaces en Go y Java.
Por supuesto, todos estos mecanismos tienen detalles específicos de Rust que se tratarán en este artículo.
De la lista anterior, los rasgos son los más importantes para este libro, ya que describen gran parte del comportamiento proporcionado por el compilador y la biblioteca estándar de Rust. El capítulo 2 se centra en artículos que dan consejos sobre el diseño y la implementación de rasgos, pero su omnipresencia significa que también aparecen con frecuencia en los demás artículos de este capítulo.
Funciones y métodos
Como cualquier otro lenguaje de programación, Rust utiliza funciones para organizar el código en trozos con nombre para su reutilización, con entradas al código expresadas como parámetros. Como en cualquier otro lenguaje de tipado estático, los tipos de los parámetros y del valor de retorno se especifican explícitamente:
/// Return `x` divided by `y`.
fn
div
(
x
:f64
,
y
:f64
)
->
f64
{
if
y
==
0.0
{
// Terminate the function and return a value.
return
f64
::NAN
;
}
// The last expression in the function body is implicitly returned.
x
/
y
}
/// Function called just for its side effects, with no return value.
/// Can also write the return value as `-> ()`.
fn
show
(
x
:f64
)
{
println!
(
"x = {x}"
);
}
Si una función está íntimamente relacionada con una estructura de datos concreta, se expresa como método. Un método actúa sobre un elemento de ese tipo, identificado por self
, y se incluye dentro de un bloque impl DataStructure
. Esto encapsula juntos datos y código relacionados de una forma orientada a objetos que es similar a la de otros lenguajes; sin embargo, en Rust, los métodos pueden añadirse a los tipos enum
así como a los tipos struct
, en consonancia con la naturaleza omnipresente de enum
de Rust(Tema 1):
enum
Shape
{
Rectangle
{
width
:f64
,
height
:f64
},
Circle
{
radius
:f64
},
}
impl
Shape
{
pub
fn
area
(
&
self
)
->
f64
{
match
self
{
Shape
::Rectangle
{
width
,
height
}
=>
width
*
height
,
Shape
::Circle
{
radius
}
=>
std
::f64
::consts
::PI
*
radius
*
radius
,
}
}
}
El nombre de un método crea una etiqueta para el comportamiento que codifica, y la firma del método da información de tipo para sus entradas y salidas. La primera entrada de un método será alguna variante de self
, indicando lo que el método puede hacer a la estructura de datos:
-
Un parámetro
&self
indica que se puede leer el contenido de la estructura de datos, pero no se modificará. -
Un parámetro
&mut self
indica que el método podría modificar el contenido de la estructura de datos. -
Un parámetro
self
indica que el método consume la estructura de datos.
Punteros de función
En el apartado anterior se ha descrito cómo asociar un nombre (y una lista de parámetros) a un código. Sin embargo, al invocar una función siempre se ejecuta el mismo código; lo único que cambia de una invocación a otra son los datos sobre los que opera la función. Eso cubre muchos escenarios posibles, pero ¿qué ocurre si el código debe variar en tiempo de ejecución?
La abstracción de comportamiento más sencilla que permite esto es el puntero a función: un puntero a (sólo) código, con un tipo que refleja la firma de la función:
fn
sum
(
x
:i32
,
y
:i32
)
->
i32
{
x
+
y
}
// Explicit coercion to `fn` type is required...
let
op
:fn
(
i32
,
i32
)
->
i32
=
sum
;
El tipo se comprueba en tiempo de compilación, de modo que cuando se ejecuta el programa, el valor es sólo el tamaño de un puntero. Los punteros de función no tienen ningún otro dato asociado, por lo que pueden tratarse como valores de varias formas:
// `fn` types implement `Copy`
let
op1
=
op
;
let
op2
=
op
;
// `fn` types implement `Eq`
assert!
(
op1
==
op2
);
// `fn` implements `std::fmt::Pointer`, used by the {:p} format specifier.
println!
(
"op = {:p}"
,
op
);
// Example output: "op = 0x101e9aeb0"
Un detalle técnico a tener en cuenta en: es necesaria la coerción explícita a un tipo fn
, porque el mero uso del nombre de una función no te da algo del tipo fn
:
error[E0369]: binary operation `==` cannot be applied to type `fn(i32, i32) -> i32 {main::sum}` --> src/main.rs:102:17 | 102 | assert!(op1 == op2); | --- ^^ --- fn(i32, i32) -> i32 {main::sum} | | | fn(i32, i32) -> i32 {main::sum} | help: use parentheses to call these | 102 | assert!(op1(/* i32 */, /* i32 */) == op2(/* i32 */, /* i32 */)); | ++++++++++++++++++++++ ++++++++++++++++++++++
En su lugar, el error del compilador indica que el tipo es algo como fn(i32, i32) -> i32 {main::sum}
, un tipo que es totalmente interno al compilador (es decir, que no podría escribirse en código de usuario) y que identifica la función específica, así como su firma. Dicho de otro modo, el tipo de sum
codifica tanto la firma de la función como su ubicación por razones de optimización; este tipo puede coaccionarse automáticamente(punto 5) a un tipo fn
.
Cierres
Los punteros de función desnudos son limitantes, porque las únicas entradas disponibles para la función invocada son las que se pasan explícitamente como valores de los parámetros. Por ejemplo, considera un código que modifica cada elemento de una rebanada utilizando un puntero a función:
// In real code, an `Iterator` method would be more appropriate.
pub
fn
modify_all
(
data
:&
mut
[
u32
],
mutator
:fn
(
u32
)
->
u32
)
{
for
value
in
data
{
*
value
=
mutator
(
*
value
);
}
}
Esto funciona para una simple mutación de la rebanada:
fn
add2
(
v
:u32
)
->
u32
{
v
+
2
}
let
mut
data
=
vec!
[
1
,
2
,
3
];
modify_all
(
&
mut
data
,
add2
);
assert_eq!
(
data
,
vec!
[
3
,
4
,
5
]);
Sin embargo, si la modificación depende de algún estado adicional, no es posible pasarlo implícitamente al puntero de la función:
error[E0434]: can't capture dynamic environment in a fn item --> src/main.rs:125:13 | 125 | v + amount_to_add | ^^^^^^^^^^^^^ | = help: use the `|| { ... }` closure form instead
El mensaje de error apunta a la herramienta adecuada para el trabajo: un cierre. Un cierre es un trozo de código que se parece al cuerpo de una definición de función (una expresión lambda), excepto en lo siguiente:
-
Puede construirse como parte de una expresión, por lo que no necesita tener un nombre asociado.
-
Los parámetros de entrada se indican en barras verticales
|param1, param2|
(normalmente, el compilador puede deducir automáticamente sus tipos asociados ). -
Puede captar partes del entorno que le rodea:
let
amount_to_add
=
3
;
let
add_n
=
|
y
|
{
// a closure capturing `amount_to_add`
y
+
amount_to_add
};
let
z
=
add_n
(
5
);
assert_eq!
(
z
,
8
);
Para entender (a grandes rasgos) cómo funciona la captura, imagina que el compilador crea un tipo efímero interno que contiene todas las partes del entorno que se mencionan en la expresión lambda. Cuando se crea el cierre, se crea una instancia de este tipo efímero para contener los valores relevantes, y cuando se invoca el cierre, esa instancia se utiliza como contexto adicional:
let
amount_to_add
=
3
;
// *Rough* equivalent to a capturing closure.
struct
InternalContext
<'
a
>
{
// references to captured variables
amount_to_add
:&
'
a
u32
,
}
impl
<'
a
>
InternalContext
<'
a
>
{
fn
internal_op
(
&
self
,
y
:u32
)
->
u32
{
// body of the lambda expression
y
+
*
self
.
amount_to_add
}
}
let
add_n
=
InternalContext
{
amount_to_add
:&
amount_to_add
,
};
let
z
=
add_n
.
internal_op
(
5
);
assert_eq!
(
z
,
8
);
Los valores que se mantienen en este contexto nocional suelen ser referencias(Elemento 8), como aquí, pero también pueden ser referencias mutables a cosas del entorno, o valores que se mueven fuera del entorno por completo (utilizando la palabra clave move
antes de los parámetros de entrada).
Volviendo al ejemplo de modify_all
, no se puede utilizar un cierre donde se espera un puntero a función:
error[E0308]: mismatched types --> src/main.rs:199:31 | 199 | modify_all(&mut data, |y| y + amount_to_add); | ---------- ^^^^^^^^^^^^^^^^^^^^^ expected fn pointer, | | found closure | | | arguments to this function are incorrect | = note: expected fn pointer `fn(u32) -> u32` found closure `[closure@src/main.rs:199:31: 199:34]` note: closures can only be coerced to `fn` types if they do not capture any variables --> src/main.rs:199:39 | 199 | modify_all(&mut data, |y| y + amount_to_add); | ^^^^^^^^^^^^^ `amount_to_add` | captured here note: function defined here --> src/main.rs:60:12 | 60 | pub fn modify_all(data: &mut [u32], mutator: fn(u32) -> u32) { | ^^^^^^^^^^ -----------------------
En su lugar, el código que recibe el cierre tiene que aceptar una instancia de uno de los rasgos Fn*
:
pub
fn
modify_all
<
F
>
(
data
:&
mut
[
u32
],
mut
mutator
:F
)
where
F
:FnMut
(
u32
)
->
u32
,
{
for
value
in
data
{
*
value
=
mutator
(
*
value
);
}
}
Rust tiene tres rasgos diferentes Fn*
, que entre ellos expresan algunas distinciones en torno a este comportamiento de captura del entorno:
FnOnce
-
Describe un cierre que sólo puede invocarseuna vez. Si alguna parte del entorno se
move
d en el contexto de la clausura, y el cuerpo de la clausura la mueve posteriormente fuera del contexto de la clausura, entonces esos movimientos sólo pueden ocurrir una vez -no hay otra copia del elemento fuente desde la quemove
- y, por tanto, la clausura sólo puede invocarse una vez. FnMut
-
Describe un cierre que puede llamarse repetidamente y que puede realizar cambios en su entorno porque toma prestado mutablemente del entorno.
Fn
-
Describe un cierre que puede llamarse repetidamente y que sólo toma prestados valores del entorno de forma inmutable.
El compilador implementa automáticamente en el subconjunto adecuado de estos rasgos Fn*
para cualquier expresión lambda del código; no es posible implementar manualmente ninguno de estos rasgos (a diferencia de la sobrecarga operator()
de C++).4
Volviendo al modelo mental aproximado anterior de los cierres, cuál de los rasgos autoimplementa el compilador corresponde aproximadamente aa si el contexto ambiental capturado tiene estos elementos:
FnOnce
-
Cualquier valor desplazado
FnMut
-
Cualquier referencia mutable a valores (
&mut T
) Fn
-
Sólo referencias normales a valores (
&T
)
Los dos últimos traits de esta lista tienen cada uno un trait bound del trait anterior, lo que tiene sentido si tienes en cuenta las cosas que utilizan los cierres:
-
Si algo espera llamar a un cierre una sola vez (lo que se indica recibiendo un
FnOnce
), está bien pasarle un cierre que sea capaz de ser llamado repetidamente (FnMut
). -
Si algo espera llamar repetidamente a un cierre que podría mutar su entorno (indicado mediante la recepción de un
FnMut
), está bien pasarle un cierre que no necesite mutar su entorno (Fn
).
El tipo de puntero de función desnudo fn
también pertenece teóricamente al final de esta lista; cualquier tipo (nounsafe
) fn
implementa automáticamente todos los rasgos Fn*
, porque no toma prestado nada del entorno.
En consecuencia, cuando escribas código que acepte cierres, utiliza el rasgo Fn*
más general que funcione, para permitir la mayor flexibilidad a los invocadores; por ejemplo, acepta FnOnce
para los cierres que sólo se utilicen una vez. El mismo razonamiento lleva también a aconsejar que se prefieran los límites del rasgo Fn*
a los punteros de función desnudos (fn
).
Rasgos
Los rasgos Fn*
son más flexibles que los punteros de función desnudos, pero aún así sólo pueden describir el comportamiento de una única función, e incluso entonces sólo en términos de la firma de la función.
Sin embargo, son en sí mismos ejemplos de otro mecanismo para describir el comportamiento en el sistema de tipos de Rust, el rasgo. Un trait define un conjunto de funciones relacionadas que algún elemento subyacente pone a disposición del público; además, las funciones son típicamente (pero no tienen por qué serlo) métodos, que toman como primer argumento alguna variante de self
.
Cada función de un rasgo también tiene un nombre, que proporciona una etiqueta que permite al compilador desambiguar funciones con la misma firma y, lo que es más importante, que permite a los programadores deducir la intención de la función.
Un rasgo de Rust es aproximadamente análogo a una "interfaz" en Go y Java, o a una "clase abstracta" (todos los métodos virtuales, sin miembros de datos) en C++. Las implementaciones del rasgo deben proporcionar todas las funciones (pero ten en cuenta que la definición del rasgo puede incluir una implementación por defecto; Tema 13) y también pueden tener datos asociados que esas implementaciones utilicen. Esto significa que el código y los datos se encapsulan juntos en una abstracción común, de una manera un tanto orientada a objetos (OO).
El código que acepta un struct
y llama a funciones sobre él está limitado a trabajar sólo con ese tipo específico. Si hay varios tipos que implementan un comportamiento común, entonces es más flexible definir un rasgo que encapsule ese comportamiento común, y hacer que el código utilice las funciones del rasgo en lugar de las funciones que implican a un struct
específico.
Esto lleva al mismo tipo de consejo que aparece para otros lenguajes con influencia OO:5 prefiere aceptar tipos trait en lugar de tipos concretos si seprevé flexibilidad en el futuro.
A veces, hay algún comportamiento que quieres distinguir en el sistema de tipos, pero no puede expresarse como una firma de función específica en una definición de rasgo. Por ejemplo, considera un rasgo Sort
para ordenar colecciones; una implementación podría ser estable (los elementos que se comparen igual aparecerán en el mismo orden antes y después de la ordenación), pero no hay forma de expresarlo en los argumentos del método sort
.
En este caso, sigue mereciendo la pena utilizar el sistema de tipos para hacer un seguimiento de este requisito, utilizando un rasgo marcador:
pub
trait
Sort
{
/// Rearrange contents into sorted order.
fn
sort
(
&
mut
self
);
}
/// Marker trait to indicate that a [`Sort`] sorts stably.
pub
trait
StableSort
:Sort
{}
Un rasgo marcador no tiene funciones, pero una implementación sigue teniendo que declarar que implementa el rasgo, lo que actúa como una promesa del implementador: "Juro solemnemente que mi implementación ordena de forma estable". El código que depende de una ordenación estable puede entonces especificar el límite del rasgo StableSort
, confiando en el sistema de honor para preservar sus invariantes. Utiliza rasgos marcadores para distinguir comportamientos que no puedan expresarse en las firmas de función de los rasgos.
Una vez que el comportamiento se ha encapsulado en el sistema de tipos de Rust como un rasgo, se puede utilizar de dos formas:
-
Como un límite de rasgo, que restringe qué tipos son aceptables para un tipo de datos o función genéricos en tiempo de compilación
-
Como un objeto trait, que restringe qué tipos pueden almacenarse o pasarse a una función en tiempo de ejecución
Las secciones siguientes describen estas dos posibilidades, y el Tema 12 da más detalles sobre las compensaciones entre ellas.
Límites de rasgo
Un trait bound indica que el código genérico parametrizado por algún tipo T
sólo puede utilizarse cuando ese tipo T
implementa algún trait específico. La presencia del límite de rasgo significa que la implementación del genérico puede utilizar las funciones de ese rasgo, con la seguridad de que el compilador se asegurará de que cualquier T
que compile tenga efectivamente esas funciones. Esta comprobación se realiza en tiempo de compilación, cuando el genérico se monomorfiza, es decir,se conviertedel código genérico que trata un tipo arbitrario T
en código específico que trata un tipo concreto SomeType
(lo que C++ llamaría instanciación de plantilla).
Esta restricción sobre el tipo objetivo T
es explícita, codificada en los límites del rasgo: el rasgo sólo puede ser implementado por tipos que satisfagan los límites del rasgo. Esto contrasta con la situación equivalente en C++, donde las restricciones sobre el tipo T
utilizado en un rasgotemplate<typename T>
son implícitas:6 El código de plantilla C++ sólo se compila si todas las funciones referenciadas están disponibles en tiempo de compilación, pero las comprobaciones se basan únicamente en el nombre y la firma de la función. (Esta "tipificación de pato" puede llevar a confusión; una plantilla C++ que utilice t.pop()
podría compilarse para un parámetro de tipo T
de Stack
oBalloon
-lo que probablemente no sea el comportamiento deseado).
La necesidad de límites de rasgo explícitos también significa que una gran parte de los genéricos utilizan límites de rasgo. Para ver por qué ocurre esto, dale la vuelta a la observación y considera lo que se puede hacer con un struct Thing<T>
en el que no hay límites de rasgo en T
. Sin un límite de rasgo, el Thing
sólo puede realizar operaciones que se apliquen a cualquier tipo T
-básicamente, sólo mover o soltar el valor-. Esto, a su vez, permite contenedores genéricos, colecciones y punteros inteligentes, pero no mucho más. Cualquier cosa que utilice el tipo T
necesitará un trait bound:
pub
fn
dump_sorted
<
T
>
(
mut
collection
:T
)
where
T
:Sort
+
IntoIterator
,
T
::Item
:std
::fmt
::Debug
,
{
// Next line requires `T: Sort` trait bound.
collection
.
sort
();
// Next line requires `T: IntoIterator` trait bound.
for
item
in
collection
{
// Next line requires `T::Item : Debug` trait bound
println!
(
"{:?}"
,
item
);
}
}
Así que el consejo aquí es utilizar trait bounds para expresar requisitos sobre los tipos utilizados en los genéricos, pero es un consejo fácil de seguir: el compilador te obligará a cumplirlo a pesar de todo.
Objetos rasgo
Un objeto trait es la otra forma de hacer uso de la encapsulación definida por un trait, pero aquí, las diferentes implementaciones posibles del trait se eligen en tiempo de ejecución en lugar de en tiempo de compilación. Este envío dinámico es análogo al uso de funciones virtuales en C++, y bajo las cubiertas, Rust tiene objetos "vtable" que sonaproximadamente análogos a los de C++.
Este aspecto dinámico de los objetos trait también significa que siempre tienen que manejarse indirectamente, mediante una referencia (por ejemplo, &dyn Trait
) o un puntero (por ejemplo, Box<dyn Trait>
) de algún tipo. La razón es que el tamaño del objeto que implementa el rasgo no se conoce en tiempo de compilación -podría ser un struct
gigante o un enum
diminuto-, por lo que no hay forma de asignar la cantidad adecuada de espacio a un objeto rasgo desnudo.
No conocer el tamaño del objeto concreto también significa que los traits utilizados como objetos trait no pueden tener funciones que devuelvan el tipo Self
ni argumentos (distintos del receptor, elobjeto sobre el que se invoca el método) que utilicen Self
. La razón es que el código compilado por adelantado que utiliza el objeto trait no tendría ni idea de lo grande que podría ser ese Self
.
Un trait que tiene una función genérica fn some_fn<T>(t:T)
permite la posibilidad de un número infinito de funciones implementadas, para todos los tipos diferentes T
que puedan existir. Esto está bien para un trait utilizado como límite de trait, porque el conjunto infinito de funciones genéricas posiblemente invocadas se convierte en un conjunto finito de funciones genéricas realmente invocadas en tiempo de compilación. No ocurre lo mismo con un objeto trait: el código disponible en tiempo de compilación tiene que hacer frente a todos los posibles T
s que puedan llegar en tiempo de ejecución.
Estas dos restricciones -no utilizar Self
y no utilizar funciones genéricas- se combinan en el concepto de seguridad de los objetos. Sólo los rasgos seguros para los objetos pueden utilizarse como objetos rasgo.
Tema 3: Prefiere las transformaciones Option
y Result
a las expresiones explícitas match
El punto 1 expuso las virtudes de enum
y mostró cómo las expresiones match
obligan al programador a tener en cuenta todas las posibilidades. El Tema 1 también introdujo las dos omnipresentes enum
s que proporciona la biblioteca estándar de Rust :
Option<T>
-
Para expresar que un valor (del tipo
T
) puede o no estar presente Result<T, E>
-
Para cuando una operación para devolver un valor (de tipo
T
) no tenga éxito y en su lugar devuelva un error (de tipoE
)
Este Artículo explora situaciones en las que deberías intentar evitar expresiones match
explícitas para estos enum
s particulares, prefiriendo en su lugar utilizar varios métodos de transformación que la biblioteca estándar proporciona para estos tipos. El uso de estos métodos de transformación (que normalmente se implementan como expresiones match
ocultas) conduce a un código más compacto e idiomático y con una intención más clara.
La primera situación en la que un match
es innecesario es cuando sólo el valor es relevante y la ausencia de valor (y cualquier error asociado) puede simplemente ignorarse:
struct
S
{
field
:Option
<
i32
>
,
}
let
s
=
S
{
field
:Some
(
42
)
};
match
&
s
.
field
{
Some
(
i
)
=>
println!
(
"field is {i}"
),
None
=>
{}
}
Para esta situación, una if let
expresión es una línea más corta y, lo que es más importante, más clara:
if
let
Some
(
i
)
=
&
s
.
field
{
println!
(
"field is {i}"
);
}
Sin embargo, la mayoría de las veces el programador tiene que proporcionar el correspondiente brazo else
: la ausencia de un valor (Option::None
), posiblemente con un error asociado (Result::Err(e)
), es algo con lo que el programador tiene que lidiar. Diseñar software para hacer frente a rutas de fallo es difícil, y la mayor parte de ello es complejidad esencial con la que ninguna cantidad de soporte sintáctico puede ayudar, en concreto, decidir qué debe ocurrir si falla una operación.
En algunas situaciones, la decisión correcta es realizar una maniobra de avestruz: meter la cabeza en la arena y no enfrentarse explícitamente al fallo. No puedes ignorar completamente el brazo de error, porque Rust requiere que el código se ocupe de las dos variantes del Error
enum
, pero puedes elegir tratar un fallo como fatal. Realizar un panic!
en caso de fallo significa que el programa termina, pero el resto del código puede escribirse asumiendo que ha tenido éxito.Hacer esto con un match
explícito sería innecesariamenteverboso:
let
result
=
std
::fs
::File
::open
(
"/etc/passwd"
);
let
f
=
match
result
{
Ok
(
f
)
=>
f
,
Err
(
_e
)
=>
panic!
(
"Failed to open /etc/passwd!"
),
};
// Assume `f` is a valid `std::fs::File` from here onward.
Tanto Option
como Result
proporcionan un par de métodos que extraen su valor interno y panic!
si está ausente: unwrap
y expect
. Este último permite personalizar el mensaje de error en caso de fallo, pero en cualquier caso, el código resultante es más corto y sencillo: la gestión del error se delega en el sufijo .unwrap()
(pero sigue estando presente):
let
f
=
std
::fs
::File
::open
(
"/etc/passwd"
).
unwrap
();
Pero tenlo claro: estas funciones de ayuda siguen siendo panic!
, por lo que elegir utilizarlas es lo mismo que elegir panic!
(Tema 18).
Sin embargo, en muchas situaciones, la decisión correcta para la gestión de errores es aplazar la decisión a otra persona. Esto es especialmente cierto cuando se escribe una biblioteca, en la que el código puede utilizarse en todo tipo de entornos diferentes que el autor de la biblioteca no puede prever. Para facilitar el trabajo a esa otra persona, prefiere Result
a Option
para expresar los errores, aunque ello implique conversiones entre distintos tipos de error(Tema 4).
Por supuesto, esto abre la pregunta: ¿Qué se considera un error? En este ejemplo, no poder abrir un archivo es definitivamente un error, y los detalles de ese error (¿no existe tal archivo? ¿permiso denegado?) pueden ayudar al usuario a decidir qué hacer a continuación. En cambio, no poder recuperar el elemento first()
de una porción porque está vacía no es realmente un error, por lo que se expresa como un tipo de retorno Option
en la biblioteca estándar. Elegir entre las dos posibilidades requiere juicio, pero inclínate por Result
si un error puede comunicar algo útil.
Result
también tiene unatributo #[must_use]
para empujar a los usuarios de la biblioteca en la direccióncorrecta : siel código que utiliza el Result
devuelto lo ignora, el compilador generará una advertencia:
warning: unused `Result` that must be used --> src/main.rs:63:5 | 63 | f.set_len(0); // Truncate the file | ^^^^^^^^^^^^ | = note: this `Result` may be an `Err` variant, which should be handled = note: `#[warn(unused_must_use)]` on by default help: use `let _ = ...` to ignore the resulting value | 63 | let _ = f.set_len(0); // Truncate the file | +++++++
Utilizar explícitamente un match
permite que se propague un error, pero a costa de cierta burocracia visible (que recuerda a Go):
pub
fn
find_user
(
username
:&
str
)
->
Result
<
UserId
,
std
::io
::Error
>
{
let
f
=
match
std
::fs
::File
::open
(
"/etc/passwd"
)
{
Ok
(
f
)
=>
f
,
Err
(
e
)
=>
return
Err
(
From
::from
(
e
)),
};
// ...
}
El ingrediente clave para reducir la burocracia es el operador de signo de interrogación de Rust , ?
. Este trozo de azúcar sintáctico se encarga de hacer coincidir el brazo Err
, transformar el tipo de error si es necesario y construir la expresión return Err(...)
, todo en un solocarácter:
pub
fn
find_user
(
username
:&
str
)
->
Result
<
UserId
,
std
::io
::Error
>
{
let
f
=
std
::fs
::File
::open
(
"/etc/passwd"
)
?
;
// ...
}
Los recién llegados a Rust a veces encuentran esto desconcertante: el signo de interrogación puede ser difícil de detectar a primera vista, lo que provoca inquietud sobre cómo es posible que funcione el código. Sin embargo, incluso con un solo carácter, el sistema de tipos sigue funcionando, garantizando que se cubran todas las posibilidades expresadas en los tipos relevantes(Elemento 1), dejando que el programador se centre en la ruta principal del código sin distracciones.
Además, por lo general, estas aparentes invocaciones a métodos no tienen ningún coste: son todas funciones genéricas marcadas como #[inline]
por lo que el código generado suele compilar código máquina idéntico a la versión manual.
Estos dos factores tomados en conjunto significan que debes preferir las transformaciones Option
y Result
a las expresiones explícitas match
.
En el ejemplo anterior, los tipos de error se alineaban: tanto el método interno como el externo expresaban errores comostd::io::Error
. A menudo no es así: una función puede acumular errores de varias sublibrerías distintas, cada una de las cuales utiliza tipos de error diferentes.
El mapeo de errores en general se trata en el Tema 4, pero por ahora, ten en cuenta que un mapeo manual de:
pub
fn
find_user
(
username
:&
str
)
->
Result
<
UserId
,
String
>
{
let
f
=
match
std
::fs
::File
::open
(
"/etc/passwd"
)
{
Ok
(
f
)
=>
f
,
Err
(
e
)
=>
{
return
Err
(
format!
(
"Failed to open password file: {:?}"
,
e
))
}
};
// ...
}
podría expresarse de forma más sucinta e idiomática con la siguiente .map_err()
transformación:
pub
fn
find_user
(
username
:&
str
)
->
Result
<
UserId
,
String
>
{
let
f
=
std
::fs
::File
::open
(
"/etc/passwd"
)
.
map_err
(
|
e
|
format!
(
"Failed to open password file: {:?}"
,
e
))
?
;
// ...
}
Mejor aún, incluso esto puede no ser necesario: si el tipo de error externo puede crearse a partir del tipo de error interno mediante una implementación del rasgo estándar From
(punto 10), entonces el compilador realizará automáticamente la conversión sin necesidad de llamar a .map_err()
.
Este tipo de transformaciones se generalizan más ampliamente. El operador interrogación es un gran martillo; utiliza métodos de transformación en los tipos Option
y Result
para maniobrarlos hasta una posición en la que puedan ser un clavo.
La biblioteca estándar proporciona una amplia variedad de estos métodos de transformación para que esto sea posible. La Figura 1-1muestra algunos de los métodos más comunes (rectángulos claros) que transforman entre los tipos correspondientes (rectángulos oscuros). De acuerdo con el punto 18, los métodos que pueden panic!
están marcados con un asterisco.
Una situación común que el diagrama no cubre tiene que ver con las referencias. Por ejemplo, considera una estructura que contiene opcionalmente algunos datos:
struct
InputData
{
payload
:Option
<
Vec
<
u8
>>
,
}
Un método en este struct
que intente pasar la carga útil a una función de encriptación con firma (&[u8]) -> Vec<u8>
falla si hay un intento ingenuo de tomar una referencia:
error[E0507]: cannot move out of `self.payload` which is behind a shared reference --> src/main.rs:15:18 | 15 | encrypt(&self.payload.unwrap_or(vec![])) | ^^^^^^^^^^^^ move occurs because `self.payload` has type | `Option<Vec<u8>>`, which does not implement the | `Copy` trait
La herramienta adecuada para ello es el as_ref()
método en Option
.8 Este método convierte una referencia a unaOption
en una Option
-de-una-referencia:
pub
fn
encrypted
(
&
self
)
->
Vec
<
u8
>
{
encrypt
(
self
.
payload
.
as_ref
().
unwrap_or
(
&
vec!
[]))
}
Cosas para recordar
-
Acostúmbrate a las transformaciones de
Option
yResult
, y prefiereResult
aOption
. Utiliza.as_ref()
cuando sea necesario cuando las transformaciones impliquen referencias. -
Utiliza estas transformaciones con preferencia a las operaciones explícitas de
match
enOption
yResult
. -
En concreto, utiliza estas transformaciones para convertir los tipos de resultado a una forma en la que se aplique el operador
?
.
Tema 4: Prefiere tipos idiomáticos Error
El Tema 3 describía cómo utilizar las transformaciones que la biblioteca estándar proporciona para los tipos Option
y Result
para permitir un manejo conciso e idiomático de los tipos de resultado utilizando el operador ?
. No llegó a discutir la mejor manera de tratar la variedad de tipos de error E
que surgen como segundo argumento de tipo de un Result<T, E>
; ése es el tema de este Tema.
Esto sólo es relevante cuando hay varios tipos de error diferentes en juego. Si todos los errores diferentes que encuentra una función ya son del mismo tipo, puede limitarse a devolver ese tipo. Cuando hay errores de distintos tipos, hay que decidir si debe conservarse la información del tipo de suberror.
El rasgo Error
Siempre es bueno entender lo que implican los rasgos estándar(Tema 10), y el rasgo relevante aquí es std::error::Error
. El parámetro de tipo E
para unResult
no tiene por qué ser un tipo que implemente Error
, pero es una convención común que permite a las envolturas expresar límites de rasgo apropiados, así que prefiere implementar Error
para tus tipos de error.
Lo primero que hay que observar es que el único requisito estricto para los tipos Error
son los límites del rasgo : cualquier tipo que implemente Error
también tiene que implementar los rasgos siguientes:
-
El rasgo
Display
, lo que significa que se puedeformat!
ed con{}
-
El rasgo
Debug
, lo que significa que se puedeformat!
ed con{:?}
En otras palabras, debería ser posible mostrar los tipos Error
tanto al usuario como al programador.
El único método del rasgo essource()
,9 que permite a un tipo Error
exponer un error interno anidado. Este método es opcional: viene con una implementación por defecto(Elemento 13) que devuelve None
, lo que indica que la información sobre el error interno no está disponible.
Una última cosa a tener en cuenta: si estás escribiendo código para un entorno no_std
(Tema 33), puede que no sea posible implementar Error
-el rasgo Error
está actualmente implementado enstd
, no en core
, por lo que no está disponible.10
Errores mínimos
Si no se necesita información de error anidada, entonces una implementación del tipo Error
no necesita ser mucho más que unString
-una rara ocasión en la que una variable "stringly typed" podría ser apropiada-. Sin embargo, sí necesita seralgo más que un String
; aunque es posible utilizar String
como parámetro del tipo E
:
pub
fn
find_user
(
username
:&
str
)
->
Result
<
UserId
,
String
>
{
let
f
=
std
::fs
::File
::open
(
"/etc/passwd"
)
.
map_err
(
|
e
|
format!
(
"Failed to open password file: {:?}"
,
e
))
?
;
// ...
}
a String
no implementa Error
, lo que preferiríamos para que otras áreas de código puedan ocuparse de Error
s. No es posible impl Error
para String
, porque ni el rasgo ni el tipo nos pertenecen (la llamada regla del huérfano):
error[E0117]: only traits defined in the current crate can be implemented for types defined outside of the crate --> src/main.rs:18:5 | 18 | impl std::error::Error for String {} | ^^^^^^^^^^^^^^^^^^^^^^^^^^^------ | | | | | `String` is not defined in the current crate | impl doesn't use only types from inside the current crate | = note: define and implement a trait or new type instead
Un alias de tipo tampoco ayuda, porque no crea un nuevo tipo y, por tanto, no cambia el mensaje de error:
error[E0117]: only traits defined in the current crate can be implemented for types defined outside of the crate --> src/main.rs:41:5 | 41 | impl std::error::Error for MyError {} | ^^^^^^^^^^^^^^^^^^^^^^^^^^^------- | | | | | `String` is not defined in the current crate | impl doesn't use only types from inside the current crate | = note: define and implement a trait or new type instead
Como de costumbre, el mensaje de error del compilador da una pista para resolver el problema. Definir una estructura de tupla que envuelva el tipoString
(el "patrón newtype", punto 6) permite implementar el rasgo Error
, siempre que Debug
y Display
también estén implementados:
#[derive(Debug)]
pub
struct
MyError
(
String
);
impl
std
::fmt
::Display
for
MyError
{
fn
fmt
(
&
self
,
f
:&
mut
std
::fmt
::Formatter
<'
_
>
)
->
std
::fmt
::Result
{
write!
(
f
,
"{}"
,
self
.
0
)
}
}
impl
std
::error
::Error
for
MyError
{}
pub
fn
find_user
(
username
:&
str
)
->
Result
<
UserId
,
MyError
>
{
let
f
=
std
::fs
::File
::open
(
"/etc/passwd"
).
map_err
(
|
e
|
{
MyError
(
format!
(
"Failed to open password file: {:?}"
,
e
))
})
?
;
// ...
}
Por comodidad de , puede tener sentido implementar el rasgo From<String>
para permitir que los valores de cadena se conviertan fácilmente en instancias de MyError
(Tema 5):
impl
From
<
String
>
for
MyError
{
fn
from
(
msg
:String
)
->
Self
{
Self
(
msg
)
}
}
Cuando encuentre el operador interrogación (?
), el compilador aplicará automáticamente las implementaciones de rasgos From
pertinentes que sean necesarias para alcanzar el tipo de retorno de error de destino. Esto permite una mayor minimización:
pub
fn
find_user
(
username
:&
str
)
->
Result
<
UserId
,
MyError
>
{
let
f
=
std
::fs
::File
::open
(
"/etc/passwd"
)
.
map_err
(
|
e
|
format!
(
"Failed to open password file: {:?}"
,
e
))
?
;
// ...
}
La ruta del error cubre aquí los siguientes pasos:
-
File::open
devuelve un error de tipostd::io::Error
. -
format!
lo convierte en unString
, utilizando la implementaciónDebug
destd::io::Error
. -
?
hace que el compilador busque y utilice una implementación deFrom
que pueda llevarlo deString
aMyError
.
Errores anidados
El escenario alternativo es aquel en el que el contenido de los errores anidados es lo suficientemente importante como para que deba conservarse y ponerse a disposición de la persona que llama.
Considera una función de biblioteca que intenta devolver la primera línea de un archivo como una cadena, siempre que la línea no sea demasiado larga. Un momento de reflexión revela (al menos) tres tipos distintos de fallo que podrían producirse:
-
Puede que el archivo no exista o que sea inaccesible para la lectura.
-
El archivo puede contener datos de que no sean UTF-8 válidos y, por tanto, no puedan convertirse a
String
. -
Puede que el archivo tenga una primera línea demasiado larga.
De acuerdo con el punto 1, puedes utilizar el sistema de tipos para expresar y englobar todas estas posibilidades como enum
:
#[derive(Debug)]
pub
enum
MyError
{
Io
(
std
::io
::Error
),
Utf8
(
std
::string
::FromUtf8Error
),
General
(
String
),
}
Esta definición enum
incluye una derive(Debug)
, pero para satisfacer el rasgo Error
, también se necesita unaDisplay
implementación:
impl
std
::fmt
::Display
for
MyError
{
fn
fmt
(
&
self
,
f
:&
mut
std
::fmt
::Formatter
<'
_
>
)
->
std
::fmt
::Result
{
match
self
{
MyError
::Io
(
e
)
=>
write!
(
f
,
"IO error: {}"
,
e
),
MyError
::Utf8
(
e
)
=>
write!
(
f
,
"UTF-8 error: {}"
,
e
),
MyError
::General
(
s
)
=>
write!
(
f
,
"General error: {}"
,
s
),
}
}
}
También tiene sentido anular la implementación por defecto de source()
para acceder fácilmente a los errores anidados:
use
std
::error
::Error
;
impl
Error
for
MyError
{
fn
source
(
&
self
)
->
Option
<&
(
dyn
Error
+
'
static
)
>
{
match
self
{
MyError
::Io
(
e
)
=>
Some
(
e
),
MyError
::Utf8
(
e
)
=>
Some
(
e
),
MyError
::General
(
_
)
=>
None
,
}
}
}
El uso de enum
permite que la gestión de errores sea concisa y, al mismo tiempo, conserva toda la información del tipo en las distintas clases de error:
use
std
::io
::BufRead
;
// for `.read_until()`
/// Maximum supported line length.
const
MAX_LEN
:usize
=
1024
;
/// Return the first line of the given file.
pub
fn
first_line
(
filename
:&
str
)
->
Result
<
String
,
MyError
>
{
let
file
=
std
::fs
::File
::open
(
filename
).
map_err
(
MyError
::Io
)
?
;
let
mut
reader
=
std
::io
::BufReader
::new
(
file
);
// (A real implementation could just use `reader.read_line()`)
let
mut
buf
=
vec!
[];
let
len
=
reader
.
read_until
(
b'\n'
,
&
mut
buf
).
map_err
(
MyError
::Io
)
?
;
let
result
=
String
::from_utf8
(
buf
).
map_err
(
MyError
::Utf8
)
?
;
if
result
.
len
()
>
MAX_LEN
{
return
Err
(
MyError
::General
(
format!
(
"Line too long: {}"
,
len
)));
}
Ok
(
result
)
}
También es una buena idea implementar el rasgo From
para todos los tipos de suberror(Tema 5):
impl
From
<
std
::io
::Error
>
for
MyError
{
fn
from
(
e
:std
::io
::Error
)
->
Self
{
Self
::Io
(
e
)
}
}
impl
From
<
std
::string
::FromUtf8Error
>
for
MyError
{
fn
from
(
e
:std
::string
::FromUtf8Error
)
->
Self
{
Self
::Utf8
(
e
)
}
}
Esto evita que los propios usuarios de la biblioteca sufran bajo las reglas de orfandad: no se les permite implementarFrom
en MyError
, porque tanto el trait como el struct son externos a ellos.
Mejor aún, la implementación de From
permite aún más concisión, porque el operador de signo de interrogación realizará automáticamente cualquier conversión necesaria de From
, eliminando la necesidad de .map_err()
:
use
std
::io
::BufRead
;
// for `.read_until()`
/// Maximum supported line length.
pub
const
MAX_LEN
:usize
=
1024
;
/// Return the first line of the given file.
pub
fn
first_line
(
filename
:&
str
)
->
Result
<
String
,
MyError
>
{
let
file
=
std
::fs
::File
::open
(
filename
)
?
;
// `From<std::io::Error>`
let
mut
reader
=
std
::io
::BufReader
::new
(
file
);
let
mut
buf
=
vec!
[];
let
len
=
reader
.
read_until
(
b'\n'
,
&
mut
buf
)
?
;
// `From<std::io::Error>`
let
result
=
String
::from_utf8
(
buf
)
?
;
// `From<string::FromUtf8Error>`
if
result
.
len
()
>
MAX_LEN
{
return
Err
(
MyError
::General
(
format!
(
"Line too long: {}"
,
len
)));
}
Ok
(
result
)
}
Escribir un tipo de error completo puede implicar una buena cantidad de repeticiones, lo que lo convierte en un buen candidato para la automatización mediante una macroderive
(Tema 28). Sin embargo, no es necesario que escribas tú mismo dicha macro:considera la posibilidad de utilizar la caja thiserror
de David Tolnay, que proporciona una implementación de alta calidad y ampliamente utilizada de dicha macro. El código generado porthiserror
también tiene cuidado de evitar que los tipos de thiserror
sean visibles en la API generada, lo que a su vez significa que las preocupaciones asociadas al punto 24 no son aplicables.
Objetos Rasgo
El primer enfoque de los errores anidados desechaba todos los detalles de los suberrores, limitándose a conservar algunas cadenas de salida (format!("{:?}", err)
). El segundo enfoque conservaba la información completa del tipo de todos los posibles suberrores, pero requería una enumeración completa de todos los posibles tipos de suberror.
Esto plantea la pregunta: ¿Existe un término medio entre estos dos enfoques, que preserve la información de los suberrores sin necesidad de incluir manualmente todos los tipos de error posibles?
Codificar la información del suberror como un objeto de rasgo evita la necesidad de una variante de enum
para cada posibilidad, pero borra los detalles de los tipos de error específicos subyacentes. El receptor de un objeto de este tipo tendría acceso a los métodos del rasgo Error
y a sus límites de rasgo -source()
, Display::fmt()
, y Debug::fmt()
, a su vez - pero no conocería el tipo estático original del suberror:
Resulta que esto es posible, pero es sorprendentemente sutil. Parte de la dificultad proviene de las restricciones de seguridad de los objetos trait(Tema 12), pero también entran en juego las reglas de coherencia de Rust, que (a grandes rasgos) dicen que puede haber como máximo una implementación de un trait para un tipo.
Ingenuamente, cabría esperar que un tipo putativo de WrappedError
pusiera en práctica ambascosas:
-
El rasgo
Error
, porque es un error en sí mismo. -
El rasgo
From<Error>
, para permitir que los suberrores se envuelvan fácilmente.
Eso significa que se puede crear un WrappedError
from
internoWrappedError
, ya que WrappedError
implementa Error
, y eso choca con la implementación reflexiva general de From
:
error[E0119]: conflicting implementations of trait `From<WrappedError>` for type `WrappedError` --> src/main.rs:279:5 | 279 | impl<E: 'static + Error> From<E> for WrappedError { | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | = note: conflicting implementation in crate `core`: - impl<T> From<T> for T;
David Tolnay anyhow
es una crate que ya ha resuelto estos problemas (añadiendo un nivel adicional de indirección a través de Box
) y que, además, añade otras funciones útiles (como las trazas de pila). Como resultado, se está convirtiendo rápidamente en la recomendación estándar para el tratamiento de errores, una recomendación que secundamos aquí: considera el uso del crate anyhow
para el tratamiento de errores en las aplicaciones.
Bibliotecas frente a aplicaciones
El último consejo de la sección anterior incluía el calificativo "...para la gestión de errores en las aplicaciones", porque a menudo hay una distinción entre el código que se escribe para reutilizarlo en una biblioteca y el código que forma una aplicación de nivel superior.11
El código que se escribe para una biblioteca no puede predecir el entorno en el que se utiliza el código, por lo que es preferible emitir información de error concreta y detallada y dejar que la persona que llama averigüe cómo utilizar esa información. Esto se inclina hacia los errores anidados al estilo de enum
descritos anteriormente (y también evita una dependencia de anyhow
en la API pública de la biblioteca, véase el Tema 24).
Sin embargo, el código de la aplicación suele tener que concentrarse más en cómo presentar los errores al usuario. También tiene que hacer frente potencialmente a todos los tipos de error diferentes emitidos por todas las bibliotecas que están presentes en su gráfico de dependencias(Tema 25). Por ello, un tipo de error más dinámico (comoanyhow::Error
) hace que la gestión de errores sea más sencilla y coherente en toda la aplicación.
Cosas para recordar
-
El rasgo estándar
Error
requiere poco de ti, así que prefiere implementarlo para tus tipos de error. -
Cuando trates con tipos de error subyacentes heterogéneos, decide si es necesario conservar esos tipos.
-
Si no es así, considera la posibilidad de utilizar
anyhow
para envolver suberrores en el código de la aplicación. -
Si es así, codifícalos en un
enum
y proporciona las conversiones. Considera la posibilidad de utilizarthiserror
como ayuda.
-
-
Considera la posibilidad de utilizar la caja
anyhow
para una cómoda gestión idiomática de errores en el código de la aplicación. -
Es tu decisión, pero decidas lo que decidas, codifícalo en el sistema de tipos(Tema 1).
Tema 5: Comprender las conversiones de tipo
Conversiones de tipo de óxido se dividen en tres categorías:
- Manual
-
Conversiones de tipo definidas por el usuario mediante la implementación de los rasgos
From
yInto
- Semiautomático
-
Elaboraciones explícitas entre valores utilizando la palabra clave
as
- Automático
-
Coerción implícita en un nuevo tipo
La mayor parte de este Tema se centra en la primera de ellas, las conversiones manuales de tipos, porque las dos últimas no se aplican a las conversiones de tipos definidos por el usuario. Hay un par de excepciones, por lo que las secciones del final del Tema tratan sobre el moldeado y la coerción, incluyendo cómo pueden aplicarse a un tipo definido por el usuario.
Ten en cuenta que, a diferencia de muchos lenguajes antiguos, Rust no realiza conversiones automáticas entre tipos numéricos. Esto se aplica incluso a las transformaciones "seguras" de tipos integrales:
error[E0308]: mismatched types --> src/main.rs:70:18 | 70 | let y: u64 = x; | --- ^ expected `u64`, found `u32` | | | expected due to this | help: you can convert a `u32` to a `u64` | 70 | let y: u64 = x.into(); | +++++++
Conversiones de tipo definidas por el usuario
Como ocurre con otras características del lenguaje(Tema 10), la capacidad de realizar conversiones entre valores de distintos tipos definidos por el usuario se encapsula como un rasgo estándar -o, mejor dicho, como un conjunto de rasgos genéricos relacionados-.
Los cuatro rasgos relevantes de que expresan la capacidad de convertir valores de un tipo sonlos siguientes:
From<T>
-
Los elementos de este tipo pueden construirse a partir de elementos del tipo
T
, y la conversión siempre tiene éxito. TryFrom<T>
-
Los elementos de este tipo pueden construirse a partir de elementos del tipo
T
, pero es posible que la conversión no tenga éxito. Into<T>
-
Los elementos de este tipo pueden convertirse en elementos del tipo
T
, y la conversión siempre tiene éxito. TryInto<T>
-
Los elementos de este tipo pueden convertirse en elementos del tipo
T
, pero es posible que la conversión no tenga éxito.
Dada la discusión del punto 1 sobre expresar cosas en el sistema de tipos, no sorprende descubrir que la diferencia con las variantes de Try...
es que el único método de rasgo devuelve un Result
en lugar de un nuevo elemento garantizado. Las definiciones de rasgo Try...
también requieren un tipo asociado que dé el tipo del error E
emitido para situaciones de fallo.
Por tanto, el primer consejo es implementar (sólo) el rasgo Try...
si es posible que falle una conversión, de acuerdo con el punto 4. La alternativa es ignorar la posibilidad de error (por ejemplo, con.unwrap()
), pero esa debe ser una elección deliberada, y en la mayoría de los casos es mejor dejar esa elección al que llama.
Los rasgos de conversión de tipos tienen una simetría obvia: si un tipo T
puede transformarse into
un tipo U
(a través de Into<U>
), ¿no es lo mismo que sea posible crear un elemento de tipo U
transformando from
un elemento de tipo T
(a través deFrom<T>
)?
Esto es así, y nos lleva al segundo consejo: implementa el rasgo From
para las conversiones. La biblioteca estándar de Rust tuvo que elegir sólo una de las dos posibilidades, para evitar que el sistema girara en círculos vertiginosos,12 y se decantó por proporcionar automáticamente Into
a partir de una implementación de From
.
Si estás consumiendo uno de estos dos rasgos, como un rasgo ligado a un nuevo genérico propio, entonces el consejo es el inverso:utiliza el rasgo Into
para los rasgos ligados. De este modo, el límite será satisfecho tanto por cosas que implementen directamente Into
como por cosas que sólo implementen directamente From
.
Esta conversión automática se destaca en la documentación de From
y Into
, pero también merece la pena leer la parte correspondiente del código de la biblioteca estándar, que es una implementación de rasgos generalizada:
impl
<
T
,
U
>
Into
<
U
>
for
T
where
U
:From
<
T
>
,
{
fn
into
(
self
)
->
U
{
U
::from
(
self
)
}
}
Traducir a palabras la especificación de un rasgo puede ayudar a comprender límites de rasgo más complejos. En este caso, es bastante sencillo: "Puedo implementar Into<U>
para un tipo T
siempre que U
ya implemente From<T>
."
La biblioteca estándar también incluye varias implementaciones de estos rasgos de conversión para tipos de la biblioteca estándar. Como era de esperar, existen implementaciones de From
para conversiones integrales en las que el tipo de destino incluye todos los valores posibles del tipo de origen (From<u32> for u64
), e implementaciones de TryFrom
cuando el origen puede no caber en el destino (TryFrom<u64> for u32
).
Además de la versión Into
mostrada anteriormente, existen otras implementaciones de rasgos generales, principalmente para los tipos de puntero inteligente, que permiten que el puntero inteligente se construya automáticamente a partir de una instancia del tipo que contiene. Esto significa que los métodos genéricos que aceptan parámetros de puntero inteligente también pueden invocarse con elementos simples; más sobre esto próximamente y en el Tema 8.
El rasgo TryFrom
también tiene una implementación general para cualquier tipo que ya implemente el rasgo Into
en la direcciónopuesta, lo que incluye automáticamente (como se ha mostrado anteriormente) cualquier tipo que implemente From
en la misma dirección. En otras palabras, si puedes convertir infaliblemente un T
en un U
, también puedes obtener infaliblemente un U
a partir de unT
; como esta conversión siempre tendrá éxito, el tipo de error asociado es el útilmente denominado Infallible
.13
En también destaca una implementación genérica muy específica de From
, la implementación reflexiva:
impl
<
T
>
From
<
T
>
for
T
{
fn
from
(
t
:T
)
->
T
{
t
}
}
Traducido a palabras, esto sólo dice que "dado un T
, puedo obtener un T
." Es un "bueno, duh" tan obvio que merece la pena pararse a entender por qué es útil.
Considera un simple newtype struct
(Elemento 6) y una función que opere sobre él (ignorando que esta función se expresaría mejor como un método):
/// Integer value from an IANA-controlled range.
#[derive(Clone, Copy, Debug)]
pub
struct
IanaAllocated
(
pub
u64
);
/// Indicate whether value is reserved.
pub
fn
is_iana_reserved
(
s
:IanaAllocated
)
->
bool
{
s
.
0
==
0
||
s
.
0
==
65535
}
Esta función puede invocarse con instancias de struct
:
let
s
=
IanaAllocated
(
1
);
println!
(
"{:?} reserved? {}"
,
s
,
is_iana_reserved
(
s
));
// output: "IanaAllocated(1) reserved? false"
pero aunque se implemente From<u64>
para la envoltura newtype:
impl
From
<
u64
>
for
IanaAllocated
{
fn
from
(
v
:u64
)
->
Self
{
Self
(
v
)
}
}
la función no puede invocarse directamente para los valores u64
:
error[E0308]: mismatched types --> src/main.rs:77:25 | 77 | if is_iana_reserved(42) { | ---------------- ^^ expected `IanaAllocated`, found integer | | | arguments to this function are incorrect | note: function defined here --> src/main.rs:7:8 | 7 | pub fn is_iana_reserved(s: IanaAllocated) -> bool { | ^^^^^^^^^^^^^^^^ ---------------- help: try wrapping the expression in `IanaAllocated` | 77 | if is_iana_reserved(IanaAllocated(42)) { | ++++++++++++++ +
Sin embargo, una versión genérica de la función que acepta (y convierte explícitamente) cualquier cosa que satisfaga Into<IanaAllocated>
:
pub
fn
is_iana_reserved
<
T
>
(
s
:T
)
->
bool
where
T
:Into
<
IanaAllocated
>
,
{
let
s
=
s
.
into
();
s
.
0
==
0
||
s
.
0
==
65535
}
permite este uso:
if
is_iana_reserved
(
42
)
{
// ...
}
Con este vínculo de rasgo, la implementación reflexiva de rasgo de From<T>
tiene más sentido: significa que la función genérica se ocupa de elementos que ya son instancias de IanaAllocated
, sin necesidad de conversión.
Este patrón también explica por qué (y cómo) a veces parece que el código Rust realiza conversiones implícitas entre tipos: la combinación de las implementaciones de From<T>
y los límites de los rasgos de Into<T>
hace que el código parezca convertir mágicamente en el lugar de la llamada (pero sigue realizando conversiones seguras y explícitas bajo cuerda). Este patrón se vuelve aún más poderoso cuando se combina con tipos de referencia y sus rasgos de conversión relacionados; más información en el Tema 8.
Reparto
Rust incluye la palabra clave as
para realizar conversiones explícitas entre algunos pares de tipos.
Los pares de tipos que pueden convertirse de este modo constituyen un conjunto bastante limitado, y los únicos tipos definidos por el usuario que incluye son los enum
"tipo C" (los que sólo tienen un valor entero asociado). Sin embargo, se incluyen conversiones integrales generales, lo que supone una alternativa a into()
:
let
x
:u32
=
9
;
let
y
=
x
as
u64
;
let
z
:u64
=
x
.
into
();
La versión as
también permite conversiones con pérdida:14
let
x
:u32
=
9
;
let
y
=
x
as
u16
;
que sería rechazado por las versiones from
/into
:
error[E0277]: the trait bound `u16: From<u32>` is not satisfied --> src/main.rs:136:20 | 136 | let y: u16 = x.into(); | ^^^^ the trait `From<u32>` is not implemented for `u16` | = help: the following other types implement trait `From<T>`: <u16 as From<NonZeroU16>> <u16 as From<bool>> <u16 as From<u8>> = note: required for `u32` to implement `Into<u16>`
Por coherencia y seguridad de , deberías preferir las conversiones from
/ into
a los vaciados as
, a menos que entiendas y necesites la semántica precisa del vaciado (por ejemplo, para la interoperabilidad con C). Este consejo puede verse reforzado por Clippy(Tema 29), que incluye varias indicaciones sobre las conversionesas
; sin embargo, estas indicaciones están desactivadas por defecto.
Coacción
Las conversiones explícitas as
descritas en el apartado anterior son un superconjunto de lascoerciones implícitas que el compilador realizará silenciosamente: cualquier coerción puede forzarse con una as
explícita, pero lo contrario no es cierto. En particular, las conversiones integrales realizadas en la sección anterior no son coerciones, por lo que siempre requerirán as
.
La mayoría de las coerciones implican conversiones silenciosas de los tipos de puntero y referencia de de formas que sean sensatas y convenientes para el programador, como la conversión de lo siguiente:
-
Una referencia mutable a una referencia inmutable (así puedes utilizar un
&mut T
como argumento de una funciónque toma un&T
) -
Una referencia a un puntero sin procesar (esto no es
unsafe
- la inseguridad se produce en el momento en que eres lo suficientemente tonto como para desreferenciar un puntero sin procesar) -
Un cierre que resulta que no captura ninguna variable en un puntero de función desnudo(Elemento 2)
-
Un elemento concreto a un objeto rasgo, para un rasgo que el elemento concreto implementa
Sólo hay dos coerciones cuyo comportamiento puede verse afectado por los tipos definidos por el usuario. La primera ocurre cuando un tipo definido por el usuario implementa la coerción Deref
o el rasgo DerefMut
rasgo. Estos rasgos indican que el tipo definido por el usuario está actuando como un puntero inteligente de algún tipo(Elemento 8), y en este caso el compilador coaccionará una referencia al elemento puntero inteligente para que sea una referencia a un elemento del tipo que contiene el puntero inteligente (indicado por su rasgo Target
).
La segunda coerción de un tipo definido por el usuario se produce cuando un elemento concreto se convierte en un objeto trait. Esta operación construye un puntero gordo al elemento; este puntero es gordo porque incluye tanto un puntero a la ubicación del elemento en memoria como un puntero a la vtable para la implementación del rasgo del tipo concreto -ver Tema 8.
Tema 6: Adoptar el patrón newtype
El Tema 1 describía los structs de tupla, en los que los campos de un struct
no tienen nombre y en su lugar se hace referencia a ellos mediante un número (self.0
). Este Tema se centra en los structs de tupla que tienen una única entrada de algún tipo existente, creando así un nuevo tipo que puede contener exactamente el mismo rango de valores que el tipo adjunto. Este patrón está tan extendido en Rust que merece su propio Artículo y tiene su propio nombre: el patrón newtype.
El uso más sencillo del patrón newtype es indicar semántica adicional para un tipo, por encima de su comportamiento normal. Para ilustrarlo, imagina un proyecto que va a enviar un satélite a Marte.16 Es un proyecto grande, así que diferentes grupos han construido diferentes partes del proyecto. Un grupo se ha encargado del código de los motores del cohete:
/// Fire the thrusters. Returns generated impulse in pound-force seconds.
pub
fn
thruster_impulse
(
direction
:Direction
)
->
f64
{
// ...
return
42.0
;
}
mientras que otro grupo se encarga del sistema de guiado inercial:
/// Update trajectory model for impulse, provided in Newton seconds.
pub
fn
update_trajectory
(
force
:f64
)
{
// ...
}
Con el tiempo, estas diferentes partes deben unirse:
let
thruster_force
:f64
=
thruster_impulse
(
direction
);
let
new_direction
=
update_trajectory
(
thruster_force
);
Ruh-roh.17
Rust incluye una función de alias de tipo, que permite a los distintos grupos dejar más claras sus intenciones:
/// Units for force.
pub
type
PoundForceSeconds
=
f64
;
/// Fire the thrusters. Returns generated impulse.
pub
fn
thruster_impulse
(
direction
:Direction
)
->
PoundForceSeconds
{
// ...
return
42.0
;
}
/// Units for force.
pub
type
NewtonSeconds
=
f64
;
/// Update trajectory model for impulse.
pub
fn
update_trajectory
(
force
:NewtonSeconds
)
{
// ...
}
Sin embargo, los alias de tipo no son más que documentación; son una indicación más clara que los comentarios de la versión anterior, pero nada impide que se utilice un valor PoundForceSeconds
cuando se espera un valorNewtonSeconds
:
let
thruster_force
:PoundForceSeconds
=
thruster_impulse
(
direction
);
let
new_direction
=
update_trajectory
(
thruster_force
);
Ruh-roh una vez más.
Este es el punto en el que ayuda el patrón newtype:
/// Units for force.
pub
struct
PoundForceSeconds
(
pub
f64
);
/// Fire the thrusters. Returns generated impulse.
pub
fn
thruster_impulse
(
direction
:Direction
)
->
PoundForceSeconds
{
// ...
return
PoundForceSeconds
(
42.0
);
}
/// Units for force.
pub
struct
NewtonSeconds
(
pub
f64
);
/// Update trajectory model for impulse.
pub
fn
update_trajectory
(
force
:NewtonSeconds
)
{
// ...
}
Como su nombre indica, un newtype es un tipo nuevo y, como tal, el compilador se opone cuando hay un desajuste de tipos: aquí se intenta pasar un valor PoundForceSeconds
a algo que espera un valor NewtonSeconds
:
error[E0308]: mismatched types --> src/main.rs:76:43 | 76 | let new_direction = update_trajectory(thruster_force); | ----------------- ^^^^^^^^^^^^^^ expected | | `NewtonSeconds`, found `PoundForceSeconds` | | | arguments to this function are incorrect | note: function defined here --> src/main.rs:66:8 | 66 | pub fn update_trajectory(force: NewtonSeconds) { | ^^^^^^^^^^^^^^^^^ -------------------- help: call `Into::into` on this expression to convert `PoundForceSeconds` into `NewtonSeconds` | 76 | let new_direction = update_trajectory(thruster_force.into()); | +++++++
Como se describe en el punto 5, añadir una implementación del rasgo estándar From
:
impl
From
<
PoundForceSeconds
>
for
NewtonSeconds
{
fn
from
(
val
:PoundForceSeconds
)
->
NewtonSeconds
{
NewtonSeconds
(
4.448222
*
val
.
0
)
}
}
permite realizar la necesaria conversión de unidad y tipo con .into()
:
let
thruster_force
:PoundForceSeconds
=
thruster_impulse
(
direction
);
let
new_direction
=
update_trajectory
(
thruster_force
.
into
());
La misma pauta de utilizar un nuevo tipo para marcar la semántica adicional de "unidad" de un tipo también puede ayudar a que los argumentos puramente booleanos sean menos ambiguos. Volviendo al ejemplo del punto 1, el uso de los tipos nuevos aclara el significado de los argumentos:
struct
DoubleSided
(
pub
bool
);
struct
ColorOutput
(
pub
bool
);
fn
print_page
(
sides
:DoubleSided
,
color
:ColorOutput
)
{
// ...
}
print_page
(
DoubleSided
(
true
),
ColorOutput
(
false
));
Si la eficiencia de tamaño o la compatibilidad binaria son una preocupación, el atributo#[repr(transparent)]
garantiza que un newtype tenga la misma representación en memoria que el tipo interno.
Ése es el uso simple de newtype, y es un ejemplo específico de la semántica de codificación del punto 1en el sistema de tipos, de modo que el compilador se encarga de vigilar esa semántica.
Eludir la regla de orfandad de los rasgos
El otro escenariocomún, pero más sutil, que requiere el patrón newtype gira en torno a la regla de orfandad de Rust. A grandes rasgos, dice que un crate puede implementar un rasgo para un tipo sólo si se cumple una de las siguientes condiciones:
-
La caja ha definido el rasgo
-
El cajón ha definido el tipo
Intentar implementar un rasgo ajeno para un tipo ajeno:
conduce a un error del compilador (que a su vez señala el camino de vuelta a newtypes):
error[E0117]: only traits defined in the current crate can be implemented for types defined outside of the crate --> src/main.rs:146:1 | 146 | impl fmt::Display for rand::rngs::StdRng { | ^^^^^^^^^^^^^^^^^^^^^^------------------ | | | | | `StdRng` is not defined in the current crate | impl doesn't use only types from inside the current crate | = note: define and implement a trait or new type instead
La razón de esta restricción se debe al riesgo de ambigüedad: si dos cajas diferentes en el gráfico de dependencias(punto 25) fueran ambas a (digamos) impl std::fmt::Display for rand::rngs::StdRng
, entonces el compilador/enlazador no tiene forma de elegir entre ellas.
Con frecuencia, esto puede llevar a la frustración: por ejemplo, si intentas serializar datos que incluyen un tipo de otro crate, la regla de orfandad te impide escribir impl serde::Serialize for somecrate::SomeType
.18
Pero el patrón newtype significa que estás definiendo un nuevo tipo, que forma parte de la caja actual, por lo que se aplica la segunda parte de la regla del rasgo huérfano. Ahora es posible implementar un rasgo ajeno:
struct
MyRng
(
rand
::rngs
::StdRng
);
impl
fmt
::Display
for
MyRng
{
fn
fmt
(
&
self
,
f
:&
mut
fmt
::Formatter
<'
_
>
)
->
Result
<
(),
fmt
::Error
>
{
write!
(
f
,
"<MyRng instance>"
)
}
}
Limitaciones de los nuevos tipos
El patrón newtype resuelve estas dos clases de problemas -evita las conversiones de unidades y elude la regla de orfandad-, pero conlleva cierta incomodidad: toda operación que implique al newtype debe reenviarse al tipo interno.
A nivel trivial, eso significa que el código tiene que utilizar thing.0
en todo el código, en lugar de sólo thing
, pero eso es fácil, y el compilador te dirá dónde es necesario.
El inconveniente más importante es que se pierde cualquier implementación de rasgos en el tipo interno: como el newtype es un tipo nuevo, no se aplica la implementación interna existente.
En el caso de los rasgos derivables, esto sólo significa que la declaración newtype acaba con un montón de derive
s:
#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd)]
pub
struct
NewType
(
InnerType
);
Sin embargo, en el caso de rasgos más sofisticados, es necesario algún tipo de código de reenvío para recuperar la implementación del tipo interno, por ejemplo :
use
std
::fmt
;
impl
fmt
::Display
for
NewType
{
fn
fmt
(
&
self
,
f
:&
mut
fmt
::Formatter
<'
_
>
)
->
Result
<
(),
fmt
::Error
>
{
self
.
0.
fmt
(
f
)
}
}
Tema 7: Utilizar constructores para tipos complejos
Este Artículo describe el patrón constructor, en el que las estructuras de datos complejas tienen un tipo constructor asociado que facilita a los usuarios la creación de instancias de laestructura de datos.
Rust insiste en que todos los campos de un struct
deben rellenarse cuando se crea una nueva instancia de ese struct
. Esto mantiene el código seguro, garantizando que nunca haya valores no inicializados, pero da lugar a un código repetitivo más verboso de lo que sería ideal.
Por ejemplo, cualquier campo opcional tiene que estar explícitamente marcado como ausente con None
:
/// Phone number in E164 format.
#[derive(Debug, Clone)]
pub
struct
PhoneNumberE164
(
pub
String
);
#[derive(Debug, Default)]
pub
struct
Details
{
pub
given_name
:String
,
pub
preferred_name
:Option
<
String
>
,
pub
middle_name
:Option
<
String
>
,
pub
family_name
:String
,
pub
mobile_phone
:Option
<
PhoneNumberE164
>
,
}
// ...
let
dizzy
=
Details
{
given_name
:"Dizzy"
.
to_owned
(),
preferred_name
:None
,
middle_name
:None
,
family_name
:"Mixer"
.
to_owned
(),
mobile_phone
:None
,
};
Este código repetitivo también es frágil, en el sentido de que un cambio futuro que añada un nuevo campo a la struct
requiere una actualización en cada lugar que construye la estructura.
La repetición puede reducirse significativamente implementando y utilizando el rasgoDefault
como se describe enel punto 10:
let
dizzy
=
Details
{
given_name
:"Dizzy"
.
to_owned
(),
family_name
:"Mixer"
.
to_owned
(),
..
Default
::default
()
};
Utilizar Default
también ayuda a reducir los cambios necesarios cuando se añade un nuevo campo, siempre que el nuevo campo sea a su vez de un tipo que implemente Default
.
Se trata de un problema más general: la implementación derivada automática de Default
sólo funciona si todos los tipos de campo implementan el rasgo Default
. Si hay un campo que no sigue el juego, el paso derive
no funciona:
error[E0277]: the trait bound `Date: Default` is not satisfied --> src/main.rs:48:9 | 41 | #[derive(Debug, Default)] | ------- in this derive macro expansion ... 48 | pub date_of_birth: time::Date, | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `Default` is not | implemented for `Date` | = note: this error originates in the derive macro `Default`
El código no puede implementar Default
para chrono::Utc
debido a la regla de orfandad; pero aunque pudiera, no sería útil: utilizar un valor por defecto para la fecha de nacimiento va a ser incorrecto casi siempre.
La ausencia de Default
significa que todos los campos deben rellenarse manualmente:
let
bob
=
Details
{
given_name
:"Robert"
.
to_owned
(),
preferred_name
:Some
(
"Bob"
.
to_owned
()),
middle_name
:Some
(
"the"
.
to_owned
()),
family_name
:"Builder"
.
to_owned
(),
mobile_phone
:None
,
date_of_birth
:time
::Date
::from_calendar_date
(
1998
,
time
::Month
::November
,
28
,
)
.
unwrap
(),
last_seen
:None
,
};
Esta ergonomía puede mejorarse si implementas el patrón constructor para estructuras de datos complejas.
La variante más sencilla del patrón constructor es un struct
independiente que contiene la información necesaria para construir el elemento. Para simplificar, el ejemplo contendrá una instancia del propio elemento:
pub
struct
DetailsBuilder
(
Details
);
impl
DetailsBuilder
{
/// Start building a new [`Details`] object.
pub
fn
new
(
given_name
:&
str
,
family_name
:&
str
,
date_of_birth
:time
::Date
,
)
->
Self
{
DetailsBuilder
(
Details
{
given_name
:given_name
.
to_owned
(),
preferred_name
:None
,
middle_name
:None
,
family_name
:family_name
.
to_owned
(),
mobile_phone
:None
,
date_of_birth
,
last_seen
:None
,
})
}
}
El tipo constructor puede entonces equiparse con métodos ayudantes que rellenen los campos del elemento naciente. Cada uno de estos métodos consume self
pero emite un nuevo Self
, lo que permite encadenar distintos métodos de construcción:
/// Set the preferred name.
pub
fn
preferred_name
(
mut
self
,
preferred_name
:&
str
)
->
Self
{
self
.
0.
preferred_name
=
Some
(
preferred_name
.
to_owned
());
self
}
/// Set the middle name.
pub
fn
middle_name
(
mut
self
,
middle_name
:&
str
)
->
Self
{
self
.
0.
middle_name
=
Some
(
middle_name
.
to_owned
());
self
}
Estos métodos de ayuda pueden ser más útiles que los simples definidores:
/// Update the `last_seen` field to the current date/time.
pub
fn
just_seen
(
mut
self
)
->
Self
{
self
.
0.
last_seen
=
Some
(
time
::OffsetDateTime
::now_utc
());
self
}
El método final que se invoca para el constructor consume el constructor y emite el elemento construido:
/// Consume the builder object and return a fully built [`Details`]
/// object.
pub
fn
build
(
self
)
->
Details
{
self
.
0
}
En general, esto permite a los clientes del constructor tener unaexperiencia de construcción más ergonómica:
let
also_bob
=
DetailsBuilder
::new
(
"Robert"
,
"Builder"
,
time
::Date
::from_calendar_date
(
1998
,
time
::Month
::November
,
28
)
.
unwrap
(),
)
.
middle_name
(
"the"
)
.
preferred_name
(
"Bob"
)
.
just_seen
()
.
build
();
La naturaleza omnipresente de este estilo de constructor conlleva un par de inconvenientes. La primera es que separar las fases del proceso de construcción no puede hacerse por sí solo:
error[E0382]: use of moved value: `builder` --> src/main.rs:256:15 | 247 | let builder = DetailsBuilder::new( | ------- move occurs because `builder` has type `DetailsBuilder`, | which does not implement the `Copy` trait ... 254 | builder.preferred_name("Bob"); | --------------------- `builder` moved due to this method | call 255 | }
256 | let bob = builder.build(); | ^^^^^^^ value used here after move | note: `DetailsBuilder::preferred_name` takes ownership of the receiver `self`, which moves `builder` --> src/main.rs:60:35 | 27 | pub fn preferred_name(mut self, preferred_name: &str) -> Self { | ^^^^
Esto puede solucionarse asignando de nuevo el constructor consumido a la mismavariable:
let
mut
builder
=
DetailsBuilder
::new
(
"Robert"
,
"Builder"
,
time
::Date
::from_calendar_date
(
1998
,
time
::Month
::November
,
28
)
.
unwrap
(),
);
if
informal
{
builder
=
builder
.
preferred_name
(
"Bob"
);
}
let
bob
=
builder
.
build
();
El otro inconveniente de la naturaleza omnipresente de este constructor es que sólo se puede construir un elemento; intentar crear varias instancias llamando repetidamente a build()
en el mismo constructor es una falta del compilador, como cabría esperar:
error[E0382]: use of moved value: `smithy` --> src/main.rs:159:39 | 154 | let smithy = DetailsBuilder::new( | ------ move occurs because `smithy` has type `base::DetailsBuilder`, | which does not implement the `Copy` trait ... 159 | let clones = vec![smithy.build(), smithy.build(), smithy.build()]; | ------- ^^^^^^ value used here after move | | | `smithy` moved due to this method call
Un enfoque alternativo es que los métodos del constructor tomen un &mut self
y emitan un &mut Self
:
/// Update the `last_seen` field to the current date/time.
pub
fn
just_seen
(
&
mut
self
)
->
&
mut
Self
{
self
.
0.
last_seen
=
Some
(
time
::OffsetDateTime
::now_utc
());
self
}
Esto elimina la necesidad de autoasignación en etapas de construcción separadas:
let
mut
builder
=
DetailsBuilder
::new
(
"Robert"
,
"Builder"
,
time
::Date
::from_calendar_date
(
1998
,
time
::Month
::November
,
28
)
.
unwrap
(),
);
if
informal
{
builder
.
preferred_name
(
"Bob"
);
// no `builder = ...`
}
let
bob
=
builder
.
build
();
Sin embargo, esta versión hace imposible encadenar la construcción del constructor junto con la invocación de sus métodos setter:
error[E0716]: temporary value dropped while borrowed --> src/main.rs:265:19 | 265 | let builder = DetailsBuilder::new( | ___________________^ 266 | | "Robert", 267 | | "Builder", 268 | | time::Date::from_calendar_date(1998, time::Month::November, 28) 269 | | .unwrap(), 270 | | ) | |_____^ creates a temporary value which is freed while still in use 271 | .middle_name("the") 272 | .just_seen(); | - temporary value is freed at the end of this statement 273 | let bob = builder.build(); | --------------- borrow later used here | = note: consider using a `let` binding to create a longer lived value
let
Como indica el error del compilador , puedes solucionarlo haciendo que el elemento constructor tenga un nombre:
let
mut
builder
=
DetailsBuilder
::new
(
"Robert"
,
"Builder"
,
time
::Date
::from_calendar_date
(
1998
,
time
::Month
::November
,
28
)
.
unwrap
(),
);
builder
.
middle_name
(
"the"
).
just_seen
();
if
informal
{
builder
.
preferred_name
(
"Bob"
);
}
let
bob
=
builder
.
build
();
Esta variante de constructor mutante también permite construir varios elementos. La firma del método build()
no debe consumir self
, por lo que debe ser como sigue:
/// Construct a fully built [`Details`] object.
pub
fn
build
(
&
self
)
->
Details
{
// ...
}
La implementación de este método build()
repetible tiene entonces que construir un elemento nuevo en cada invocación. Si el elemento subyacente implementa Clone
, esto es fácil: el constructor puede mantener una plantilla y clone()
para cada construcción. Si el elemento subyacente no implementa Clone
, entonces el constructor necesita tener suficiente estado para poder construir manualmente una instancia del elemento subyacente en cada llamada a build()
.
Con cualquier estilo de patrón constructor, el código repetitivo se limita ahora a un lugar -el constructor- en lugar de ser necesario en cada lugar que utilice el tipo subyacente.
El boilerplate que queda puede reducirse potencialmente aún más mediante el uso de una macro(punto 28), pero si sigues este camino, también debes comprobar si hay una crate existente (como la crate derive_builder
en particular) que proporcione lo que necesitas, suponiendo que estés dispuesto a depender de ella en (punto 25).
Tema 8: Familiarízate conlos tipos de referenciay puntero
Para la programación en general, una referencia es una forma de acceder indirectamente a alguna estructura de datos, independientemente de la variable que posea esa estructura de datos. En la práctica, suele implementarse como un puntero: un número cuyo valor es la dirección en memoria de la estructura de datos.
Una CPU moderna suele imponer algunas restricciones a los punteros: la dirección de memoria debe estar en un intervalo válido de memoria (ya sea virtual o física) y puede ser necesario que esté alineada (por ejemplo, un valor entero de 4 bytes puede ser accesible sólo si su dirección es múltiplo de 4).
Sin embargo, los lenguajes de programación de alto nivel suelen codificar más información sobre los punteros en sus sistemas de tipos. En los lenguajes derivados de C, incluido Rust, los punteros tienen un tipo que indica qué tipo de estructura de datos se espera que esté presente en la dirección de memoria apuntada. Esto permite al código interpretar el contenido de la memoria en esa dirección y en la memoria siguiente a esa dirección.
Este nivel básico de información sobre punteros -localización de memoria y disposición prevista de la estructura de datos- se representa en Rust como un puntero sin procesar. Sin embargo, el código Rust seguro no utiliza punteros en bruto, porque Rust proporciona tipos de referencia y puntero más ricos que proporcionan garantías y restricciones de seguridad adicionales. Estos tipos de referencia y puntero son el tema de este Tema; los punteros en bruto quedan relegados al Tema 16 (que trata del código unsafe
).
Referencias de óxido
El tipo tipo puntero más ubicuo en Rust es la referencia, con un tipo que se escribe como &T
para algún tipoT
. Aunque se trata de un valor de puntero encubierto, el compilador se asegura de que se respeten varias reglas en torno a su uso: siempre debe apuntar a una instancia válida y correctamente alineada del tipo correspondiente T
, cuyo tiempo de vida(Punto 14) se extiende más allá de su uso, y debe satisfacer las reglas de comprobación de préstamo(Punto 15). Estas restricciones adicionales siempre están implícitas en el término referencia en Rust, por lo que el término puntero desnudo suele ser poco frecuente.
La restricción de que una referencia Rust debe apuntar a un elemento válido y correctamente alineado es compartida por los tipos de referencia de C++. Sin embargo, C++ no tiene el concepto de tiempo de vida y, por tanto, permite estribos con referencias colgantes:19
Las comprobaciones de préstamo y tiempo de vida de Rust significan que el código equivalente ni siquiera compila:
error[E0515]: cannot return reference to local variable `x` --> src/main.rs:477:5 | 477 | &x | ^^ returns a reference to data owned by the current function
Una referencia Rust &T
permite el acceso de sólo lectura al elemento subyacente (aproximadamente equivalente a const T&
de C++ ). Una referencia mutable que también permite modificar el elemento subyacente se escribe como &mut T
y también está sujetaa las reglas de comprobación de préstamos comentadas en el Tema 15. Este patrón de nomenclatura refleja una mentalidad ligeramente diferente entre Rust y C++:
-
En Rust, la variante por defecto es de sólo lectura, y los tipos escribibles se marcan especialmente (con
mut
). -
En C++, la variante por defecto es escribible, y los tipos de sólo lectura se marcan especialmente (con
const
).
El compilador convierte el código Rust que utiliza referencias en código máquina que utiliza punteros simples, que tienen un tamaño de ocho bytes en una plataforma de 64 bits (que este Artículo asume en todo momento). Por ejemplo, un par de variables locales junto con referencias a ellas:
pub
struct
Point
{
pub
x
:u32
,
pub
y
:u32
,
}
let
pt
=
Point
{
x
:1
,
y
:2
};
let
x
=
0
u64
;
let
ref_x
=
&
x
;
let
ref_pt
=
&
pt
;
pueden acabar dispuestos en la pila como se muestra en la Figura 1-2.
Una referencia Rust puede referirse a elementos que se encuentran en la pila o en el montón.Rust asigna elementos en la pila por defecto, pero el tipo de puntero Box<T>
(aproximadamente equivalente al std::unique_ptr<T>
de C++) obliga a que la asignación se produzca en el montón, lo que a su vez significa que el elemento asignado puede superar el alcance del bloque actual. Bajo la cubierta, Box<T>
es también un simple valor de puntero de ocho bytes:
let
box_pt
=
Box
::new
(
Point
{
x
:10
,
y
:20
});
Esto se representa en la Figura 1-3.
Rasgos del puntero
Un método que espera un argumento de referencia como &Point
también puede ser alimentado con un &Box<Point>
:
fn
show
(
pt
:&
Point
)
{
println!
(
"({}, {})"
,
pt
.
x
,
pt
.
y
);
}
show
(
ref_pt
);
show
(
&
box_pt
);
(1, 2) (10, 20)
Esto es posible porque Box<T>
implementa el Deref
con Target = T
. Una implementación de este rasgo para algún tipo significa que el métododeref()
puede utilizarse para crear una referencia al tipo Target
. También existe un DerefMut
que emite una referencia mutable al tipo Target
.
Los rasgos Deref
/DerefMut
son algo especiales, porque el compilador de Rust tiene un comportamiento específico cuando trata con tipos que los implementan. Cuando el compilador encuentra una expresión de desreferencia (por ejemplo, *x
), busca y utiliza una implementación de uno de estos rasgos, dependiendo de si la desreferencia requiere acceso mutable o no.Esta coerción deDeref
permite que varios tipos de punteros inteligentes se comporten como referencias normales y es uno de los pocos mecanismos que permiten la conversión implícita de tipos en Rust (como se describe en el punto 5).
Como apunte técnico, merece la pena entender por qué los rasgos Deref
no pueden ser genéricos (Deref<Target>
) para el tipo de destino. Si lo fueran, entonces sería posible que algún tipo ConfusedPtr
implementara tanto Deref<TypeA>
como Deref<TypeB>
, y eso dejaría al compilador incapaz de deducir un único tipo único para una expresión como *x
. Así que, en su lugar, el tipo de destino se codifica como el tipo asociado denominadoTarget
.
Este inciso técnico contrasta con otros dos rasgos estándar de los punteros, el AsRef
y AsMut
. Estos rasgos no inducen un comportamiento especial en el compilador, sino que permiten conversiones a una referencia o a una referencia mutable mediante una llamada explícita a sus funciones de rasgo (as_ref()
yas_mut()
respectivamente). El tipo de destino de estas conversiones se codifica como un parámetro de tipo (por ejemplo, AsRef<Point>
), lo que significa que un único tipo de contenedor puede admitir varios destinos.
Por ejemplo, el tipo String
implementa el rasgoDeref
con Target = str
, lo que significa que una expresión como &my_string
puede coaccionarse al tipo &str
. Pero también implementa lo siguiente:
-
AsRef<[u8]>
permitiendo la conversión a una porción de byte&[u8]
-
AsRef<OsStr>
permitiendo la conversión a una cadena OS -
AsRef<Path>
permitiendo la conversión a una ruta del sistema de archivos -
AsRef<str>
, permitiendo la conversión a una rebanada de cadena&str
(como conDeref
)
Tipos de puntero gordo
Rust tiene dos tipos de punteros gordos incorporados: las rebanadas y los objetos trait. Son tipos que actúan como punteros pero contienen información adicional sobre aquello a lo que apuntan.
Rebanadas
El primer tipo de puntero gordo es la rebanada: una referencia a un subconjunto de alguna colección contigua de valores. Se construye a partir de un puntero simple (no propietario), junto con un campo de longitud, lo que hace que tenga el doble de tamaño que un puntero simple (16 bytes en una plataforma de 64 bits). El tipo de una porción se escribe como &[T]
-una referencia a [T]
, que es el tipo nocional de una colección contigua de valores de tipo T
.
El tipo nocional [T]
no puede instanciarse, pero hay dos contenedores comunes que lo encarnan. El primero es la matriz: una colección contigua de valores con un tamaño conocido en el momento de la compilación: una matriz con cinco valores siempre tendrá cinco valores. Por tanto, una porción puede referirse a un subconjunto de una matriz (como se muestra en la Figura 1-4):
let
array
:[
u64
;
5
]
=
[
0
,
1
,
2
,
3
,
4
];
let
slice
=
&
array
[
1
..
3
];
El otro contenedor común de valores contiguos es un Vec<T>
. Contiene una colección contigua de valores como una matriz, pero a diferencia de ésta, el número de valores en Vec
puede crecer (por ejemplo, con push(value)
) o reducirse (por ejemplo, con pop()
).
El contenido del Vec
se guarda en el montón (lo que permite esta variación de tamaño), pero siempre es contiguo, por lo que una rebanada puede referirse a un subconjunto de un vector, como se muestra en la Figura 1-5:
let
mut
vector
=
Vec
::<
u64
>
::with_capacity
(
8
);
for
i
in
0
..
5
{
vector
.
push
(
i
);
}
let
vslice
=
&
vector
[
1
..
3
];
La expresión &vector[1..3]
encierra muchas cosas, así que vale la pena desglosarla en sus componentes:
-
La parte
1..3
es una expresión de rango; el compilador la convierte en una instancia del tipoRange<usize>
que contiene un límite inferior inclusivo y un límite superior exclusivo. -
El tipo
Range
implementael rasgoSliceIndex<T>
trait, que describe operaciones de indexación en trozos de un tipo arbitrarioT
(por lo que el tipoOutput
es[T]
). -
La parte
vector[ ]
es una expresión de indexación; el compilador convierte en una invocación a la funciónIndex
métodoindex
envector
, junto con una desreferencia (es decir,*vector.index( )
).20 -
vector[1..3]
por tanto, invoca laimplementación deVec<T>
deIndex<I>
, que requiere queI
sea una instancia deSliceIndex<[u64]>
. Esto funciona porqueRange<usize>
implementaSliceIndex<[T]>
para cualquierT
, incluidou64
. -
&vector[1..3]
deshace la desreferencia, dando como resultado un tipo de expresión final de&[u64]
.
Objetos rasgo
El segundo tipo incorporado de puntero gordo es un objeto trait: una referencia a algún elemento que implementa un trait concreto. Se construye a partir de un puntero simple al elemento, junto con un puntero interno a la tabla virtual del tipo, lo que da un tamaño de 16 bytes (en una plataforma de 64 bits). La tabla v de la implementación de un rasgo en un tipo contiene punteros a funciones para cada una de las implementaciones de métodos, lo que permite el envío dinámico en tiempo de ejecución(elemento 12).21
Así que un rasgo sencillo:
trait
Calculate
{
fn
add
(
&
self
,
l
:u64
,
r
:u64
)
->
u64
;
fn
mul
(
&
self
,
l
:u64
,
r
:u64
)
->
u64
;
}
con un struct
que lo implemente:
struct
Modulo
(
pub
u64
);
impl
Calculate
for
Modulo
{
fn
add
(
&
self
,
l
:u64
,
r
:u64
)
->
u64
{
(
l
+
r
)
%
self
.
0
}
fn
mul
(
&
self
,
l
:u64
,
r
:u64
)
->
u64
{
(
l
*
r
)
%
self
.
0
}
}
let
mod3
=
Modulo
(
3
);
puede convertirse en un objeto trait de tipo &dyn Trait
. La palabra clavedyn
resalta el hecho de que se trata de un envío dinámico:
// Need an explicit type to force dynamic dispatch.
let
tobj
:&
dyn
Calculate
=
&
mod3
;
let
result
=
tobj
.
add
(
2
,
2
);
assert_eq!
(
result
,
1
);
La disposición de la memoria equivalente a se muestra en la Figura 1-6.
El código que contiene un objeto trait puede invocar los métodos del trait a través de los punteros de función de la vtable, pasando el puntero del elemento como parámetro &self
; consulta el Tema 12 para obtener más información y consejos.
Más rasgos de puntero
"Rasgos depuntero" describe dos pares de rasgos (Deref
/DerefMut
, AsRef
/AsMut
) que se utilizan cuando se trabaja con tipos que pueden convertirse fácilmente en referencias. Hay algunos rasgos estándar más que también pueden entrar en juego al trabajar con tipos tipo puntero, ya sean de la biblioteca estándar o definidos por el usuario.
El más sencillo de ellos es el Pointer
que formatea un valor de puntero para la salida. Esto puede ser útil para la depuración de bajo nivel, y el compilador buscará este trait automáticamente cuando encuentre el especificador de formato {:p}
.
Más intrigantes son los Borrow
y BorrowMut
que tienen un único método (borrow
yborrow_mut
respectivamente). Este métodotiene la misma firma que los métodos equivalentes de los rasgos AsRef
/AsMut
.
La diferencia clave en las intenciones entre estos rasgos es visible a través de las implementaciones globales que proporciona la biblioteca estándar. Dada una referencia Rust arbitraria &T
, existe una implementación general tanto de AsRef
como deBorrow
; del mismo modo, para una referencia mutable &mut T
, existe una implementación general tanto de AsMut
como de BorrowMut
.
Sin embargo, Borrow
también tiene una implementación general para tipos (sin referencia): impl<T> Borrow<T> for T
.
Esto significa que un método que acepte el rasgo Borrow
puede tratar tanto casos de T
como referencias aT
:
fn
add_four
<
T
:std
::borrow
::Borrow
<
i32
>>
(
v
:T
)
->
i32
{
v
.
borrow
()
+
4
}
assert_eq!
(
add_four
(
&
2
),
6
);
assert_eq!
(
add_four
(
2
),
6
);
Los tipos contenedores de la biblioteca estándar tienen usos más realistas de Borrow
. Por ejemplo,HashMap::get
utiliza Borrow
para permitir una recuperación cómoda de las entradas, tanto si están codificadas por valor como por referencia.
El rasgo ToOwned
se basa en el rasgo Borrow
y añade un método to_owned()
que produce un nuevo elemento propio del tipo subyacente. Se trata de una generalización del rasgo Clone
: donde Clone
requiere específicamente una referencia Rust &T
, ToOwned
se ocupa en cambio de cosas que implementan Borrow
.
Esto ofrece un par de posibilidades para tratar de forma unificada tanto las referencias como los elementos movidos:
-
Una función que opera sobre referencias a algún tipo puede aceptar
Borrow
para que también pueda ser llamada con elementos desplazados además de referencias. -
Una función que opere sobre elementos poseídos de algún tipo puede aceptar
ToOwned
, de modo que también puede ser llamada con referencias a elementos, así como con elementos movidos; cualquier referencia que se le pase se replicará en un elemento poseído localmente.
Aunque no es un tipo puntero, merece la pena mencionar el tipo Cow
merece la pena mencionarlo en este punto, porque proporciona una forma alternativa de tratar el mismo tipo de situación. Cow
es un enum
que puede contener datos propios o una referencia a datos prestados. Su peculiar nombre significa "clone-on-write" (clonar al escribir): una entrada Cow
puede permanecer como dato prestado hasta el momento en que haya que modificarlo, pero se convierte en una copia propia en el momento en que haya que alterar el dato.
Tipos de puntero inteligente
La biblioteca estándar de Rust incluye una variedad de tipos que actúan como punteros en un grado u otro, mediados por los rasgos de la biblioteca estándar descritos anteriormente. Cada uno de estos tipos de puntero inteligente viene con una semántica y unas garantías particulares, lo que tiene la ventaja de que la combinación adecuada de ellos puede proporcionar un control minucioso sobre el comportamiento del puntero, pero tiene el inconveniente de que los tipos resultantes pueden parecer abrumadores al principio (Rc<RefCell<Vec<T>>>
, ¿alguien?).
El primer tipo de puntero inteligente es Rc<T>
que es un puntero contado por referencia a un elemento (aproximadamente análogo al puntero C std::shared_ptr<T>
). Implementa todos los rasgos relacionados con los punteros y, por tanto, actúa como Box<T>
en muchos aspectos.
Esto es útil para estructuras de datos en las que se puede llegar al mismo elemento de distintas formas, pero elimina una de las reglas básicas de Rust sobre la propiedad: que cada elemento sólo tiene un propietario. Relajar esta regla significa que ahora es posible filtrar datos: si el elemento A tiene un puntero Rc
al elemento B, y el elemento B tiene un puntero Rc
a A, entonces el par nunca se abandonará.22 Dicho de otro modo: necesitas Rc
para soportar estructuras de datos cíclicas, pero el inconveniente es que ahora hay ciclos en tus estructuras de datos.
El riesgo de fugas puede reducirse en algunos casos mediante el tipo relacionado Weak<T>
que contiene una referencia no propietaria al elemento subyacente (más o menos análogo al tipo std::weak_ptr<T>
). Mantener una referencia débil no impide que se elimine el elemento subyacente (cuando se eliminan todas las referencias fuertes), por lo que hacer uso del Weak<T>
implica una actualización a un Rc<T>
-que puede fallar.
Bajo el capó, Rc
se implementa (actualmente) como un par de recuentos de referencia junto con el elemento referenciado, todo ello almacenado en el montón (como se muestra en la Figura 1-7):
use
std
::rc
::Rc
;
let
rc1
:Rc
<
u64
>
=
Rc
::new
(
42
);
let
rc2
=
rc1
.
clone
();
let
wk
=
Rc
::downgrade
(
&
rc1
);
El elemento subyacente se elimina cuando el recuento de referencias fuertes llega a cero, pero la estructura de contabilidad sólo se elimina cuando el recuento de referencias débiles también llega a cero.
Un Rc
por sí solo te da la posibilidad de llegar a un elemento de distintas formas, pero cuando llegas a ese elemento, sólo puedes modificarlo (mediante get_mut
) sólo si no hay otras formas de llegar al elemento, es decir, si no existen otras referencias Rc
o Weak
al mismo elemento. Esto es difícil de arreglar, por lo que Rc
se combina a menudo con RefCell
.
El siguiente tipo de puntero inteligente RefCell<T>
relaja la regla(Tema 15) de que un elemento sólo puede ser mutado por su propietario o por el código que posee la (única) referencia mutable al elemento. Esta mutabilidad interior permite una mayor flexibilidad, por ejemplo, permitiendo implementaciones de rasgos que mutan elementos internos incluso cuando la firma del método sólo permite &self
. Sin embargo, también conlleva costes: además de la sobrecarga de almacenamiento adicional (un isize
adicional para realizar un seguimiento de los préstamos actuales, como se muestra en la Figura 1-8), las comprobaciones normales de los préstamos se trasladan del tiempo de compilación al tiempo de ejecución:
use
std
::cell
::RefCell
;
let
rc
:RefCell
<
u64
>
=
RefCell
::new
(
42
);
let
b1
=
rc
.
borrow
();
let
b2
=
rc
.
borrow
();
La naturaleza en tiempo de ejecución de estas comprobaciones significa que el usuario de RefCell
tiene que elegir entre dos opciones, ninguna agradable:
-
Acepta que pedir prestado es una operación de que puede fallar, y haz frente a
Result
valores detry_borrow[_mut]
-
Utiliza los métodos de préstamo supuestamente infalibles
borrow[_mut]
, y acepta el riesgo de unpanic!
en tiempo de ejecución(Tema 18) si no se han cumplido las normas de préstamo
En cualquier caso, esta comprobación en tiempo de ejecución significa que la propia RefCell
no implementa ninguno de los rasgos estándar de los punteros; en su lugar, sus operaciones de acceso devuelven un puntero Ref<T>
o RefMut<T>
que sí implementa esos rasgos.
Si el tipo subyacente T
implementa el rasgo Copy
(que indica que una copia rápida bit a bit produce un elemento válido; véase el punto 10), entonces el tipo Cell<T>
permite la mutación interior con menos sobrecarga: el método get(&self)
copia el valor actual y el método set(&self, val)
copia un nuevo valor. El tipo Cell
se utiliza internamente en las implementaciones de Rc
yRefCell
, para el seguimiento compartido de contadores que pueden mutarse sin &mut self
.
Los tipos de punteros inteligentes descritos hasta ahora sólo son adecuados para su uso con un único hilo; sus implementaciones asumen que no hay acceso concurrente a sus partes internas. Si no es así, se necesitan punteros inteligentes que incluyan una sobrecarga de sincronización adicional.
El equivalente seguro para hilos de Rc<T>
es Arc<T>
que utiliza contadores atómicos para garantizar la exactitud del recuento de referencias. Al igual que Rc
, Arc
implementa todos los rasgos relacionados con los punteros.
Sin embargo, Arc
por sí solo no permite ningún tipo de acceso mutable al elemento subyacente. Esto lo cubre el tipo Mutex
que garantiza que sólo un hilo tengaacceso -mutableo inmutable- al elemento subyacente. Al igual que RefCell
, Mutex
no implementaningún rasgo de puntero, pero su operación lock()
devuelve un valor de un tipo que sí lo hace: MutexGuard
, que implementa Deref[Mut]
.
Si es probable que haya más lectores que escritores, es preferible el tipo RwLock
es preferible, ya que permite que varios lectores accedan al elemento subyacente en paralelo, siempre que no haya en ese momento un (único) escritor.
En cualquier caso, las reglas de préstamo e hilado de Rust obligan a utilizar uno de estos contenedores de sincronización en el código multihilo (pero esto sólo protege contra algunos de los problemas de la concurrencia de estado compartido; véase el Tema 17).
La misma estrategia -ver qué rechaza el compilador y qué sugiere en su lugar- puede aplicarse a veces con los otros tipos de punteros inteligentes. Sin embargo, es más rápido y menos frustrante entender lo que implica el comportamiento de los distintos punteros inteligentes. Tomando prestado (juego de palabras) un ejemplo de la primera edición del libro de Rust:
-
Rc<RefCell<Vec<T>>>
tiene un vector (Vec
) con propiedad compartida (Rc
), en el que el vector puede mutar, pero sólo como vector completo. -
Rc<Vec<RefCell<T>>>
también contiene un vector con propiedad compartida, pero aquí cada entrada individual del vector puede mutar independientemente de las demás.
Los tipos implicados describen con precisión estos comportamientos.
Tema 9: Considera la posibilidad de utilizar transformaciones de iteradoresen lugar de bucles explícitos
El humilde bucle ha tenido un largo recorrido de creciente comodidad y creciente abstracción. El lenguaje B (precursor de C) sólo tenía while (condition) { ... }
, pero con la llegada de C, el escenario habitual de iterar a través de los índices de una matriz se hizo más cómodo con la adición del bucle for
:
// C code
int
i
;
for
(
i
=
0
;
i
<
len
;
i
++
)
{
Item
item
=
collection
[
i
];
// body
}
Las primeras versiones de C++ mejoraron aún más la comodidad y el alcance al permitir que la declaración de la variable de bucle seincrustara en la declaración for
(esto también lo adoptó C en C99):
// C++98 code
for
(
int
i
=
0
;
i
<
len
;
i
++
)
{
Item
item
=
collection
[
i
];
// ...
}
La mayoría de los lenguajes modernos abstraen aún más la idea del bucle: la función principal de un bucle suele ser pasar al siguiente elemento de algún contenedor. El seguimiento de la logística necesaria para llegar a ese elemento (index++
o ++it
) es, en la mayoría de los casos, un detalle irrelevante. Esta realización produjo dos conceptos centrales:
- Iteradores
-
Un tipo cuya finalidad es emitir repetidamente el siguiente elemento de un contenedor, hasta que se agote23
- Bucles For-each
-
Una expresión de bucle compacta para iterar sobre todos los elementos de un contenedor, vinculando una variable de bucle al elemento en lugar de a los detalles para llegar a ese elemento.
Estos conceptos permiten que el código del bucle sea más corto y (lo que es más importante) más claro sobre lo que se pretende:
// C++11 code
for
(
Item
&
item
:
collection
)
{
// ...
}
Una vez que estos conceptos estuvieron disponibles, eran tan evidentemente potentes que se adaptaron rápidamente a los lenguajes que aún no los tenían (por ejemplo, los bucles for-each se añadieron a Java 1.5 y C++11).
Rust incluye iteradores y bucles de tipo "para cada uno", pero también incluye el siguiente paso en la abstracción: permitir que todo el bucle se exprese como una transformación de iterador (a veces también denominada adaptador de iterador). Al igual que en el Tema 3sobre Option
y Result
, este Tema intentará mostrar cómo pueden utilizarse estas transformaciones de iterador en lugar de bucles explícitos, y dará orientaciones sobre cuándo es una buena idea. En concreto, las transformaciones de iterador pueden ser más eficientes que un bucle explícito, porque el compilador puede saltarse las comprobaciones de límites que de otro modo tendría que realizar.
Al final de este Tema, un bucle explícito tipo C para sumar los cuadrados de los cinco primeros elementos pares de un vector:
let
values
:Vec
<
u64
>
=
vec!
[
1
,
1
,
2
,
3
,
5
/* ... */
];
let
mut
even_sum_squares
=
0
;
let
mut
even_count
=
0
;
for
i
in
0
..
values
.
len
()
{
if
values
[
i
]
%
2
!=
0
{
continue
;
}
even_sum_squares
+=
values
[
i
]
*
values
[
i
];
even_count
+=
1
;
if
even_count
==
5
{
break
;
}
}
debería empezar a sentirse más natural expresado como una expresión de estilo funcional:
let
even_sum_squares
:u64
=
values
.
iter
()
.
filter
(
|
x
|
*
x
%
2
==
0
)
.
take
(
5
)
.
map
(
|
x
|
x
*
x
)
.
sum
();
Las expresiones de transformación de un iterador como esta pueden dividirse aproximadamente en tres partes:
-
Un iterador fuente inicial, a partir de una instancia de un tipo que implemente uno de los rasgos de iterador de Rust
-
Una secuencia de transformaciones de iteradores
-
Un método consumidor final para combinar los resultados de la iteración en un valor final
Las dos primeras partes desplazan la funcionalidad del cuerpo del bucle a la expresión for
; la última elimina por completo la necesidad de la declaración for
.
Rasgos del iterador
El núcleo Iterator
tiene una interfaz muy sencilla: un único método next
que emite elementos Some
hasta que no lo hace (None
). El tipo de los elementos emitidos viene dado por el tipo Item
asociado al rasgo.
Las colecciones que permiten iterar sobre su contenido -lo que en otros lenguajes se llamaría iterables- implementan el rasgo IntoIterator
el método into_iter
de este rasgo consume Self
y emite en su lugar un Iterator
. El compilador utilizará automáticamente este rasgo para expresiones de la forma
for
item
in
collection
{
// body
}
convirtiéndolos efectivamente en un código más o menos así:
let
mut
iter
=
collection
.
into_iter
();
loop
{
let
item
:Thing
=
match
iter
.
next
()
{
Some
(
item
)
=>
item
,
None
=>
break
,
};
// body
}
o de forma más sucinta e idiomática:
let
mut
iter
=
collection
.
into_iter
();
while
let
Some
(
item
)
=
iter
.
next
()
{
// body
}
Para que todo vaya sobre ruedas, también hay una implementación de IntoIterator
para cualquier Iterator
, que sólo devuelve self
; después de todo, ¡es fácil convertir un Iterator
en un Iterator
!
Esta forma inicial es un iterador que consume , consumiendo la colección a medida que se crea:
let
collection
=
vec!
[
Thing
(
0
),
Thing
(
1
),
Thing
(
2
),
Thing
(
3
)];
for
item
in
collection
{
println!
(
"Consumed item {item:?}"
);
}
Cualquier intento de utilizar la colección después de haber iterado sobre ella falla:
println!
(
"Collection = {collection:?}"
);
error[E0382]: borrow of moved value: `collection` --> src/main.rs:171:28 | 163 | let collection = vec![Thing(0), Thing(1), Thing(2), Thing(3)]; | ---------- move occurs because `collection` has type `Vec<Thing>`, | which does not implement the `Copy` trait 164 | for item in collection { | ---------- `collection` moved due to this implicit call to | `.into_iter()` ... 171 | println!("Collection = {collection:?}"); | ^^^^^^^^^^^^^^ value borrowed here after move | note: `into_iter` takes ownership of the receiver `self`, which moves `collection`
Aunque es sencillo de entender, este comportamiento que todo lo consume suele ser indeseable; se necesita algún tipo de préstamo de los elementos iterados.
Para garantizar que el comportamiento sea claro, en los ejemplos se utiliza un tipo Thing
que no implementa Copy
(punto 10), ya que eso ocultaría cuestiones de propiedad(punto 15): el compilador haría copias silenciosamente en todas partes:
// Deliberately not `Copy`
#[derive(Clone, Debug, Eq, PartialEq)]
struct
Thing
(
u64
);
let
collection
=
vec!
[
Thing
(
0
),
Thing
(
1
),
Thing
(
2
),
Thing
(
3
)];
Si la colección sobre la que se está iterando lleva el prefijo &
:
for
item
in
&
collection
{
println!
(
"{}"
,
item
.
0
);
}
println!
(
"collection still around {collection:?}"
);
el compilador de Rust buscará una implementación deIntoIterator
para el tipo &Collection
. Los tipos de colección correctamente diseñados proporcionarán dicha implementación; esta implementación seguirá consumiendo Self
, pero ahora Self
es &Collection
en lugar de Collection
, y el tipo Item
asociado será una referencia &Thing
.
Esto deja la colección intacta después de la iteración, y el código expandido equivalente es el siguiente:
let
mut
iter
=
(
&
collection
).
into_iter
();
while
let
Some
(
item
)
=
iter
.
next
()
{
println!
(
"{}"
,
item
.
0
);
}
Si tiene sentido proporcionar iteración sobre referencias mutables,24 entonces se aplica un patrón similar para for item in &mut collection
: el compilador busca y utiliza una implementación de IntoIterator
para &mut Collection
, siendo cada Item
de tipo &mut Thing
.
Por convención, los contenedores estándar también proporcionan un método iter()
que devuelve un iterador sobre referencias al elemento subyacente, y un método iter_mut()
equivalente, si procede, con el mismo comportamiento que el que acabamos de describir. Estos métodos pueden utilizarse en bucles for
, pero tienen un beneficio más evidente cuando se utilizan como inicio de una transformación de iterador:
let
result
:u64
=
(
&
collection
).
into_iter
().
map
(
|
thing
|
thing
.
0
).
sum
();
se convierte:
let
result
:u64
=
collection
.
iter
().
map
(
|
thing
|
thing
.
0
).
sum
();
Iterador Transformaciones
El rasgo Iterator
tiene un único método obligatorio (next
), pero también proporciona implementaciones por defecto(Elemento 13) de un gran número de otros métodos que realizan transformaciones en un iterador.
Algunas de estas transformaciones afectan al proceso global de iteración de:
take(n)
-
Restringe un iterador a emitir como máximo
n
elementos. skip(n)
step_by(n)
-
Convierte un iterador para que sólo emita cada enésimo elemento.
chain(other)
-
Pega dos iteradores, para construir un iterador combinado que se mueva por uno y luego por el otro.
cycle()
-
Convierte un iterador que termina en uno que se repite para siempre, volviendo a empezar desde el principio cada vez que llega al final. (El iterador debe soportar
Clone
para permitir esto). rev()
-
Invierte la dirección de un iterador. (El iterador debe implementar el
DoubleEndedIterator
que tiene un método adicionalnext_back
adicional).
Otras transformaciones afectan a la naturaleza del Item
que es el sujeto del Iterator
:
map(|item| {...})
-
Aplica repetidamente un cierre para transformar cada elemento sucesivamente. Ésta es la versión más general; varias de las siguientes entradas de esta lista son variantes de conveniencia que podrían implementarse de forma equivalente como
map
. cloned()
-
Produce un clon de todos los elementos del iterador original; este es especialmente útil con iteradores sobre referencias
&Item
. (Obviamente, esto requiere que el tipoItem
subyacente implementeClone
.) copied()
-
Produce una copia de todos los elementos del iterador original; esto es especialmente útil con iteradores sobre referencias
&Item
. (Obviamente, esto requiere que el tipoItem
subyacente implementeCopy
, pero es probable que sea más rápido quecloned()
, si ese es el caso). enumerate()
-
Convierte un iterador sobre elementos en un iterador sobre pares
(usize, Item)
, proporcionando un índice a los elementos del iterador. zip(it)
-
Une un iterador con un segundo iterador, para producir un iterador combinado que emita pares de elementos, uno de cada uno de los iteradores originales, hasta que termine el más corto de los dos iteradores.
Sin embargo, otras transformaciones realizan un filtrado de los Item
s emitidos por elIterator
:
filter(|item| {...})
-
Aplica un cierre
bool
-returning a cada referencia de elemento para determinar si debe pasarse por él. take_while()
-
Emite un subrango inicial del iterador, basado en un predicado. Imagen especular de
skip_while
. skip_while()
-
Emite un subrango final del iterador, basado en un predicado. Imagen especular de
take_while
.
El método flatten()
trata con un iterador cuyos elementos son a su vez iteradores, aplanando el resultado. Por sí solo, esto no parece muy útil, pero lo es mucho más cuando se combina con la observación de que tanto Option
y Result
actúan como iteradores: producen cero (para None
, Err(e)
) o uno (para Some(v)
, Ok(v)
) elementos. Esto significa que flatten
ing un flujo de valoresOption
/Result
es una forma sencilla de extraer sólo los valores válidos, ignorando el resto.
En conjunto, estos métodos permiten transformar los iteradores para que produzcan exactamente la secuencia de elementos que se necesita en la mayoría de las situaciones.
Iteradores Consumidores
Las dos secciones anteriores describían cómo obtener un iterador y cómo transformarlo en la forma exacta para una iteración precisa. Esta iteración precisa podría producirse como un bucle for-each explícito:
let
mut
even_sum_squares
=
0
;
for
value
in
values
.
iter
().
filter
(
|
x
|
*
x
%
2
==
0
).
take
(
5
)
{
even_sum_squares
+=
value
*
value
;
}
Sin embargo, la gran colección de métodosIterator
incluye muchos que permiten consumir una iteración en una sola llamada al método, eliminando la necesidad de un bucle for
explícito.
El más general de estos métodos es for_each(|item| {...})
que ejecuta un cierre para cada elemento producido por Iterator
. Puede hacer la mayoría de las cosas que puede hacer un bucle explícito de for
(las excepciones se describen en una sección posterior), pero su generalidad también hace que sea un poco incómodo de utilizar: el cierre tiene que utilizar referencias mutables al estado externo para poder emitir algo:
let
mut
even_sum_squares
=
0
;
values
.
iter
()
.
filter
(
|
x
|
*
x
%
2
==
0
)
.
take
(
5
)
.
for_each
(
|
value
|
{
// closure needs a mutable reference to state elsewhere
even_sum_squares
+=
value
*
value
;
});
Sin embargo, si el cuerpo del bucle for
coincide con alguno de los patrones habituales, existen métodos más específicos de consumo de iteradores que son más claros, cortos e idiomáticos.
Estos patrones incluyen atajos para construir un único valor a partir de la colección:
sum()
-
Suma una colección de valores numéricos (enteros o flotantes).
product()
min()
-
Busca el valor mínimo de una colección, en relación con la implementación
Ord
del elemento (véase el elemento 10). max()
-
Busca el valor máximo de una colección, en relación con la implementación
Ord
del artículo (véase el artículo 10). min_by(f)
-
Encuentra el valor mínimo de una colección, en relación con una función de comparación especificada por el usuario
f
. max_by(f)
-
Busca el valor máximo de una colección, en relación con una función de comparación especificada por el usuario
f
. reduce(f)
-
Construye un valor acumulado del tipo
Item
ejecutando en cada paso un cierre que toma el valor acumulado hasta el momento y el elemento actual. Se trata de una operación más general que engloba los métodos anteriores. fold(f)
-
Construye un valor acumulado de un tipo arbitrario (no sólo del tipo
Iterator::Item
) ejecutando en cada paso un cierre que toma el valor acumulado hasta el momento y el elemento actual. Se trata de una generalización dereduce
. scan(init, f)
-
Construye un valor acumulado de un tipo arbitrario ejecutando en cada paso un cierre que toma una referencia mutable a algún estado interno y al elemento actual. Se trata de una generalización ligeramente diferente de
reduce
.
También hay métodos para seleccionar un único valor de la colección:
find(p)
position(p)
-
También encuentra el primer elemento que satisface un predicado, pero esta vez devuelve el índice del elemento.
nth(n)
Hay métodos para comprobar cada elemento de la colección:
any(p)
-
Indica si un predicado es
true
para cualquier elemento de la colección. all(p)
-
Indica si un predicado es
true
para todos los elementos de la colección.
En cualquier caso, la iteración terminará antes si se encuentra el contraejemplo pertinente.
Hay métodos que permiten la posibilidad de fallo en los cierres utilizados con cada elemento. En cada caso, si un cierre devuelve un fallo para un elemento, se termina la iteración y la operación en su conjunto devuelve el primer fallo:
Por último, hay métodos que acumulan todos los elementos iterados en una nueva colección. El más importante es collect()
que puede utilizarse para construir una nueva instancia de cualquier tipo de colección que implemente el rasgoFromIterator
rasgo.
El rasgo FromIterator
se implementa para todos los tipos de colección de la biblioteca estándar (Vec
, HashMap
, BTreeSet
etc.), pero esta ubicuidad también significa que a menudo tienes que utilizar tipos explícitos, porque de lo contrario el compilador no puede averiguar si estás intentando ensamblar (digamos) un Vec<i32>
o un HashSet<i32>
:
use
std
::collections
::HashSet
;
// Build collections of even numbers. Type must be specified, because
// the expression is the same for either type.
let
myvec
:Vec
<
i32
>
=
(
0
..
10
).
into_iter
().
filter
(
|
x
|
x
%
2
==
0
).
collect
();
let
h
:HashSet
<
i32
>
=
(
0
..
10
).
into_iter
().
filter
(
|
x
|
x
%
2
==
0
).
collect
();
Este ejemplo también ilustra el uso de expresiones de rango para generar los datos iniciales sobre los que se va a iterar.
Otros métodos (más oscuros) de producción de colecciones son los siguientes:
unzip()
partition(p)
-
Divide un iterador en dos colecciones en función de un predicado que se aplica a cada elemento
En este artículo se ha tratado una amplia selección de métodos de Iterator
, pero esto es sólo un subconjunto de los métodos disponibles; para más información, consulta la documentación de los iteradores o lee el Capítulo 15 de Programming Rust, 2ª edición (O'Reilly), que cubre ampliamente las posibilidades.
Esta rica colección de transformaciones de iteradores está ahí para ser utilizada. Produce código más idiomático, más compacto y con una intención más clara.
Expresar los bucles como transformaciones de iteradores también puede producir código más eficiente. En aras de la seguridad, Rust realiza comprobaciones de límites en el acceso a contenedores contiguos como vectores y rebanadas; un intento de acceder a un valor más allá de los límites de la colección desencadena un pánico en lugar de un acceso a datos no válidos. Un bucle de estilo antiguo que accede a valores contenedores (por ejemplo, values[i]
) podría estar sujeto a estas comprobaciones en tiempo de ejecución, mientras que un iterador que produce un valor tras otro ya se sabe que está dentro de los límites.
Sin embargo, también puede darse el caso de que un bucle de estilo antiguo no esté sujeto a comprobaciones de límites adicionales en comparación con la transformación equivalente de un iterador. El compilador y el optimizador de Rust son muy buenos a la hora de analizar el código que rodea el acceso a un trozo para determinar si es seguro omitir las comprobaciones de límites; el artículo 2023 de Sergey "Shnatsel" Davidoff explora las sutilezas implicadas.
Construir colecciones a partir de Result
Valores
La sección anterior describía el uso de collect()
para construir colecciones a partir de iteradores, pero collect()
también tiene una característica especialmente útil cuando se trata de valores de Result
.
Considera un intento de convertir un vector de valores i64
en bytes (u8
), con la optimista expectativa de que quepan todos:
Esto funciona hasta que aparece alguna entrada inesperada:
let
inputs
:Vec
<
i64
>
=
vec!
[
0
,
1
,
2
,
3
,
4
,
512
];
y provoca un fallo de ejecución:
thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: TryFromIntError(())', iterators/src/main.rs:266:36 note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Siguiendo el consejo dado en el punto 3, queremos mantener el tipo Result
en juego y utilizar el operador ?
para que cualquier fallo sea problema del código llamante. La modificación obvia de emitir el Result
no ayuda mucho:
let
result
:Vec
<
Result
<
u8
,
_
>>
=
inputs
.
into_iter
().
map
(
|
v
|
<
u8
>
::try_from
(
v
)).
collect
();
// Now what? Still need to iterate to extract results and detect errors.
Sin embargo, existe una versión alternativa de collect()
, que puede ensamblar un Result
que sujeta un Vec
, en lugar de unVec
que sujeta Result
s.
Forzar el uso de esta versión requiere el turbofish (::<Result<Vec<_>, _>>
):
let
result
:Vec
<
u8
>
=
inputs
.
into_iter
()
.
map
(
|
v
|
<
u8
>
::try_from
(
v
))
.
collect
::<
Result
<
Vec
<
_
>
,
_
>>
()
?
;
Combinando esto con el operador signo de interrogación se obtiene un comportamiento útil:
-
Si la iteración encuentra un valor de error, ese valor de error se emite a la persona que llama y la iteración se detiene.
-
Si no se encuentra ningún error, el resto del código puede tratar con una colección sensata de valores del tipo correcto.
Transformación en bucle
El objetivo de este Tema es convencerte de que muchos bucles explícitos pueden considerarse transformaciones de iteradores. Esto puede resultar algo antinatural para los programadores que no estén acostumbrados, así que vamos a recorrer una transformación paso a paso.
Comienza con un bucle explícito muy similar a C para sumar los cuadrados de los cinco primeros elementos pares de un vector:
let
mut
even_sum_squares
=
0
;
let
mut
even_count
=
0
;
for
i
in
0
..
values
.
len
()
{
if
values
[
i
]
%
2
!=
0
{
continue
;
}
even_sum_squares
+=
values
[
i
]
*
values
[
i
];
even_count
+=
1
;
if
even_count
==
5
{
break
;
}
}
El primer paso es sustituir la indexación de vectores por el uso directo de un iterador en un bucle for-each:
let
mut
even_sum_squares
=
0
;
let
mut
even_count
=
0
;
for
value
in
values
.
iter
()
{
if
value
%
2
!=
0
{
continue
;
}
even_sum_squares
+=
value
*
value
;
even_count
+=
1
;
if
even_count
==
5
{
break
;
}
}
Un brazo inicial del bucle que utiliza continue
para saltarse sobre algunos elementos se expresa naturalmente como filter()
:
let
mut
even_sum_squares
=
0
;
let
mut
even_count
=
0
;
for
value
in
values
.
iter
().
filter
(
|
x
|
*
x
%
2
==
0
)
{
even_sum_squares
+=
value
*
value
;
even_count
+=
1
;
if
even_count
==
5
{
break
;
}
}
A continuación, la salida anticipada del bucle una vez que se han localizado cinco elementos pares se asigna a un take(5)
:
let
mut
even_sum_squares
=
0
;
for
value
in
values
.
iter
().
filter
(
|
x
|
*
x
%
2
==
0
).
take
(
5
)
{
even_sum_squares
+=
value
*
value
;
}
Cada iteración del bucle utiliza sólo el elemento al cuadrado, en la combinación value * value
, lo que hace que sea un objetivo ideal para un map()
:
let
mut
even_sum_squares
=
0
;
for
val_sqr
in
values
.
iter
().
filter
(
|
x
|
*
x
%
2
==
0
).
take
(
5
).
map
(
|
x
|
x
*
x
)
{
even_sum_squares
+=
val_sqr
;
}
Estas refactorizaciones del bucle original dan como resultado un cuerpo de bucle que es el clavo perfecto para encajar bajo el martillo del método sum()
:
let
even_sum_squares
:u64
=
values
.
iter
()
.
filter
(
|
x
|
*
x
%
2
==
0
)
.
take
(
5
)
.
map
(
|
x
|
x
*
x
)
.
sum
();
Cuando lo explícito es mejor
Este artículo ha puesto de relieve las ventajas de las transformaciones de iteradores, sobre todo en cuanto a concisión y claridad. Entonces, ¿cuándo no son apropiadas o idiomáticas las transformaciones de iteradores?
-
Si el cuerpo del bucle es grande y/o multifuncional, tiene sentido mantenerlo como un cuerpo explícito en lugar de apretujarlo en un cierre.
-
Si el cuerpo del bucle implica condiciones de error que provocan la finalización anticipada de la función circundante, a menudo es mejor mantener explícitos estos -los métodos
try_..()
sólo ayudan un poco. Sin embargo, la capacidad decollect()
de convertir una colección de valoresResult
en unaResult
que contenga una colección de valores, a menudo permite seguir gestionando las condiciones de error con el operador?
. -
Si el rendimiento es vital, una transformación de iterador que implique un cierre debe optimizarse para que sea tan rápida como el código explícito equivalente. Pero si el rendimiento de un bucle central es tan importante, mide diferentes variantes y afina adecuadamente:
-
Ten cuidado de que tus mediciones reflejen el rendimiento en el mundo real: el optimizador del compilador puede dar resultados demasiado optimistas con datos de prueba (como se describe en el punto 30).
-
El explorador del compilador Godbolt es una herramienta increíble para explorar lo que escupe el compilador.
-
Y lo que es más importante, no conviertas un bucle en una transformación de iteración si la conversión resulta forzada o incómoda. Se trata de una cuestión de gustos, pero ten en cuenta que es probable que tus gustos cambien a medida que te familiarices con el estilo funcional.
1 La situación se enturbia aún más si interviene el sistema de archivos, ya que los nombres de archivo en las plataformas más populares están a medio camino entre los bytes arbitrarios y las secuencias UTF-8: consulta la documentación std::ffi::OsString
documentación.
2 Técnicamente, un valor escalar Unicode en lugar de un punto de código.
3 La necesidad de considerar todas las posibilidades también significa que añadir una nueva variante a un enum
existente en una biblioteca es un cambio de ruptura(Tema 21): los clientes de la biblioteca tendrán que cambiar su código para adaptarse a la nueva variante. Si un enum
es realmente una lista de valores numéricos relacionados, similar a C, este comportamiento puede evitarse marcándolo como un non_exhaustive
enum
ver punto 21.
4 Al menos no en Rust estable en el momento de escribir esto. Los enlaces unboxed_closures
y fn_traits
pueden cambiar esto en el futuro.
5 Por ejemplo, el libro de Joshua Bloch Effective Java (3ª edición, Addison-Wesley) incluye el punto 64: Refiérete a los objetos por sus interfaces.
6 La adición de conceptos en C++20 permite la especificación explícita de restricciones en los tipos de plantilla, pero las comprobaciones siguen realizándose sólo cuando se instancian las plantillas, no cuando se declaran.
7 La versión online de este diagrama es clicable; cada recuadro enlaza con la documentación pertinente.
8 Observa que este método es independiente del rasgo AsRef
, aunque el nombre del método sea el mismo.
9 O, al menos, el único método no deprecado y estable.
10 En el momento de escribir esto, Error
se ha trasladado a core
, pero aún no está disponible en Rust estable.
11 Esta sección está inspirada en el artículo de Nick Groenen "Rust: Estructuración y gestión de errores en 2020".
12 Más propiamente conocidas como reglas de coherencia de rasgos.
13 Por ahora, es probable que se sustituya por el tipo!
"nunca" en una futura versión de Rust.
14 Permitir las conversiones con pérdida en Rust fue probablemente un error, y ha habido discusiones en torno a intentar eliminar este comportamiento.
15 Rust se refiere a estas conversiones como "subtipado", pero es bastante diferente de la definición de "subtipado" utilizada en los lenguajes orientados a objetos.
16 Concretamente, el Orbitador Climático de Marte.
17 Véase "Mars Climate Orbiter" en Wikipedia para más información sobre la causa del fallo.
18 Éste es un problema lo suficientemente común para serde
como para que incluya un mecanismo de ayuda.
19 Aunque con una advertencia de los compiladores modernos.
20 El rasgo equivalente para expresiones mutables es IndexMut
.
21 Esto es algo simplificado; una vtabla completa también incluye información sobre el tamaño y la alineación del tipo, junto con un puntero a la función drop()
para que el objeto subyacente pueda abandonarse con seguridad.
22 Ten en cuenta que esto no afecta a las garantías de seguridad de memoria de Rust: los elementos siguen siendo seguros, sólo que inaccesibles.
23 De hecho, el iterador puede ser más general: no es necesario asociar a un contenedor la idea de emitir los siguientes elementos hasta su finalización.
24 Este método no se puede proporcionar si una mutación del elemento puede invalidar las garantías internas del contenedor. Por ejemplo, cambiar el contenido del elemento de forma que se altere su Hash
invalidaría las estructuras de datos internas de HashMap
.
Get Óxido efectivo now with the O’Reilly learning platform.
O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.