라이프타임



라이프타임은 어쩌면 러스트에서 가장, 적어도 지금까지 다룬 내용 중 개념적으로 가장 이해하기 어려운 파트일 수 있음. 이해한 것을 정리하는 느낌으로 라이프타임이 무엇이고, 왜 필요한지 적어본다. (단, 라이프타임 파라미터의 문법에 대해 세세하게 다루지는 않는다.)


라이프타임은 프로그래밍 언어에서 자주 등장하는, 변수 혹은 레퍼런스의 '유효 범위(이하 스코프)' 라고 생각하면 된다. 


차근차근 살펴보자.


먼저, 러스트의 모든 변수와 레퍼런스는 각자의 스코프를 가진다. 변수의 스코프는 자신이 선언/생성된 스코프와 그 하위 스코프 까지이고, 그 범위를 벗어난 위치에서의 접근은 컴파일 에러를 일으킨다. (레퍼런스의 경우 자신이 생성된 스코프에서 다른 스코프로 '이동' 할 가능성이 있다.)


fn main() {                                 // 메인 스코프

{ // 또 다른 스코프가 열림
let s1 = "my string".to_string(); // 이 스코프에서 변수와 스트링 생성.
} // 스코프가 끝나면서 변수 s1 은 폐기되고, 참조하던 스트링 메모리도 해제됨.
println!("{}", s1); // 변수 s1 의 유효 스코프 밖에서의 접근 -에러!
}


에러 메시지는 다음과 같다.


error[E0425]: cannot find value `s1` in this scope



그렇다면 아래 코드는 어떨까?


fn main() {
let s1;

{
let s2 = "my string".to_string();
s1 = s2;
}
println!("{}", s1);
}


위 코드의 아웃풋은 "my string" 이다. 스트링이 생성된 스코프가 끝난 뒤 스코프 밖에서 스트링에 접근했는데 컴파일-실행에 아무런 문제가 없다. 이것은 왜냐하면 변수 s2 이 폐기되기 전에 s1 에 레퍼런스를 '이동' 시켰기 때문이다. s2 은 폐기되어도 바깥 스코프의 s1 에 스트링 레퍼런스를 넘겼으니 스트링은 살아있고, 변수 s1 을 통한 접근에 문제가 없다.



이제 아래 코드를 본다.


fn main() {

let s1;

{
let s2 = "my string".to_string();
s1 = &s2;
}
println!("{}", s1);
}


위 코드는 컴파일 에러를 일으킨다. 에러 메시지는 다음과 같다.


error[E0597]: `s2` does not live long enough


이게 무슨 말인가. 여기서부터 라이프타임의 개념이 시작된다.


라이프타임은 레퍼런스의 '이동' 이 아닌, '대여' 에 관한 것이다. 원본 레퍼런스가 존재하고, 이 레퍼런스를 다른 변수에 빌려주었는데, 이 상태에서 원본 레퍼런스가 사라질 '가능성' 이 있는 코드는 러스트에서 컴파일 에러를 일으킨다. 위 코드에서는 변수 s1 의 스코프가 s2 의 스코프보다 넓다. s2 이 스코프가 끝나 사라져도 s1 은 여전히, '더 오래' 존재한다. 그런데 s1 은 s2 의 레퍼런스를 '대여' 한 상황이다. 이 상태에서 원본 레퍼런스를 가진 s2 이 먼저 사라진다면? s1 이 대여한 레퍼런스도 무효화된다. 변수 s1 의 사용자는 여전히 s1 이 스트링을 참조하고 있다고 생각하지만, 이 참조는 쓰레기 참조이다. 전형적인 댕글링 레퍼런스(댕글링 포인터)의 발생이며, use-after-free 케이스이다. 러스트는 컴파일 타임에 댕글링 레퍼런스 현상을 막고자 한다.


라이프타임은 댕글링 레퍼런스를 어떻게 막을 것인가? 에 대한 러스트의 해답이다.


처음에, 모든 변수와 레퍼런스는 자신의 스코프를 가진다고 했다. 이 스코프를 라이프타임이라 생각하자. 모든 변수와 레퍼런스는 생성되면서 자신의 라이프타임을 가진다.


{
let r; // -------+-- 'a
// |
{ // |
let x = 5; // -+-----+-- 'b
r = &x; // | |
} // -+ |
// |
println!("r: {}", r); // |
// |
// -------+
}


위 코드에서, 변수 r 의 라이프타임은 'a 이다. 변수 x 의 라이프타임은 'b 이다. 라이프타임 'a 의 범위가 'b 의 범위보다 넓다. 이 말은 즉, 라이프타임 'a 의 변수는 라이프타임 'b 보다 오래 존재한다. 때문에 라이프타임 'b 의 변수는 라이프타임 'a 의 레퍼런스를 대여해도 괜찮지만, 반대로 라이프타임 'a 의 변수는 라이프타임 'b 의 레퍼런스를 대여할 수 없다. (대여된 레퍼런스가 대여한 레퍼런스보다 먼저 사리지면 대여한 레퍼런스가 가진 것은 무효한 참조(댕글링 레퍼런스)가 되기 때문에.)


간단히 말해서, 대여되는 쪽이 대여 해가는 쪽보다 오래(or 동일하게) 살아 있어야 한다.


{
let x = 5; // -----+-- 'b
// |
let r = &x; // --+--+-- 'a
// | |
println!("r: {}", r); // | |
// --+ |
}


위 코드에서, 변수 x 의 라이프타임은 'b 이다. 변수 r 의 라이프타임은 'a 이다. 'a 보다 'b 이 더 길다. 고로, 변수 x 가 r 보다 오래(사실 여기서는 똑같이) 살고, r 은 x 의 레퍼런스를 대여할 수 있다.


간단하지 않은가? 사실 다른 언어에서도 마찬가지인 이야기이고, 특별할 게 없다. 그럼 넘어가보자.



함수에서의 라이프타임



fn longest(x: &str, y: &str) -> &str {
if x.len() > y.len() {
x
} else {
y
}
}


위 코드는 아래와 같은 컴파일 에러를 일으킨다.


error[E0106]: missing lifetime specifier

 --> src/main.rs:6:33

  |

6 | fn longest(x: &str, y: &str) -> &str {

  |                                 ^ expected lifetime parameter

  |

  = help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `x` or `y`


이 함수의 리턴값은 x 가 될 수도, y 가 될 수도 있다. 둘 중 무엇이 될지는 런타임에 파라미터 값에 따라 결정된다. 즉 컴파일 타임에 미리 알 수는 없다. 당연하다. 그런데 이게 왜 문제가 될까?

아래 코드를 보면 답이 나온다. (코드에서 하드코딩된 스트링 값은 실제 런타임에는 알 수 없는 값이라고 생각하고 봐야 한다.)

fn main() {

let s1 = "value of s1".to_string();
let result;
{
let s2 = "value of s2".to_string();
result = longest(&s1, &s2);
}

println!("{}", result);
}


감이 오는가? 코드의 스코프 구조에 집중해서 보도록 하자.


함수 longest 의 입장에서는 파라미터로 어떤 라이프타임을 가진 레퍼런스가 전달될지 모른다. 그리고 리턴값을 받는 주체도 어떤 라이프타임을 가졌을지 알 수 없다. 위 코드에서 파라미터가 된 s1, s2 의 라이프타임은 명백히 다르다. longest 는 두 값의 길이를 비교하여 큰 쪽을 레퍼런스 그대로 리턴하는데, 그 리턴값은 받는 변수 result 의 라이프타임은 s1 과 동일하다. 앞서 언급한 규칙을 생각해보자. 이 상황에서, 함수 longest(&s1, &s2); 의 실행 결과로 &s1 이 리턴된다면 어떨까? 리턴값(레퍼런스)은 result 가 받는데, result 와 s1 의 라이프타임이 동일하기 때문에 문제가 없다. 그럼 &s2 이 리턴된다면 어떨까? 이걸 result 가 받는다면? 규칙에서 대여되는 쪽이 대여해가는 쪽보다 오래(or 동일하게) 살아야 한다고 했는데, 대여되는 쪽인 &s2 의 라이프타임이 대여해가는 쪽인 result 보다 짧게 된다. 이 코드를 컴파일 허용한다면, 댕글링 레퍼런스가 생길 여지를 허용하는 셈이 되는 것이다.


러스트는 이렇게 댕글링 레퍼런스를 야기할 여지가 있는 코드를 컴파일에서 걸러낸다. 그리고 하나의 함수는 런타임에 여러 랜덤한 값을 파라미터로 받게 될 수 있고, 특히 다양한 스코프 구조에서 실행될 수 있다. 때문에 댕글링 레퍼런스가 생길 수 있는 코드인지 체크하는 작업은 하나의 독립된 함수 단위에서부터 이루어진다. 간단히 말해, 정작 당장 함수를 호출하는 코드가 전혀 없더라도, 그 함수의 생김새 만으로도 이 함수가 댕글링 레퍼런스를 야기할 수 있다고 판단하면 러스트는 컴파일 에러를 던진다.


그럼 함수 longest 는 어떻게 해야 컴파일 할 수 있나?


아래와 같이 라이프타임 파라미터를 정의하면 된다. (처음에 언급했듯, 라이프타임 파라미터의 문법에 대해서는 다루지 않는다.)


fn longest<'a>(s1: &'a str, s2: &'a str) -> &'a str {
if s1.len() > s2.len() {
s1
} else {
s2
}
}


위 라이프타임 파라미터의 의미는, "함수 longest 에 'a 라는 라이프타임이 있고, 레퍼런스 아규먼트 s1, s2, 그리고 리턴 레퍼런스는 같은 라이프타임을 가진다." 라는 것이다. 설명했듯이 컴파일러는 longest 함수의 리턴값이 s1 이 될지 s2 이 될지 알 수 없으므로, 이에 대한 조언을 해주는 셈이다. 컴파일러는 이를 이해하고 longest 를 컴파일한다. 


여기서 절대 착각해서는 안된다. 라이프타임 파라미터는 절대로 라이프타임을 변경하는 것이 아니다. 파라미터의 라이프타임은 호출 코드의 문맥에서 결정되는 것이지, 함수 안에서 변경 가능한 것이 아니다. 라이프타임 파라미터는 컴파일러에게 컴파일을 위한 힌트를 줄 뿐, 프로그램의 런타임 동작에 어떠한 영향도 주지 않는다.


이렇게 라이프타임 파라미터를 정의해도 아래와 같은 코드는 여전히 컴파일 에러를 일으킨다.


fn main() {

let s1 = "value of s1".to_string();
let result;
{
let s2 = "value of s2".to_string();
result = longest(&s1, &s2);
}

println!("{}", result);
}

fn longest<'a>(s1: &'a str, s2: &'a str) -> &'a str {
if s1.len() > s2.len() {
s1
} else {
s2
}
}


그런데 컴파일 에러 메시지가 아까와 좀 다르다.


error[E0597]: `s2` does not live long enough

  --> src/main.rs:7:32

   |

7  |         result = longest(&s1, &s2);   

   |                                ^^ borrowed value does not live long enough

8  |     }

   |     - `s2` dropped here while still borrowed

...

11 | }

   | - borrowed value needs to live until here


그렇다, 함수 longest 는 컴파일이 된 것이다! 동시에, 라이프타임 파라미터를 정의했어도 s2 의 라이프타임을 지적하는 에러는 발생한다는 점에 주목해야 한다. 다시 한 번 말하건데, 라이프타임 파라미터를 똑같이 정의한다고 해서 레퍼런스의 실제 라이프타임이 절대로 같아지지 않는다.


그렇다면 라이프타임 파라미터가 어떤 역할을 했길래 함수 longest 가 컴파일 됐을까? 라이프타임 생략 규칙




Elision Rules (생략 규칙)



라이프타임 생략 규칙이라는 것이 있다. 라이프타임을 항상 명시적으로 정의하는 일은 번거로운 일이기에, 몇 가지 아주 명백한 상황에서는 라이프타임 파라미터를 명시하지 않아도 러스트가 알아서 라이프타임을 추론한다. 이 '명백한 상황' 을 판단하는 규칙이 elision rules(이하 생략 규칙) 이며, 이 규칙에 해당되지 않으면 라이프타임 파라미터를 명시적으로 정의해주어야 한다. 그렇지 않으면 컴파일 에러가 발생한다.


규칙은 다음과 같다.


1. 레퍼런스 타입의 파라미터는 모두 각자의 독립된 라이프타임을 가진다. 

-> 예를 들어, 함수 fn foo(x: &i32) 는 fn foo<'a>(x: &'a i32) 가 되고, fn foo(x: &i32, y: &i32) 는 fn foo<'a, 'b>(x: &'a i32, y: &'b i32) 가 된다.


2. 파라미터가 단 하나일 경우, 리턴 레퍼런스는 동일한 라이프타임 파라미터를 가진다. 

-> 예를 들어, fn foo(x: &i32) -> &i32 는 fn foo<'a>(x: &'a i32) -> &'a i32 가 된다.


3. 다수의 파라미터가 있는데 그 중 하나가 &self 혹은 &mut self 인 경우(즉 메서드인 경우), self 의 라이프타임은 리턴 레퍼런스의 라이프타임이 된다.


몇 가지 케이스로 규칙을 이해해보자. 


일단 아래 코드가 있다. 컴파일러가 어떤 방식으로 여기에 규칙을 적용하여 라이프타임을 추론할까?


fn first_word(s: &str) -> &str {


첫 번째 규칙에 따라, 파라미터 s 는 라이프타임을 가진다.


fn first_word<'a>(s: &'a str) -> &str {


그리고 이 코드는 두 번째 규칙도 적용된다. 컴파일러는 함수 first_word 의 라이프타임을 아래와 같이 추론하여 컴파일한다.


fn first_word<'a>(s: &'a str) -> &'a str {



파라미터가 두 개인 경우는 어떨까?


fn longest(x: &str, y: &str) -> &str {


첫 번째 규칙에 따라, 각 파라미터는 각자의 독립된 라이프타임을 가진다.


fn longest<'a, 'b>(x: &'a str, y: &'b str) -> &str {


파라미터 수가 하나를 초과하기 때문에, 두 번째 규칙은 적용되지 못한다. 그리고 파라미터에 self 가 없기 때문에, 세 번째 규칙도 적용되지 못한다. 규칙을 모두 대입해도 리턴 파라미터의 라이프타임을 알 수 없다. 이제 컴파일 에러가 발생한다. 


알겠는가? 처음에 함수 longest 에 컴파일 에러가 발생한 이유는 이 때문이다. 그리고 후에 명시적 파라미터 정의를 통해서 파라미터의 라이프타임과 리턴값의 라이프타임을 연결해 주었기 때문에 정상적으로 컴파일 될 수 있었다. 함수에서의 라이프타임 파라미터의 역할은, 궁극적으로 다양한 레퍼런스 파라미터와 리턴 레퍼런스의 라이프타임을 연결해 주는 것이다. 


논리적으로, 레퍼런스가 리턴값인 함수에서 레퍼런스 파라미터 말고는 리턴할 수 있는 레퍼런스가 없다. 


fn return_invaild_reference(r1: &i32, r2: &i32) -> &i32 {


위와 같은 함수가 있다고 할 때, 리턴값은 반드시 r1 혹은 r2 가 오게 된다는 말이다. 왜냐하면 둘 말고 다른 레퍼런스, 즉 함수 안에서 생성된 또다른 레퍼런스를 리턴한다는 것은 그 시도 자체만으로도 이미 러스트의 스코프 범위 규칙을 위반하는 일이다. 함수가 종료되면서 그 안에서 생성된 레퍼런스는 모두 해제될태니까. 


때문에 레퍼런스 리턴은 파라미터를 통해 이루어져야 하고, 이를 위해서 파라미터 레퍼런스의 라이프타임과 리턴 레퍼런스의 라이프타임이 동일해야 한다.



구조체에서의 라이프타임


구조체에서의 경우는 더 단순하다. 구조체가 레퍼런스 타입 필드를 가지고 있다면, 무조건 라이프타임 파라미터가 명시되어야 한다.


struct ImportantExcerpt<'a> {
part: &'a str,
}

fn main() {
let novel = String::from("Call me Ishmael. Some years ago...");
let first_sentence = novel.split('.')
.next()
.expect("Could not find a '.'");
let i = ImportantExcerpt { part: first_sentence };
}


구조체 ImportantExcerpt 에 왜 라이프타임이 필요한지는 지금까지 알아본 내용에 비추어 보면 당연한 일이다. 구조체가 자신이 참조하는 메모리보다 오래 살아있으면 안되니까.



메서드 정의에서의 라이프타임


메서드를 정의할 때, 메서드가 속한 구조체의 필드 또는 메서드의 아규먼트 와 리턴값이 라이프타임 파라미터가 필요한 경우라면 impl 에도 라이프타임 파라미터가 필요하다. 실제로 구현되는 메서드가 라이프타임 파라미터를 필요로 하지 않더라고, 그 구조체가 라이프타임 파라미터를 가진다면 impl 에도 라이프타임 파라미터는 필요하다.


impl<'a> ImportantExcerpt<'a> {
fn level(&self) -> i32 {
3
}
}


위 코드의 경우, 메서드 level 은 생략 규칙에 따라 라이프타임 파라미터가 필요하지 않다. 하지만 구조체 ImportantExcerpt 가 라이프타임 파라미터를 필요로 하기에, impl 에도 똑같이 이를 지정해 주어야 한다.


impl<'a> ImportantExcerpt<'a> {
fn announce_and_return_part(&self, announcement: &str) -> &str {
println!("Attention please: {}", announcement);
self.part
}
}


위 코드의 메서드는 파라미터가 둘이다. 첫 번째 생략 규칙에 따라 두 파라미터는 각각 독립된 라이프타임 파라미터를 가진다. 그리고 세 번째 규칙에 따라 &self 파라미터의 라이프타임이 리턴 레퍼런스의 라이프타임이 된다.



스태틱 라이프타임


스태틱 라이프타임은 좀 특별하다. 스태틱 라이프타임은 프로그램 전체에 걸쳐 유효하다. 스태틱 라이프타임이 적용된 대표적인 예는 스트링이다. 모든 스트링 리터럴은 스태틱 라이프타임을 가지고 있다.



제너릭 타입, 트레잇 바운드, 라이프타임을 함께 사용하기


말이 필요없고, 아래 코드처럼 사용 가능하다.


use std::fmt::Display;

fn longest_with_an_announcement<'a, T>(x: &'a str, y: &'a str, ann: T) -> &'a str
where T: Display
{
println!("Announcement! {}", ann);
if x.len() > y.len() {
x
} else {
y
}
}




'Rust' 카테고리의 다른 글

클로저(심화)  (0) 2018.03.07
클로저(심화)  (0) 2018.03.07
트레잇(Traits)  (0) 2018.02.28
제너릭 타입  (0) 2018.02.27
에러 핸들링(Result)  (0) 2018.02.26

+ Recent posts