プロンプトと適用範囲
異なるエグゼキュータで使用されるService traitを保守しているとします。そのメソッドはasync fnを使用していますが、スレッド間を移動する可能性のあるタスクにFutureを渡す呼び出し元(call site)は一部のみです。返されるすべてのFutureをSendと宣言することが制限的すぎる理由を説明し、戻り値型記法(RTN)、traitバリアント、およびGATの選択基準を示してください。
この質問は、Rust 1.75で安定化されたAFITおよびRPITIT、ならびにRFC 3654で提案されたRTNを対象としています。RTNはまだnightlyの実験的機能です。回答では言語機能、エグゼキュータの要件、リリースの安定性を区別して説明してください。
面接官がテストしていること
優れた回答では、各Futureがtraitメソッドの匿名戻り値型であることを明確に特定します。ジェネリックまたはdyn Traitの呼び出し元は、trait単体からそのSendプロパティを推論できないため、境界は実際にスレッドを跨ぐ呼び出し元に付与されるべきです。
面接官はRTNの適用範囲についても確認しています。RTNは通常のフィールド型として機能するのではなく、AFIT/RPITITの戻り値を制約するものです。nightlyのリスク、シングルスレッドエグゼキュータ、ワークスティーリングエグゼキュータ、および互換性パスについて論じてください。
明確化のための質問
Futureはスレッド間を移動できるか
エグゼキュータが作業を1つのスレッドに固定する場合、Sendは不要な場合があります。ワークスティーリングエグゼキュータでは、spawnされるFutureがSendである必要があり、一般的に'staticも必要となります。
境界は1つのメソッドに対するものか、それともtrait全体に対するものか
callのみが移動可能である必要がある場合、RTNでcall(..): Sendを表現できます。すべてのメソッドで同じ境界が必要な場合は、traitバリアントを使用することでメソッドレベルの節の繰り返しを避けることができます。
本番環境でnightlyを使用できるか
パブリックライブラリが安定版Rustをサポートしなければならない場合、RTNのみの設計にはできません。生成されたSend traitまたは明示的なGATを評価し、リリースマトリックスにコンパイラバージョンを記録してください。
30秒の回答
「まず、エグゼキュータがタスクを移動できるかどうか、そしてどのメソッドがその保証を必要としているかを確認します。ベースtraitの制約は最小限に保ち、スレッドを跨いでspawnする呼び出し元にT::call(..): Send + 'staticを記述します。すべてのメソッドにSendが必要な場合はtraitバリアントを使用し、安定版Rustと名前付きFutureが必要な場合はGATを使用します。RTNはnightlyであるため、分離されたツールチェーンでテストし、安定版のフォールバックを保持します。」
ステップバイステップの解決策
ステップ 1: エグゼキュータから境界を導出する
シングルスレッドまたはthread-per-coreエグゼキュータは、通常スレッド間でタスクを移動しません。ワークスティーリングエグゼキュータは移動するため、spawnに渡されるFutureは通常Sendが必要であり、多くの場合'staticも必要です。したがって、Sendはコンシューマの要件であり、すべてのtraitメソッドのデフォルトのプロパティではありません。
ステップ 2: RTNで1つのメソッドを制約する
RTNはメソッドの戻り値型に境界を追加します。以下はnightly構文を使用しています:
trait Service<Request> {
type Response;
async fn call(&self, request: Request) -> Self::Response;
}
async fn spawn_call<S, R>(service: S, request: R) -> S::Response
where
S: Service<R> + Send + 'static,
R: Send + 'static,
S::call(..): Send + 'static,
{
tokio::spawn(async move { service.call(request).await })
.await
.expect("task failed")
}S::call(..)は、traitメソッドが返すFutureを指しており、awaitした後に生成される値ではありません。そのプロパティを必要とするコンシューマのみを制約し、ローカル実装は他の場所で引き続き使用できるようにします。
ステップ 3: traitバリアントを比較する
すべてのasyncメソッドにSendが必要な場合は、制約を最小限に抑えたベースtraitを維持し、Sendバリアントを生成します:
#[trait_variant::make(SendService: Send)]
trait LocalService<R> {
async fn call(&self, request: R) -> Response;
}これは共通パスを持つパブリックAPIに適しています。実装者は1つのベースtraitを対象とし、呼び出し元はローカルまたはSendバリアントを選択します。トレードオフはメソッドレベルの精度が弱くなることです。1つのメソッドのみが境界を必要とする場合はRTNの方が優れています。
ステップ 4: 安定した名前付けが重要な場合はGATを選択する
安定版Rustで返されるFutureに名前を付ける必要がある場合、GATは関連型を公開できます:
trait StableService {
type Future<'a>: Future<Output = Response> + Send + 'a
where
Self: 'a;
fn call(&self) -> Self::Future<'_>;
}すべての実装で具象Future型を提供する必要があるため、ボイラープレートが増加します。安定版サポートやFuture型の保存・再利用が、async構文の人間工学的メリット(使いやすさ)を上回る場合に使用します。
ステップ 5: 制限事項と移行リスクを検証する
Rustチームは、RTNがAFIT/RPITITを使用するtrait関連関数またはメソッドに適用されるとドキュメントに記載しています。現時点では構造体のフィールド型としては使用できません。安定版リリースのためのtraitバリアントまたはGATパスを維持しながら、nightly CIで構文、診断、マクロ展開をテストしてください。
ステップ 6: 比較をルール化する
メソッドレベルおよび呼び出し元レベルのスレッド間要件にはRTNを選択し、すべてのメソッドが要件を共有する場合はtraitバリアントを、安定版Rustと名前付け可能な戻り値型が必須の場合はGATを選択します。エグゼキュータがタスクを移動しない場合は、見かけの安全性のために実装セットを縮小するのではなく、ローカルFutureを維持してください。
模範的な高評価の回答
まず、エグゼキュータがタスクを移動できるかどうかを尋ねます。Tokioなどのワークスティーリングエグゼキュータでは、spawnされる外側のFutureは一般にSend + 'staticが必要です。シングルスレッドエグゼキュータでは、その境界をすべての実装に伝播させる必要はありません。ベースtraitは最小限に保ち、スレッドを跨ぐジェネリック関数にのみS::call(..): Send + 'staticを記述します。これにより、call Futureがローカル専用の実装が維持されます。
すべてのasyncメソッドが移動可能である必要がある場合は、traitバリアントを使用してSend APIを提供します。プロジェクトが安定版Rustにとどまる必要があり、Futureに名前を付けたり保存したりする必要がある場合は、GATを使用します。RTNはまだnightlyであるため、CIで分離し、実験的構文をパブリックの最小バージョンにするのではなく、安定した設計を維持します。
よくある間違い
- 兆候 → trait内のすべてのasync戻り値に
Sendを追加する → 失敗する理由 → 有効なシングルスレッド実装が除外される → 修正方法 → スレッドを跨ぐコンシューマ側に境界を配置する。 - 兆候 →
S::call(..)をawaitされた結果の型として扱う → 失敗する理由 → RTNはS::Responseではなく、返されるFutureを制約する → 修正方法 → この違いを明示的に説明する。 - 兆候 → nightlyのRTNを本番環境に直接リリースする → 失敗する理由 → コンパイラおよび構文サポートが変更される可能性がある → 修正方法 → CIでnightlyを固定し、安定版のフォールバックを維持する。
- 兆候 → すべてのasync traitをGATに変換する → 失敗する理由 → 実装者が具象Future型を公開しなければならなくなる → 修正方法 → 安定した名前付けが真の要件である場合にのみGATを使用する。
- 兆候 →
ServiceがSendであるかのみを確認する → 失敗する理由 → 移動可能なサービスであっても、非SendのFutureを返す可能性がある → 修正方法 → オブジェクト、引数、返されるFuture、および外側のタスクを個別に制約する。
フォローアップの質問と回答
フォローアップ 1: なぜS::call(..): Sendに対してS: Sendだけでは不十分なのですか?
S: Sendは、サービスの値がスレッド間を移動できることを示しています。そのメソッドは、Future内でRcや別の非Send値を依然としてキャプチャする可能性があります。スレッド間のspawnでは、外側のFutureとawaitされるすべてのFutureがチェックされるため、返されるFutureには独自の境界が必要です。
フォローアップ 2: putのみがSendを必要とし、getはローカルキャッシュを使用しています。どう対応しますか?
ベースtraitを維持し、スレッドを跨ぐ呼び出し元でBackendのput(..): Sendを制約します。getも制限するバリアントを生成しないでください。有効なローカルキャッシュ実装が拒否されてしまうためです。
フォローアップ 3: RTNはまだフィールド型として使用できません。Futureをどのように保存できますか?
明示的なGAT、名前付きFutureを使用するか、Box::pinを使用して境界で型消去します。安定版サポート、アロケーションコスト、およびオブジェクト安全性のニーズに基づいて選択してください。RTNは保存可能な型を自動的に作成するわけではありません。
フォローアップ 4: 境界が強すぎないことをどのように証明しますか?
3つのコンパイルケースを作成します:Sendを持たないが1つのスレッドで実行されるローカル実装、putのみがSend Futureを返す実装、そしてすべてのメソッドをスレッド間でspawnできる実装です。それぞれを安定版とnightlyで実行し、意図した呼び出し元で失敗が発生することを確認します。