![Rust 걸음마 떼기 (4) - 소유권 (러스트의 메모리 관리)](https://img1.daumcdn.net/thumb/R750x0/?scode=mtistory2&fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F4Cjae%2FbtsAFyH6VvW%2F1QWCAiu55vVdwAb0OPITa1%2Fimg.png)
2023.11.21 - [백엔드/Rust] - Rust 걸음마 떼기 (1) - Rust 설치 및 실행
2023.11.21 - [백엔드/Rust] - Rust 걸음마 떼기 (2) - 변수 선언, 입력, 비교
2023.11.21 - [백엔드/Rust] - Rust 걸음마 떼기 (3) - 일반 프로그래밍 개념을 rust에서는 어떻게 다루는가
소유권은 러스트의 독특한 기능중 하나로, 가비지 콜렉터에 의존하지 않고도 메모리 안정성을 보장하려는 러스트의 해법이다!
소유권, 대여, 슬라이스에 대해 알아본다.
소유권이란?
러스트는 메모리를 컴파일러가 컴파일 시점에서 다양한 규칙으로 이루어진 소유권 시스템으로 관리한다.
소유권과 관련된 기능들은 실행 성능에 아무런 영향을 끼치지 않는다.
(1) 소유권 규칙
먼저 소유권에 적용되는 규칙은 다음과 같다.
- 러스트가 다루는 각각의 값은 소유자라고 부르는 변수를 가지고 있다.
- 특정 시점에 값의 소유자는 단 하나 뿐이다.
- 소유자가 범위를 벗어나면 그 값은 제거된다.
(2) 변수의 범위
앞으로의 코드 작성에서는 fn main()은 생략한다. (필요 코드에만 집중하기 위해)
아래 예제에서
변수 s는 문자열 리터럴을 참조한다.
{
let s = "hello"; //변수 s는 여기서 부터 유효하다.
} // 범위를 벗어나므로 s는 유효하지 않다.
위 예제의 핵심은 두가지다
- 변수 s가 범위 안으로 들어오면 유효하다
- 변수는 범위를 벗어나기 전까지 유효하다.
(3) String 타입
앞서 살펴 본 타입들은 모두 스택에 저장되며 범위를 벗어나면 스택에서 제거된다.
힙에 저장되는 데이터는 러스트가 어떻게 제거하는지 알아보자.
이번 예제는 String 타입과 관련된 소유권 동작을 설명한다.
앞서 변수 s로 정의한 문자열 리터럴은 변경할 수 없는 변수인데 ( 문자열은 immutable )
이걸로 모든 문자열을 처리하기는 어렵다.
왜냐하면 코드 작성시점에 필요한 모든 문자열을 알 수 없기 때문이다.
따라서, 사용자가 입력한 값을 어딘가에 저장해야한다면 또 다른 타입인 String 타입을 써야한다.
이 타입은 힙에 할당되므로 컴파일 시점에 알 수 없는 문자열을 저장할 수 있다.
다음과 같이 쓴다.
let s = String::from("Hello");
이렇게 생성한 문자열은 다음과 같이 변경가능하다.
let mut s = String::from("Hello");
s.push_str(", world!"); // push_str()은 String 인스턴스에 리터럴을 결합한다.
println!("{}",s);
String과 문자열 리터럴은 왜 다를까?
차이점은 두 타입이 메모리를 다루는 방법에있다.
(4) 메모리와 할당
문자열 리터럴은 컴파일 시점에 문자열의 내용을 알 고 있다. (직접 하드코딩 가능)
반면, 가변문자열을 지원하는 String 타입은 힙 메모리에 일정 부분의 메모리를 할당해야한다.
따라서, 다음과 같은 두 절차를 거친다.
1. 해당 메모리는 반드시 런타임에 운영체제에 요청해야한다..
2. String타입이 사용 완료된 후에는 이 메모리를 운영체제에 다시 돌려줄 방법이 필요하다.
1번은 개발자가 직접 처리해야하는것이고, 2번은 언어마다 다르다.
JAVA는 가비지 컬렉터가 있고,
C/C++ 에서는 allocate (주로 malloc ,new) 그리고 free 작업을 해줘야한다. (이걸 관리하기가 상당히 힘들다!)
반면 러스트는 이를 다른 방식으로 수행한다!
변수에 할당된 메모리는 변수를 소유한 범위를 벗어나는 순간 자동으로 해제한다..!
{
let s = String::from("hello!"); // s는 이 지점부터 유효하다.
//s를 이용해 필요한 동작을 수행한다.
}//여기서 범위를 벗어나므로 변수 s는 이제 유효하지 않다.
변수가 범위를 벗어나면 러스트는 drop 이라는 이름의 특별한 함수를 호출한다.
[1] 변수와 데이터가 상호 작용하는 방식 : 이동(Move)
let x = 5;
let y = x;
위는 x,y 에 둘다 5를 할당하는 방식으로 잘 작동한다.
하지만 아래는 같은 방식으로 작동하지는 않는다.
let s1 = String::from("hello");
let s2 = s1;
String 타입은 아래 그림과 같이 작동한다.
그리고 s2에 s1을 할당하는 방법은 모든 값을 그대로 복사하는게 아니다
아래와 같이 동작한다.
힙 데이터인 hello를 그대로 복사하는게 아니라 포인터와 길이만 복사를 하는것이다.
그러면 이렇게 된다면 변수가 범위 밖으로 벗어날때 러스트는 자동으로 drop 함수를 호출해서 해당변수가 사용하는 힙 메모리를 제거할것이다.
근데 위에서는 힙 메모리에 저장된 Hello는 하나밖에 없는데 s1,s2를 drop하게 되니 double free 문제가 발생할 수 있다.
이런 경우를 방지하기 위해서 러스트는 하나의 디테일이 더 있는데 예제로 확인할 수 있다.
let s1 = String::from("hello");
let s2 = s1;
println!("{}, world!", s1);
위를 빌드하면 다음 과 같은 에러가 발생할것이다.
error[E0382]: use of moved value: `s1`
--> src/main.rs:4:27
|
3 | let s2 = s1;
| -- value moved here
4 | println!("{}, world!", s1);
| ^^ value used here after move
|
= note: move occurs because `s1` has type `std::string::String`,
which does not implement the `Copy` trait
다른 언어에서 "얕은 복사" 와 "깊은 복사" 같은 개념이 있는데 그것과 같지는 않고 얕은 복사와 비슷하게 보일 수 있으나
러스트에서는 "이동(Move)" 라고 한다.
여기서 우리는 s1이 s2로 이동되었다고 말한다.
실제로 다음 그림 처럼 s1은 무효화 된다.
[2] 변수와 데이터가 상호작용 하는 방식 : 복제 (clone)
하지만 실제로 String 데이터를 복사하기를 원한다면 clone() 이라는 공통 메서드를 활용한다!
let s1 = String::from("hello");
let s2 = s1.clone();
println!("s1 = {}, s2 = {}", s1, s2);
[3] 스택 전용 데이터 : 복사 (Copy)
let x = 5;
let y = x;
println!("x = {}, y = {}", x, y);
근데 아까 예제에서는 정수형을 위와같은 방식으로 쓸때는 복제를 사용하지 않았는데도 변수y로 x값이 이동하지 않았다.
그 이유는 정수형 같은 타입은 컴파일 시점에 이미 크기를 알 수 있으며
온전히 스택에 저장되기때문에 실제값을 복사해도 크게 부담이 없기 때문이란다. (완전 자기 맘대로다..)
그래서 러스트는 스택에 저장되는 정수형 같은 타입에 적용할 수 있는 Copy 트레이트 (trait) 라는 특별한 특성을 활용한다.
그래서 어떤 타입에 copy 트레이트가 적용되어 있다면 이전 변수를 새 변수에 할당해도 무효화하지 않는다.
그런 타입들은 다음과 같다.
- u32와 같은 모든 정수형 타입들
- true와 false값을 갖는 부울린 타입 bool
- 문자 타입 char
- f64와 같은 모든 부동 소수점 타입들
- Copy가 가능한 타입만으로 구성된 튜플들. (i32, i32)는 Copy가 되지만, (i32, String)은 안됩니다.
(5) 소유권과 함수
값을 함수에 전달한다는 의미는 값을 변수에 대입하는것과 유사하다.
예시로 알아보자
fn main() {
let s = String::from("hello"); // s가 스코프 안으로 들어왔습니다.
takes_ownership(s); // s의 값이 함수 안으로 이동했습니다...
// ... 그리고 이제 더이상 유효하지 않습니다.
let x = 5; // x가 스코프 안으로 들어왔습니다.
makes_copy(x); // x가 함수 안으로 이동했습니다만,
// i32는 Copy가 되므로, x를 이후에 계속
// 사용해도 됩니다.
} // 여기서 x는 스코프 밖으로 나가고, s도 그 후 나갑니다. 하지만 s는 이미 이동되었으므로,
// 별다른 일이 발생하지 않습니다.
fn takes_ownership(some_string: String) { // some_string이 스코프 안으로 들어왔습니다.
println!("{}", some_string);
} // 여기서 some_string이 스코프 밖으로 벗어났고 `drop`이 호출됩니다. 메모리는
// 해제되었습니다.
fn makes_copy(some_integer: i32) { // some_integer이 스코프 안으로 들어왔습니다.
println!("{}", some_integer);
} // 여기서 some_integer가 스코프 밖으로 벗어났습니다. 별다른 일은 발생하지 않습니다.
(6) 리턴값과 범위
값의 반환또한 소유권을 이동시킨다.
fn main() {
let s1 = gives_ownership(); // gives_ownership은 반환값을 s1에게
// 이동시킵니다.
let s2 = String::from("hello"); // s2가 스코프 안에 들어왔습니다.
let s3 = takes_and_gives_back(s2); // s2는 takes_and_gives_back 안으로
// 이동되었고, 이 함수가 반환값을 s3으로도
// 이동시켰습니다.
} // 여기서 s3는 스코프 밖으로 벗어났으며 drop이 호출됩니다. s2는 스코프 밖으로
// 벗어났지만 이동되었으므로 아무 일도 일어나지 않습니다. s1은 스코프 밖으로
// 벗어나서 drop이 호출됩니다.
fn gives_ownership() -> String { // gives_ownership 함수가 반환 값을
// 호출한 쪽으로 이동시킵니다.
let some_string = String::from("hello"); // some_string이 스코프 안에 들어왔습니다.
some_string // some_string이 반환되고, 호출한 쪽의
// 함수로 이동됩니다.
}
// takes_and_gives_back 함수는 String을 하나 받아서 다른 하나를 반환합니다.
fn takes_and_gives_back(a_string: String) -> String { // a_string이 스코프
// 안으로 들어왔습니다.
a_string // a_string은 반환되고, 호출한 쪽의 함수로 이동됩니다.
}
참조와 대여
값의 소유권을 넘기지 않고 대신 개체에 대한 참조자(&)를 인자로 사용하는 방법이 있다.
fn main() {
let s1 = String::from("hello");
let len = calculate_length(&s1);
println!("The length of '{}' is {}.", s1, len);
}
fn calculate_length(s: &String) -> usize { // s는 String의 참조자입니다
s.len()
} // 여기서 s는 스코프 밖으로 벗어났습니다. 하지만 가리키고 있는 값에 대한 소유권이 없기
// 때문에, 아무런 일도 발생하지 않습니다.
이 엠퍼센드(&) 기호가 참조자이며, 이는 여러분이 어떤 값을 소유권을 넘기지 않고 참조할수 있도록 해줍니다.
&s1 문법을 이용하면 변수 s1의 값은 읽을수있지만 소유권은 가져오지 않는 참조를 생성할 수 있다.
이처럼 함수 매개변수로 참조를 전달하는 것을 대여 (borrowing) 이라고 한다.
대여한 변수를 변경하려고 하면 어떻게 될까?
fn main() {
let s = String::from("hello");
change(&s);
}
fn change(some_string: &String) {
some_string.push_str(", world");
}
error: cannot borrow immutable borrowed content `*some_string` as mutable
--> error.rs:8:5
|
8 | some_string.push_str(", world");
| ^^^^^^^^^^^
오류가 난다.
(1) 가변 참조
위의 코드를 조금 수정하면 에러를 수정할 수 있다.
fn main() {
let mut s = String::from("hello");
change(&mut s);
}
fn change(some_string: &mut String) {
some_string.push_str(", world");
}
s를 mut로 바꿔야하고, &mut string 처럼 가변참조자를 파라미터로 받아야한다
가변참조에는 한가지 제약이 있다.
특정 범위내의 특정 데이터에 대한 가변참조는 오직 한 개만 존재해야한다.
다음의 경우 오류가 난다.
let mut s = String::from("hello");
let r1 = &mut s;
let r2 = &mut s;
println!("{}, {}",r1,r2);
error[E0499]: cannot borrow `s` as mutable more than once at a time
--> borrow_twice.rs:5:19
|
4 | let r1 = &mut s;
| - first mutable borrow occurs here
5 | let r2 = &mut s;
| ^ second mutable borrow occurs here
6 | }
| - first borrow ends here
중괄호로 범위를 여러개 만들어서 가능하게는 할 수 있다.
let mut s = String::from("hello");
{
let r1 = &mut s;
} // 여기서 r1은 스코프 밖으로 벗어났으므로, 우리는 아무 문제 없이 새로운 참조자를 만들 수 있습니다.
let r2 = &mut s;
가변참조자와 불변참조자를 혼용할때도 조심해야한다.
let mut s = String::from("hello");
let r1 = &s; // 문제 없음
let r2 = &s; // 문제 없음
let r3 = &mut s; // 큰 문제
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as
immutable
--> borrow_thrice.rs:6:19
|
4 | let r1 = &s; // 문제 없음
| - immutable borrow occurs here
5 | let r2 = &s; // 문제 없음
6 | let r3 = &mut s; // 큰 문제
| ^ mutable borrow occurs here
7 | }
| - immutable borrow ends here
불변 참조를 이미 사용중일때도 가변 참조를 생성할 수 없다는 점이다.
어딘가에서 이미 가변참조를 생성했다면 그 값을 변경해서는 안되기 때문이다.
(2) 죽은 참조 (Dangling reference)
포인터를 사용하는 언어는 죽은 포인터로 인해 에러가 발생하기 쉽다.
러스트는 죽은 참조가 발생하지 않도록 컴파일러가 보장해준다.
다음은 에러가 발생한다.
fn main() {
let reference_to_nothing = dangle();
}
fn dangle() -> &String {
let s = String::from("hello");
&s
}
error[E0106]: missing lifetime specifier
--> dangle.rs:5:16
|
5 | fn dangle() -> &String {
| ^^^^^^^
|
= help: this function's return type contains a borrowed value, but there is no
value for it to be borrowed from
= help: consider giving it a 'static lifetime
error: aborting due to previous error
이유는 다음과 같다.
fn dangle() -> &String { // dangle은 String의 참조자를 반환합니다
let s = String::from("hello"); // s는 새로운 String입니다
&s // 우리는 String s의 참조자를 반환합니다.
} // 여기서 s는 스코프를 벗어나고 버려집니다. 이것의 메모리는 사라집니다.
// 위험하군요!
이 문제를 해결하려면 String 타입을 직접 리턴하는 방법이 있다.
fn no_dangle() -> String {
let s = String::from("hello");
s
}
(3) 참조자의 규칙
- 어떤 경우든 간에 아래의 둘 중 하나만 가질 수 있다.
- 하나의 가변 참조자
- 하나 혹은 여러개수의 불변 참조자
- 참조자는 항상 유효해야만 한다.
슬라이스
슬라이스도 소유권을 갖지 않는 타입이다.
슬라이스를 이용하면 컬렉션 전체가 아니라 컬렉션 내의 연속된 요소들을 참조 할 수 있다.
전체 문자열에서 첫 단어만 리턴하는 함수를 작성해보자
하지만 무엇을 리턴해야할까?
문자열 일부를 추출하는 방법은 없지만, 단어의 끝에 해당하는 문자의 인덱스는 리턴할 수 있다.
fn first_word(s: &String) -> usize {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return i;
}
}
s.len()
}
enumerate 메서드는 iter 메서드의 결과를 감싸서 각 원소를 튜플 형태로 리턴하는것만 알아두자.
위의 방식으로 구현은 했지만, 이는 &String의 내용물 내에서만 의미가 있다.
즉, 이 숫자 자체가 String으로 부터 분리되어 있는 숫자기 때문에 이것이 나중에도 유효한지 보장할 길이 없다.
fn main() {
let mut s = String::from("hello world");
let word = first_word(&s); // word는 5를 갖게 될 것입니다.
s.clear(); // 이 코드는 String을 비워서 ""로 만들게 됩니다.
// word는 여기서 여전히 5를 갖고 있지만, 5라는 값을 의미있게 쓸 수 있는 스트링은 이제 없습니다.
// word는 이제 완전 유효하지 않습니다!
}
(1) 문자열 슬라이스 (String slices)
문자열 슬라이스는 String 일부에 대한 참조로 다음과 같이 사용할 수 있다.
let s = String::from("hello world");
let hello = &s[0..5];
let world = &s[6..11];
실제 할당은 다음과 같이 된다.
이런식으로도 쓸 수 있다.
let s = String::from("hello");
let slice = &s[0..2];
let slice = &s[..2]; //위와 동일
let s = String::from("hello");
let len = s.len();
let slice = &s[3..len];
let slice = &s[3..]; //위와 동일
문자열 슬라이스를 이용해서 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[0..i];
}
}
&s[..]
}
그러면 이전의 예제에서 보장하지 못했던 것도 다음과 같이 보장해 줄 수 있게 된다.
fn main() {
let mut s = String::from("hello world");
let word = first_word(&s);
s.clear(); // Error!
println!("the first word is: {}", word);
}
17:6 error: cannot borrow `s` as mutable because it is also borrowed as
immutable [E0502]
s.clear(); // Error!
^
15:29 note: previous borrow of `s` occurs here; the immutable borrow prevents
subsequent moves or mutable borrows of `s` until the borrow ends
let word = first_word(&s);
^
18:2 note: previous borrow ends here
fn main() {
}
^
(2) 문자열 리터럴은 슬라이스다
let s = "Hello, world!";
여기서 s의 타입은 &str이다.
이것은 바이너리의 특정 지점을 가리키고있는 슬라이스다.
이는 왜 문자열 리터럴이 immutable인지 설명해준다.
&str은 불변 참조자기 때문이다.
(3) 파라미터로서의 문자열 슬라이스
여러분이 리터럴과 String의 슬라이스를 얻을 수 있다는 것을 알게 되었다면 first_word 함수를 한번 더 개선시킬 수 있는데, 바로 이 함수의 시그니처입니다
fn first_word(s: &String) -> &str: {
더 경험이 많은 러스트인이라면 대신 아래와 같이 작성하는데, 그 이유는 &String과 &str 둘 모두에 대한 같은 함수를 사용할 수 있도록 해주기 때문입니다.
fn first_word(s: &str) -> &str {
fn main() {
let my_string = String::from("hello world");
// first_word가 `String`의 슬라이스로 동작합니다.
let word = first_word(&my_string[..]);
let my_string_literal = "hello world";
// first_word가 스트링 리터럴의 슬라이스로 동작합니다.
let word = first_word(&my_string_literal[..]);
// 스트링 리터럴은 *또한* 스트링 슬라이스이기 때문에,
// 아래 코드도 슬라이스 문법 없이 동작합니다!
let word = first_word(my_string_literal);
}
(4) 이외의 슬라이스들
let a = [1, 2, 3, 4, 5];
let slice = &a[1..3];
이 슬라이스는 &[i32] 타입을 갖는다.
이는 문자열 슬라이스가 동작하는 방법과 같이, 슬라이스의 첫번째 요소에 대한 슬라이스의 길이를 저장하는 방식으로 동작한다.
'백엔드 > Rust' 카테고리의 다른 글
Rust with Flutter Tutorial (4) | 2023.11.21 |
---|---|
Rust 걸음마 떼기 (5) - Rust의 구조체 (2) | 2023.11.21 |
Rust 걸음마 떼기 (3) - 일반 프로그래밍 개념을 rust에서는 어떻게 다루는가 (1) | 2023.11.21 |
Rust 걸음마 떼기 (2) - 변수 선언, 입력, 비교 (2) | 2023.11.21 |
Rust 걸음마 떼기 (1) - Rust 설치 및 실행 (0) | 2023.11.21 |
개발 및 IT 관련 포스팅을 작성 하는 블로그입니다.
IT 기술 및 개인 개발에 대한 내용을 작성하는 블로그입니다. 많은 분들과 소통하며 의견을 나누고 싶습니다.