Rust를 매력적이고 특별한 언어로 만드는 이유를 이해하려면,
반드시 Ownership 오너십 규칙을 알아야 한다.
모든 프로그래밍 언어는 각자의 방법으로 메모리 관리를 한다.
1. Garbage collection(GC)를 이용해 자동으로 안쓰는 메모리를 해제
(e.g. Java, Python, C#, Javascript, Go 등 대부분의 언어)
2. 메모리 할당과 해제를 프로그래머가 직접 명시
(e.g. C, C++, Object-C 등)
Rust는 위 둘 중 어느것도 아닌 ownership 시스템을 통해 메모리 관리를 한다.이는 컴파일러가 몇 가지 규칙들을 기준으로 컴파일타임에 실시한다. 오너십 개념을 제대로 이해하기 위해서는 heap과 stack에 대한 이해가 있어야 한다. 이 개념은 원문에서 참고 가능하다.
오너십 규칙
- Rust의 모든 값(value)은 owner라 불리는 변수들을 가지고 있다.
- 하나의 값은 하나의 owner만 가질 수 있다.
- owner가 scope밖으로 나가게 되면 그 값도 사라진다.
{ // s는 아직 유효하지 않다. 호출시 Error
let s = "hello"; // 여기서부터 s가 유효하다.
// 여기서도 s는 유효하다.
} // 이제 s는 유효하지 않다. 호출시 Error
이제 이 개념을 Rust의 String 타입에 대해 이야기해보겠다.
1. String 타입
String타입은 메모리에 할당되는 방식이 일반 데이터타입과는 조금 다르다. 일반 데이터타입은 변수의 크기가 정해져 있기 때문에 정해진 크기의 메모리를 stack으로부터 할당받지만, String타입은 컴파일타임에도 해당 변수에 어떤 크기의 값이 들어갈지 모르는 경우도 있다.
(e.g. 유저로부터 입력을 받거나 프로그램 진행상황에 따라 다른 값이 들어오게 되거나)
이러한 이유로 String타입은 heap영역으로부터 메모리를 할당받는다. 이를 위해서는 아래 두가지 작업이 이루어져야 한다.
- 런타임에 메모리를 os에게 요청한다.
- 해당 String 변수로 할 일이 끝났다면 할당받은 메모리르 돌려준다.
1번 작업은 String::new 혹은 String:from 같은 해당 타입의 메서드를 호출함으로 해결된다. 하지만 2번 작업은 조금 다르다.
GC가 있는 언어들은 2번 작업을 알아서 처리해준다. 하지만 이는 오버헤드가 따르기 때문에 C나C++같은 퍼포먼스를 중요시하는 언어에서는 프로그래머가 직접 메모리를 해제하는 방법이 쓰인다. Rust에서는 그 메모리 공간을 소유한 변수가 scope을 나가게 될 때 자동으로 메모리가 해제된다. 위에서 봤던 예제를 다시 보자.
{
let s = "hello"; // 여기서부터 s가 유효하다.
// 여기서도 s는 유효하다.
} // s는 메모리를 반환한다.
변수가 scope에서 나가게 되면 Rust는 drop이라는 특별한 함수를 자동으로 호출한다. (중괄호가 닫힐 때 drop이 자동으로 불린다.)
간단히 보이지만 여러 변수들이 heap을 사용하게 되면 이 과정이 꽤나 복잡해진다.
/// 러스트에서 복제는 얕은 복사처럼 보이지만
/// 소유권이 이전된 이후에 본래의 변수가 무효화 되기 때문에
/// '이동'되었다고 보는 것이 맞다.
let s1 = String::from("hello");
let s2 = s1;
println!("{}, world!", s1);
/// ERROR 발생!
error[E0382]: use of moved value: 's1'
-- 후략 --
핵심 미리보기
위의 주석을 풀어서 설명하자면,
메모리 공간만 복사하는 위 과정은 shallow copy 와 비슷해 보이지만 Rust는 copy된 변수는 그 후로부터 무효화시키기 때문에 Rust에서는 이를 Shallow copy가 아닌 move라고 부른다. 위 예제에서는 “s1 이 s2 로 move됐다” 라고 표현한다. s1 = s2 코드가 실행된 이후 메모리 공간은 아래 그림 1–4와 같아진다.
참고로 Rust는 프로그래머가 명시하지 않는 한 절대 “deep” copy를 하지 않는다. 때문에 자동복사가 일어나는 곳에서도 런타임 퍼포먼스에 영향을 받을 걱정은 하지 않아도 된다.
위와 같은 에러가 발생한 이유를 살펴보자.
s1이 할당됐을 때, 메모리 공간은 아래와 같이 s1을 저장한다.
왼쪽 테이블은 stack영역을 나타내며 오른쪽 테이블은 heap영역을 나타낸다. stack에서는 heap영역에서 해당 변수가 시작되는 메모리의 주소와 그 후로 얼만큼이 이 변수가 차지하는 공간인지를 나타내기 위해 len, capacity 값을 갖고 있다. 그리고 실제 데이터는 heap영역에 저장된다.
이제 s2 = s1 이 실행된 후의 메모리 공간을 아래에서 살펴보자. s2은 s1이 할당받았던 메모리 공간을 그대로 가리킨다.
아래와 같은 모양으로 저장되지 않는다는 뜻이다. 만약 아래와 같이 저장된다면 큰 변수가 여러개로 복사되면 중복된 정보가 쓸데없이 여기저기 퍼지게 될 것이다.
이와 같이 같은 메모리 공간을 가리키고 있기 때문에, 두 변수 모두 같은 메모리를 해제하려고 시도하면
결국 둘 중 하나는 빈 메모리를 해제하려고 시도하게되며, 이는 메모리 중복해제 에러로 이어진다.
Rust는 이러한 잠정적인 에러를 막기 위해 다른 변수에게 메모리가 복사된 변수를 부르는 것을 허용하지 않는다. 이러한 이유로 s1으로부터 값을 불러오려는 println함수에서 에러가 발생하게 된 것이다.
2. Clone
만약 변수를 복사할 때 shallow copy가 아닌 deep copy를 하고 싶은 경우에는 변수들의 공통 메서드인 clone메서드를 사용한다.
clone을 이용한 복사는 해당 런타임 상황에 따라 다르게 실행되기 때문에 자주 사용할시 프로그램 속도에 영향을 줄 수 있다.
let s1 = String::from("hello");
let s2 = s1.clone(); // 따라서 복사하고자 할 때는 clone을 이용해야 한다.
3. Stck only Data: Copy
Clone을 사용하지 않았는데 에러가 발생하지 않는 경우도 있다.
let x = 5;
let y = x;
println!("x = {}, y = {}", x, y);
/// 정수는 이미 알려진 크기를 갖은 채 스택에 완전히 저장되기 때문에,
/// 실제 값의 복사본을 빠르게 만들 수 있고,
/// 변수를 생성한 후 유효하지 않도록 방지할 이유가 없다.
그 이유는 정수, 문자, 불리언, 배열 등 데이터의 크기가 정해져있는 변수들은 deep copy, shallow copy 구분없이 모두 stack영역에 값이 저장되기 때문이다. 즉 clone을 통해서 복사를 하더라도 그렇지 않았을 때와 완전히 같은 방식의 복사가 이루어진다.
Rust에서는 위와 같이 크기가 정해져있는 변수들이 Copy trait이란 것을 갖고있다. Copy trait을 갖는 변수는 위 코드와 같이 값이 복사된 이후에도 복사된 원본 변수를 그대로 사용할 수 있다. String 타입은 Copytrait이 없기 때문에 기존 변수를 사용할 수 없는 것이다. 대신 String은 Drop trait을 사용하며 Rust는 Copy 와 Drop trait를 동시에 가질 수 없게 해준다.
일반적으로
스칼라 변수는 copy를 가지며 메모리를 할당받을 필요가 없거나 어떤 자원의 형태인 경우에도 copy를 갖는다.
copy를 갖는 변수들
- u32와 같은 모든 정수타입
- 불리언타입
- f64와 같은 실수타입
- 문자타입
- 튜플
(copy를 갖는 타입으로 이루어진 튜플의 경우에만 가능하다. (i32, i32)는 가능하지만, (i32, String)은 불가능하다.
4. 함수에서의 ownership
fn main(){
let s = String::from("hello"); // 사용 범위 안에 들어 온다.
takes_ownership(s); // 해당 함수로 변수가 이동 하고 더 이상 이 곳 에서는 사용할 수 없다.
let x = 5; // 사용 범위 안에 들어 온다.
makes_copy(x); // 해당 함수로 변수가 이동 하지만, 자동으로 복사 되었기 때문에 이 곳 에서도 사용할 수 있다.
}
fn takes_ownership(some_string: String) { // some_string comes into scope
println!("{}", some_string);
} // 여기서 파라미터의 범위가 끝나고 'drop'이 호출 되며, 백업 메모리가 확보된다.
fn makes_copy(some_integer: i32) { // some_integer comes into scope
println!("{}", some_integer);
} // 여기서 int 파라미터의 범위가 끝나지만, 특별히 바뀌는 것은 없다.
요약
ownership은 Rust에서 워낙 중요한 개념이라 다시 정리해보면 아래와 같다.
- Rust는 ownership 기반으로 메모리를 관리
- 오너십을 가진 변수가 scope에서 빠져나갈 경우 drop 함수가 자동으로 호출되고 이때 메모리 해제가 이루어진다.
- 하나의 값(메모리공간)의 오너십은 하나의 변수만이 가질 수 있으므로 중복해제 에러가 일어나지 않는다.
- 오너십은 String타입과 같이 할당받을 메모리의 크기가 정해져 있지 않은 타입들에 대해서만 적용된다.
- 함수 단위에서도 오너십은 같은 방식으로 동작한다.
Rust는 직접 메모리를 관리해줘야하는 불편함없이 거의 동일한 수준의 퍼포먼스를 내기 위해서 ownership을 이용한다.
해당 글은 Rust 튜토리얼 가이드와 medium의 설명글을 참고하여 작성하였다.
'Language > Rust' 카테고리의 다른 글
[Rust]소유권 - string slice (0) | 2022.12.28 |
---|---|
[Rust]소유권 - referrence, borrowing (0) | 2022.12.28 |
[Rust]Control Flow 제어문 (0) | 2022.12.22 |
[Rust]Functions:Statements&Expressions (0) | 2022.12.22 |
[Rust]데이터타입 (0) | 2022.12.22 |