DEV Community

Cover image for [Advanced Rust] 2.10. API Design Principles of Constrained Pt.1 - Changing Types
SomeB1oody
SomeB1oody

Posted on

[Advanced Rust] 2.10. API Design Principles of Constrained Pt.1 - Changing Types

2.10.1. Think Carefully Before Changing an Interface

If your interface is going to change in a way that is visible to users, think twice before doing it.

You need to make sure that the changes you make:

  • Do not break existing user code
  • Should remain in place for a while

Frequently shipping backward-incompatible changes (major version bumps) will make users unhappy.

2.10.2. Backward-Incompatible Changes

Some backward-incompatible changes are obvious, such as changing the name of a public type or removing a public item from it.

Some backward-incompatible changes are more subtle and are closely tied to how Rust works. This article mainly focuses on those changes and how you, as a developer, should plan for them.

In the process, you sometimes need to make trade-offs and compromises in interface flexibility.

2.10.3. Modifying Types

If you remove or rename a public type, it will almost certainly break user code. The solution is to use visibility modifiers as much as possible. For example:

  • pub(crate): visible within the current crate
  • pub(in path): visible within the specified path

Example:

pub mod outer_mod {
    pub mod inner_mod {
        // This function is visible only to `outer_mod`
        pub(in crate::outer_mod) fn outer_mod_visible_fn() {}

        // This function is visible to the entire crate
        pub(crate) fn crate_visible_fn() {}

        // This function is visible only to `outer_mod` (using `super` to refer to the outer module)
        pub(super) fn super_mod_visible_fn() {
            // `inner_mod_visible_fn` is visible within the same module, so it can be called normally
            inner_mod_visible_fn();
        }

        // This function is visible only inside `inner_mod`, equivalent to `private`
        pub(self) fn inner_mod_visible_fn() {}
    }

    pub fn foo() {
        inner_mod::outer_mod_visible_fn();
        inner_mod::crate_visible_fn();
        inner_mod::super_mod_visible_fn();

        // This function is no longer visible because we are outside `inner_mod`
        // Error! `inner_mod_visible_fn` is private
        inner_mod::inner_mod_visible_fn();
    }
}

fn bar() {
    // This function is still visible because we are in the same crate
    outer_mod::inner_mod::crate_visible_fn();

    // This function is no longer visible outside `outer_mod`
    // Error! `super_mod_visible_fn` is private
    outer_mod::inner_mod::super_mod_visible_fn();

    // This function is also not visible outside `outer_mod`
    // Error! `outer_mod_visible_fn` is private
    outer_mod::inner_mod::outer_mod_visible_fn();

    outer_mod::foo();
}
Enter fullscreen mode Exit fullscreen mode

Visibility control for the functions in the inner_mod module:

  • outer_mod_visible_fn(): visible only inside outer_mod, not accessible from outside
  • crate_visible_fn(): visible to the entire crate, so bar() can still access it
  • super_mod_visible_fn(): visible only inside outer_mod, so bar() cannot access it
  • inner_mod_visible_fn(): private, visible only inside inner_mod

The fewer public types you expose in your API, the more freedom you have to change it later (freedom here means not breaking existing code).


#[non_exhaustive] Attribute

User code depends on more than just the name of your type. Example:

An Example of a Breaking Change

At the beginning, I wrote a struct called Unit in lib.rs:

pub struct Unit;
Enter fullscreen mode Exit fullscreen mode

Then I used Unit in main.rs:

fn main() {
    let u = constrained::Unit;
}
Enter fullscreen mode Exit fullscreen mode
  • That works fine.

Later, I modified Unit because users needed it:

pub struct Unit {
    pub field: bool,
}
Enter fullscreen mode Exit fullscreen mode

The code in main.rs would also change:

fn is_true(u: constrained::Unit) -> bool {
    matches!(u, constrained::Unit { field: true })
}

fn main() {
    let u = constrained::Unit {
        field: true,
    };
}
Enter fullscreen mode Exit fullscreen mode
  • The is_true function uses the modified Unit field
  • But the original code in main would then fail to compile

The same thing happens when Unit has a private field. The compiler knows that Unit has fields, but you did not provide values for them.


Solution

For this situation, Rust provides the #[non_exhaustive] attribute to mitigate these problems. It can be applied to struct, enum, and enum variants. It indicates that the type or enum may gain more fields or variants in the future.

If you use it, then when others use your crate, the compiler will:

  • Forbid explicit construction, such as lib::Unit { field: true }
  • Forbid non-exhaustive pattern matching, that is, patterns without a trailing ..

If your interface is relatively stable, you should avoid using this attribute.

Example:

lib.rs:

#[non_exhaustive]
pub struct Config {
    pub window_width: u16,
    pub window_height: u16,
}

fn some_function() {
    let config: Config = Config {
        window_width: 640,
        window_height: 480,
    };

    // Non-exhaustive structs can be matched exhaustively within the defining crate.
    if let Config {
        window_width,
        window_height,
    } = config
    {
        // ...
    }
}
Enter fullscreen mode Exit fullscreen mode
  • With #[non_exhaustive], lib.rs can still use explicit construction and exhaustive matching, because this code belongs to the same crate that defines the struct

What if I write this in main.rs?

use constrained::Config;

fn main() {
    let config: Config = Config {
        window_width: 640,
        window_height: 480,
    };

    if let Config {
        window_width,
        window_height,
    } = config {}
}
Enter fullscreen mode Exit fullscreen mode
  • This will fail to compile, because this code belongs to an external crate, and the compiler will prohibit the two operations mentioned above

Output:

error[E0639]: cannot create non-exhaustive struct using struct expression
 --> src/main.rs:4:26
  |
4 |       let config: Config = Config {
  |  __________________________^
5 | |         window_width: 640,
6 | |         window_height: 480,
7 | |     };
  | |_____^

error[E0638]: `..` required with struct marked as non-exhaustive
  --> src/main.rs:9:12
   |
 9 |       if let Config {
   |  ____________^
10 | |         window_width,
11 | |         window_height,
12 | |     } = config {}
   | |_____^
Enter fullscreen mode Exit fullscreen mode

We can slightly change the code so that the match in main.rs becomes a non-exhaustive pattern with ..:

if let Config {
    window_width,
    window_height,
    .. // this ignores the remaining fields or variants in a struct, tuple, or enum
} = config {
}
Enter fullscreen mode Exit fullscreen mode

Top comments (0)