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.

Tabla 4-1. Especificación de la versión de la dependencia de carga
Especificación 1.2.2 1.2.3 1.2.4 1.3.0 2.0.0

"1.2.3"

No

No

"^1.2.3"

No

No

"=1.2.3"

No

No

No

No

"~1.2.3"

No

No

No

"1.2.*"

No

No

"1.*"

No

"*"

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:

  1. 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:

  • Cambiar algo requiere una nueva versión del parche.

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

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

  • 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 un enum debe cubrir todas las posibilidades, por lo que si un crate añade una nueva variante enum , eso es un cambio de ruptura (a menos que el enum ya esté marcado como non_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; un struct también puede marcarsecomo non_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:

  1. Publica una actualización de versión menor que incluya la nueva versión de la API y que marque la variante antigua comodeprecatedincluyendo una indicación de cómo migrar.

  2. 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 interno std::iter::adapters y tiene lo siguiente:

pub(self)

Equivale a pub(in self), que equivale a no ser pub. 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:

    • Tema 15: Minimizar la accesibilidad de clases y miembros.

    • Tema 16: En las clases públicas, utiliza métodos de acceso, no campos públicos.

  • Effective C++ de Scott Meyers (Addison-Wesley Professional) tiene lo siguiente en su segunda edición:

    • Punto 18: Esfuérzate por que las interfaces de clase sean completas y mínimas (cursiva mía).

    • Tema 20: Evita los miembros de datos en la interfaz pública.

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 biblioteca dep-lib ), el métodorand::gen_range() toma dos parámetros, low y high.

  • En la versión 0.8.x de rand (la que utiliza la caja binaria), el métodorand::gen_range() toma un único parámetro range.

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 Rngde 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 y some-crate 3.1 pueden coexistir

  • some-crate 1.2 y some-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 y other-crate 0.2.0 pueden coexistir

  • other-crate 0.1.2 y other-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 que cargo 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-denyque 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_endianEsto 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 schemao 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.