메뉴

Rust - ownership

2022-01-03 15:22:25

목차

변수와 mutability문서를 먼저 읽어 주세요.

Rust 소유권(ownership)

Rust는 Stack overflow에서 실시한 개발자 설문조사에서 5년 연속 가장 사랑받는 프로그래밍 언어로 떠올랐다. 개발자들이 Rust를 좋아하는 여러가지 이유들이 있겠는데, 그 중하나가 메모리 안전 보장이다.

Rust는 소유권(ownership)이라는 기능으로 메모리의 안전성을 보장한다. 소유권은 런타임에 작동하는 GC와는 다르게 컴파일 시간에 확인해야 하는 규칙의 집합으로 구성이되어 있다. 소유권을 따르지 않으면 아예 컴파일이 되지 않는다. 이런식으로 GC 없이, 메모리 안전성을 확보한다. GC가 없으므로 Stop the world가 발생하지 않는다.

GC를 제공하지 않는 (C/C++ 같은)언어는 개발자가 명시적으로 메모리의 할당과 해제를 관리해야 한다. 이 작업은 대규모 코드에서 지루하며 관리하기 어려운 작업이다.

Rust는 소유권을 이용해서 컴파일 시간에 "개발자를 대신해서" 처리해 준다. 소유권 모델을 사용하여 메모리를 해제할 위치를 결정한다. 만약 소유자가 메모리의 사용 범위를 벗어나면 메모리가 해제된다. 아래코드를 보자.

fn main() {
    let x = 47;
    println!("{}", x)
}
변수 x에 47을 할당하는 것은 특별할게 없어보인다. 다른 모든 언어들이 이렇게 한다. 하지만 Rust는 여기서 한 단계를 더 진행한다. x가 값 47의 단독 소유자가 되는 것이다. 예외는 없다. 할당과 소유자, 소유권과의 결합을 통해서 컴파일 시간에 메모리를 관리할 수 있게 한다.

소유권의 범위

소유자는 변수에 대한 소유권을 가진다. 이 소유권은 변수가 scope를 벗어나는 순간 삭제된다. 소유권이 삭제되면 리소스는 즉시 해제되며 사용할 수 없게 된다.

이 명확한 규칙을 사용하면 값의 생명력을 쉽게 추론 할 수 있다.

  1. 변수가 범위(scope)내에 있는 한, 변수의 소유자가 존재하며 소유자가 존재하는 한 값은 절대 삭제되지 않는다.
  2. 컴파일러는 소유자거 범위내에서 더 이상 값을 사용하지 않는다고 판단하면, 그 전에 값을 삭제 할 수 있다.
Global Scope를 가지는 상수를 제외한 모든 변수는 Scope를 가진다는 걸 알고 있을 것이다. 이 Scope는 중괄호나 괄호를 기준으로 범위가 설정된다.
fn main() {
    {
        let x = 47;
        println!("x: {}", x)
    }

    println!("{}", x)
}

코드를 컴파일해보자.
   Compiling hello_world v0.1.0 (/home/yundream/workspace/rust/hello_world)
error[E0425]: cannot find value `x` in this scope
 --> src/main.rs:7:20
  |
7 |     println!("{}", x)
  |                    ^ not found in this scope

이런 Scope 규칙은 다른 대부분의 언어에서도 그대로 사용하기 때문에 별로 특별할게 없어 보인다. 그러나 Rust에서는 제약이 추가된다. Scope 범위가 끝나면 x가 소유한 값이 삭제된다.

소유권의 이동(move)

할당은 소유권의 관계를 생성한다는 것을 이해했을 것이다. 그렇다면 재할당은 어떻게될까 ? a에 할당한 값을 b에 재할당 하는 경우를 생각해보자.

fn main() {
    let a = vec![1,2,3];
    let b = a;   // 더 이상 'a'를 사용 할 수 없다.

    println!("{:?}", b);
    println!("{:?}", a);
}

코드를 실행해보자.
  |
2 |     let a = vec![1,2,3];
  |         - move occurs because `a` has type `std::vec::Vec<i32>`, which does not implement the `Copy` trait
3 |     let b = a;
  |             - value moved here
...
6 |     println!("{:?}", a);
  |                      ^ value borrowed here after move
위 코드에서 vec![1,2,3]의 첫번째 소유자는 변수 a 다. vec! 데이터 타입은 Copy trait를 구현하지 않았기 때문에 소유권은 변수 b로 넘어간다. a는 소유자로써 권한을 잃었기 때문에, 변수 a를 사용 하려하면 컴파일 시간에 에러가 발생한다.

 소유권의 이전

아래 코드를 실행해보자.
fn main() {
    let x = 10;
    let y = x;

    println!("{}", x);
    println!("{}", y);
}
Copy 타입(예: Integer, Float등)에서는 move 대신 복사되기 때문이다.

Rust에서의 move는 다른 언어에서의 얕은복사(shallow copy)와 매우 비슷하다는 것을 알 수 있다.

Copy

소유권을 이전하는 대신 복사해야 하는 경우 deriver를 이용하거나 Copy Trait를 구현하면 된다.

아래는 테스트에 사용할 코드다.
#[derive(Debug)]
struct MyStruct{
    x:i32,
    y:i32,
}

fn main(){
    let x = MyStruct{x:1,y:2};
    let y = x;

    println!("{:?}",y);
    println!("{:?}",x); // error here!
}

Copy deriver를 이용해서 문제를 해결해보자. Copy는 반드시 Clone과 함께 사용해야 한다.
#[derive(Copy,Clone)]
struct MyStruct{
    x:i32,
    y:i32,
}

fn main(){
    let d1 = MyStruct{x:1,y:2};
    let d2 = d1;

    println!("{}{}",d1.x, d1.y);
    println!("{}{}",d2.x, d2.y);
}

Copy를 직접 구현(impl)하는 방법도 있다.
struct MyStruct{
    x:i32,
    y:i32,
}

impl Copy for MyStruct{}

impl Clone for MyStruct {
    fn clone(&self) -> MyStruct {
        *self
    }
}

fn main(){
    let d1 = MyStruct{x:1,y:2};
    let d2 = d1;

    println!("{}{}",d1.x, d1.y);
    println!("{}{}",d2.x, d2.y);
}
Copy trait은 메모리를 복사하는 작업을 수행하는데 오버로딩을 할 수 없다.

Clone trait는 Copy trait의 superstrait이다.

Borrow

Copy는 메모리를 복사하기 때문에 시간과 메모리 측면에서 비싼 연산이다. 이러한 경우에는 Borrow를 사용 할 수 있다. 변수앞에 "&"를 붙이면 된다.
#[derive(Debug)]

struct MyStruct{
    x:i32,
    y:i32,
}

impl MyStruct{
    fn Area(&self) -> i32 {
        self.x * self.y
    }
}

fn main(){
    let my_box = MyStruct{x:3, y:5};
    let your_box = &my_box;

    println!("{:?}{:?}", my_box, your_box);
}
Copy trait를 구현하지 않았지만 성공적으로 컴파일 된다. 참조(Reference)라고 보면 되겠다.
fn main(){
    let my_box = MyStruct{x:3, y:5};
    let your_box = &my_box;

    println!("{:p}\n{:p}", &my_box, your_box);
}
코드를 실행해보면 같은 주소를 바라보고 있는 것을 확인 할 수 있다.

borrowed된 반환 값

때때로 borrow한 값을 반환하는 함수를 만들어야 할 수 있다. 예를 들어, 바이트 길이가 더 긴 문자열을 반환하는 아래와 같은 코드가 있다.
fn longest(x: &str, y: &str) -> &str {
    if x.bytes().len() > y.bytes().len() {
        x
    } else {
        y
    }
}

fn main() {
    let alice = "Alice";
    let bob = "Bob";

    println!("{}", longest(alice, bob));
}

컴파일해보자.
--> src/main.rs:2:33
  |
2 | fn longest(x: &str, y: &str) -> &str {
  |               ----     ----     ^ expected named 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
expected named lifetime parameter이라는 에러를 뱉어낸다. 왜 이런 오류가 발생하는지를 알려면 lifetime을 검토해야 한다.

lifetime

Rust는 변수의 lifetime이 존재한다. 만약 lifetime이 끝난 변수를 반환하려고 하면, 에러가 발생할 것이다. 대부분의 경우 Rust는 lifetime을 잘 판단하기 때문에 명시적으로 lifetime을 설정할 필요가 없다.

borrow reference의 경우 reference한 변수는 현재 함수 scope가 아닌 현재 함수를 호출한 함수(예제의 경우 main 함수)에서 lifetime이 관리되어야 한다. 그런데 이러한 lifetime까지를 Rust가 알아서 관리해주는 것은 아니라서 에러가 발생하는 것이다.

개발자가 개입을 해야 하는데 함수에 lifetime을 관리할 심볼이름을 "<>"에 정의하고 함수의 변수앞에 (')심볼을 설정하면 된다. 이전 코드가 컴파일 되도록 수정해보자.
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.bytes().len() > y.bytes().len() {
        x
    } else {
        y
    }
}

fn main() {
    let alice = "Alice";
    let bob = "Bob";

    println!("{}", longest(alice, bob));
}

lifetime은 구조체에서도 검토가 필요하다. 아래 코드는 컴파일 실패한다.
struct Person {
    name: &str 
    age: i16
}

fn main() {
    let alice = Person { name: "Alice", age: 30 };

    println!("alice: {:?}", alice);
}

Rust 컴파일러 입장에서는 name 변수보다 Person 구조체가 먼저 삭제되는 경우를 배제할 수 없다. 이런 실수를 방지하기 위해서 컴파일 시간에 에러를 리턴한다.

아래와 같이 개발자가 lifetime 매개변수를 추가해서 문제를 해겷해야 한다.
struct Person<'a> {
    name: &'a str,
    age: i16
}

fn main() {
    let alice = Person { name: "Alice",  age: 30};

    println!("{} {}", alice.name, alice.age);
}

가변성(Mutability)

지금까지는 불변성(immutable) 변수만 다루었는데, 가변성 변수를 사용해야 하는 경우도 있다. 실제 코드는 가변성 변수를 포함하는 경우가 더 많기도 하다.

변수앞에 mut키워드를 사용하는 것으로 변수의 동작을 재정의 할 수 있다. 아래코드를 보자.
fn main() {
    let fruits = vec!["banana", "apple", "grape"];
    fruits.push("strawberry");
    println!("{:?}",fruits)
}

컴파일시 에러가 발생한다.
  |
2 |     let fruits = vec!["banana", "apple", "grape"];
  |         ------ help: consider changing this to be mutable: `mut fruits`
3 |     fruits.push("strawberry");
  |     ^^^^^^^^^^^^^^^^^^^^^^^^^ cannot borrow as mutable

정리

소유권은 Rust에서만 찾아볼 수 있는 독특한 기능이다. 다행인 것은 소유권과 관련된 일반적인 상식에서 벗어나지는 않아서, 소유권에 대한 몇 가지 원칙만 알고 있으면 쉽게 사용할 수 있다는 점이다. 게다가 컴파일러가 소유권을 친절하게 검사해주기 때문에 몇 번 실패하면서 내 것으로 만들 수 있다.

  1. 변수에 값을 할당하면, 유일한 소유자를 변수에 바인딩한다.
  2. 값으로 전달하고 반환하는 것은 모두 할당으로 간주한다.
  3. 값은 소유자가 범위를 벗어날 때 삭제된다.
  4. 값을 재할당하면 소유권도 이동한다.
  5. 소유권이 이저되면 이전 소유자는 값을 사용할 수 없다.
  6. & 심볼를 이용해서 소유권을 borrow 할 수 있다.
  7. ' 심볼을 이용해서 lifetime을 관리 할 수 있다.