Rust - Memory Ownership
Rust는 ownership이라는 접근법으로 메모리를 관리합니다.
Rust 맛보기
- 1. Rust - 입문하기
- 2. Rust - 타입 모음
- 3. Rust - Memory Ownership
- 4. Rust - Control Flow
- 5. Rust - Structured Data Types
- 6. Rust - Project Organization
- 7. Rust - Error Handling
- 8. Rust - Collections
- 9. Rust - Generics
- 10. Rust - Test Automation
- 11. Rust - Functional Programming
- 12. Rust - Memory Management
Ownership Rules
-
R1. Rust에서 각 value는 owner를 갖는다
-
R2. 한번에 하나의 owner만 존재할 수 있다.
-
R3. owner가 scope 밖으로 나가면, value는 drop된다(= value가 점유하던 메모리 반환).
R3 원리
{
let s = String::from("hello"); // s is valid from this point forward
// do stuff with s
} // this scope is now over, and s is no longer valid
variable이 scope 밖으로 나가면, Rust는 drop()
함수를 호출합니다. 즉, Rust 컴파일러는 }
부분에 drop()
을 호출하는 코드 라인을 자동으로 삽입합니다. 이런 작업은 컴파일 타임에 이루어지므로, 런타임에 overhead가 발생하지 않습니다. 다시 말해 Zero-cost abstraction이 가능합니다.
이런 방식으로 어떤 item의 lifetime의 끝에 resource를 할당 해제하는 패턴을 Resource Acquisition Is Initialization이라고도 합니다.
Move: Interaction Between Variables and Data
Simple Example
let x = 5;
let y = x;
여기서 두 개의 5
값은 스택에 push됩니다(Deep copy). 스택은 함수가 리턴할 때 비워지고, 컴파일 타임에 크기가 결정되므로 아무 문제가 없습니다(Stack-Only Data: Copy).
값 5
의 Owner가 여러 개가 된다는 것이 R1에 위배되는 것처럼 보이기도 합니다. 하지만 서로 다른 값이 별개의 메모리 공간에 저장되므로, 두 개의 5
가 각각의 owner를 가지고 있기 때문에 이는 문제가 되지 않습니다.
이러한 방식이 가능한 타입에 Rust는 Copy
trait이라는 이름을 붙입니다.
String
타입의 경우
let s1 = String::from("hello");
let s2 = s1;
String
의 포인터가 가리키는 str
는 heap에 저장되므로, s1
과 s2
는 heap에 저장된 데이터를 가리키는 포인터를 갖습니다.
s2
에 s1
을 할당할 때, String
데이터는 그대로 복사된다고 가정해 봅시다(즉, 스택 내 포인터, len
, capacity
가 복사). 그러나 이 상황은 R2를 위반합니다. 예를 들어, s1
과 s2
가 모두 scope 밖으로 나가면, 스택 내 두 변수의 포인터가 같은 메모리(heap 영역)를 가리키게 되어 double free가 발생합니다.
실제로는 s1
의 ownership이 s2
로 이동(move)됩니다. Rust는 s1
이 더 이상 유효하지 않다고 간주합니다. 이 부분이 단순한 shallow copy와의 차이점이기도 합니다.
이후 s1
에 접근을 시도하면, Rust는 컴파일 에러를 발생시킵니다.
.clone()
메소드로 Deep copy 수행
String
의 heap 데이터를 복사하려면, .clone()
메소드를 사용합니다.
let s1 = String::from("hello");
let s2 = s1.clone();
println!("s1 = {}, s2 = {}", s1, s2);
단 heap의 데이터 크기가 크다면 runtime performance 관점에서 매우 비싼 operation이 될 수 있으니 주의해야 합니다.
Copy trait
처음 소개된 예시에서처럼, stack-only 데이터는 Copy
trait을 가집니다. 즉 .clone()
메소드가 없어도 기본적으로 assign 시 deep copy가 이루어집니다.
heap allocation을 요구하지 않고 스택에 저장되는 데이터 타입은 Copy
trait을 가질 수 있습니다. 이는 다음과 같은 타입들을 포함합니다.
- 모든 integer 타입
- boolean
- floating point
- char
- tuple 및 array (모든 element가
Copy
trait을 가질 때)
Ownership and Functions
함수에 변수를 전달할 때도 ownership의 이동 또는 값의 복사가 발생합니다.
fn main() {
let s = String::from("hello");
takes_ownership(s);
// s is no longer valid
let x = 5;
makes_copy(x);
}
fn takes_ownership(some_string: String) {
println!("{}", some_string);
} // some_string goes out of scope and `drop()` is called
fn makes_copy(some_integer: i32) {
println!("{}", some_integer);
} // some_integer goes out of scope, nothing special happens
takes_ownership
함수에 s
를 전달하면 ownership이 이동하므로 s
는 더 이상 유효하지 않습니다. 반면 makes_copy
함수에 x
를 전달하면 값이 복사되므로 x
는 여전히 유효합니다.
물론, ownership을 다시 되찾고 싶다면, 함수에서 반환값을 사용하면 됩니다.
fn main() {
let s1 = gives_ownership();
let s2 = String::from("hello");
let s3 = takes_and_gives_back(s2);
}
fn gives_ownership() -> String {
let some_string = String::from("hello");
some_string
}
fn takes_and_gives_back(a_string: String) -> String {
a_string
}
gives_ownership()
함수 내에서 생성된 String
의 ownership이 main
함수의 s1
에게로 넘어오고, takes_and_gives_back()
함수에서 s2
의 ownership이 함수 내부로 갔다가 바로 s3
로 넘어갑니다.
Reference and Borrowing
그런데 함수 호출 시마다 ownership을 넘기면 부모 함수에서 ownership을 되찾기 위해 반드시 return value를 사용해야 합니다. 이는 코드의 가독성을 떨어뜨리고, 불필요한 overhead를 발생시킬 수 있습니다.
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();
(s, length)
}
함수가 호출될 때, 반드시 ownership을 넘기지 않고도 값을 참조할 수 있습니다. 이를 borrowing이라고 합니다.
fn main() {
let s1 = String::from("hello");
let len = calculate_length(&s1); // s1의 ownership이 넘어가지 않음 (전달되는 것은 reference임)
println!("The length of '{}' is {}.", s1, len);
}
fn calculate_length(s: &String) -> usize {
s.len() // 사실 (*s).len()과 동일
// (*s)의 ownership을 갖고 있지 않으므로, s가 scope 밖으로 나가도 (*s)는 drop되지 않음 (당연히)
}
&
를 사용하여 reference를 생성하고, &String
타입을 사용하여 String
의 reference를 전달합니다.
&
를 사용하여 reference를 생성하면, ownership을 넘기지 않고도 값을 참조할 수 있습니다. 이때, reference를 사용하는 함수는 reference된 변수의 값을 변경할 수 없습니다. 이를 immutable reference라고 합니다.
reference는 포인터와 비슷하지만, 포인터와 달리 reference는 항상 유효한 값을 가리키고 있어야 합니다. 이를 dangling pointer를 방지하기 위한 Rust의 안전성 체크라고 할 수 있습니다(memory-safer).
Mutable Reference
fn main() {
let mut s = String::from("hello");
change(&mut s);
}
fn change(s: &mut String) {
s.push_str(", world");
}
&mut
을 사용하여 mutable reference를 생성하면, reference된 변수의 값을 변경할 수 있습니다. 이때, target variable도 mutable variable 이어야 합니다. 또, mutable reference는 오직 하나만 존재해야 합니다. 이는 R2를 준수하여 data races를 방지하기 위함입니다.
Rust는 근본적으로 Single-Writer Multiple-Reader (SWMR) 모델을 따릅니다. 이는 한 번에 최대 하나의 writer가 존재해야 할 뿐만 아니라, write operation이 진행 중일 때에는 다른 operation(특히, read operation)이 진행되어서는 안 된다는 것을 의미합니다. Rust는 여기서 더 강한 제약을 두어, immutable reference가 존재할 때에는 mutable reference가 존재하지 않도록 합니다.
단 reference의 scope는 그것이 선언되는 지점부터 마지막으로 그 reference가 사용되는 지점까지이므로, scope를 벗어나면 새 mutable reference를 생성할 수 있습니다.
let mut s = String::from("hello");
let r1 = &s;
let r2 = &s;
println!("{} and {}", r1, r2);
// 컴파일러가 이 이후 r1, r2가 사용되지 않는다는 것을 인식 가능
let r3 = &mut s;
println!("{}", r3);
Slice Type
연속적인 데이터의 일부분을 참조하는 reference를 나타내는 slice 타입이 있습니다.
예를 들어 &str
타입은 string slice를 나타냅니다. String Slice는 string의 일부분을 참조하는 reference의 일종 입니다.
fn first_word(s: &String) -> usize {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
// &item: pattern matching을 통해 byte를 참조하는 reference를 풀어줌(즉, item은 reference가 아닌 value가 됨)
// enumerate() 메소드는 iterator를 받아 (index, value) 튜플을 반환
if item == b' ' { // &item이 reference 이므로 item은 value임
return i;
}
}
s.len()
}
위와 같은 함수는 String
의 reference를 받아, 첫 번째 공백 문자의 index를 반환합니다. 이때, &item
은 byte를 참조하는 reference입니다. iter()
로 얻어지는 iterator는 &u8
타입을 반환하므로, 패턴 매칭 &item
을 통해 byte를 참조하는 reference를 풀어줍니다. 참고로 패턴 매칭은 함수의 인자 등에서는 사용 불가능하며, let
, match
, for
statement 등에서만 사용 가능합니다.
slice를 활용하면 훨씬 간단하게 문자열의 일부를 참조할 수 있습니다.
let s= String::from("hello world");
let hello = &s[0..5]; // &s[..5]와 동일
let world = &s[6..11]; // &s[6..]와 동일
형태만 보면 잘 와닿지 않을 수도 있는데, string slice의 타입이 &str
라는 것은 기억해 둡시다. s[0..5]
의 타입이 str
이고 이것에 &
을 붙여 &str
이 됐다고 이해하면 편할 것 같습니다. string slice의 타입 -> &str
이라는 명제는 역도 성립합니다. 즉, &str
타입은 모두 string slice를 나타냅니다.
&str
타입은 String
과 다르게 capacity
를 가지지 않고 ptr
, len
attribute만 가지고 있습니다.
slice를 활용하여 first_word
함수를 수정하면:
fn first_word(s: &String) -> &str {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[..i];
}
}
&s[..]
}
위 함수에서는 s
가 &String
이지만 String
을 다룰 때와 마찬가지로 슬라이싱이 가능한 것을 확인할 수 있습니다. 이는 String
이 Deref
트레이트를 구현하기 때문에 가능하다고 하는데(출처: ChatGPT), 자세한 건 다음에 다뤄봐야 겠습니다.
String Slice를 parameter로 하는 함수를 작성하면 더 일반적이고 유용하게 사용할 수 있습니다.
fn first_word(s: &str) -> &str {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[..i];
}
}
&s[..]
}
Other Slices
&[i32]
String
뿐만 아니라, 다른 타입의 slice도 사용할 수 있습니다. 예를 들어, &[i32]
타입은 i32
타입 배열의 slice를 나타냅니다.
let a = [1, 2, 3, 4, 5]; // 타입: [i32; 5]
let slice = &a[1..3]; // [2, 3] // 타입: &[i32]
assert_eq!(slice, &[2, 3]);
참고 문헌
-
고려대학교 컴퓨터학과 오상은 교수님의 시스템 프로그래밍(COSE322) 과목 강의자료
Rust 맛보기