プロンプトと適用されるコンテキスト
ある Rust クレートを 2021 エディションから 2024 エディションへ移行した際、既存の extern ブロックが受け入れられなくなりコンパイルに失敗します。なぜ unsafe extern が必要なのか、ABI 宣言をどのように監査するのか、そして unsafe な境界を検証可能な安全な API でラップする方法を説明してください。
Rust 2024 では、外部ブロックに unsafe キーワードを使用することが義務付けられています。Rust は外部ライブラリから提供されるシグネチャ、呼び出し規約、グローバル変数、またはポインタのコントラクトを検証できないため、宣言の作成者がそれらの前提に責任を持つ必要があります。
面接官が評価するポイント
面接官は、unsafe な宣言と、すべての呼び出し箇所で unsafe を公開する API との違いを理解しているかを見ています。ABI、整数の幅、レイアウト、null 許容ポインタ、所有権、スレッド制約、初期化、そして監査可能な小さな FFI モジュールについて網羅する必要があります。
明確化のための質問
クレートのエディション、ターゲットプラットフォーム、外部 ABI、およびヘッダーのバージョンを確認します。関数が所有権を持つリソースを返すか、どのポインタが null になり得るか、誰がそれらを解放するか、コールバックがスレッドをまたぐか、動的ライブラリのバージョンが乖離する可能性があるかを尋ねます。これらの回答なしでは、構文だけの修正は不完全です。
30秒の回答フレームワーク
「Rust 2024 では、コンパイラが外部 ABI コントラクトを検証できないため、extern 宣言自体を unsafe としてマークします。私なら unsafe extern と記述し、呼び出し規約、レイアウト、整数の幅、ポインタの有効性、破棄関数、スレッドルールを監査し、ラッパーによって事前条件が証明されている場合にのみ関数を safe としてマークします。証明できない条件は unsafe のままにし、ターゲットをまたいだビルドと ABI のリグレッションテストで移行を検証します。」
ステップバイステップの詳細解説
ステップ 1: unsafe extern の責任を明確にする
unsafe extern は、その宣言が未定義動作を引き起こす可能性があり、宣言の作成者がそのコントラクトに対して責任を持つことを示します。C の実装を検証したり、呼び出し側の引数をチェックしたりするわけではありません。2024 エディションでは、その責任をソースコード上で可視化しています。
ステップ 2: ABI とレイアウトを監査する
extern "C" などの呼び出し規約、構造体のレイアウト、enum の表現、アライメント、整数の幅、戻り値のルールを確認します。ヘッダー、生成されたバインディング、およびリンクされたライブラリは、同じバージョン管理されたコントラクトを記述している必要があります。ローカルでの実行成功は、クロスプラットフォームにおける証拠にはなりません。
ステップ 3: 安全な外部関数と安全でない外部関数を分離する
外部ブロック内の関数はデフォルトで unsafe です。パブリックな事前条件が証明されている場合は、関数を safe として宣言できます。これにより呼び出し側は unsafe ブロックが不要になりますが、宣言の作成者がその証明の責任を負うことに変わりはありません。単に unsafe 構文を減らすためだけに、未知の関数を safe としてマークしてはなりません。
unsafe extern "C" {
safe fn library_version() -> u32;
fn library_parse(ptr: *const u8, len: usize) -> i32;
}ステップ 4: ポインタ、所有権、破棄をラップする
生ポインタを参照に変換する前に、非 null 性、アライメント、長さ、ライフタイムを検証します。外部ライブラリから返されたリソースは、通常、対応する解放関数で破棄する必要があります。その境界を越えて Rust のデフォルトデストラクタや別のアロケータを使用してはなりません。
ステップ 5: スレッドとコールバックの制約を確認する
ハンドルがスレッドをまたげるか、コールバックがライブラリ所有のスレッドで実行されるか、コールバックが再入可能か、破棄がコールバックの完了を待機するかを判断します。これらのプロパティを静的に保証できない場合は、ラッパーのスレッドモデルを制約し、シャットダウンバリアを提供します。
ステップ 6: 安全性の事前条件をラッパーに組み込む
安全な関数は、すべての呼び出し側に生ポインタと長さを渡させるのではなく、スライス、enum、所有権を持つハンドルなど、制約を表現する Rust の型を受け取るべきです。チェックを一元化し、unsafe な操作を数行に抑え、すべての事前条件を文書化してテストします。
ステップ 7: 移行ツールとマルチターゲットで検証する
エディション移行チェックと cargo fix --edition を実行し、生成された extern の変更を手動でレビューします。CI では、ホストプラットフォームとターゲットプラットフォーム、debug ビルドと release ビルド、静的リンクと動的リンク、および実際のライブラリバージョンに対する ABI リグレッションをカバーする必要があります。
ステップ 8: バージョンの乖離とロールバックに対処する
ヘッダー、バインディング、動的ライブラリが一致しない場合は、キャストで不一致を隠すのではなく、バージョンを固定するかバインディングを再生成します。段階的に展開し、ロールバックパスを維持し、ロードの失敗、エラーコードの変更、リソースリークを監視します。
質の高い模範解答
私なら、宣言のレビューと呼び出し側のカプセル化を分離します。まず、各外部ブロックを unsafe extern "C" に変更し、ABI、レイアウト、null 許容性、所有権、解放関数を正確なヘッダーおよびライブラリのバージョンと比較します。パブリックな事前条件が証明された関数のみを safe としてマークします。次に、生ポインタを Rust のハンドルとスライスベースの FFI モジュールの背後に配置し、長さ、初期化、スレッド処理、コールバックのシャットダウンをチェックし、常にライブラリの関数でリソースを解放するようにします。最後に、機械的な変更には cargo fix --edition を使用し、差分をレビューした上で、複数のターゲットで ABI、エラーパス、並行シャットダウン、動的ライブラリの互換性テストを実行します。これにより、すべての呼び出し側に外部ライブラリの隠れたコントラクトを再現させることなく、unsafe を可視化し監査可能な状態に保ちます。
よくある間違い
extern に unsafe を追加しただけで終わらせる
それでは構文が修正されただけです。シグネチャ、レイアウト、破棄プロトコルが正しくないと、依然として未定義動作が発生する可能性があるため、コントラクトのレビューと実行時のリグレッションテストは依然として必要です。
すべての外部関数を safe としてマークする
safe は呼び出し側に対する保証であり、コンパイラへのヒントではありません。ラッパーと型が事前条件を一貫して強制している場合にのみ使用してください。未知の関数やグローバルな状態を持つ関数は unsafe のままにしておく必要があります。
C のリソースを Rust の Box で解放する
アロケータをまたぐ破棄はヒープを破壊する可能性があります。リソースを生成したライブラリが解放関数を提供する必要があり、ラッパーの Drop 実装は正しいシャットダウン順序でそれを呼び出す必要があります。
フォローアップの質問と回答
C のヘッダーに構造体のレイアウトが記載されていない場合はどうしますか?
レイアウトを未検証のコントラクトとして扱います。公式のバインディングまたは不透明(opaque)なハンドルを優先して使用します。構造体が境界を越える必要がある場合は、フィールドを推測するのではなく、コンパイラ、プラットフォーム、ライブラリのバージョンを固定し、サイズ、アライメント、エンドツーエンドの動作を検証します。
安全な宣言を行った後にライブラリのアップグレードで動作が変更された場合はどうしますか?
バージョンのアップグレードごとに safe の保証を再監査します。ライブラリとバインディングのバージョンを固定し、エラーコード、スレッド処理、リソースのセマンティクスに関する互換性テストを追加します。同じ事前条件が保持されなくなった場合は、safe を削除し、ラッパー内でその条件を明示的に処理します。
任意のスレッドで実行される可能性のあるコールバックを Rust API でどのように処理すべきですか?
共有された可変状態を持つ任意の Rust クロージャを直接 C に渡してはなりません。スレッドセーフなチャネルまたは制御されたエグゼキュータを使用し、コールバックのライフタイムとシャットダウンバリアを定義した上で、Send、同期、およびライフタイムの要件が強制されている場合にのみ安全なインターフェースを公開します。