Understanding Ownership

이 포스트는 Rust를 처음 공부하면서 정리한 내용입니다. 해당 포스트는 개인적으로 기억하기 위한 메모 성격에 가깝습니다. “러스트 프로그래밍 공식 가이드 (2018, 제이펍)” 서적을 참고하였으며, 러스트 공식 사이트 에서 책과 동일한 내용을 찾을 수 있습니다. 따라서 더 자세한 내용을 찾으시면 위의 링크를 참고하시면 좋습니다.

소유권은 Rust의 핵심적인 기능입니다. 일반적인 프로그래밍 언어들의 메모리 관리 기법은 크게 두 가지로 나뉠 수 있습니다. C언어와 같이 프로그래머가 명시적으로 메모리를 할당하고 해제하는 방법이 있고, 다른 하나는 가비지 컬렉터 (Garbage Collector)를 두어 사용되지 않는 메모리를 찾아 자동으로 해제해주는 방법이 있습니다. 반면 Rust는 소유권(Ownership)이라는 새로운 개념을 제시합니다. 소유권은 컴파일러가 컴파일 시점에 메모리를 다양한 규칙을 사용하여 검사하는 시스템입니다. 따라서 소유권과 관련된 기능은 프로그램 실행에는 영향을 미치지 않습니다.

소유권 규칙

기본적으로 적용되는 소유권 규칙은 다음과 같습니다.

  • Rust가 다루는 각각의 값은 소유자(Owner)라는 변수를 가지고 있습니다.
  • 특정 시점에서 값의 소유자는 단 하나뿐입니다.
  • 소유가자 범위(Scope)를 벗어나는 경우 그 값은 제거됩니다.

아래의 간단한 예제를 통해 소유권을 직관적으로 이해할 수 있습니다.

{ // 1. 변수가 아직 선언되지 않았으므로 변수는 유효(valid)하지 않습니다.
    let s = "hello"; // 2. 변수 s는 이 시점에서부터 유효합니다.
} // 3. 범위(Scope)를 벗어나므로 더 이상 s는 유효하지 않습니다.

Rust는 GC와 다르게 메모리 할당과 해제를 다른 방식으로 수행합니다. 변수는 할당된 범위를 넘어가는 순간 자동적으로 해제됩니다. 즉, 위의 예제에서 시점 3에서는 더 이상 변수 s를 사용할 수 없게 됩니다.

변수가 범위를 벗어나게 되면 Rust는 drop이라는 함수를 자동적으로 호출하게 됩니다. drop 함수는 개발자가 메모리를 해제하게 하는 동작을 정의해 둔 함수입니다.

아래 예시에서 부터는 std::string::String 을 사용하여 설명합니다. 참고 자료는 아래와 같습니다.

1. 이동(Move)

std::string::String은 아래와 같이 문자열만 덩그러니 저장하고 있는 타입이 아닙니다. 문자열 내용을 저장하는 포인터, 길이, 용량 등의 정보를 같이 저장하고 있습니다. 기타 메타데이터는 스택(Stack)에 할당이 되어있을 것이고, 문자열이 할당된 곳은 힙(Heap)이겠지요.

let s1 = String::from("Hello Rust!");
let s2 = s1;

println!("s1 = {}, s2 = {}", s1, s2);
// Error 발생!

위의 코드를 예시로 들어보겠습니다. 변수 s1을 s2 변수에 대입하고 있으므로 String 타입의 데이터가 복사 될 것입니다. 이때 String 타입은 문자열 포인터 외의 다양한 정보를 포함하고 있다고 했으므로 기타 정보까지 같이 복사 될 것이라고 기대할 수 있습니다.

하지만 실제로는 s1가 가리키고 있는 포인터 값만 복사가 되면서 s1과 s2의 내부 문자열 포인터는 같은 곳을 가리키고 있는 형태가 됩니다. 앞서 범위를 벗어나면 자동으로 drop 함수가 호출되면서 해당 변수가 가리키고 있던 힙 메모리를 정리합니다. 따라서 s1과 s2가 동시에 같은 메모리 주소를 해제하기 때문에 이중 해제 에러 (double free error)가 발생하게 되고, 심각한 메모리 안정성 버그 중 하나가 됩니다. 이 현상은 Rust 뿐만 아니라 C언어에서도 흔히 일어나는 실수입니다. C 계열 언어의 경험이 있다면 일종의 깊은 복사(deep copy)와 얕은 복사((shallow copy) 문제라고 접근하시면 됩니다.

Rust에서는 이러한 잠재적 보안 문제를 해결하고 메모리 안전성을 확보하기 위하여 s1이 s2로 대입되는 순간 s1을 무효화합니다. 할당된 값에 대한 소유권이 s2로 넘어가 버리면서 문자열 데이터는 그대로 남아있게 되는데, 이를 이동(Move)했다 라고 표현합니다.

2. 복제(Clone)

만일 힙에 할당되어 있는 문자열을 그대로 복사해오고 싶다면 clone이라는 공통 메서드를 사용하면 됩니다. 물론 이 작업은 할당된 메모리 크기에 따라 Workload가 다를 수 있습니다.

let s1 = String::from("Hello Rust!");
let s2 = s1.clone();

println!("s1 = {}, s2 = {}", s1, s2);
// 오류 없음

3. 복사(Copy)

앞서 설명한 것과 다르게 한가지 예외가 있습니다. 아래의 코드는 정상적으로 컴파일되며 오류를 발생시키지 않습니다.

let x = 10;
ley y = x;

println1!("x = {}, y = {}", x, y); // No error!

위에서 언급한 것과 다르게 명시적으로 clone() 메서드를 호출하지 않았는데도 x와 y 변수 모두 유효합니다. 즉, x의 값이 y로 이동(Move)되지 않은 상태입니다.

Rust는 정수형과 같은 타입은 컴파일 시점에 이미 그 크기를 알 수 있습니다. 또한 스택에 저장되어 있기 때문에 값을 복사하는 행위가 큰 부담이 아닙니다. 따라서 굳이 x를 무효화 할 이유가 없습니다. 따라서 위의 예시 코드는 clone() 함수를 호출하는 것과 차이점이 없습니다.

Rust에서는 스택에 저장되는 기본 타입형에는 Copy 트레이트(Trait)라는 특성을 부여합니다. 트레이트에 따른 특성은 아래와 같습니다.

  • Copy Trait 적용된 경우: 이전 변수를 새 변수에 할당해도 무효화 하지 않습니다.
  • Drop Trait 적용된 경우: 이전 변수를 새 변수에 할당하면 이전 변수는 무효화됩니다.

만일 어느 타입, 또는 그 타입 일부에 Drop Trait가 적용되어 있다면 Copy Trait를 적용할 수 없습니다. Trait에 관련된 내용은 추후 정리하도록 하겠습니다.

Copy Trait가 적용된 타입은 러스트 문서에서 찾을 수 있습니다. 일반저으로 스칼라 값과 Copy Trait가 적용된 타입을 포함하는 튜플에는 에는 Copy Trait가 적용된다고 생각하시면 됩니다. 목록은 여기에서 찾을 수 있습니다.

소유권과 함수

함수 인자

함수를 호출할 때 인수로 인자를 넘기는 경우에도 값의 복사나 이동이 일어납니다. 즉, 소유권에 대한 변동이 생깁니다.

아래의 예시를 통해 직관적으로 이해할 수 있습니다.

fn takes_ownership(some_string: String) {
    // 변수 some_string이 범위 내에 생성됩니다.
    println!("{}", some_string);
}

fn makes_copy(some_integer: i32) {
    // 변수 some_integer이 범위 내에 생성됩니다.
    println!("{}", some_integer);
}

fn main() {
    let s1 = String::from("Hello Rust!!");
    // 이 시점에서 s1은 유효합니다.
    takes_ownership(s1); // 함수의 인자로 넘길 때 s1의 소유권은 함수로 이동합니다.
    // 함수가 끝날 때 범위(scope)를 벗어나므로 s1 (some_string)의 drop 함수가 호출됩니다.
    // 따라서 some_string이 차지하고 있던 메모리가 해제되어
    // 이 시점에서는 더 이상 s1은 유효하지 않습니다.

    let x = 19;
    // 이 시점에서 x는 유효합니다.
    makes_copy(x); // 함수의 인자로 넘길 때 x의 소유권은 함수로 이동합니다.
    // 하지만 i32 타입은 복사를 수행하기 때문에 이 시점에서 x는 유효합니다.
}

함수 리턴값과 범위

함수의 리턴값도 소유권을 이전합니다.

fn main() {
    let s1 = gives_ownership();
    // gives_ownership 함수 리턴값의 소유권이 s1으로 이전된다.

    let s2 = String::from("hello");
    let s3 = takes_and_gives_back(s2); 
    // s2의 소유권이 takes_and_gives_back 으로 이전되고
    // 그대로 리턴하면서 다시 s3로 이전된다.

} // s1과 s3의 변수가 drop 함수가 호출된다. 
// s2는 s3로 소유권이 이전된 상태이므로 s2는 아무일도 일어나지 않는다.

fn gives_ownership() -> String {
    let some_string = String::from("hello"); 
    some_string
    // some_string이 만들어지고 리턴되면서 호출부로 소유권이 이전된다.
}

fn takes_and_gives_back(a_string: String) -> String {
    a_string
}

참조와 대여 (References and 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()
}

위의 앰퍼센트(Ampersands, &)가 참조(Reference)입니다. 참조는 s1 값을 읽을 수는 있지만 소유권은 가져오지 않습니다. 소유권이 없으므로 범위를 벗어날 때 drop 함수가 호출되지 않습니다. 함수의 시그니쳐(함수, 인수, 리턴값 등을 정의하는 부분)에도 & 기호를 사용하여 참조 타입임을 표시합니다.

이렇게 함수의 매개변수로 함조를 전달하는 것을 대여(Borrowing)이라고 합니다. 위의 예시 코드는 기본적으로 불변 참조를 전달하고 있습니다. 따라서 가변참조는 아래와 같이 전달합니다.

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

    change(&mut s);
}

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

위와 다른 점은 변수 s를 mut 키워드로 가변으로 만든 후 &mut s와 같이 가변 참조를 생성하면 됩니다. 단, 특정 데이터에 대한 가변 참조는 특정 범위 내에 하나만 존재해야 합니다. 가변 참조가 복수개 존재하는 경우 데이터 경합 (race condition)이 발생하기 때문에 Rust에서는 이러한 경합이 발생하는 코드의 컴파일을 허용하지 않습니다.

또한 이미 불변 참조를 사용하는 경우에도 가변 참조를 허용하지 않습니다. 불변 참조를 사용하고 있다는 의미는 값이 변하면 안되는 것을 의미하기 때문입니다.

Rust에서는 이러한 제약 조건이 있으나 사전에 문제가 될 가능성을 차단합니다. 따라서 데이터가 왜 예상한 값을 가지고 있지 않은지 추적할 필요가 없게 됩니다.

  • 어느 한 시점에 코드는 하나의 가변 참조 또는 여러 개의 불변 참조를 생성할 수는 있지만, 둘 모두를 생성할 수는 없습니다.
  • 참조는 항상 유효해야 합니다. (죽은 참조는 컴파일 불가능)

슬라이스 타입 (Slices)

슬라이스는 파이썬의 문자열 슬라이싱을 생각하면 편합니다. Rust에서의 슬라이스 또한 소유권을 갖지 않는 타입입니다. 슬라이스를 사용하면 컬렉션 전체가 아니라 연속된 일부분 요소들을 참조할 수 있습니다. 아래의 문자열 슬라이싱 예시를 들어보겠습니다.

let s = String::from("Hello Rust!");

let hello = &s[0::5];
let rust = &s[6..10];

슬라이싱을 사용하는 용법은 아래와 같습니다.

[ starting_index..ending_index ]

내부적으로는 슬라이스는 ending index부터 stargting index를 뺀 만큼의 데이터를 저장하는 구조체입니다. 아래와 같은 용법은 파이썬과 비슷합니다.

let s = String::from("Hello Rust!");

let slice = &s[..2]; // 0번 인덱스 부터 시작
let slice = &s[3..]; // 3번 인덱스부터 끝까지 슬라이스
let slice = &s[..]; // 전체 문자열 슬라이스

슬라이스 참고

참고로, 문자열 리터럴은 슬라이스입니다. 아래의 s 타입은 &str 입니다. 즉, 바이너리의 어느 한 지점을 가리키는 슬라이스가 됩니다.

let s = "Hello Rust!";

또한 보편적으로 사용 가능한 슬라이스도 존재합니다.

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

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

태그:

카테고리:

업데이트: