[Rust] 소유권이란?
포스트
취소

[Rust] 소유권이란?


Title


소유권 이해하기

소유권은 Rust의 가장 특별한 특성이다. 이 특성은 Rust 언어의 다른 부분들에도 큰 영향을 미친다. Rust는 가비지 컬렉터를 사용하지 않으면서도 메모리의 안전성을 보장하기 위해 소유권 개념을 도입했다. 따라서 소유권이 어떻게 작동하는지 파악하는 것은 매우 중요하다.


개요

소유권은 Rust 프로그램에서 메모리 관리의 규칙들을 정의하는 개념이다. 모든 프로그램은 런타임 중 메모리를 어떻게 사용할지 관리해야 한다. 어떤 언어들은 가비지 컬렉션을 통해 사용되지 않는 메모리를 주기적으로 관리한다. 다른 언어들은 개발자가 메모리를 직접 할당하고 해제해야 한다.

Rust는 이러한 방법들과는 다른 방법을 택한다. Rust의 메모리 관리는 컴파일러가 검사하는 소유권 시스템의 규칙을 통해 이루어진다. 이 규칙들 중 하나라도 위반되면 프로그램은 컴파일 되지 않는다.

소유권에 대한 개념은 많은 개발자들에게 생소한 내용일 수 있다. 하지만 Rust와 그 소유권 시스템을 잘 이해하면, 보다 안전하고 효율적인 코드 작성이 가능해진다.

소유권을 이해하는 것은 Rust의 독특한 특징을 파악하는 데 도움이 된다. 이번 장에서는 예제를 통해 이를 설명하며, 특히 문자열이라는 기본적인 자료구조를 중심으로 소유권에 대해 살펴볼 것이다.

스택과 힙

많은 프로그래밍 언어들은 개발자가 스택과 힙을 자주 고려하지 않도록 설계되었다. 그러나 Rust와 같은 시스템 프로그래밍 언어에서는 값이 스택이나 힙에 저장되는지에 따라 언어의 동작과 결정이 크게 영향받는다.

스택과 힙은 런타임 중에 사용할 수 있는 메모리 영역이지만, 구조와 동작 방식이 서로 다르다. 스택은 마지막으로 들어온 데이터가 처음으로 나가는 (LIFO, Last In, First Out) 방식으로 동작한다. 접시 더미를 예로 들면, 새 접시를 위에 놓고 필요할 때 맨 위의 접시를 가져간다. 데이터를 스택에 추가하는 것을 “푸시“라고 하며, 제거하는 것을 ““이라고 한다. 스택에 저장되는 데이터는 크기가 고정되어야 한다. 크기가 변동될 수 있는 데이터는 힙에 저장해야 한다.

힙은 조금 더 유연하게 동작한다. 데이터를 힙에 저장할 때는 필요한 메모리 크기를 요청하고, 메모리 할당자는 적절한 크기의 빈 공간을 찾은 후 해당 주소, 즉 포인터를 반환한다. 이 과정을 “힙에 할당” 또는 간단히 “할당”이라고 부른다(스택에 값을 푸시하는 것은 할당으로 간주되지 않는다). 힙 포인터의 크기는 고정되어 있어 스택에 저장될 수 있지만, 실제 데이터에 접근하기 위해선 해당 포인터를 통해야 한다. 레스토랑에서의 예시를 생각해보면, 들어갈 때 당신의 그룹 인원을 말하면 웨이터는 해당 인원을 수용할 수 있는 빈 테이블을 찾아서 안내한다. 만약 당신의 그룹에 누군가 늦게 도착하면, 당신이 어디에 앉았는지 물어봐서 찾을 수 있다.

스택에 데이터를 푸시하는 것은 힙에 데이터를 할당하는 것보다 빠르다. 스택에서는 항상 맨 위의 위치에 데이터가 추가되기 때문에 할당자가 새로운 위치를 찾을 필요가 없다. 반면, 힙에서는 적절한 크기의 빈 공간을 찾고, 다음 할당을 위한 준비를 하는 등의 추가 작업이 필요하다.

힙의 데이터에 접근하는 속도는 스택에 비해 느리다. 이는 포인터를 통해 데이터에 접근해야 하기 때문이다. 현대의 프로세서는 메모리 접근 시 점프 횟수가 줄어들수록 더 빠르게 동작한다. 레스토랑에서 여러 테이블에 서빙하는 웨이터의 상황을 예로 들면, 한 테이블에서 모든 주문을 완료한 후 다음 테이블로 이동하는 것이 여러 테이블 사이를 번갈아 가며 주문을 받는 것보다 효율적이다. 비슷하게, 프로세서도 데이터가 서로 가까이 위치할 때 더 효율적으로 작업을 수행한다.

코드에서 함수를 호출할 때 함수에 전달된 값들(힙의 데이터를 가리키는 포인터 포함)과 함수 내의 지역 변수들은 스택에 푸시된다. 함수의 실행이 종료되면, 이 값들은 스택에서 팝되어 제거된다.

힙의 어떤 데이터가 어디에서 사용되는지, 중복 사용을 어떻게 최소화할지, 더 이상 사용되지 않는 데이터를 어떻게 처리할지 등은 소유권의 개념을 통해 해결할 수 있다. 소유권을 제대로 이해하면 스택과 힙에 대한 깊은 고민 없이도 안전하게 코드를 작성할 수 있으며, 소유권의 주된 목적은 힙 데이터의 관리에 있음을 이해하면 그 동작 원리를 더 잘 이해할 수 있을 것이다.


소유권 규칙

먼저 Rust의 소유권 규칙에 대해 알아보자:

  • Rust의 모든 값은 소유자를 가진다.
  • 한 번에 단 하나의 소유자만 존재할 수 있다.
  • 소유자가 유효 범위에서 벗어나게 되면, 해당 값은 삭제된다.

변수의 유효 범위

앞으로 대부분의 예제 코드에서 fn main() { 부분을 생략할 것이다. 이제부터는 아래 코드들을 직접 main 함수 내에 작성하도록 하자. 이렇게 하면 예제 코드가 더욱 간결해져, 불필요한 부분보다는 핵심 내용에 더욱 집중할 수 있다.

소유권의 개념을 설명하기전에, 일부 변수의 유효 범위에 대해 알아보자. 여기서 범위란 프로그램 내에서 특정 항목이 유효한 지역을 의미한다. 아래 변수를 확인해보자:

1
let s = "hello";

변수 s는 문자열 리터럴을 참조하며, 문자열 값은 프로그램 코드에 하드코딩되어 있다. 해당 변수는 선언된 시점부터 그 변수가 포함된 유효 범위가 종료될 때까지 유효하다. 아래 코드는 변수 s의 유효 범위를 주석으로 설명한 것이다.

1
2
3
4
5
6
7
// 변수와 그 유효 범위

{                      // s는 아직 유효하지 않다. 선언되지 않았기 때문이다.
    let s = "hello";   // s는 이 시점부터 유효하다.

    // s를 사용한 작업 수행
}                      // 이 유효 범위가 종료되면, s도 유효하지 않게 된다.

우리가 여기서 주의해야 할 두 가지 주요 시점은 다음과 같다:

  • s가 유효 범위 내로 있다면, s는 유효하다.
  • s가 유효 범위를 벗어나면, 더 이상 유효하지 않게 된다.

이 부분은 다른 프로그래밍 언어에서 변수의 유효 범위와 유사한 개념이다. 이제 String 타입을 사용하여 이 개념을 더욱 깊게 탐구해보자.


문자열 타입

소유권의 규칙을 설명하려면, 이전 자료형 섹션에서 다룬 것보다 더 복잡한 자료형이 필요하다. 이전에 언급한 타입들은 정해진 크기를 가지므로 스택에 저장할 수 있고, 해당 범위가 끝나면 스택에서 제거된다. 또한 다른 부분의 코드에서 같은 값을 다른 범위에서 사용해야 할 경우 빠르고 간단하게 복사하여 새롭고 독립된 인스턴스를 만들 수 있다. 그러나 우리는 힙에 저장된 데이터에 주목하고 Rust가 그 데이터를 언제 정리하는지 알아보고자 한다. 이러한 측면에서 String 타입은 좋은 예이다.

또한 소유권과 관련된 String의 특성에 주목할 것이다. 이 특성들은 표준 라이브러리가 제공하는 다른 복잡한 자료형 또는 개발자가 직접 만든 자료형에도 적용된다.

우리는 이미 문자열 리터럴에 대해 알아보았다. 문자열 리터럴은 프로그램에 직접 적힌 문자열 값을 의미한다. 문자열 리터럴은 편리하지만, 모든 상황에서 텍스트를 사용하는 것에 적합하지 않다. 그들은 “변경할 수 없다”는 제한 사항도 있고, 코드를 작성하는 시점에서 모든 문자열 값을 알 수 없는 경우도 있기 때문이다. 예를 들어, 유저로부터 입력을 받아 데이터를 저장하려면 어떻게 해야 할까? 이러한 경우를 위해 Rust는 두 번째 문자열 타입인 String을 제공한다. 이 타입은 컴파일 타임에는 알 수 없던 텍스트 양을 힙에 할당하여 관리한다. 문자열 리터럴로부터 String을 다음과 같이 from 함수를 통해 생성할 수 있다:

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

이 두 콜론 :: 연산자는 String 타입의 네임스페이스 안의 from 함수를 호출할 수 있도록 한다.

이렇게 생성된 String 타입의 문자열은 가변 변수로도 만들 수도 있다:

1
2
3
4
5
let mut s = String::from("hello");

s.push_str(", world!"); // push_str() 함수는 String에 리터럴을 추가한다.

println!("{}", s); // 'hello, world!'를 출력한다.

String은 어떻게 변경 가능한 것이고, 또 리터럴은 변경 불가능한 것일까? 이는 두 타입이 메모리를 처리하는 방식의 차이 때문이다.


메모리와 할당

문자열 리터럴은 컴파일 시간에 이미 내용이 결정되어 최종 실행 파일에 직접 포함된다(하드코딩). 이것이 문자열 리터럴이 빠르고 효율적인 이유이다. 그러나 이러한 특성 때문에 문자열 리터럴은 불변이다. 즉, 실행 파일에는 컴파일 시점에 크기가 정해지지 않거나 런타임 중에 크기가 변할 수 있는 텍스트를 메모리 블록으로 포함시킬 수 없다.

String 타입은 변경 가능하며 확장할 수 있는 텍스트 조각을 지원하기 위해, 컴파일 타임에 크기를 알 수 없는 힙 메모리에 일정 공간을 할당하여 내용을 저장한다. 이것은 다음과 같은 의미를 갖는다:

  • 메모리는 런타임 중에 메모리 할당자에게 요청되어야 한다.
  • String 사용이 끝났을 때 이 메모리를 할당자에게 반환하는 방법이 필요하다.

첫 번째 부분은 우리에게 달려있다. 우리가 String::from을 호출하면, 그 구현에서 필요한 메모리를 요청한다. 이 과정은 대부분의 프로그래밍 언어에서도 일반적인 동작이다.

그러나 두 번째 부분에서는 다른 점이 있다. 가비지 컬렉터(GC)가 있는 언어에서는 GC가 더 이상 사용되지 않는 메모리를 추적하고 정리하기 때문에, 개발자는 이 부분에 대해 신경 쓸 필요가 없다. 하지만 GC가 없는 대부분의 언어에서는, 메모리가 더 이상 사용되지 않을 때를 식별하고 메모리를 명시적으로 해제하는 코드를 호출하는 것이 개발자의 책임이다. 이를 올바르게 처리하는 것은 전통적으로 어려운 프로그래밍 문제였다. 메모리를 해제하는 것을 잊으면 메모리 누수가 발생한다. 너무 일찍 해제하면 유효하지 않은 변수를 가지게 된다. 두 번 해제하면 버그가 발생한다. 따라서 할당(allocate)과 해제(free)를 정확히 한 번씩 짝지어 수행해야 한다.

Rust는 이 문제에 대해 다른 접근 방식을 취한다. 변수가 소유한 메모리는 그 변수가 유효 범위를 벗어나면 자동으로 반환된다. 예를 들어, 문자열 리터럴 대신 String을 사용하는 유효 범위 예제를 살펴보자:

1
2
3
4
5
{
    let s = String::from("hello"); // s는 이 시점부터 유효하다.

    // s를 사용한 작업 수행
}                      // 이 유효 범위가 종료되면, s도 유효하지 않게 된다.

s가 유효 범위를 벗어날 때가 String으로 할당한 메모리를 할당자에게 반환하기 자연스러운 시점이다. 변수가 유효 범위를 벗어날 때 Rust는 우리를 대신해 특별한 함수를 호출한다. 이 함수를 drop이라고 하며, 메모리를 반환하는 코드를 여기에 배치할 수도 있다. Rust는 닫는 중괄호에서 자동적으로 drop 함수를 호출한다.

참고: C++에서는 어떤 객체의 수명이 끝날 때 리소스를 해제하는 패턴을 Resource Acquisition Is Initialization (RAII)라고 한다. RAII 패턴에 익숙한 사람은 Rust의 drop 함수를 쉽게 이해할 수 있을 것이다.

이 패턴은 Rust에서 코드를 작성하는 방식에 큰 영향을 준다. 지금은 단순해보일 수 있지만, 힙에 할당한 데이터를 여러 변수에서 활용하려고 할 때는 코드의 동작이 복잡해질 수 있다. 이러한 복잡한 상황에 대해 바로 알아보자.


변수와 데이터: 이동과의 상호작용

Rust에서는 여러 변수가 동일한 데이터를 다양한 방식으로 활용할 수 있다. 아래 예제에서 정수를 사용한 예제를 살펴보자.

1
2
3
4
// 변수 `x`의 정수 값을 `y`에 할당

let x = 5;
let y = x;

이 코드는 “값 5x에 바인딩하고, x의 값을 복사하여 y에 바인딩한다.”로 해석할 수 있다. 이제 두 변수, xy가 있으며 둘 다 5의 값을 가진다. 이는 정수가 고정 크기의 간단한 값이기 때문에 스택에 두 개의 5 값을 넣는 것이다.

이제 String 버전을 살펴보자:

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

이 코드는 매우 유사해 보이므로 같은 방식으로 동작할 것이라 추측하기 쉽다. 두 번째 줄에서는 s1의 값을 s2에 복사하는 것처럼 보인다. 그러나 실제로는 이렇게 동작하지 않는다.

그림 1-1을 참조하여 String이 내부에서 어떻게 동작하는지 확인해보자. String은 왼쪽에 표시된 세 부분으로 구성된다: 문자열의 내용을 보유하는 메모리를 가리키는 포인터, 길이, 그리고 용량이다. 이 데이터 그룹은 스택에 저장된다. 오른쪽은 내용을 보유하는 힙의 메모리이다.

ownership0

[그림 1-1] s1에 바인딩된 값 "hello"를 보유하는 String의 메모리 표현

길이는 String의 내용이 현재 사용 중인 메모리를 바이트 단위로 나타낸다. 용량은 할당자로부터 String이 받은 메모리의 총량이다. 길이와 용량의 차이는 중요하지만, 여기서는 용량을 무시해도 된다.

s1s2에 할당할 때, String 데이터는 복사된다. 즉, 스택에 있는 포인터, 길이, 용량을 복사한다. 그러나 포인터가 참조하는 힙의 데이터는 복사하지 않는다. 다시 말해, 메모리의 데이터 표현은 그림 1-2와 같이 보인다.

ownership1

[그림 1-2] s1의 포인터, 길이, 용량의 복사본을 가진 변수 s2의 메모리 표현

이러한 표현은 Rust가 힙 데이터를 복사하면 어떻게 될지를 보여주는 그림 1-3과는 다르다. Rust가 이렇게 동작했다면, 힙의 데이터가 큰 경우 s2 = s1 연산은 런타임 성능 면에서 매우 비용이 많이 들 수 있다.

ownership2

[그림 1-3] Rust가 힙 데이터를 복사하는 경우 s2 = s1이 수행할 수 있는 다른 가능성

앞서 언급한 것처럼, 변수가 유효 범위를 벗어나면 Rust는 drop 함수를 자동으로 호출하여 해당 변수의 힙 메모리를 해제한다. 그러나 그림 1-2는 두 데이터의 포인터가 같은 위치를 가리키는 것을 보여준다. 이것은 큰 문제가 될 수 있다. s2s1이 유효 범위를 벗어나면 두 변수 모두 같은 메모리 영역을 해제하려 할 것이다. 이것은 두 번의 메모리 해제, 즉 이중 해제라는 버그를 유발한다.

이 문제를 피하기 위해 Rust는 s1s2에 할당한 다음 s1을 사용할 수 없도록 만든다. 이렇게 하면 s1을 자동으로 해제할 수 없으므로 이중 해제 문제는 발생하지 않는다. 아래 예제는 s2 할당 후 s1 사용을 시도하는 것을 보여준다.

아래 코드는 컴파일되지 않는다.

1
2
3
let s1 = String::from("hello");
let s2 = s1;
println!("{}, world!", s1);

이 코드는 컴파일되지 않는다. Rust가 s1을 더 이상 사용할 수 없게 했기 때문이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0382]: borrow of moved value: `s1`
 --> *src/main.rs*:5:28
  |
2 |     let s1 = String::from("hello");
  |         -- `s1` 타입이 `String`이므로 값이 이동되었습니다. `String``Copy` 트레잇을 구현하지 않습니다.
3 |     let s2 = s1;
  |              -- 여기서 값이 이동되었습니다.
4 |
5 |     println!("{}, world!", s1);
  |                            ^^ 이동 후에 여기서 값이 참조되었습니다.
  |
  = note: 이 에러는 `println` 매크로의 확장에서 기인한 `$crate::format_args_nl` 매크로에서 시작됩니다 (Nightly 빌드에서는 더 많은 정보를 위해 -Z macro-backtrace로 실행하십시오)
help: 성능 비용이 허용되는 경우 값의 복제를 고려하십시오
  |
3 |     let s2 = s1.clone();
  |                ++++++++

이 에러에 대한 상세한 정보는 rustc --explain E0382를 실행해서 확인하실 수 있습니다.
error: 이전의 에러로 인해 `ownership` 컴파일할 수 없습니다.

다른 언어를 사용하면서 얕은 복사깊은 복사라는 용어를 들어본 적이 있다면, 포인터, 길이, 용량을 복사하지 않고 데이터만 복사하는 것이 얕은 복사처럼 들릴 것이다. 그러나 Rust에서는 첫 번째 변수를 무효화하므로 얕은 복사 대신 이동(Move)이라는 용어를 사용한다. 이 예에서 s1s2로 이동되었다고 볼 수 있다. 그러므로 실제로 발생하는 일은 그림 4-4에서 볼 수 있다.

스택 위에 위치한 문자열들을 대표하는 s1s2 테이블, 그리고 둘 다 힙 위의 동일한 문자열 데이터를 가리키는 테이블이 세 개 있다. s1 테이블은 더 이상 유효하지 않기 때문에 회색으로 표시되었다. 그 결과, 힙 데이터에는 s2만이 접근할 수 있다.

ownership3

[그림 1-4] s1이 무효화된 후의 메모리 표현

이러한 방식으로 문제가 해결된다. s2만 유효하므로, s2가 범위 밖으로 나갈 때 메모리는 자동으로 해제된다.

Rust는 데이터의 깊은 복사를 자동으로 진행하지 않는, 설계적 선택을 하였다. 따라서, 자동으로 이루어지는 복사 작업이 런타임 성능에 부담을 주지 않는다는 사실을 알 수 있다.

변수와 데이터의 복제와 상호작용

String의 힙 데이터를 깊게 복사하길 원한다면, 스택 데이터가 아니라 힙 데이터를 대상으로 하는 복제(Clone) 메서드를 활용할 수 있다.

Clone 메서드의 사용 예는 다음과 같다:

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

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

이 코드는 예상대로 잘 실행되며, 힙에 위치한 데이터가 정상적으로 복사되는 것을 확인할 수 있다(그림 1-3의 동작을 명시적으로 생성).

clone 메서드를 사용할 때는, 해당 메서드가 어느 정도의 연산 비용이 발생하는지 인지하고 있어야 한다. 복제 과정에서 추가적인 처리가 수행되기 때문에, 이를 시각적으로 인지하는 것이 중요하다.


스택 전용 데이터: 복사

아직 논의되지 않은 내용이 있다. 아래의 코드는 정수를 사용한 예시로, 잘 작동한다:

1
2
3
4
    let x = 5;
    let y = x;

    println!("x = {}, y = {}", x, y);

이 코드는 앞서 학습한 내용과 일치하지 않아 보일 수 있다: clone을 호출하지 않았는데도 x는 여전히 유효하며 y로 이동하지 않았다.

이러한 현상의 이유는, 컴파일 타임에 크기가 확정된 정수와 같은 자료형은 스택에 완전 저장되기 때문이다. 따라서 실제 값들을 빠르게 복사할 수 있다. 이것은 y 변수를 생성한 이후에도 x가 여전히 유효하게 유지될 수 있음을 의미한다. 여기에서는 깊은 복사와 얕은 복사 사이에 차이가 없으므로, clone을 호출해도 얕은 복사와 차이가 없다.

Rust는 스택에 저장되는, 정수와 같은 자료형에 사용할 수 있는 Copy라는 특별한 트레잇을 가지고 있다. 어떤 자료형이 Copy 트레잇을 구현하면, 그 자료형을 사용하는 변수는 이동되지 않고 복사되므로, 다른 변수에 할당된 이후에도 유효하다.

만약 타입 또는 그 일부가 Drop 트레잇을 구현했다면, Rust는 그 타입에 Copy 트레잇을 적용할 수 없게 한다. 만약 값이 유효 범위를 벗어날 때 특별한 처리가 필요한 자료형에 Copy 트레잇을 추가하려고 하면 컴파일 에러가 발생한다.

그렇다면 어떤 자료형들이 Copy 트레잇을 구현할 수 있을까? 해당 타입의 공식 문서에서 확인할 수 있지만, 기본적으로 단순한 스칼라 값들은 Copy 트레잇을 구현할 수 있으며, 할당이나 리소스 형태의 것들은 Copy 트레잇을 구현할 수 없다. Copy 트레잇을 구현하는 몇몇 자료형은 다음과 같다:

  • 모든 정수 타입, 예를 들면 u32.
  • 불리언 타입 bool로, 값은 truefalse.
  • 모든 부동 소수점 타입, 예를 들면 f64.
  • 문자 타입인 char.
  • Copy 특성을 구현하는 타입만을 포함하는 튜플. 예를 들어, (i32, i32)Copy를 구현하지만, (i32, String)은 그렇지 않다.

소유권과 함수

함수로 값을 전달하는 방식은 변수에 값을 할당하는 것과 매우 비슷하다. 변수의 값을 함수에 전달하면, 그 값은 변수를 할당할 때와 같이 이동하거나 복사된다. 아래 코드는 변수가 유효 범위에 들어가거나 나올 때 어떤 일이 일어나는지를 주석으로 설명해주는 예제이다.

파일명: src/main.rs

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
// 소유권과 유효 범위가 주석으로 표시된 예제

fn main() {
    let s = String::from("hello"); // s가 유효 범위에 진입한다

    takes_ownership(s);            // 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이 유효 범위를 벗어나면서 메모리 해제가 이루어진다.

fn makes_copy(some_integer: i32) { // some_integer가 유효 범위에 진입한다
    println!("{}", some_integer);
} // some_integer는 유효 범위를 벗어나지만 특별한 처리는 필요 없다.

takes_ownership을 호출한 후에 s를 사용하려고 하면, Rust는 컴파일 타임에 에러를 발생시킨다. 이러한 정적 검사는 우리의 실수를 방지해 준다. main 함수 안에서 sx를 사용하는 코드를 추가해봄으로써 어디서 사용할 수 있는지, 그리고 어디서 소유권 규칙으로 인해 사용할 수 없게 되는지 직접 확인해보자.

반환 값과 유효 범위

우리는 반환 값의 소유권도 이동시킬 수 있다. 아래의 코드는 값을 반환하는 함수를 어떻게 사용하는지를 보여준다.

파일명: src/main.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 반환 값의 소유권 이동에 관한 예제

fn main() {
    let s1 = gives_ownership();        // gives_ownership의 반환 값이 s1로 이동한다

    let s2 = String::from("hello");    // s2가 유효 범위에 들어왔다

    let s3 = takes_and_gives_back(s2); // s2는 함수로 이동하고, 그 반환 값은 s3로 이동한다
} // s3는 유효 범위를 벗어나고 메모리 해제가 이루어진다.
  // s2는 이동되었으므로 아무런 처리도 필요하지 않다.
  // s1도 유효 범위를 벗어나면서 메모리 해제된다.

fn gives_ownership() -> String {        
    let some_string = String::from("yours"); // some_string이 유효 범위에 들어왔다
    some_string                              // some_string이 반환되어
                                             // 호출한 함수로 이동한다
}

fn takes_and_gives_back(a_string: String) -> String { // a_string이 유효 범위에 들어왔다
    a_string  // a_string이 반환되어 호출한 함수로 이동한다
}

변수의 소유권은 항상 동일한 패턴을 따른다. 다른 변수에 값을 할당할 때, 값이 이동한다. 힙에 저장된 데이터를 포함하는 변수가 유효 범위를 벗어날 때, 그 변수의 소유권이 다른 변수로 이동하지 않았다면, drop을 통해 메모리에서 해제된다.

Rust에서는 여러 값을 한 번에 반환하기 위해 튜플을 사용할 수 있다. 아래의 코드 예제에서는 이를 보여준다.

파일명: src/main.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 매개변수의 소유권 반환에 관한 예제

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

    let (s2, len) = calculate_length(s1);

    println!("'{}'의 길이는 {}입니다.", s2, len);
}

fn calculate_length(s: String) -> (String, usize) {
    let length = s.len();  // len()은 String의 길이를 반환한다

    (s, length)
}

하지만 이 방법은 번거로울 수 있고, 일반적인 작업에 대한 추가적인 처리가 필요할 수 있다. 다행히, Rust는 값을 사용하면서 소유권을 이동시키지 않는 참조라는 기능을 제공한다.


출처: rust-lang book

이 포스트는 저작권자의 CC BY-NC-ND 4.0 라이센스를 따릅니다.

[Rust] 제어 흐름

[Rust] 참조와 빌림