Item 23: Re-export dependencies whose types appear in your API

The title of this Item is a little convoluted, but working through an example will make things clearer.

Item 24 described how cargo supports different versions of the same library crate being linked into a single binary, in a transparent manner. Consider a binary that uses the rand crate; more specifically, one which uses some 0.8 version of the crate:

# Top-level binary crate
[dependencies]
dep-lib = "0.1.0"
rand = "0.8.*"
    let mut rng = rand::thread_rng(); // rand 0.8
    let max: usize = rng.gen_range(5..10);
    let choice = dep_lib::pick_number(max);

The final line of code also uses a notional dep-lib crate, and this crate internally uses1 a 0.7 version of the rand crate:

# dep-lib library crate
[dependencies]
rand = "0.7.3"
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)
}

An eagle-eyed reader might notice a difference between the two code examples:

  • In version 0.7.x of rand (as used by the binary crate), the rand::gen_range() method takes two non-self parameters, low and high.
  • In version 0.8.x of rand (as used by the dep-lib library crate), the rand::gen_range() method takes a single non-self parameter range.

This is a non-back-compatible change and so rand has increased its leftmost version component accordingly, as required by semantic versioning (Item 20). Nevertheless, the binary that combines the two incompatible versions works just fine; cargo sorts everything out2

However, things get a lot more awkward if the crate's API exposes a type from its dependency. In the example, this involves an Rng item – but specifically a version-0.7 Rng item:

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
}

As an aside, think carefully before using another crate's types in your API: it intimately ties your crate to that of the dependency. For example, a major version bump for the dependency (Item 20) will automatically require a major version bump for your crate too.

In this case, rand is a semi-standard crate that is high quality and widely used, and which only pulls in a small number of dependencies of its own (Item 24), so including its types in the crate API is probably fine on balance.

Returning to the example, an attempt to use this entrypoint from the top-level binary fails:

        let mut rng = rand::thread_rng();
        let max: usize = rng.gen_range(5..10);
        let choice = dep_lib::pick_number_with(&mut rng, max);

Unusually for Rust, the compiler error message isn't very helpful:

error[E0277]: the trait bound `ThreadRng: rand_core::RngCore` is not satisfied
  --> re-export/src/main.rs:17:48
   |
17 |         let choice = dep_lib::pick_number_with(&mut rng, max);
   |                                                ^^^^^^^^ the trait `rand_core::RngCore` is not implemented for `ThreadRng`
   | 
  ::: /Users/dmd/src/effective-rust/examples/dep-lib/src/lib.rs:17:28
   |
17 | pub fn pick_number_with<R: Rng>(rng: &mut R, n: usize) -> usize {
   |                            --- required by this bound in `pick_number_with`
   |
   = note: required because of the requirements on the impl of `rand::Rng` for `ThreadRng`

Investigating the types involved leads to confusion because the relevant traits do appear to be implemented – but the caller actually implements RngCore_v0_8_3 and the library is expecting an implementation of RngCore_v0_7_3.

Once you've finally deciphered the error message and realized that the version clash is the underlying cause, how can you fix it? The key observation is to realize that the while the binary can't explicitly use two different versions of the same crate, it can do so indirectly (as in the original example above).

From the perspective of the binary author, the problem can be worked around by adding an intermediate wrapper crate that hides the naked use of rand v0.7 types.

This is awkward, and a much better approach is available to the author of the library crate. It can make like easier for its users by explicitly re-exporting either:

  • the types involved in the API
  • the entire dependency crate.

For the example, the latter approach work best: as well as making the version 0.7 Rng and RngCore types available, it also makes available the methods (like thread_rng) that construct instances of the type:

// Re-export the version of `rand` used in this crate's API.
pub use rand;

The calling code now has a different way to directly refer to version 0.7 of rand, as 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);

With this example in mind, the advice of the title should now be a little less obscure: re-export dependencies whose types appear in your API.


1: This example (and indeed Item) is inspired by the approach used in the RustCrypto crates.

2: This is possible because the Rust toolchain handles linking, and does not have the constraint that C++ inherits from C of needing to support separate compilation.