型をラッパーに統合する
説明
このパターンは、メモリ安全でない状態が入り込む領域を最小限に抑えながら、 関連する複数の型を適切に扱えるように設計されています。
Rust のエイリアシング規則の基礎の 1 つはライフタイムです。これにより、 型間の多くのアクセスパターンが、データ競合に対する安全性も含めて メモリ安全であることが保証されます。
しかし、Rust の型が他の言語へエクスポートされると、通常はポインターに 変換されます。Rust においてポインターとは、「ユーザーがポインター先の ライフタイムを管理する」という意味です。メモリ安全でない状態を避ける 責任はユーザーにあります。
したがって、ユーザーコードにはある程度の信頼が必要です。特に、 Rust ではどうすることもできない use-after-free についてはそうです。 ただし、API 設計によっては、他の言語で書かれたコードに課す負担が 他の設計よりも大きくなります。
最もリスクの低い API は「統合ラッパー」です。これは、オブジェクトとの あらゆる可能な相互作用を「ラッパー型」にまとめつつ、Rust API を クリーンに保つものです。
コード例
これを理解するために、エクスポートする API の典型例である、 コレクションのイテレーションを見てみましょう。
その API は次のようになります。
- イテレーターは
first_keyで初期化されます。 next_keyを呼び出すたびに、イテレーターが進みます。- イテレーターが末尾にある場合、
next_keyを呼び出しても何も起こりません。 - 上で述べたように、イテレーターはコレクションに「ラップされます」 (ネイティブの Rust API とは異なります)。
イテレーターが nth() を効率的に実装している場合、各関数呼び出しごとに
一時的なものにできます。
struct MySetWrapper {
myset: MySet,
iter_next: usize,
}
impl MySetWrapper {
pub fn first_key(&mut self) -> Option<&Key> {
self.iter_next = 0;
self.next_key()
}
pub fn next_key(&mut self) -> Option<&Key> {
if let Some(next) = self.myset.keys().nth(self.iter_next) {
self.iter_next += 1;
Some(next)
} else {
None
}
}
}
その結果、ラッパーは単純になり、unsafe コードを含みません。
利点
これにより、型間のライフタイムに関する問題を避けられるため、 API をより安全に使用できます。これによって避けられる利点と落とし穴の 詳細については、オブジェクトベースの API を参照してください。
欠点
多くの場合、型をラップするのはかなり困難であり、ときには Rust API を 妥協したほうが物事が簡単になります。
例として、nth() を効率的に実装していないイテレーターを考えてみましょう。
オブジェクトが内部でイテレーションを処理できるように特別なロジックを
入れるか、外部関数 API だけが使用する別のアクセスパターンを効率的に
サポートすることには、間違いなく価値があります。
イテレーターをラップしようとする(そして失敗する)
あらゆる種類のイテレーターを API に正しくラップするには、ラッパーは C 版のコードが行うこと、つまりイテレーターのライフタイムを消去し、 手動で管理する必要があります。
控えめに言っても、これは信じられないほど困難です。
ここでは、数ある落とし穴のうち、たった1 つを示します。
MySetWrapper の最初のバージョンは次のようになります。
struct MySetWrapper {
myset: MySet,
iter_next: usize,
// transmute された Box<KeysIter + 'self> から作成
iterator: Option<NonNull<KeysIter<'static>>>,
}
transmute を使ってライフタイムを延長し、それを隠すためにポインターを
使っている時点で、すでに見苦しいものです。しかし、さらに悪いことがあります。
他のどんな操作でも Rust の undefined behaviour を引き起こす可能性があります。
ラッパー内の MySet は、イテレーション中に他の関数によって操作される
可能性があることを考えてください。たとえば、イテレーション対象のキーに
新しい値を格納するような場合です。この API はそれを抑止していませんし、
実際に類似の C ライブラリの中にはそれを想定しているものもあります。
myset_store の単純な実装は次のようになります。
pub mod unsafe_module {
// その他のモジュール内容
pub fn myset_store(myset: *mut MySetWrapper, key: datum, value: datum) -> libc::c_int {
// このコードを使用しないでください。問題を示すための安全でないコードです。
let myset: &mut MySet = unsafe {
// SAFETY: おっと、ここで UB が発生します!
&mut (*myset).myset
};
/* ...key と value のデータをチェックしてキャストする... */
match myset.store(casted_key, casted_value) {
Ok(_) => 0,
Err(e) => e.into(),
}
}
}
この関数が呼び出されたときにイテレーターが存在している場合、Rust の
エイリアシング規則の 1 つに違反しています。Rust によれば、このブロック内の
可変参照は、そのオブジェクトへの排他的アクセス権を持っていなければ
なりません。イテレーターが単に存在しているだけで排他的ではなくなるため、
undefined behaviour が発生します! 1
これを避けるには、可変参照が本当に排他的であることを保証する手段が 必要です。これは基本的に、イテレーターが存在している間はイテレーターの 共有参照を消し去り、その後で再構築することを意味します。ほとんどの場合、 それでも C 版より効率は低くなります。
こう尋ねる人もいるかもしれません。C はどうしてこれをより効率的に 実行できるのでしょうか? 答えは、C がずるをしているからです。 問題は Rust のエイリアシング規則であり、C はポインターについてそれらを 単に無視します。その代わり、一部またはすべての状況において「スレッド セーフではない」とマニュアルで宣言されているコードをよく見かけます。 実際、GNU C library には、並行動作に特化した語彙体系がまるごと存在します!
Rust は、安全性のためにも、C コードでは達成できない最適化のためにも、 常にあらゆるものをメモリ安全にしたいと考えています。特定の近道への アクセスを拒まれることは、Rust プログラマーが支払う必要のある代償です。
-
首をかしげている C プログラマーのために説明すると、UB を引き起こすのに、 このコードの実行中にイテレーターが読み取られる必要はありません。 排他性の規則は、コンパイラー最適化も可能にします。その結果、 イテレーターの共有参照によって一貫性のない観測が発生する可能性があります (たとえば、効率化のためのスタック退避や命令の並べ替えなど)。 こうした観測は、可変参照が作成された後の任意の時点で発生する可能性があります。 ↩