ラッパーへの型の統合

説明

このパターンは、メモリ安全性に関する露出面を最小限に抑えながら、複数の関連する型を優雅に処理できるように設計されています。

Rustのエイリアシング規則の基礎の一つはライフタイムです。これにより、型間のアクセスパターンの多くが、データ競合の安全性を含めてメモリ安全であることが保証されます。

しかし、Rust の型が他の言語にエクスポートされる場合、通常はポインタに変換されます。Rustにおいて、ポインタは「ユーザーがポインタの指す先のライフタイムを管理する」ことを意味します。メモリ安全性を回避するのはユーザーの責任です。

したがって、ユーザーコードに対してある程度の信頼が必要とされ、特に解放後使用(use-after-free)については、Rustは何もできません。しかし、APIの設計によっては、他の言語で書かれたコードに課す負担が高いものもあります。

最もリスクの低いAPIは「統合ラッパー」であり、オブジェクトに対するすべての可能な相互作用を「ラッパー型」に折りたたみながら、Rust APIをクリーンに保ちます。

コード例

これを理解するために、エクスポートするAPIの古典的な例を見てみましょう:コレクションの反復処理です。

そのAPIは次のようになります:

  1. イテレータは first_key で初期化されます。
  2. next_key への各呼び出しでイテレータが進みます。
  3. イテレータが最後にある場合、next_key への呼び出しは何もしません。
  4. 上記のように、イテレータはコレクションに「ラップされます」(ネイティブな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バージョンのコードが行うことを行う必要があります:イテレータのライフタイムを消去し、手動で管理します。

言うまでもなく、これは非常に難しいです。

これは一つの落とし穴の例です。

MySetWrapper の最初のバージョンは次のようになります:

struct MySetWrapper {
    myset: MySet,
    iter_next: usize,
    // created from a transmuted Box<KeysIter + 'self>
    iterator: Option<NonNull<KeysIter<'static>>>,
}

transmute を使用してライフタイムを延長し、ポインタでそれを隠すことで、すでに醜くなっています。しかし、さらに悪いことに:他の操作がRustの未定義動作を引き起こす可能性があります

ラッパー内の MySet は、反復処理中に他の関数によって操作される可能性があることを考えてください。たとえば、反復処理中のキーに新しい値を格納するなどです。APIはこれを阻止せず、実際にいくつかの類似したCライブラリはこれを期待しています。

myset_store の単純な実装は次のようになります:

pub mod unsafe_module {

    // other module content

    pub fn myset_store(myset: *mut MySetWrapper, key: datum, value: datum) -> libc::c_int {
        // DO NOT USE THIS CODE. IT IS UNSAFE TO DEMONSTRATE A PROBLEM.

        let myset: &mut MySet = unsafe {
            // SAFETY: whoops, UB occurs in here!
            &mut (*myset).myset
        };

        /* ...check and cast key and value data... */

        match myset.store(casted_key, casted_value) {
            Ok(_) => 0,
            Err(e) => e.into(),
        }
    }
}

この関数が呼び出されたときにイテレータが存在する場合、Rustのエイリアシング規則の一つに違反したことになります。Rustによれば、このブロック内の可変参照はオブジェクトへの排他的アクセスを持たなければなりません。イテレータが単に存在するだけであれば、それは排他的ではないため、未定義動作となります!1

これを避けるためには、可変参照が本当に排他的であることを保証する方法が必要です。それは基本的に、存在している間にイテレータの共有参照をクリアし、その後再構築することを意味します。ほとんどの場合、それでもCバージョンよりも効率が低くなります。

なぜCはこれをより効率的に行えるのか疑問に思う人もいるかもしれません。答えは、Cがズルをしているからです。Rustのエイリアシング規則が問題であり、Cは単にポインタに対してそれらを無視します。その代わりに、マニュアルで「スレッドセーフではない」と宣言されているコードを見ることが一般的です。実際、GNU Cライブラリには、並行動作専用の全語彙があります!

Rustは、安全性とCコードでは達成できない最適化の両方のために、すべてを常にメモリ安全にすることを好みます。特定のショートカットへのアクセスを拒否されることは、Rustプログラマーが支払う必要がある代償です。

1

頭を悩ませているCプログラマーのために、このコード中にイテレータが読み取られる必要はなく、UBが発生します。排他性規則は、イテレータの共有参照による不整合な観測を引き起こす可能性のあるコンパイラの最適化も可能にします(例:スタックスピルや効率のための命令の並べ替え)。これらの観測は、可変参照が作成された後のいつでも発生する可能性があります。