🦍

Rustの#[non_exhaustive]を使ってクレートの後方互換性を壊さないようにする

2023/02/19に公開
2

はじめに

何かしらのクレートを提供する時、提供している構造体フィールドが増えた場合、利用側はビルドできなくなるという問題があります。

具体的に次の例を考えてみます。

  • 複数のクレートをもつプロジェクト内でRepositoryクレートを提供している
  • RepositoryではFindOptionという構造体を提供していて、それを他のクレートが使用している
repository/src/lib.rs
pub struct FindOption {
    pub id: String,
}
src/lib.rs
use repository::FindOption;

fn find(option: FindOption) {
    let FindOption { id } = option;
    // using id for something
    // ...
}

FindOptionにオプショナルなフィールドが増えた場合、それを使用しているクレートは追加されたフィールドを使用するように修正するか、
または..を使用して省略するという対処が必要になります。

repository/src/lib.rs
 pub struct FindOption {
     pub id: String,
+    pub limit: Option<usize>,
 }

src/lib.rs
 use repository::FindOption;
 
 fn find(option: FindOption) {
-    let FindOption { id } = option;
+    let FindOption { id, .. } = option;
     // using id for something
     // ...
 }

このように、必須ではないがフィールドが増えた場合は利用者側はビルドができなくなるという問題があります。

#[non_exhaustive]を使用して、省略を強制させる

#[non_exhaustive]を構造体やenumに追加することで、実装時に利用者側にパターン省略を強制させることで、あとから修正しなくて済むようにできます。
ちなみに#[non_exhaustive]はモジュールレベルでしか動作しないのでご注意ください。

repository/src/lib.rs
#[non_exhaustive]
pub struct FindOption {
    pub id: String,
}
src/lib.rs
use repository::FindOption;

fn find(option: FindOption) {
    let FindOption { id } = option;
    // using id for something
    // ...
}

試しにcargo checkしてみると、エラーになります。

$ cargo check
warning: unused manifest key: package.members
    Checking exhaustive v0.1.0 (/Users/skanehira/dev/github.com/skanehira/sandbox/rust/exhaustive)
error[E0638]: `..` required with struct marked as non-exhaustive
 --> src/lib.rs:4:9
  |
4 |     let FindOption { id } = option;
  |         ^^^^^^^^^^^^^^^^^
  |
help: add `..` at the end of the field list to ignore all other fields
  |
4 |     let FindOption { id , .. } = option;
  |                         ~~~~~~

#[non_exhaustive]を使わないほうがよい場合

利用者側にパターン省略を強制することで、フィールドが増えても修正が必要なく互換性を維持できますが、これは却って問題が起きるケースもあります。

具体的に次の例を考えてみます。

  • エラーenumを提供している
  • エラーのパターンマッチでpanicを使用している
rpeository/src/lib.rs
#[non_exhaustive]
pub struct FindOption {
    pub id: String,
}

#[non_exhaustive]
pub enum FindError {
    NotFound(String),
}

pub struct Client;
impl Client {
    pub fn find(&self, option: FindOption) -> Result<String, FindError> {
        // find data with option
        Ok("found".into())
    }
}
src/lib.rs
use repository::{Client, FindError, FindOption};

fn find(client: &Client, option: FindOption) {
    let result = client.find(option);
    match result {
        Ok(_) => {
            // do something
        }
        Err(error) => {
            match error {
                FindError::NotFound(_) => {
                    // error handling
                }
                // #[non_exhaustive]があるため、_によるパターンマッチが必要
                _ => panic!("unknown error"),
            }
        }
    }
}

上記の例ではエラーのパターンマッチで省略を強制しているため、新しいエラーが追加されても利用者側はビルドが通るので、本当は新しいエラーの対応が必要だけどそれに気づかない可能性があります。
このような場合は#[non_exhaustive]を使用しないほうがよいでしょう。

#[non_exhaustive]はフィールドが増えたことを相手に知らせたほうがよいどうかという基準で考えるとよいかなと思います。

まとめ

  • #[non_exhaustive]は互換性を維持できるように、パターン省略を利用者に強制させることができる
  • それにより構造体やenumのフィールドが増えてもビルドは通るため、互換性が維持される
  • しかし、それだと問題が起きる場合があるので、フィールドが増えたことを相手に知らせたいかどうかという判断基準で使用するとよい

参考文献

Discussion

magicantmagicant

「返って問題が起きるケースもあります」といいますが、わざわざライブラリー側で non_exhaustive を付けてやっているのに _ => panic!() みたいなコードを書いちゃうようなユーザーのことなんか気にする必要ありますかね? non_exhaustive を付けていなくても _ => panic!() と書いてたらどうせ enum の variant が増えたことに気付けないので同じですよ

ゴリラ@週休7日の仕事くださいゴリラ@週休7日の仕事ください

non_exhaustive を付けていなくても _ => panic!() と書いてたらどうせ enum の variant が増えたことに気付けないので同じですよ

はい、結果的にそうかもしれないですね。
しかし#[non_exhaustive]をつけることによって、ユーザに _ => ... を強制することになります。
つまりユーザに_ => panic!() を書かせてしまう可能性があるということになります。
明言していないですが、そこまで考慮したほうが良いんじゃないかという意味合いで書きました。

わざわざライブラリー側で non_exhaustive を付けてやっているのに _ => panic!() みたいなコードを書いちゃうようなユーザーのことなんか気にする必要ありますかね?

「気にする必要があるかどうか」はライブラリ作者が決めることなので、「そんなの必要がないよ」と考えているのであれば、それはそれで良いんじゃないかなと思います。