Rust : 레퍼런스(Reference)
- 프로그래밍 언어/Rust
- 2024. 12. 21.
들어가기 전
이 글은 Oreily에서 나온 프로그래밍 러스트를 공부하며 작성한 글입니다.
레퍼런스란?
- 스마트 포인터 : 소유권을 가지는 포인터다. 스마트 포인터가 해제되는 시점에 포인터가 가리키는 값도 해제된다.
- 레퍼런스 : 소유권을 가지지 않는 포인터다. 소유권은 Borrow 된다.
- 레퍼런스 특징
- 레퍼런스는 자신이 가리키는 대상보다 오래 살아있으면 안됨. 레퍼런스가 안전하게 살아있을 수 있는 범위를 라이프타임이라고 함.
- 모든 레퍼런스는 라이프타임을 가짐.
- Shared Reference (Multi Reader를 위한 것)
- Shared Reference는 동시에 여러 개 존재할 수 있음.
- Shared Reference가 있는 동안은 값을 수정할 수 없음. 소유자라도 수정할 수 없음.
- Shared Reference가 살아있는 동안 Mutable Reference는 존재할 수 없음.
- Mutable Reference (Single Writer를 위한 것)
- Mutable Reference는 1개만 존재할 수 있음.
- Mutable Reference가 존재하면, Shared Reference는 존재할 수 없음.
- Mutable Refrence가 존재하는 동안 소유자도 값을 수정할 수 없음.
- 레퍼런스 특징
레퍼런스(Reference, Borrow)의 필요성
fn show(table: HashMap<String, Vec<String>>) {
for (artist, works) in table {
for work in works {
println!("{} - {}", artist, work);
}
}
}
fn main() {
let mut table = Table::new();
table.insert("Gesualdo".to_string(), vec!["many madrigals".to_string()]);
table.insert("Caravaggio".to_string(), vec!["The Music".to_string()]);
table.insert("Cellini".to_string(), vec!["Perseus with the head of medusa".to_string()]);
show(&table);
assert_eq!(table["Gesualdo"][0], "many madrigals");
}
다음 코드는 컴파일 되지 않는다.
- table의 소유권은 show()를 호출부로 넘어갔다.
- table 소유권이 넘어간 이후 assert_eq!()에서 table을 호출하려고 한다. 그러나 table은 이미 무효화되었다.
소유권 문제로 위 코드는 컴파일 되지 않는다. 그런데 show() 함수는 출력에만 사용되기 때문에 굳이 소유권을 넘길 필요는 없을 것이다. 이 때 레퍼런스를 이용하면 원하는대로 동작하게 할 수 있다.
fn show(table: &HashMap<String, Vec<String>>) {
for (artist, works) in table {
for work in works {
println!("{} - {}", artist, work);
}
}
}
fn main() {
let mut table = Table::new();
table.insert("Gesualdo".to_string(), vec!["many madrigals".to_string()]);
table.insert("Caravaggio".to_string(), vec!["The Music".to_string()]);
table.insert("Cellini".to_string(), vec!["Perseus with the head of medusa".to_string()]);
show(&table);
assert_eq!(table["Gesualdo"][0], "many madrigals");
}
fn show() 함수가 &HashMap<String, Vec<String>> 타입을 받도록 수정해주면 된다. 이 때, for문에서 나오는 EntrySet의 타입도 변경된다.
- 기존 : (String, Vec<String>)
- 변경 : (&String, &Vec<String>)
이것은 당연한 결과다. 해시맵의 참조자 타입인 &HashMap<String, Vec<String>>은 HashMap 내부에 저장된 값의 소유권을 가지고 있지 않다. 따라서 해시맵 참조자 타입은 소유권 트리에서 자식 노드들의 참조만 가지는 것이 당연하기 때문에 For문이 돌아갈 때, (&String, &Vec<String>)만 나오게 될 것이다.
레퍼런스 다루기
러스트에서 레퍼런스를 다루는 것을 살펴보자.
명시적 참조, 역참조가 기본
fn main() {
let x = 10;
let r = &x;
assert!(*r == 10);
}
- 러스트에서 참조와 역참조는 명시적으로 이루어진다.
- &를 이용해 명시적으로 참조.
- *를 이용해 명시적으로 역참조.
암묵적 참조, 역참조 케이스
몇몇 경우에는 암묵적 참조, 역참조가 이루어진다.
- ".' 연산자
- '.' 연산자 왼쪽이 Rerference인 경우, Value가 나올 때까지 암묵적으로 역참조가 반복됨.
- '.' 연산자 왼쪽이 Value인 경우, 암묵적으로 참조가 진행됨.
- 비교 연산자
- 비교 연산자는 참조자인 경우, Value값이 나올 때까지 암묵적으로 역참조가 반복됨.
- 산술 연산자
- 산술 연산자는 참조자인 경우, 딱 1단계만 암묵적 역참조를 시도한다.
자주 사용하는 매크로인 println!()도 내부적으로 '.' 이걸 사용하게 되므로 암묵적 역참조가 일어난다.
// 암묵적 역참조가 일어나는 경우.
struct Anime { name: &'static str, bechdel_pass: bool }
fn main() {
let aria = Anime { name: "aria", bechdel_pass: true };
let anime_ref = &aria;
// anime_ref.name -> 암묵적 역참조.
assert_eq!(anime_ref.name, "aria");
}
// 암묵적 참조가 일어나는 경우
fn main() {
let mut v = vec![0, 1, 2, 3];
v.sort(); // 암묵적 참조
(&mut v).sort(); // 같은 일을 하는 함수.
}
위 코드에서 '.' 연산자에 의한 암묵적 참조, 역참조를 볼 수 있다.
레퍼런스 배정하기
fn main() {
let x = 10;
let y = 20;
let mut r = &x;
if true { r = &y;}
assert!(*r == 10 || *r == 20);
}
위 코드는 변수 r에 레퍼런스 값을 배정하는 코드다.
이 그림은 메모리 관점에서 참조자가 어떻게 동작하는지를 보여준다.
- r은 기본적으로 x의 스택 프레임을 가리킨다.
- 그리고 if 절 이후에 r는 y의 스택 프레임을 가리킨다.
이 부분을 좀 더 꼬아서 보면 이렇게도 가능할 것이다. 레퍼런스를 중첩할 수도 있다.
struct Point { x: i32, y: i32 }
fn main() {
let point = Point { x: 1, y: 2 };
let r = &point;
let rr = &r;
let rrr = &rr;
assert!(rrr.x == 1);
}
이 코드에서 주목할 부분은 다음과 같다.
- rrr의 타입은 &&&Point가 된다.
- '.' 연산자는 암묵적으로 반복 역참조를 하기 때문에 rrr.x == 1은 성립한다.
스택 메모리 관점에서는 위와 같이 동작한다. 각자의 스택 메모리를 충실히 가리킨다는 것을 이해할 수 있다.
레퍼런스 비교하기
fn main() {
let x = 10;
let y = 10;
let rx = &x;
let rrx = ℞
let ry = &y;
let rry = &ry;
assert!(rrx == ry); // 이건 안됨. 참조 타입이 서로 다르기 때문.
assert!(rrx == rry); // 이건 됨. 참조 타입이 같기 때문.
}
- 레퍼런스가 비교 연산을 만나는 경우, 가리키는 값이 나올 때까지 암묵적 역참조가 반복된다.
- 따라서 위 코드의 assert 문은 값을 비교하는 의미를 가진다.
- 만약 Reference들이 가리키는 메모리가 같은 것인지 확인하고 싶다면, std::ptr::eq()를 사용해야 한다.
임의의 표현식을 가리키는 레퍼런스 빌려오기
fn factorial(n: usize) -> usize {
(1..n + 1).product()
}
fn main() {
&factorial(7); // 이 표현식이 평가되는 동안만 값이 살아있음. 배정된 곳이 없으므로 바로 소멸됨.
// factorial 연산결과가 익명 변수에 저장됨.
// 익명 변수의 참조가 r에 저장됨.
let r = &factorial(6);
// 산술 연산자 '+'는 참조자를 한 단계만 암묵적 역참조함.
assert_eq!(r + 1009, 1729);
}
러스트에서는 임의의 표현식의 결과를 가리키는 레퍼런스를 빌려올 수 있다. 이 레퍼런스는 어떻게 사용하느냐에 따라 서로 다른 수명을 가진다.
- let을 이용해 변수에 배정하는 경우 : 변수와 같은 수명을 가진다. 위 코드의 &factorial(6)가 예시.
- let을 이용하지 않는 경우 : 표현식이 호출된 후 바로 제거된다. 위 코드의 &factorial(7)가 예시.
레퍼런스 안정성
- 레퍼런스의 안정성은 레퍼런스가 Dangling Pointer가 되지 않는다는 것을 의미한다.
- 모든 레퍼런스는 라이프타임을 가지고, 이는 컴파일 타임에만 유효한 개념이다.
- 컴파일 타임에 Borrow Checker가 라이프 타임을 검사하여, Dangling Pointer가 생기지 않도록 한다.
- 레퍼런스의 라이프타임은 소유권 트리에서 자식 노드들의 라이프타임에도 동일하게 적용되어야 한다.
- 레퍼런스는 '라이프타임'을 가지기 때문에 레퍼런스를 전달받는 함수, 구조체 같은 것들 역시 '라이프타임'와 관련이 있다.
지역변수 빌려오기
fn main() {
{
let r; // r 선언
{
let x = 1; // x 라이프 타임 시작 -->
r = &x; // x 라이프 타임 종료 <--
}
println!("{}", r); // r이 요구하는 최소 라이프 타임.
}
}
- 이 코드는 컴파일 되지 않는다. r은 x를 참조하는데, r이 마지막으로 쓰이는 곳까지 x가 살아있지 않기 때문이다.
- 라이프 타임 개념으로 인해 컴파일 타임에 r이 Dangling Pointer가 되는 것을 막는다.
fn main() {
{
let r; // r 선언
{
let x = 1; // x 라이프 타임 시작 -->
r = &x; // x 라이프 타임 종료 <--
println!("{}", r); // r이 요구하는 최소 라이프 타임.
}
// r 소멸 범위
}
}
- println!()의 위치를 조절해주면, 라이프타임 문제가 해결되기 때문에 컴파일이 될 수 있다.
- r이 실제로 소멸되는 범위까지 x가 살아있지 않아도 문제는 없다. r이 마지막으로 사용되는 위치까지만 x가 살아있으면 Danling Pointer 문제는 없기 때문이다.
라이프타임을 그림으로 살펴보면 다음과 같다.
- 왼쪽 그림은 컴파일이 실패했던 코드의 라이프타임을 보여준다.
- 오른쪽 그림은 컴파일이 성공했던 코드의 라이프타임을 보여준다.
레퍼런스를 함수의 Parameter로 전달받기
static mut STASH: &i32 = &10;
fn f(p: &i32) {
unsafe {
STASH = p;
}
}
위 코드는 컴파일 되지 않는다. 이유는 다음과 같다.
- STASH는 Static이므로 어플리케이션 내내 살아있어야한다.
- 그러나 함수 f가 가지는 암묵적 라이프타임 'a는 최소한 함수 실행 범위 내에서만 살아있는 것이 보장된다.
함수가 호출되어 STASH에 p를 저장한 이후, 함수 호출이 종료되었을 때도 p가 가리키는 값이 살아있다는 보장이 없다. 즉, STASH가 Danling Pointer가 될 수 있다.
static mut STASH: &i32 = &10;
fn f(p: &'static i32) {
unsafe {
STASH = p;
}
}
- 이 문제를 해결하기 위해서 암묵적으로 선언되었던 파라메터 p의 라이프타임을 static으로 명시해주면 된다.
- 이 함수 호출 시 전달되는 파라미터는 반드시 어플리케이션 러닝 내내 살아있는 static 라이프타임을 가져야하기 때문에 Dangling Pointer가 발생하지 않을 것이다.
레퍼런스를 함수에 전달하기
fn g<'a>(p: &'a i32) { ... }
let x = 10;
g(&x); // 함수에 매개변수 전달
- 라이프타임 'a는 최소한 함수 g() 호출동안은 유효해야한다.
- 러스트는 보수적으로 가장 좁은 범위의 라이프타임만 선택한다. 따라서 &x의 라이프타임은 g()의 호출 구간만큼만 될 것이다.
레퍼런스 반환하기
// 변수가 하나라 라이프타임이 하나 뿐이기 때문에, 기본적으로 명시하지 않아도 괜찮음.
fn smallest<'a>(v: &[i32]) -> &'a i32 {
v.iter().min().unwrap()
}
fn main() {
let s;
{
let parabola = [9, 4, 1, 0, 1, 4, 9];
s = smallest(¶bola);
} // parabola 소멸됨. -> parabola의 라이프타임.
assert_eq!(*s, 0); // s의 요구 라이프타임 -> 컴파일 되지 않음.
}
- 함수에 레퍼런스를 반환할 때도 라이프타임이 알려줘야한다. 그러지 않으면, 반환된 레퍼런스가 얼만큼 유효할지 알 수 없기 때문에 Dangling Pointer가 발생할 수 있다. 그래서 컴파일이 되지 않는다.
- 반환값 s와 parabola는 같은 라이프타임 'a를 가진다.
- 그러나 parabola는 assert_eq!까지 살아남지 않기 때문에 두 가지 조건을 만족시키는 라이프타임이 존재하지 않음.
레퍼런스를 갖는 구조체
struct S {
r: &i32 // 컴파일 되지 않음.
}
fn main() {
let s;
{
let x = 10;
s = S { r: &x };
}
assert!(*s.r == 10);
}
위 코드는 컴파일 되지 않는다.
- 모든 레퍼런스는 라이프타임을 가진다.
- 구조체 필드인 r은 레퍼런스이기 때문에 라이프타임을 가진다. 그러나 레퍼런스 r의 라이프타임과 S구조체의 관계를 알 수 없다. S가 살아있어도, r이 없어지는 경우가 있을 수 있기 때문이다.
따라서 구조체 S와 그 필드 사이의 라이프 타임을 명시해줘야한다.
// 구조체에 라이프타임을 명시.
struct S<'a> {
r: &'a i32
}
- 구조체에 라이프타임을 명시해주면, 구조체 S와 r의 라이프타임 관점의 관계를 알 수 있게 됨. 따라서 컴파일러는 구조체 필드가 Dangling Pointer가 되는 것을 컴파일 타임에 발견할 수 있게 된다.
- 다만 위 선언을 통해 r는 반드시 구조체 S보다 오랫동안 유효해야한다는 제약을 가진다.
고유한 수명 매개변수
struct S<'a> {
x: &'a i32,
y: &'a i32,
}
fn main() {
let x = 10;
let r;
{
let y = 20;
let s = S { x: &x, y: &y }; // 컴파일 에러 발생.
r = s.x;
} // y 소거
println!("{}", r); // x는 이까지 유효해야함.
} // x 소거
위 코드는 컴파일 에러가 발생한다.
- S의 필드인 x, y는 같은 라이프타임 'a를 가진다.
- x는 println!()에서 사용되기 때문에 x는 최소한 println!()까지는 유효해야한다.
- 같은 라이프타임을 가지는 y도 println!()까지 유효해야한다.
- 그러나 y는 그 전에 메모리에서 제거된다.
- 2 + 3을 만족시키는 라이프타임은 존재하지 않는다.
y가 실제로는 println!()에서 쓰이지 않아 문제가 없는 코드 같아 보여도, 이런 이유때문에 컴파일 에러가 발생한다. (매번 나오는 이야기지만, 러스트 컴파일러는 각 조건을 만족하는 가장 최소한의 라이프타임을 선정한다!)
// 라이프타임을 독립적으로 분리시키기.
struct S<'a, 'b> {
x: &'a i32,
y: &'b i32,
}
fn main() {
let x = 10;
let r;
{
let y = 20;
let s = S { x: &x, y: &y }; ---> 'a, 'b 라이프타임 시작
r = s.x;
} <--- 'b 라이프타임 종료
println!("{}", r); <--- 'a 라이프타임 종료
}
이 문제를 해결하기 위해서 S의 필드 x, y는 독립적인 라이프타임을 가지도록 해주면 된다. 위의 라이프 타임 선언은 다음 의미를 가진다.
- 라이프타임 'a, 'b는 최소한 S가 살아있는 동안은 유효해야한다.
- 라이프타임 'a, 'b는 서로 독립적이다. (어떤 관계가 있는지 알지 못한다)
- 각각의 라이프타임은 다음과 같다. 조건을 만족하는 라이프타임 중, 최소한의 라이프타임만 선택한다.
- 'a 라이프타임 : 최소한 println!()까지 유효해야 함. (사용하기 때문)
- 'b 라이프타임 : s가 살아있는 동안만 유지됨.
위 제약을 풀면서 컴파일 에러는 해소되게 된다.
독립적인 라이프타임을 많이 쓰는 것이 능사는 아님.
fn hello<'a, 'b, 'c>(x: &'a i32, y: &'b i32, z: &'c i32) -> &'a i32 {
x
}
- 장점 : 라이프타임을 많이 사용하면 제약사항에 자유로워진다.
- 단점 : 코드가 읽기 어려워진다.
라이프타임을 통해서 러스트는 함수의 의도를 일부 노출한다. 이것의 목적은 함수 시그니처만 보더라도 어떤 의미가 있는지를 캐치할 수 있도록 하는 것이다. 그러나 쉬운 코딩을 위해 과도한 라이프타임을 도입한다면 이것이 희석될 수 있다. 따라서 꼭 필요한 경우에만 새로운 라이프타임을 도입하는 것이 좋다.
기본적인 전략은 최소한의 라이프타임을 도입하고, 컴파일 될 때까지 제약을 서서히 풀어주는 것이다.
수명 매개변수 생략하기
레퍼런스 공유 vs 변경
라이프타임과 함수 시그니처의 계약 관계
fn g<'a>(p: &'a i32) { ... }
러스트는 함수 시그니처에 라이프타임을 노출한다. 라이프타임이 노출되면서, 함수의 의도를 일부 드러낸다. 여기서 드러난 의도는 다음과 같다.
- 인수로 받은 p의 어떠한 값도 함수 g() 호출부보다 긴 곳에 저장되지 않는다.
이것은 함수 시그니쳐에 반환부가 없다는 것으로 알 수 있다. 라이프타임 'a의 유효 최소 범위는 함수 호출내부다. 따라서 함수 시그니처를 통해, p가 가리키는 값은 함수 호출 내에서 사용될 수는 있으나 반환되지 않기 때문에 함수 호출부보다 긴 곳에 저장되지 않는다는 것을 나타낸다.
fn parse_record<'i>(input: &i [u8]) -> Record<'i> { ... }
이 함수 시그니처도 마찬가지로 함수의 의도를 일부 드러낸다. 의도는 다음과 같다.
- input의 일부분은 Record에 담아서 반환할 것이다
이 코드에서 Record 변수가 가지는 값을 input에게서 일부 빌려올 것이라는 것을 알 수 있다. 이처럼 러스트는 라이프타임을 통해 내부 구현을 일부 드러내면서, 코드에 대한 문제점을 추론하는데 많은 도움을 준다. 예를 들어 Record에서 일부 에러가 발생했을 때, 우리는 input 변수에서 빌려온 값중에 무엇인가 문제가 있을 수도 있겠구나라고 판단할 수 있는 것이다.
https://chatgpt.com/share/6772a955-60bc-8012-9837-94b8d496807e
'프로그래밍 언어 > Rust' 카테고리의 다른 글
Rust : 소유와 이동 (2) | 2024.12.14 |
---|