🦀 Chapter 4: 빌림과 라이프타임
빌림(Borrowing)
"소유권을 이전하지 않고 값을 사용하는 Rust의 우아한 메커니즘"
소유권 시스템이 Rust의 심장이라면, 빌림은 Rust의 혈관입니다. 빌림을 마스터하면 유연하고 효율적인 코드를 작성할 수 있습니다.
4.1 참조(References)의 개념
3장에서 배운 소유권 시스템은 강력하지만, 매번 소유권을 이동시키는 것은 불편합니다. 함수에 값을 전달할 때마다 소유권을 잃고, 다시 받기 위해 반환값으로 돌려받아야 합니다.
참조(Reference)는 이 문제를 해결합니다. 참조를 사용하면 소유권을 이전하지 않고 값을 "빌려서" 사용할 수 있습니다.
4.1.1 기본 참조 사용법
fn main() {
let s1 = String::from("hello");
let len = calculate_length(&s1); // &s1: s1의 참조를 전달
println!("The length of '{}' is {}.", s1, len); // s1 여전히 유효!
}
fn calculate_length(s: &String) -> usize { // &String: String의 참조를 받음
s.len()
} // s가 스코프를 벗어나지만, 소유권이 없으므로 아무것도 해제되지 않음
&s1 구문은 s1의 참조를 생성합니다. 참조는 값을 가리키지만 소유하지 않습니다. 따라서 참조가 스코프를 벗어나도 원본 값은 drop되지 않습니다.
4.1.2 참조와 포인터의 차이
C/C++의 포인터와 달리, Rust의 참조는 항상 유효함이 보장됩니다. 널(null) 참조는 존재하지 않으며, 댕글링 참조(dangling reference)도 컴파일 타임에 방지됩니다.
| 항목 | C/C++ 포인터 | Rust 참조 |
|---|---|---|
| Null 가능 | 예 (nullptr, NULL) | 아니오 (Option<&T> 사용) |
| 댕글링 가능 | 예 (런타임 버그) | 아니오 (컴파일 에러) |
| 포인터 연산 | 가능 (위험함) | 불가능 (안전함) |
| 자동 역참조 | 불가능 | 가능 (. 연산자 사용 시) |
4.2 불변 참조와 가변 참조
Rust는 두 종류의 참조를 제공합니다: 불변 참조(&T)와 가변 참조(&mut T)입니다.
4.2.1 불변 참조 (&T)
데이터를 읽을 수 있지만 수정할 수 없습니다. "읽기 전용 접근"으로 생각할 수 있습니다.
fn main() {
let s = String::from("hello");
let r1 = &s;
let r2 = &s; // 여러 개의 불변 참조 가능
println!("{} and {}", r1, r2);
// s.push_str(", world"); // 에러! s가 불변 참조로 빌려졌으므로 수정 불가
}
4.2.2 가변 참조 (&mut T)
데이터를 읽고 수정할 수 있습니다. 하지만 중요한 제약이 있습니다.
fn main() {
let mut s = String::from("hello"); // mut 키워드 필수
change(&mut s);
println!("{}", s); // "hello, world"
}
fn change(some_string: &mut String) {
some_string.push_str(", world");
}
4.2.3 가변 변수 vs 가변 참조
// 가변 변수 (mut)
let mut x = 5;
x = 6; // OK: x 자체를 수정
// 가변 참조 (&mut)
let mut s = String::from("hello");
let r = &mut s;
r.push_str(", world"); // OK: r을 통해 s를 수정
4.3 빌림 규칙 (Borrowing Rules)
Rust의 빌림 시스템은 두 가지 핵심 규칙으로 데이터 경쟁(data race)을 컴파일 타임에 방지합니다.
특정 스코프 내에서, 어떤 값에 대해 다음 중 하나만 가질 수 있습니다:
- 하나의 가변 참조 (
&mut T), 또는 - 여러 개의 불변 참조 (
&T)
하지만 가변 참조와 불변 참조를 동시에 가질 수 없습니다!
4.3.1 규칙 1의 예제
// OK: 여러 개의 불변 참조
let s = String::from("hello");
let r1 = &s;
let r2 = &s;
let r3 = &s;
println!("{}, {}, {}", r1, r2, r3); // 모두 사용 가능
// OK: 하나의 가변 참조
let mut s = String::from("hello");
let r1 = &mut s;
r1.push_str(", world");
// 에러: 가변 참조와 불변 참조를 동시에
let mut s = String::from("hello");
let r1 = &s;
let r2 = &mut s; // 컴파일 에러!
println!("{}", r1); // r1이 여기서 사용되므로 에러
// OK: 참조의 스코프가 겹치지 않음
let mut s = String::from("hello");
let r1 = &s;
let r2 = &s;
println!("{} and {}", r1, r2);
// r1과 r2는 여기서 마지막으로 사용됨
let r3 = &mut s; // OK: r1, r2의 스코프가 끝났음
r3.push_str(", world");
println!("{}", r3);
4.3.2 왜 이런 규칙이 필요한가?
이 규칙은 데이터 경쟁(data race)을 방지합니다. 데이터 경쟁은 다음 세 가지 조건이 동시에 발생할 때 일어납니다:
데이터 경쟁 발생 조건:
- 둘 이상의 포인터가 같은 데이터에 동시에 접근
- 적어도 하나의 포인터가 데이터를 쓰는 데 사용됨
- 데이터 접근을 동기화하는 메커니즘이 없음
Rust의 빌림 규칙은 조건 1과 2를 컴파일 타임에 차단합니다!
참조는 그것이 가리키는 데이터보다 오래 살 수 없습니다. 댕글링 참조(dangling reference)가 발생하지 않도록 컴파일러가 보장합니다.
4.3.3 댕글링 참조 방지
// 컴파일 에러: 댕글링 참조
fn dangle() -> &String {
let s = String::from("hello");
&s // 에러! s는 함수가 끝나면 drop되는데, 그 참조를 반환하려고 함
} // s가 여기서 drop됨
// 해결책 1: 소유권을 반환
fn no_dangle() -> String {
let s = String::from("hello");
s // OK: 소유권이 호출자에게 이동
}
// 해결책 2: 정적 수명 사용
fn static_str() -> &'static str {
"hello" // OK: 문자열 리터럴은 프로그램 전체 수명을 가짐
}
4.4 라이프타임(Lifetimes)
라이프타임은 참조가 유효한 범위를 나타냅니다. 대부분의 경우 Rust 컴파일러가 자동으로 추론하지만, 때로는 명시적으로 표시해야 합니다.
4.4.1 라이프타임 문법
라이프타임은 'a, 'b 같은 작은따옴표로 시작하는 이름으로 표시됩니다. 일반적으로 'a (라이프타임 a)를 가장 많이 사용합니다.
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}
이 함수 시그니처는 다음을 의미합니다:
- x와 y는 모두 라이프타임
'a를 가진 참조입니다 - 반환되는 참조도 라이프타임
'a를 가집니다 - 즉, 반환값은 x와 y 중 짧게 사는 쪽만큼만 유효합니다
4.4.2 라이프타임 추론 규칙
Rust 컴파일러는 세 가지 규칙으로 라이프타임을 자동 추론합니다. 이 규칙으로 추론되지 않는 경우에만 명시적 표기가 필요합니다.
| 규칙 | 설명 |
|---|---|
| 규칙 1 | 각 참조 매개변수는 자신만의 라이프타임을 가진다fn foo(x: &i32, y: &i32) → fn foo<'a, 'b>(x: &'a i32, y: &'b i32) |
| 규칙 2 | 입력 라이프타임이 정확히 하나면, 모든 출력 라이프타임에 적용fn foo<'a>(x: &'a i32) -> &'a i32 |
| 규칙 3 | 메서드에서 여러 입력 라이프타임이 있고 그 중 하나가 &self나 &mut self면, self의 라이프타임이 모든 출력 라이프타임에 적용 |
4.4.3 실전 라이프타임 예제
fn main() {
let string1 = String::from("long string is long");
let result;
{
let string2 = String::from("xyz");
result = longest(string1.as_str(), string2.as_str());
println!("The longest string is {}", result); // OK
}
// println!("The longest string is {}", result); // 에러!
// string2의 라이프타임이 끝났으므로 result도 무효
}
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}
4.5 구조체의 라이프타임
구조체가 참조를 포함하는 경우, 라이프타임 표기가 필요합니다.
struct ImportantExcerpt<'a> {
part: &'a str, // 'a: 이 참조는 구조체와 같은 라이프타임을 가져야 함
}
fn main() {
let novel = String::from("Call me Ishmael. Some years ago...");
let first_sentence = novel.split('.').next().expect("Could not find a '.'");
let i = ImportantExcerpt {
part: first_sentence,
};
println!("Excerpt: {}", i.part);
} // novel이 여기서 drop되기 전에 i도 drop되어야 함 (라이프타임 보장)
4.6 특수 라이프타임: 'static
'static 라이프타임은 프로그램의 전체 수명 동안 유효합니다. 모든 문자열 리터럴은 'static 라이프타임을 가집니다.
let s: &'static str = "I have a static lifetime.";
// 문자열 리터럴은 바이너리에 직접 저장되므로 항상 유효함
주의: 'static을 남용하지 마세요. 대부분의 경우 라이프타임 문제는 댕글링 참조나 잘못된 라이프타임 명시 때문입니다. 'static은 진짜로 프로그램 전체 수명 동안 유효한 데이터에만 사용해야 합니다.
4.7 빌림의 실전 패턴
4.7.1 패턴 1: 반복자(Iterator)와 빌림
fn main() {
let v = vec![1, 2, 3, 4, 5];
// iter(): 불변 참조 반복
for val in v.iter() {
println!("{}", val); // val은 &i32 타입
}
println!("v still valid: {:?}", v); // v 여전히 유효
// iter_mut(): 가변 참조 반복
let mut v2 = vec![1, 2, 3, 4, 5];
for val in v2.iter_mut() {
*val += 10; // val은 &mut i32 타입
}
println!("v2: {:?}", v2); // [11, 12, 13, 14, 15]
// into_iter(): 소유권 이동
let v3 = vec![1, 2, 3, 4, 5];
for val in v3.into_iter() {
println!("{}", val); // val은 i32 타입 (소유권 이동)
}
// println!("{:?}", v3); // 에러! v3는 이동됨
}
4.7.2 패턴 2: 슬라이스(Slices)
fn first_word(s: &str) -> &str {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[0..i]; // 슬라이스 반환
}
}
&s[..] // 전체 문자열 슬라이스
}
fn main() {
let my_string = String::from("hello world");
let word = first_word(&my_string);
println!("First word: {}", word); // "hello"
// 슬라이스 덕분에 my_string이 유효한 동안 word도 유효함이 보장됨
}
4.8 챕터 요약
주요 개념 5가지:
- 참조의 개념: 참조(&T)는 소유권을 이전하지 않고 값을 빌려서 사용하는 메커니즘입니다. 항상 유효함이 보장됩니다.
- 불변 vs 가변 참조: 불변 참조(&T)는 읽기 전용이며 여러 개 가능하지만, 가변 참조(&mut T)는 읽기/쓰기가 가능하고 단 하나만 허용됩니다.
- 빌림 규칙: 특정 스코프에서 하나의 가변 참조 또는 여러 불변 참조만 가능하며, 참조는 항상 유효해야 합니다. 이는 데이터 경쟁을 방지합니다.
- 라이프타임: 참조가 유효한 범위를 명시하는 메커니즘으로, 컴파일러가 댕글링 참조를 방지하는 데 사용합니다.
- 실전 활용: 반복자, 슬라이스 등에서 빌림을 활용하여 효율적이고 안전한 코드를 작성할 수 있습니다.
4.9 복습 문제
- 참조(&T)와 소유권 이동의 차이점을 설명하세요. 각각 언제 사용해야 하나요?
- 빌림의 두 가지 규칙을 설명하고, 각 규칙이 어떤 버그를 방지하는지 구체적으로 설명하세요.
- 다음 코드에서 에러가 발생하는 이유를 설명하세요:
let mut s = String::from("hello"); let r1 = &s; let r2 = &mut s; println!("{}", r1); - 라이프타임이란 무엇이며, 언제 명시적으로 표기해야 하나요? 라이프타임 추론 규칙 3가지를 설명하세요.
'static라이프타임은 무엇이며, 언제 사용해야 하나요? 남용하면 안 되는 이유는 무엇인가요?- 구조체에 참조 필드를 포함시킬 때 왜 라이프타임 표기가 필요한가요? 예제를 작성하세요.
4.10 다음 장 예고
5장에서는 코드 구조화를 배웁니다. 구조체(Structs)로 관련 데이터를 묶는 방법, 메서드와 연관 함수를 정의하는 impl 블록, 열거형(Enums)과 패턴 매칭(Pattern Matching), 그리고 모듈 시스템으로 코드를 체계적으로 조직하는 방법을 다룹니다.
이제 기본 개념은 충분히 익혔으니, 실제 프로젝트에서 사용할 수 있는 구조화 기법을 배울 시간입니다!
Chapter 4 완료