2. Rust - 소유권 이해하기

이현우·2022년 8월 17일
0

Rust

목록 보기
2/2

1 소유권이 무엇인가

러스트의 핵심 기능

모든 프로그램은 실행하는 동안 컴퓨터의 메모리를 사용하는 방법을 관리해야 한다.

몇몇 언어들은 프로그램이 실행될 때 더이상 사용하지 않는 메모리를 끊임없이 찾는 가비지 콜렉션을 갖고있다.

또다른 언어들에서는 프로그래머가 직접 명시적으로 메모리를 할당하고 해제한다.

러스트는 제 3의 접근법을 이용

  • 메모리는 컴파일 타임에 컴파일러가 체크할 규칙들로 구성된 소유권 시스템을 통해 관리
  • 소유권 기능들의 어떤 것도 런타임 비용이 발생하지 않는다.

1.1 스택과 힙

스택과 힘 둘다 런타임에 사용할 수 있는 메모리의 부분이지만, 각기 다른 방식으로 구조화 되어 있다.

스택

  • 후입 선출(last in, first out)
    • 값을 받아들인 순서대로 저장하고 제거는 반대방향으로(마지막에 들어온 값부터) 값을 지운다.
    • 접시를 쌓아올렸을 때 중간이나 밑에서부터 접시를 추가하거나 제거하는 건 힘들다.
  • 데이터에 접근하는 방식 덕택에 빠르다.
    • 데이터를 넣어두기 위한 공간 or 데이터를 가져올 공간을 검색할 필요 없음 -> 그 공간이 항상 꼭대기(top)이기 때문
  • 스택은 담긴 모든 데이터가 결정되어 있는 고정된 크기를 갖고 있어야 하는 특성 때문에 빠르다.

  • 컴파일 타임에 크기가 결정되어 있지 않거나 크기가 변경될 수 있는 데이터를 위해서 사용됨.
  • 힙의 구조
    • 데이터를 힙에 넣을 때, 먼저 저장할 공간이 있는지 물어봄
    • 운영체제가 충분히 커다란 힙 안의 빈 어떤 지점을 찾아서 이 곳을 사용중이라고 표시
    • 해당 지점의 포인터를 돌려줌
    • 위의 절차를 힙 공간 할당하기, 줄여서 '할당'으로 부름
  • 스택에 포인터를 푸싱하는 것은 할당에 해당되지 않는다.
  • 포인터는 결정되어 있는 고정된 크기의 값이므로, 스택에 포인터를 저장할 수 있지만, 실제 데이터를 사용하고자 할 때는 포인터를 따라가야 한다.
  • 저장된 데이터에 접근하는 것은 스택에 저장된 데이터에 접근하는 것보다 느리다. -> 포인터가 가리킨 곳을 따라가야 하기 때문

1.2 소유권 규칙

  1. 러스트의 각가의 값은 해당값의 오너(owner)라고 불리우는 변수를 갖고 있다.
  2. 한번에 딱 하나의 오너만 존재할 수 있따.
  3. 오너가 스코프 밖으로 벗어날 때, 값은 버려진다(dropped)

1.3 변수의 스코프

스코프 - 프로그램 내에서 아이템이 유효함을 표시하기 위한 범위

  • 변수는 선언된 시점부터 현재의 스코프가 끝날 때까지 유효하다.
fn main() {
                            // s는 유효하지 않습니다. 아직 선언이 안됐거든요.
    let s = "hello";  // s는 이 지점부터 유효합니다.
                            // s를 가지고 뭔가 합니다.
}                           // 이 스코프는 이제 끝이므로, s는 더이상 유효하지 않습니다.

두가지 중요한 지점

  1. 스코프 안에서 s가 등장하면 유효하다.
  2. 이 유효기간은 스코프 밖으로 벗어날 때까지 지속된다.

이 지점에서 스코프와 변수가 유효한 시점 간의 관계는 다른 프로그래밍 언어와 비슷하다.

1.4 String 타입

이전에 봐온 모든 데이터 타입들은 스택에 저장되었다가 스코프를 벗어날 때 스택으로부터 팝 된다.

이제 힙에 저장되는 데이터를 관찰하고 어떻게 비워내는지 살펴보자.

  • 문자열 값(스트링 리터럴)은 편리하지만 텍스트를 필요로 하는 모든 경우에 대해 항상 적절하진 않다. 그 이유는
    • 문자열 값은 불변(immutable)이다.
    • 모든 문자열이 우리가 프로그래밍 하는 시점에서 다 알 수 있는 것이 아니다.
      • 사용자의 입력을 받아 저장하고 싶다면?
      • 이러한 경우들에 대해, 러스트는 두번째 문자열 타입인 String을 제공
fn main() {
    let mut s = String::from("hello");
    s.push_str(", world!"); // push_str()은 해당 스트링 리터럴을 스트링에 붙여줍니다.
    println!("{}", s)   // 이 부분이 `hello, world!`를 출력할 겁니다.
}

:: 은 String 타입 아래의 from 함수를 특정 지을 수 있도록 해주는 네임스페이스 연산자(메소드 문법)

  • String은 변할 수 있지만 스트링 리터럴이 안되는 이유는 두 타입이 메모리를 쓰는 방식에 있다.

1.5 메모리와 할당

스트링 리터럴의 경우, 내용물을 컴파일 타임에 알 수 있으므로 텍스트가 최종 실행 파일에 직접 하드 코딩 되었고, 이렇게 하면 스트링 리터럴이 빠르고 효율적이 된다.

그러나 이는 문자열이 변경되지 않는 것을 전재로 하는 특성이다.

컴파일 타임에 크기를 알 수 없는 경우 및 실행 중 크기가 변할 수도 있는 경우의 텍스트 조각을 바이너리 파일에 집어넣을 수 없다.

  • String 타입은 변경 가능하고 커질 수 있는 텍스트를 지원하기 위해 만들어졌기에, 우리는 힙에서 컴파일 타임에는 알 수 없는 어느 정도 크기의 메모리 공간을 할당 받아 내용물을 저장할 필요가 있다.

    1. 런타임에 운영체제로부터 메모리가 요청되어야 한다.

      • 직접 수행한다. String::from을 호출하면, 구현 부분에서 필요한 만큼의 메모리를 요청
    2. String의 사용이 끝났을 때 운영체제에게 메모리를 반납할 방법이 필요하다.

      • 가비지 콜렉터(GC)를 갖고 있는 언어들의 경우, GC가 더 이상 사용하지 않는 메모리 조각을 계속해서 찾고 지워주며, 우리는 프로그래머로서 이와 관련한 생각을 안 해도 된다.
      • GC가 없을 경우 할당 받은 메모리가 더 필요 없는 시점을 알아서 명시적으로 이를 반납하는 코드를 호출하는 것이 프로그래머의 책임
      • 러스트: 메모리는 변수가 소속되어 있는 스코프 밖을 벗어나는 순간 자동으로 반납된다.
      {
          let s = String::from("hello"); // s는 여기서부터 유효합니다
      
          // s를 가지고 뭔가 합니다
      }                                  // 이 스코프는 끝났고, s는 더 이상 
                                         // 유효하지 않습니다

1.5.1 변수와 데이터가 상호작용하는 방법: 이동(move)

  • 여러 개의 변수들은 러스트에서 서로 다른 방식으로 같은 데이터에 대해 상호작용을 할 수 있다.
let x = 5;
let y = x;
  • 정수값 5x에 묶어놓고, x의 값의 복사본을 만들어 y에 묶는다.
  • 정수값이 결정되어 있는 고정된 크기의 단순한 값이고, 5라는 값들이 스택에 푸쉬되기 때문에 실제로도 이렇게 된다.
let s1 = String::from("hello");
let s2 = s1;
  • s1의 복사본을 만들어서 s2의 묶어놓는 식으로 동작할 것이라 생각하지만 이는 실제 동작과 다른 생각이다.

  • String은 그림의 왼쪽과 같이 세 개의 부분으로 이루어져 있다.
    • 문자열의 내용물을 담고 있는 메모리의 포인터, 길이, 그리고 용량이다.
  • 이 데이터의 그룹은 스택에 저장된다.
  • 내용물을 담은 오른쪽의 것은 힙 메모리에 있다.
  • 길이값은 바이트 단위로 String의 내용물이 얼마나 많은 메모리를 현재 사용하고 있는지를 말한다.
  • 용량값은 바이트 단위로 String이 운영체제로부터 얼마나 많은 양의 메모리를 할당 받았는지를 말한다.

  • s2s1을 대입하면 String 데이터가 복사되는데, 이는 스택에 있는 포인터, 길이값, 그리고 용량값이 복사된다는 의미이다.
  • 포인터가 가리키고 있는 힙 메모리 상의 데이터는 복사되지 않는다.
  • 따라서 메모리 내의 데이터 구조는 위의 그림과 같다.

  • 메모리 구조는 위의 그림과 같지 않는데, 위 그림은 Rust가 힙 메모리 상의 데이터까지도 복사한다면 벌어질 일이다.

  • 만일 이렇게 동작한다면 힙 안의 데이터가 클 경우 s2 = s1 연산은 런타임 상에서 매우 느려질 가능성이 있다.

  • 변수가 스코프 밖으로 벗어날 때, 러스트는 자동적으로 drop 함수를 호출하여 해당 변수가 사용하는 힙 메모리를 제거한다.

  • 두 데이터 포인터가 모두 같은 곳을 가리키고 있는 Figure 4-4 그림에서는 문제가 된다.

    • s2s1이 스코프 밖으로 벗어나게 되면, 둘 다 같은 메모리를 해제하려 할 것이다.
    • 이는 두번 해제(double free)오류라고 알려져 있으며 메몰 안정성 버그들 중 하나이다.
    • 메모리를 두번 해제하는 것은 메모리 손상(memory corruption)의 원인이 되는데, 이는 보안 취약성 문제를 일으킬 가능성이 있다.
  • 메모리 안정성을 보장하기 위해 Rust에서는 이런 경우 할당된 메모리를 복사하는 것을 시도하는 대신, s1이 더이상 유효하지 않다고 간주한다.

  • 그러므로 s1이 스코프 밖으로 벗어났을 때 아무것도 해제할 필요가 없어진다.

  • s1s2가 만들어진 후에 사용하려고 할 때 어떤 일이 벌어지는지 확인해 보자.

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

println!("{}, world!", s1);
error[E0382]: use of moved value: `s1`
 --> src/main.rs:4:27
  |
3 |     let s2 = s1;
  |         -- value moved here
4 |     println!("{}, world!", s1);
  |                            ^^ value used here after move
  |
  = note: move occurs because `s1` has type `std::string::String`,
which does not implement the `Copy` trait
  • 데이터의 복사 없이 포인터와 길이값 및 용량값만 복사한다는 개념이 얕은 복사(shallow copy)와 비슷하게 들릴 수 있다.
  • 하지만 러스트는 첫번째 변수를 무효화 시키기도 하기 때문에 이를 이동(move)이라 한다.
  • 그러므로 실제로 일어난 일은 다음과 같다.

  • Rust는 결코 자동적으로 데이터에 대한 깊은 복사본을 만들지 않는다.
  • 그러므로 어떠한 자동적인 복사라도 런타임 실행 과정에서 효율적일 것이라 가정할 수 있다.

1.5.2 변수와 데이터가 상호작용하는 방법: 클론

  • 만일 String의 스택 데이터 만이 아니라, 힙 데이터를 깊이 복사하기를 원한다면 clone이라 불리우는 공용 메소드를 사용할 수 있다.
let s1 = String::from("hello");
let s2 = s1.clone();
println!("s1 = {}, s2 = {}", s1, s2);
  • 이 코드는 Figure 4-5가 나타내는, 즉 힙 데이터가 정말로 복사되는 동작을 명시적으로 만들어낼 수 있는 방법이다.

1.5.3 스택에만 있는 데이터: 복사

let x = 5;
let y = x;
println!("x = {}, y = {}, x, y");
  • 방금 정리한 것과 대립되는 것처럼 보인다.
    • clone을 호출하지 않았지만, x도 유효하며 y로 이동하지도 않았다.
  • 이유는 정수형과 같이 컴파일 타임에 결정되어 있는 크기의 타입은 스택에 모두 저장되기 때문에, 실제 값의 복사본이 빠르게 만들어질 수 있다.
  • 이는 변수 y가 생성된 후에 x가 더 이상 유효하지 않도록 해야할 이유가 없다.
    • 바꿔말하면 여기선 깊은 복사와 얕은 복사 간의 차이가 없다
  • Rust는 정수형과 같이 스택에 저장할 수 있는 타입에 대해 달 수 있는 Copy 트레잇이라고 불리우는 특별한 어노테이션(annotation)이 있다.
  • 만일 어떤 타입이 Copy 트레잇을 갖고 있다면 대입 과정 후에도 예전 변수를 계속 사용할 수 있다.
  • 일반적인 규칙으로서 단순한 스칼라 값들의 묶음은 Copy가 가능하고 할당이 필요하거나 어떤 자원의 형태인 경우 Copy를 사용할 수 없다.
  • 사용가능한 타입
    • u32와 같은 모든 정수형 타입들
    • truefalse값을 갖는 불린 타입 bool
    • f64와 같은 모든 부동 소수점 타입들
    • Copy가 가능한 타입만으로 구성된 튜플들, (i32, i32)Copy가 되지만, (i32, String)은 안된다.

1.6 소유권과 함수

  • 함수에게 값을 넘기는 의미론(semantics)은 값을 변수에 대입하는 것과 유사하다.
  • 함수에게 변수를 넘기는 것은 대입과 마찬가지로 이동하거나 복사될 것이다,
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가 스코프 밖으로 벗어났습니다. 별다른 일은 발생하지 않습니다.
  • stakes_ownership함수를 호출한 이후에 사용하려 한다면, 컴파일 타임 오류가 발생한다.
  • 이러한 정적 확인은 여러 실수들을 방지해 준다.

1.7 반환 값과 스코프

  • 값의 반환 또한 소유권을 이동시킨다.
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는 스코프 밖으로 벗어났으며 drop이 호출됩니다. s2는 스코프 밖으로
  // 벗어났지만 이동되었으므로 아무 일도 일어나지 않습니다. s1은 스코프 밖으로
  // 벗어나서 drop이 호출됩니다.

fn gives_ownership() -> String {             // gives_ownership 함수가 반환 값을
                                             // 호출한 쪽으로 이동시킵니다.

    let some_string = String::from("hello"); // some_string이 스코프 안에 들어왔습니다.

    some_string                              // some_string이 반환되고, 호출한 쪽의
                                             // 함수로 이동됩니다.
}

// takes_and_gives_back 함수는 String을 하나 받아서 다른 하나를 반환합니다.
fn takes_and_gives_back(a_string: String) -> String { // a_string이 스코프
                                                      // 안으로 들어왔습니다.

    a_string  // a_string은 반환되고, 호출한 쪽의 함수로 이동됩니다.
}
  • 변수의 소유권을 모든 순간 똑같은 패턴을 따른다.

  • 힙에 데이터를 갖고 있는 변수가 스코프 밖으로 벗어나면, 해당 값은 데이터가 다른 변수에 의해 소유되도록 이동하지 않는한 drop에 의해 제거된다.

  • 만일 함수에게 값을 사용할 수 있도록 하되 소유권은 갖지 않도록 하고 싶다면, 즉 함수의 본체로부터 얻어진 결과와 더불어 우리가 넘겨주고자 하는 어떤 값을 다시 쓰고 싶어서 함께 반환받아야 한다면 튜플을 이용해 여러 값을 돌려받는 식으로 가능하긴 하다.

fn main() {
    let s1 = String::from("hello");
    let (s2, len) = calculate_length(s1);
    println!("The length of '{}' is {}.", s2, len)
}
fn calculate_length(s: String) -> (String, usize) {
    let length = s.len(); // len() 함수는 문자열의 길이를 반환한다.
    (s, length)
}
  • 이는 과한 작업이다.
  • Rust는 이를 위해 참조자(references)라는 기능을 갖고 있다.

참고 문서

https://rinthel.github.io/rust-lang-book-ko/ch04-00-understanding-ownership.html

profile
GitHub - https://github.com/jenu8628

0개의 댓글