암묵적 호출자 위치
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(); // this should produce a useful panic message!
}
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!이 file!(), line!(), column!() 매크로를 사용하여 패닉이 발생한 위치를 가리키는 Location을 구성했습니다. 이 매크로들에는 재정의된 위치를 전달할 수 없었으므로, 의도적으로 panic!을 호출하는 함수는 자신의 위치를 제공할 수 없었고, 그 결과 실제 오류의 원인이 가려졌습니다.
이제 panic!()은 내부적으로 core::panic::Location::caller()를 호출하여 자신이 어디에서 확장되었는지 알아냅니다. 이 함수 자체에도 #[track_caller] 어노테이션이 붙어 있으며, rustc가 구현하는 caller_location 컴파일러 내장 함수를 감쌉니다. 이 내장 함수는 const 컨텍스트에서 어떻게 동작하는지를 기준으로 설명하는 것이 가장 쉽습니다.
const에서의 호출자 위치
const 컨텍스트에서 호출자 위치를 반환하는 과정은 크게 두 단계로 이루어집니다. 스택을 거슬러 올라가 올바른 위치를 찾는 단계와 반환할 const 값을 할당하는 단계입니다.
올바른 Location 찾기
const 컨텍스트에서는 내장 함수가 호출된 지점부터 “스택을 거슬러 올라가며”, 해당 속성이 없는 첫 번째 함수 호출을 스택에서 만나면 멈춥니다. 이 탐색은 InterpCx::find_closest_untracked_caller_location()에 구현되어 있습니다.
맨 아래에서 시작하여 InterpCx::stack에 있는 스택 Frame들을 위로 순회하면서 각 Frame의 Instance에 대해 InstanceKind::requires_caller_location을 호출합니다. false를 반환하는 것을 찾으면 멈추고, “최상위” 추적 함수였던 직전 프레임의 span을 반환합니다.
정적 Location 할당하기
Span을 얻고 나면 Location을 위한 정적 메모리를 할당해야 하며, 이 작업은 TyCtxt::const_caller_location() 쿼리가 수행합니다. 이 쿼리는 내부적으로 InterpCx::alloc_caller_location()을 호출하며, 그 결과 고유한 메모리 종류(MemoryKind::CallerLocation)가 만들어집니다. SSA 코드 생성 백엔드도 이와 동일한 값에 대한 코드를 내보낼 수 있으며, 그곳에서도 이 코드를 사용합니다.
Location이 정적 메모리에 할당되면, 내장 함수는 그에 대한 참조를 반환합니다.
#[track_caller] 피호출자를 위한 코드 생성
추적되는 함수와 그 호출자를 위한 효율적인 코드를 생성하려면, 런타임에 거슬러 올라갈 스택이 없는 상태에서도 내장 함수 관점에서 동일한 동작을 제공해야 합니다. 그래서 접근 방식을 뒤집습니다. 내장 함수가 호출될 때 스택을 거슬러 올라가는 대신, 스택이 아래로 자라는 동안 추적되는 함수를 호출할 때마다 추가 인자를 전달합니다. 이 추가 인자는 호출자 위치를 조회하는 곳이라면 어디에서든 반환할 수 있습니다.
추가하는 인자의 타입은 &'static core::panic::Location<'static>입니다. 이 글을 작성하는 시점에서 포인터의 크기는 std::mem::size_of::<core::panic::Location>() == 24의 3분의 1이므로, 불필요한 복사를 피하기 위해 참조를 선택했습니다.
추적되는 함수에 대한 호출을 생성할 때는 위치 인자에 FunctionCx::get_caller_location의 값을 전달합니다.
호출하는 함수가 추적되는 함수라면 get_caller_location은 현재 호출자의 호출자가 채워 넣은 FunctionCx::caller_location의 로컬을 반환합니다. 이러한 경우 내장 함수는 실제로는 호출자에게 인자로 제공된 참조를 “반환“합니다.
호출하는 함수가 추적되지 않는 함수라면 get_caller_location은 현재 Span으로부터 Location 정적 값을 할당하고 그에 대한 참조를 반환합니다.
스택을 아래쪽으로 키워 나가면서 여러 FunctionCx의 caller_location 필드를 통해 단일 &Location 값을 전달함으로써, 맨 아래에서 시작하는 루프와 동일한 동작을 더 효율적으로 구현합니다.
코드 생성 예제
이 변환은 실제로 어떤 모습입니까? 새 기능을 사용하는 다음 예제를 살펴보십시오.
#![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!()));
}
동적 디스패치
코드 생성 맥락에서는 이 정보를 스택 아래로 전달하기 위해 피호출자의 ABI를 수정해야 하지만, 이 속성은 함수의 타입을 명시적으로 수정하지 않습니다. ABI 변경은 타입 검사에 드러나지 않아야 하며 모든 사용에서 건전성을 유지해야 합니다.
추적되는 함수에 대한 직접 호출은 항상 피호출자의 전체 코드 생성 플래그를 알고 있으므로 적절한 코드를 생성할 수 있습니다. 간접 호출자는 이 정보를 갖고 있지 않으며 이 정보는 호출하는 함수 포인터의 타입에도 인코딩되어 있지 않으므로, 함수에 대한 포인터를 취할 때마다 해당 함수를 감싸는 ReifyShim을 생성합니다. 이 심(shim)은 간접 호출의 실제 위치를 보고할 수 없지만(대신 함수의 정의 위치가 보고됩니다), 잘못된 컴파일을 방지하며, 완전히 안정화된 타입 시그니처를 수정하지 않고 할 수 있는 최선일 것입니다.
참고: 추적되는 함수에 대한 포인터를 취할 때는 항상
ReifyShim을 생성합니다. 여기서의 제약은 코드 생성 컨텍스트에 의해 부과되지만, shim의 MIR을 구성하는 동안에는 호출되는 곳이 const 컨텍스트(shim을 무시해도 안전함)인지 코드 생성 컨텍스트(shim을 무시하면 안전하지 않음)인지 알 수 없습니다. 설령 알 수 있다 하더라도, const 맥락과 코드 생성 맥락의 결과는 일치해야 합니다.
속성
#[track_caller] 속성은 다른 코드 생성 속성과 함께 검사되어 함수가 다음 조건을 만족하는지 확인합니다.
"Rust"ABI를 가짐(예:"C"가 아님)- 클로저가 아님
#[naked]가 아님
사용이 유효하면 CodegenFnAttrsFlags::TRACK_CALLER를 설정합니다. 이 플래그는 InstanceKind::requires_caller_location의 반환 값에 영향을 주며, 이 값은 다시 const 맥락과 코드 생성 맥락 모두에서 올바른 전파를 보장하는 데 사용됩니다.
트레이트
트레이트 메서드 구현에 적용하면 이 속성은 일반 함수에서와 동일하게 동작합니다.
트레이트 메서드 프로토타입에 적용하면 이 속성은 해당 메서드의 모든 구현에 적용됩니다. 기본 트레이트 메서드 구현에 적용하면 이 속성은 해당 구현과 모든 재정의에 적용됩니다.
예시:
#![feature(track_caller)]
macro_rules! assert_tracked {
() => {{
let location = std::panic::Location::caller();
assert_eq!(location.file(), file!());
assert_ne!(location.line(), line!(), "line should be outside this fn");
println!("called at {}", location);
}};
}
trait TrackedFourWays {
/// All implementations inherit `#[track_caller]`.
#[track_caller]
fn blanket_tracked();
/// Implementors can annotate themselves.
fn local_tracked();
/// This implementation is tracked (overrides are too).
#[track_caller]
fn default_tracked() {
assert_tracked!();
}
/// Overrides of this implementation are tracked (it is too).
#[track_caller]
fn default_tracked_to_override() {
assert_tracked!();
}
}
/// This impl uses the default impl for `default_tracked` and provides its own for
/// `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의 실행 불가능한 대안을 참고하십시오). RFC 2091이 승인되기까지는 2년이 더 걸렸으며, 이 기능의 설계에 대한 근거의 상당 부분은 앞선 여러 제안을 둘러싼 논의를 통해 발견되었습니다.
원래 RFC의 설계는 당시 대대적인 리팩터링 없이 컴파일러 내부에서 수행할 수 있는 구현으로 범위를 한정했습니다. 그러나 RFC 승인과 실제 구현 작업 사이의 1년 반 동안 수정된 설계가 제안되어 추적 이슈에 작성되었습니다. 이를 구현하는 과정에서 함수 MIR의 인자 개수를 변경하지 않고도 구현할 수 있다는 사실도 발견되었으며, 이는 이후 단계를 단순화하고 트레이트에서의 사용을 가능하게 합니다.
RFC의 구현 전략으로는 트레이트를 쉽게 지원할 수 없었기 때문에, 처음에는 그 의미론이 명시되지 않았습니다. 그 이후로 저자와 리뷰어가 가장 올바르다고 판단한 경로에 따라 구현되었습니다.