オブジェクトベースAPI
説明
他言語に公開されるRust APIを設計する際、通常のRust API設計とは反対の重要な設計原則があります:
- すべてのカプセル化された型は、Rustが所有し、ユーザーが管理し、不透明であるべきです。
- すべてのトランザクショナルデータ型は、ユーザーが所有し、透過的であるべきです。
- すべてのライブラリの振る舞いは、カプセル化された型に作用する関数であるべきです。
- すべてのライブラリの振る舞いは、構造ではなく由来/ライフタイムに基づく型にカプセル化されるべきです。
動機
Rustには他言語へのFFIサポートが組み込まれています。これは、クレート作成者が異なるABIを通じてC互換APIを提供する方法を提供することで実現されています(この実践においては重要ではありませんが)。
よく設計されたRust FFIは、C API設計原則に従いつつ、可能な限りRust側の設計を妥協しないようにします。外部APIには3つの目標があります:
- ターゲット言語で使いやすくする。
- API がRust側の内部的な unsafe を可能な限り強制しないようにする。
- メモリ安全性違反とRustの
未定義動作の可能性を可能な限り小さく保つ。
Rustコードは、ある時点を超えて外部言語のメモリ安全性を信頼する必要があります。しかし、Rust側のすべてのunsafeコードは、バグの機会であり、未定義動作を悪化させる機会でもあります。
例えば、ポインタの由来が間違っている場合、無効なメモリアクセスによるセグメンテーション違反になる可能性があります。しかし、unsafeコードによって操作されると、完全なヒープ破壊になる可能性があります。
オブジェクトベースAPI設計により、良好なメモリ安全性特性を持つshimを記述でき、安全なものとunsafeなものの明確な境界を作ることができます。
コード例
POSIX標準は、DBMとして知られるファイルベースのデータベースにアクセスするためのAPIを定義しています。これは「オブジェクトベース」APIの優れた例です。
以下はCでの定義で、FFIに関わる方々には読みやすいはずです。下記の解説は、細かい点を見逃した方々の理解を助けるでしょう。
struct DBM;
typedef struct { void *dptr, size_t dsize } datum;
int dbm_clearerr(DBM *);
void dbm_close(DBM *);
int dbm_delete(DBM *, datum);
int dbm_error(DBM *);
datum dbm_fetch(DBM *, datum);
datum dbm_firstkey(DBM *);
datum dbm_nextkey(DBM *);
DBM *dbm_open(const char *, int, mode_t);
int dbm_store(DBM *, datum, datum, int);
このAPIは2つの型を定義しています:DBMとdatum。
DBM型は、上記で「カプセル化された」型と呼ばれました。これは内部状態を含むように設計されており、ライブラリの振る舞いのエントリーポイントとして機能します。
これはユーザーにとって完全に不透明であり、ユーザーはそのサイズやレイアウトを知らないため、DBMを自分で作成することはできません。代わりに、dbm_openを呼び出す必要があり、それはポインタのみを提供します。
これは、すべてのDBMがRustの意味でライブラリによって「所有」されていることを意味します。未知のサイズの内部状態は、ユーザーではなくライブラリによって制御されるメモリに保持されます。ユーザーはopenとcloseでそのライフサイクルを管理し、他の関数で操作を実行することしかできません。
datum型は、上記で「トランザクショナル」型と呼ばれました。これはライブラリとユーザー間の情報交換を容易にするように設計されています。
データベースは「非構造化データ」を格納するように設計されており、事前定義された長さや意味はありません。その結果、datumはRustスライスに相当するCの等価物です:バイトの塊と、その数のカウント。主な違いは型情報がないことで、これがvoidが示すものです。
このヘッダーはライブラリの視点から書かれていることに注意してください。ユーザーはおそらく既知のサイズを持つ何らかの型を使用しているでしょう。しかし、ライブラリは気にせず、Cのキャストルールにより、ポインタの背後にある任意の型はvoidにキャストできます。
前述のように、この型はユーザーにとって透過的です。しかし、この型はユーザーが所有します。これには、内部のポインタのため、微妙な影響があります。問題は、そのポインタが指すメモリを誰が所有するかです。
メモリ安全性のための最良の答えは「ユーザー」です。しかし、値を取得する場合など、ユーザーは正しく割り当てる方法を知りません(値の長さがわからないため)。この場合、ライブラリコードはユーザーがアクセスできるヒープ(Cライブラリのmallocやfreeなど)を使用し、Rustの意味で所有権を移転することが期待されます。
これはすべて推測に見えるかもしれませんが、これがCにおけるポインタの意味です。Rustと同じことを意味します:「ユーザー定義のライフタイム」。ライブラリのユーザーは、正しく使用するためにドキュメントを読んで理解する必要があります。とはいえ、ユーザーが間違えた場合の結果が少ないか大きいかという決定があります。それらを最小化することがこのベストプラクティスの目的であり、鍵は透過的なすべてのものの所有権を移転することです。
利点
これにより、ユーザーが守らなければならないメモリ安全性保証の数を比較的少数に最小化できます:
dbm_openによって返されないポインタで関数を呼び出さない(無効なアクセスまたは破損)。- closeの後にポインタで関数を呼び出さない(解放後の使用)。
- 任意の
datumのdptrはNULLであるか、宣伝された長さの有効なメモリスライスを指している必要があります。
さらに、多くのポインタ由来の問題を回避します。その理由を理解するために、代替案を詳しく検討しましょう:キーの反復。
Rustはイテレータでよく知られています。実装する際、プログラマーは所有者に制限されたライフタイムを持つ別の型を作成し、Iteratorトレイトを実装します。
以下はDBMに対するRustでの反復の実装です:
struct Dbm { ... }
impl Dbm {
/* ... */
pub fn keys<'it>(&'it self) -> DbmKeysIter<'it> { ... }
/* ... */
}
struct DbmKeysIter<'it> {
owner: &'it Dbm,
}
impl<'it> Iterator for DbmKeysIter<'it> { ... }
これはクリーンで、慣用的で、安全です。Rustの保証のおかげです。しかし、単純なAPI変換がどのように見えるか考えてみましょう:
#[no_mangle]
pub extern "C" fn dbm_iter_new(owner: *const Dbm) -> *mut DbmKeysIter {
// THIS API IS A BAD IDEA! For real applications, use object-based design instead.
}
#[no_mangle]
pub extern "C" fn dbm_iter_next(
iter: *mut DbmKeysIter,
key_out: *const datum
) -> libc::c_int {
// THIS API IS A BAD IDEA! For real applications, use object-based design instead.
}
#[no_mangle]
pub extern "C" fn dbm_iter_del(*mut DbmKeysIter) {
// THIS API IS A BAD IDEA! For real applications, use object-based design instead.
}
このAPIは重要な情報を失います:イテレータのライフタイムは、それを所有するDbmオブジェクトのライフタイムを超えてはなりません。ライブラリのユーザーは、イテレータが反復するデータより長生きするような使い方をする可能性があり、初期化されていないメモリの読み取りを引き起こします。
Cで書かれたこの例には、後で説明するバグが含まれています:
int count_key_sizes(DBM *db) {
// DO NOT USE THIS FUNCTION. IT HAS A SUBTLE BUT SERIOUS BUG!
datum key;
int len = 0;
if (!dbm_iter_new(db)) {
dbm_close(db);
return -1;
}
int l;
while ((l = dbm_iter_next(owner, &key)) >= 0) { // an error is indicated by -1
free(key.dptr);
len += key.dsize;
if (l == 0) { // end of the iterator
dbm_close(owner);
}
}
if l >= 0 {
return -1;
} else {
return len;
}
}
このバグは古典的です。イテレータが反復終了マーカーを返したときに何が起こるかを示します:
- ループ条件が
lをゼロに設定し、0 >= 0であるためループに入ります。 - 長さが増加します。この場合はゼロだけ。
- if文が真になるため、データベースが閉じられます。ここにbreak文があるべきです。
- ループ条件が再度実行され、閉じられたオブジェクトに対して
next呼び出しが発生します。
このバグの最悪な部分は?Rust実装が注意深い場合、このコードはほとんどの場合動作します!Dbmオブジェクトのメモリがすぐに再利用されなければ、内部チェックがほぼ確実に失敗し、イテレータはエラーを示す-1を返します。しかし時折、セグメンテーション違反を引き起こし、さらに悪いことに、意味不明なメモリ破壊を引き起こします!
これのどれもRustでは回避できません。Rustの観点からは、それらのオブジェクトをヒープに置き、それらへのポインタを返し、それらのライフタイムの制御を放棄しました。Cコードは単に「きちんと振る舞う」必要があります。
プログラマーはAPIドキュメントを読んで理解する必要があります。Cではそれが当然と考える人もいますが、良いAPI設計はこのリスクを軽減できます。DBMのPOSIX APIは、イテレータの所有権を親と統合することでこれを実現しました:
datum dbm_firstkey(DBM *);
datum dbm_nextkey(DBM *);
したがって、すべてのライフタイムが結び付けられ、そのような安全性違反が防止されました。
欠点
しかし、この設計選択には多くの欠点もあり、それも考慮すべきです。
まず、API自体が表現力に欠けるようになります。POSIX DBMでは、オブジェクトごとに1つのイテレータしかなく、すべての呼び出しがその状態を変更します。これは安全ではありますが、ほぼすべての言語のイテレータよりもはるかに制限的です。おそらく他の関連オブジェクトでは、ライフタイムがあまり階層的でない場合、この制限は安全性よりもコストが高くなります。
次に、APIの部分の関係によっては、重要な設計努力が必要になる場合があります。より簡単な設計ポイントの多くには、他のパターンが関連付けられています:
-
ラッパー型の統合は、複数のRust型を不透明な「オブジェクト」にグループ化します
-
FFIエラー処理は、整数コードとセンチネル戻り値(
NULLポインタなど)を使用したエラー処理を説明します -
外部文字列の受け入れは、最小限のunsafeコードで文字列を受け入れることができ、FFIへの文字列の受け渡しよりも正しく実装しやすいです
しかし、すべてのAPIがこの方法で実行できるわけではありません。対象者が誰であるかは、プログラマーの最善の判断に委ねられています。