[rust] 4. 소유권 이해하기

About_work·2024년 6월 16일
0

rust

목록 보기
6/16
  • 소유권은 러스트가 가비지 컬렉터 없이 메모리 안정성을 보장하도록 해줌

1. 소유권이 뭔가요?

  • 소유권: 러스트 프로그램의 메모리 관리법을 지배하는 규칙 모음
  • 몇몇 언어는
    • 가비지 컬렉션으로 프로그램에서 더 이상 사용하지 않는 메모리를 정기적으로 찾는 방식을 채택했고,
    • 다른 언어는 프로그래머가 직접 명시적으로 메모리를 할당하고 해제하는 방식을 채택
  • rust는 제 3의 방식
    • 소유권 (ownership) 이라는 시스템을 만들고,
    • 컴파일러가 컴파일 중에 검사할 여러 규칙을 정해 메모리를 관리하는 방식
    • 이 규칙 중 하나라도 위반하면 프로그램은 컴파일되지 않습니다.

1.1. stack and heap

1.1.1. stack

  • last in, first out (마지막에 들어온게, 먼저 제거)
  • push /pop
  • 스택에 저장되는 데이터는 모두 명확하고 크기가 정해져 있어야 함
  • 컴파일 타임에 크기를 알 수 없거나, 크기가 변경될 수 있는 데이터는 힙에 저장되어야 함
  • 스택 영역은 데이터에 접근하는 방식상 힙 영역보다 속도가 빠릅니다.
  • 여러분이 함수를 호출하면,
    • 호출한 함수에 넘겨준 값(값 중엔 힙 영역의 데이터를 가리키는 포인터도 있을 수 있습니다)과
    • 해당 함수의 지역 변수들이 스택에 푸시
    • 그리고 이 데이터들은 함수가 종료될 때 팝

1.1.2. heap

  • 소유권의 주 목표: heap 데이터의 관리
  • 데이터를 힙에 넣을 때 먼저 저장할 공간이 있는지 운영체제에 물어봅니다.
  • 그러면 메모리 할당자는 커다란 힙 영역 안에서 어떤 빈 지점을 찾고, 이 지점은 사용 중이라고 표시한 뒤 해당 지점을 가리키는 포인터 (pointer) 를 우리한테 반환
    • allocating (on the heap)이라 부름
    • stack에 값을 push: NOT allocating
    • 포인터는 크기가 정해져 있어 스택에 저장할 수 있으나, 포인터가 가리키는 실제 데이터를 사용하고자 할 때는 포인터를 참조해 해당 포인터가 가리키는 위치로 이동하는 과정을 거쳐야 합니다.
  • 힙 구조는 레스토랑에서 자리에 앉는 과정으로 비교할 수 있습니다.
    • 레스토랑에 입장하면, 직원에게 인원수를 알립니다.
    • 그러면 직원은 인원수에 맞는 빈 테이블을 찾아 안내하겠죠.
    • 이후에 온 일행이 우리 테이블을 찾을 땐 직원에게 물어 안내받을 겁니다.
  • 힙 영역은 포인터가 가리키는 곳을 찾아가는 과정으로 인해 느려집니다.
  • 힙 영역처럼 데이터가 서로 멀리 떨어져 있으면 작업이 느려지고, 반대로 스택 영역처럼 데이터가 서로 붙어 있으면 작업이 빨라집니다.

1.2. 소유권 규칙

  • 각각의 값은 소유자 (owner) only 1명 가 정해져 있습니다.
  • 소유자가 스코프 밖으로 벗어날 때, 값은 버려집니다 (dropped).

1.3. 변수와 스코프

  • 스코프란, 프로그램 내에서 아이템이 유효한 범위
    {                      // s는 아직 선언되지 않아서 여기서는 유효하지 않습니다
        let s = "hello";   // 이 지점부터 s가 유효합니다

        // s로 어떤 작업을 합니다
    }                      // 이 스코프가 종료되었고, s가 더 이상 유효하지 않습니다
  • s는 문자열 리터럴 (string literal)
  • 이 변수는 선언된 시점부터 현재의 스코프를 벗어날 때까지 유효

1.4. string type

  • 여태 보아온 문자열은 코드 내에 하드코딩하는 방식의 문자열 리터럴
  • 문자열 리터럴은 쓰기 편리하지만, 만능은 아닙니다.
  • 그 이유는 문자열 리터럴이 불변성 (immutable) 을 지니기에 변경할 수 없다는 점과, 프로그램에 필요한 모든 문자열을 우리가 프로그래밍하는 시점에 알 수는 없다는 점 때문
  • 사용자한테 문자열을 입력받아 저장하는 기능 등을 만들어야 하는 상황에서는 문자열 리터럴을 사용할 수 없죠.
  • 따라서 러스트는 또 다른 문자열 타입인 String을 제공
    • 이 타입은 힙에 할당된 데이터를 다루기 때문에, 컴파일 타임에 크기를 알 수 없는 텍스트도 저장할 수 있음
  • String 타입은 다음과 같이 from 함수와 문자열 리터럴을 이용해 생성 가능
    • 이중 콜론 :: -> String 타입에 있는 특정된 from 함수라는 것을 지정할 수 있게 해주는 네임스페이스 연산자
    • 변경이 가능
    let mut s = String::from("hello");

    s.push_str(", world!"); // push_str()이 문자열에 리터럴을 추가합니다

    println!("{}", s); // 이 줄이 `hello, world!`를 출력합니다

1.5. 메모리와 할당

  • 문자열 리터럴은 컴파일 타임에 내용을 알 수 있으므로, 텍스트가 최종 실행파일에 하드코딩
    • 이 방식은 빠르고 효율적이지만, 문자열이 변하지 않을 경우에만 사용 가능
  • 컴파일 타임에 크기를 알 수 없고 실행 중 크기가 변할 수도 있는 텍스트는 바이너리 파일에 집어넣을 수 없죠.
  • String 타입은 힙에 메모리를 할당하는 방식을 사용하기 때문에 텍스트 내용 및 크기를 변경 가능
    • 실행 중 메모리 할당자로부터 메모리를 요청해야 합니다.
      • String::from 호출 시, 필요한 만큼 메모리를 요청하도록 구현되어 있음
    • String 사용을 마쳤을 때 메모리를 해제할 (즉, 할당자에게 메모리를 반납할) 방법이 필요
  • GC가 없는 대부분의 언어에서는 할당받은 메모리가 필요 없어지는 지점을 프로그래머가 직접 찾아 메모리 해제 코드를 작성해야 합니다.
    • 러스트에서는 변수가 자신이 소속된 스코프를 벗어나는 순간 자동으로 메모리를 해제하는 방식으로 해결
    • 러스트는 변수가 스코프 밖으로 벗어나면 drop이라는 특별한 함수를 호출
    • 이 함수는 해당 타입을 개발한 개발자가 직접 메모리 해제 코드를 작성해 넣을 수 있게 되어있으며, 위의 경우 String 개발자가 작성한 메모리 해제 코드가 실행되겠죠.
    • drop은 닫힌 중괄호 }가 나타나는 지점에서 자동으로 호출됩니다.

1.6. 변수와 데이터 간 상호작용 방식: 이동

    let x = 5;
    let y = x;
    let s1 = String::from("hello");
    let s2 = s1;
  • 길이: String의 내용이 현재 사용하고 있는 메모리를 Byte 단위로 나타낸 것
  • 용량: 메모리 할당자가 String에 할당한 메모리의 양을 뜻 (더 큰 범위??)
  • 메모리 안정성을 보장하기 위해서, 러스트는 let s2 = s1; 라인 뒤로는 s1이 더 이상 유효하지 않다고 판단합니다.
  • 이로써 러스트는 s1이 스코프를 벗어나더라도 아무것도 해제할 필요가 없어집니다. s2가 만들어진 이후에 s1을 사용하는 경우 어떤 일이 생기는지 확인해 보면, 작동하지 않음을 알 수 있습니다:
  • 러스트에서는 기존의 변수를 무효화하기 때문에 이를 얕은 복사가 아닌 이동 (move) 이라 하고, 앞선 코드는 s1이 s2로 이동되었다라고 표현합니다.
  • 러스트는 절대 자동으로 ‘깊은’ 복사로 데이터를 복사하는 일이 없습니다.

1.7. 변수와 데이터 간 상호작용 방식: 클론

  • tring의 힙 데이터까지 깊이 복사하고 싶을 땐 clone이라는 공용 메서드를 사용할 수 있음
  • clone은 해당 위치에서 무언가 다른 일이 수행될 것을 알려주는 시각적인 표시이기도 합니다.
    let s1 = String::from("hello");
    let s2 = s1.clone();

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

1.8. 스택에만 저장되는 데이터: 복사

    let x = 5;
    let y = x;

    println!("x = {}, y = {}", x, y);
  • clone() 메서드가 필요 없음
    • 이유: copy trait가 있어서!
  • clone을 호출하지도 않았는데 x는 계속해서 유효하며 y로 이동되지도 않았습니다.
  • 정수형 등 컴파일 타임에 크기가 고정되는 타입은 모두 스택에 저장되기 때문
  • 스택에 저장되니, 복사본을 빠르게 만들 수 있고, 따라서 굳이 y를 생성하고 나면 x를 무효화할 필요가 없습니다.
  • 다시 말해 이런 경우엔 깊은 복사와 얕은 복사 간에 차이가 없습니다.
  • 여기선 clone을 호출해도 얕은 복사와 차이가 없으니 생략해도 상관없죠.
  • 러스트에는 정수형처럼 스택에 저장되는 타입에 달아 놓을 수 있는 Copy 트레이트가 있습니다 (트레이트는 10장에서 자세히 다룹니다).
    • 만약 어떤 타입에 이 Copy 트레이트가 구현되어 있다면, 이 타입의 변수는 사용되어도 이동되지 않고 자명하게 복사되고, 대입 연산 후에도 사용할 수 있죠.
  • 하지만 구현하려는 타입이나, 구현하려는 타입 중 일부분에 Drop 트레이트가 구현된 경우엔 Copy 트레이트를 어노테이션 (annotation) 할 수 없습니다.
    • 즉, 스코프 밖으로 벗어났을 때 특정 동작이 요구되는 타입에 Copy 어노테이션을 추가하면 컴파일 에러가 발생합니다.
    • drop 트레이트와 copy 트레이트는 동시에 존재 불가능
  • 일반적으로 단순한 스칼라 값의 묶음은 Copy 가능하고, 할당이 필요하거나 리소스의 일종인 경우엔 불가능
모든 정수형 타입 (예: u32)
true, false 값을 갖는 논리 자료형 bool
모든 부동 소수점 타입 (예: f64)
문자 타입 char
Copy 가능한 타입만으로 구성된 튜플 (예를 들어, (i32, i32)는 Copy 가능하지만 (i32, String)은 불가능합니다)

1.9 소유권과 함수

  • 함수로 값을 전달하는 메커니즘은 변수에 값을 대입할 때와 유사
  • 함수에 변수를 전달하면 대입 연산과 마찬가지로 이동이나 복사가 일어나기 때문
fn main() {
    let s = String::from("hello");  // s가 스코프 안으로 들어옵니다

    takes_ownership(s);             // s의 값이 함수로 이동됩니다...
                                    // ... 따라서 여기서는 더 이상 유효하지 않습니다

    let x = 5;                      // x가 스코프 안으로 들어옵니다

    makes_copy(x);                  // x가 함수로 이동될 것입니다만,
                                    // i32는 Copy이므로 앞으로 계속 x를
                                    // 사용해도 좋습니다

} // 여기서 x가 스코프 밖으로 벗어나고 s도 그렇게 됩니다. 그러나 s의 값이 이동되었으므로
  // 별다른 일이 발생하지 않습니다.

fn takes_ownership(some_string: String) { // some_string이 스코프 안으로 들어옵니다
    println!("{}", some_string);
} // 여기서 some_string이 스코프 밖으로 벗어나고 `drop`이 호출됩니다.
  // 메모리가 해제됩니다.

fn makes_copy(some_integer: i32) { // some_integer가 스코프 안으로 들어옵니다
    println!("{}", some_integer);
} // 여기서 some_integer가 스코프 밖으로 벗어납니다. 별다른 일이 발생하지 않습니다.
  • 러스트는 takes_ownership 함수를 호출한 이후에 s를 사용하려 할 경우, 컴파일 타임 에러를 발생시킵니다.

1.10. 반환 값과 스코프

fn main() {
    let s1 = gives_ownership();         // gives_ownership이 자신의 반환 값을 s1로
                                        // 이동시킵니다

    let s2 = String::from("hello");     // s2가 스코프 안으로 들어옵니다

    let s3 = takes_and_gives_back(s2);  // s2는 takes_and_gives_back로 이동되는데,
                                        // 이 함수 또한 자신의 반환 값을 s3로
                                        // 이동시킵니다
} // 여기서 s3가 스코프 밖으로 벗어나면서 버려집니다. s2는 이동되어서 아무 일도
  // 일어나지 않습니다. s1은 스코프 밖으로 벗어나고 버려집니다.

fn gives_ownership() -> String {             // gives_ownership은 자신의 반환 값을
                                             // 자신의 호출자 함수로 이동시킬
                                             // 것입니다

    let some_string = String::from("yours"); // some_string이 스코프 안으로 들어옵니다

    some_string                              // some_string이 반환되고
                                             // 호출자 함수 쪽으로
                                             // 이동합니다
}

// 이 함수는 String을 취하고 같은 것을 반환합니다
fn takes_and_gives_back(a_string: String) -> String { // a_string이 스코프 안으로
                                                      // 들어옵니다

    a_string  // a_string이 반환되고 호출자 함수 쪽으로 이동합니다
}
  • 힙에 데이터를 갖는 변수가 스코프를 벗어나면, 사전에 해당 데이터가 이동하여 소유권이 다른 변수에 이동되지 않은 이상 drop 에 의해 데이터가 제거됩니다.

2. 참조와 대여

  • 참조: 소유권 이동 X + 값 참조
  • 참조자 (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()
}
  • &(앰퍼센트)를 이용한 참조의 반대는 역참조 (dereferencing) 라 합니다. 역참조 기호는 *이며, 8장에서 몇 번 다뤄보고 15장에서 자세한 내용을 배울 예정입니다.
    let s1 = String::from("hello");

    let len = calculate_length(&s1);
    
fn calculate_length(s: &String) -> usize { // s는 String의 참조자입니다
    s.len()
} // 여기서 s가 스코프 밖으로 벗어납니다. 하지만 참조하는 것을 소유하고 있진 않으므로,
  // 버려지지는 않습니다.
  • 값을 소유하지 않으므로, 이 참조자가 가리킨 값은 참조자가 사용되지 않을 때까지 버려지지 않습니다.
  • s에는 소유권이 없으므로 s가 더 이상 사용되지 않을 때도 이 참조자가 가리킨 값이 버려지지 않습니다.
  • 이처럼 참조자를 만드는 행위를 대여 (borrow) 라고 합니다.
  • 변수가 기본적으로 불변성을 지니듯, 참조자도 마찬가지로 참조하는 것을 수정할 수 없습니다.

1.1. mutable reference

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

    change(&mut s);
}

fn change(some_string: &mut String) {
    some_string.push_str(", world");
}
  • 같은 데이터에 대하여 동시에 여러 가변 참조자의 사용을 막는 이러한 제약은, 값의 변경에 대한 제어가 원활하도록 해 줍니다.
  • 이 제약 덕분에 러스트에서는 컴파일 타임에 데이터 경합 (data race) 을 방지할 수 있습니다.
  • 데이터 경합 (data race) 정의
    • 둘 이상의 포인터가 동시에 같은 데이터에 접근
    • 포인터 중 하나 이상이 데이터에 쓰기 작업을 시행
    • 데이터 접근 동기화 메커니즘이 없음
  • 아래의 경우는 가능
    let mut s = String::from("hello");

    {
        let r1 = &mut s;
    } // 여기서 r1이 스코프 밖으로 벗어나며, 따라서 아무 문제없이 새 참조자를 만들 수 있습니다.

    let r2 = &mut s;
  • 가변 참조자와 불변 참조자를 혼용할 때도 유사한 규칙이 적용됨
  • 어떤 값에 대한 불변 참조자가 있는 동안, 같은 값의 가변 참조자를 만드는 것 또한 불가능
    • 불변 참조자를 사용하는 쪽에서는 사용 중 값이 중간에 변경되리라 예상하지 않으니까요.
    • 반면 데이터를 읽기만 하는 기능으로는 다른 쪽에서 값을 읽는 기능에 영향을 주지 않으므로, 여러 개의 불변 참조자를 만드는 것은 가능합니다.
  • 참조자는 정의된 지점부터 시작하여 해당 참조자가 마지막으로 사용된 부분까지 유효
    let mut s = String::from("hello");

    let r1 = &s; // 문제없음
    let r2 = &s; // 문제없음
    println!("{} and {}", r1, r2);
    // 이 지점 이후로 변수 r1과 r2는 사용되지 않습니다

    let r3 = &mut s; // 문제없음
    println!("{}", r3);
  • 불변 참조자 r1, r2의 스코프는 자신들이 마지막으로 사용된 println! 이후로 종료
  • 컴파일러는 이 참조자가 어떤 지점 이후로 스코프 끝까지 사용되지 않음을 알 수 있습니다.

1.2. 댕글링 참조

  • 댕글링 포인터 (dangling pointer) 란, 어떤 메모리를 가리키는 포인터가 남아있는 상황에서 일부 메모리를 해제해 버림으로써, 다른 개체가 할당받았을지도 모르는 메모리를 참조하게 된 포인터
  • 하지만 러스트에서는 어떤 데이터의 참조자를 만들면, 해당 참조자가 스코프를 벗어나기 전에 데이터가 먼저 스코프를 벗어나는지 컴파일러에서 확인하여 댕글링 참조가 생성되지 않도록 보장
  • 참조자는 항상 유효해야 합니다.

3. 슬라이스

  • reference의 다른 종류
  • 슬라이스 (slice) 는 컬렉션 (collection) 을 통째로 참조하는 것이 아닌, 컬렉션의 연속된 일련의 요소를 참조
  • 슬라이스는 참조자의 일종으로서 소유권을 갖지 않습니다.
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()
}
  • 바이트 배열에 사용할 반복자 (iterator) 를 iter 메서드로 생성
    • iter 메서드는 컬렉션의 각 요소를 반환
    • enumerate 메서드는 iter의 각 결괏값을 튜플로 감싸 반환
  • 이때 패턴에 &를 사용하는 이유는 iter().enumerate()에서 얻은 요소의 참조자가 필요하기 때문
  • usize (unsigned integer type)를 반환하고 있는데, 이는 &String의 컨텍스트에서만 의미 있는 숫자일 뿐입니다.
    • 바꿔 말하면, String과는 별개의 값이기 때문에 향후에도 유효하다는 보장이 없습니다. (아래 코드 참조)
fn main() {
    let mut s = String::from("hello world");

    let word = first_word(&s); // word는 값 5를 받습니다

    s.clear(); // 이 코드는 String을 비워서 ""으로 만듭니다

    // 여기서 word에는 여전히 5가 들어있지만, 이 5를 의미있게 쓸 수 있는
    // 문자열은 더 이상 없습니다. word는 이제 전혀 유효하지 않습니다!
}
  • 하지만 word에 담긴 값 5를 본래 목적대로 s에서 첫 단어를 추출하는 데 사용할 경우, 버그를 유발할 수도 있습니다. s의 내용물은 변경되었으니까요.
  • 다행히도, 러스트에는 문자열 슬라이스라는 적절한 대안이 존재합니다.

3.1. 문자열 슬라이스 (string slice)

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

    let hello = &s[0..5];
    let world = &s[6..11];
  • [starting_index..ending_index]는 starting_index부터 시작해 ending_index 직전, 즉 ending_index 에서 1을 뺀 위치까지 슬라이스를 생성한다는 의미
  • 슬라이스는 내부적으로 시작 위치, 길이를 데이터 구조에 저장
  • Note:
    • 본 내용은 문자열 슬라이스를 소개할 목적이기에 ASCII 문자만 사용하여 문제가 발생하지 않았지만, UTF-8 문자열 슬라이스 생성 시 인덱스는 반드시 올바른 UTF-8 문자 경계로 지정되어야 합니다.
    • 멀티바이트 문자의 중간 부분에 슬라이스를 생성할 경우, 프로그램은 에러와 함께 강제 종료됩니다.
  • 첫 단어의 끝부분 인덱스를 찾은 이후 문자열이 비워지면 찾아낸 인덱스가 쓸모없어지는 문제도 해결
    • 이제는 컴파일러가 String을 가리키는 참조자의 유효함을 보증하니까요
  • 대여 규칙 중에서 특정 대상의 불변 참조자가 이미 존재할 경우에는 가변 참조자를 만들 수 없다
  • clear 함수는 String의 길이를 변경해야 하니 가변 참조자가 필요
  • println!는 word의 참조자를 사용하므로, 이 불변 참조자는 이 지점까지 계속 활성화되어 있어야 합니다.

3.2. 슬라이스로써의 문자열 리터럴

  • 문자열 리터럴은 바이너리 내에 저장된다

  • let s = "Hello, world!";

  • s는 바이너리의 특정 지점을 가리키는 슬라이스입니다. &str 타입이죠. &str은 불변 참조자

  • &str

    • 문자열 슬라이스
    • &String을 포함하는 큰 개념
  • 리터럴과 String의 슬라이스를 만들 수 있다는 걸 알고 나면 first_word 함수 정의를 다음과 같이 작성할 수 있습니다:

    fn first_word(s: &String) -> &str {
    
    fn first_word(s: &str) -> &str {
  • 문자열 슬라이스라면 이를 바로 인수로써 전달할 수 있습니다.

  • String이라면 String의 슬라이스 혹은 String에 대한 참조자를 전달할 수 있습니다.

  • 이러한 유연성은 역참조 강제 변환 (deref coercions) 기능을 이용하는데, 15장의 ‘함수와 메서드를 이용한 암묵적 역참조 강제 변환’절에서 다룰 것입니다.

  • String에 대한 참조자 대신에, 문자열 슬라이스를 매개변수로 하는 함수를 정의하면, 기능 면에서 손해보지 않으면서 API를 더 일반적이고 유용하게 만들어 줍니다:

3.3. 그 외 슬라이스

let a = [1, 2, 3, 4, 5];

let slice = &a[1..3];

assert_eq!(slice, &[2, 3]);
  • 이 슬라이스는 &[i32] 타입
profile
새로운 것이 들어오면 이미 있는 것과 충돌을 시도하라.

0개의 댓글