본문 바로가기

Language/Rust

Rust 소유권과 Lifetime

러스트의 소유권을 알기 전에 러스트의 메모리 관리에 대해 알아보자.

Rust의 메모리 관리

Java, Go, Python, Javascript와 같은 언어들은 GC를 사용하고 C/C++과 같은 경우에는 수동으로 프로그래머가 메모리를 관리한다.
이와는 다르게 Rust는 컴파일 시점메모리 관리 규칙을 검사한다.

 

컴파일 시점에 메모리 관리를 확인 하면서 생기는 여러가지 특징이 있다.

  • 소유권 규칙에 따라 컴파일 시점에 메모리 할당/해제를 관리한다.
  • 규칙에 어긋나면 컴파일되지 않는다.
  • 규칙을 잘 지키면 컴파일러가 알아서 잘 처리해준다.
  • 실행시간 손해가 없다.
  • 개발자가 실수할 여지가 없다.

Rust의 메모리는 블록(scope)이 끝날 때 해제가 된다. 예를 들어

fn main() {
    {
        let s = "hello";
        println!("s = {}", s);
    }
    println!("s = {}", s); // 메모리가 해제되어 불가능
}

위와 같이 main의 {}와 내부의 {}는 다른 블록으로 처리가 되어 내부의 {} 밖에서는 안에서 선언된 변수를 사용할 수 없다.

 

러스트도 다른 언어들과 마찬가지로 Stack과 Heap메모리가 존재한다. 

Stack

  • 크기가 고정된 데이터
  • 기본 데이터 타입
  • 문자열 리터럴

Heap

  • 크기가 가변적인 데이터
  • 컴파일 시점에 알 수 없는 데이터

이제 러스트의 핵심인 소유권에 대해 알아보자.

소유권

각 데이터는 하나의 소유자(owner)만 가질 수 있고, 해당 소유자가 스코프를 벗어나면 메모리가 자동으로 해제된다. 이는 Rust에서 가비지 컬렉터(Garbage Collector) 없이도 메모리 관리를 안전하게 할 수 있는 방법이다.

 

소유권은 3가지 규칙이 존재 한다.

  • 한 시점에 딱 하나의 소유자만 있을 수 있다.
  • 소유자의 범위가 끝나면, 값도 제거된다.
  • 데이터를 참조할 때는 불변 참조(&)와 가변 참조(&mut) 중 하나만 사용할 수 있으며, 동시에 여러 가변 참조는 불가능하다.

소유권은 하나의 값에는 하나의 소유자만 존재할 수 있다는 뜻이다. 이를 코드로 설명하자면

fn main() {
    {
        let s1 = String::from("hello"); // Heap
        let s2 = s1;
        println!("s2 = {}", s2);
        println!("s1 = {}", s1); // Error!!
    }
}

1. 처음에 s1에 값을 할당할 때 Heap 메모리에 올라가게 된다.

2. 그런 다음에 s2에 s1을 할당하므로써 소유권을 넘기게 되었다.

3. 따라서 더 이상 s1에 접근 할 수 없게 된다. 


여기서 러스트만의 놀라운 점은 위의 scope가 끝나고 s2의 데이터는 회수하지만

s1은 더 이상 소유권을 가지고 있지 않아 s1에 대해서는 특별한 작업을 하지 않는다. 

메모리 해제 작업은 1번만 일어나게 된다.
※ Rust는 자체적으로 Heap 데이터를 복사하는 경우는 없다.

 

위의 상황에서 s1, s2모두 같은 값을 가지게 하고 싶다면 clone이라는 함수를 사용해서 메모리를 복사해주어야 한다.

fn main() {
    {
        let s1 = String::from("hello"); // Heap
        let s2 = s1.clone(); // Heap 메모리 복사
        println!("s2 = {}", s2);
        println!("s1 = {}", s1); // Success!!
    }
}

 

위와는 다르게 Stack에서 사용하는 기본 데잍 타입은 러스트가 자동으로 복사를 해주어 따로 소유권 개념이 없다고 보아도 무방하다. 러스트가 가지는 기본 데이터 타입은 아래와 같다.

  타입
모든 정수 타입 i8, i16, i32, i64, i128, u8, u17, u32, u64, f32, ...
부울 타입 bool(true, false)
문자 타입 char
튜플 (가능) (i32, bool)
튜플 (불가능) (i32, String)
힙에서 관리하는 String이 껴있어 복사가 불가능하다.

 

소유권의 이동

함수 인자로 넘겨주게 되면 소유권도 넘어가 넘겨준 곳에서는 더 이상 사용이 불가능하다.
fn main() {
    let s = String::from("hello"); // Heap
    string_length(s); // 소유권 전달.
    println!("s = {}", s); // Error!!
}

fn string_length(s:String) {
    println!("문자열의 길이: {}", s); 
}

위의 코드에서 s는 함수로 전달하는 과정에서 소유권도 같이 전달해 더 이상 main함수에서는 s를 사용할 수가 없다.

소유권 임대

Rust에서는 데이터를 복사하지 않고 다른 함수나 코드에서 사용할 수 있도록 빌림(Borrowing)을 사용한다.
이는 참조를 통해 이루어진다.

 

만약 함수에 소유권을 줬다가 다시 받아야할 때

fn main() {
    let s = String::from("hello");
    
    let (s, len) = calc_length(s);
    
    println!("'{}의 길이는  {}입니다.", s, len);
}

fn calc_length(s:String) -> (String, usize){
    let length = s.len();
    (s, length)
}

위와 같이 소유권을 함수로 넘겨주고 소유권을 다시 돌려준다면 함수의 기대 값으로 예상하지 않았던 s를 넘겨주어야 해서 비용이 증가하게 된다. 이를 해결하기 위해 소유권 임대라는 것을 사용한다.

 

- 소유권 임대 추가 -

fn main() {
    let s = String::from("hello");
    
    let len = calc_length(&s); // 소유권 임대
    
    println!("'{}의 길이는  {}입니다.", s, len);
}

fn calc_length(s: &String) -> usize {
    let length = s.len();
    length // 더 이상 소유권을 돌려줄 필요가 없음
}

기존과 다른게 함수 이자 부분에 &를 붙여 줌으로써 소유권 임대를 명시해준다.

이 전과는 다르게 소유권을 빌려 주었기 때문에 다시 돌려 받을 필요가 없어 반환 부분에 소유권이 빠지게 된다.

 

기존의 방식으로 값을 할당하게 되면 포인터가 Heap메모리의 같은 주소를 가리켜 소유권을 양도하게 되지만

위처럼 &를 붙이면 &s가 s를 가리켜 참조하여 접근하게 된다. 

참조(Reference)

참조는 다른 누군가가 소유한 데이터에 접근 하는 것을 말한다. 러스트에서 참조는 기본적으로 불변 하다는 특성을 가지고 있다. 하지만 mutable하다고 명시해준다면, mutable하게 사용할 수 있다. 

fn main() {
    let mut s = String::new("hello");

    append_word_immut(&s); // immutable
    
    append_word_mut(&mut s); // mutable
    println!("s = {}", s);
}

fn append_word_immut(s:&String) {
    // 참조 변수는  immutable 하기 때문에 변경할 수 없다
    s.push_str(", world"); // Error
}

fn append_word_mut(s:&mut String) {
    s.push_str(", world"); // Success
}

위에 처럼 &mut이라는 키워드를 명시해줌으로써 mutable하게 사용할 수 있게된다.

 

하지만 주의해야할 점이 있다. mutable 참조는 한 번에 하나의 가변 참조만 허용된다는 것이다.

fn main() {
    let mut s = String::new("hello");

    let r1 = &mut s; // mut으로 참조하지 않았다면 문제 없음
    let r2 = &mut s; // ""
    
    // mut으로 참조된걸 사용하면 이후의 해당 스코프에서 더 이상 참조에 대한 접근이 불가해진다.
    println!("r1 = {}, r2 = {}", &r1, &r2); // Error
}

위 예시는 처음 변수를 선언했을 때는 문제가 생기지 않지만 실제 사용을 하게 되면 문제가 발생하게 된다.

이렇게 여러번 사용을 제안하게 되는 것을 데이터 경쟁 조건(data trace) 라고 한다.

 

데이터 경쟁 조건(data trace)

둘 이상의 포인터가 같은 데이터를 참조하여 데이터를 쓰려고 접근 할 때, 해당 데이터 접근을 동기화 할 방법이 존재하지 않는다. 이에 Rust는 아예 컴파일 타임에 데이터 경쟁 조건을 방지한다.

이러한 빌림 규칙을 통해 Rust는 데이터 경쟁을 방지하고 안전한 동시성을 보장한다.

 

Rust의 Lifetime

  • 수명 - 참조 값이 필요한 만큼 유효하게 선언
  • 모든 참조값에는 유효 수명 존재
  • 타입 추론으로 타입 선언하듯 대부분 생략 가능

임대 검사

타입이 잘 맞춰졌는지 검사하는 타입 거사 처럼 임대한 참조의 수명이 유효한지 검사하는 임대 검사

참조의 수명보다, 원래 값의 수명이 같거나 길어야 함

 

참조 값의 수명이 더 짧은 경우

// x가 y보다 수명이 길어서 값을 넣을 수가 없다.
fn short_lifetime() {
    let x;
    {
        let y = 5;
        x = &y;
    }
    println!("x = {}", x);
}

borrowed value does not live long enough 에러가 발생한다.

참조 리턴 값의 수명 명시

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

컴파일러 입장에서는 둘 중 lifetime이 뭐가 길지 알 수 없고 runtime에서만 알 수 있다. 또한 반환하는 참조자가 항상 유효한지도 알 수 없다. 따라서 수명을 명시 해주어야만 한다.

lifetime 명시는 참조자가 얼마나 사는지를 결정 짓는 것이 아니라 여러개의 참조자에 대한 lifetime을 연관 짓도록 하는 것이다.

 

러스트의 lifetime 명시는 살짝 독특한 문법을가지고 있다. '로 시작해야하고 보통 소문자를 사용한다.

관례적으로 'a를 많이 사용하고 &뒤에 온다.

 

함수의 파라미터에 lifetime을 특정해야 한다면 Generic과 마찬가지로 <> 안에 정의 되어있어야 한다.

 

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

 

static 수명

'static 은 프로그램 실행 내내 유효한 수명을 뜻한다.

fn static_lifetime() {
    let s: &'static str = "프로그램 실행 내내 유효한 수명";
}

 

수명 표기 생략 규칙

1. 각가의 파라미터에 차례로 수명 표기(원칙)
2. 파라미터가 딱 하나라면, 모든 반환 값에 해당 수명 부여
3. 메서드이고, 여러 파라미터 중 하나가 &(mut)self 라면, 모든 반환 값 수명에 self 수명 반영

 

파라미터가 하나라면, 모든 값에 해당 수명을 부여한다.

// 수명을 표기해주지 않아도 된다
fn rule(s: &str) -> &str {
    // ...
}

 

메서드이고, 여러 파라미터 중 하나가 &(mut)self 라면, 모든 반환 값 수명에 self 수명을 반영 

impl ImportantPart {
    fn notice(&self, text:&str) -> &str {
        // ...
    }
}

 

LifeTime 요약

1. 참조의 유효 수명을 표기 (수명 자체를 변경하지는 못함)
2. 함수의 입력 파라미터에 있는 참조값과 반환 참조 값의 수명관계 표현
3. 유효 수명이 확보되는지 "임대 검사" 진행
4. 수명 표기 생략 규칙에 따라 생략 가능

 

'Language > Rust' 카테고리의 다른 글

왜 Rust일까  (1) 2024.08.26