1. 소유권
소유권은 Rust 특유의 개념입니다.
어떤 스코프 안에서 값을 할당받고 생성된 변수는 그 스코프 안에서 값에 대한 소유권을 가집니다.
그 스코프를 벗어나면 소유권은 소멸됩니다.
또 어떤 변수가 다른 변수에게 값을 재할당하면 소유권이 이동(move)합니다.
값을 가지고 있던 변수는 소유권을 잃습니다.
아래 예에서 스코프와 소유권의 관계를 봅시다.
{
let x = 1; .....영역 1
}
........ 영역 2
이 경우에 변수 x는 정수 1에 대한 소유권을 가지고 있습니다.
브레이스 { }로 감싸인 영역 1은 스코프 내의 공간입니다.
스코프를 벗어나면( 영역 2) 변수 x는 소유권을 잃고 삭제됩니다.
그래서 영역 2에서 변수 x는 데이터를 가지고 있지 않아 사용할 수 없습니다.
이번에는 재할당의 예시입니다.
fn main(){
let s1 = "hello".to_string();
let s2 = s1; // 1
println!("{}", s1);
println!("{}", s2);
}
이 경우에 변수 s1은 s2에게 소유권을 넘겨주고 아무런 값을 가지고 있지 않습니다.
이 코드를 실행하면 에러가 발생합니다. // 1에서 소유권 이동(move)리 일어납니다.
변수 s1이 문자열 “hello”를 가지고 있다가 변수 s2에게 문자열 “hello”를 건네준다고 생각하면 받아들이기 쉽습니다.
그 결과 변수 s1은 가지고 있는 데이터가 없어집니다.
이처럼, 소유권은 어려운 개념이 아니라, 어떤 값을 만들어 변수 x에 할당하고는, "변수 x는 생성된 스코프 안에서 그 값에 대한 소유권을 가진다"라고 표현하는 것입니다.
그런데 이 소유권 개념이, 처음 Rust를 접한 사람을 당황시키고 Rust를 어렵다고 느끼게 만듭니다.
다음은, 처음 접했을 때 일관성 없고 뭔가 예외처럼 보이는 현상을 보겠습니다.
fn main(){
let a = "hello";
let b = a; // 1
println!("{}, {}",a,b);
}
// 출력
hello, hello
변수 b는 a에서 재할당으로 값을 받았지만 여기서는 에러가 나지 않습니다.
이것은 변수 a, b의 데이터 타입이 가진 특성 때문에 발생한 일입니다.
a와 b의 데이터 타입은 &str 즉 문자열 슬라이스입니다.
생성되었을 때 문자열 슬라이스나 정수형, 소수형 타입 등은 stack 메모리에 자리 잡습니다.
이런 데이터 타입들은 위 코드처럼 재할당 되었을 때 값의 복사가 일어납니다.
이런 특성을, 어떤 데이터 타입이 Cooy 트레이트를 가졌다고 표현합니다.
그래서 // 1에서 소유권 이동이 일어나는 것이 아니라 변수 a는 자신이 가진 값을 복사해서 복사본을 변수 b에게 넘겨줍니다.
앞에서 본 문자열(String)은 Cooy 트레이트가 없어서 소유권 이동이 일어났습니다. 그 코드를 실행해 보겠습니다.
fn main() {
let x = "hello".to_string(); // 1
let y = x;
println!("{}", x);
println!("{}", y);
}
// 출력
...에러
2 | let x = "hello".to_string();
| - move occurs because `x` has type `String`, which does not implement the `Copy` trait
3 | let y = x;
| - value moved here
4 | println!("{}", x);
| ^ value borrowed here after move
// 1: 이 코드에서 String 타입 문자열을 만들어서 변수 x에 할당하고, 다시 변수 x를 변수 y에 할당했습니다.
실행해보면 에러가 발생합니다. 소유권 이동 때문에 이런 일이 벌어진다고 설명했습니다. 에러 시지를 봅시다.
“move occurs because `x` has type `String`, which does not implement the `Copy` trait”
: 변수 x는 String 타입인데 String 타입에는 `Copy` 트레이트가 구현되어 있지 않기 때문에 소유권 이동이 일어났다.
조금 어려울 수 있지만 내부 사정을 조금 더 살펴보겠습니다.
메모리의 낮은 주소부터 높은 주소 순으로, Code segment, Data segment, Heap, free zone, Stack이 존재합니다.
이것은 Rust뿐만 아니라 다른 언어에도 적용됩니다.
우리가 살펴보고자 하는 주재애 중요하다고 생각되는 stack과 heap의 차이점만 살펴보겠습니다.
stack에는 크기가 정해져 있는 데이터만 저장합니다. 정적 데이터를 저장한다고 표현합니다.
heap에는 크기가 변하는 데이터가 저장됩니다. 동적 데이터가 저장된다고 표현합니다.
heap에 저장되는 데이터가 특이한 점이 있습니다.
데이터의 실제 내용은 heap에 저장되지만 그 heap의 주소를 가리키는 포인터(더 정확히는 헤더)는 stack에 저장됩니다. stack에 저장되는 데이터는 당연히 실제 내용이 stack에 저장됩니다.
stack에 저장되는 타입에는 프리미티브 타입과 배열, 함수의 파라미터가 있습니다. 또 고정된 데이터 타입은 아니지만 현재 실행되고 있는 구문의 객체들도 stack에 저장됩니다.
String은 heap에 저장되는 타입입니다. 좀 더 풀어서 이야기하자면, String 타입의 내용물(값)은 heap 메모리 공간에 저장됩니다. 반면에 String 타입의 헤더(포인터와 사이즈 등을 담고 있습니다)는 stack에 저장 됩니다.
다음 부분은 중요합니다.
stack에 저장되는 데이터 타입은 헤더와 내용물(값)이 모두 stack에 저장된다고 했습니다. 이런 타입의 변수들은 위와 같이 재할당 되었을 때 값이 복사되어 복사된 값이 stack에 저장됩니다. 물론 이렇게 자동으로 복사되는 것은, 이런 타입들이 Copy 트레이트를 속성으로 가지고 있기 때문에 가능한 일입니다.
반면에 문자열(String)처럼 heap에 저장되는 타입은 값의 복사가 일어나지 않습니다. 그래서 재할당했을 때 값은 heap에 하나인데 stack의 포인터는 두 개인 상황이 발생하고 에러의 원인이 됩니다. 이를 방지하기 위해서, 먼저 값을 가지고 있던 변수의 포인터가 지워지고 할당받은 변수의 포인터만 남도록 Rust는 설계되었습니다.
이런 과정을 소유권의 이동(move)이라고 합니다.
메모리에 대한 내용은 나중에 다른 글을 통해서 자세히 소개하겠습니다.
(참고 : 하나의 값을 두고 동시에 여러 개의 포인터나 스레드가 이 값을 참조해서 서로 읽고 변경하는 상황을 데이터 경합-Data Race-이라고 합니다. 데이터 경합을 미연에 방지한다는 점도 Rust의 중요한 장점 중에 하나로 소개됩니다.)
그러면 heap에 저장되면서 Copy 트레이트를 구현하지 않은 데이터 타입을 만들어서 테스트해 보겠습니다.
#[derive(Debug)] // 2
struct Point { // 1
x: i32,
y: i32,
}
fn main() {
let mut point1 = Point {
x: 1,
y: 1,
};
let mut point2 = point1;
println!("{:#?}", point1);
}
// 출력
,,,에러
2 | let mut point1 = Point {
| ---------- move occurs because `point1` has type `Point`, which does not implement the `Copy` trait
...
7 | let mut point2 = point1;
| ------ value moved here
8 | println!("{:#?}", point1);
| ^^^^^^ value borrowed here after move
|
// 1: 우리가 만든 struct 타입 객체 데이터는 heap에 저장됩니다.
// 2: #[derive(Debug)]은 derived Trait를 구조체에 적용시키는 코드입니다. derived Trait는 트레이트의 한 종류입니다. 트레이트는 구현 방식으로는 다른 언어의 인터페이스와 비슷하고 , 기능은 구조체에 특별한 특성을 부가적으로 더하는 성격을 가집니다. 트레이트에 대해서는 뒤에서 더 자세히 다루겠습니다. #[derive(Debug)]의 Debug는 println! 에서 출력 가능한 특성을 부여하는 역할을 합니다.
// 출력: 에러 메시지를 보면 Point 타입에는 Copy 트레이트가 구현되어 있지 않다고 표시됩니다.
이미 소유권은 point2로 옮겨 가 있기 때문에 point1은 값이 없습니다.
그러면 위 코드의 Point에 Copy 트레이트를 구현해 보겠습니다.
#[derive(Debug, Copy, Clone)] // 1 Copy, Clone 추가
struct Point {
x: i32,
y: i32,
}
fn main() {
let mut point1 = Point {
x: 1,
y: 1,
};
let mut point2 = point1;
println!("{:#?}", point1);
}
// 출력
Point {
x: 1,
y: 1,
}
// 1: Copy 트레이트를 구현했습니다. Copy를 구현할 때, Clone과 함께 구현해야 합니다. 뒤에서 트레이트를 다룰 때 더 자세히 알아보겠습니다.
// 출력: 이번에는 제대로 재할당 되고 출력됩니다.
Copy 트레이트가 구현되어 있는 타입에는 프리미티브 타입과 배열, 문자열 리터럴, 튜플 그리고 다음 장에서 살펴 볼 참조형이 있습니다.
참조형에서의 Copy 트레이트 구현을 확인해 보겠습니다.
main 함수는 생략했습니다.
let a1 = "hello".to_string(); // a1은 String 타입이라 Copy 트레이트 구현 안 됨
let r1 = &a1; // r1은 참조형(&String)
let r2 = r1; // r2는 참조형 변수인 r1을 재할당 받음
println!("{} {} {}", a1, r1, r2); // 1
// 출력
hello hello hello
// 1: 참조형 변수 r1, r2에는 Copy 트레이트가 구현 되어 있어서 재할당한 후에 다시 사용해도 에러가 발생하지 않습니다.
스코프에 대해서 조금 더 알아보겠습니다.
fn main() {
{
let x = "hello".to_string(); // 1
}
let y = x; // 2
println!("{}", y);
}
// 출력
...에러
5 | let y = x;
| ^ not found in this scope
위 코드를 보면 let x = “hello".to_string(); 부분이 브레이스{}로 감싸져 있습니다.
이 브레이스는 특별한 다른 기능은 없고 다만 스코프를 설정하기 위해 억지스럽게 감싼 것입니다.
Rust는 브레이스로 스코프를 구분합니다.
그래서 에러 메시지를 보면 “이 스코프에는 x가 발견되지 않는다” 고 지적하고 있습니다.
// 1: 변수 x는 main 함수의 브레이스 안에 있는 또 다른 브레이스 공간에 선언되어 있습니다.
// 2: 반면에 변수 y는 main 함수의 브레이스 공간에 선언되어 있습니다.
Rust에서 변수는 자기가 선언된 스코프를 벗어나면 소유권을 잃습니다. 어떤 자원에 대해서 소유권을 잃으면 빈 깡통이 되고 메모리 상에서 지워집니다.
그래서 에러 메시지에서 변수 x에 대해 “이 스코프에는 x가 발견되지 않는다”라고 한 까닭도 변수 x가 만들어진 스코프를 벗어난 순간 변수 x가 삭제되었기 때문입니다. 변수 y 입장에서는 존재하지 않는 변수에서 할당을 받으려고 한 것입니다.
Rust가 브레이스로 스코프를 구분하기 때문에 브레이스로 감싸져 있는 구문을 쓸 때는 조심해야 합니다.
예를 들어서 함수나 if, match, for, while 문 등등은 모두 자기 자신의 브레이스 블록을 가지고 있습니다.
당연히 그 안에서 만들어 사용한 변수들은 그 구문 범위 밖에서는 더 이상 존재하지 않게 됩니다.
이런 것은 다른 언어에서도 비슷합니다.
for 문의 예를 보겠습니다.
fn main() {
let mut sum = 0; // 1
for i in 1..=5 { // 2
println!("in for i ={} ", i);
sum += i;
}
let i = sum; // 3
println!("in main sum ={}", sum);
println!("in main i ={}", i);
}
// 출력
in for i =1
in for i =2
in for i =3
in for i =4
in for i =5
in main sum =15
in main i =15
// 1: 이 코드에서 변수 sum은 main 함수의 스코프에서 만들어졌습니다.
그래서 그 안에 포함된 for문의 스코프 안에서도 사용 가능합니다.
그리고 다시 for문 밖에 나와서도 여전히 main 함수의 스코프이므로 계속 사용 가능합니다.
// 2: 반면에 for문에서 만들어지고 사용된 변수 i는 for문을 벗어나면 사용할 수 없습니다.
// 3: main 함수에서 만들어진 변수 i는 for문에서 만들어진 변수 i와는 이름만 같을 뿐 전혀 다른 변수입니다.
변수 sum은 let i = sum; 코드를 통해서 변수 i에게 값을 재할당해 주었지만 에러가 나지 않고 제대로 출력이 됩니다.
in main sum =15
in main i =15
이것은 문자열과 달리 i32 타입에는 Copy 트레이트가 구현되어 있기 때문입니다.
그래서 재할당할 때 자동으로 값의 복사가 일어납니다.
소유권 개념은 Rust에 가비지 컬렉터를 만들지 않기 위해 고안되었습니다.
더 이상 사용하지 않는 변수에 묶여 있는 메모리를 풀어 주는 역할을 하는 것이 가비지 컬렉터입니다.
메모리를 효율적으로 사용하기 위해서는 아주 중요한 기능입니다.
이것이 없다면 프로그래머가 프로그램을 작성할 때 수동으로 일일이 풀어 주어야 합니다.
이 과정에 에러가 잘 발생하기 때문에 제대로 효율적으로 구현하기가 어렵습니다.
가비지 컬렉터는 이 작업을 자동으로 해주지만 성능, 주로 속도 면에서 손해를 보게 되는 단점이 있습니다.
그래서 Rust에서는 가비지 컬렉터 없이 가비지 컬렉터처럼 자동으로 이 작업을 해내려고 시도했습니다.
그 결과가 소유권 개념입니다.
'Rust 입문' 카테고리의 다른 글
[Rust] 모듈 (0) | 2022.10.11 |
---|---|
[Rust] 참조 (대여) (0) | 2022.10.09 |
[Rust] Result와 Rust의 에러 처리 (0) | 2022.10.07 |
[Rust] Option (0) | 2022.10.07 |
[Rust] If let (0) | 2022.10.07 |