Rust : 소유와 이동
- 프로그래밍 언어/Rust
- 2024. 12. 14.
들어가기 전
이 글은 프로그래밍 러스트(Oreilly)를 공부하며 작성한 글입니다.
소유권 (Ownership)
- 러스트에서 소유권은 컴파일 시점에 검증된다.
- 소유권은 아래 메모리 안정성을 위해 러스트 컴파일러가 제공한다.
- Dangling Pointer가 없도록 한다.
- 원하는 시점에 메모리가 해제되도록 한다.
- 모든 값은 하나의 소유자를 가진다. (Rc, Arc는 복수 소유자가 존재 가능)
- 소유자는 자신의 구성 요소들에 대한 소유권도 가진다. 이것은 소유권 트리 형태로 구성된다.
- 구조체는 자신의 필드들을 소유한다.
- 벡터는 자신의 요소들을 소유한다.
- 소유자가 자신이 선언된 블록을 벗어날 때 무효화된다. 이 때, 소유하고 있던 값들도 모두 힙에서 제거된다.
- 소유권을 이동해서 트리를 만들고 바꿀 수 있다.
- Copy Trait을 구현한 타입은 소유권 규칙에 적용되지 않는다.
- 소유권은 이동 / 빌림 개념이 있다.
아래 예시를 통해 소유권 트리와 스택 / 힙 메모리의 상태를 고민해본다.
struct Person { name: String, birth: i32}
fn main() {
let mut composers: Vec<Person> = vec![];
composers.push(Person { name: "Palestrina".to_string(), birth: 1525 });
composers.push(Person { name: "Dowland".to_string(), birth: 1563 });
composers.push(Person { name: "Lully".to_string(), birth: 1632 });
for composer in &composers {
println!("{}, born in {}", composer.name, composer.birth);
}
}
이 코드는 다음과 같이 동작한다.
- composer는 Vector를 소유한다.
- Vector는 자신에게 포함된 Person 3개를 소유한다.
- Person은 자신의 필드인 name, birth를 소유한다.
composer를 Root Node로 해서 소유권 트리가 형성될 것을 암시한다.
위 그림에서는 composer 필드가 Vector, 구조체를 소유하면서 생성되는 소유권 트리를 보여준다. composer가 선언된 블록을 벗어나게 되면, composer가 해제되면서 위 소유권 트리가 모두 해제된다.
메모리 관점에서 자세히 살펴보자.
- 스택 프레임에는 버퍼를 가리키는 포인터가 있다. 이 때, 버퍼는 힙에 저장된 Vector를 가리킨다.
- Vector 버퍼에는 Person 구조체가 3개 저장되어 있다.
- Person 구조체는 String 타입의 필드 name을 가진다. 이 때, Person 구조체의 버퍼는 이 String 값을 가리킨다. birth는 Vector 버퍼 내에 같이 저장되어있다.
birth는 Vector 버퍼에 저장되어있다. birth는 i32 타입이고, 이것은 크기가 정해진 값이기 때문에 Vector 버퍼 내에 할당할 수 있다. 그러나 String은 크기가 바뀔 수 있기 때문에 버퍼를 가리키는 포인터가 Vector 버퍼 내에 할당된다.
이동 (Move)
- 소유권의 이동은 소유권 트리를 변경하기 위함이다.
- 값을 변수에 배정, 함수에 값을 전달, 함수에서 값을 반환하는 경우 값의 소유권은 이동한다.
- 변수가 초기화 된 후에 값의 소유권이 이동하면, 그 변수는 미초기화된 상태가 된다. (그림 확인)
- 모든 소유권 이동은 원본을 미초기화 된 상태로 바꾸는 '바이트 단위의 얕은 복사'이다.
- 소유권의 일부만 이동할 수는 없다.
- Vector의 일부 요소의 소유권만 인덱스를 이용해 이동. (let a = vector[0].name;
- 구조체의 일부 요소의 소유권만 이동할 수는 없다. 구조체의 일부 소유권만 이동하면, 구조체 전체가 무효화된다.
- Copy Trait을 구현한 값은 이동대신 '복사'된다.
- i32, bool 같은 간단한 타입들에 대해서 적용된다.
- 해제할 때 특별한 처리가 필요한 타입들은 모두 Copy Trait을 구현하지 않는다. (Rc, MutexGuard, File 등등)
- Copy Type만을 요소로 가지는 사용자 정의 타입은 자동적으로 Copy Trait을 구현하지 않는다.
- Copy 타입을 나중에 비Copy 타입으로 변경하는 경우, 이 타입을 사용하는 많은 코드가 변경되어야 한다. 따라서 자동으로 Copy Type을 가진다고 하지 않고, 명시적으로 #[derive(Copy, Clone)]을 선언해야한다.
- Rc<T>, Arc<T>는 레퍼런스 카운트 기반의 스마트 포인터다.
- Rc<T>는 힙에 할당된 T를 가리킨다. 이 때, Reference Count가 힙에 내장되어있다.
- Rc<T>를 복제하면, Reference Count가 증가하면서 힙에 할당된 T를 가리키는 포인터가 반환된다.
- 기본적으로 Rc<T>는 불변이기 때문에 순환참조는 만들어지지 않는다.
- let a = Rc::new(String::from("a")) 코드에서 a는 Rc 인스턴스에 대한 소유권만 가진다. Rc 인스턴스는 스택 메모리에 선언되어있다.
- a가 해제되면, Rc 인스턴스가 스택 메모리에서 제거된다. Rc 인스턴스가 제거될 때 마다, 이 인스턴스가 참조하던 힙 메모리에서 Reference Count가 감소한다. Reference Count가 0이 되면, 그 힙 메모리는 해제된다.
소유권이 이동하면 기존 값은 미초기화 됨.
fn main() {
let s = vec![
String::from("udon"),
String::from("rament"),
String::from("soba"),
];
let t = s;
println!("{:?}", s);
}
위 코드는 소유권 관점에서 변수가 초기화 된 이후, 이동하는 것을 보여주는 코드다.
- s는 초기화된다.
- s에 저장된 값의 소유권은 t로 이동한다. 이 때, s는 미초기화 상태(무효화)가 된다.
스택 메모리 / 힙 메모리 관점에서 보면 이렇게 동작하는 것을 알 수 있다.
- s는 Vector 버퍼를 가리키는 포인터, 메타 정보를 스택 프레임에 저장한다.
- s가 가진 값의 소유권이 t로 이동한 뒤로, s는 미초기화(무효화) 된다. 스택 메모리 상에서 이전에 있던 s의 값은 없어지고, t에 그 값이 복사되어 있음을 알 수 있다.
위를 통해 소유권이 이동되고, 기존 변수가 무효화 되었을 때의 스택 메모리가 어떻게 구성되어있는지를 알 수 있다.
소유권의 일부만 이동할 수 없음. (Vector)
fn main() {
let v = vec![
"a".to_string(),
"b".to_string(),
"c".to_string()
];
let k = v[1]; // 컴파일되지 않음. Cannot move
}
- Vector의 인덱스를 이용해 일부 요소만 소유권을 이동할 수 없다. 위 코드는 컴파일 되지 않는데, 인덱스를 이용해 일부 요소의 소유권을 이동하려고 했기 때문이다.
- 소유권 트리 관점에서 생각해보면 당연하다.
- 힙 메모리에 선언된 Vector는 Vector 버퍼의 시작 지점을 가리키는 포인터를 가리킨다.
- 만약 Vector[1], Vector[2]의 소유권을 각각 가지려고 한다면 스택 프레임에 저장된 메타 정보에 내가 소유하고 있는 인덱스 정보를 저장해야하는데 바람직하지 않다.
- 따라서 그런 방식으로 쓸 수가 없다.
소유권의 일부만 이동할 수 없음. (구조체)
struct Person { name: String, second_name: String}
fn main() {
let k = Person { name: String::from("John"), second_name: "Hello".to_string() };
let h = k.name;
println!("{}", h);
println!("{}", k);
}
- 구조체의 소유권 중 일부만 이동되는 것은 불가능하다.
- 구조체의 필드 중 일부만 소유권이 이동되면, 그 구조체는 전체가 무효화된다.
- 아래 그림을 참고해보면 소유권 트리 구조가 깨지는 것을 알 수 있음.
- k는 여전히 힙의 구조체 메모리에 대한 소유권을 가지고 있음.
- h는 가장 잎새 노드 값을 가리키고 있음. 예를 들면 Palestrina가 될 수 있음.
- 결국 Palestrina는 두 개의 소유자를 가지기 때문에 러스트의 소유권 트리에서 벗어난다.
Copy Trait을 구현한 값은 이동 대신 복사된다.
fn main() {
let string1 = "somnambulance".to_string();
let string2 = string1; // MOVE
let num1 = 36;
let num2 = num1; // COPY
}
- Copy Trait을 구현한 타입은 값을 대입했을 때, COPY 된다.
- Copy Trait을 구현한 타입은 컴파일 타임에 값이 정해지는 타입이거나, 메모리를 해제할 때 특별한 조치가 필요하는 타입이다.
위 코드를 메모리 관점에서 보면 다음과 같다.
- string1 값은 string2로 이동했기 때문에 무효화된다. 이것은 String 타입이 Copy Trait을 구현하지 않았기 때문이다.
- i32 타입인 36은 Copy Trait을 구현했기 때문에 스택 메모리 상에 값이 그대로 복사된다.
Copy Type만을 요소로 가지는 사용자 정의 타입은 자동적으로 Copy Trait을 구현하지 않는다.
struct Person { age: i32, price: i32}
- 구조체 Person의 구성요소는 모두 Copy Trait을 구현한 타입이지만, Person은 Copy Trait을 구현했다고 취급되지 않음.
- Copy Trait을 구현한 것과 구현하지 않은 인스턴스를 사용하는 것은 코드에서 많은 차이가 발생한다. 이는 추후에 Copy Trait 구현 객체에서 미구현 객체로 변경할 때 많은 Break Point를 가져온다. 따라서 기본적으로는 Copy Trait을 구현하지 않았다고 가정한다.
#[derive(Copy, Clone)]
struct Person { age: i32, price: i32}
- 만약 Copy Trait을 구현한 객체로 사용하고 싶다면, #[derive(Copy, Clone)]을 명시적으로 사용해야한다.
Rc, Arc는 레퍼런스 카운트 기반의 스마트 포인터다.
fn main() {
let s = Rc::new("shirataki");
let t = s.clone();
let u = s.clone();
}
Rc는 Reference Count 기반의 스마트 포인터다.
- s, t, u 자체는 스택 프레임에 저장된 포인터에 대한 소유권만 가진다.
- s, t, u의 포인터는 레퍼런스 카운트가 내장된 T 값이 저장된 힙 메모리를 가리킨다.
- s, t, u가 선언된 블록을 벗어나면, 그 스택 프레임은 제거된다. 왜냐하면 s, t, u 자체는 스택 프레임에 대한 소유권만 가지기 때문이다.
- 그런데 referecne count가 0이 되면, 힙 메모리에 저장된 값 T가 메모리에서 제거된다.
Rc<T>, Arc<T>만 사용했을 때는 순환참조가 발생하지 않음.
let s = Rc::new("shirataki");
*s.as_mut_ptr() = "Hello";
- 위 코드는 컴파일 되지 않는다. 기본적으로 Rc<T>가 내부적으로 가지고 있는 값 T는 불변이기 때문이다.
- Rc<T>가 불변인 이유는 Rc가 여러 소유자를 가진다고 가정하기 때문이다. (Reference Count)
- 여러 소유자가 있는 경우, 가변인 값에 대해서는 Race Condition 문제를 본질적으로 가지는 것을 의미함.
- Rc<T>는 불변이기 때문에 Rc<T>만 이용해서는 순환참조 문제가 발생하지는 않는다.
- Race Condition을 방지하기 위해 Rc<T>는 불변이다. RefCell을 이용하면 내부 가변성을 통해 unsafe하게 값을 수정할 수는 있다.
위와 같이 순환참조를 하려면, 처음에 Rc 인스턴스를 선언한 다음에 값을 수정해서 넣어줄 수 있어야 하기 때문이다.
'프로그래밍 언어 > Rust' 카테고리의 다른 글
Rust : 레퍼런스(Reference) (1) | 2024.12.21 |
---|