수명
러스트는 이런 규칙들을 수명을 통해서 강제합니다. 수명은 레퍼런스가 유효해야 하는, 이름이 지어진 코드의 지역입니다. 이 지역들은 꽤나 복잡할 수 있는데, 프로그램의 실행 분기에 대응하기 때문입니다. 또한 이런 실행 분기에는 구멍까지도 있을 수 있는데, 레퍼런스가 다시 쓰이기 전에 재초기화된다면, 이 레퍼런스를 무효화할 수 있기 때문입니다. 레퍼런스를 포함하는 (또는 포함하는 척하는) 타입도 수명을 붙여서 러스트가 그것을 무효화하는 것을 막게 할 수 있습니다.
우리의 예제 대부분, 수명은 코드 구역과 대응할 것입니다. 이것은 우리의 예제가 간단하기 때문입니다. 그렇게 대응되지 않는 복잡한 경우는 밑에 서술하겠습니다.
함수 본문 안에서 러스트는 보통 관련된 수명을 명시적으로 쓰게 하지 않습니다. 이것은 지역적인 문맥에서 수명에 대해 말하는 것은 대부분 별로 필요없기 때문입니다: 러스트는 모든 정보를 가지고 있고, 모든 것들이 가능한 한 최적으로 동작하도록 할 수 있습니다. 컴파일러가 아니라면 일일히 작성해야 하는 많은 무명 구역과 경계들을 컴파일러가 대신해 주어서 당신의 코드가 "그냥 작동하게" 해 줍니다.
그러나 일단 함수 경계를 건너고 나면 수명에 대해서 이야기해야만 합니다. 수명은 보통 작은 따옴표로 표시됩니다: 'a
, 'static
처럼요. 수명이라는 주제에 발가락을 담그기 위해서, 우리는 코드 구역을 수명으로 수식할 수 있는 척을 하며, 이 챕터의 시작부터 있는 예제들의 문법적 설탕을 해독해 보겠습니다.
원래 우리의 예제들은 코드 구역과 수명에 대해서 공격적인 문법적 설탕-- 고당 옥수수콘 시럽 같은 --을 이용했는데, 모든 것들을 명시적으로 적는 것은 굉장히 요란하기 때문입니다. 모든 러스트 코드는 이런 식의 공격적인 추론과 "뻔한" 것들을 생략하는 것에 의존합니다.
문법적 설탕 중 하나의 특별한 조각은 모든 let
문장이 암시적으로 코드 구역을 시작한다는 점입니다. 대부분의 경우에는 이것이 문제가 되지는 않습니다. 그러나 서로 참조하는 변수들에게는 문제가 됩니다.
간단한 예제로, 이런 간단한 러스트 코드 조각을 해독해 봅시다:
#![allow(unused)] fn main() { let x = 0; let y = &x; let z = &y; }
대여 검사기는 항상 수명의 길이를 최소화하려고 하기 때문에, 아마 이런 식으로 해독할 것입니다:
// 주의: `'a: {` 나 `&'b x` 는 유효한 문법이 아닙니다!
'a: {
let x: i32 = 0;
'b: {
let y: &'b i32 = &'b x;
'c: {
let z: &'c &'b i32 = &'c y; // "i32의 레퍼런스의 레퍼런스" (수명이 표시되어 있음)
}
}
}
와. 이건... 별로네요. 러스트가 이런 것들을 간단하게 만들어 준다는 것을 잠시 감사하는 시간을 가집시다.
...
자, 계속하자면, 외부 범위에 레퍼런스를 넘기면 러스트는 더 긴 수명을 추론하게 됩니다:
#![allow(unused)] fn main() { let x = 0; let z; let y = &x; z = y; }
'a: {
let x: i32 = 0;
'b: {
let z: &'b i32;
'c: {
// x의 레퍼런스가 'b 구역으로 넘겨지기 때문에
// 'b 가 됩니다.
let y: &'b i32 = &'b x;
z = y;
}
}
}
예제: 본체보다 오래 사는 레퍼런스들
좋습니다, 예전의 예제 중 몇 가지를 살펴봅시다:
#![allow(unused)] fn main() { fn as_str(data: &u32) -> &str { let s = format!("{}", data); &s } }
이렇게 해독됩니다:
fn as_str<'a>(data: &'a u32) -> &'a str {
'b: {
let s = format!("{}", data);
return &'a s;
}
}
as_str
의 시그니처는 어떤 수명을 가지고 있는, u32의 레퍼런스를 받고 딱 그만큼 사는, str의 레퍼런스를 만들 수 있다고 약속하고 있습니다. 이미 여기서 우리는 왜 이 시그니처가 문제가 있을 수도 있는지 알 수 있습니다. 이것은 우리가 str을 u32의 레퍼런스가 있던 코드 구역에서, 혹은 더 이전의 구역에서 찾을 것을 내포하고 있습니다. 이것은 좀 무리한 요구입니다.
그 다음 우리는 String s
를 계산하고, 그것을 가리키는 레퍼런스를 반환합니다. 우리의 함수가 체결한 계약은 레퍼런스가 'a
보다 더 살아야 한다고 되어 있으므로, 이것이 바로 우리가 이 레퍼런스에 할당해야 하는 수명입니다. 불행하게도 s
는 'b
구역에서 정의되었으므로, 이 코드가 건전할 수 있는 유일한 방법은 'b
가 'a
를 포함하는 것입니다 -- 'a
는 함수 호출 자체를 포함해야 하므로, 이는 명백하게 성립하지 않습니다. 그러므로 우리는 그 수명이 본체를 능가하는 레퍼런스를 만들었는데, 이것은 말 그대로 레퍼런스가 할 수 없다고 우리가 말했던 가장 첫번째 것입니다. 컴파일러는 당연히 우리 눈앞에서 터질 겁니다.
좀더 확실하게 하기 위해 이 예제를 확장해 볼 수 있습니다:
fn as_str<'a>(data: &'a u32) -> &'a str {
'b: {
let s = format!("{}", data);
return &'a s
}
}
fn main() {
'c: {
let x: u32 = 0;
'd: {
// x가 유효한 범위 전체 동안 빌림이 유지될 필요가
// 없기 때문에 새로운 구역을 집어넣었습니다. as_str의 반환값은
// 이 함수 호출 이전에 존재하는 str을 찾아야만 합니다.
// 당연히 일어나지 않을 일이죠.
println!("{}", as_str::<'d>(&'d x));
}
}
}
젠장!
이 함수를 올바르게 작성하는 방법은 물론 다음과 같습니다:
#![allow(unused)] fn main() { fn to_string(data: &u32) -> String { format!("{}", data) } }
우리는 함수 안에서 소유한 값을 생성해서 반환해야 합니다! 우리가 이렇게 &'a str
을 반환하려면 이것이 &'a u32
의 필드 안에 있어야만 하는데, 당연히 이 경우에는 말이 안됩니다.
(사실 우리는 문자열 상수값을 반환할 수도 있었습니다. 이 상수값은 스택의 맨 밑바닥에 있다고 생각할 수 있습니다. 이 구현이 우리가 원하는 것을 조금 제한하기는 하지만요.)
예제: 가변 레퍼런스의 복제
다른 예제를 볼까요:
#![allow(unused)] fn main() { let mut data = vec![1, 2, 3]; let x = &data[0]; data.push(4); println!("{}", x); }
'a: {
let mut data: Vec<i32> = vec![1, 2, 3];
'b: {
// 'b 는 우리가 이 레퍼런스가 필요한 동안 유지됩니다
// (`println!`까지 가야 하죠)
let x: &'b i32 = Index::index::<'b>(&'b data, 0);
'c: {
// &mut가 더 오래 살아남을 필요가 없기 때문에
// 임시 구역을 추가합니다
Vec::push(&'c mut data, 4);
}
println!("{}", x);
}
}
여기서 문제는 조금 더 감추어져 있고 흥미롭습니다. 우리는 다음의 이유로 러스트가 이 프로그램을 거부하기를 원합니다: 우리는 data
의 하위 변수를 참조하는, 살아있는 불변 레퍼런스 x
를 가지고 있는데,
이 동안 data
에 push
함수를 호출하여 가변 레퍼런스를 취하려고 합니다. 이러면 복제된 가변 레퍼런스를 생성할 테고, 이것은 레퍼런스의 두번째 규칙을 위반할 것입니다.
하지만 이것은 러스트가 이 프로그램이 나쁘다고 알아내는 방법이 전혀 아닙니다. 러스트는 x
가 data
의 일부의 레퍼런스라는 것을 이해하지 못합니다. 러스트는 Vec
을 아예 이해하지 못합니다.
러스트가 보는 것은 x
가 출력되기 위해서는 'b
만큼 살아야 한다는 것입니다. Index::index
의 시그니처가 그 다음에 요구하는 것은 우리가 data
에서 만든 레퍼런스가 'b
만큼 살아야 한다는 것입니다.
우리가 push
를 호출하려고 할 때, 러스트는 우리가 &'c mut data
를 만드려고 한다는 것을 보게 됩니다. 러스트는 'c
가 'b
안에 있다는 것을 알게 되고, &'b data
가 아직 살아 있어야 하기 때문에 우리의 프로그램을 거부합니다!
여기서 우리는 우리가 보존하는 데에 실제로 관심이 있는 레퍼런스 의미론보다 수명 시스템이 훨씬 헐거운 것을 볼 수 있습니다. 대부분의 경우에는 이것은 아무 문제가 없습니다, 왜냐하면 이것은 우리가 우리의 프로그램을 컴파일러에게 하루종일 설명하는 것을 방지해 주기 때문입니다. 그러나 이것은 러스트의 진정한 의미론에 잘 부합하는 몇 가지의 프로그램들이, 수명이 너무 멍청하기 때문에, 거부되는 것을 의미하기는 합니다.
수명이 차지하는 영역
레퍼런스(종종 빌림이라고 불립니다)는 창조된 곳부터 마지막으로 쓰이는 곳까지 살아있습니다. 빌려진 값은 살아있는 빌림들보다만 오래 살면 됩니다. 이것은 간단하게 보이지만, 몇 가지 미묘한 것들이 있습니다.
다음의 코드 조각은 컴파일되는데, x
를 출력하고 난 후에는 더이상 필요하지 않아지므로, 이것이 달랑거리거나 복제되어도 상관없기 때문입니다 (변수 x
가 실제로는 이 구역 끝까지 존재한다고 해도 말이죠).
#![allow(unused)] fn main() { let mut data = vec![1, 2, 3]; let x = &data[0]; println!("{}", x); // 이건 괜찮습니다, x는 더 이상 필요하지 않습니다 data.push(4); }
하지만, 만약 값이 소멸자가 있다면, 그 소멸자는 그 구역 끝에서 실행됩니다. 그리고 소멸자를 실행하는 것은 그 값을 사용하는 것으로 간주되죠 - 당연하게도 마지막으로요. 따라서, 이것은 컴파일되지 않을 것입니다.
#![allow(unused)] fn main() { #[derive(Debug)] struct X<'a>(&'a i32); impl Drop for X<'_> { fn drop(&mut self) {} } let mut data = vec![1, 2, 3]; let x = X(&data[0]); println!("{:?}", x); data.push(4); //여기서 소멸자가 실행되고 따라서 이 코드 조각은 컴파일에 실패합니다 }
컴파일러에게 x
가 더 이상 유효하지 않다고 설득하는 방법 중 하나는 data.push(4)
전에 drop(x)
를 사용하는 것입니다.
더 나아가서, 빌림의 가능한 마지막 사용처들이 여러 개 있을 수 있는데, 예를 들면 조건문의 각 가지입니다.
#![allow(unused)] fn main() { fn some_condition() -> bool { true } let mut data = vec![1, 2, 3]; let x = &data[0]; if some_condition() { println!("{}", x); // 이것이 이 가지에서 `x`의 마지막 사용입니다 data.push(4); // 따라서 여기서 push할 수 있죠 } else { // 여기에는 `x`의 사용이 없으므로, 사실상 마지막 사용은 // 이 예제 맨 위에서 x의 정의 시점입니다. data.push(5); } }
그리고 수명은 잠시 일시정지될 수 있습니다. 아니면 당신은 이것을 두 개의 별개의 빌림들이 같은 지역변수에 묶여 있다고 볼 수도 있습니다. 이것은 반복문 주변에서 종종 일어납니다 (반복문의 끝에서 변수에 새로운 값을 쓰고, 다음 차례 반복의 첫째 줄에서 그것을 마지막으로 사용하는 것이죠).
#![allow(unused)] fn main() { let mut data = vec![1, 2, 3]; // 이 mut은 레퍼런스가 가리키는 곳을 바꿀 수 있게 해 줍니다 let mut x = &data[0]; println!("{}", x); // 이 빌림의 마지막 사용 data.push(4); x = &data[3]; // 여기서 새로운 빌림이 시작합니다 println!("{}", x); }
역사적으로 러스트는 빌림을 그 구역의 끝까지 살려 놓았으므로, 이런 예제들은 예전의 컴파일러에서는 컴파일에 실패할 수도 있습니다. 또한, 러스트가 빌림의 살아있는 범위를 제대로 줄이지 못하고, 컴파일되어야 할 것 같은 때에도 컴파일에 실패하는 희귀한 경우들이 있습니다. 이런 것들은 시간이 지나면서 해결될 것입니다.