소유권(Ownership)

Posted by yunki kim on January 16, 2022

  소유권은 러스트가 gc없이 메모리 안정성 보장을 하게 해준다. 

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

  애플리케이션 실행 시 힙에 저장된 데이터에 접근하는 것은 스택에 저장된 데이터에 접근하는 것 보다 느리다. 스택 메모리는 top에 접근하면 되는 반면에 힙은 포인터가 가리키는 곳을 따라가야 하기 때문이다. 코드의 어느 부분이 힙의 어떤 데이터를 사용하는지 추적하는 것, 힙의 중복된 데이터의 양을 최소화 하는 것, 힙 내에 사용하지 않는 데이터를 제거하는 것이 소유권과 관련된 문제이다. 따라서 힙 데이터를 관리하는 것이 소유권 존재의 이유이다.

 

소유권 규칙

  소유권은 다음과 같은 규칙을 가지고 있다.

    1. 러스트의 각각의 값은 해당값의 오너(owner)라 불리는 변수를 가지고 있다.

    2. 한번에 딱 하나의 오너만 존재할 수 있다.

    3. 오너가 스코프 밖으로 벗어나면 값은 버려진다(dropped).

 

String 타입

  소유권을 설명하기 위해 우선 String타입을 보자. 

  문자열 타입은 불변이기 때문에 스트링 리터럴을 사용하는 것은 일부 경우에 부적절할 수 있다. 이를 위해 러스트는 String이라는 타입을 제공한다. 이 타입은 힙에 할당되며 길이가 정해져 있지 않는 문자열을 저장할 수 있다.

1
2
3
4
5
6
fn main() {
    // ::은 타입 아래 함수를 특정짓는 네임스페이스 연산자
    let mut str = String::from("hello");
    str.push_str(", world");
    println!("{}", str); // hello world
}
cs

  위 예제에서 볼 수 있듯이 String타입은 변할 수 있지만 스트링 리터럴은 변할 수 없다. 이 차이는 두 타입이 메모리를 사용하는 방식의 차이에서 비롯된다.

  스트링 리터럴과 String 타입의 차이

    스트링 리터럴의 경우 문자열이 변경되지 않을 것을 전제로 한다. 따라서 내용물을 컴파일 타임에 알 수 있고 텍스트가 최종 파일에 직접 하드코딩 되어 있다.

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

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

      2. String의 사용이 끝났을 때 OS에게 메모리를 반납할 방법이 필요하다.

    첫번째 기능의 경우 String::from을 호출해 구현 부분에서 필요한 만큼의 메모리를 요청하면 된다.

    하지만 두번째 기능의 경우 러스트는 GC도 없고, 직접 메모리를 해제하지도 않는다. 즉, 러스트는 다른 방식으로 이 문제를 해결한다. 러스트는 변수가 소속된 스코프 밖으로 벗어나는 순간 자동으로 반납된다. 이를 위해 러스트는 drop이라는 함수를 '}'가 나올때 자동으로 호    출한다.

  변수와 데이터가 상호작용 하는 방법: move.

1
2
let x = 5;
let y = x;
cs

    위의 코드는 y에 x를 value로 복사한다. 그리고 5라는 값들이 스택에 push된다.

    하지만 아래의 예시처럼 런타임때 메모리를 할당받는 경우는 동작 방식이 다르다.

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

    이 코드에서 첫번째 줄은 다음과 같은 구조를 가지고 있다.(String은 메모리의 포인터, 길이, 용량 총 3개의 부분으로 이루어져 있다.)

    두 번째 줄이 실행되면 String 데이터가 복사된다. 여기서 복사는 스택에 존재하는 ptr, len, capacity가 복사된다는 의미이다. 따라서 다음과 같은 구조가 된다.

    이제 러스트의 메모리 해제 방식을 다시 떠올려 보자. 러스트는 변수가 스코프 밖으로 벗어나면 자동으로 drop 함수를 호출해 메모리를 해재한다. 따라서 위와 같이 s1과 s2가 같은 값을 가리키고 있을 경우 s1, s2가 스코프 밖으로 벗어나면 메모리가 두번 해제(double free) 오류가 발생한다. 이는 메모리 손상(memory corruption)의 원인이며 보안 취약성이 발생할 가능성이 존재한다. 이 문제를 해결 하기 위해 러스트는 할당된 메모리를 복사하는 대신 s1을 더이상 유효하지 않다고 간주한다. 이러면 s1이 스코프 밖으로 벗어나도 해제할 필요가 없어진다. 이는 새로운 변수가 같은 값을 가리킬때 기존 변수를 유효하지 않다고 간주한다는 점에서 얕은 복사와 차이가 발생한다. 따라서 러스트는 이를 이동(move)이라 한다.

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

    만약 데이터를 위와 같이 이동을 통해 스택 데이터만 복사하는 것이 아닌 힙 데이터까지 깊은 복사를 하고 싶다면 clone을 사용하면 된다.

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

    변수와 데이터가 상호작용하는 방법: 복사

      러스트는 정수형과 같이 스택에 저장할 수 있는 타입에 대해 달수 있는 Copy 트레잇이라 불리는 특별한 어노테이션을 가지고 있다. 만약 어떤 타입이 Copy 트레잇을 가지고 있으면 대입 과정 후에도 예전 변수를 계속 사용할 수 있다. 이런 Copy가 가능한 타입으로는 스칼라 타입과 스칼라 타입들의 묶음(스칼라들로만 이루어진 튜플)이 있다.

  소유권과 함수

    함수에게 변수를 넘기는 것은 이동을 사용한다. 따라서 인자로 변수를 넘기면 그 인자의 소유권은 호출한 함수로 이동한다. 함수가 값을 반환하면 그 값의 소유권은 반환값을 받는 함수로 이동한다. 복사가 되는 변수의 경우 '변수와 데이터가 상호작용하는 방법: 복사'에서 설명한 것과 같이 사용된다.

 

참조자(references)와 빌림(borrowing)

  만약 함수에게 값을 사용할 수 있도록 하되 소유권은 갖지 않도록 하고 싶다면 참조자를 사용하면 된다.

1
2
3
4
5
6
7
8
9
10
11
fn main() {
    let s1 = String::from("hello");
    // &가 참조자
    let len = calculate_length(&s1);
    println!("{}", len);
}
 
fn calculate_length(s: &String) -> usize {
    s.len()
}
 
cs

  이 코드는 다음과 같은 구조를 가지고 있다.

  즉, 참조자는 소유권에 대한 참조만을 한다. 변수 s가 유효한 스코프의 범위는 일반 함수 파라미터의 스코프와 동일하지만 소유권을 갖고 있지 않기 때문에 참조자가 스코프 밖으로 벗어나도 참조자가 가리키는 값을 버리지 않는다. 또 한 실제 값 대신 참조자를 파라미터로 갖고 있는 함수는 소유권을 갖고 있지 않기 때문에 소유권을 되돌려주기 위해 값을 다시 반환할 필요도 없다.

  만약 빌린 것을 수정하려 한다면 에러가 발생한다. 이는 참조자 역시 불변이기 때문이다.

1
2
3
4
5
6
7
8
9
10
11
fn main() {
    let s1 = String::from("hello");
    // &가 참조자
    change(&s1);
}
 
fn change(s: &String) {
    // error: cannot borrow immytable borrewed content `*s` as mutable
    s.push_str(", world");
}
 
cs

 

가변 참조자(mutable references)

  만약 참조자를 사용해 값을 바꾸고 싶다면 가변 참조자를 사용하면 된다.

1
2
3
4
5
6
7
8
9
10
11
fn main() {
    let mut s1 = String::from("hello");
    // &가 참조자
    change(&mut s1);
}
 
fn change(s: &mut String) {
    // error: cannot borrow immytable borrewed content `*s` as mutable
    s.push_str(", world");
}
 
cs

  하지만 가변 참조자는 특정 스코프 내에서 특정한 데이터 조각에 대해 가변 참조자를 오직 한번만 사용할 수 있다.

1
2
3
4
5
6
fn main() {
   let mut s = String::from("hello");
   // error[E0499]: cannot borrow `s` as mutable more than once at a time
   let sr1 = &mut s;
   let sr2 = &mut s;
}
cs

  이런 제약 사항은 러스트가 컴파일 타임에 데이터 레이스(data race)를 방지하게 해준다. 데이터 레이스틑 다음과 같은 동작이 발생했을 때 나타나는 특정한 레이스 조건이다.

    1. 두 개 이상의 포인터가 동시에 같은 데이터에 접근한다.

    2. 그 중 적어도 하나의 포인터가 데이터를 사용한다.

    3. 데이터에 접ㅈ근하는데 동기화를 하는 어떠한 매커니즘도 없다.

  가변 참조자와 불변 참조자를 혼용할 경우도 위와 비슷한 규칙이 적용된다.

1
2
3
4
5
6
7
fn main() {
   let mut s = String::from("hello");
   let sr1 = & s;
   let sr2 = & s;
   // error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
   let sr3 = &mut s;
}
cs

  불변 참조자를 사용중일 때는 가변 참조자를 사용할 수 없다. 대신 데이터를 읽기만 하는 것은 다른 것들이 그 데이터를 읽는데에 어떤 영향도 주지 않기 때문에 여러개의 불변 참조자는 만들 수 있다.

 

댕글링 참조자(Dangling references)

  댕글링 포인터는 어떤 메모리를 가리키는 포인터를 보존하는 동안, 그 메모리를 해제해서 다른 개체에게 사용하도록 줄지도 모르는 메모리를 참조하고 있는 포인터를 의미한다.

  러스트에서는 컴파일러가 모든 참조자가 댕글링 참조자가 되지 않도록 보장해 준다.

1
2
3
4
5
6
7
8
9
10
fn main() {
    let reference_to_nothing = dangle();
}
 
// error[E0106]: missing lifetime specifier
fn dangle() -> &String {
    let s = String::from("hello");
 
    &s
}
cs

 

슬라이스(Slices)

  슬라이스는 참조처럼 소유권을 갖지 않는다. 슬라이스는 컬렉션 전체가 아닌 컬렉션의 연속된 일련의 요소들을 참조할 수 있게 한다.

  스트링을 입력받아 해당 스트링의 첫번째 단어를 반환해야 한다고 해보자. 그리고 이 문제를 다음과 같은 코드로 해결해 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
fn main() {
    let mut s = String::from("hello world");
    let word = get_first_world(&s);
    s.clear(); // String을 빈 문자열로 만든다
    // word는 5라는 값을 가지고 있지만 이 값을 의미있게 사용할
    // 스트링이 존재하지 않는다.
    // word는 s의 상태와 연결되 있지 않다.
}
 
fn get_first_world(s: &String) -> usize {
    // 반복문 순회를 위해 바이트 배열로 변환
    let bytes = s.as_bytes();
 
    // iter()는 컬랙션의 각 요소를 반환
    // enumerate()는 iter의 결과값을 직접 반환하는 대신
    // 이를 감싸서 튜플의 일부로 만들어 반환
    // 튜플의 첫 요소는 인덱스, 두번째는 요소에 대한 참조값
    for (i, &item) in bytes.iter().enumerate() {
        // 바이트 리터럴 문법을 활용해 공백 문자를 찾는다.
        if item == b' ' {
            return i;
        }
    }
 
    s.len()
}
 
cs

  위 코드는 word와 s의 상태가 연결되 있지 않기 때문에 버그가 발생할 수 있다. 이 문제를 해결하기 위해서 스트링 슬라이스를 사용하면 된다.

 

스트링 슬라이스

  스트링 슬라이스는 String의 일부분에 대한 참조자이다.

1
2
let s2 = String::from("hello world");
let hello = &s[0..5]; // "hello"
cs

 

  대괄호 내에는 [시작 인덱스..끝인덱스 + 1]를 통해 범위를 특정할 수 있다. 내부적으로는 시작 위치와 슬라이스의 길이를 저장한다. 따라서 let world = &s[6..11]이면 다음과 같은 구조를 갖는다.

  만약 시작을 문자열의 처음, 끝을 문자열의 마지막으로 정한다면 각각 처음과 끝의 인덱스를 생략할 수 있다.

1
2
let hello = &s[..5]; // 처음부터
let hello = &s[2..]; // 인덱스 2부터 끝까지
cs

  이를 활용해 get_first_world의 코드를 수정하면 빌림 규칙으로 인해 에러가 발생하게 된다. 빌림 규칙때문에 불변 참조자를 만들면 가변 참조자를 만들지 못한다. clear 함수가 String을 수정할때 이 함수는 가변 참조자를 가지려 하고 실패하게 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
fn main() {
    let mut s = String::from("hello world");
    let word = get_first_world(&s);
    s.clear(); // Error
}
 
// &str은 스트링 슬라이스를 나타내는 
fn get_first_world(s: &String) -> &str {
    let bytes = s.as_bytes();
 
    for (i, &item) in bytes.iter().enumerate() {
        // 바이트 리터럴 문법을 활용해 공백 문자를 찾는다.
        if item == b' ' {
            return &s[0..i];
        }
    }
 
    &s[..]
}
 
cs

 

스트링 리터럴은 슬라이스이다.

  스트링 리터럴은 바이너리 안에 저장된다. 따라서 스트링 리터럴의 타입은 &str이다. &str은 불변 참조자 이므로 스트링 리터럴은 불변이다.

 

배열의 슬라이스

  배열의 슬라이스 역시 스트링 슬라이스와 같이 동작한다. 아래의 예제의 경우 슬라이스는 &[i32] 타입을 갖는다.

1
2
let a = [1234];
let slice = &a[1..3];
cs