소유권 개념은 러스트를 특별하게 만드는 방식 중 하나이다. 자바, 자바스크립트, 파이썬 같은 언어는 가비지 컬렉션을 통해 사용하지 않는 객체들을 정기적으로 제거하는 방식을 선택했고, C++ 같은 언어들은 개발자가 직접 수동으로 메모리를 할당하고 해제하도록 하였다.
C++는 높은 수준의 제어권을 보장하지만, 메모리 누수나 이중 해제와 같은 오류를 발생시킬 위험이 있다.
이중 해제 오류를 발생시키는 코드
void double_free() {
int* ptr = new int;
delete ptr;
// 이미 해제된 포인터를 다시 해제하려고 하면 이중 해제 오류가 발생함.
delete ptr;
}
int main() {
double_free();
return 0;
}
메모리 누수 오류를 발생시키는 코드
void memory_leak() {
int* ptr = new int[100];
// ...
// 사용 후 메모리 해제 생략
}
int main() {
memory_leak();
// memory_leak 함수가 종료된 후, 더 이상 ptr 포인터에 접근 할 방법이 없음.
// 따라서 메모리 누수가 발생함.
return 0;
}
러스트의 경우 소유권(ownership) 시스템을 통해 개발자가 임의로 메모리를 할당하거나 해제할 필요가 없다. 대신 러스트 컴파일러가 코드를 검사하고 메모리 관리를 자동으로 처리한다.
소유권 개념을 이해하기 위해 스택과 힙에 대한 선수 지식이 필요하다.
중요한 것은 소유권 시스템의 주요 목표는 힙 데이터의 관리라는 점이다.
변수의 스코프 개념을 설명하기 위해 문자열 리터럴의 값을 할당했다.
fn main() {
// s는 아직 유효하지 않다.
let s = "안녕하세요"; // s는 이제 유효함.
// 여기서 s는 유효함.
} // 스코프가 종료되었고, 여기서 s는 더 이상 유효하지 않다.
String
타입은 고정 크기의 문자열 리터럴과는 달리 힙에 저장된다. 힙에 저장된다는 것은 실행 중에 메모리 할당이 이루어 진다는 것을
의미하며, 사용을 모두 끝내면 메모리를 해제할 방법이 필요하다는 것이다.
String
타입의 경우 메모리를 할당하는 방법은 다음과 같다.
let s = String::from("안녕하세요");
여차 다른 언어에서도 그렇듯 메모리 할당과 해제가 하나씩 짝짓도록 만들어야 한다.
러스트에서는 이 문제를 변수가 자신의 스코프를 벗어나는 순간 자동으로 메모리를 해제하는 방식으로 해결하였다.
fn main() {
let s = String::from("안녕하세요");
// 여기서 s는 유효함.
} // 스코프가 종료되었고, 여기서 s는 더 이상 유효하지 않다.
변수 s
가 스코프 밖으로 벗어날 때가 String
에서 사용한 메모리가 자연스럽게 해제되는 지점이다. 러스트는 변수가 스코프 밖으로 벗어나면
drop
이라는 특별한 함수를 호출한다. 이 함수는 개발자가 직접 호출 할 수도 있다. 여기서는 중괄호를 벗어나는 순간 자동으로 호출된다.
러스트에서는 소유권 이동이라는 특별한 상호작용이 존재한다. 소유권의 경우 규칙 2번에서 언급한 것처럼 한 값의 소유자는 여럿 존재할 수 없다.
fn main() {
let s1 = String::from("안녕하세요"); // s1의 String의 소유권을 가짐.
let s2 = s1; // s1의 소유권을 s2로 이동.
// 여기서 s1은 더 이상 유효하지 않다.
// 따라서 더 이상 사용할 수 없다.
println!("{}", s1); // 컴파일 오류!
}
로딩 중...
정수 타입의 경우 스택에 저장되어 소유권 이동이 일어나지 않는다. 따라서 다음과 같은 코드는 컴파일 오류가 발생하지 않는다.
fn main() {
let x = 10;
let y = x;
println!("x: {}, y: {}", x, y);
}
String
의 힙 데이터를 복사하여 소유권을 이동하는 대신 복사하는 방법이 있다.
fn main() {
let s1 = String::from("안녕하세요");
let s2 = s1.clone();
println!("s1: {}, s2: {}", s1, s2);
}
정수형과 같이 컴파일 타임에 크기가 정해져 있는 데이터의 경우 굳이 clone을 호출하지 않아도 복사가 이루어진다.
정확히는 Copy
트레잇을 구현한 데이터의 경우 복사가 이루어진다. 참고
함수로 값을 전달 할 때는 변수에 값을 전달할 때와 비슷하다. 함수에 변수를 전달하면 소유권이 이동하거나 복사가 이루어진다.
fn main() {
let s = String::from("안녕하세요"); // s가 스코프 안으로 들어옴
something(s); // 소유권 이동
println!("s: {}", s); // 소유권 이동된 변수는 사용 불가
let x = 10; // x가 스코프 안으로 들어옴
something2(x); // x가 함수로 이동되지만 i32는 복사되어 이동되므로 원본 변수는 사용 가능
println!("x: {}", x); // 사용 가능
}
fn something(s: String) {
println!("s: {}", s);
} // 여기서 s가 스코프 밖으로 벗어나고 'drop' 함수가 호출되어 메모리 해제
fn something2(x: i32) {
println!("x: {}", x);
} // 여기서 x가 스코프 밖으로 벗어나지만 별다른 일이 일어나지 않음.
소유권은 값을 반환하는 과정에서도 이동한다.
fn main() {
let s = String::from("안녕하세요"); // s가 스코프 안으로 들어옴
let s = something(s); // 소유권 이동이 일어나지만 반환값을 변수에 할당하므로 소유권이 다시 이동됨
println!("s: {}", s); // 소유권이 다시 이동된 변수는 사용 가능
}
fn something(s: String) -> String {
println!("s: {}", s);
s // 반환값을 변수에 할당하므로 소유권이 다시 이동됨
}
사실 이런식으로 소유권을 가졌다가 반납하는 방식은 조금 번거롭다. 이를 위해 러스트에서는 참조자와 빌림 개념이 존재한다.