주니어 JS 개발자가 배우는 Rust - (4)

이동창·2022년 4월 29일
1

Rust Study

목록 보기
4/8

소유권

자바스크립트 같은 경우는 가비지 콜렉터를 통해 메모리 관리를 하는데
러스트는 소유권이라는 개념으로 메모리를 관리한다.

들어가기 전에...

일단 러스트 변수들은 블록 스코프를 따른다

{
	let s = "Hello";
    println!("{}", s); // Hello
} // s 소멸
println!("{}", s); // ERROR

기본적으로 크기가 정해져 있지 않은 배열과 같은 데이터 타입들은
스택에 저장될 수 없기 때문에 힙 메모리에 저장되게 된다.

또한, 이들을 함수의 인자로 전달하면 그 함수의 스택에 이들이 담기게 된다.
만약 함수 인자들이 힙 메모리에 머물러 있다면, 힙 데이터 구조 상 성능이 하락할 수 밖에 없다.
따라서 함수만의 스택을 만들어 속도를 높이고,
함수가 끝나면 이 스택을 비우는 과정을 통해 메모리 관리를 하게 된다.

하지만 함수의 인자로 넘겨진 변수가, 함수가 끝난 후 더 이상 힙 데이터에 남아있을 필요가 없다면?
이런 경우에는 스택을 비우더라도 힙 데이터 구조에는 변수가 남아 있게 된다.

소유권 규칙

  • 러스트가 다루는 각각의 값은 소유자라고 부르는 변수를 갖고 있다.
  • 특정 시점에 값의 소유자는 단 하나뿐이다.
  • 소유자가 범위를 벗어나면 그 값은 제거된다.

문자열 리터럴 vs String 타입

우린 이때까지 선언과 함께 크기가 정해지는 원시 타입들만 배웠기 때문에
동적으로 크기가 변할 수 있는 조금 더 복잡한 데이터 타입에 대해 배워보자

let s = "hello"; // 문자열 리터럴
let t = String::from("hello"); // String 타입

문자열 리터럴은 변경이 불가하지만, String 타입은 내용을 바꿀 수 있다.
그 이유는 위에서도 언급했듯이 String 타입은 동적 크기를 갖고, 힙 메모리를 이용하기 때문이다.

하지만 가비지 컬렉터가 없다면, 사용하지 않는 메모리를 개발자가 직접 처리해야하는데,
이를 제대로 수행하는 것이 예전부터 쉽지 않았다. (너무 빨리 제거, 혹은 중복 해제 등등)

이를 위해 단 한번씩의 할당과 해제로 메모리를 관리하는 것이 바람직하다.
러스트는 이를 어떻게 해결했을까?

drop

바로 변수가 스코프를 벗어나는 순간, 할당된 메모리는 자동으로 해제하는 방식을 사용한다.
이는 러스트의 drop이라는 특별한 함수가 자동으로 처리해주고 있다.

{
    let s = String::from("hello") // s는 지금부터 유효
} // 여기서 범위를 벗어나게 되는 순간, s는 이제 유효하지 않다.

Move, Clone, Copy

Copy
let x = 5;
let y = x;

정수와 같은 고정 크기의 단순한 값은 복사하더라도, 동일한 값 2개가 스택에 저장된다.
당연히 x와 y 둘다 접근이 가능하고, 값도 동일하게 복사된다.

이렇게 작동되는 이유는 Copy 트레이트가 적용되어 있어서인데, 이는 나중에 더 알아보자.

Move

반대로 String 타입과 같은 가변 메모리는 값을 가리키는 포인터 값이 복사되어 스택에 저장된다.
즉, 데이터 값은 동일한데, 이 데이터를 가리키는 포인터가 2개 생기는 것이다.

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

단순하게 코드만 봤을 땐, 그냥 자바스크립트 객체 복사와 비슷한 것처럼 보인다.
하지만 러스트는 위와 같이 작성하면 복사하지 않고, s1과 같은 첫 번째 변수를 무효화시킨다.
따라서 s1에 접근할 수 없고, s1에서 s2move했다고 표현한다.

Clone

만약 스택 데이터가 아닌 힙 메모리에 저장된 데이터가 복사되길 원한다면
clone이라는 공통 메서드를 사용하면 된다.
이러면 동일한 데이터가 힙메모리에 2개 생기고, 각각의 데이터를 가리키는 포인터가 복사되어 스택에 쌓인다.

Q : 그럼 특정 타입이 Copy인지 Move인지 어떻게 확인할 수 있는거지?
A : 그 타입의 Document를 확인해서 Copy trait이 있는지 확인해보면 된다.
허나 보통은 선언 당시의 크기가 변하지 않는 타입들은 Copy 트레이트가 있다고 보면 된다.

Reference

Borrowing

Rust에서 함수 인자로 변수를 전달하면, 변수에 할당된 값을 Copy하거나 Move하게 된다.
만약 Move 하는 경우라면, 변수의 소유권은 함수인 상태가 되고
만약에 그 함수가 다시 변수를 return하지 않고 끝내면, 그 변수는 drop되게 되어 할당이 해제된다.

fn main() {
    let s = String::from("hello");
    takes_ownership(s); // Move
    // 더 이상 s에 접근할 수 없음

    let x = 5;
    makes_copy(x); // Copy
    // x에 계속 접근 가능
    
    let s2 = String::from("hi");
    let s3 = take_and_give_back(s2);
    
  	// println!("{}", s); // Error
    println!("{}", s3); // hi
}

fn takes_ownership(some_string: String) {
    println!("{}", some_string);
}

fn take_and_give_back(some_string: String) -> String {
    println!("{}", some_string);
	some_string
}

fn makes_copy(some_integer: i32) {
    println!("{}", some_integer);
}

매번 소유권을 받았다 갖다줬다 하는게 쉽지 않기에, Reference(참조)Borrowing이라는 개념이 존재한다.
Borrowing으로 어떤 변수의 Reference를 받아오면, 잠깐 빌렸다는 의미이기에
소유권을 다시 주지 않아도 원래의 스코프에서 변수의 값을 참조할 수 있다.

변수의 참조를 함수에 넘겨주면 소유권이 변경되지 않기 때문에,
함수가 끝나고 변수를 다시 넘겨주지 않더라도, 기존의 선언된 변수를 그대로 사용할 수 있음

fn main() {
    let s1 = String::from("hello");

    let len = calculate_length(&s1);

    println!("The length of '{}' is {}.", s1, len);
}

fn calculate_length(s: &String) -> usize {
    s.len()
}

Mutable Reference

Reference는 아쉽게도 값을 변경하지는 못한다.
만약 참조를 통해 변수의 값 변경하고 싶다면 Mutable Reference(가변참조)를 이용해야 한다.
&mut a와 같이 mut 키워드만 붙여주면 되는데, 한 가지 제한 사항이 따라 붙는다.

바로 한 시점에서 하나의 변수에, 단 하나의 참조만 허락하는 것인데

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

let r1 = &mut s;
let r2 = &mut s;

println!("{}, {}", r1, r2);

다음과 같이 r1s를 가변 참조하고 있는 시점에서, r2s의 가변 참조를 시도하면
컴파일 단계에서 에러를 리턴하게 된다.

이는 다른 언어에 비하면 꽤나 까다로운 제한 사항인데, 러스트가 이런 제한 사항을 두는 이유는
애초에 컴파일 단계에서 data race를 방지하기 위해서이다.

data race 란?
다음과 같은 조건에서 발생하는 race condition이다.

  • 한 시점에서 같은 데이터에 두개 이상의 포인터가 접근하고 있으며
  • 적어도 하나의 포인터가 데이터를 변경하려고 하며
  • 데이터의 싱크를 맞추는 메커니즘이 없을 때

이런 이슈가 발생했을 때 버그를 잡는게 굉장히 어렵기에,
러스트는 이를 컴파일 단계에서 걸러주는 작업을 하고 있는 것이다.

따라서 참조를 사용할 땐 다음을 기억하자!

  • 불변 참조는 몇 번을 참조하든 상관없다.
  • 단, 가변 참조는 범위 안에서 한번만 사용할 수 있다.
  • 또한 불변 참조의 사용이 끝나지 않은 상태로 가변 참조를 사용할 수 없다.
  • 참조의 대상은 항상 유효해야 한다.

마지막 주의 사항을 조금 더 살펴보자면

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

    let r1 = &s; // (1)
    let r2 = &s; // (2)
    let r3 = &mut s; // (3)
    println!("{} and {}", r1, r2);

    let r4 = &mut s; // (4)
    println!("{}", r4);
}

fn calculate_length(s: &String) -> usize {
    s.len()
}

다음과 같은 코드에서, r1과 r2가 print 매크로에서 사용됨에도 불구하고,
그전에 r3로 가변 참조를 시도하기에 에러가 나게 된다.

만약 (4)의 상황처럼, 더 이상 선언된 불변 참조가 사용되지 않는다면
가변 참조를 사용해도 상관없다.

Slice

String의 참조를 이용해서 새로운 변수를 만들어낼 때.
만들어진 변수는 기존의 변수에 싱크되지 않는 문제가 발생할 수 있다.

예를 들어

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

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

    s.len()
}

fn main() {
    let mut s = String::from("hello world");
    let word = first_word(&s); // word: 5
    s.clear(); // s = ""
}

위의 코드에서, s.clear()로 s가 빈 문자열이 됐음에도 불구하고,word는 5로 남아 있는다
그 이유는 word가 s의 context와 전혀 상관없는 변수이기 때문이다.

만약 word라는 변수가 사용되기 이전에, 개발자가 자기도 모르게 s를 변경 시켜 버린다면
예상치 못한 런타임 에러가 나타날 수 있다.

그래서 러스트는 이를 컴파일 단계에서 잡기 위해 Slice 개념을 이용한다.

즉, 참조한 값으로 새로운 변수를 만들어 낼 때,
새로 만든 변수가 이전 변수와 싱크가 이루어져야 할 때,
다시 말해 새 변수가 이전 변수의 변경에 민감하게 반응해야할 때
그럴 때 slice를 쓰면 된다.

0개의 댓글