オブジェクトベース API
説明
他の言語に公開される Rust の API を設計する際には、通常の Rust API 設計とは相反する重要な設計原則がいくつかあります。
- すべてのカプセル化された型は、Rust によって所有され、ユーザーによって管理され、不透明であるべきです。
- すべてのトランザクション型データ型は、ユーザーによって所有され、透過的であるべきです。
- すべてのライブラリの振る舞いは、カプセル化された型に作用する関数であるべきです。
- すべてのライブラリの振る舞いは、構造ではなく、来歴/ライフタイムに基づく型へカプセル化されるべきです。
動機
Rust には、他の言語に対する FFI サポートが組み込まれています。これは、crate 作者が異なる ABI を通じて C 互換 API を提供できる手段を用意することで実現されています(ただし、このプラクティスにとってその点は重要ではありません)。
よく設計された Rust FFI は、Rust 側の設計上の妥協を可能な限り少なく抑えながら、C API の設計原則に従います。あらゆる外部 API には 3 つの目標があります。
- 対象言語で使いやすくすること。
- Rust 側で API が内部的な unsafe 性を強いることを可能な限り避けること。
- メモリ安全性が損なわれる可能性と Rust の
undefined behaviourの可能性を可能な限り小さく保つこと。
Rust コードは、ある点を越えると外部言語のメモリ安全性を信頼しなければなりません。しかし、Rust 側の unsafe コードはどれも、バグの機会になったり、undefined behaviour を悪化させたりする可能性があります。
たとえば、ポインターの来歴が誤っている場合、不正なメモリアクセスによるセグメンテーションフォルトになる可能性があります。しかし、それが unsafe コードによって操作されると、本格的なヒープ破壊になり得ます。
オブジェクトベース API 設計により、メモリ安全性の特性が良好で、安全なものと 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 を呼び出す必要があり、それによって得られるのはその 1 つへのポインターだけです。
これは、Rust 的な意味ですべての DBM がライブラリによって「所有」されることを意味します。サイズ不明の内部状態は、ユーザーではなくライブラリが制御するメモリに保持されます。ユーザーは open と close によってそのライフサイクルを管理し、他の関数によってそれに対する操作を実行できるだけです。
datum 型は、上で「トランザクション型」と呼んだものです。ライブラリとそのユーザーとの間で情報交換を容易にするように設計されています。
このデータベースは、「非構造化データ」を格納するように設計されており、事前定義された長さや意味はありません。その結果、datum は Rust のスライスに相当する C のものです。つまり、バイト列と、その数です。主な違いは型情報がないことであり、それを void が示しています。
このヘッダーはライブラリの視点から書かれていることに注意してください。ユーザーはおそらく既知のサイズを持つ何らかの型を使用しています。しかし、ライブラリはそれを気にせず、C のキャスト規則により、ポインターの背後にある任意の型は void にキャストできます。
先に述べたように、この型はユーザーに対して透過的です。しかし同時に、この型はユーザーによって所有されています。これには、その内部のポインターに起因する微妙な影響があります。問題は、そのポインターが指すメモリを誰が所有するのか、ということです。
メモリ安全性にとって最良の答えは「ユーザー」です。しかし、値を取得する場合などでは、ユーザーはそれを正しく割り当てる方法を知りません(値の長さを知らないためです)。この場合、ライブラリコードは、C ライブラリの malloc や free など、ユーザーがアクセスできるヒープを使用し、その後 Rust 的な意味で所有権を移譲することが期待されます。
これはすべて推測に見えるかもしれませんが、C においてポインターが意味するのはこれです。それは Rust と同じ意味、すなわち「ユーザー定義のライフタイム」です。ライブラリのユーザーは、それを正しく使用するためにドキュメントを読む必要があります。とはいえ、ユーザーが間違えた場合の影響が小さい判断もあれば、大きい判断もあります。それらを最小化することがこのベストプラクティスの目的であり、鍵となるのは透過的なものはすべて所有権を移譲することです。
利点
これにより、ユーザーが守らなければならないメモリ安全性の保証の数を、比較的少数に最小化できます。
dbm_openから返されていないポインターで関数を呼び出さないこと(不正アクセスまたは破壊)。- close 後のポインターに対して関数を呼び出さないこと(解放後使用)。
- 任意の
datumのdptrはNULLであるか、示された長さの有効なメモリスライスを指していなければならないこと。
さらに、多くのポインター来歴の問題を避けられます。その理由を理解するために、キーのイテレーションという代替案をある程度詳しく考えてみましょう。
Rust はイテレーターでよく知られています。イテレーターを実装する際、プログラマーはその所有者に束縛されたライフタイムを持つ別の型を作成し、Iterator トレイトを実装します。
Rust で DBM のイテレーションを行うなら、次のようになります。
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 {
// この API は悪い考えです!実際のアプリケーションでは、代わりにオブジェクトベースの設計を使用してください。
}
#[no_mangle]
pub extern "C" fn dbm_iter_next(
iter: *mut DbmKeysIter,
key_out: *const datum
) -> libc::c_int {
// この API は悪い考えです!実際のアプリケーションでは、代わりにオブジェクトベースの設計を使用してください。
}
#[no_mangle]
pub extern "C" fn dbm_iter_del(*mut DbmKeysIter) {
// この API は悪い考えです!実際のアプリケーションでは、代わりにオブジェクトベースの設計を使用してください。
}
この API は、イテレーターのライフタイムが、それを所有する Dbm オブジェクトのライフタイムを超えてはならない、という重要な情報を失っています。ライブラリのユーザーは、イテレーターが反復処理しているデータよりも長生きするような使い方をしてしまう可能性があり、その結果、初期化されていないメモリを読み取ることになります。
C で書かれたこの例にはバグが含まれており、その後で説明します。
int count_key_sizes(DBM *db) {
// この関数を使用しないでください。微妙ですが重大なバグがあります!
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) { // エラーは -1 で示されます
free(key.dptr);
len += key.dsize;
if (l == 0) { // イテレーターの終端
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 の各部分の関係によっては、大きな設計労力が必要になる場合があります。 より容易な設計上の要点の多くには、それらに関連する別のパターンがあります。
-
Wrapper Type Consolidation は、複数の Rust 型を 不透明な「オブジェクト」にまとめます
-
FFI Error Passing は、整数コードと番兵戻り値 (
NULLポインターなど)によるエラー処理を説明します -
Accepting Foreign Strings は、 最小限の unsafe コードで文字列を受け入れられるようにし、 Passing Strings to FFI よりも正しく行うのが容易です
ただし、すべての API をこの方法で実現できるわけではありません。対象読者が誰であるかについては、 プログラマーの最善の判断に委ねられます。