Capítulo 4. Dependencias
Este trabajo se ha traducido utilizando IA. Agradecemos tus opiniones y comentarios: translation-feedback@oreilly.com
Cuando los Dioses desean castigarnos, responden a nuestras plegarias.
Oscar Wilde
Durante décadas, la idea de la reutilización del código no era más que un sueño. La idea de que el código pudiera escribirse una vez, empaquetarse en una biblioteca y reutilizarse en muchas aplicaciones diferentes era un ideal, realizado sólo para unas pocas bibliotecas estándar y para herramientas corporativas internas.
El crecimiento de Internet y el auge del software de código abierto cambiaron por fin esa situación. El primer repositorio de libre acceso que contenía una amplia colección de bibliotecas, herramientas y ayudantes útiles, todo empaquetado para facilitar su reutilización, fue CPAN: la Comprehensive Perl Archive Network, en línea desde 1995. Hoy en día, casi todos los lenguajes modernos disponen de una amplia colección de bibliotecas de código abierto, alojadas en un repositorio de paquetes que facilita y agiliza el proceso de añadir una nueva dependencia.1
Sin embargo, esa facilidad, comodidad y rapidez conlleva nuevos problemas. Por lo general, sigue siendo más fácil reutilizar el código existente que escribirlo tú mismo, pero existen posibles trampas y riesgos que conllevan las dependencias del código de otra persona. Este capítulo del libro te ayudará a ser consciente de ellos.
Se centra específicamente en Rust, y con ello en el uso de la herramienta cargo
pero muchas de las preocupaciones, temas y cuestiones tratadas son igualmente aplicables a otras cadenas de herramientas (y a otros lenguajes).
Tema 21: Entender lo que promete el versionado semántico
Si reconocemos que el SemVer es una estimación con pérdidas y que sólo representa un subconjunto del posible alcance de los cambios, podemos empezar a verlo como un instrumento contundente.
Titus Winters, Ingeniería de software en Google (O'Reilly)
Cargo, el gestor de paquetes de Rust, permite la selección automática de dependencias(punto 25) para el código Rust según el versionado semántico (semver). Una estrofa de Cargo.toml como
[dependencies]
serde
=
"1.4"
indica a cargo
qué rangos de versiones de semver son aceptables para esta dependencia. La documentación oficial proporciona los detalles sobre la especificación de rangos precisos de versiones aceptables, pero las siguientes son las variantes más utilizadas:
"1.2.3"
-
Especifica que es aceptable cualquier versión que sea semicompatible con 1.2.3
"^1.2.3"
-
Es otra forma de especificar lo mismo más explícitamente
"=1.2.3"
-
Pines a una versión concreta, sin que se acepten sustitutos
"~1.2.3"
-
Permite versiones semicompatibles con la 1.2.3, pero sólo cuando cambie el último componente especificado (así, la 1.2.4 es aceptable, pero la 1.3.0 no).
"1.2.*"
-
Acepta cualquier versión que coincida con el comodín
En la Tabla 4-1 se muestran ejemplos de lo que permiten estas especificaciones.
Especificación | 1.2.2 | 1.2.3 | 1.2.4 | 1.3.0 | 2.0.0 |
---|---|---|---|---|---|
|
No |
Sí |
Sí |
Sí |
No |
|
No |
Sí |
Sí |
Sí |
No |
|
No |
Sí |
No |
No |
No |
|
No |
Sí |
Sí |
No |
No |
|
Sí |
Sí |
Sí |
No |
No |
|
Sí |
Sí |
Sí |
Sí |
No |
|
Sí |
Sí |
Sí |
Sí |
Sí |
Al elegir las versiones de dependencia, Cargo elegirá generalmente la versión más grande que esté dentro de la combinación de todos estos rangos de semver.
Dado que el versionado semántico es el núcleo del proceso de resolución de dependencias de cargo
, este Artículo explora más detalles sobre lo que significa semver.
Lo esencial de Semver
Lo esencial del versionado semántico de se enumera en el resumen de la documentación de semver, reproducido aquí:
Dado un número de versión MAYOR.MENOR.PATCH, incrementa el:
versión MAJOR cuando haces cambios incompatibles en la API
versión MENOR cuando añades funcionalidad de forma compatible con versiones anteriores
Versión PATCH cuando hagas correcciones de errores compatibles con versiones anteriores
Un punto importante se esconde en los detalles:
Una vez liberado un paquete versionado, el contenido de esa versión NO DEBE modificarse. Cualquier modificación DEBE publicarse como una nueva versión.
Dicho con otras palabras:
-
Añadir cosas a la API de forma que los usuarios existentes del crate sigan compilando y funcionando requiere una actualización menor de la versión.
-
Eliminar o cambiar cosas en la API requiere una actualización importante de la versión.
Hay otro codicilo importante a las normas semver:
La versión mayor cero (0.y.z) es para el desarrollo inicial. Todo PUEDE cambiar en cualquier momento. La API pública NO DEBE considerarse estable.
Cargo adapta ligeramente esta última regla, "desplazando a la izquierda" las reglas anteriores para que los cambios en el componente distinto de cero situado más a la izquierda indiquen cambios incompatibles. Esto significa que de 0.2.3 a 0.3.0 puede haber un cambio de API incompatible, al igual que de 0.0.4 a 0.0.5.
Semver para autores de cajas
En teoría, la teoría es lo mismo que la práctica. En la práctica, no.
Como autor de crate , la primera de estas reglas es fácil de cumplir, en teoría: si tocas algo, necesitas una nueva versión. Utilizar etiquetas Git para hacer coincidir las versiones puede ayudar conesto:por defecto, una etiqueta se fija a una confirmación concreta y sólo se puede mover con una opción manual de --force
. Las cajas publicadas en crates.io
también tienen un control automático de esto, ya que el registro rechazará un segundo intento de publicar la misma versión de crate. El principal peligro de incumplimiento es cuando te das cuenta de un error justo después de que se haya publicado una versión, y tienes que resistir la tentación de simplemente corregirlo.
La especificación semver cubre la compatibilidad de la API, por lo que si haces un cambio menor en el comportamiento que no altere la API, debería bastar con actualizar la versión del parche. (Sin embargo, si se depende mucho de tu crate, en la práctica puede que tengas que tener en cuenta la Ley de Hyrum: independientemente de lo pequeño que sea el cambio que hagas en el código, es probable que alguien dependa del comportamiento anterior,aunque la API no cambie).
La parte difícil para los autores de crate son estas últimas reglas, que requieren una determinación precisa de si un cambio es compatible hacia atrás o no. Algunos cambios son obviamente incompatibles -eliminar puntos de entrada o tipos públicos, cambiar firmas de métodos- y otros son obviamente retrocompatibles (por ejemplo, añadir un nuevo método a un struct
, o añadir una nueva constante), pero hay mucha zona gris entre medias.
Para ayudar en este sentido, el libro de Carga entra en detalles considerables sobre lo que es y lo que no es retrocompatible. La mayoría de estos detalles no son sorprendentes, pero hay algunas áreas que merece la pena destacar:
-
Añadir nuevos elementos suele ser seguro, pero puede provocar conflictos si el código que utiliza la caja ya utiliza algo que casualmente tiene el mismo nombre que el nuevo elemento.
-
Esto es especialmente peligroso si el usuario realiza una importación comodín desde la caja, porque todos los elementos de la caja se encuentran automáticamente en el espacio de nombres principal del usuario. El punto 23 desaconseja hacer esto.
-
Incluso sin una importación comodín, un nuevo método de rasgo (con una implementación por defecto; Tema 13) o un nuevo método inherentetiene posibilidades de chocar con un nombre existente.
-
-
La insistencia de Rust en cubrir todas las posibilidades significa que cambiar el conjunto de posibilidades disponibles puede ser un cambio de ruptura.
-
Realizar un
match
en unenum
debe cubrir todas las posibilidades, por lo que si un crate añade una nueva varianteenum
, eso es un cambio de ruptura (a menos que elenum
ya esté marcado comonon_exhaustive
-añadirnon_exhaustive
también es un cambio de ruptura). -
Crear explícitamente una instancia de un
struct
requiere un valor inicial para todos los campos, por lo que añadir un campo a una estructura que puede instanciarse públicamenteesun cambio deruptura. Las estructuras que tienen campos privados están bien, porque los usuarios de la caja no pueden construirlas explícitamente de todos modos; unstruct
también puede marcarsecomonon_exhaustive
para evitar que usuarios externos realicen unaconstrucción explícita.
-
-
Cambiar un rasgo para que deje de ser objeto seguro(punto 12) es un cambio de ruptura; cualquier usuario que construya objetos de rasgo para el rasgo dejará de poder compilar su código.
-
Añadir una nueva implementación general para un rasgo es un cambio de ruptura; cualquier usuario que ya implemente el rasgo tendrá ahora dos implementaciones conflictivas.
-
Cambiar la licencia de un crate de código abierto es un cambio incompatible: los usuarios de tu crate que tienen restricciones estrictas ensobre qué licencias son aceptables pueden verse perjudicados por el cambio. Considera que la licencia forma parte de tu API.
-
Cambiar las características por defecto(elemento 26) de una caja es un cambio potencialmente destructivo. Eliminar una función por defecto es casi seguro que romperá cosas (a menos que la función ya fuera un no-op); añadir una función por defecto puede romper cosas dependiendo de lo que habilite. Considera que el conjunto de funciones por defecto forma parte de tu API.
-
Cambiar el código de la biblioteca para que utilice una nueva característica de Rust puede ser un cambio incompatible, porque los usuarios de tu crate que aún no hayan actualizado su compilador a una versión que incluya la característica se verán perjudicados por el cambio.Sin embargo, la mayoría de los crates de Rust tratan un aumento de la versión mínima soportada de Rust (MSRV) como un cambiono incompatible, así que ten en cuenta si la MSRV forma parte de tu API.
Un corolario obvio de las reglas es el siguiente: cuantos menos elementos públicos tenga una caja, menos cosas habrá que puedan inducir un cambio incompatible(Tema 22).
Sin embargo, no se puede eludir el hecho de que comparar todos los elementos de la API pública para comprobar su compatibilidad de una versión a otra es un proceso que lleva mucho tiempo y que, en el mejor de los casos, sólo arrojará una evaluación aproximada (mayor/menor/parche) del nivel de cambio. Dado que esta comparación es un proceso un tanto mecánico, es de esperar que lleguen herramientas(punto 31) que faciliten el proceso.2
Si tienes que hacer un cambio de versión importante incompatible, es bueno facilitar la vida a tus usuarios asegurándote de que la misma funcionalidad general esté disponible después del cambio, aunque la API haya cambiado radicalmente. Si es posible, la secuencia más útil para tus usuarios de crate es la siguiente:
-
Publica una actualización de versión menor que incluya la nueva versión de la API y que marque la variante antigua como
deprecated
incluyendo una indicación de cómo migrar. -
Publica una actualización de la versión principal que elimine las partes obsoletas de la API.
Un punto más sutil es hacer que los cambios rompedores rompan. Si tu crate está cambiando su comportamiento de un modo que en realidad es incompatible para los usuarios existentes, pero que podría reutilizar la misma API: no lo hagas. Fuerza un cambio de tipos (y un salto importante de versión) para asegurarte de que los usuarios no puedan utilizar inadvertidamente la nueva versión de forma incorrecta.
Para las partes menos tangibles de tu API -como el MSRV o la licencia- considera la posibilidad de establecer una comprobación CI(punto 32) que detecte los cambios, utilizando herramientas (por ejemplo, cargo-deny
; véase el punto 25) según sea necesario.
Por último, no tengas miedo de la versión 1.0.0 porque es un compromiso de que tu API ya está arreglada. Muchas cajas caen en la trampa de quedarse en la versión 0.x para siempre, pero eso reduce la ya limitada expresividad de semver de tres categorías (mayor/menor/parche) a dos (efectiva-mayor/efectiva-menor).
Semver para usuarios de cajas
Para el usuario de , las expectativas teóricas para una nueva versión de una dependencia son las siguientes:
-
A new patch version of a dependency crate Should Just Work’.
-
Una nueva versión menor de un crate de dependencia debería simplemente funcionar,™ pero puede merecer la pena explorar las nuevas partes de la API para ver si ahora hay formas más limpias o mejores de utilizar el crate. Sin embargo, si utilizas las partes nuevas, no podrás revertir la dependencia a la versión antigua.
-
Todas las apuestas están hechas para una nueva versión principal de una dependencia; lo más probable es que tu código ya no compile, y tendrás que reescribir partes de tu código para cumplir con la nueva API. Incluso si tu código sigue compilando, debescomprobar que tu uso de la API sigue siendo válido tras un cambio de versión importante, porque las restricciones y condiciones previas de la biblioteca pueden haber cambiado.
En la práctica, incluso los dos primeros tipos de cambio pueden provocar cambios de comportamiento inesperados, incluso en código que sigue compilándose bien, debido a la Ley de Hyrum.
Como consecuencia de estas expectativas, tus especificaciones de dependencia adoptarán normalmente una forma como "1.4.3"
o"0.7"
, que incluye versiones posteriores compatibles; evita especificar una dependencia completamente comodín como "*"
o "0.*"
. Una dependencia completamente comodín dice que tu crate puede utilizar cualquier versión de la dependencia, concualquier API, lo que probablemente no sea lo que realmente quieres.Evitar los comodines también es un requisito para la publicación de en crates.io
; los envíos con comodines "*"
seránrechazados.
Sin embargo, a largo plazo, no es seguro ignorar los cambios de versión importantes en las dependencias. Una vez que una biblioteca ha sufrido un cambio de versión importante, lo más probable es que no se realicen más correcciones de errores -y, lo que es más importante, actualizaciones de seguridad- en la versión importante anterior. Una especificación de versión como "1.4"
se quedará entonces cada vez más atrasada a medida que lleguen nuevas versiones 2.x, y los problemas de seguridad quedaránsin resolver.
Como resultado, tienes que aceptar los riesgos de quedarte atascado en una versión antigua o seguir eventualmente las actualizaciones de versiones importantes de tus dependencias. Herramientas como cargo update
o Dependabot(Artículo 31) pueden avisarte cuando haya actualizaciones disponibles; entonces puedes programar la actualización para un momento que te convenga.
Debate
El versionado semántico tiene un coste: cada cambio en una caja tiene que evaluarse según sus criterios, para decidir el tipo adecuado de salto de versión. El versionado semántico también es una herramienta poco precisa: en el mejor de los casos, refleja la suposición del propietario de una caja sobre a cuál de las tres categorías pertenece la versión actual. No todo el mundo acierta, no todo está claro sobre lo que significa exactamente "correcto", e incluso si aciertas, siempre existe la posibilidad de que caigas en la Ley de Hyrum.
Sin embargo, semver es el único juego en la ciudad para cualquiera que no tenga el lujo de trabajar en un entorno comoel altamente probado monorepo interno gigantesco de Google. Como tal, comprender sus conceptos y limitaciones es necesario para gestionar las dependencias.
Tema 22: Minimizar la visibilidad
Rust permite ocultar o exponer elementos del código a otras partes de la base de código. Este Tema explora los mecanismos previstos para ello y sugiere consejos sobre dónde y cuándo deben utilizarse.
Sintaxis de visibilidad
La unidad básica de visibilidad de Rust es el módulo. Por defecto, los elementos de un módulo (tipos, métodos, constantes) sonprivados y accesibles sólo al código del mismo módulo y de sus submódulos.
El código que necesita estar más ampliamente disponible se marca con la palabra clave pub
, haciéndolo público a algún otro ámbito. Para la mayoría de las funciones sintácticas de Rust, hacer que la función sea pub
no expone automáticamente elcontenido:los tipos y funciones de un pub mod
no son públicos, como tampoco lo son los campos de un pub struct
. Sin embargo, hay un par de excepciones en las que aplicar la visibilidad al contenido tiene sentido:
-
Al hacer público un
enum
, automáticamente también se hacen públicas las variantes del tipo (junto con los campos que puedan estar presentes en esas variantes). -
Al hacer público un
trait
, automáticamente también se hacen públicos los métodos del rasgo.
Es decir, una colección de tipos en un módulo:
pub
mod
somemodule
{
// Making a `struct` public does not make its fields public.
#[derive(Debug, Default)]
pub
struct
AStruct
{
// By default fields are inaccessible.
count
:i32
,
// Fields have to be explicitly marked `pub` to be visible.
pub
name
:String
,
}
// Likewise, methods on the struct need individual `pub` markers.
impl
AStruct
{
// By default methods are inaccessible.
fn
canonical_name
(
&
self
)
->
String
{
self
.
name
.
to_lowercase
()
}
// Methods have to be explicitly marked `pub` to be visible.
pub
fn
id
(
&
self
)
->
String
{
format!
(
"{}-{}"
,
self
.
canonical_name
(),
self
.
count
)
}
}
// Making an `enum` public also makes all of its variants public.
#[derive(Debug)]
pub
enum
AnEnum
{
VariantOne
,
// Fields in variants are also made public.
VariantTwo
(
u32
),
VariantThree
{
name
:String
,
value
:String
},
}
// Making a `trait` public also makes all of its methods public.
pub
trait
DoSomething
{
fn
do_something
(
&
self
,
arg
:i32
);
}
}
permite el acceso a pub
cosas y las excepciones anteriormente mencionadas:
use
somemodule
::*
;
let
mut
s
=
AStruct
::default
();
s
.
name
=
"Miles"
.
to_string
();
println!
(
"s = {:?}, name='{}', id={}"
,
s
,
s
.
name
,
s
.
id
());
let
e
=
AnEnum
::VariantTwo
(
42
);
println!
(
"e = {e:?}"
);
#[derive(Default)]
pub
struct
DoesSomething
;
impl
DoSomething
for
DoesSomething
{
fn
do_something
(
&
self
,
_arg
:i32
)
{}
}
let
d
=
DoesSomething
::default
();
d
.
do_something
(
42
);
pero las cosas que no son depub
suelen ser inaccesibles:
let
mut
s
=
AStruct
::default
();
s
.
name
=
"Miles"
.
to_string
();
println!
(
"(inaccessible) s.count={}"
,
s
.
count
);
println!
(
"(inaccessible) s.canonical_name()={}"
,
s
.
canonical_name
());
error[E0616]: field `count` of struct `somemodule::AStruct` is private --> src/main.rs:230:45 | 230 | println!("(inaccessible) s.count={}", s.count); | ^^^^^ private field error[E0624]: method `canonical_name` is private --> src/main.rs:231:56 | 86 | fn canonical_name(&self) -> String { | ---------------------------------- private method defined here ... 231 | println!("(inaccessible) s.canonical_name()={}", s.canonical_name()); | private method ^^^^^^^^^^^^^^ Some errors have detailed explanations: E0616, E0624. For more information about an error, try `rustc --explain E0616`.
El marcador de visibilidad más común es la palabra clave pub
, que hace que el elemento sea visible para cualquier cosa que pueda ver el módulo en el que se encuentra. Ese último detalle es importante: si un módulo somecrate::somemodule
no es visible para otro código en primer lugar, cualquier cosa que sea pub
dentro de él seguirá sin ser visible.
Sin embargo, también existen algunas variantes más específicas de pub
que permiten limitar el alcance de la visibilidad. Por orden decreciente de utilidad, son las siguientes:
pub(crate)
-
Accesible desde cualquier lugar dentro de la propia caja. Esto es especialmente útil para las funciones de ayuda internas de la caja que no deben estar expuestas a usuarios externos de la caja.
pub(super)
-
Accesible al módulo padre del módulo actual y sus submódulos. Esto es útil ocasionalmente para aumentar selectivamente la visibilidad en un cajón que tenga una estructura de módulos profunda. También es el nivel de visibilidad efectivo de los módulos: un
mod mymodule
plano es visible para su módulo o cajón padre y lossubmódulos correspondientes. pub(in <path>)
-
Accesible al código en
<path>
, que tiene que ser una descripción de algún módulo antepasado del módulo actual. En ocasiones, esto puede ser útil para organizar el código fuente, porque permite trasladar subconjuntos de funcionalidad a submódulos que no son necesariamente visibles en la API pública. Por ejemplo, la biblioteca estándar de Rust consolida todos losadaptadores de iteradores en un submódulo internostd::iter::adapters
y tiene lo siguiente:-
Un marcador de visibilidad
pub(in crate::iter)
en todos los métodos adaptadores necesarios en los submódulos, como por ejemplostd::iter::adapters::map::Map::new
. -
Un
pub use
de todos los tiposadapters::
del módulo exteriorstd::iter
.
-
pub(self)
-
Equivale a
pub(in self)
, que equivale a no serpub
. Los usos de esto son muy oscuros, como reducir el número de casos especiales necesarios en las macros de generación de código.
El compilador de Rust te advertirá si tienes un elemento de código que es privado para el módulo pero que no se utiliza dentro de ese módulo (y sus submódulos):
pub
mod
anothermodule
{
// Private function that is not used within its module.
fn
inaccessible_fn
(
x
:i32
)
->
i32
{
x
+
3
}
}
Aunque la advertencia indica que el código "nunca se utiliza" en su módulo propietario, en la práctica esta advertencia suele indicar que el código no puede utilizarse desde fuera del módulo, porque las restricciones de visibilidad no lo permiten:
warning: function `inaccessible_fn` is never used --> src/main.rs:56:8 | 56 | fn inaccessible_fn(x: i32) -> i32 { | ^^^^^^^^^^^^^^^ | = note: `#[warn(dead_code)]` on by default
Semántica de la visibilidad
Aparte de la cuestión de cómo aumentar la visibilidad, está la de cuándo hacerlo. La respuesta generalmente aceptada a esto es lo menos posible, al menos para cualquier código que pueda llegar a utilizarse y reutilizarse en el futuro.
La primera razón de este consejo es que los cambios de visibilidad pueden ser difíciles de deshacer. Una vez que un elemento de la caja es público, no puede volver a hacerse privado sin romper el código que utiliza la caja, lo que requiere un cambio de versión importante(punto 21). Lo contrario no es cierto: cambiar un elemento privado a público generalmente sólo requiere un pequeño cambio de versión y no afecta a los usuarios de crates; lee las directrices de compatibilidad de la API de Rust y observa que muchas sólo son relevantes si hay elementos pub
en juego.
Una razón más importante -aunque más sutil- para preferir la privacidad es que mantiene tus opciones abiertas. Cuantas más cosas se expongan, más cosas deberán permanecer fijas para el futuro (en ausencia de un cambio incompatible). Si expones los detalles de implementación interna de una estructura de datos, un posible cambio futuro para utilizar un algoritmo más eficiente se convierte en un cambio de ruptura. Si expones funciones internas de ayuda, es inevitable que algún código externo llegue a depender de los detalles exactos de esas funciones.
Por supuesto, esto es una preocupación sólo para el código de bibliotecas que potencialmente tiene múltiples usuarios y una larga vida útil. Pero nada es tan permanente como una solución temporal, por lo que es un buen hábito en el que caer.
También vale la pena observar que este consejo de restringir la visibilidad no es en absoluto exclusivo de este Artículo ni de Rust:
-
Las directrices de la API de Rust incluyen este consejo: los structs deben tener campos privados.
-
Java Eficaz, 3ª edición, (Addison-Wesley Professional) tiene lo siguiente:
-
Effective C++ de Scott Meyers (Addison-Wesley Professional) tiene lo siguiente en su segunda edición:
Tema 23: Evita las importaciones comodín
La sentencia use
de Rust extrae un elemento con nombre de otro crate o módulo y hace que ese nombre esté disponible para su uso en el código del módulo local sin cualificación. Una importación comodín (o glob import) de la forma use somecrate::module::*
dice que todo símbolo público de ese módulo debe añadirse al espacio de nombres local.
Como se describe en el Tema 21, un crate externo puede añadir nuevos elementos a su API como parte de una actualización de versión menor; esto se considera un cambio compatible con versiones anteriores.
La combinación de estas dos observaciones suscita la preocupación de que un cambio no rupturista en una dependencia pueda romper tu código: ¿qué ocurre si la dependencia añade un nuevo símbolo que choca con un nombre que ya estás utilizando?
En el nivel más sencillo, esto resulta no ser un problema: los nombres de una importación comodín se tratan como de menor prioridad, por lo que cualquier nombre coincidente que haya en tu código tiene prioridad:
use
bytes
::*
;
// Local `Bytes` type does not clash with `bytes::Bytes`.
struct
Bytes
(
Vec
<
u8
>
);
Por desgracia, aún hay casos en los que pueden producirse choques. Por ejemplo, considera el caso en que la dependencia añade un nuevo rasgo y lo implementa para algún tipo:
trait
BytesLeft
{
// Name clashes with the `remaining` method on the wildcard-imported
// `bytes::Buf` trait.
fn
remaining
(
&
self
)
->
usize
;
}
impl
BytesLeft
for
&
[
u8
]
{
// Implementation clashes with `impl bytes::Buf for &[u8]`.
fn
remaining
(
&
self
)
->
usize
{
self
.
len
()
}
}
Si algún nombre de método del nuevo trait clash con nombres de métodos existentes que se apliquen al tipo, entonces el compilador ya no podrá averiguar sin ambigüedad a qué método se refiere:
como indica el error de compilación:
error[E0034]: multiple applicable items in scope --> src/main.rs:40:18 | 40 | assert_eq!(v.remaining(), 2); | ^^^^^^^^^ multiple `remaining` found | note: candidate #1 is defined in an impl of the trait `BytesLeft` for the type `&[u8]` --> src/main.rs:18:5 | 18 | fn remaining(&self) -> usize { | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ = note: candidate #2 is defined in an impl of the trait `bytes::Buf` for the type `&[u8]` help: disambiguate the method for candidate #1 | 40 | assert_eq!(BytesLeft::remaining(&v), 2); | ~~~~~~~~~~~~~~~~~~~~~~~~ help: disambiguate the method for candidate #2 | 40 | assert_eq!(bytes::Buf::remaining(&v), 2); | ~~~~~~~~~~~~~~~~~~~~~~~~~
En consecuencia, debes evitar las importaciones comodín de cajas que no controles.
Si controlas el origen de la importación comodín, desaparecen las preocupaciones antes mencionadas. Por ejemplo, es habitual que un módulo de test
haga use super::*;
. También es posible que las cajas que utilizan módulos principalmente como forma de dividir el código tengan una importación comodín de un módulo interno:
mod
thing
;
pub
use
thing
::*
;
Sin embargo, hay otra excepción común en la que las importaciones comodín tienen sentido. Algunos crates tienen la convención de que los elementos comunes del crate se reexportan desde un módulo prelude, que está explícitamente pensado para ser importado con comodines:
use
thing
::prelude
::*
;
Aunque en teoría se aplican las mismas preocupaciones en este caso, en la práctica es probable que un módulo preludio de este tipo esté cuidadosamente comisariado, y una mayor comodidad puede pesar más que un pequeño riesgo de problemas futuros.
Por último, si no sigues los consejos de este Tema, considera la posibilidad de fijar las dependencias que importes con comodín a una versión precisa (véase el Tema 21), de modo que no se permitan automáticamente las actualizaciones de versiones menores de la dependencia.
Tema 24: Reexportar las dependencias cuyos tiposaparecen en tu API
El título de este artículo es un poco enrevesado, pero con un ejemplo las cosas quedarán más claras.3
El punto 25 describe cómo cargo
permite enlazar diferentes versiones de la misma biblioteca en un único binario, de forma transparente. Considera un binario que utilice la rand
más concretamente, uno que utilice una versión 0.8 del crate:
# Cargo.toml file for a top-level binary crate.
[dependencies]
# The binary depends on the `rand` crate from crates.io
rand
=
"=0.8.5"
# It also depends on some other crate (`dep-lib`).
dep-lib
=
"0.1.0"
// Source code:
let
mut
rng
=
rand
::thread_rng
();
// rand 0.8
let
max
:usize
=
rng
.
gen_range
(
5
..
10
);
let
choice
=
dep_lib
::pick_number
(
max
);
La última línea de código también utiliza un crate nocional de dep-lib
como otra dependencia. Este crate podría ser otro crate de crates.io
, o podría ser un crate local que se localiza a través del mecanismopath
de Cargo.
Esta caja dep-lib
utiliza internamente una versión 0.7 de la caja rand
:
# Cargo.toml file for the `dep-lib` library crate.
[dependencies]
# The library depends on the `rand` crate from crates.io
rand
=
"=0.7.3"
// Source code:
//! The `dep-lib` crate provides number picking functionality.
use
rand
::Rng
;
/// Pick a number between 0 and n (exclusive).
pub
fn
pick_number
(
n
:usize
)
->
usize
{
rand
::thread_rng
().
gen_range
(
0
,
n
)
}
Un lector avispado podría notar una diferencia entre los dos ejemplos de código:
-
En la versión 0.7.x de
rand
(utilizada por la bibliotecadep-lib
), el métodorand::gen_range()
toma dos parámetros,low
yhigh
. -
En la versión 0.8.x de
rand
(la que utiliza la caja binaria), el métodorand::gen_range()
toma un único parámetrorange
.
No se trata de un cambio retrocompatible, por lo que rand
ha aumentado en consecuencia su componente de versión más a la izquierda, como exige el versionado semántico(punto 21). No obstante, el binario que combina las dos versiones incompatibles funciona perfectamente:cargo
lo arregla todo.
Sin embargo, las cosas se ponen mucho más complicadas si la API de la biblioteca dep-lib
expone un tipo de su dependencia, convirtiendo esa dependencia en una dependencia pública.
Por ejemplo, supongamos que el punto de entrada dep-lib
implica un elemento Rng
, pero concretamente un elemento Rng
de la versión-0.7:
/// Pick a number between 0 and n (exclusive) using
/// the provided `Rng` instance.
pub
fn
pick_number_with
<
R
:Rng
>
(
rng
:&
mut
R
,
n
:usize
)
->
usize
{
rng
.
gen_range
(
0
,
n
)
// Method from the 0.7.x version of Rng
}
Por otra parte, piénsatelo bien antes de utilizar los tipos de otra crate en tu API: vincula íntimamente tu crate a la de la dependencia. Por ejemplo, un cambio importante en la versión de la dependencia(punto 21) requerirá automáticamente un cambio importante en la versión de tu crate.
En este caso, rand
es un crate semiestándar que se utiliza mucho y sólo tiene un pequeño número de dependencias propias(elemento 25), por lo que incluir sus tipos en la API de crates probablemente esté bien en conjunto.
Volviendo al ejemplo, un intento de utilizar este punto de entrada desde el binario de nivel superior falla:
Inusualmente para Rust, el mensaje de error del compilador no es muy útil:
error[E0277]: the trait bound `ThreadRng: rand_core::RngCore` is not satisfied --> src/main.rs:22:44 | 22 | let choice = dep_lib::pick_number_with(&mut rng, max); | ------------------------- ^^^^^^^^ the trait | | `rand_core::RngCore` is not | | implemented for `ThreadRng` | | | required by a bound introduced by this call | = help: the following other types implement trait `rand_core::RngCore`: &'a mut R
Investigar los tipos implicados lleva a confusión, porque los rasgos relevantes parecen estar implementados, pero quien llama implementa en realidad un RngCore_v0_8_5
(nocional) y la biblioteca espera una implementación deRngCore_v0_7_3
.
Una vez que por fin has descifrado el mensaje de error y te has dado cuenta de que el choque de versiones es la causa subyacente, ¿cómo puedes solucionarlo?4 La observación clave es darse cuenta de que, aunque el binario no puede utilizar directamente dos versiones diferentes de la misma crate, sí puede hacerlo indirectamente (como en el ejemplo original mostrado anteriormente).
Desde el punto de vista del autor del binario, el problema podría solucionarse añadiendo una crate envolvente intermedia que oculte el uso desnudo de los tipos de rand
v0.7. Un wrapper crate es distinto del crate binario y, por tanto, puede depender de rand
v0.7 independientemente de la dependencia del crate binario de rand
v0.8.
Esto es incómodo, y el autor de la biblioteca crate dispone de un enfoque mucho mejor. Puede facilitar la vida a sus usuarios reexportando explícitamente cualquiera de lassiguientes opciones:
-
Los tipos implicados en la API
-
Toda la caja de dependencias
Para este ejemplo, este último enfoque funciona mejor: además de poner a disposición los tipos de la versión 0.7 Rng
y RngCore
, también pone a disposición los métodos (como thread_rng()
) que construyen instancias del tipo:
// Re-export the version of `rand` used in this crate's API.
pub
use
rand
;
El código de llamada tiene ahora una forma diferente de referirse directamente a la versión 0.7 de rand
, como dep_lib::rand
:
let
mut
prev_rng
=
dep_lib
::rand
::thread_rng
();
// v0.7 Rng instance
let
choice
=
dep_lib
::pick_number_with
(
&
mut
prev_rng
,
max
);
Con este ejemplo en mente, el consejo que se da en el título del Artículo debería ser ahora un poco menos oscuro: reexporta las dependencias cuyos tipos aparezcan en tu API.
Tema 25: Gestiona tu gráfico de dependencias
Como la mayoría de los lenguajes de programación modernos, Rust facilita la incorporación de bibliotecas externas, en forma de crates. La mayoría de los programas Rust no triviales utilizan crates externas, y esas crates pueden tener a su vez dependencias adicionales, formando un gráfico de dependencias para el programa en su conjunto.
[dependencies]
Por defecto, Cargo descargará de crates.io
y buscará versiones de esos crates que cumplan los requisitos configurados enCargo.toml.
Bajo esta simple afirmación se esconden algunas sutilezas. Lo primero que hay que tener en cuenta es que los nombres de los crates decrates.io
forman un único espacio de nombres plano, y este espacio de nombres global también se solapa con los nombres de las características de un crate (ver Tema 26).5
Si estás pensando en publicar un cajón en crates.io
, ten en cuenta que los nombres suelen asignarse por orden de llegada, por lo que es posible que tu nombre preferido para un cajón público ya esté ocupado. Sin embargo, el name-squatting -reservar el nombre de un cajón prerregistrando un cajón vacío- está mal visto, a menos que realmente vayas a publicar código en un futuro próximo.
Como pequeño detalle, también hay una pequeña diferencia entre lo que se permite como nombre de crate en el espacio de nombres crates y lo que se permite como identificador en el código: un crate puede llamarse some-crate
, pero en el código aparecerá como some_crate
(con guión bajo). Dicho de otro modo: si ves some_crate
en el código, el nombre de crate correspondiente puede ser some-crate
o some_crate
.
La segunda sutileza que hay que entender es que Cargo permite que en la compilación haya varias versiones semicompatibles del mismo crate. Para empezar, esto puede parecer sorprendente, porque cada archivo Cargo.toml sólo puede tener una única versión de cualquier dependencia, pero la situación se plantea con frecuencia con dependencias indirectas: tu crate depende desome-crate
versión 3.x pero también depende de older-crate
, que a su vez depende de some-crate
versión 1.x.
Esto puede llevar a confusión si la dependencia se expone de alguna forma en lugar de utilizarse internamente(punto 24): el compilador tratará las dos versiones como si fueran cajas distintas, pero sus mensajes de error no lo dejarán claro necesariamente.
Permitir múltiples versiones de un crate también puede salir mal si el crate incluye código C/C++ al que se accede mediante los mecanismos FFI de Rust(Tema 34). La cadena de herramientas de Rust puede desambiguar internamente distintas versiones de código Rust, pero cualquier código C/C++ incluido está sujeto a la regla de una definición: sólo puede haber una única versión de cualquier función, constante o variable global.
Existen restricciones en el soporte de múltiples versiones de Cargo. Cargo no permite múltiples versiones de la misma caja dentro de un rango semicompatible(punto 21):
-
some-crate
1.2 ysome-crate
3.1 pueden coexistir -
some-crate
1.2 ysome-crate
1.3 no pueden
Cargo también amplía las reglas semánticas de versionado para las cajas pre-1.0, de modo que la primera subversión distinta de cero cuenta como una versión mayor, por lo que se aplica una restricción similar:
-
other-crate
0.1.2 yother-crate
0.2.0 pueden coexistir -
other-crate
0.1.2 yother-crate
0.1.4 no pueden
El algoritmo de selección de versiones de Cargo se encarga de averiguar qué versiones incluir. Cada línea de dependencia de Cargo.toml especifica un rango aceptable de versiones, según las reglas semánticas de versionado, y Cargo lo tiene en cuenta cuando el mismo crate aparece en varios lugares del gráfico de dependencias. Si los rangos aceptables se solapan y son semicompatibles, Cargo elegirá (por defecto) la versión más reciente del crate dentro del solapamiento. Si no hay solapamiento semicompatible, Cargo creará varias copias de la dependencia con versiones diferentes.
Una vez que Cargo ha elegido versiones aceptables para todas las dependencias, sus elecciones se registran en el archivo Cargo .lock.Las construcciones posteriores reutilizarán las elecciones codificadas en Cargo.lock para que la construcción sea estable y no se necesiten nuevas descargas.
Esto te deja con una elección: ¿debes confirmar tus archivos Cargo.lock en el control de versiones o no? El consejo de los desarrolladores de Cargoes el siguiente:
-
Las cosas que producen un producto final, es decir, aplicaciones y binarios, deben confirmar Cargo.lock para garantizar una construcción determinista.
-
Los "crates" de biblioteca no deben crear un archivo Cargo.lock, porque es irrelevante para cualquier consumidor posterior de la biblioteca: ellos tendrán su propio archivo Cargo.lock; ten en cuenta que el archivo Cargo.lock de un "crate" de biblioteca es ignorado por los usuarios de la biblioteca.
Incluso para una crate de biblioteca, puede ser útil tener un archivo Cargo.lock comprobado para garantizar que las construcciones regulares y el CI(Punto 32) no tengan un objetivo móvil. Aunque las promesas del versionado semántico(Tema 21) deberían evitar los fallos en teoría, en la práctica se producen errores, y es frustrante tener construcciones que fallan porque alguien, en algún lugar, ha cambiado recientemente una dependencia de una dependencia.
Sin embargo, si controlas las versiones de Cargo.lock, establece un proceso para gestionar las actualizaciones (como Dependabot de GitHub). Si no lo haces, tus dependencias permanecerán ancladas a versiones cada vez más antiguas, obsoletas y potencialmente inseguras.
Fijar versiones con un archivo Cargo.lock comprobado no evita el dolor de manejar las actualizaciones de dependencias, pero significa que puedes manejarlas en el momento que elijas, en lugar de hacerlo inmediatamente cuando cambie el crate aguas arriba. También hay una parte de los problemas de actualización de dependencias que desaparecen por sí solos: un crate que se publica con un problema, a menudo recibe una segunda versión corregida en un corto espacio de tiempo, y un proceso de actualización por lotes podría ver sólo esta última versión.
La tercera sutileza del proceso de resolución de Cargo que hay que tener en cuenta es la unificación de características: las características que se activan para una caja dependiente son la unión de las características seleccionadas por distintos lugares del gráfico de dependencias; para más detalles, consulta el Tema 26.
Especificación de la versión
La cláusula de especificación de versión de una dependencia define un rango de versiones permitidas, según lasreglas explicadas en el libro Cargo:
- Evita una dependencia de versión demasiado específica
-
Anclar a una versión concreta (
"=1.2.3"
) suele ser una mala idea: no ves las versiones más recientes (que pueden incluir correcciones de seguridad), y reduces drásticamente el rango de solapamiento potencial con otras crates del gráfico que dependen de la misma dependencia (recuerda que Cargo sólo permite utilizar una única versión de una crate dentro de un rango compatible con semver). Si quieres asegurarte de que tus construcciones utilizan un conjunto coherente de dependencias, el archivo Cargo.lock es la herramienta adecuada. - Evita una dependencia de versión demasiado general
-
Es posible especificar una dependencia de versión (
"*"
) que permita utilizar cualquier versión de la dependencia, pero es una mala idea. Si la dependencia lanza una nueva versión principal de la crate que cambia por completo todos los aspectos de su API, es poco probable que tu código siga funcionando después de quecargo update
incorpore la nuevaversión.
La especificación Ricitos de Oro más común de -ni demasiado precisa, ni demasiado vaga- es permitir versiones semicompatibles ("1"
) de un crate, posiblemente con una versión mínima específica que incluya una característica o corrección que necesites ("1.4.23"
). Ambas especificaciones de versión utilizan el comportamiento por defecto de Cargo, que es permitir versiones que sean semicompatibles con la versión especificada. Puedes hacerlo más explícito añadiendo un signo de intercalación:
-
Una versión de
"1"
es equivalente a"^1"
, que permite todas las versiones 1.x (y, por tanto, también es equivalente a"1.*"
). -
Una versión de
"1.4.23"
es equivalente a"^1.4.23"
, que permite cualquier versión 1.x superior a 1.4.23.
Resolver problemas con las herramientas
El Tema 31 recomienda que aproveches la gama de herramientas disponibles en el ecosistema de Rust. Esta sección describe algunos problemas de grafos de dependencia en los que las herramientas pueden ser de ayuda.
El compilador te avisará rápidamente si utilizas una dependencia en tu código pero no incluyes esa dependencia enCargo.toml. Pero, ¿y al revés? Si hay una dependencia en Cargo. toml que no utilizas en tu código -o, lo que es más probable, que ya no utilices en tu código-, Cargo seguirá a lo suyo. La herramienta cargo-udeps
está diseñada para resolver exactamente este problema: te avisa cuando tu Cargo. toml incluye una dependencia no utilizada ("udep").
Una herramienta más versátil es cargo-deny
que analiza tu gráfico de dependencias para detectar una serie de posibles problemas en todo el conjunto de dependencias transitivas:
-
Dependencias que tienen problemas de seguridad conocidos en la versión incluida
-
Dependencias cubiertas por una licencia inaceptable
-
Dependencias simplemente inaceptables
-
Dependencias que se incluyen en varias versiones diferentes a través del árbol de dependencias
Cada una de estas características puede configurarse y puede tener excepciones especificadas. El mecanismo de excepciones suele ser necesario para los proyectos más grandes, sobre todo la advertencia de versiones múltiples: a medida que crece el gráfico de dependencias, también lo hace la posibilidad de depender transitoriamente de distintas versiones del mismo crate. Merece la pena intentar reducir estos duplicados siempre que sea posible -por razones de tamaño binario y tiempo de compilación, entre otras-, pero a veces no hay ninguna combinación posible de versiones de dependencia que pueda evitar un duplicado.
Estas herramientas pueden ejecutarse una sola vez, pero es mejor asegurarse de que se ejecutan con regularidad y fiabilidad incluyéndolas en tu sistema CI(punto 32). Esto ayuda a detectar problemas recién introducidos, incluidos los que puedan haberse introducido fuera de tu código, en una dependencia del flujo ascendente (por ejemplo, una vulnerabilidad de la que se haya informado recientemente).
Si una de estas herramientas informa de un problema, puede ser difícil averiguar exactamente en qué parte del gráfico de dependencias surge el problema. El comando cargo tree
que se incluye en cargo
ayuda en este caso, ya que muestra el gráfico de dependencias como una estructura de árbol:
dep-graph v0.1.0 ├── dep-lib v0.1.0 │ └── rand v0.7.3 │ ├── getrandom v0.1.16 │ │ ├── cfg-if v1.0.0 │ │ └── libc v0.2.94 │ ├── libc v0.2.94 │ ├── rand_chacha v0.2.2 │ │ ├── ppv-lite86 v0.2.10 │ │ └── rand_core v0.5.1 │ │ └── getrandom v0.1.16 (*) │ └── rand_core v0.5.1 (*) └── rand v0.8.3 ├── libc v0.2.94 ├── rand_chacha v0.3.0 │ ├── ppv-lite86 v0.2.10 │ └── rand_core v0.6.2 │ └── getrandom v0.2.3 │ ├── cfg-if v1.0.0 │ └── libc v0.2.94 └── rand_core v0.6.2 (*)
cargo tree
incluye una variedad de opciones que pueden ayudar a resolver problemas concretos, como éstos:
--invert
-
Muestra lo que depende de un paquete específico, ayudándote a centrarte en una dependencia problemática concreta
--edges features
-
Muestra qué características de la caja están activadas por un enlace de dependencia, lo que te ayuda a averiguar qué está pasando con la unificación de características(Tema 26)
--duplicates
-
Muestra las cajas que tienen varias versiones presentes en el gráfico de dependencias
De qué depender
Las secciones anteriores han cubierto el aspecto más mecánico de trabajar con dependencias, pero hay una pregunta más filosófica (y, por tanto, más difícil de responder): ¿cuándo debes asumir una dependencia?
La mayoría de las veces, no hay mucho que decidir: si necesitas la funcionalidad de un crate, necesitas esa función, y la única alternativa sería escribirla tú mismo.6
Pero cada nueva dependencia tiene un coste, en parte en términos de construcciones más largas y binarios más grandes, pero sobre todo en términos del esfuerzo de los desarrolladores para solucionar los problemas con las dependencias cuando surgen.
Cuanto mayor sea tu gráfico de dependencias, más probabilidades tendrás de exponerte a este tipo de problemas. El ecosistema de crates de Rust es tan vulnerable a los problemas de dependencias accidentales como otros ecosistemas de paquetes, en los que la historia ha demostrado que un desarrollador que elimina un paquete, o un equipo que arregla la licencia de su paquete, puede tener efectos en cadena generalizados.
Más preocupantes aún son los ataques a la cadena de suministro , en los que un actor malintencionado intenta deliberadamente subvertir las dependencias de uso común, ya sea mediante el typo-squatting, el secuestro de la cuenta de un mantenedor u otros ataques más sofisticados.
Este tipo de ataque no sólo afecta a tu código compilado: ten en cuenta que una dependencia puede ejecutar código arbitrario en tiempo decompilación, a través de build.rs
scripts o macros procedimentales(Tema 28). Esto significa que una dependencia comprometida podría acabar ejecutando un minero de criptomonedas como parte de tu sistema de IC.
Así que para las dependencias que son más "cosméticas", a veces merece la pena plantearse si añadir la dependencia merece la pena.
Sin embargo, la respuesta suele ser "sí"; al final, el tiempo empleado en solucionar los problemas de dependencia acaba siendo mucho menor que el que se tardaría en escribir una funcionalidad equivalente desde cero.
Cosas para recordar
-
Los nombres de las cajas en
crates.io
forman un único espacio de nombres plano (que se comparte con los nombres de las características). -
Los nombres de las cajas pueden incluir un guión, pero aparecerá como un guión bajo en el código.
-
Cargo admite varias versiones de un mismo crate en el gráfico de dependencias, pero sólo si son de versiones diferentes e incompatibles entre sí. Esto puede ir mal en el caso de crates que incluyan código FFI.
-
Prefiere permitir versiones semicompatibles de las dependencias (
"1"
, o"1.4.23"
para incluir una versión mínima). -
Utiliza los archivos Cargo. lock para asegurarte de que tus construcciones son repetibles, pero recuerda que el archivo Cargo.lock no se envía con una caja publicada.
-
Utiliza herramientas (
cargo tree
,cargo deny
,cargo udep
, ...) para ayudar a encontrar y solucionar problemas de dependencia. -
Comprende que incorporar dependencias te ahorra escribir código, pero no es gratis.
Tema 26: Desconfía de feature
creep
Rust permite que la misma base de código admita una variedad de configuraciones diferentes mediante el mecanismo de características de Cargo, que se construye sobre un mecanismo de nivel inferior para la compilación condicional. Sin embargo, el mecanismo de características tiene algunas sutilezas que hay que conocer, y que este Artículo explora.
Compilación condicional
Rust incluye soporte para la compilación condicional, que se controla mediante cfg
(y cfg_attr
).Estos atributos determinan si la cosa -función, línea, bloque, etc.- a la que se adjuntan se incluye o no en el código fuente compilado (a diferencia del preprocesador basado en líneas de C/C++). La inclusión condicional se controla mediante opciones de configuración que son nombres simples (por ejemplo, test
) o pares de nombres y valores (por ejemplo, panic = "abort"
).
Ten en cuenta que las variantes nombre/valor de las opciones de configuración son multivaluadas: es posible establecer más de un valor para el mismo nombre:
// Build with `RUSTFLAGS` set to:
// '--cfg myname="a" --cfg myname="b"'
#[cfg(myname =
"a"
)]
println!
(
"cfg(myname = 'a') is set"
);
#[cfg(myname =
"b"
)]
println!
(
"cfg(myname = 'b') is set"
);
cfg(myname = 'a') is set cfg(myname = 'b') is set
Aparte de los valores de feature
descritos en esta sección, los valores de configuración más utilizados son los que la cadena de herramientas rellena automáticamente, con valores que describen el entorno de destino para la construcción. Estos valores incluyen el sistema operativo (target_os
), la arquitectura de la CPU (target_arch
), la anchura del puntero(target_pointer_width
) y el idioma (target_endian
Esto permite la portabilidad del código, ya que las características específicas de un objetivo concreto sólo se compilan cuando se construye para ese objetivo.
La opción estándar target_has_atomic
también proporciona un ejemplo de la naturaleza multivaluada de los valores de configuración: tanto [cfg(target_has_atomic = "32")]
como[cfg(target_has_atomic = "64")]
se establecerán para los objetivos que admitan operaciones atómicas tanto de 32 como de 64 bits. (Para más información sobre los atómicos, consulta el Capítulo 2 de Rust Atomics and Locks [O'Reilly] de Mara Bos).
Características
El gestor de paquetes Cargo se basa en este mecanismo de nombre/valor de la base cfg
para proporcionar el concepto de características: aspectos selectivos nombrados de la funcionalidad de un crate que pueden activarse al compilarlo. Cargo se asegura de que la opción feature
se rellena con cada uno de los valores configurados para cada crate que compila, y los valores son específicos de cada crate.
Se trata de una funcionalidad específica de Cargo: para el compilador de Rust, feature
no es más que otra opción de configuración.
En el momento de escribir esto, la forma más fiable de determinar qué funciones están disponibles para una caja es examinar el archivo de manifiesto Cargo.toml de la caja. Por ejemplo, el siguiente fragmento de un archivo de manifiesto incluye seis funciones:
[features]
default
=
[
"featureA"
]
featureA
=
[]
featureB
=
[]
# Enabling `featureAB` also enables `featureA` and `featureB`.
featureAB
=
[
"featureA"
,
"featureB"
]
schema
=
[]
[dependencies]
rand
=
{
version
=
"^0.8"
,
optional
=
true
}
hex
=
"^0.4"
Dado que sólo hay cinco entradas en la estrofa [features]
, está claro que hay que prestar atención a un par de sutilezas.
La primera es que la línea default
de la estrofa [features]
es un nombre de característica especial, que se utiliza para indicar a cargo
cuáles de las características deben estar activadas por defecto. Estas funciones pueden desactivarse pasando la bandera --no-default-features
al comando de compilación , y un consumidor del crate puede codificar esto en su archivoCargo.toml de la siguiente manera:
[dependencies]
somecrate
=
{
version
=
"^0.3"
,
default-features
=
false
}
Sin embargo, default
sigue contando como un nombre de función, que puede probarse en código:
#[cfg(feature =
"default"
)]
println!
(
"This crate was built with the
\"
default
\"
feature enabled."
);
#[cfg(not(feature =
"default"
))]
println!
(
"This crate was built with the
\"
default
\"
feature disabled."
);
La segunda sutileza de las definiciones de las características se oculta en la sección [dependencies]
del ejemplo original Cargo.toml: el crate rand
es una dependencia que se marca como optional = true
, y que convierte de hecho a "rand"
en el nombre de una característica.7 Si el crate se compila con --features rand
, entonces se activa esa dependencia:
#[cfg(feature =
"rand"
)]
pub
fn
pick_a_number
()
->
u8
{
rand
::random
::<
u8
>
()
}
#[cfg(not(feature =
"rand"
))]
pub
fn
pick_a_number
()
->
u8
{
4
// chosen by fair dice roll.
}
Esto también significa que los nombres de las crates y los nombres de las características comparten un espacio de nombres, aunque uno suele ser global (y normalmente se rige por crates.io
), y el otro es local a la crate en cuestión. Por lo tanto, elige los nombres de las características con cuidado para evitar conflictos con los nombres de las crates que puedan ser relevantes como posibles dependencias. Es posible evitar un conflicto, porque Cargo incluye un mecanismo que permite cambiar el nombre de los crates importados (la teclapackage
), pero es más fácil no tener que hacerlo.
Así que puedes determinar las características de un crate examinando [features]
así como optional
[dependencies]
en el archivo Cargo.toml del crate. Para activar una característica de una dependencia, añade la opción features
a la línea correspondiente de la estrofa[dependencies]
de tu propio archivo de manifiesto:
[dependencies]
somecrate
=
{
version
=
"^0.3"
,
features
=
[
"featureA"
,
"rand"
]
}
Esta línea garantiza que somecrate
se construirá con las características featureA
y rand
activadas. Sin embargo, puede que no sean las únicas características activadas; también pueden activarse otras características debido a un fenómeno conocido como unificación de características. Esto significa que un crate se construirá con la unión de todas las características solicitadas por cualquier cosa en el gráfico de construcción. En otras palabras, si alguna otra dependencia del gráfico de construcción también depende de somecrate
, pero sólo tiene habilitada featureB
, el cajón se construirá con todas las características featureA
, featureB
y rand
habilitadas, para satisfacer a todos.8 La misma consideración se aplica a las características por defecto: si tu crate establece default-features = false
para una dependencia pero algún otro lugar del gráfico de construcción deja las características por defecto habilitadas, entonces estarán habilitadas.
La unificación de funciones significa que las funciones deben ser aditivas; es una mala idea tener funciones incompatibles entre sí, porque no hay nada que impida que las funciones incompatibles sean activadas simultáneamente por distintos usuarios.
Por ejemplo, si un crate expone públicamente un struct
y sus campos, es una mala idea hacer que los campos dependan de la función:
Un usuario del cajón que intenta construir una instancia del struct
tiene un dilema: ¿debe rellenar el campo schema
o no? Una forma de intentar resolverlo es añadir la función correspondiente en el Cargo.toml del usuario:
[features]
# The `use-schema` feature here turns on the `schema` feature of `somecrate`.
# (This example uses different feature names for clarity; real code is more
# likely to reuse the feature names across both places.)
use-schema
=
[
"somecrate/schema"
]
y hacer que la construcción de struct
dependa de esta característica:
Sin embargo, esto no cubre todas las eventualidades: el código fallará al compilar si este código no activasomecrate/schema
pero alguna otra dependencia transitiva sí lo hace. El núcleo del problema es que sólo el crate que tiene la función puede comprobar la función; no hay forma de que el usuario del crate determine si Cargo ha activadosomecrate/schema
o no. En consecuencia, debes evitar los campos públicos con características en las estructuras.
Una consideración similar se aplica a los rasgos públicos, destinados a ser utilizados fuera de la caja en la que están definidos. Considera un rasgo que incluye una puerta de función en uno de sus métodos:
Los implementadores externos del rasgo se encuentran de nuevo ante un dilema: ¿deben implementar el método cddl(&self)
o no? El código externo que intenta implementar el rasgo no sabe -y no puede saber- si debe implementar el método de rasgo o no.
Así que la red es que debes evitar los métodos de feature-gating en traits públicos. Un método trait con una implementación por defecto(punto 13) podría ser una excepción parcial a esto, pero sólo si nunca tiene sentido que el código externo anule el valor por defecto.
La unificación de características también significa que si tu caja tiene N características independientes9 entonces todas las 2N combinaciones de construcción posibles pueden darse en la práctica. Para evitar sorpresas desagradables, es una buena idea asegurarte de que tu sistema de CI(punto 32) cubre todas estas 2N combinaciones, en todas las variantes de prueba disponibles(punto 30).
Sin embargo, el uso de características opcionales es muy útil para controlar la exposición a un gráfico de dependencias ampliado(Tema 25). Esto es especialmente útil en las cajas de bajo nivel que se pueden utilizar en un entorno no_std
(Tema 33)-es habitual tener una función std
o alloc
que activa la funcionalidad que depende de esas bibliotecas.
Cosas para recordar
-
Los nombres de las características se solapan con los nombres de las dependencias.
-
Los nombres de las características deben elegirse cuidadosamente para que no choquen con los nombres de las posibles dependencias.
-
Las características deben ser aditivas.
-
Evita las puertas de rasgo en los campos públicos
struct
o en los métodos de rasgo. -
Tener muchas características independientes puede llevar a una explosión combinatoria de diferentes configuraciones de construcción.
1 Con la notable excepción de C y C++, donde la gestión de paquetes sigue estando algo fragmentada.
2 Por ejemplo, cargo-semver-checks
es una herramienta que intenta hacer algo parecido.
3 Este ejemplo (y de hecho Item) se inspira en el enfoque utilizado en las cajas RustCrypto.
4 Este tipo de error puede aparecer incluso cuando el gráfico de dependencias incluye dos alternativas para un crate con la misma versión, cuando algo en el gráfico de construcción utiliza el campo path
para especificar un directorio local en lugar de una ubicación crates.io
.
5 También es posible configurar un registro alternativo de cajas (por ejemplo, un registro corporativo interno). Cada entrada de dependencia en Cargo.toml puede utilizar la clave registry
para indicar de qué registro debe proceder una dependencia.
6 Si tienes como objetivo un entorno no_std
, puede que esta elección se haga por ti: muchas cajas no son compatibles con no_std
, sobre todo si alloc
tampoco está disponible(punto 33).
7 Este comportamiento predeterminado puede desactivarse utilizando una referencia "dep:<crate>"
en otra parte de la estrofa features
; consulta la documentación para obtener más detalles.
8 El comando cargo tree --edges features
puede ayudar a determinar qué funciones están activadas para qué cajas, y por qué.
9 Las funciones pueden forzar la activación de otras funciones; en el ejemplo original, la función featureAB
fuerza la activación de featureA
y featureB
.
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.