소유권(Ownership)이란 무엇인가요?
소유권(Ownership)이란 무엇인가요?
프로그램을 실행하면 메모리를 관리해야 하는데, 언어마다 이 문제를 푸는 방식이 달라요. 가비지 컬렉션(GC)이 돌면서 안 쓰는 메모리를 찾아내는 언어도 있고, 프로그래머가 직접 할당하고 해제하는 언어도 있죠. Rust는 여기서 세 번째 길을 택해요. 아주 독특해서 처음엔 낯설 수 있지만, 그 원리를 알면 Rust가 왜 안전하면서도 빠른지 제대로 이해하게 돼요. 이 글에서는 소유권(ownership)이라는 개념을 문자열이라는 친숙한 자료형을 통해 차근차근 살펴볼게요.
출처: Rust 공식문서
본문
소유권은 무엇이고, 왜 필요한가요?
소유권은 Rust 프로그램이 메모리를 어떻게 관리할지 정하는 규칙들의 모음이에요. 어떤 규칙이든 어기면 프로그램이 컴파일되지 않고, 소유권 때문에 프로그램이 느려지는 일은 없어요. 규칙은 컴파일러가 검사하기 때문이죠.
소유권은 많은 프로그래머에게 생소한 개념이라 익숙해지는 데 시간이 좀 걸려요. 하지만 다행인 점은, Rust와 소유권 규칙에 익숙해질수록 안전하고 효율적인 코드를 자연스럽게 짜게 된다는 거예요. 조금만 힘내서 따라와 보세요.
소유권을 이해하면 Rust를 Rust답게 만드는 다른 기능들도 잘 받쳐줄 수 있어요. 이번 장에서는 아주 흔한 자료구조인 **문자열(string)**을 예시로 소유권을 배워볼게요.
스택과 힙
많은 언어에서는 스택과 힙을 자주 의식하지 않아요. 하지만 Rust 같은 시스템 프로그래밍 언어에서는 값이 스택에 있느냐 힙에 있느냐가 언어의 동작 방식과 선택의 이유를 바꿔요. 그래서 이번 장의 전반부에서 소유권을 스택과 힙과 엮어서 설명할 거라, 미리 간단히 짚고 넘어갈게요.
스택과 힙은 모두 코드가 런타임에 사용할 수 있는 메모리 영역이지만 구조가 달라요. 스택은 값을 넣은 순서대로 저장하고, 반대 순서로 제거해요. 이를 **후입선출, LIFO(Last In, First Out)**라고 불러요. 접시를 쌓아 올리고 위에서부터 집어 쓰는 걸 떠올리면 돼요. 중간이나 바닥에서 접시를 꺼내는 건 잘 안 되잖아요. 데이터를 추가하는 걸 푸시(pushing), 제거하는 걸 **팝(pop)**이라고 해요. 스택에 저장되는 모든 데이터는 크기가 정해져 있어야 해요. 컴파일 시점에 크기를 모르거나 크기가 변할 수 있는 데이터는 힙에 저장해야 해요.
힙은 좀 덜 정돈되어 있어요. 힙에 데이터를 넣으려면 일정한 공간을 요청해요. 그러면 메모리 할당자가 힙에서 충분히 큰 빈 자리를 찾아 '사용 중'으로 표시하고, 그 위치의 주소인 **포인터(pointer)**를 돌려줘요. 이 과정을 **힙에 할당(allocating on the heap)**이라 하고, 줄여서 그냥 할당이라고도 불러요(스택에 값을 푸시하는 건 할당으로 치지 않아요). 힙을 가리키는 포인터는 크기가 고정되어 있으니 스택에 저장할 수 있고, 실제 데이터가 필요할 때만 포인터를 따라가면 돼요. 식당에 들어가 인원수를 말하면 호스트가 빈 테이블을 찾아 안내해 주는 것과 비슷해요. 늦게 온 일행이 자기 자리를 물어보면 되죠.
스택에 푸시하는 건 힙에 할당하는 것보다 빨라요. 할당자는 새 데이터를 놓을 자리를 찾을 필요가 없고, 그 위치는 언제나 스택의 맨 위이기 때문이에요. 반면 힙 할당은 충분히 큰 공간을 먼저 찾고, 다음 할당을 위한 정리(bookkeeping)까지 해야 해서 일이 더 많아요.
힙에 있는 데이터에 접근하는 건 스택보다 일반적으로 느려요. 포인터를 따라가야 하니까요. 현대 프로세서는 메모리에서 덜 이리저리 뛰어다닐수록 빨라요. 식당 서버가 여러 테이블 주문을 받는 걸 생각해 보면, 한 테이블 주문을 다 받고 다음으로 넘어가는 게 효율적이죠. A 테이블, B 테이블, 다시 A, 다시 B를 오가는 건 훨씬 느려요. 프로세서도 데이터가 서로 가까이 있을 때(스택처럼) 멀리 떨어져 있을 때보다(힙처럼) 일을 잘 해요.
코드가 함수를 호출하면 함수에 전달되는 값(힙 데이터를 가리키는 포인터일 수도 있어요)과 함수의 지역 변수가 스택에 푸시돼요. 함수가 끝나면 이 값들은 스택에서 팝되죠.
어떤 코드가 힙의 어떤 데이터를 쓰는지 추적하고, 힙의 중복 데이터를 최소화하고, 안 쓰는 힙 데이터를 정리해서 공간이 떨어지지 않게 하는 것 — 이 모든 게 소유권이 해결하는 문제예요. 소유권을 이해하면 스택과 힙을 자주 생각할 필요가 없어져요. 그래도 소유권의 핵심 목적이 힙 데이터 관리라는 걸 아는 건, 소유권이 왜 그렇게 동작하는지 설명해 주니까 기억해 두면 좋아요.
소유권 규칙
먼저 소유권의 규칙을 보여드릴게요. 이 규칙들을 머릿속에 두고 아래 예시를 따라와 보세요.
- Rust에서 각 값은 소유자(owner)를 가져요.
- 한 번에 소유자는 하나뿐이에요.
- 소유자가 유효 범위(scope)를 벗어나면 값은 버려집니다(drop).
변수의 유효 범위
기본 Rust 문법은 지나갔으니, 이제부터 예시에 fn main() { 코드를 다 넣지는 않을게요. 따라 한다면 아래 예시들을 직접 main 함수 안에 넣어 주세요. 예시가 좀 더 간결해져서 보일러플레이트보다 실제 내용에 집중할 수 있을 거예요.
소유권의 첫 예시로 변수의 유효 범위(scope)를 볼게요. 유효 범위란 프로그램 안에서 어떤 항목이 유효한 범위를 말해요. 다음 변수를 봐요.
#![allow(unused)]
fn main() {
let s = "hello";
}
변수 s는 문자열 리터럴을 가리키는데, 문자열 값이 프로그램 텍스트에 하드코딩되어 있어요. 이 변수는 선언된 지점부터 현재 유효 범위가 끝날 때까지 유효해요. Listing 4-1은 변수 s가 어디서 유효한지 주석으로 표시해 둔 프로그램이에요.
fn main() {
{ // s is not valid here, since it's not yet declared
let s = "hello"; // s is valid from this point forward
// do stuff with s
} // this scope is now over, and s is no longer valid
}
Listing 4-1: 변수와 그것이 유효한 유효 범위
즉, 여기서 중요한 시점이 두 가지 있어요.
s가 유효 범위에 들어오면 유효해요.- 유효 범위를 벗어날 때까지 유효하게 유지돼요.
이 지점까지 스코프와 변수 유효성의 관계는 다른 프로그래밍 언어와 비슷하죠. 이제 이 이해를 바탕으로 String 타입을 소개할게요.
String 타입
소유권 규칙을 설명하려면 “Data Types”에서 다룬 것보다 더 복잡한 자료형이 필요해요. 앞서 다룬 타입들은 크기가 정해져 있어서 스코프가 끝나면 스택에서 팝되고, 다른 코드가 같은 값을 다른 스코프에서 써야 할 때 복사본을 빠르게 만들 수 있죠. 하지만 우리는 힙에 저장되는 데이터를 보고, Rust가 그 데이터를 언제 정리하는지 알아보고 싶어요. String 타입이 아주 좋은 예시예요.
String 중 소유권과 관련된 부분에 집중할게요. 이 특징들은 표준 라이브러리가 제공하든 직접 만들든 다른 복잡한 자료형에도 똑같이 적용돼요. String의 비소유권 측면은 Chapter 8에서 다뤄요.
문자열 리터럴은 값이 프로그램에 하드코딩된다고 했죠. 리터럴은 편리하지만 텍스트를 쓰려는 모든 상황에 어울리진 않아요. 첫째, **불변(immutable)**이에요. 둘째, 코드를 작성할 때 문자열 값을 다 알 수 있는 것도 아니에요. 예를 들어 사용자 입력을 받아 저장하려면? 이런 상황을 위해 Rust가 바로 String 타입을 두고 있어요. 이 타입은 힙에 할당된 데이터를 관리해서 컴파일 타임에 크기를 모르는 텍스트도 저장할 수 있어요. String은 리터럴에서 from 함수로 만들 수 있어요.
#![allow(unused)]
fn main() {
let s = String::from("hello");
}
이중 콜론 :: 연산자는 string_from 같은 이름을 쓰는 대신 이 from 함수를 String 타입 아래에 네임스페이스로 묶어 주는 역할이에요. 이 문법은 “Methods”에서, 모듈로 네임스페이스를 나누는 건 “Paths for Referring to an Item in the Module\nTree”에서 더 다뤄요.
이런 종류의 문자열은 변경할 수 있어요(mutable).
fn main() {
let mut s = String::from("hello");
s.push_str(", world!"); // push_str() appends a literal to a String
println!("{s}"); // this will print `hello, world!`
}
그럼 차이가 뭘까요? 왜 String은 바꿀 수 있는데 리터럴은 바꿀 수 없을까요? 그 차이는 이 두 타입이 메모리를 다루는 방식에 있어요.
메모리와 할당
문자열 리터럴은 컴파일 타임에 내용을 알 수 있어서 텍스트가 최종 실행 파일에 직접 하드코딩돼요. 그래서 리터럴은 빠르고 효율적이죠. 하지만 이런 특성은 리터럴의 불변성 덕분에 가능한 거예요. 컴파일 타임에 크기를 모르고 실행 중에 크기가 변할 수 있는 텍스트를, 그 모든 텍스트마다 메모리 덩어리를 바이너리에 넣을 수는 없잖아요.
String은 변경 가능하고 늘어날 수 있는 텍스트를 지원하려고 컴파일 타임에 크기를 모르는 메모리를 힙에 할당해 내용을 담아요. 즉, 이렇게 해야 해요.
- 메모리를 런타임에 메모리 할당자에게 요청해야 해요.
String을 다 쓰고 나면 이 메모리를 할당자에게 돌려줄 방법이 필요해요.
첫 번째는 우리가 하는 일이에요. String::from을 호출하면 그 구현이 필요한 메모리를 요청해요. 이건 프로그래밍 언어에서 거의 보편적이에요.
하지만 두 번째는 달라요. 가비지 컬렉터(GC)가 있는 언어에서는 GC가 더 이상 안 쓰는 메모리를 추적하고 정리해 주니 우리가 신경 쓸 필요가 없어요. GC가 없는 대부분의 언어에서는 메모리가 더 이상 안 쓰이는 때를 우리가 직접 알아내고, 요청했던 것처럼 명시적으로 해제하는 코드를 호출해야 해요. 이걸 정확히 하는 건 역사적으로 어려운 프로그래밍 문제였어요. 잊어버리면 메모리를 낭비하고, 너무 일찍 하면 유효하지 않은 변수가 되고, 두 번 하면 그것도 버그예요. 정확히 한 번의 할당에 정확히 한 번의 해제를 맞춰야 하죠.
Rust는 다른 길을 가요. 소유한 변수가 유효 범위를 벗어나면 메모리가 자동으로 돌아와요. Listing 4-1의 스코프 예시를 문자열 리터럴 대신 String으로 써 볼게요.
fn main() {
{
let s = String::from("hello"); // s is valid from this point forward
// do stuff with s
} // this scope is now over, and s is no
// longer valid
}
String이 필요로 하는 메모리를 할당자에게 돌려줄 자연스러운 시점이 있어요. 바로 s가 유효 범위를 벗어날 때예요. 변수가 유효 범위를 벗어나면 Rust가 특별한 함수를 자동으로 호출해요. 그 함수를 drop이라고 하는데, String의 작성자가 메모리를 돌려주는 코드를 넣는 자리예요. Rust는 닫는 중괄호에서 drop을 자동으로 호출해요.
참고: C++에서는 항목의 수명이 끝날 때 자원을 해제하는 이런 패턴을 **RAII(Resource Acquisition Is Initialization)**라고 불러요. RAII 패턴을 써 봤다면 Rust의
drop함수가 익숙하게 느껴질 거예요.
이 패턴은 Rust 코드를 쓰는 방식에 깊은 영향을 줘요. 지금은 단순해 보이지만, 우리가 힙에 할당한 데이터를 여러 변수가 쓰고 싶은 더 복잡한 상황에서는 코드의 동작이 예상 밖일 수 있어요. 그 상황들을 지금부터 살펴볼게요.
변수와 데이터: Move와의 상호작용
Rust에서는 여러 변수가 같은 데이터와 다른 방식으로 상호작용할 수 있어요. Listing 4-2는 정수를 쓰는 예시예요.
fn main() {
let x = 5;
let y = x;
}
Listing 4-2: 변수 x의 정수 값을 y에 할당하기
이 코드가 뭘 하는지 짐작할 수 있을 거예요. “값 5를 x에 바인딩하고, x의 값을 복사해서 y에 바인딩한다.” 이제 변수 x와 y가 있고, 둘 다 5예요. 실제로 그런 일이 일어나요. 정수는 크기가 정해진 단순한 값이고, 이 두 5 값은 스택에 푸시되니까요.
이제 String 버전을 볼게요.
fn main() {
let s1 = String::from("hello");
let s2 = s1;
}
아주 비슷해 보이니 같은 방식으로 동작할 거라고 짐작할 수 있어요. 즉 두 번째 줄이 s1의 값을 복사해서 s2에 바인딩할 거라고요. 하지만 실제로는 그렇지 않아요.
Figure 4-1을 보면 String이 내부적으로 어떻게 생겼는지 알 수 있어요. String은 세 부분으로 이루어져 있어요(왼쪽). 문자열 내용을 담고 있는 메모리를 가리키는 포인터, 길이(length), **용량(capacity)**이에요. 이 데이터 묶음은 스택에 저장돼요. 오른쪽은 내용을 담고 있는 힙의 메모리예요.
Figure 4-1:
"hello"값을 담고s1에 바인딩된String의 메모리 표현
length는 String 내용이 현재 사용 중인 메모리 크기(바이트)예요. capacity는 String이 할당자로부터 받은 전체 메모리 크기(바이트)예요. length와 capacity의 차이가 중요하긴 한데 이 맥락에선 아니니, 지금은 capacity를 무시해도 돼요.
s1을 s2에 할당하면 String 데이터가 복사돼요. 즉 스택에 있는 포인터, length, capacity를 복사한다는 뜻이에요. 포인터가 가리키는 힙의 데이터는 복사하지 않아요. 다시 말해 메모리 표현은 Figure 4-2처럼 보여요.
Figure 4-2:
s1의 포인터, length, capacity 복사본을 가진 변수s2의 메모리 표현
이 표현은 힙 데이터까지 함께 복사했을 때의 Figure 4-3과는 달라요. 만약 Rust가 힙 데이터까지 복사했다면 힙 데이터가 클 때 s2 = s1 연산이 런타임 성능 면에서 매우 비쌀 수 있었을 거예요.
Figure 4-3: Rust가 힙 데이터도 함께 복사했다면
s2 = s1이 했을 또 다른 가능성
아까 변수가 유효 범위를 벗어나면 Rust가 자동으로 drop 함수를 호출해 그 변수의 힙 메모리를 정리한다고 했어요. 그런데 Figure 4-2를 보면 두 데이터 포인터가 같은 위치를 가리키고 있어요. 여기가 문제예요. s2와 s1이 유효 범위를 벗어나면 둘 다 같은 메모리를 해제하려고 할 거예요. 이를 이중 해제(double free) 오류라고 하고, 앞서 언급한 메모리 안전 버그 중 하나예요. 메모리를 두 번 해제하면 메모리 손상이 생기고, 잠재적으로 보안 취약점으로 이어질 수 있어요.
메모리 안전을 보장하기 위해 Rust는 let s2 = s1; 줄 이후로 s1을 더 이상 유효하지 않은 것으로 간주해요. 그래서 s1이 유효 범위를 벗어나도 아무것도 해제할 필요가 없죠. s2가 만들어진 뒤 s1을 쓰려고 하면 안 되는 걸 직접 확인해 볼게요.
fn main() {
let s1 = String::from("hello");
let s2 = s1;
println!("{s1}, world!");
}
무효화된 참조를 쓰지 못하게 하기 때문에 이런 오류가 나와요.
$ cargo run
Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0382]: borrow of moved value: `s1`
--> src/main.rs:5:16
|
2 | let s1 = String::from("hello");
| -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
3 | let s2 = s1;
| -- value moved here
4 |
5 | println!("{s1}, world!");
| ^^ value borrowed here after move
|
= note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)
help: consider cloning the value if the performance cost is acceptable
|
3 | let s2 = s1.clone();
| ++++++++
For more information about this error, try `rustc --explain E0382`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error
다른 언어에서 **shallow copy(얕은 복사)**와 **deep copy(깊은 복사)**라는 말을 들어봤다면, 데이터 없이 포인터·length·capacity만 복사하는 이 개념이 shallow copy처럼 들릴 거예요. 하지만 Rust는 첫 번째 변수도 무효화하기 때문에 shallow copy라고 부르지 않고 **move(이동)**라고 불러요. 이 예시에서는 s1이 s2로 moved되었다고 말해요. 실제로 벌어지는 일은 Figure 4-4에 나와 있어요.
Figure 4-4:
s1이 무효화된 후의 메모리 표현
그래서 문제가 해결돼요! s2만 유효하니, 유효 범위를 벗어나면 s2 혼자 메모리를 해제하면 되거든요.
여기서 한 가지 설계 선택이 더 드러나요. Rust는 데이터의 “깊은” 복사본을 자동으로 만들지 않아요. 그렇기 때문에 자동 복사는 런타임 성능 면에서 저렴하다고 가정해도 돼요.
유효 범위와 할당
스코프·소유권·drop을 통한 메모리 해제의 관계에서도 이와 반대되는 내용이 성립해요. 기존 변수에 완전히 새로운 값을 할당하면 Rust가 drop을 호출해 원래 값의 메모리를 즉시 해제해요. 다음 코드를 볼게요.
fn main() {
let mut s = String::from("hello");
s = String::from("ahoy");
println!("{s}, world!");
}
처음에 변수 s를 선언하고 값이 "hello"인 String을 바인딩해요. 그다음 값이 "ahoy"인 새 String을 만들어 s에 할당하죠. 이 시점에는 힙의 원래 값을 가리키는 게 아무것도 없어요. Figure 4-5가 지금의 스택과 힙 데이터를 보여줘요.
Figure 4-5: 초기 값이 통째로 교체된 후의 메모리 표현
그래서 원래 문자열은 즉시 유효 범위를 벗어나요. Rust가 그 값에 drop을 실행하고 메모리가 바로 해제되죠. 마지막에 값을 출력하면 "ahoy, world!"가 나와요.
변수와 데이터: Clone
String의 힙 데이터까지 깊게 복사하고 싶다면(스택 데이터만이 아니라) clone이라는 흔한 메서드를 쓰면 돼요. 메서드 문법은 Chapter 5에서 다루지만, 메서드는 많은 언어에서 흔한 기능이라 이미 본 적이 있을 거예요.
clone 메서드가 동작하는 예시를 볼게요.
fn main() {
let s1 = String::from("hello");
let s2 = s1.clone();
println!("s1 = {s1}, s2 = {s2}");
}
이건 문제없이 동작하고, Figure 4-3이 보여준 것처럼 힙 데이터까지 실제로 복사되는 동작을 명시적으로 만들어요.
clone 호출을 보면 “임의의 코드가 실행되고 있고, 그 코드는 비쌀 수 있겠구나”를 알 수 있어요. 뭔가 다른 일이 벌어지고 있다는 시각적 신호인 셈이에요.
스택에만 있는 데이터: Copy
아직 다루지 않은 구석이 하나 있어요. 정수를 쓰는 다음 코드 — 그중 일부는 Listing 4-2에서 봤죠 — 는 동작하고 유효해요.
fn main() {
let x = 5;
let y = x;
println!("x = {x}, y = {y}");
}
그런데 이 코드는 방금 배운 내용과 모순돼 보여요. clone 호출이 없는데도 x는 여전히 유효하고 y로 이동하지 않았잖아요.
이유는 이래요. 컴파일 타임에 크기가 정해지는 정수 같은 타입은 전부 스택에 저장되므로 실제 값의 복사본을 빠르게 만들 수 있어요. 그 말은 변수 y를 만든 뒤에도 x가 유효하지 못하게 막을 이유가 없다는 뜻이에요. 여기선 깊은 복사와 얕은 복사 간 차이가 없으니 clone을 불러도 보통의 얕은 복사와 다를 게 없어서 생략할 수 있어요.
Rust에는 Copy 트레이트라는 특별한 표기가 있어요. 정수처럼 스택에 저장되는 타입에 붙일 수 있어요(트레이트는 Chapter 10에서 더 다뤄요). 타입이 Copy를 구현하면 그 타입을 쓰는 변수는 이동하지 않고 단순히 복사되며, 다른 변수에 할당한 뒤에도 여전히 유효해요.
그런데 타입(또는 그 일부)이 Drop 트레이트를 구현했다면 Rust는 그 타입에 Copy를 붙이는 걸 허락하지 않아요. 값이 유효 범위를 벗어날 때 특별한 일이 필요한 타입에 Copy를 붙이면 컴파일 타임 오류가 나요. 내 타입에 Copy를 붙여 트레이트를 구현하는 방법은 “Derivable Traits”에서 볼 수 있어요.
그럼 어떤 타입이 Copy를 구현할까요? 정확히는 해당 타입의 문서를 확인하면 되지만, 일반적인 규칙은 이래요. 단순한 스칼라 값의 묶음은 Copy를 구현할 수 있고, 할당이 필요하거나 어떤 형태의 자원을 쓰는 것은 Copy를 구현할 수 없어요. Copy를 구현하는 타입을 몇 가지 들면:
u32같은 모든 정수 타입true와false값을 가진 불리언 타입boolf64같은 모든 부동소수점 타입- 문자 타입
char Copy를 구현하는 타입만 담고 있는 튜플. 예를 들어(i32, i32)는Copy를 구현하지만(i32, String)은 구현하지 않아요.
소유권과 함수
값을 함수에 전달하는 방식은 값을 변수에 할당할 때와 비슷해요. 변수를 함수에 전달하면 할당할 때처럼 이동하거나 복사돼요. Listing 4-3은 변수들이 어디서 유효 범위에 들어오고 나가는지 주석으로 표시한 예시예요.
파일 이름: src/main.rs
fn main() {
let s = String::from("hello"); // s comes into scope
takes_ownership(s); // s's value moves into the function...
// ... and so is no longer valid here
let x = 5; // x comes into scope
makes_copy(x); // Because i32 implements the Copy trait,
// x does NOT move into the function,
// so it's okay to use x afterward.
} // Here, x goes out of scope, then s. However, because s's value was moved,
// nothing special happens.
fn takes_ownership(some_string: String) { // some_string comes into scope
println!("{some_string}");
} // Here, some_string goes out of scope and `drop` is called. The backing
// memory is freed.
fn makes_copy(some_integer: i32) { // some_integer comes into scope
println!("{some_integer}");
} // Here, some_integer goes out of scope. Nothing special happens.
Listing 4-3: 소유권과 유효 범위를 주석으로 표시한 함수들
takes_ownership 호출 뒤에 s를 쓰려고 하면 Rust가 컴파일 타임 오류를 냈을 거예요. 이런 정적 검사가 우리를 실수에서 지켜 줘요. main에 s와 x를 쓰는 코드를 직접 추가해 보면서, 어디에서 쓸 수 있고 어디에서 소유권 규칙이 막는지 확인해 보세요.
반환 값과 유효 범위
값을 반환하는 것도 소유권을 이동시킬 수 있어요. Listing 4-4는 Listing 4-3과 비슷한 주석을 단, 어떤 값을 반환하는 함수의 예시예요.
파일 이름: src/main.rs
fn main() {
let s1 = gives_ownership(); // gives_ownership moves its return
// value into s1
let s2 = String::from("hello"); // s2 comes into scope
let s3 = takes_and_gives_back(s2); // s2 is moved into
// takes_and_gives_back, which also
// moves its return value into s3
} // Here, s3 goes out of scope and is dropped. s2 was moved, so nothing
// happens. s1 goes out of scope and is dropped.
fn gives_ownership() -> String { // gives_ownership will move its
// return value into the function
// that calls it
let some_string = String::from("yours"); // some_string comes into scope
some_string // some_string is returned and
// moves out to the calling
// function
}
// This function takes a String and returns a String.
fn takes_and_gives_back(a_string: String) -> String {
// a_string comes into
// scope
a_string // a_string is returned and moves out to the calling function
}
Listing 4-4: 반환 값을 통한 소유권 이동
변수의 소유권은 매번 같은 패턴을 따라요. **다른 변수에 값을 할당하면 그 값은 이동(move)**해요. 힙 데이터를 포함한 변수가 유효 범위를 벗어나면, 그 데이터의 소유권이 다른 변수로 이동되지 않았다면 drop으로 정리돼요.
이 방식은 동작하지만, 모든 함수에서 소유권을 가져갔다가 다시 돌려주는 건 꽤 번거로워요. 값은 쓰되 소유권은 가져가지 않게 하려면 어떻게 해야 할까요? 뭔가를 전달하면 다시 쓰려면 그걸 다시 돌려받아야 하니, 함수 본문에서 만들어진 데이터까지 돌려받아야 한다면 정말 성가시죠.
Rust는 튜플을 써서 여러 값을 반환하게 해 줘요. Listing 4-5에 나와 있어요.
파일 이름: src/main.rs
fn main() {
let s1 = String::from("hello");
let (s2, len) = calculate_length(s1);
println!("The length of '{s2}' is {len}.");
}
fn calculate_length(s: String) -> (String, usize) {
let length = s.len(); // len() returns the length of a String
(s, length)
}
Listing 4-5: 매개변수의 소유권 반환
하지만 흔하디흔한 개념치고는 절차가 너무 번거롭고 일도 많아요. 다행히 Rust에는 소유권을 옮기지 않고 값을 쓰는 기능이 있어요. 바로 **참조(references)**예요.
더 알아보기
- 이어서 배우면 좋아요: References and Borrowing — 소유권을 옮기지 않고 값을 쓰는 참조와 빌림에 대해 다뤄요.
- 이 장의 시작: Understanding Ownership