Lifetime
Borrow Checker
Rust는 & 레퍼런스(reference)를 통해 소유권을 이전하지 않고 borrow 하게 되는데, Borrow Checker는 레퍼런스가 가리키는 메모리가 타당한 지를 체크하는 일을 한다.
Borrow Checker는 레퍼런스가 가리키는 메모리가 Scope를 벗어나 없어지지 않았는지 체크하기 위해 "레퍼런스의 Lifetime"을 사용하게 된다. Borrow Checker는 컴파일 시에 동작하기 때문에, 만약 레퍼런스 에러를 발견한다면 컴파일 에러가 발생한다.
아래 코드 라인 6에서 변수 x는 y의 레퍼런스로 borrow하게 되는데, 변수 y는 라인 7에서 Scope를 벗어나 없어지게 되므로, 라인 9에서 변수 x를 사용하면 에러가 발생할 것이다. Borrow Checker는 borrow의 타당성을 파악하기 위해 Lifetime을 사용하게 되는데, x의 Lifetime은 라인 2 ~10 까지('a 로 표시하였다)이고, y의 Lifetime은 라인 5 ~ 7까지('b 로 표시하였다)이어서, y의 Lifetime이 x보다 작기 때문에 borrow가 타당하지 않다고 판단하게 된다. 만약 y의 Lifetime이 x보다 크거나 같다면, borrow가 타당하고 판단할 것이다.
Lifetime Annotation
위 예제와 같이 간단한 코드의 경우 Lifetime을 컴파일러가 체크할 수 있지만, 함수나 구조체(struct) 등에 레퍼런스가 사용될 때는 일반적으로 컴파일러가 Lifetime을 체크할 수 없는 경우가 많다(주: Lifetime을 생략하는 경우도 있다). 만약 함수나 구조체 레퍼런스가 사용되면, 개발자는 명시적으로 Lifetime을 함께 표시하여 컴파일러가 borrow 체킹할 수 있도록 해주는데, 이를 Lifetime Annotation 이라 한다.
Lifetime Annotation은 'a 와 같이 어포스트로피 뒤에 간단한 문자(혹은 단어)를 적어 표시한다. 예를 들어, &str은 string slice 타입인데, 여기에 'a 라는 Lifetime Annotation을 넣으면 &'a str 와 같이 쓸 수 있다. 즉, 레퍼런스 & 뒤에 Lifetime Annotation 'a 을 넣고 뒤에 스페이스를 두고 str 을 적는다. 마찬가지로 mutable string slice 인 경우는 &'a mut str 같이 쓴다.
&str &'a str &'a mut str
예를 들어, 아래와 같은 echo 함수는 &str (string slice)를 입력 파라미터와 리턴 타입으로 사용하고 있다.
fn echo(msg: &str) -> &str { msg }
위의 echo 함수는 Lifetime을 생략하는 경우이지만, 정식으로 Lifetime을 넣어 표현하면 아래와 같이 된다. (즉, 원래는 아래와 같이 Lifetime Annotation을 넣어 주어야 하는데, Rust에서 몇가지 패턴의 경우는 위와 같이 생략할 수 있도록 하고 있다.) Lifetime Annotation은 함수/메서드의 시그니쳐(signature)에만 사용하고, 함수/메서드의 Body에는 사용하지 않는다.
아래 예제의 Lifetime Annotation을 보면, 함수명 뒤에 제네릭으로 'a Lifetime Annotation을 넣었고, 이를 파라미터 타입에서 &'a str 와 같이 사용하였다. 또한, 리턴 타입에도 입력 파라미터와 동일한 Lifetime을 표시하는 'a 를 넣고 있다. 이는 다시 말하면, 리턴 레퍼런스는 입력 레퍼런스와 동일한 Lifetime을 갖는다는 것을 의미한다.
fn echo<'a>(msg: &'a str) -> &'a str { msg }
그럼 이렇게 Lifetime Annotation을 사용하는 것이 어떠한 효과를 미치는 것일까? 아래 예제는 두 개의 문자열의 길이가 긴 쪽을 리턴하는 longer() 함수를 예시한 것이다. longer() 함수는 2개의 string slice인 x, y 파라미터를 받아들이고 있고, 이중 긴 쪽을 리턴하는 것이다. 이 함수의 Lifetime Annotation으로 'a 를 사용하고 있고, 2개의 파라미터와 리턴 타입 모두 'a Lifetime으로 표시되어 있다. 이 프로그램을 실행하면 긴 쪽의 문자열인 x (abcde) 가 리턴되므로, 프로그램은 "abcde"를 출력한다.
fn longer<'a>(x: &'a str, y: &'a str) -> &'a str { if x.len() > y.len() { x } else { y } } fn main() { let x = String::from("abcde"); let y = String::from("abc"); let z = longer(&x, &y); println!("{}", z); }
그런데, 위의 logner() 함수를 다음과 같이 호출하면 어떻게 될까?
fn main() { let x = String::from("abcde"); let z; { let y = String::from("abc"); z = longer(&x, &y); } // y의 lifetime이 여기서 종료 println!("{}", z); }
이 프로그램은 error[E0597]: `y` does not live long enough 와 같은 컴파일 에러를 발생시킨다. 비록 longer() 함수에서 x의 값을 리턴하고 y를 사용하지 않는다고 하더라도, 컴파일러는 (런타임이 아닌 컴파일타임에) 이런 실행 결과를 예측하지 않고, longer() 함수의 Lifetime이 파라미터와 리턴 타입 모두에게 'a 으로 표시되어 있는 상황에서, 함수에 전달되는 두개의 파라미터의 Lifetime 중에서 더 작은 Lifetime을 갖는 파라미터만큼의 Lifetime을 리턴 타입에 적용한다. 따라서, logner() 함수는 y의 Lifetime을 리턴 타입의 lifetime에 사용하고, 이것이 main() 에서 변수 z에 할당된다. 변수 z의 Lifetime은 y의 Lifetime과 같게 되므로, 이 때문에 블럭을 벗어나면서 에러가 발생하는 것이다.
다시 말하면, Rust의 Borrow Checker는 이렇게 함수에 정의된 Lifetime 정보에 기초하여 컴파일시에 레퍼런스가 이미 없어진 메모리를 엑세스하는지를 체크하게 된다.
그런데, 만약 위의 longer() 함수에 Lifetime Annotation을 사용하지 않으면 어떻게 될까? 만약 longer() 함수에 Lifetime Annotation을 전혀 사용하지 않으면, 컴파일러는 아래와 같이 각 파라미터마다 서로 다른 Lifetime 레이블을 붙이게 된다. 그런데, 컴파일러는 리턴 타입의 경우 'a 를 사용해야 할 지, 'b를 사용해야 할 지 판단할 수 없게 되기 때문에, 컴파일 에러를 발생시키게 된다.
// 컴파일 에러 발생함 fn longer<'a, 'b>(x: &'a str, y:&'b str) -> &'??? str { if x.len() > y.len() { x } else { y } }
Lifetime Annotation은 기본적으로 리턴값의 Lifetime이 어떤 입력 파라미터의 Lifetime과 연관되는 지를 표시한 것으로, 아래 예의 경우 리턴값의 Lifetime은 word 파라미터 보다는 sentences 파라미터의 Lifetime과 같은 것임을 표현한 것이다.
fn search<'a>(word: &str, sentences: &'a str) -> Vec<&'a str> { // 생략... }
함수 이외에 구조체나 impl 메서드에서도 Lifetime Annotation을 사용할 수 있는데, 아래는 이들에 Lifetime Annotation을 사용하는 용법을 예시한 것이다.
struct Data<'a, 'b> { field1: &'a str, field2: &'b str, } impl<'a, 'b> Data<'a, 'b> { //... }
Lifetime Annotation 생략 (Lifetime Elision)
Lifetime Annotation은 함수나 메서드의 시그니쳐를 복잡하게 만들기 때문에, Borrow Checker가 예측할 수 있는 일정한 패턴을 갖는 경우 Lifetime Annotation을 생략할 수 있다.
Lifetime Annotation을 생략할 경우, 컴파일러는 아래와 같은 3가지 Elision Rule을 적용하여 Lifetime Annotation을 생략해도 되는지를 체크한다.
- 각 파라미터는 각각의 Lifetime 파라미터를 갖는다. 예를 들어, 파라미터가 하나인 경우 'a 를 갖고, 두개인 경우 'a와 'b를 갖는다.
- 만약 파라미터가 하나이면, 그 파라미터의 Lifetime을 모든 리턴 타입(들)에 적용한다.
- 만약 파라미터가 복수 개이고 메서드로서 self를 갖는다면, self의 Lifetime을 모든 리턴 타입(들)에 적용한다.
예를 들어, 아래 echo() 함수의 경우
fn echo(msg: &str) -> &str
첫번째 Elision Rule에 의해 파라미터 msg는 다음과 같이 'a 를 갖는다
fn echo<'a>(msg: &'a str) -> &str
다음으로 두번째 Rule에 의해 파라미터가 하나이므로, 다음과 같이 리턴 타입에 (파라미터와 동일한 Lifetime인) 'a 를 갖는다.
fn echo<'a>(msg: &'a str) -> &'a str
이러한 방식으로 컴파일러는 필요한 Lifetime을 알아낼 수 있으므로, 개발자가 Lifetime을 생략하는 것을 허용한다.
하지만, longer() 함수의 경우는 첫번째 Rule에 의해 아래와 같이 변경할 수 있지만, 두번째 Rule이나 세번째 Rule을 적용할 수 없기 때문에 리턴 레퍼런스의 Lifetime을 알아낼 수 없게 되어, Elision을 사용할 수 없게 된다. 즉, 이 경우는 개발자가 명시적으로 Lifetime Annotation을 지정해 주어야 한다.
fn longer<'a, 'b>(x: &'a str, y:&'b str) -> &str
Static Lifetime
Lifetime Annotation 에는 'static 이라는 특별한 Lifetime 이 있다. 이 Lifetime은 프로그램이 실행되는 기간 동안 존재하는 Lifetime 이다. Rust에서 모든 string 리터럴은 프로그램 실행 기간 동안 존재하여 'static Lifetime을 갖는다. string 리터럴의 경우, 'static Annotation을 생략할 수 있다.
let s: &'static str = "hello";