Cargoのクレートのバージョンは**Semantic Versioning**(SemVer)によって互換性が担保されている1ので、ライブラリの利用者はcargo updateを実行するだけで互換性を保ったまま依存ライブラリをアップデートできます。
しかし、それはあくまでライブラリとアプリケーションの双方でバージョンの設定が適切に行われた場合の話であり、これが不十分であった場合はcargo updateを実行した途端にコンパイルが通らなくなってしまうといったことが起こりえます。
この記事では適切なバージョニングのための基本的な考え方を掴むことを目標とします。記事の前半ではCargoにおける依存クレートのバージョンの扱いについて述べ、それを踏まえて後半ではライブラリ作者向けにバージョニングに関するルールや慣習について述べていきます。
Semantic Versioning
Cargo固有の問題に進む前に、一般的なSemVerのルールについて簡単にまとめておきます。
SemVerは基本的にX.Y.Zのような形で表され、ここでXをメジャーバージョン(major version)、Yをマイナーバージョン(minor version)、Zをパッチバージョン(patch version)と呼びます2。
SemVerの変更は以下のように行います。
- メジャーバージョンは後方互換性のないAPIの変更を行ったときに上げる
- マイナーバージョンは後方互換な機能追加を行ったときに上げる
- パッチバージョンは後方互換なバグ修正を行ったときに上げる
ただし、メジャーバージョンが0の場合(0.x.y)のバージョンの上げ方はSemVerの仕様上規定されていません。例えば、0.1.0と0.1.1の間に全く互換性がないといったこともあり得るのです。しかし、メジャーバージョンが0であるクレートがたくさんある中でこのルールに厳密に従うのは現実的ではないので、Cargoではより互換性に厳格な慣習(次節で述べる)が採用されています。
Cargo.tomlの[dependencies]セクションにおけるバージョンの解釈3
ご存知の通り、Cargo.tomlでは依存するクレートを以下のように指定します。
[dependencies]
time = "0.1.12"
ここで、文字列0.1.12は^0.1.12の省略形であり、>=0.1.12かつ<0.2の範囲内のバージョンを表します。これらのバージョン文字列(requirement)の解釈はsemverクレートによって実装されており、具体的には以下のルールに従って行われます。
Caret requirement
Caret requirementは、クレートのあるバージョンに対してSemVer的に互換(SemVer compatible)なバージョンの範囲を指定します。具体的には、X.Y.Zの3つの番号のうち、最も左の0でない番号(ただし全て0のときは最も右側の0)を保つようなアップデートのみを許容します。
^1.2.3 := >=1.2.3 <2.0.0
^1.2 := >=1.2.0 <2.0.0 (^1.2.0も同じ範囲を指す)
^1 := >=1.0.0 <2.0.0 (^1.0や^1.0.0も同じ範囲を指す)
^0.2.3 := >=0.2.3 <0.3.0
^0.0.3 := >=0.0.3 <0.0.4 (=0.0.3と同じ)
^0.0 := >=0.0.0 <0.1.0
^0 := >=0.0.0 <1.0.0 (^0と^0.0がそれぞれ別の範囲を指すことに注意)
この表記は、(一般のSemVerと違って)メジャーバージョンが0であるクレートについても一定の互換性を保証しています。
Caret requirementはCargo.comlのdependenciesのバージョン指定におけるデフォルトの表記です。つまり、単にx.y.z、x.y、xと書いた場合はそれぞれ自動的に^x.y.z、^x.y、^xが指定されたもの同様に解釈されます。実際、crates.io上のクレートの多くはdependenciesでこの省略表記のみを使用しているので、特に事情がない限りはこの表記を使っておけば問題はないでしょう。
Tilde requirements
Caret requirementの他に~1.2.3のようなTilde requirementと呼ばれる形式もあります。これは、あるバージョンの次のマイナー/メジャーアップデートまでのバージョンを許容します。つまり、マイナーバージョンが指定されていれば(~x.y.zや~x.y)パッチバージョンの変更のみを可能とし、そうでなければ(~x)マイナーバージョンの変更を可能とします。
~1.2.3 := >=1.2.3 <1.3.0
~1.2 := >=1.2.0 <1.3.0 (~1.2.0と同じ)
~1 := >=1.0.0 <2.0.0 (1.*と同じ)
~0.2.3 := >=0.2.3 <0.3.0
~0.2 := >=0.2.0 <0.3.0 (~0.2.0と同じ)
~0 := >=0.0.0 <1.0.0 (0.*と同じ)
Wildcard requirements
Wildcard requirementはその名の通り0.0.*のようにワイルドカードで範囲を指定します。なお、*のようにワイルドカードのみによるrequirementも可能ではありますが、そのようなrequirementを含むクレートは互換性を損ねる恐れがあるためcrates.ioでは拒否されます4。
* := >=0.0.0 (全てのバージョン。crates.ioでは使えない)
1.* := >=1.0.0 <2.0.0 (^1と同じ)
0.0.* := >=0.0.0 <0.1.0
Inequality requirements
Inequality requirementはその名の通り、不等号で範囲を直接示す形式です。
>= 1.2.0 (2.Y.Z, 3.Y.Z, 4.Y.Z, ...なども含んでしまうため推奨しない)
> 1 (上と同様)
< 2
= 1.2.3
Multiple requirements
複数のrequirementをカンマ区切りで指定することが出来ます(例:>= 1.2, < 1.5)。
ライブラリのバージョニング
後方非互換な変更
記事冒頭ではSemVer一般のルールについて述べましたが、実際にRustのライブラリを作成していると、どのタイミングでメジャー・マイナー・パッチのバージョンを上げるべきか判別しづらいケースがあります。
SemVerの仕様上、後方互換性のない変更の際にはメジャーバージョンを上げなくてはならないとされています。しかし、Rustではほぼ全てのAPIの変更が厳密には後方互換ではないのです。その例として以下のアプリケーションを考えます。
extern crate lib;
use lib::*;
mod module {
#[derive(Debug)]
pub struct Bar(pub i32);
}
use module::*;
fn main() {
let foo = Foo; // `lib`からインポートした型
let bar = Bar(1);
println!("I have {:?} and {:?}", foo, bar);
}
上のコードではモジュールmoduleと外部のライブラリlibから*(glob)を用いてアイテムを無差別にインポートしています。ここでもしlibがアップデートしてBarという型をトップレベルに追加すると問題が起こります。試しにコンパイルしようとすると以下のようにエラーとなります5。
error: `Bar` is ambiguous
--> src/main.rs:14:15
|
14 | let bar = Bar(1);
| ^^^
|
note: `Bar` could refer to the name imported here
--> src/main.rs:3:5
|
3 | use lib::*;
| ^^^^^^^
note: `Bar` could also refer to the name imported here
--> src/main.rs:10:5
|
10 | use module::*;
| ^^^^^^^^^^
= note: consider adding an explicit import of `Bar` to disambiguate
このように、ライブラリにパブリックなアイテムを追加するだけでも後方互換性が崩れてしまうのです。
RFC 1105
上の例から、些細な変更が後方互換性を破壊しうることが分かりましたが、かといってこういった変更を行う度にSemVerの仕様に従ってメジャーバージョンを上げるのは非現実的です。そこで、そのような変更をマイナーバージョンの変更だけでできるようにするために、RFC 1105(以降、単にRFCと呼ぶ)がSemVerの規定に対していくつかの例外を定めています。
RFCでは以下の用語が定義されています。ただし訳語は私が勝手につけたものです。
-
メジャーチェンジ(major change): メジャーバージョン(
0の場合はマイナーバージョン)の変更を必要とする変更 - マイナーチェンジ(minor change): マイナーバージョンの変更を必要とする変更
- 破壊的変更(breaking change): 当該ライブラリに依存するアプリケーション(downstream code)のコンパイルを通らなくしてしまう可能性のある変更
SemVerの仕様に厳密に従えば破壊的変更は全てメジャーチェンジになりますが、RFCではいくつかの破壊的変更を例外的にマイナーチェンジと定めます。
RFCの方針として、マイナーチェンジの引き起こす非互換性は最悪でもアプリケーション側にわずかな変更しか要求しないべきとされています。例えば、アプリケーション側への型注釈やUFCSの追加だけでコンパイルが通せるような破壊的変更はマイナーチェンジにできます。前節の例で言えば、アプリケーション側のglobを開いてuse lib::Foo; use module::Bar;と書けば互換性を守れるのでこれもマイナーチェンジと定めることが可能です。
RFCにおける破壊的変更の例の一覧
RFCはいくつかの破壊的変更の例を挙げて、それぞれメジャーチェンジとマイナーチェンジのどちらであるか示しています。しかし、それらを一つひとつ紹介すると長くなるので、下表に各項目の抜粋だけ載せておきます。
なお、ここで明示されていない全ての破壊的変更はメジャーチェンジとして解釈するものと規定されています。
RFCに示されていない破壊的変更
以下では、RFCに示されていない破壊的変更のうち私が特筆すべきと感じたものを列挙します。
新しいバージョンの言語/標準ライブラリ機能の使用
例えばtry!マクロで書かれていたコードを?演算子で書き換えたとすると、そのコードはコンパイルにRust 1.13以降を必要とするようになります。これはバージョン1.13未満のユーザにとっては破壊的変更とも言えそうです。
しかし、実際にこれを破壊的変更とするかどうかについてはコミュニティ内でもまだ明確な合意が形成されていないように見えます。Rust library teamのAlex Crichton氏はGitHubのrust-lang-nursery organizationのクレートについてこのような変更をマイナーチェンジと定めるRFCを提案していますが、互換性について懸念する意見が多かったため判断が保留されています。実際、bitflagsクレートやrandクレートなどは現在も?演算子でなくtry!マクロを使うなど、古いバージョンのRustとの互換性を保っています。
Cargo.tomlのdependenciesセクションの変更
ライブラリが依存しているクレートのバージョンの変更は、次の条件を満たすとき破壊的変更になりえます。
- 依存先クレートのアイテムを自ライブラリのパブリックなAPIに露出している
これは依存先クレート(libとする)のアイテムを、pub use lib::foo;やpub fn bar(bar: lib::Baz)のようにクレート外部から見えるようにしているようなケースです。このような依存のことをpublic dependencyと呼びます。
例えばlib = "=0.1"のトレイトlib::Traitとlib = "0.2"のlib::Traitは、たとえその内容に変更がないとしても全く別のトレイトとして扱われるため、一方のバージョンのトレイトに対する実装と他方のバージョンに対する実装の間に互換性がありません。そのため、libのバージョンを変更することで同じくlib::Traitに依存するアプリケーションとの互換性が損なわれてしまうのです。
ただし依存先クレートが後述のsemver trickを用いている場合は破壊的変更にならない場合があります。
また、RFC 1977がpublic dependenciesを適切に管理するための仕組みを定めています(1.22.1 stable時点で未実装)。
- 依存先クレートの
Cargo.tomlでlinksキーが指定されている(主にFFI関連クレート)
Cのライブラリへバインディングするときにそのライブラリの複数のバージョンへ同時にリンクしようとすると、Cには名前マングリングの仕組みがないためリンク時や実行時に問題が起こりえます6。linksキーはそのようなエラーをCargoの時点で補足するための仕組みです。具体的には、CargoはCargo.tomlの[package]セクションで同一のlinksキーが指定されているクレート同士(同一クレートの別バージョンも含む)が同じバイナリに混在するようなビルドをエラーとして扱います。
例えば、linksキーを持つfoo-sysクレートがあったとして、自ライブラリとそれに依存するアプリケーションがともにfoo-sys = "0.1"に(間接的/直接的問わず)依存しているとします。ここで自ライブラリの依存をfoo-sys = "0.2"に切り替えると、アプリケーションはfoo-sysの^0.1と^0.2に同時にリンクしようとしてエラーとなってしまうので破壊的変更になります。
一見して、自ライブラリの依存をfoo-sys = ">=0.1, <0.3"のようにすれば問題がなくなるように見えるかもしれません。しかし、このように指定してもcargo updateを実行するとCargoが>=0.1, <0.3の範囲のうち最大のバージョンを取りに行ってしまうので結局^0.1系と^0.2系が混在することとなってしまいエラーになります。
原則7としてlinksキーはFFIのための仕組みなので、FFI関連のクレート(典型的には*-sysという名前のクレートやそのラッパ)がこれに関係してきます。また、[ring]クレートもCやアセンブリのコードを含むためlinksキーを持ちます(なのでこれに依存している蓋然性が高い暗号系のクレート全般は要注意)。
- 依存先クレートのサポートするRustのバージョンが引き上げられる
これは前節の内容とも繋がる話ですね。ただ、あるクレートの要求するRustのバージョンを調べることは現時点であまり容易な作業ではないので、ここまで気にし始めると正直キリがないような気がします(私のライブラリの更新時はここまで調べていません)。
パブリックなアイテムをオプショナルにする
あるアイテムに#[cfg(feature = "...")]をつけてオプショナルにするのはアイテムの削除と同様の働きをするため破壊的変更になります。これは当該のfeatureがCargo.tomlでdefault featureとして指定されている場合も同様です(アプリケーション側でdefault-feature = falseが指定されている場合に無効になるため)。
これに関してはFeature Request: Ability to disable single default features · Issue #3126 · rust-lang/cargoの提案が受け入れられれば互換性が改善される可能性がありますが、いずれにしても破壊的変更であることに違いはないです。
featureの削除
前節とは逆にオプショナルだった機能を常に有効化して(これ単独ではメジャーチェンジでない)、Cargo.tomlから当該featureを削除することも破壊的変更になります。というのも、Cargoは存在しないfeatureへの依存をエラーとして扱うので、featureを削除した時点でアプリケーション側にエラーが発生してしまうからです。
C言語ライクな列挙型の判別子の順序の変更
C言語ライクな列挙型とは、以下のようにヴァリアントがデータを一切持たない列挙型のことを言います。
enum CLike {
A,
B,
C,
}
このような列挙型の値は、Safe Rustの範囲で整数にキャストすることができます。
fn main() {
assert_eq!(0, CLike::A as u8);
assert_eq!(1, CLike::B as u8);
assert_eq!(2, CLike::C as u8);
}
この値は宣言時の判別子の順序に依存するため、これを並び替えることは破壊的変更になります。
ただし、この値は宣言時に明示的に指定することも出来るので、値を適切に設定すれば破壊的変更なしで並び替えることができます。
enum CLike {
C = 2,
B = 1,
A = 0,
}
fn main() {
assert_eq!(0, CLike::A as u8);
assert_eq!(1, CLike::B as u8);
assert_eq!(2, CLike::C as u8);
}
SendやSync (auto traits)の実装の削除
SendとSync(とUnwindSafeとRefUnwindSafe)はauto trait 8と呼ばれる特殊な種類のトレイトです。Auto traitであるようなトレイトTraitは以下の性質を持ちます。
- ある構造体や列挙型の全てのメンバ(ただし幽霊型
PhantomData<T>の場合はT)がTraitを実装している(あるいはメンバを1つも含まない)ならばその型は自動的にTraitを実装する - 1.を満たさない型でも、明示的に
impl Traitを書くことで実装を上書きできる(SendとSyncの場合はunsafe impl) - 1.を満たす型でも、明示的に
impl !Traitを書くことで実装しないようにできる(ただし執筆時時点でunstable9)
1.の性質から、あるauto traitを自動的に実装している型にそのトレイトを実装しないメンバ(public/private問わず)を追加することは、トレイトの実装の削除と同じ働きをするのでメジャーチェンジになります。
SendやSyncを実装しないプリミティブ型と標準ライブラリの型の例(全てではない)として以下のものがあります。
-
*const T(!Send + !Sync) -
*mut T(!Send + !Sync) -
std::rc::Rc<T>(!Send + !Sync) -
std::cell::UnsafeCell<T>(!Syncのみ)
またUnsafeCell<T>をフィールドとして持つstd::cell::Cell<T>やstd::cell::RefCell<T>などもSyncを実装しません。
追記 2018-3-22: 最新のrustdoc nightly(6728f21d8、2017-11-23以降)ではSendとSyncの実装の有無が各型のドキュメントの"Auto Trait Implementations"という特別のセクションに表示されます10。ただし、現行の安定版(1.24.1)では自動的に実装された(あるいはされていない)auto traitはドキュメントに表示されません。
ある型にauto traitが確実に実装されていることを保証する方法としては、以下のようにテストに実際にSendやSyncを必要とするコードを書くことが考えられます。
use super::Foo; // `Send`かつ`Sync`であることを保証したい型
fn assert_send<T: Send>() {}
fn assert_sync<T: Sync>() {}
# [test]
fn test_thread_safety() {
assert_send::<Foo>();
assert_sync::<Foo>();
// `Foo: !Sync`の場合(`Foo`が`RefCell<()>`をフィールドに持つ場合)の例:
// error[E0277]: the trait bound `std::cell::RefCell<()>: std::marker::Sync` is not satisfied in `Foo`
}
また、一度auto traitを実装してしまうと元に戻すのがメジャーチェンジになってしまうため、auto traitが実装されていないことを保証したいケースもあるかと思います。そのような場合はcompiletest_rsクレート(私は試していませんが)やRustdocのcompile_failテストが役に立つかもしれません。
Deref<Target=任意>を実装する型に固有メソッドを追加する
Deref<Target=Bar>を実装する型FooにメソッドFoo::hogeを追加することを考えます。このとき、Barも同様にメソッドBar::hogeを持っていた場合、従来はメソッド構文で呼び出せていたBar::hogeがFoo::hogeによって上書きされてしまいます((*foo).hoge()のようにして呼び出すことは引き続き可能)。
このことは、Fooが例えば任意の型にディレファレンスするコンテナ型Foo<T>であるような場合だと大きな問題になります。よって、任意の型をDerefのTargetとするような型に固有メソッド(inherent method)を追加するのは一般的に避けるべきです。例えばBox::into_rawはメソッドではなく関連関数として実装されています。
破壊的変更の緩和策
The semver trick
Semver trick(あるいはdtolnay trick)とはRust library teamのDavid Tolnay氏がdtolnay/semver-trick: How to avoid complicated coordinated upgradesで発表した、ライブラリのアップデート時の影響を最小限にとどめる手法です。
実例として、ライブラリクレートlibがバージョン0.1.0であるとして、lib::Fooにメジャーチェンジを加えてバージョン0.2.0をリリースすることを考えます。このときlib::Barには特に変更を加えていないにも関わらず、バージョン0.1.0のlib::Barとバージョン0.2.0のlib::Barが別の型として扱われてしまい、このままでは互換性が崩れてしまいます。
そこでバージョン0.1.0のCargo.tomlの[dependencies]にlib = "0.2"を書き加えて、さらにlib::Barの実態をextern crate lib; pub use lib::Bar;に書き換えて、これをバージョン0.1.1としてリリースします。こうすることでバージョン0.1.1のlib::Barとバージョン0.2のlib::Barは同じ型として扱われるようになり、破壊的変更が最小限にとどまります。
Rust API guidelinesを読む
rust-lang-nursery/api-guidelinesはRust teamによるAPI設計についてのガイドラインです。このうちfuture-proofing.mdが破壊的変更を防ぐための賢い方針をいくつか解説しています。また、このガイドラインは互換性の問題に限らず広くAPIの設計に関するベストプラクティスが詰まっているので一読をお勧めします。
追記 2018-1-6: 非公式の日本語訳もあります:
概要 - Rust APIガイドライン
誤ってリリースしてしまったときはyankを使う
マイナーリリースにメジャーチェンジを入れてしまった、という最悪のケースにはyankが有効です。Yankはリリースの取り消しと似たような働きをします。crates.ioからyankされたクレートのバージョンはアプリケーション側でcargo updateによって新たにCargo.lockに追加されなくなります(ただし既に追加されているものは取り消せません)。
例えばあるクレートのバージョン1.0.1をyankするには以下のコマンドを実行します。
$ cargo yank --vers 1.0.1
これでこのクレートのバージョン1.0.1に新たにアプリケーションが依存することを防げます。
バージョン1.0.0をリリースする前に
ライブラリが十分安定したらバージョン1.0.0をリリースしたいところです。ただ、一度メジャーバージョンが1になるとメジャーチェンジを行う手段がメジャーリリースしかなくなってしまうため、バージョン1.0.0のリリースにはおのずから慎重さが求められます。
近い将来にメジャーチェンジが予見される場合はバージョン1.0.0はリリースしないべきです。特にpublic dependencyが安定していない(バージョンが<1.0.0)場合は当該の依存クレートがアップデートした時点で自ライブラリにもメジャーチェンジを加える必要が出てしまうのでAPIを安定させるには早すぎます(Rust API guidelinesのC-STABLE)。また、public dependencyでなくとも上述のlinksキーを含むクレートも同様に扱うべきでしょう。
また、将来のRustの機能追加がAPIの設計に影響を及ぼす可能性も検討するべきです。例えばbitflagsクレートがassociated constantsがstableになるまでバージョン1のリリースを待ったという例があります(issue rust-lang-nursery/bitflags#80)。
終わりに(愚痴)
この記事ではクレートの互換性について述べましたが、この中でもとりわけ私が実際にライブラリを保守する上で問題になったのがringクレートのlinksキー周りの非互換性でした。そのため本文もこのあたりの内容が気持ち多めになっています。
ringクレートがアップデートするたびに、依存するringのバージョンの違いによって周辺のクレート群が分断されて相互に依存できなくなるのでつらいです。自ライブラリの依存の中に古いringに依存するクレートがあると、自ライブラリのringのバージョンも上げられなくてつらいです。また、依存するringのバージョンを変更することがメジャーチェンジなので気軽に手を出せない点もつらいです(つらい)。
Pure Rustでないライブラリにはこういうつらみがあるので、もっとRustが流行って何もかもpure Rustで解決できる世界になれば良いと思います。
-
The Manifest Format (doc.crates.io) ↩
-
他にも
1.0.0-alpha.1のようにpre-release versionと呼ばれる情報を指定することもできるが、この記事では扱わない。 ↩ -
参考: Specifying Dependencies (doc.crates.io)、semver | npm Documentation (docs.npmjs.com)(
semverクレートはnpmの表記を採用している) ↩ -
Frequently Asked Questions (doc.crates.io) ↩
-
環境は
rustc 1.22.1 (05e2e1c41 2017-11-22)のstable。 ↩ -
原則というからには例外もあって、pure Rustの
rayon-coreクレートがlinksキーを持つ。ただしこのクレートはメジャーチェンジを行わないことを明言しているため、利用者として互換性を気にする必要は特にない。
[ring]: https://github.com/briansmith/ring ↩ -
古いドキュメントなどではopt-in builtin trait (OIBIT)と呼ばれていることが多い。 ↩
-
#![feature(optin_builtin_traits)]が必要(rustc 1.24.0-nightly (dc39c3169 2017-12-17)時点)。ただし、フィールドの追加を許容できるならTraitを実装しない幽霊型PhantomData<T>(例えばSendやSyncの場合はPhantomData<*const ()>など)で同様のことが実現できる。 ↩ -
表示例: hyper::header::Headers - Rust。
Sendと!Syncが表示されている。 ↩