자바스크립트 같은 경우는 가비지 콜렉터를 통해 메모리 관리를 하는데
러스트는 소유권이라는 개념으로 메모리를 관리한다.
들어가기 전에...
일단 러스트 변수들은 블록 스코프를 따른다
{ let s = "Hello"; println!("{}", s); // Hello } // s 소멸 println!("{}", s); // ERROR
기본적으로 크기가 정해져 있지 않은 배열과 같은 데이터 타입들은
스택에 저장될 수 없기 때문에 힙 메모리에 저장되게 된다.또한, 이들을 함수의 인자로 전달하면 그 함수의 스택에 이들이 담기게 된다.
만약 함수 인자들이 힙 메모리에 머물러 있다면, 힙 데이터 구조 상 성능이 하락할 수 밖에 없다.
따라서 함수만의 스택을 만들어 속도를 높이고,
함수가 끝나면 이 스택을 비우는 과정을 통해 메모리 관리를 하게 된다.하지만 함수의 인자로 넘겨진 변수가, 함수가 끝난 후 더 이상 힙 데이터에 남아있을 필요가 없다면?
이런 경우에는 스택을 비우더라도 힙 데이터 구조에는 변수가 남아 있게 된다.
우린 이때까지 선언과 함께 크기가 정해지는 원시 타입들만 배웠기 때문에
동적으로 크기가 변할 수 있는 조금 더 복잡한 데이터 타입에 대해 배워보자
let s = "hello"; // 문자열 리터럴
let t = String::from("hello"); // String 타입
문자열 리터럴은 변경이 불가하지만, String 타입은 내용을 바꿀 수 있다.
그 이유는 위에서도 언급했듯이 String 타입은 동적 크기를 갖고, 힙 메모리를 이용하기 때문이다.
하지만 가비지 컬렉터가 없다면, 사용하지 않는 메모리를 개발자가 직접 처리해야하는데,
이를 제대로 수행하는 것이 예전부터 쉽지 않았다. (너무 빨리 제거, 혹은 중복 해제 등등)
이를 위해 단 한번씩의 할당과 해제로 메모리를 관리하는 것이 바람직하다.
러스트는 이를 어떻게 해결했을까?
바로 변수가 스코프를 벗어나는 순간, 할당된 메모리는 자동으로 해제하는 방식을 사용한다.
이는 러스트의 drop
이라는 특별한 함수가 자동으로 처리해주고 있다.
{
let s = String::from("hello") // s는 지금부터 유효
} // 여기서 범위를 벗어나게 되는 순간, s는 이제 유효하지 않다.
let x = 5;
let y = x;
정수와 같은 고정 크기의 단순한 값은 복사하더라도, 동일한 값 2개가 스택에 저장된다.
당연히 x와 y 둘다 접근이 가능하고, 값도 동일하게 복사된다.
이렇게 작동되는 이유는 Copy
트레이트가 적용되어 있어서인데, 이는 나중에 더 알아보자.
반대로 String 타입과 같은 가변 메모리는 값을 가리키는 포인터 값이 복사되어 스택에 저장된다.
즉, 데이터 값은 동일한데, 이 데이터를 가리키는 포인터가 2개 생기는 것이다.
let s1 = String::from("hello");
let s2 = s1;
단순하게 코드만 봤을 땐, 그냥 자바스크립트 객체 복사와 비슷한 것처럼 보인다.
하지만 러스트는 위와 같이 작성하면 복사하지 않고, s1
과 같은 첫 번째 변수를 무효화시킨다.
따라서 s1
에 접근할 수 없고, s1
에서 s2
로 move
했다고 표현한다.
만약 스택 데이터가 아닌 힙 메모리에 저장된 데이터가 복사되길 원한다면
clone
이라는 공통 메서드를 사용하면 된다.
이러면 동일한 데이터가 힙메모리에 2개 생기고, 각각의 데이터를 가리키는 포인터가 복사되어 스택에 쌓인다.
Q : 그럼 특정 타입이 Copy인지 Move인지 어떻게 확인할 수 있는거지?
A : 그 타입의 Document를 확인해서Copy trait
이 있는지 확인해보면 된다.
허나 보통은 선언 당시의 크기가 변하지 않는 타입들은Copy
트레이트가 있다고 보면 된다.
Rust에서 함수 인자로 변수를 전달하면, 변수에 할당된 값을 Copy하거나 Move하게 된다.
만약 Move 하는 경우라면, 변수의 소유권은 함수인 상태가 되고
만약에 그 함수가 다시 변수를 return하지 않고 끝내면, 그 변수는 drop되게 되어 할당이 해제된다.
fn main() {
let s = String::from("hello");
takes_ownership(s); // Move
// 더 이상 s에 접근할 수 없음
let x = 5;
makes_copy(x); // Copy
// x에 계속 접근 가능
let s2 = String::from("hi");
let s3 = take_and_give_back(s2);
// println!("{}", s); // Error
println!("{}", s3); // hi
}
fn takes_ownership(some_string: String) {
println!("{}", some_string);
}
fn take_and_give_back(some_string: String) -> String {
println!("{}", some_string);
some_string
}
fn makes_copy(some_integer: i32) {
println!("{}", some_integer);
}
매번 소유권을 받았다 갖다줬다 하는게 쉽지 않기에, Reference(참조)
와 Borrowing
이라는 개념이 존재한다.
Borrowing
으로 어떤 변수의 Reference
를 받아오면, 잠깐 빌렸다는 의미이기에
소유권을 다시 주지 않아도 원래의 스코프에서 변수의 값을 참조할 수 있다.
변수의 참조를 함수에 넘겨주면 소유권이 변경되지 않기 때문에,
함수가 끝나고 변수를 다시 넘겨주지 않더라도, 기존의 선언된 변수를 그대로 사용할 수 있음
fn main() {
let s1 = String::from("hello");
let len = calculate_length(&s1);
println!("The length of '{}' is {}.", s1, len);
}
fn calculate_length(s: &String) -> usize {
s.len()
}
Reference는 아쉽게도 값을 변경하지는 못한다.
만약 참조를 통해 변수의 값 변경하고 싶다면 Mutable Reference(가변참조)
를 이용해야 한다.
&mut a
와 같이 mut
키워드만 붙여주면 되는데, 한 가지 제한 사항이 따라 붙는다.
바로 한 시점에서 하나의 변수에, 단 하나의 참조만 허락하는 것인데
let mut s = String::from("hello");
let r1 = &mut s;
let r2 = &mut s;
println!("{}, {}", r1, r2);
다음과 같이 r1
이 s
를 가변 참조하고 있는 시점에서, r2
도 s
의 가변 참조를 시도하면
컴파일 단계에서 에러를 리턴하게 된다.
이는 다른 언어에 비하면 꽤나 까다로운 제한 사항인데, 러스트가 이런 제한 사항을 두는 이유는
애초에 컴파일 단계에서 data race
를 방지하기 위해서이다.
data race 란?
다음과 같은 조건에서 발생하는 race condition이다.
- 한 시점에서 같은 데이터에 두개 이상의 포인터가 접근하고 있으며
- 적어도 하나의 포인터가 데이터를 변경하려고 하며
- 데이터의 싱크를 맞추는 메커니즘이 없을 때
이런 이슈가 발생했을 때 버그를 잡는게 굉장히 어렵기에,
러스트는 이를 컴파일 단계에서 걸러주는 작업을 하고 있는 것이다.
따라서 참조를 사용할 땐 다음을 기억하자!
마지막 주의 사항을 조금 더 살펴보자면
fn main() {
let mut s = String::from("hello");
let r1 = &s; // (1)
let r2 = &s; // (2)
let r3 = &mut s; // (3)
println!("{} and {}", r1, r2);
let r4 = &mut s; // (4)
println!("{}", r4);
}
fn calculate_length(s: &String) -> usize {
s.len()
}
다음과 같은 코드에서, r1과 r2가 print
매크로에서 사용됨에도 불구하고,
그전에 r3
로 가변 참조를 시도하기에 에러가 나게 된다.
만약 (4)의 상황처럼, 더 이상 선언된 불변 참조가 사용되지 않는다면
가변 참조를 사용해도 상관없다.
String의 참조를 이용해서 새로운 변수를 만들어낼 때.
만들어진 변수는 기존의 변수에 싱크되지 않는 문제가 발생할 수 있다.
예를 들어
fn first_word(s: &String) -> usize {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return i;
}
}
s.len()
}
fn main() {
let mut s = String::from("hello world");
let word = first_word(&s); // word: 5
s.clear(); // s = ""
}
위의 코드에서, s.clear()로 s가 빈 문자열이 됐음에도 불구하고,word는 5로 남아 있는다
그 이유는 word가 s의 context와 전혀 상관없는 변수이기 때문이다.
만약 word라는 변수가 사용되기 이전에, 개발자가 자기도 모르게 s를 변경 시켜 버린다면
예상치 못한 런타임 에러가 나타날 수 있다.
그래서 러스트는 이를 컴파일 단계에서 잡기 위해 Slice 개념을 이용한다.
즉, 참조한 값으로 새로운 변수를 만들어 낼 때,
새로 만든 변수가 이전 변수와 싱크가 이루어져야 할 때,
다시 말해 새 변수가 이전 변수의 변경에 민감하게 반응해야할 때
그럴 때 slice를 쓰면 된다.