Rust 공식 문서를 참고한 글입니다.
Ownership
소유권(ownership)은 Rust에서 메모리를 관리하는 방식을 규정하는 일련의 규칙이다. 모든 프로그램은 실행 중 컴퓨터 메모리를 관리해야 하는데, 몇몇 언어는 실행 중에 사용되지 않는 메모리를 찾는 방식을 사용하기도 하고, 프로그래머가 명시적으로 메모리를 할당하고 해제하는 경우도 있다. Rust는 이와 달리, Owner System을 통해 관리되고 컴파일러가 소유권 규칙을 감시하기 때문에, 규칙을 하나라도 어긋나면 컴파일되지 않는다.
소유권 규칙 Ownership rule
- Rust의 모든 값은 owner이 존재한다.
- 한 순간에 한 owner만 존재할 수 있다.
- owner이 해당 범위를 벗어나면, 그 값은 제거된다.
변수 범위 Variable Scope
변수의 범위는 ownership의 대표적인 예시다. 변수 s는 {} 안에 선언되어 있기 때문에 {} 밖에서는 변수 s에 접근할 수 없다.
{ // 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
String Type
소유권 규칙에 대해서 알아보기 위해서 조금 더 복잡한 자료형이 필요하다. 지금까지 다룬 자료형은 크기가 고정되어 있어서 스택에 저장된 후 해당 범위가 끝나면 스택에서 빠져나온다. 또 다른 코드에서 해당 범위를 다시 사용해야 할 때, 빠르게 복사해서 독립적인 인스턴스를 생성할 수 있다.
우리는 Heap 메모리에 저장되는 문자열 자료형을 사용해보자. 큰 따옴표를 묶어서 하드코딩한 문자열은 불변성을 띄고 중복을 피해 관리가 어렵다. Rust는 컴파일 시점에 크기를 알 수 없는 문자열을 저장하기 위한 String 자료형을 제공한다. 컴파일 시점에는 크기를 알 수 없기 때문에, heap 영역에 저장된다.
문자열 리터럴을 사용해 String 인스턴스를 만들고 싶으면 아래와 같이 작성할 수 있다. :: 연산자는 from 함수를 String 타입의 네임스페이스로 구분한다.
let s = String::from("hello");
가변성을 가지도록 하려면, mut과 함께 작성하면 된다.
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 타입은 변경이 가능하고 확장할 수 있는 텍스트를 지원하기 위해서 컴파일 시점에 알 수 없는 메모리 양을 heap 에 할당하는 것이다. 그래서 String::from()을 호출하면, 필요한 메모리를 할당한다.
Heap 메모리에 메모리를 할당하기 위해서는 아래와 같은 규칙을 따라야 한다.
- 실행 시점에 메모리 할당자에게 메모리를 요청해야 한다.
- 사용이 완료되었을 때 운영체제의 메모리 할당자에게 메모리를 반환할 방법이 필요하다.
첫 번째 규칙은 우리가 String::from()을 통해 가능하다. 두 번째 규칙은 일반적으로, Garbage Collector가 메모리 사용 여부를 판단해 메모리를 해제하거나 사용자가 메모리 할당을 해제한다. 그러나 Rust에서는 어떤 변수가 변수 범위를 넘어버리면 자동적으로 메모리 할당을 해제한다.
즉, {} 안에 선언된 s는 중괄호 안에서만 유효한 값이고 중괄호 밖을 벗어나는 순간 메모리가 자동적으로 해제된다.
{
let s = String::from("hello");
}
메모리를 해제할 때는 drop이라는 함수를 사용하는데, 중괄호를 벗어난 시점과 같이 메모리 할당 해제가 필요한 시점에서 자동적으로 호출된다.
변수의 이동(Move)
정수처럼 간단한 자료형은 변수를 다른 변수로 할당할 때 복사가 되지만, String처럼 복합 데이터 타입은 다르게 동작한다. 만약에 아래처럼 정수가 다른 정수를 참조한다고 하자. 그렇다면, x와 y에 모두 5가 바인딩 된다.
let x = 5;
let y = x;
String의 경우는 다르다. s2에 s1을 바인딩하면, 모두 hello라는 String이 바인딩될 것 같지만 아니다. s1은 아래 그림처럼 포인터, 길이, 용량 세 가지 파트로 구성된다. 이 데이터 그룹은 Stack에 저장되고 오른쪽에 있는 index, value 값들이 Heap 영역에 저장된다.
let s1 = String::from("hello");
let s2 = s1;
만약에 s1을 s2에 할당을 한다고 하자. 그러면 왼쪽 그림처럼, 스택에 있는 포인터, 길이, 용량이 담긴 데이터 그룹이 복사되고 Heap에 존재하는 값은 복사되지 않는다. 오른쪽 그림처럼 할당되지 않는다.
우리는 앞서, Rust는 변수 스코프를 벗어나면 drop이라는 함수를 통해 메모리를 해제한다고 배웠다. 왼쪽 그림처럼, s1과 s2는 모두 같은 위치를 가리키고 있기 때문에, s1과 s2 모두 변수 스코프를 벗어나게 되면 둘 다 같은 메모리를 해제하려고 할 것이다. 이러한 에러를 double free problem이라고 한다. 메모리를 두 번 해제하면 메모리 손상이 발생해서 보안상 취약점으로 이어질 수 있다.
이러한 문제를 방지하기 위해서, let s2 = s1; 이후에는 s1이 더이상 유효하지 않은 값으로 간주한다. 다른 언어를 공부하면서 얕은 복사, 깊은 복사를 들어봤을 텐데, Rust에서는 포인터와 길이, 용량은 복사하되 데이터는 복사하지 않는 얕은 복사와 유사하다. 실제로는 첫 번째 변수를 무효화하기 때문에 이를 복사라고 하지 않고 Move라고 하는 것이다. 그래서 아래 그림처럼, 변수가 이동했다고 볼 수 있다.
변수의 복제(Clone)
다른 언어에서처럼 얕은 복사의 개념이 존재한다면, 깊은 복사를 하려면 Clone을 사용하면 된다. 아래와 같은 방식으로 한다면, 포인터, 길이, 용량 뿐만 아니라 힙 영역의 데이터까지 복사할 수 있고, s1도 여전히 유지된다.
let s1 = String::from("hello");
let s2 = s1.clone();
println!("s1 = {s1}, s2 = {s2}");
변수의 복사(Copy)
정수와 같이 컴파일 시점에서 크기를 알 수 있는 자료형은 모두 스택에 저장된다. 그래서 변수를 다른 변수로 할당할 때 스택 영역에 있는 값이 복사되므로 실제로 아주 빠르다. 이때 다른 변수로 할당해도 기존의 변수는 무효화되지 않고 여전히 남아있다.
'언어 > Rust' 카테고리의 다른 글
[Rust] 4-3. Slice Type (0) | 2024.09.28 |
---|---|
[Rust] 4-2. 참조와 대여 (0) | 2024.09.28 |
[Rust] 3-3. 제어문(Control Flow) (0) | 2024.09.26 |
[Rust] 3-2. 함수와 주석 (0) | 2024.09.25 |
[Rust] 3-1. 변수와 자료형 (1) | 2024.09.25 |