Rust 기본 개념 - 소유권
Rust
메모리 안전 언어로 저수준의 제어가 가능하면서도 메모리 제어를 사용자가 하지 않도록 하는 것을 목표로 하는 언어입니다.
Java, Javascript 같은 언어들은 이미 개발자가 메모리 제어를 하지 않아서 별 신경을 안썻지만, C, C++ 같은 C 계열은 개발자가 메모리를 하나하나 제어해줘야 합니다.
예를들어 특정 메모리 공간을 할당만 해놓고 해제하지 않으면 해당 메모리 공간을 의미없이 점유하게 되는 문제가 생깁니다.
이를 메모리 릭 (Memory Leak) 이라고 합니다 .메모리 릭은 힙 영역에 할당된 메모리를 해제하지 않아서 발생합니다.
힙 영역에 할달된 메모리는 함수호출이 종료되어도 해제되지 않습니다.
개발자가 직접 명시적으로 해제 해야 해제 됩니다. 그로인해 함수가 호출되면 될수록 새로운 메모리 공간을 할당하며 메모리 사용량이 계속 늘어나는 버그가 생깁니다.
또한 이미 해제된 메모리 주소를 포인터가 가리키고 있는 경우, 해당 포인터를 통해 값을 가져오는 로직이 있다면 쓰레기값이 반환될 수 있다는 문제가 생깁니다. 정상로직에서 쓰레기값이 반환되면 이는 프로그램 에러로 이어질 수 있습니다.
러스트는 이런 메모리 문제에서 자유로울 수 있는 언어입니다.
특징
러스트는 소유권, 참조 및 빌림, 생명주기 세가지 특징을 가집니다.
이 글에서는 소유권에 대해 알아보겠습니다.
소유권
각 언어들은 반드시 메모리 제어 방식을 정의해야 합니다. C 계열은 이를 개발자가 수동으로 제어하도록 정의하였고, Java, Javascript 같은 언어들은 가비지 컬렉션을 통해 자동으로 메모리를 제어해줍니다. Rust에서는 이를 소유권이라는 하나의 규칙으로 관리를 합니다.
- 러스트의 각각의 값은 해당값의 오너(owner)라고 불리우는 변수를 갖고 있다.
- 한번에 딱 하나의 오너만 존재할 수 있다.
- 오너가 스코프 밖으로 벗어나는 때, 값은 버려진다(dropped).
스코프
아래와 같은 스코프가 있다고 가정했을 때 해당 스코프를 벗어나면 s라는 변수에게 할당된 메모리 공간은 자동으로 해제된다 라는 의미 같습니다. 예시를 보면 다른 언어와 다를게 없어보이긴 하지만 내부적인 처리 과정이니 아무튼 일종의 유효기간을 달아둔 것이라 생각 하면 될 것 같습니다.
{
let s = "hello";
}
다음은 스트링 리터럴이 아닌 동적인 값을 제어하는 방식입니다. 또 얼핏보기엔 다른 언어와 차이가 없어보입니다.
하지만 내부 구현에선 조금 차이가 있는 것 같습니다. 가비지 콜렉션이 있는 언어에서는 변수를 선언,할당,사용하고 그대로 두면 알아서 해제 해줄테지만 C,C++ 등에선 명시적으로 해제 해주는 과정이 필요합니다. 이때 러스트는 해당 과정을 스코프가 끝날 때 drop 함수를 호출 하는 식으로 처리합니다.
{
let s = String::from("hello");
} // drop 함수 실행됨 ( 메모리 해제 해주는 함수 )
이동
기존 언어에서 복사 라고 부르던 형태인데 러스트 에서는 이를 이동 이라는 행위로 표현합니다.
아래 예시는 변수 x의 값을 y에 복사하는 간단한 코드 입니다.
#1
let x = 5;
let y = x;
#2
let s1 = String::from("hello");
let s2 = s1;
#1의 정수는 실제 다른 언어처럼 값이 복사됩니다.
아래와 같은 조건을 통과하면 Copy 트레이트를 구현하여 값을 복사하도록 할 수 있습니다.
- 고정된 크기: 크기가 컴파일 시점에 정해져야 합니다.
- 단순한 메모리 레이아웃: 힙 할당이 필요하지 않습니다.
- 소유권 이동이 안전하지 않음: 복사된 값이 독립적으로 유지되어야 합니다.
즉 위 조건에 통과하는 값들은 기본동작으로 Copy 할 수 있도록 한다 라는 일종의 규칙 입니다.
반면에 #2는 처리 방식이 조금 다릅니다.
s2에 복사한 s1의 값은 유효하지 않다고 간주해버립니다. 즉 소유권이 이동한겁니다.
왜 이런식으로 처리를 하는가?
- Copy 트레이트를 구현할 수 없습니다. from을 통해 동적으로 값을 받기에 컴파일 시점에 고정된 크기를 가질 수 없습니다.
- DoubleFree ( 해제된 메모리 다시 해제 ) 문제 방지 목적
만약 똑같이 s2에 s1의 값을 복사한다면 결국 두 변수는 같은 메모리 공간을 바라보게 됩니다.
앞에서 러스트는 스코프가 끝나면 drop을 항상 실행한다고 했는데 이때 이런 처리가 문제가 됩니다.
s1, s2 모두 drop의 대상이 되는데 그렇게 되면 같은 메모리 공간을 두번 해제하는 문제가 생깁니다.
이는 버그로 이어질 수 있기 때문에 이동(move)라는 방식으로 처리 한다고 합니다.
그럼 복사는 못하는건가?
clone 이라는 메서드를 제공하고 있어서 타 언어의 깊은 복사와 같은 형태로 작동 하게 할 수도 있습니다.
이는 실제 메모리 공간을 하나 더 할당하여 거기에 복사하고 s2는 해당 메모리 공간을 바라보는 형태입니다.
예시
다음 예시는 조금 더 실제와 비슷한 예시 입니다. takes_ownership 함수를 호출하고 이후에 s 변수를 사용하려하면 에러가 발생합니다.
이는 소유권이 함수 내부로 이동한 것입니다. 이전에 설명한 이동(move)에 해당합니다. 반면에 x 변수는 이후에 사용해도 별 문제가 발생하지 않습니다. 이전에 설명했던 Copy 트레이트를 구현할 수 있기 때문에 정상작동합니다.
fn main() {
let s = String::from("hello"); // s가 스코프 안으로 들어왔습니다.
takes_ownership(s); // s의 값이 함수 안으로 이동했습니다...
// ... 그리고 이제 더이상 유효하지 않습니다.
let x = 5; // x가 스코프 안으로 들어왔습니다.
makes_copy(x); // x가 함수 안으로 이동했습니다만,
// i32는 Copy가 되므로, x를 이후에 계속
// 사용해도 됩니다.
} // 여기서 x는 스코프 밖으로 나가고, s도 그 후 나갑니다. 하지만 s는 이미 함수 안으로 이동 했습니다.
// main takes_ownership 함수를 실행 후 다시 s를 사용하려하면 에러가 발생 합니다.
fn takes_ownership(some_string: String) { // some_string이 스코프 안으로 들어왔습니다.
println!("{}", some_string);
} // 여기서 some_string이 스코프 밖으로 벗어났고 `drop`이 호출됩니다. 메모리는
// 해제되었습니다.
fn makes_copy(some_integer: i32) { // some_integer이 스코프 안으로 들어왔습니다.
println!("{}", some_integer);
} // 여기서 some_integer가 스코프 밖으로 벗어났습니다. 별다른 일은 발생하지 않습니다.
반환값과 스코프
값의 반환도 소유권을 이동 합니다. 함수의 호출 결과가 변수에 할당되고 소유권도 변수로 이동 합니다.
fn main() {
let s1 = gives_ownership(); // gives_ownership은 반환값을 s1에게
// 이동시킵니다.
let s2 = String::from("hello"); // s2가 스코프 안에 들어왔습니다.
let s3 = takes_and_gives_back(s2); // s2는 takes_and_gives_back 안으로
// 이동되었고, 이 함수가 반환값을 s3으로도
// 이동시켰습니다.
} // 여기서 s3는 스코프 밖으로 벗어났으며 drop이 호출됩니다. s2는 스코프 밖으로
// 벗어났지만 이동되었으므로 아무 일도 일어나지 않습니다. s1은 스코프 밖으로
// 벗어나서 drop이 호출됩니다.
fn gives_ownership() -> String { // gives_ownership 함수가 반환 값을
// 호출한 쪽으로 이동시킵니다.
let some_string = String::from("hello"); // some_string이 스코프 안에 들어왔습니다.
some_string // some_string이 반환되고, 호출한 쪽의
// 함수로 이동됩니다.
}
// takes_and_gives_back 함수는 String을 하나 받아서 다른 하나를 반환합니다.
fn takes_and_gives_back(a_string: String) -> String { // a_string이 스코프
// 안으로 들어왔습니다.
a_string // a_string은 반환되고, 호출한 쪽의 함수로 이동됩니다.
}
그럼 함수에게 전달한 인자는 해당 함수만 사용할 수 있는건가? 하는 생각이 듭니다.
당연히 그러지 않아도 됩니다. 아래 예시처럼 함수가 반환한 값을 튜플로 받으면 됩니다.
fn main() {
let s1 = String::from("hello");
let (s2, len) = calculate_length(s1);
println!("The length of '{}' is {}.", s2, len);
}
fn calculate_length(s: String) -> (String, usize) {
let length = s.len(); // len()함수는 문자열의 길이를 반환합니다.
(s, length)
}
공식 문서에서는 이 방식보단 참조자를 사용하는 방식에 대해 더 자세히 설명하고 있습니다.
다음 글에서 알아보겠습니다.