소유권(Ownership)은 러스트의 가장 독특한 기능이며, 러스트가 가비지 컬렉터(garbage collector)를 사용하지 않아도 메모리 안전을 보장할 수 있게 해주는 역할을 한다.
소유권이란 Rust프로그램이 메모리를 관리하는 방법을 제어하는 일련의 규칙이고, 컴파일러가 메모리를 명시적으로 할당하고 해제하며 소유권이 지켜진다.
스택(Stack)과 힙(Heap)
Rust같은 시스템 프로그래밍 언어에서 값이 스택에 있는지 힙에 있는지 여부가 작동하는 방식과 특정 결정을 내려야 하는 이유에 영향을 준다.
스택과 힙은 모두 코드에서 런타임에 사용할 수 있는 메모리의 일부이지만 서로 다른 방식으로 구조화되어 있다. 스택은 FILO 방식이고, 저장되는 데이터들은 알려진 고정 크기를 가져야 한다. 컴파일 시 크기를 알 수 없거나 크기가 변경될 수 있는 데이터는 힙에 저장해야 한다.
힙에 데이터를 넣을 때 특정 공간을 요청하는데, 메모리 할당자는 힙에서 충분히 큰 빈 자리를 찾아 사용 중인 것으로 표시하고 해당 위치의 주소인 포인터를 반환한다.(allocating이라고도 함.) 스택에 값을 push하는 것은 할당으로 간주되지 않는다. 힙에 대한 포인터는 알려진 고정 크기이기 때문에 스택에 포인터를 저장할 수 있지만 실제 데이터를 찾을 때는 포인터를 따라가야 한다.
allocator가 새 데이터를 저장할 장소를 찾을 필요가 없기 때문에 스택으로 push하는것이 힙에 allocating하는 것보다 빠르다. 반면, 힙에 있는 데이터에 액세스하는 것은 포인터를 따라가야 하기 때문에 스택에 있는 데이터에 액세스하는 것보다 느리다.
코드의 어떤 부분이 힙의 데이터를 사용하고 있는지 추적하고, 힙의 중복 데이터 양을 최소화하고, 공간이 부족하지 않도록 힙에서 사용하지 않는 데이터를 정리하는 것은 모두 소유권이 해결하는 문제이고, 소유권의 주요 목적이 힙 데이터를 관리하는 것이다.
소유권 규칙
- Rust의 각 값에는 소유자(owner)라는 변수가 있다.
- 한 번에 한 명의 소유자만 있을 수 있다.
- 소유자가 범위를 벗어나면 값이 삭제된다.
가변 범위
다음 예시를 보자.
{ // s is not valid here, 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
변수 s는 문자열 값이 프로그램에 하드코딩된 문자열 리터럴을 참조한다. 변수는 선언된 시점부터 현재 범위가 끝날 때까지 유효하다.
여기서 중요한 점은 다음과 같다.
- s 범위에 들어오면 유효하다.
- 범위를 벗어날 때까지 유효하다.
여태까지 배운 데이터 유형은 모두 알려진 크기이며 스택에 저장하고 꺼낼 수 있다. 그러므로 범위가 끝났을 때 스택이 생성되고 코드의 다른 부분이 다른 범위에서 동일한 값을 사용하는 경우 새 독립 인스턴스를 만들기 위해 빠르고 간단하게 복사할 수 있다. 그러나 여기서는 힙에 저장된 데이터를 살펴보고 Rust가 해당 데이터를 정리할 때를 어떻게 아는지 살펴보기 위해 String 타입을 예로 들어 보자.
문자열 값이 프로그램에 하드코딩된 문자열 리터럴은 [변경할 수 없거나 사용자 입력을 받을 때처럼 코드를 작성할 때 모든 문자열 값을 알 수 없기 때문에] 텍스트를 사용하려는 모든 상황에 적합하지는 않다. 이런 상황에는 String 타입을 쓴다. 이 유형은 힙에 할당된 데이터를 관리하므로 컴파일 시 알 수 없는 양의 텍스트를 저장할 수 있다. 문자열 리터럴에서 String으로 만드는 방법은 다음과 같다.
let s = String::from ("hello");
이러한 종류의 string은 mutated될 수 있다.
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은 변경될 수 있지만 리터럴(literal)는 안되는 이유, 둘의 차이점은 메모리를 처리하는 방법에 있다.
리터럴(literal)
리터럴은 데이터 그 자체, 변수에 넣은 변하지 않는 데이터를 의미한다. 보통 소스 코드의 고정된 값을 대표하는 용어이다. 리터럴과 대조적으로, 고정된 값을 가질 수 있는 변수나 변경되지 않는 상수가 있다.
리터럴은 변수 초기화에 종종 사용된다. 아래는 예시이다.
int i = 1; String s = "봄싹";
메모리 및 할당
문자열 리터럴의 경우 컴파일 시간에 내용을 알고 있으므로 빠르고 효율적이지만, 컴파일 시간에 크기를 알 수 없고 프로그램을 실행하는 동안 크기가 변경될 수 있는 텍스트 조각에 대해서는 바이너리에 메모리 blob을 넣을 수 없다.
이를 해결하기 위해 String 타입을 이용해서 힙에 메모리를 할당한다. 이것은 다음을 의미한다.
- 런타임 시 memory allocator에 메모리를 요청한다.
- 끝났을 때, 메모리를 할당자에 반환하는 방법을 안다.
첫 번째 부분은 String::from을 이용해 구현에 필요한 메모리를 요청한다.
두 번째 부분은 Garbage Collector가 있다면 생각하지 않아도 되지만, 러스트는 메모리가 더이상 사용되지 않는 시점을 식별하고 우리가 요청했던 것처럼 명시적으로 반환하는 코드를 호출하는 것을 고려해야 한다.
이를 위해 Rust는 메모리를 소유한 변수가 범위를 벗어나면 메모리가 자동으로 반환된다. 다음은 예제이다.
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
}
변수가 범위를 벗어날때 drop이라는 함수를 호출하며 메모리를 반환한다. Rust는 닫는 중괄호에서 drop을 자동으로 호출한다.
C++에서 item's lifetime이 끝날 때 리소스 할당을 해제하는 이패턴을 RAII(Resource Acquisition Is Initialization)이라고 한다.
변수와 데이터의 상호 작용 방식: Move
let x = 5;
let y = x;
다음에서 일어나는 일은
값 5를 x 에 대입한다. 그러고 나서 x의 값을 y에 대입한다.
정수는 알려진 고정 크기의 단순한 값이고, 두 값이 스택에 푸시되기 때문에 위의 설명은 맞다.
let s1 = String::from("hello");
let s2 = s1;
하지만 다음에서 일어나는 일은 다르다.
String은 문자열의 내용을 보유하는 메모리에 대한 포인터, 길이, 용량의 세 부분으로 구성된다. 이 데이터 그룹은 스택에 저장되고, 내용은 힙의 메모리에 저장되어 있다.
len은 String의 내용이 현재 사용중인 메모리 양(byte)이다. capacity는 String이 allocator로부터 받은 메모리의 양(byte)이다. s2를 s1에 대입하면, s2는 s1의 String 데이터를 복사한다. 즉, 스택에 있는 ptr, len, capacity를 복사하고, 힙에 있는 데이터는 복사하지 않는다.
이 경우, 앞서 말한 drop함수 호출에서 문제가 발생한다. s2나 s1이 범위를 벗어나면 동일한 메모리를 해제하려고 하기 때문에 오류가 생길 수 있다.(double free error) 이에 메모리 안전을 보장하기 위해 let s2 = s1 이후에 러스트는 s1이 더 이상 유효하지 않은 것으로 간주한다. 따라서 러스트는 s1이 범위를 벗어날 때 아무 것도 해제할 필요가 없어진다. s2로 소유권이 이전된 이후에 s1을 호출하면 어떻게 되는지 알아보자.
fn main() {
let s1 = String::from("hello");
let s2 = s1;
println!("{}, world!", s1);
}
Rust가 무효화된 참조를 사용하는 것을 방지하기 때문에 다음과 같은 오류가 발생한다.
깊은 복사(deep copy), 얕은 복사(shallow copy)와 비슷하지만, Rust는 첫 번째 변수도 무효화하기 때문에 이를 얕은 복사라고 부르는 대신 move 라고 한다. Rust는 데이터의 "깊은" 복사본을 자동으로 생성하지 않기 때문에 모든 자동 복사는 런타임 성능 측면에서 더 저렴하다.
변수와 데이터의 상호 작용 방식: Clone
String의 스택 데이터뿐만 아니라 힙 데이터를 깊이 복사하려면 clone이라는 함수를 사용할 수 있다.
fn main() {
let s1 = String::from("hello");
let s2 = s1.clone();
println!("s1 = {}, s2 = {}", s1, s2);
}
스택 전용 데이터: Copy
let x = 5;
let y = x;
println!("x = {}, y = {}", x, y);
위의 이야기와 다르게 다음의 코드는 정상 작동한다. 그 이유는 컴파일 타임에 알려진 크기를 가진 정수와 같은 유형이 스택에 완전히 저장되게되므로 실제 값의 복사본을 빠르게 만들 수 있기 때문이다.
러스트는 정수처럼 스택에 저장된 유형에 배치할 수 있는 Copy trait이라는 특별한 annotation을 가지고 있다. type이 copy trait를 구현하는 경우, 변수는 다른 변수에 할당된 후에도 유효하다. 타입이나 그 일부가 Drop trait가 구현되었는지의 여부에 따라 Copy가 type에 annotate될지 말지 결정된다. 값이 범위를 벗어날 때 type에 Copy annotation이 되어 있다면 컴파일 에러가 일어난다.
어떤 타입들에 Copy trait가 구현될까? 일부는 다음과 같다.
- u32같은 모든 iteger 타입들
- Boolean 타입
- f64같은 floating point 타입
- char같은 caracter타입
- Copy가 구현된 타입들로 이루어진 Tuple. ex) i32, i32 O i32, String X
함수에 값을 전달하는 의미(semantics)는 변수에 값을 할당하는 의미와 비슷하다. 함수에 변수를 전달하면 할당과 마찬가지로 move나 clone을 한다. 예제를 보자.
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); // x would move into the function,
// but i32 is Copy, so it's okay to still
// use x afterward
} // Here, x goes out of scope, then s. But 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.
s를 take_ownership호출 후에 사용하려고 하면 컴파일 타임 오류가 난다. s는 move되기 때문이다. 하지만 makes_copy호출 후에도 x는 계속 사용할 수 있다. x는 clone되기 때문이다.
반환 값(return value)은 소유권을 이전할 수도 있다. 예시를 보자.
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 one
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
}
s1은 gives_ownership함수에서 some_string을 리턴받아 main함수에서 소유권을 가지게 된다.
s2는 선언되면서 소유권이 있었지만, s3에서 매개변수로 전달되며 a_string으로 소유권이 옮겨져 소유권을 잃게된다.
s3는 a_string을 리턴받아 main함수에서 소유권을 가지게 된다.
코드를 실행해보면 언더스코프(_)를 붙이라는 warning이 나온다. _의 의미는 사용하지 않는 변수를 사용자가 알 수 있게 표시하는 역할을 한다고 한다.
-참조
https://ko.wikipedia.org/wiki/%EB%A6%AC%ED%84%B0%EB%9F%B4
https://bblackscene21.tistory.com/6