/ PROGRAMMING, RUST

Rust - Memory Ownership

Rust는 ownership이라는 접근법으로 메모리를 관리합니다.

Rust 맛보기

Ownership Rules

  • R1. Rust에서 각 value는 owner를 갖는다

  • R2. 한번에 하나의 owner만 존재할 수 있다.

  • R3. owner가 scope 밖으로 나가면, value는 drop된다(= value가 점유하던 메모리 반환).

R3 원리

{
let s = String::from("hello"); // s is valid from this point forward
// do stuff with s
} // this scope is now over, and s is no longer valid

variable이 scope 밖으로 나가면, Rust는 drop() 함수를 호출합니다. 즉, Rust 컴파일러는 } 부분에 drop()을 호출하는 코드 라인을 자동으로 삽입합니다. 이런 작업은 컴파일 타임에 이루어지므로, 런타임에 overhead가 발생하지 않습니다. 다시 말해 Zero-cost abstraction이 가능합니다.

이런 방식으로 어떤 item의 lifetime의 끝에 resource를 할당 해제하는 패턴을 Resource Acquisition Is Initialization이라고도 합니다.

Move: Interaction Between Variables and Data

Simple Example

let x = 5;
let y = x;

여기서 두 개의 5 값은 스택에 push됩니다(Deep copy). 스택은 함수가 리턴할 때 비워지고, 컴파일 타임에 크기가 결정되므로 아무 문제가 없습니다(Stack-Only Data: Copy).

5의 Owner가 여러 개가 된다는 것이 R1에 위배되는 것처럼 보이기도 합니다. 하지만 서로 다른 값이 별개의 메모리 공간에 저장되므로, 두 개의 5가 각각의 owner를 가지고 있기 때문에 이는 문제가 되지 않습니다.

이러한 방식이 가능한 타입에 Rust는 Copy trait이라는 이름을 붙입니다.

String 타입의 경우

let s1 = String::from("hello");
let s2 = s1;

String의 포인터가 가리키는 str는 heap에 저장되므로, s1s2는 heap에 저장된 데이터를 가리키는 포인터를 갖습니다.

s2s1을 할당할 때, String 데이터는 그대로 복사된다고 가정해 봅시다(즉, 스택 내 포인터, len, capacity가 복사). 그러나 이 상황은 R2를 위반합니다. 예를 들어, s1s2가 모두 scope 밖으로 나가면, 스택 내 두 변수의 포인터가 같은 메모리(heap 영역)를 가리키게 되어 double free가 발생합니다.

실제로는 s1의 ownership이 s2이동(move)됩니다. Rust는 s1이 더 이상 유효하지 않다고 간주합니다. 이 부분이 단순한 shallow copy와의 차이점이기도 합니다.

이후 s1에 접근을 시도하면, Rust는 컴파일 에러를 발생시킵니다.

.clone() 메소드로 Deep copy 수행

String의 heap 데이터를 복사하려면, .clone() 메소드를 사용합니다.

let s1 = String::from("hello");
let s2 = s1.clone();

println!("s1 = {}, s2 = {}", s1, s2);

단 heap의 데이터 크기가 크다면 runtime performance 관점에서 매우 비싼 operation이 될 수 있으니 주의해야 합니다.

Copy trait

처음 소개된 예시에서처럼, stack-only 데이터는 Copy trait을 가집니다. 즉 .clone() 메소드가 없어도 기본적으로 assign 시 deep copy가 이루어집니다.

heap allocation을 요구하지 않고 스택에 저장되는 데이터 타입은 Copy trait을 가질 수 있습니다. 이는 다음과 같은 타입들을 포함합니다.

  • 모든 integer 타입
  • boolean
  • floating point
  • char
  • tuple 및 array (모든 element가 Copy trait을 가질 때)

Ownership and Functions

함수에 변수를 전달할 때도 ownership의 이동 또는 값의 복사가 발생합니다.

fn main() {
    let s = String::from("hello");
    takes_ownership(s);
    // s is no longer valid
    let x = 5;
    makes_copy(x);
}

fn takes_ownership(some_string: String) {
    println!("{}", some_string);
} // some_string goes out of scope and `drop()` is called

fn makes_copy(some_integer: i32) {
    println!("{}", some_integer);
} // some_integer goes out of scope, nothing special happens

takes_ownership 함수에 s를 전달하면 ownership이 이동하므로 s는 더 이상 유효하지 않습니다. 반면 makes_copy 함수에 x를 전달하면 값이 복사되므로 x는 여전히 유효합니다.

물론, ownership을 다시 되찾고 싶다면, 함수에서 반환값을 사용하면 됩니다.

fn main() {
    let s1 = gives_ownership();
    let s2 = String::from("hello");
    let s3 = takes_and_gives_back(s2);
}

fn gives_ownership() -> String {
    let some_string = String::from("hello");
    some_string
}

fn takes_and_gives_back(a_string: String) -> String {
    a_string
}

gives_ownership() 함수 내에서 생성된 String의 ownership이 main 함수의 s1 에게로 넘어오고, takes_and_gives_back() 함수에서 s2의 ownership이 함수 내부로 갔다가 바로 s3로 넘어갑니다.

Reference and Borrowing

그런데 함수 호출 시마다 ownership을 넘기면 부모 함수에서 ownership을 되찾기 위해 반드시 return value를 사용해야 합니다. 이는 코드의 가독성을 떨어뜨리고, 불필요한 overhead를 발생시킬 수 있습니다.

fn main() {
    let s1 = String::from("hello");
    let (s2, len) = calculate_length(s1);
    println!("The length of '{}' is {}.", s2, len);
}

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

    (s, length)
}

함수가 호출될 때, 반드시 ownership을 넘기지 않고도 값을 참조할 수 있습니다. 이를 borrowing이라고 합니다.

fn main() {
    let s1 = String::from("hello");
    let len = calculate_length(&s1); // s1의 ownership이 넘어가지 않음 (전달되는 것은 reference임)
    println!("The length of '{}' is {}.", s1, len);
}

fn calculate_length(s: &String) -> usize {
    s.len() // 사실 (*s).len()과 동일
    
    // (*s)의 ownership을 갖고 있지 않으므로, s가 scope 밖으로 나가도 (*s)는 drop되지 않음 (당연히)
}

&를 사용하여 reference를 생성하고, &String 타입을 사용하여 String의 reference를 전달합니다.

&를 사용하여 reference를 생성하면, ownership을 넘기지 않고도 값을 참조할 수 있습니다. 이때, reference를 사용하는 함수는 reference된 변수의 값을 변경할 수 없습니다. 이를 immutable reference라고 합니다.

reference는 포인터와 비슷하지만, 포인터와 달리 reference는 항상 유효한 값을 가리키고 있어야 합니다. 이를 dangling pointer를 방지하기 위한 Rust의 안전성 체크라고 할 수 있습니다(memory-safer).

Mutable Reference

fn main() {
    let mut s = String::from("hello");
    change(&mut s);
}

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

&mut을 사용하여 mutable reference를 생성하면, reference된 변수의 값을 변경할 수 있습니다. 이때, target variable도 mutable variable 이어야 합니다. 또, mutable reference는 오직 하나만 존재해야 합니다. 이는 R2를 준수하여 data races를 방지하기 위함입니다.

Rust는 근본적으로 Single-Writer Multiple-Reader (SWMR) 모델을 따릅니다. 이는 한 번에 최대 하나의 writer가 존재해야 할 뿐만 아니라, write operation이 진행 중일 때에는 다른 operation(특히, read operation)이 진행되어서는 안 된다는 것을 의미합니다. Rust는 여기서 더 강한 제약을 두어, immutable reference가 존재할 때에는 mutable reference가 존재하지 않도록 합니다.

단 reference의 scope는 그것이 선언되는 지점부터 마지막으로 그 reference가 사용되는 지점까지이므로, scope를 벗어나면 새 mutable reference를 생성할 수 있습니다.

let mut s = String::from("hello");

let r1 = &s;
let r2 = &s;
println!("{} and {}", r1, r2);
// 컴파일러가 이 이후 r1, r2가 사용되지 않는다는 것을 인식 가능 


let r3 = &mut s;
println!("{}", r3);

Slice Type

연속적인 데이터의 일부분을 참조하는 reference를 나타내는 slice 타입이 있습니다.

예를 들어 &str 타입은 string slice를 나타냅니다. String Slice는 string의 일부분을 참조하는 reference의 일종 입니다.

fn first_word(s: &String) -> usize {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
    // &item: pattern matching을 통해 byte를 참조하는 reference를 풀어줌(즉, item은 reference가 아닌 value가 됨)
    // enumerate() 메소드는 iterator를 받아 (index, value) 튜플을 반환
        if item == b' ' { // &item이 reference 이므로 item은 value임
            return i;
        }
    }

    s.len()
}

위와 같은 함수는 String의 reference를 받아, 첫 번째 공백 문자의 index를 반환합니다. 이때, &item은 byte를 참조하는 reference입니다. iter()로 얻어지는 iterator는 &u8 타입을 반환하므로, 패턴 매칭 &item을 통해 byte를 참조하는 reference를 풀어줍니다. 참고로 패턴 매칭은 함수의 인자 등에서는 사용 불가능하며, let, match, for statement 등에서만 사용 가능합니다.

slice를 활용하면 훨씬 간단하게 문자열의 일부를 참조할 수 있습니다.

let s= String::from("hello world");
let hello = &s[0..5]; // &s[..5]와 동일
let world = &s[6..11]; // &s[6..]와 동일

형태만 보면 잘 와닿지 않을 수도 있는데, string slice의 타입이 &str라는 것은 기억해 둡시다. s[0..5]의 타입이 str 이고 이것에 &을 붙여 &str이 됐다고 이해하면 편할 것 같습니다. string slice의 타입 -> &str 이라는 명제는 역도 성립합니다. 즉, &str 타입은 모두 string slice를 나타냅니다.

&str 타입은 String과 다르게 capacity를 가지지 않고 ptr, len attribute만 가지고 있습니다.

slice를 활용하여 first_word 함수를 수정하면:

fn first_word(s: &String) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[..i];
        }
    }

    &s[..]
}

위 함수에서는 s&String 이지만 String을 다룰 때와 마찬가지로 슬라이싱이 가능한 것을 확인할 수 있습니다. 이는 StringDeref 트레이트를 구현하기 때문에 가능하다고 하는데(출처: ChatGPT), 자세한 건 다음에 다뤄봐야 겠습니다.

String Slice를 parameter로 하는 함수를 작성하면 더 일반적이고 유용하게 사용할 수 있습니다.

fn first_word(s: &str) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[..i];
        }
    }

    &s[..]
}

Other Slices

  • &[i32]

String뿐만 아니라, 다른 타입의 slice도 사용할 수 있습니다. 예를 들어, &[i32] 타입은 i32 타입 배열의 slice를 나타냅니다.

let a = [1, 2, 3, 4, 5]; // 타입: [i32; 5]
let slice = &a[1..3]; // [2, 3] // 타입: &[i32]
assert_eq!(slice, &[2, 3]);

참고 문헌

Rust 맛보기