Rust(10) – Ownership

Ownership은 러스트 언어의 핵심 개념입니다. 이것만 알아도 Rust를 알수 있다 할수 있습니다. 가비지 콜렉터도 없지만 C++의 포인터처럼 복사를 최소화하여 성능을 극대화 시킬수있는 새로운 개념입니다. 많은 프로그래머들에게는 생소할수있지만 사실 개념자체는 이미 오래전에 만들어지고 C++에서도 스마트포인터라는 이름으로 20여년전부터 많이 쓰이는 방식이긴 합니다. 문제는 C++에서 이 방식을 사용할경우 변수선언에 코드가 지나치게 길어지기도 해서 일반변수에는 잘안쓰고 class 객체위주로 사용하는 사람이 대부분이었습니다. 문제는 C++ 스마트 포인터는 코드가 길어질뿐만아니라, 익숙치않으면 사용할때 컴파일에러도 많이 발생하고, 정작 유저가 혜택을보는 부분에서는 컴파일러가 에러를 잘 잡아주지못하는 문제도 있습니다. Rust는 기본 개념자체가 모든 힙메모리를 사용하는 변수에 오너십을 적용하고 있어서, 코드가 간결하여 컴파일러가 에러를 확실하게 잡아줍니다. 애플의 Swift나 메타의 move 언어도 같은개념을 사용할수있지만 언어의 기본 시스템은 아닙니다.

Stack and Heap

스택은 자료구조에서 다들 배웠겠지만, 여기서말하는 스택은 조금 다릅니다. 자료구조의 스택처럼 LIFO 개념은 동일합니다만, 여기서 말하는 Stack은 프로그램을 실행시 정적으로 가지는 메모리입니다. C++에서 Vector를 사용할때 Stack처럼 push로 데이터를 추가하고 pop으로 빼낼수있지만 C++ Vector는 가변메모리로써 Heap에 존재하죠. Stack에 저장될수있는 메모리는 컴파일 시점에 메모리의 크기를 정확히 알수 있어야 합니다.

스택메모리는 프로그램 실행시점부터 순차적으로 메모리를 쌓아올라가기 떄문에, 호출비용이 없고 메모리 낭비가 거의 제로에 가깝습니다. 힙메모리는 메모리 조각조각 사이에서 필요로하는 메모리보다 큰공간을 사용하게되므로 메모리 낭비도있고 빈공간을 찾아야하는 호출비용도 존재합니다.

최적화 기법중에는 stack메모리를 크게할당해둔다음 데이터를 수동으로 stack메모리에서 관리해주는 방법도 있습니다. 하지만 이는 생산성이 떨어지는 단점이 있어서 특별한경우를 제외하고는 잘 사용하지 않습니다. Ownership 개념을위해 짧게 정리하고 넘어갑니다.

fn main() {
    let score: i32 = 100;                                // stack
    let name: String = String::from("김개똥");     // heap
}

name 변수를 일반변수로 설정해도 heap 메모리에 저장되게 됩니다. 만약 문자열을 stack메모리에 올리고싶으면, 아래처럼 사용해야합니다.

let stack_str: [u8; 9] = *b"Hello Rust";

Ownership

오너십은 조건

  • 각 변수는 오너십을 갖고있다.
  • 오너십은 단 한명만 갖는다.
  • 오너가 스코프를 넘어가는경우 값은 사라진다. – 가비지콜렉터 대용기능

scope의 개념

 {                      
        let s = "hello";   // s는 이 라인 이후로 사용 가능

        // s 를 사용
}                            // 여기서부터는 스코프를 떠나서 s는 더이상 사용 불가능

소유권 이동

먼저 아래의 경우 integer 타입 변수는 사이즈를 알고있고, 때문에 x 와 y 모두 5라는값을 스택메모리에 저장하게됩니다.

{
    let x = 5;
    let y = x;
}

문자열 형식의 경우, 힙메모리를 사용하기때문에 아래처럼 코드를 작성할경우 아래 그림처럼 hello 문자열이 담긴 주소(포인터)가 복사되게 된다. 좋게 말하면 포인터 코드를 알지못해도 포인터를 사용한것처럼 낼수있는것이고. 단점이라면, 포인터를 사용하진않지만 실제로는 포인터개념을 알고있어야 Rust를 잘 사용할수 있다는 이야기 이기도 하다.

s2에 s1을 대입하는 순간 소유권이 s2로 이동해서, s1은 사용불가 -> s1을 사용하려 하면 컴파일 에러

{
    let s1 = String::from("hello");
    let s2 = s1;                   // 이 statement 이후 s1은 사용불가
}
Three tables: tables s1 and s2 representing those strings on the
stack, respectively, and both pointing to the same string data on the heap.

drop 타이밍

같은 s변수에 문자열을 두번 초기화 하는 경우엔, 2개의 힙메모리가 할당이 되는데 2 번 statement가 실행되는순간 아래 그림처럼 s변수는 ahoy문자열이 저장된 주소를 가리키게된다.

Rust에서는 메모리를 해제하는것을 drop 함수로 진행하는데 drop함수가 호출되는 부분은 4번 scope를 떠나는 순간이므로 hello라는 메모리는 3번 statement가 실행된 이후에도 메모리에는 실제로 남아있다. 이렇게 작동하는 이유는 좀더 복잡한코드에서 1번과 2번 statement사이에서 s의 소유권을 가져가진 않지만 참조로 읽을수있는 다른객체가 존재할수 있기때문이다.

{
    let mut s = String::from("hello");  // 1
    s = String::from("ahoy");            // 2

    println!("{s}, world!");                // 3
}                                             // 4
One table representing the string value on the stack, pointing to
the second piece of string data (ahoy) on the heap, with the original string
data (hello) grayed out because it cannot be accessed anymore.

deep copy 깊은 복사

만약 문자열을 복사 하고싶다면? 아래처럼 clone() 함수를 사용해주면된다.

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

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

구조체 객체에 저장할때 소유권을 넘겨주면 되지만 여러 객체에 할당하거나 다른이유로 원본(s1)을 유지하고싶으면 clone 명령어로 깊은복사를 해야한다.

struct User {
    username: String,
}

fn main() {
    let current_name = String::from("김개똥");

    // User 구조체가 username의 소유권을 가져야 하므로 복사본을 만듭니다.
    let user1 = User {
        username: current_name.clone(),
    };

    // 여전히 current_name을 다른 곳(예: 로그 출력)에 쓸 수 있습니다.
    println!("가입 신청자: {}", current_name);
}

Return Values and Scope 함수 리턴과 스코프

아래코드는 https://doc.rust-lang.org/book/ch04-01-what-is-ownership.html 에서 빌려왔다. 이해해야할 포인트가 몇군데 있다. some_string은 gives_ownersihp 함수에서 선언되었지만, 해당 함수가끝나도 yours 문자열은 drop되지않는다. 소유권이 이미 s1변수로 넘어갔기때문이다. 그리고 s2에서 생성된 hello 문자열은 takes_and_gives_back 함수로 소유권을 전달하지만 해당함수 리턴값으로 소유권을 돌려받는다. 여기서 중요한건 그냥 문자열을 전달하듯 사용했을뿐인데, C++에서 포인터를 통해 문자열을 전달했다가 돌려받은것처럼 메모리 복사가 발생하지 않는다는것이다.

fn main() {
    let s1 = gives_ownership();        // gives_ownership moves its return
                                       // value into s1

    let s2 = String::from("hello");    // s2 comes into scope

    let s3 = takes_and_gives_back(s2); // s2 is moved into
                                       // takes_and_gives_back, which also
                                       // moves its return value into s3
} // Here, s3 goes out of scope and is dropped. s2 was moved, so nothing
  // happens. s1 goes out of scope and is dropped.

fn gives_ownership() -> String {       // gives_ownership will move its
                                       // return value into the function
                                       // that calls it

    let some_string = String::from("yours"); // some_string comes into scope

    some_string                        // some_string is returned and
                                       // moves out to the calling
                                       // function
}

// This function takes a String and returns a String.
fn takes_and_gives_back(a_string: String) -> String {
    // a_string comes into
    // scope

    a_string  // a_string is returned and moves out to the calling function
}

참조와 빌리기

Rust는 기본적으로 값을 수정할수 없다. 따라서 가변참조를 하기위해서는 mut키워드를 사용하여 변수를 선언해야함

let s = String::from("hello"); // mut 없음 (읽기 전용)

let r1 = &mut s; // ❌ 여기서 바로 컴파일 에러!
                 // 에러 메시지: `cannot borrow `s` as mutable, as it is not declared as mutable`

가변 참조, (불변) 참조, 소유권 이전

아래는 변수를 다른변수에 대입할때 참조나 소유권 이전이 되는 경우의 예시이다.

  • 아래처럼 {} 대괄호 Curly Brackets을 사용하여 참조 범위를 명시적으로 지정해줄수 있다.
  • 소유권이 이전되면 더이상 다른변수에서 참조할수없다.
  • 이미 참조된경우 소유권이전이나 가변참조가 불가능해진다. (예시에서 블럭으로 구분해둔 이유)
  • 결론적으로 동시에 가능한것은 불변참조만 가능하다.
  • 블럭을 사용하지않아도 예를들어 아래 예시에서 r1 r3 두 예시에 블럭을 제거해도 에러나지 않고 잘됩니다 이유는 컴파일러가 r3 소유권 이전 구문이 실행될 시점 이후에 r1이 사용되지않는걸 알기에 알아서 참조 해제 해버립니다. 하지만 만약 이후에 r1를 다시 사용하려하면 r3에서 참조되고있는 변수를 소유권 이전하려 한다면서 에러가 발생합니다.
fn main() {
    let mut s = String::from("hello");
    
    { // 불변 참조: r1에서는 값을 수정 불가능
        let r1 = &s;
        println!("{r1}n");
    }
    { // 가변 참조
        let r2 = &mut s;
        r2.push_str(" world");
        println!("{r2}n");
    }
    { // 소유권 이전
        let mut r3 = s;
        r3.push_str(" and friends");
        println!("{r3}n");
    }
}
[실행결과]
hello

hello world

hello world and friends

Dangling Reference (빈주소 참조)

C++ 사용자라면 포인터를 공부할때 빈 메모리참조하여 런타임 에러가 나는 경우를 자주 겪어봤을겁니다. Rust에서는 포인터를 사용하지는 않지만 댕글링 포인터 참조로인해 런타임에러가 발생하는경우가 생길수도 있습니다. Rust에서 포인터를 사용하지 않고 소유권 이전과 빌리기 개념을 사용한다고 하지만 결국 low-level 언어로써 메모리를 직접 관리해줘야 하기때문입니다. 다시말하면 Rust는 포인터를 사용자가 직접 사용하진않지만 내부적으로는 포인터를 사용하는것과 다름이 없고 좀 더 편하게 안전하게 사용할수있게 해주는것이라 생각하시면 됩니다.

아래 함수에서는 dangle이라는 함수에서 내부에서 생성된 변수의 참조를 리턴을하고있는데 dangle함수가 끝나는시점에 s 문자열이 drop되므로 dangle함수 밖에서 &s를 사용하지못할것을 컴파일러가 알고 미리 에러를 발생시킵니다. C++에서는 컴파일 단에서 잡아내지 못하는 에러를 Rust에서는 잡아주는경우가 있어서 좀더 에러를 쉽게 발견하고 해결해줄수있게 해주는 장점이 존재합니다.

fn main() {
    let reference_to_nothing = dangle();
}

fn dangle() -> &String {
    let s = String::from("hello");

    &s
}

멀티바이트 캐릭터 셋 이슈 (유니코드/UTF-8 인덱싱 이슈)

여러단어로 구성된 문장이있을때 첫번째 단어를 찾아서 리턴해주는 함수를 만든다고 가정할때, 함수가 띄어쓰기 (스페이스 공백)을 찾지않는다면 전체문장이 한단어로 판단하여 전체문장을 그대로 리턴하게 될것이라는 문제가 있다고 가정해봅시다.

먼저 slice 없이 이 함수를 구현해보겠습니다. 문장을 입력받아 첫번째 단어 끝나는부분의 인덱스를 찾아주는 함수를 작성해보겠습니다.

fn main() {
    let text = String::from("안녕하세요 김개똥 입니다.");
    let i = first_word(&text);

    println!("{i}");
}

// 첫번째 단어 끝나는부분의 인덱스를 찾아주는 함수 
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()
}

예상되는 출력 결과가 있나요? 아래 실제 출력결과와 일치하는지 확인해보세요

15

정답이 맞았나요? 여러분은 5를 예상하셨을겁니다. 답이 틀린 이유는 한글이 한글자에 3바이트를 차지하기 때문입니다. 따라서 “안녕하세요” 라는 글자는 실제로 15바이트를 차지하고 그 뒤에오는 공백 b’ ‘는 16번째 바이트 즉 인덱스값으로는 15가 되기 때문입니다. 아래처럼 문자열 내용을 영어로바꾸면 예상되는 결과를 보실수 있을겁니다.

let text = String::from("hello world.");  // 함수 결과값 5 출력

Slice Type

슬라이스란 콜렉션의 요소를 순회하며 참조하는것으로 소유권을 갖지 않습니다. 슬라이스의 개념설명을 위해 위의 예제를 다시사용할테니 멀티바이트 캐릭터셋 이슈를 안보신분은 위로올라가서 읽고 오시는걸 추천합니다.

위의코드는 문자열이 영어일땐 상관없지만 한국어나 다른언어, 또는 이모트일경우 에러가 발생할수있는 문제를 지적하는 예시였습니다. 그런데 코드에 또 다른 문제가 존재하는데요. 바로 i 인덱스값이 문제입니다. text변수의 문자열의 내용은 언제든지 수정되거나 삭제될수 있는데요. 이런경우 이 인덱스값을 사용하면 원치 않는 결과가 출력될수 있습니다.

이런 문제가 있을때, Slice로 문제를 해결할수있는데 먼저 Slice의 사용 예시부터 보겠습니다.

fn main() {
    let text = String::from("안녕하세요 김개똥 입니다");

    let s = text;

    let hello = &s[0..15];
    let name = &s[16..25];

    println!("{hello}, {name}");
}

이렇게 [0..15] 같은문법으로 슬라이싱하여 hello변수에 “안녕하세요” 그리고 name변수에 “김개똥” 으로참조를 전달할수있습니다. 앞서 언급했듯, hello와 name은 소유권이 없이 참조만 갖고있는것을 잊지마세요. 아래 그림처럼 문자열의 시작위치 끝위치 크기등을 갖고있을뿐입니다.

Three tables: a table representing the stack data of s, which points
to the byte at index 0 in a table of the string data "hello world" on
the heap. The third table represents the stack data of the slice world, which
has a length value of 5 and points to byte 6 of the heap data table.

자, 그럼 이 slice가 어떤 장점이 있을까요? 먼저위에서 작성했던 첫단어를 찾아주는함수를, 슬라이싱을 찾아 리턴하는 함수로 개선해보겠습니다.

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[..]
}

문자열 처음 0 부터 공백이있는 i까지를 슬라이싱하여 리턴해주는걸 알수있습니다.

댓글 남기기