🦀 Rust
[Rust 입문] 4. 소유권, 참조자, 라이프타임
date
Mar 23, 2023
slug
rust-ownership
author
status
Public
tags
Rust
독학
summary
Rust의 소유권, 참조자, 라이프타임에 대해 설명합니다.
type
Post
thumbnail
category
🦀 Rust
updatedAt
Apr 17, 2023 01:48 PM
소유권 (Ownership)
C/C++같은 언어는 메모리를 동적으로 할당할 때 해제까지 신경써줘야 한다. 이런 불편함을 없애기 위해 GC가 존재하는 언어도 있는데 GC가 존재하는 언어는 그로 인해서 느려지는 단점이 있다.
이런 문제들을 해결하기 위해 rust에선 소유권이라는 개념이 있다.
소유권의 규칙은 크게 다음 세가지가 있다.
- rust의 각각의 값은 해당값의 오너(owner)라고 불리는 변수를 가지고 있다.
- 한번에 하나의 오너만 존재할 수 있다.
- 오너가 스코프 밖으로 벗어날 때 값은 버려진다.
1번의 규칙은 값이 존재할 때 값을 가리키는 (값을 의미하는) 변수가 존재한다는 의미이며 3번의 규칙은 스코프 (중괄호) 내에서 정의되어 스코프를 벗어나기 전까지만 변수 (오너) 가 유효하다는 것을 의미한다.
예를 들면 다음과 같다. 밑에서 a는 a를 정의하고 나서 스코프 내에서만 유효하고 b도 메인함수가 끝날때까지만 유효하다.
fn main() { { let a: u32 = 1234; println!("a : {}", a); } let b = 4321; println!("b : {}", b); }
2번의 규칙은 특정 값을 의미하는 오너는 하나만 존재할 수 있다는 의미이다.
힙에 데이터를 저장하는 타입과 소유권
힙에 데이터를 저장하는 타입 중 대표적인 것이 String 타입이다. 이 타입은 rust에서 문자열을 편하게 다루기 위해 사용되는 타입이다. String 타입은 꽤 복잡하게 구현되어 있기 때문에 자세한 설명은 생략한다.
스트링 리터럴로부터 String 타입을 생성하는 예제는 다음과 같다.
let s = String::from("hello");
스트링 리터럴은 컴파일 타임에 결정되기 때문에 함부로 변경이 불가능하지만 String 타입은 변경이 가능하다.
let mut s = String::from("hello"); s.push_str(", world!"); println!("{}", s); // `hello, world!`
위에서 String 타입이 생성될 때 힙 영역에 문자열을 담기 위한 공간이 할당된다. (C++의
new std::stirng("hello") 와 유사하다)힙 영역에 할당했으므로 힙에 할당한 공간을 추후에 해제해야 하는데 rust에선 위의 소유권 규칙 중 3번에 의해 스코프를 벗어날 때 값이 버려지게 된다. 이 때 메모리 할당이 해제되게 된다.
rust는 스코프를 벗어날 때 내부적으로
drop 함수를 이용해서 해제한다.참고로 String 타입은 String의 실제 문자열인 ptr 요소만 힙에 저장되며 len, capacity 요소는 스택에 저장된다.
변수의 이동과 복사
변수를 대입 연산자 (=) 로 다른 변수로 이동하는 상황을 생각해 보자.
let x = 5; let y = x; println!("x : {}, y : {}", x, y); let s_x = String::from("hello~"); let s_y = s_x; println!("s_y : {}", s_y);
위에서 정수 x, y와 String 타입 s_x, s_y 중 s_x에 접근할 수 없다. s_x에 접근하려 하면
use of moved value 오류가 발생할 것이다.정수 x, y는
y = x 에서 x의 값이 y로 복사가 된다. 하지만 String 타입 s_x, s_y는 그렇지 않다.만약 C++에서 위와 같은 코드를 짠다면 s_x, s_y엔 얕은 복사 혹은 포인터만 복사되어 s_x, s_y를 할당 해제할 때 String 타입을 구성하는 내부 요소 중 힙에 할당된 부분에 대해 double free가 될 가능성이 있다. 이를 방지하기 위해 rust에선 위와 같이
s_y = s_x 처럼 내부에 힙에 할당된 인자를 복사하려고 할 때 s_x를 무효화 한다.그래서 대입 연산자를 사용할 때 값의 복사가 아니라 이동이 일어난다. 위와 같은 타입의 변수에 대해 복사 (깊은 복사)를 하고자 할 때엔
clone 메소드를 사용한다.let s1 = String::from("hello"); let s2 = s1.clone(); println!("s1 = {}, s2 = {}", s1, s2);
이 경우엔 s2에 s1의 복사본이 들어가게 되어 독립적인 값이 된다.
하지만 위의 정수 x, y에 대해선 이동이 아니라 복사가 일어났다. 이동과 복사가 일어나는 기준은 변수의 값이 스택에만 저장되냐, 스택과 힙을 둘다 사용하냐가 기준이 된다.
정확하겐 rust에서는 스택에 저장할 수 있는 타입에
Copy 트레잇이라는 어노테이션을 가지고 있다. 이 어노테이션을 가지고 있는 타입에 대해선 값의 복사가 가능하다.일반적으로 단순한 스칼라 값이나 그의 묶음은 스택에 저장되므로 값의 복사가 가능하다.
함수와 소유권
인자로 넘길 때의 변수의 소유권 이전
한 스코프에서 값을 사용하는 경우 외에 함수의 인자로 값을 넘기는것을 생각해보자. 일반적으로 함수에게 값을 넘기는 것은 값을 변수에 대입하는 것과 유사한 동작이 일어난다. 위에서의 규칙을 생각해서 함수의 인자로 값을 넘겨줄 때를 생각해보자.
fn main() { let v_int = 1234; let v_str = String::from("string"); get_integer(v_int); // get_integer 내의 var로 "값 복사" 가 일어남. get_string(v_str); // get_string 내의 var로 "값 이동" 이 일어남. println!("v_int : {}", v_int); //println!("v_str : {}", v_str); } fn get_integer(var: i32) { println!("var : {}", var); } // var 변수 파기 fn get_string(var: String) { println!("var : {}", var); } // var 변수 파기
위에서 v_int는 get_integer를 호출할 때 get_integer 내부의 var 변수로 복사가 일어나 get_integer의 함수가 종료되어도 여전히 v_int에 접근이 가능하다.
하지만 v_str는 get_string가 호출된 시점에서 get_string 내부의 var 변수로 값 이동이 일어나 get_string 함수가 종료될때 v_str (var)는 할당 해제되어 더이상 접근이 불가능하다.
값을 리턴받을 때의 소유권 이전
함수가 값을 리턴할 때에 스코프를 벗어나지만 다른 변수에 대해 소유되도록 이동한다면 값은 제거되지 않는다.
fn main() { let v_str = String::from("string"); let v_str = pipe(v_str); // get_integer 내의 var로 "값 복사" 가 일어남. println!("v_str : {}", v_str); } fn pipe(var: String) -> String { println!("{}에 대해 소유권을 잠시 가지지만 아무 동작도 하지 않는 함수", var); var } // var 변수 이동
위에서 v_str은 생서되고 나서 main 함수가 종료될때까지 유효하다. pipe 함수 내 스코프에서 외부로 값이 이동되었기 때문이다.
함수로 소유권을 이전하지 않고 인자를 사용하는 법
위와 같은 소유권의 특성 때문에 함수의 인자에 소유권이 이동되는 인자를 집어 넣으면 소유권이 상실되게 된다.
예를 들어 문자열의 길이를 구하는 함수
strlen(str: String) 을 구현한다고 가정할 때 인자를 저렇게 쓴다면 소유권이 strlen 함수로 넘어가게 된다.지금까지 알고있는 개념을 이용해 이를 해결하려면 튜플을 사용하는 방법이 있다. 튜플을 사용하면 소유권을 다시 돌려받을 수 있도록 할 수 있다. 하지만 이런 방법은 너무 지저분하며 번거롭다는 단점이 있다.
fn main() { let s1 = String::from("hello"); let (s2, len) = strlen(s1); println!("The length of '{}' is {}.", s2, len); } fn strlen(s: String) -> (String, usize) { let length = s.len(); (s, length) }
참조자
위와 같은 번거로움 때문에 함수에서 소유권을 이전받지 않고 인자를 사용할 때 rust에서는 references (참조자) 라는 개념을 사용한다. 참조자를 사용하면 소유권을 넘기지 않고 변수를 함수 내에서 사용할 수 있다.
앤드 기호(ampersand) 연산자를 이용해 함수를 선언할때와 함수에 변수를 대입할 때 참조자임을 명시한다. 이를 “빌린다” 라고 표현한다. 변수를 잠시 빌려오기 때문에 빌린다라는 표현을 사용한다.
fn main() { let s = String::from("hello"); let len = strlen(&s); println!("The length of '{}' is {}.", s, len); } fn strlen(s: &String) -> usize { s.len() // 여기서 s는 main 함수 내의 s를 잠시 빌려온 것이다. }
여기서 참조자는 일종의 포인터같은 개념으로 참조자를 사용하는 함수 내에서 참조자는 함수 외부에 있는 원본 변수를 가리키고 있게 된다.
rust의 참조자는 C++의 참조자와 명칭도 그렇고 비슷해 보이지만 C++의 참조자처럼 원본 변수의 값을 함부로 변경하는 것은 불가능하다.
rust의 참조자를 C++의 참조자처럼 함수 내부에서 값을 변경 가능하게 하려면 "가변 참조자"를 사용해야 한다.
가변 참조자
가변 참조자를 사용하려면 원본 변수도 mutable해야 한다. 그리고 함수에서 선언할 때 & 연산자 뒤에 mut 키워드를 붙인다.
fn main() { let mut s = String::from("hello"); addstr(&mut s); println!("new string : {}.", s); } fn addstr(s: &mut String) { s.push_str(", world"); }
함수의 인자로 가변 참조자를 쓸 수 있으므로 동일한 스코프에서도 가변 참조자를 사용할 수 있다.
fn main() { let mut s = String::from("hello"); let borrow = &s; println!("borrow string : {}.", borrow); }
참조자와 가변 참조자와 멀티스레드 프로그래밍
참조자는 참조한 값이 불변성이 있으므로 참조자를 만드는 개수엔 제한이 없다. 하지만 가변 참조자는 특정 스코프 내에서 가변 참조자를 하나밖에 만들수 없다.
왜냐 하면 참조자는 여러개 생성되서 서로 다른 스코프에서 사용되어도 참조자가 가리키는 데이터를 함부로 변경할 수 없으므로 데이터의 불변성이 보장된다.
하지만 가변 참조자가 여러개 생성된다고 가정하면 가변 참조자가 서로 다른 스레드에서 가변 참조자에 데이터를 쓸 때 경쟁 상태(data race)가 발생할 수 있다.
경쟁 상태 (data race)가 발생하는 상황은 두 개 이상의 스레드에서 하나의 데이터에 동시에 접근할 때, 하나 이상의 스레드에서 데이터에 값을 쓸 때 발생한다. 이 경우 동기화 매커니즘이 없다면 경쟁 상태가 발생한다.
rust는 이런 상황을 근원부터 방지해 준다. 두 개 이상의 스레드에서 하나의 데이터에 접근해 데이터를 쓸 수 있는 상황 (가변 참조자가 생성되어 서로 다른 스레드에서 쓰기 작업을 시도하는 것)을 문법 단에서 방지한다.
이런 이유로 하나의 값에 대해 가변 참조자와 불변 참조자를 동시에 쓸 때에도 규칙이 있다. 하나의 값에 대해 불변 참조자와 가변 참조자가 동시에 존재할 때에도 경쟁 상태가 발생할 수 있기 때문에 값에 대해 불변 참조자가 존재할 때 가변 참조자를 만들 수 없다.
그래서 가변 참조자에 대한 규칙은 다음과 같다.
가변 참조자는 특정 스코프 내에 가변 참조자를 하나밖에 만들 수 없으며 동일한 값에 불변 참조자가 존재할 때 가변 참조자를 만들 수 없다.
슬라이스
전체 컬렉션의 일부분을 “참조" 하는 것을 슬라이스라 한다. 슬라이스의 사용 이유는 원본 컬렉션의 인덱스를 가리키는 위치가 의미가 없어지는 것을 방지하는 것을 의미한다.
예를 들어서 문장의 첫번째 단어가 위치한 인덱스를 리턴하는 함수가 있다고 가정할 때 원본 문자열이 파기되면 인덱스는 아무런 의미가 없어지고 오류를 유발하게 되는 값이다. 원본 데이터와 원본 데이터를 가리키는 데이터의 싱크가 맞지 않기 때문이다.
스트링 슬라이스
rust에선 문자열의 일부를 가리키는 스트링 슬라이스라는 것을 제공해 준다. 스트링 슬라이스는 문자열의 일부분에 대한 “참조자" 이다.
스트링 슬라이스는 내부적으로 원본 문자열의 위치, 길이를 담고 있고 참조자의 일종이므로 원본 문자열과 싱크를 유지할 수 있다.
스트링 리터럴도 스트링 슬라이스이다. 바이너리의 특정 위치를 가리키고 있다. 그래서 스트링 리터럴은 불변 속성을 가지고 있다.
단순히 인덱스를 의미하는 숫자와 슬라이스를 비교해 보자. 문자열의 앞 문자 2개를 가져오기 위한 인덱스를 리턴하는 함수 get_two_char_index 를 만들 때에 대한 상황이다.
fn main() { let mut string = String::from("hello world"); let pos = get_two_char_index(&string); println!("string : {}, pos : {}", string, pos); string.clear(); println!("string : {}, pos : {}", string, pos); } fn get_two_char_index(s: &String) -> usize { if s.len() <= 2 { s.len() } else { 2 } }
위 코드는 에러가 발생하진 않지만 pos를 이용해 string에 접근해 무엇인가를 할 때
string.clear(); 호출 이후 pos는 이를 가리키는 문자열인 string과 싱크 문제가 발생할 수 있다.문자열의 부분 문자열을 가리킬 수 있는 스트링 슬라이스를 사용하면 이런 문제가 발생하는 것을 방지해 준다.
fn main() { let mut string = String::from("hello world"); let substr = get_two_char(&string); println!("string : {}, substr : {}", string, substr); string.clear(); // clear 함수에서 가변 참조자를 만들기 때문에 E0502 에러 발생 println!("string : {}, substr : {}", string, substr); } fn get_two_char(s: &String) -> &str { if s.len() <= 2 { s } else { &s[..2] } }
위 코드에서 get_two_char를 이용해 기존 string에 대해 불변 참조자인 substr 스트링 슬라이스를 만들었다. 이후
string.clear(); 호출시에 문자열을 비우기 위해 string.clear(); 내부적으로 string에 대해 가변 참조자를 만드는 동작을 한다. 하지만 위에서 다룬 규칙대로 이미 불변 참조자가 만들어졌으므로 가변 참조자를 만들 수 없기 때문에 위 코드는 컴파일 에러가 발생한다.만약
string.clear(); 구문을 지우고 동작시키면 정상적으로 동작되는 것을 확인할 수 있다.라이프타임
참조자를 사용할 때 댕글링 참조자(dangling reference) 문제가 생길 수 있다. 참조자가 가리키는 대상이 도중에 할당 해제되거나 하는 문제가 생길 수 있기 때문이다.
아래 코드는 댕글링 참조자의 문제가 있는 코드이며 댕글링 참조자 문제 때문에 컴파일이 되지 않는다.
let str_a = "Hello, world!"; // 프로그램이 종료될때까지 유효한 변수 let str_ref; // 프로그램이 종료될때까지 유효한 참조자 변수 (초기화 되어 있지 않아 현재 사용 불가) { let str_b = "Hello"; // 이 스코프 내에서만 유효한 변수 str_ref = &str_b; // str_a를 참조하면 괜찮지만 str_b는 스코프를 나가면 무효화되어 댕글링 포인터가 된다. println!("str_ref : [{}]", str_ref); // str_a, str_b 에 대해서 모두 정상 동작 } println!("str_ref : [{}]", str_ref); // str_b를 참조할 시 에러
이런 문제 때문에 rust에서는 “라이프타임" 이라는 개념을 도입한다. 라이프타임은 모든 참조자에 대해 적용되는 개념이며 rust에서 모든 참조자들은 라이프타임을 가진다. 라이프타임의 의미는 해당 참조자가 유효한 스코프를 의미한다.
위 코드에서는
str_ref 가 빌린 값이 유효한지 컴파일러가 판별 가능하다. 빌림 검사기 (Borrow Checker)가 동작하여 스코프를 비교하여 체크해 준다. 빌림 검사기는 원본 데이터가 참조자보다 짧은 라이프타임을 가진다면 오류를 발생시킨다.라이프타임 파라미터
라이프타임을 추론하기 어려운 경우 직접 라이프타임을 명시해야 할 경우가 있다.
예를 들면 참조자가 함수의 인수로 들어가 참조자를 반환하는 형태의 함수가 있는데 이 경우 어느 참조자를 리턴하는지에 대한 명시가 필요하다. 함수 내에 스코프에서만 사용되는 참조자를 리턴하면 안되며 인수로 들어가는 참조자들을 리턴해야 오류가 발생하지 않기 때문이다.
fn int_max<'a>(a: &'a u32, b: &'a u32) -> &'a u32 { if a > b { a } else { b } }
위 함수처럼 입력 인자 두개 중 하나와 리턴 인자 하나가 모두 동일한 라이프타임을 가져야만 오류가 나지 않는다. 위에서 나타낸
'a 가 라이프타임 파라미터이며 함수에서 라이프타임 파라미터를 사용할 경우 <> 을 이용해 명시를 한다. 그리고 라이프타임을 맞춰줄 참조자에 대해 파라미터를 적용한다.알파벳
a 를 사용하는 특별한 이유는 없다. 하지만 보통 a를 사용한다.라이프타임 파라미터는 구조체나 메소드, 함수 정의에서 사용할 수 있다.
정적 라이프타임 파라미터
특별한 라이프타임 파라미터가 하나 존재하는데
'static 이다. 이 라이프타임은 프로그램의 전체 라이프타임 주기와 동일하다.예를 들면 스트링 리터럴이 있는데 스트링 리터럴 원본은 바이너리 내에 저장되고 그것을 가리키는 스트링 리터럴은 프로그램 시작부터 종료까지 가리키고 있는 스트링 리터럴 원본이 사라지지 않기 때문에 모든 스트링 리터럴은
'static 으로 취급된다.