비욘드 JS: 러스트 - reference & borrow

dante Yoon·2022년 12월 18일
0

beyond js

목록 보기
3/20
post-thumbnail

영상으로 보기

글을 시작하며

안녕하세요, 단테입니다.
오늘은 Reference, Borrow, Slice type에 대해 알아보겠습니다.

Reference

러스트에서 reference는 값(value)를 한 메모리 위치에서 borrow한 후 새로운 이름을 부여하는 것을 말합니다.
reference 스스로가 값을 가지는게 아니라 값을 참조하기 때문에 스코프를 벗어나더라도 메모리에서 해제되지 않습니다.

다음 함수에서 x는 5라는 값을, y는 x를 가르키고 있습니다. x, y를 출력해보면 x,y는 같은 메모리 공간을 참조하기 때문에 동일한 값이 출력됩니다.

fn main() {
    let x = 5;
    let y = &x;
    println!("x = {}", x); // 5
    println!("y = {}", y); // 5
}

pointer랑 뭐가 다른데?

먼저 reference, pointer 모두 값의 주소를 가르킵니다.

reference는 타입 세이프하며 null일 수 없지만 포인터는 세이프 하지 않고 null이거나 dangling pointer일 수도 있으므로 dereferencing 하기 전에 유효한 값을 가지고 있는지 확인해봐야 합니다.

또 다른 차이점으로는 곧 알아볼 borrowing에 대한 부분인데요, 러스트의 reference는 항상 소유권을 가져오는 것이 아닌 값의 주소를 통해 값을 참조할 수 있는 것이라면, 포인터는 소유권또한 양도받을 수 있습니다. reference 사용이 끝나더라도 소유권이 이동되지 않았기 때문에 data race와 같은 문제가 발생하지 않습니다.

또한

fn main() {
    // Using a reference
    let x = 5;
    let y = &x;
    println!("x = {}", x);
    println!("y = {}", y);

    // Using a smart pointer
    let x = Box::new(5);
    let y = &*x;
    println!("x = {}", x);
    println!("y = {}", y);


}

위에서 smart pointer가 보이죠? 러스트에서 포인터는 두 가지로 나뉩니다.

  • Raw pointer (원시 포인터)
  • Smart pointer (스마트 포인터)

원시 포인터는 메모리를 직접 조작하는 데 사용되며 null 또는 매달린 포인터 참조와 같은 메모리 오류로 쉽게 이어질 수 있으므로 사용하기에 안전하지 않습니다. 반면에 스마트 포인터는 더 이상 필요하지 않을 때 메모리를 할당 해제하는 것을 포함하여 가리키는 메모리를 자동으로 관리하기 때문에 사용하기에 더 안전합니다.

위의 예시에서 x는 5라는 값을 가지고 있는 Box 이며 y는 Box가 가지고 있는 값에 대한 reference입니다.
x, y를 출력해보면 둘다 동일한 메모리 공간을 참조하기 때문에 동일한 결과를 출력하는 것을 알 수 있습니다.

let x: Box<i32> = Box::new(5); x는 포인터,
let y: &i32 = &*x; y는 reference 입니다.

Borrowing

도식화해서 알아보자

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()
}

다음의 코드에서 calculate_length에 전달되는 s가 어떤 것을 가르키는지 보세요.

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

 let len = calculate_length(&s1);

s1은 reference가 아니라 value입니다.
&s1은 value s1에 대한 reference를 만듭니다. 하지만 reference &s1은 s1 값을 가지고 있지는 않습니다.
오너쉽이 없다는 것입니다. 오너쉽이 없기 때문에 스코프를 벗어나도 drop되지 않습니다.

fn calculate_length(s: &String) -> usize { // s is a reference to a String
    s.len()
} // Here, s goes out of scope. But because it does not have ownership of what
  // it refers to, it is not dropped.

함수 calculate_length의 s 또한 String의 reference이기 때문에 String의 오너쉽을 가지고 있지 않습니다. 따라서 s.len()을 반환한 이후에 reference인 s에 대해 drop이 발생하지 않습니다.

위와 같이 reference를 생성하는 것을 borrowing이라고 합니다. 특정 값에 대한 오너쉽을 가지고 있지는 않고 그저 해당 값의 참조만 가지는 것입니다. 빌려온다는 것이죠.

러스트에서 borrowing을 사용하게 된다면 일시적으로 값을 빌려와 사용하는 것일 뿐 그 과정에서 오너쉽의 교체는 이뤄지지 않습니다.

친구에게 책을 빌려줄 때 소유권을 양도하는 것은 아닌 것과 비슷한 맥락입니다.

borrowing을 사용해서 원본 값을 변경하고 싶을 때는 어떻게 해야 할까요?

아래 코드는 동작하지 않습니다.

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

    change(&s);
}

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

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0596]: cannot borrow `*some_string` as mutable, as it is behind a `&` reference
 --> src/main.rs:8:5
  |
7 | fn change(some_string: &String) {
  |                        ------- help: consider changing this to be a mutable reference: `&mut String`
8 |     some_string.push_str(", world");
  |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `some_string` is a `&` reference, so the data it refers to cannot be borrowed as mutable

For more information about this error, try `rustc --explain E0596`.
error: could not compile `ownership` due to previous error

variable이 기본적으로 immutable한 것과 동일하게 reference 또한 immutable 합니다. 따라서 다음과 같이 mut 키워드를 사용해야 합니다.

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

    change(&mut s);
}

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

mutable reference에 대해 주의해야 할 점

특정 값에 대한 mutable reference가 존재할 때 주의해야 할 점은 해당 값을 참조하는 다른 reference는 추가적으로 존재할 수 없다는 것입니다.

그 이유는 특정 값에 대한 data race 발생을 막기 위해서입니다.

data race

data race는 두개 이상의 스레드가 공유 데이터에 대해 동시에 접근할 때 발생합니다.
결과 값의 예상치 못한 변경을 일으켜 버그를 양산하고 디버깅을 어렵게 하기 때문에 이를 조심해야 합니다.

use std::sync::Arc;
use std::thread;

fn main() {
    let data = Arc::new(0);
    let data_ref = Arc::clone(&data);

    let t1 = thread::spawn(move || {
        *data_ref += 1;
    });

    let t2 = thread::spawn(move || {
        *data_ref += 1;
    });

    t1.join().unwrap();
    t2.join().unwrap();
}

위에서 두개의 스레드에서 동일 data를 참조하고 있는데요,동시에 data_ref가 참조하는 값을 변경하기 때문에 data race를 유발합니다.

dangling reference

dangling pointer라고 불리기도 합니다. 만약 특정 값을 가르키는 reference가 있는데 해당 값이 더 이상 존재하지 않거나 할당해제가 되어 버린다면 이 reference는 dangling reference가 되어 버립니다.

러스트에서 dangling reference는 borrow checker 컴포넌트에 의해 방지됩니다. 이 borrow checker는 reference가 유효한지 항상 확인하고 reference가 참조하고 있는 값이 drop된 이후에 이 reference가 사용되지 않는지 확인합니다.

fn create_dangling_reference() -> & i32 {
    let x = 10;
    &x
}

fn main() {
    let y = create_dangling_reference();
    println!("{}", y);
}

위에서 값 10을 가지고 있는 변수 x는 create_dangling_reference 함수 스코프가 끝난 이후 스택에서 메모리 해제됩니다.
따라서 let y가 가르키는 x의 reference는 dangling pointer가 되게 됩니다.

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0106]: missing lifetime specifier
 --> src/main.rs:5:16
  |
5 | fn create_dangling_reference() -> & i32 {
  |                ^ expected named lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but there is no value for it to be borrowed from
help: consider using the `'static` lifetime
  |
5 | fn create_dangling_reference() -> &'static i32 {
  |                ~~~~~~~~

For more information about this error, try `rustc --explain E0106`.
error: could not compile `ownership` due to previous error

이를 해결하기 위해서는 다음과 같이 값을 그대로 반환시켜야 합니다.

fn create_dangling_reference() -> i32 {
    let x = 10;
    x
}

fn main() {
    let y = create_dangling_reference();
    println!("{}", y);
}

Slice type

다음 함수는 슬라이스를 사용하지 않습니다.
그런데 String의 일부분을 반환하고 싶습니다. 특별히 문자열 중 가장 마지막 문자를 반환해야 합니다.
그런데 파라메터 s는 reference 이기 때문에 String에 대한 오너쉽이 없습니다.

복습) 아, reference는 오너쉽이 없구나

fn first_word(s: &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()
}

문자열을 바이트의 배열로 바꾸어 줘야 합니다. 그래서 위에서 as_bytes 메소드를 사용했습니다.

let bytes = s.as_bytes();

그리고 iterator를 사용했습니다.

for (i, &item) in bytes.iter().enumerate() {
  ... 

iter은 collection의 각 요소(element)를 반환하는 메소드입니다.
그리고 enumerate는 iter의 반환 값을 감싸고 있죠.
이 메소드는 각 요소의 인덱스와 iter가 반환했던 값의 reference를 tuple 타입으로 반환합니다.

enumerate가 tuple을 반환하기 때문에 구조분해를 사용해 index와 reference를 조회헀습니다.

복습) 왜 &를 사용했죠? 튜플의 두번째 인덱스의 값이 reference이기 때문입니다.

for loop에서 우리는 b' ' 라는 새로운 문법과 마주합니다.

이 문법은 byte literal이라고 부릅니다.

여기서 잠시 byte literal 문법이 뭔지 알아보고 오겠습니다.

byte literal

다시 돌아와서

        if item == b' ' {
            return i;
        }
    }

    s.len()

if 문을 통해 string에서 빈 공간을 찾은ㅇ 이후에 return i를 통해 해당 공간의 인덱스를 반환합니다.
만약 공간을 찾지 못한다면 그저 문자열의 길이를 s.len을 통해 반환합니다.

아, 빈 공간을 byte literal 문법과 조건문을 통해 알아냈구나.

위와 같은 flow를 통해 빈 공간의 index를 알아냈습니다.

이제 first_word 함수를 사용하는 main 함수를 사용하겠습니다.

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

    let word = first_word(&s); // word will get the value 5

    s.clear(); // this empties the String, making it equal to ""

    // word still has the value 5 here, but there's no more string that
    // we could meaningfully use the value 5 with. word is now totally invalid!
}

let word 패턴 바인딩을 통해 first_word가 "hello world"에 대한 index를 반환하고 이 값이 word에 바인딩 되었습니다.

하지만 다음 줄에서 s.clear()를 통해 s가 empty String이 되었습니다.
word는 &s ("hello world" 문자열의 레퍼런스)와 독립적인 값이라 s.clear() 메소드 호출 여부와 관계없이 항상 5라는 값을 가지게 됩니다. s.clear()호출 이후 s의 상태와는 전혀 상관없게 된 것이죠.

따라서 이 index를 통해 문자열의 데이터를 얻는 것은 잠재적인 에러를 가져오는 방법입니다.

String Slices

러스트는 slice를 제공함으로 인덱스를 직접 관리하는 것과 비교해 더욱 안전하게 문자열의 값을 참조할 수 있게 해줍니다.

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

    let hello = &s[0..5];
    let world = &s[6..11];

위의 코드를 그림으로 살펴보면 아래와 같습니다.

자바스크립트의 slice와 유사하죠?

러스트의 slice 문법은 다음처럼 첫번째 인덱스를 생략할 수 있습니다.
이 경우 문자열의 첫번째 인덱스부터 참조하는 것이 보장됩니다.

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

let slice = &s[0..2]; // 아랫줄과 동일한 결과입니다.
let slice = &s[..2];

마지막 인덱스를 생략함으로 문자열의 마지막 바이트 또한 항상 포함됨을 보장할 수 있습니다.

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

let len = s.len();

let slice = &s[3..len];
let slice = &s[3..];

시작과 끝 인덱스를 모두 생략할 수도 있습니다.

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

let len = s.len();

let slice = &s[0..len];
let slice = &s[..];

slice를 사용해 다시 first_word 타입을 정의해봅니다.

fn second_word(s: &String) -> &str {

여기서 함수 리턴 타입으로 선언된 str 가 바로 오늘의 주제인 String Slice type입니다.

str 타입을 사용한다면 앞서 봤었던 인덱스를 직접 관리하는 코드보다 더 안전하게 코드를 작성할 수 있습니다.
s.clear() 사용시 컴파일 에러가 발생하거든요.

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

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

    &s[..]
}

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

    let word = first_word(&s);

    s.clear(); // error!

    println!("the first word is: {}", word);

이제 String reference가 항상 유효함을 보장할 수 있습니다. 앞서 인덱스를 관리하여 잠재적인 오류를 가지고 있던 코드에서는 s.clear() 함수 호출 이후 유효하지 않은 인덱스의 값을 참조하는 것을 막지 못했잖아요?

슬라이스 타입을 사용할 시 에러가 발생할 수 있는 가능성의 코드를 작성 시 가장 빠른 시점에 해당 오류 발생을 예측할 수 있습니다.

&string, &str은 뭐가 다를까?

러스에서 &str은 string slice에 대한 타입입니다. 문자열의 일부분에 대한 reference를 의미하며 immutable borrow에 대한 타입입니다.

&stringString 객체에 대한 reference입니다.
&str과 마찬가지로 immutable borrow이지만 문자열에 대한 일부분을 참조하는 것이 아니라 String 객체 전체를 참조합니다.

아래 예제 코드에서 두 타입의 차이점에 대해 이야기해보겠습니다.

fn main() {
    let s = "hello world".to_string();
    let str_slice: &str = &s[..];
    let string_ref: &String = &s;

    println!("str_slice: {}", str_slice);
    println!("string_ref: {}", string_ref);
}

sString 객체이며 "hello world"라는 값을 가지고 있습니다. str_slice 변수는 s에 대한 slice를 가지고 있으며 slice이지만 "hello world" 문자열 전체를 포함합니다.
string_refString 객체 전체에 대한 reference입니다.

str_slice, string_ref 모두 "hello world" 값을 가지고 있는 것이 아니기 때문에 해당 값에 대한 오너쉽은 없고 immutable하게 borrow 합니다.

그렇다면 언제 String 타입을 사용해야 할까?

String type은 mutable 합니다.
헷갈리지 마세요. 앞서 봤던 let string_ref: &String = &s와 같이 string_ref는 String type이 아니라 &String 타입이고 이는 immutable borrow를 사용한 reference이기 때문에 immutable 하지만 String 그 자체는 mutable type 입니다.

이 말은 String 타입을 생성한 직후 변경할 수 있다는 것인데요,

fn main() {
    let mut s = "hello".to_string();
    s.push_str(" world");
    println!("{}", s);
}

String 객체에 push_str 메소드를 사용하여 world 글자를 추가했습니다.

그와 반면에 아래 코드의 s는 &str 타입이며 생성 직후 값을 변경할 수 없습니다. String 객체가 아니므로 push_str과 같은 메소드 또한 사용할 수 없습니다.

fn main() {
    let s = "hello world";
    let slice = &s[6..];
    slice[0] = 'W'; // This line will cause a compile-error
}

String 타입은 값에 대한 오너쉽, 값 변경을 위한 여러 메소드가 있습니다.

따라서 문자열 타입의 값은 변경할 수 있지만 string slice 타입의 실제 내용은 변경할 수 없습니다.
string 값을 변경하고 싶다면 String 타입 사용이 더 나은 선택일지도 모릅니다

앞서 봤던 s.push_str 메소드 외에도 replace_range와 같은 메소드를 사용할 수 있습니다. 아래 코드를 보면
chars 메소드를 통해 String 값에 대한 iterators를 얻고 next메소드를 통해 index 0에 해당하는 글자부터 마지막 글자까지의 값을 복사하여 새로운 글자 Hello를 반환합니다.

fn main() {
    let mut s = String::from("hello");
    let mut chars = s.chars();
    if let Some(c) = chars.next() {
        let new_c = 'H';  // the new character we want to use
        s.replace_range(0..c.len_utf8(), &new_c.to_string());
    }
    println!("{}", s);  // prints "Hello"
}

보너스) 만약 slice type을 변경하고 싶다면 아래와 같이 get_mut 메소드를 이용해 엘리먼트에 대한 mutable reference를 얻어야 합니다.

fn main() {
    let mut words = ["hello", "world", "!"];
    if let Some(w) = words.get_mut(0) {
        *w = "Hi";
    }
    println!("{:?}", words);  // prints ["Hi", "world", "!"]
}

Capacity

String 타입은 capaciy(수용력)이 있습니다. 여기서 말하는 capaicty란 특정 사이즈 만큼의 메모리에 문자열을 저장할 수 있다는 것인데요, 이 메모리에 공간이 더 없게 되면 명시적으로 메모리 공간을 더 늘릴 필요 없이 자동으로 메몰 ㅣ할당을 해줍니다. String slice type은 capacity가 없고 만들 때 충분한 메모리가 있는지 먼저 확인해야 합니다.

글을 마치며

오늘은 reference, data race, dangling reference, borrowing, slice type에 대해 알아보았습니다.
수고 많으셨습니다 :)

profile
성장을 향한 작은 몸부림의 흔적들

0개의 댓글