暗黙的な呼び出し元の位置情報
RFC 2091 で承認されたこの機能により、Option::unwrap、Result::expect、Index::index のような関数から開始されたパニック時に、呼び出し元の位置を正確に報告できるようになります。この機能は、関数用の #[track_caller] 属性、caller_location intrinsic、および安定化しやすい core::panic::Location::caller ラッパーを追加します。
動機となる例
このサンプルプログラムを考えてみます。
fn main() {
let foo: Option<()> = None;
foo.unwrap(); // これは有用なパニックメッセージを生成するべきです!
}
Rust 1.42 より前は、この unwrap() のようなパニックは core 内の位置を出力していました。
$ rustc +1.41.0 example.rs; example.exe
thread 'main' panicked at 'called `Option::unwrap()` on a `None` value',...core\macros\mod.rs:15:40
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace.
1.42 以降では、はるかに有用なメッセージが得られます。
$ rustc +1.42.0 example.rs; example.exe
thread 'main' panicked at 'called `Option::unwrap()` on a `None` value', example.rs:3:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
これらのエラーメッセージは、core::panic::Location::caller を利用するための panic! 内部の変更と、呼び出し元情報を伝播する標準ライブラリ内の多数の #[track_caller] アノテーションの組み合わせによって実現されています。
呼び出し元の位置情報を読み取る
以前は、panic! は Location を構築するために file!()、line!()、column!() マクロを使用して、パニックが発生した場所を指していました。これらのマクロにはオーバーライドされた位置を渡すことができなかったため、意図的に panic! を呼び出す関数は独自の位置を提供できず、実際のエラーの発生源が隠されていました。
内部的には、panic!() は現在、どこで展開されたかを調べるために core::panic::Location::caller() を呼び出します。この関数自体にも #[track_caller] がアノテートされており、rustc によって実装された caller_location コンパイラ intrinsic をラップしています。この intrinsic は、const コンテキストでどのように動作するかという観点から説明するのが最も簡単です。
const における呼び出し元の位置情報
const コンテキストで呼び出し元の位置を返すには、主に 2 つのフェーズがあります。適切な位置を見つけるためにスタックをさかのぼることと、返す const 値を割り当てることです。
適切な Location を見つける
const コンテキストでは、intrinsic が呼び出された場所から「スタックをさかのぼり」、その属性を持たないスタック内の最初の関数呼び出しに到達したところで停止します。この走査は InterpCx::find_closest_untracked_caller_location() 内で行われます。
一番下から開始して、InterpCx::stack 内のスタック Frame を上方向に反復し、[各 FrameのInstance][frame-instance] に対して [InstanceKind::requires_caller_location][requires-location] を呼び出します。false` を返すものを見つけた時点で停止し、「最上位」の tracked 関数であった前のフレームの span を返します。
静的な Location を割り当てる
Span が得られたら、Location 用の静的メモリを割り当てる必要があります。これは TyCtxt::const_caller_location() クエリによって実行されます。内部的には、これは InterpCx::alloc_caller_location() を呼び出し、一意の memory kind(MemoryKind::CallerLocation)になります。SSA codegen バックエンドはこれらと同じ値のコードを生成できるため、ここでもこのコードを使用します。
Location が静的メモリに割り当てられると、intrinsic はそれへの参照を返します。
#[track_caller] の callee のコードを生成する
tracked 関数とその呼び出し元のために効率的なコードを生成するには、実行時にさかのぼるスタックがなくても、intrinsic の視点から同じ振る舞いを提供する必要があります。そこでアプローチを反転します。スタックを下方向に伸ばす際、intrinsic が呼び出されたときにスタックをさかのぼるのではなく、tracked 関数の呼び出しに追加の引数を渡します。その追加引数は、呼び出し元の位置が照会される場所であればどこでも返すことができます。
追加する引数の型は &'static core::panic::Location<'static> です。執筆時点でポインターは std::mem::size_of::<core::panic::Location>() == 24 の 3 分の 1 のサイズであるため、不要なコピーを避ける目的で参照が選ばれました。
tracked である関数への呼び出しを生成する際には、location 引数として FunctionCx::get_caller_location の値を渡します。
呼び出し元の関数が tracked である場合、get_caller_location は FunctionCx::caller_location 内のローカルを返します。このローカルは現在の呼び出し元の呼び出し元によって設定されています。このような場合、intrinsic は、実際にはその呼び出し元への引数として提供された参照を「返し」ます。
呼び出し元の関数が tracked でない場合、get_caller_location は現在の Span から Location static を割り当て、それへの参照を返します。
スタックを下方向に伸ばす際、複数の FunctionCx の caller_location フィールドを通じて単一の &Location 値を渡すことで、一番下から始まるループと同じ振る舞いをより効率的に実現します。
Codegen の例
この変換は実際にはどのようなものになるのでしょうか。新しい機能を使用するこの例を考えてみます。
#![feature(track_caller)]
use std::panic::Location;
#[track_caller]
fn print_caller() {
println!("called from {}", Location::caller());
}
fn main() {
print_caller();
}
ここでは print_caller() は引数を取らないように見えますが、実際には次のようなものへコンパイルされます。
#![feature(panic_internals)]
use std::panic::Location;
fn print_caller(caller: &Location) {
println!("called from {}", caller);
}
fn main() {
print_caller(&Location::internal_constructor(file!(), line!(), column!()));
}
動的ディスパッチ
codegen コンテキストでは、この情報をスタックの下方向へ渡すために callee ABI を変更する必要がありますが、この属性は明示的に関数の型を変更しません。ABI の変更は型検査に対して透過的でなければならず、すべての使用において健全なままでなければなりません。
tracked 関数への直接呼び出しでは、callee の完全な codegen フラグが常に分かるため、適切なコードを生成できます。間接呼び出し元はこの情報を持たず、呼び出す関数ポインターの型にもエンコードされていないため、その関数へのポインターを取得するたびに、関数の周囲に ReifyShim を生成します。この shim は間接呼び出しの実際の位置を報告することはできません(代わりに関数の定義位置が報告されます)が、誤コンパイルを防ぎ、完全に安定化された型シグネチャを変更せずにできることとしては、おそらく最善です。
注: tracked 関数へのポインターを取得するときは、常に
ReifyShimを出力します。ここでの制約は codegen コンテキストによって課されるものですが、shim の MIR 構築中には、const コンテキスト(shim を無視しても安全)で呼び出されるのか、codegen コンテキスト(shim を無視すると安全でない)で呼び出されるのかは分かりません。仮に分かっていたとしても、const コンテキストと codegen コンテキストの結果は一致しなければなりません。
属性
#[track_caller] 属性は、他の codegen 属性とともにチェックされ、関数が次の条件を満たすことを保証します。
* `"Rust"` ABI を持つ(たとえば `"C"` などではない)
* クロージャではない
* `#[naked]` ではない
使用が有効であれば、[`CodegenFnAttrsFlags::TRACK_CALLER`][attrs-flags] を設定します。このフラグは [`InstanceKind::requires_caller_location`][requires-location] の戻り値に影響し、それはさらに const とコード生成の両方のコンテキストで使用され、正しい伝播を保証します。
### トレイト
トレイトメソッドの実装に適用された場合、この属性は通常の関数に対する場合と同じように動作します。
トレイトメソッドのプロトタイプに適用された場合、この属性はそのメソッドのすべての実装に適用されます。デフォルトのトレイトメソッド実装に適用された場合、この属性はその実装*および*任意のオーバーライドに対して有効になります。
例:
```rust
#![feature(track_caller)]
macro_rules! assert_tracked {
() => {{
let location = std::panic::Location::caller();
assert_eq!(location.file(), file!());
assert_ne!(location.line(), line!(), "行はこの関数の外側でなければなりません");
println!("{} で呼び出されました", location);
}};
}
trait TrackedFourWays {
/// すべての実装は `#[track_caller]` を継承します。
#[track_caller]
fn blanket_tracked();
/// 実装者は自身にアノテーションを付けることができます。
fn local_tracked();
/// この実装は追跡対象になります(オーバーライドも同様です)。
#[track_caller]
fn default_tracked() {
assert_tracked!();
}
/// この実装のオーバーライドは追跡対象になります(この実装自体も同様です)。
#[track_caller]
fn default_tracked_to_override() {
assert_tracked!();
}
}
/// この impl は `default_tracked` にはデフォルト実装を使用し、
/// `default_tracked_to_override` には独自の実装を提供します。
impl TrackedFourWays for () {
fn blanket_tracked() {
assert_tracked!();
}
#[track_caller]
fn local_tracked() {
assert_tracked!();
}
fn default_tracked_to_override() {
assert_tracked!();
}
}
fn main() {
<() as TrackedFourWays>::blanket_tracked();
<() as TrackedFourWays>::default_tracked();
<() as TrackedFourWays>::default_tracked_to_override();
<() as TrackedFourWays>::local_tracked();
}
背景/歴史
大まかに言えば、この機能の目標は、安定性の保証を破ることなく、エンドユーザーのソースに変更を要求せず、プラットフォーム固有のデバッグ情報に依存せず、ユーザー定義型が同じエラー報告上の利点を得られなくなることもなく、一般的な Rust のエラーメッセージを改善することです。
これらのパニックの出力を改善することは、少なくとも 2016 年半ば以降、提案の目標となっていました(詳細については、承認済み RFC の non-viable alternatives を参照してください)。RFC 2091 が承認されるまでにはさらに 2 年を要し、この機能の設計に関する rationale の多くは、それ以前のいくつかの提案をめぐる議論を通じて発見されました。
元の RFC の設計は、当時コンパイラ内で大幅なリファクタリングなしに実装できるものに限定されていました。しかし、RFC の承認から実際の実装作業までの 1 年半の間に、revised design が提案され、追跡 issue に書き起こされました。その実装の過程で、関数の MIR における引数の数を変更せずに実装できることも判明しました。これにより後段の処理が簡素化され、トレイトでの使用が可能になりました。
RFC の実装戦略はトレイトを容易にはサポートできなかったため、セマンティクスは当初仕様化されていませんでした。その後、著者とレビュー担当者にとって最も正しいと思われる道筋に従って実装されました。