소유권
러스트 프로그램의 메모리 관리법을 지배하는 규칙모음
몇몇 언어는 가비지 컬렉션으로 프로그램에서 더 이상 사용하지 않는 메모리를 정기적으로 찾는 방식을 채택했고,
다른 언어는 프로그래머가 직접 명시적으로 메모리를 할당하고 해제하는 방식을 채택
러스트는 소유권이라는 시스템을 만들고 컴파일러가 컴파일 중에 여러 규칙을 정래 메모리를 관리
이 규칙중 어느 하나라도 위반하면 프로그램은 컴파일 되지 않음
소유권의 어떠한 특성도 프로그램 실행 속도를 느리게하지 않음
스택
값이 들어온 순서대로 저장하고 역순으로 제거하는 후입선출 (Last In First Out) 방식으로 데이터를 저장하는 메모리 영역
데이터를 저장하는 것을 push, 스택에서 데이터를 꺼내고 제거하는 것을 pop이라고 함
스택에 저장되는 데이터는 모두 명확하고 크기가 정해져 있어야함
컴파일 타임에 크기를 알 수 없거나 크기가 변경될 수 있는 데이터는 스택 대신 힙에 저장되어야 함
힙
포인터를 참조하여 포인터가 가리키는 위치로 이동하여 저장하는 방식의 메모리 영역
데이터를 넣을 때 운영체제에 먼저 저장할 공간이 있는지 물어보고 메모리 할당자가 힙 영역 내의 빈 지점을 찾아서,
해당 지점을 사용 중으로 표시한 뒤 해당 지점을 가리키는 포인터를 반환
이 과정을 할당 (allocation)이라고 함
소유권과 스택/힙
스택 영역이 데이터에 접근하는 방식 상 힙 영역보다 빠름 (새로운 데이터를 저장할 공간을 찾을 필요가 없기 때문)
힙 영역은 포인터가 가리키는 곳을 찾아가는 과정으로 인해 느려짐
현대 프로세서는 메모리 내부를 왔다갔다 하는 작업이 적을 수록 속도가 빨라지는데 힙에 있는 데이터들은 서로 멀리 떨어져 있어 느림
우리가 함수를 호출하면 호출한 함수에 넘겨준 값 (그중에는 힙 영역의 데이터를 가리키는 포인터도 있을 수 있음)과 해당 함수의 지역 변수들이 스택에 push 되고, 이 데이터들은 함수가 종료될 때 pop 됨
코드 어느 부분에서 힙의 어떤 데이터를 사용하는지 추적하고 힙에서 중복되는 데이터를 최소화하고 쓰지 않는 데이터를 힙에서 정리해 영역을 확보하는 등의 작업은 모두 소유권과 관련되어 있음
즉, 소유권의 주요 목표는 힙 데이터의 관리라는 것임
소유권 규칙
1. 러스트에서 각각의 값은 소유자(owner)가 정해져 있음
2. 한 값의 소유자는 동시에 여럿 존재할 수 없음
3. 소유자가 스코프 밖으로 벗어날 때 값은 버려짐 (dropped)
변수의 스코프
스코프란 프로그램 내에서 아이템이 유효한 범위를 의미
아래 코드에서 변수 s는 스코프 밖으로 벗어나기 전까지 유효
fn main() { // s가 아직 선언되지 않아 여기서는 유효하지 않음
let s = "hello"; // 이 지점부터 s가 유효하게 됨
// s가 여전히 유효
} // 스코프가 종료되었고 s가 더이상 유효하지 않음
String 타입
정적 타입의 경우 명확한 크기를 가지고 있어서 전부 스택에 저장되고 스코프를 벗어날 때 제거되면서
코드의 다른 쪽에서 별도의 스코프 내에서 같은 값을 사용 시 독립적인 인스턴스를 빠르고 간단하게 만들어 낼 수 있으나,
String 타입의 경우 힙에 저장되며 가변한 크기를 가짐
String은 from 함수와 문자열 리터럴을 이용해 생성 가능
이중콜론 ::은 함수를 사용할 때 string_from 같은 함수명을 사용하지 않고 String 타입에 있는 특정된 from 함수라는 것을 지정할 수 있게 해주는 네임스페이스 연산자
fn main() {
let s = String::from("hello");
s.push_str(", world!"); // 문자열에 리터럴 추가
println!("{s}"); // hello, world!
}
String은 힙에 메모리를 할당하기 때문에 텍스트 내용과 크기를 변경할 수 있음
그러나 실행 중 메모리 할당자로부터 메모리를 요청해야하며 String 사용을 마쳤을 때 메모리를 해제할 (할당자에게 메모리를 반납할) 방법이 필요
- 실행중 메모리 할당자로부터 메모리 요청
String::from을 통해 필요한 만큼 메모리를 요청
- String 사용을 마쳤을 때 메모리를 해제
러스트에는 GC가 없기 때문에 자동으로 해제되지가 않으나, 프로그래머가 메모리 해제 시점을 직접 설정하지도 않음
대신 변수가 자신이 소속된 스코프를 벗어나는 순간 자동으로 메모리를 해제함
즉, allocate (할당)와 free (해제)를 하나 씩 짝짓지 않아도 편하게 메모리를 해제할 수 있음
러스트는 변수가 스코프 밖으로 벗어나면 drop 이라는 특별한 함수를 호출함
이 함수는 해당 타입을 개발한 개발자가 직점 메모리 해제 코드를 작성해 넣을 수 있게 되어있음
(C++의 RAII 패턴과 비슷)
변수 간 데이터 상호작용 방식: 이동
fn main() {
let x = 5; // 스택에 5를 push
let y = x; // x 값의 복사본을 스택에 push (5가 push됨)
let s1 = String::from("hello");
let s2 = s1; // 메모리 안정성을 위해 s1은 더 이상 유효하지 않게 됨
// 이후로 s1 참조 시 오류 발생
}
러스트에서는 힙 메모리의 데이터를 복사하지 않음
왜냐하면 스코프를 벗어났을 때 두 변수를 drop 하면 중복 해제 (double free) 문제가 발생하여 메모리 안정성에 영향을 주기 때문
대신 러스트에서는 힙 메모리에 있는 데이터를 다른 변수에 이동 (move) 시켜 기존 변수가 스코프를 벗어나더라도 아무것도 해제할 필요가 없게 함
다른 프로그래밍 언어에서는 힙 데이터를 복사하지 않고 포인터, 길이, 용량 값만 복사하는 얕은 복사 (shallow copy)와
힙 데이터까지 복사하는 깊은 복사 (deep copy) 라는 개념이 있지만
러스트에서는 기존 변수를 무효하하기 때문에 이동 (move)이라고 표현함
따라서 위 코드에서는 s1이 s2로 이동되었다고 표현함
러스트에서는 절대 자동으로 깊은 복사를 통해 데이터를 복사하는 일이 없기 때문에 러스트가 자동으로 수행하는 모든 복사는 런타임 성능 측면에서 효율적임
변수 간 데이터 상호작용 방식: 클론
러스트에서는 String의 힙 데이터까찌 깊은 복사를 하고 싶은 경우 clone 이라는 공용 메서드를 사용
fn main() {
let s1 = String::from("hello");
let s2 = s1.clone();
println!("s1 = {}, s2 = {}", s1, s2); // s1과 s2 모두 유효
}
러스트에서는 clone을 통해 성능에 영향이 갈 수 있는 코드가 실행될 수 있음을 미리 알 수 있음 (시각적인 표시)
스택에만 저장되는 데이터: 복사
fn main() {
let x = 5;
let y = x;
println!("x = {}, y = {}", x, y);
}
정수형 등 컴파일 타임에 크기가 고정되는 타입은 모두 데이터가 스택에 저장됨
이로인해 복사본을 빠르게 만들 수 있기 때문에 clone을 하지 않아도 됨
러스트에는 정수형 처럼 스택에 저장되는 타입에 붙혀 놓을 수 있는 Copy 트레이트라는 것이 있음
어떤 타입에 Copy 트레이트가 구현되어 있으면, 이 타입의 변수는 자명하게 복사가 되고 대입 연산 이후에도 사용 가능
그러나 사용할 타입에 Drop 트레이트가 구현된 경우에는 Copy 트레이트를 애너테이션 (annotaion) 할 수 없음
즉, Copy 트레이트와 Drop 트레이트는 공존 할 수 없다는 것임
스코프 밖으로 벗어났을 때 특정 동작이 요구되는 타입에 Copy 애너테이션을 추가하면 컴파일 에러가 발생
- Copy 트레이트를 애너테이션 할 수 있는 타입
할당이 필요하거나 리소스의 일종인 경우에는 불가능
단순한 스칼라 묶음
모든 정수형 타입
불리언
모든 부동소수점 타입
문자 타입
Copy 가능한 타입만으로 구성된 튜플
소유권과 함수
함수로 값을 전달하는 메커니즘은 변수에 값을 대입할 때와 유사함
함수에 변수를 전달하면 대입 연산과 마찬가지로 이동이나 복사가 발생하기 때문
fn main() {
let s = String::from("hello"); // s가 스코프 안에 들어옴
takes_ownership(s); // s의 값이 함수로 이동됨
// 여기서부터는 s가 유효하지 않음
let x = 5; // x가 스코프 안에 들어옴
makes_copy(x); // x가 함수로 이동되지만 i32는 Copy 트레이트이므로 이 라인 이후에도 x는 사용 가능
}
fn takes_ownership(some_string: String) { // some_string 스코프 안에 들어옴
println!("{}", some_string);
} // some_string이 스코프 밖으로 벗어나고 drop이 호출됨
// some_string에 대한 메모리 해제
fn makes_copy(some_integer: i32) { // some_integer가 스코프 안으로 들어옴
println!("{}", some_integer);
}// some_integer가 스코프 밖으로 벗어나지만, 별다른 일이 발생하지 않음
반환 값과 스코프
소유권은 값을 반환하는 과정에서도 발생
어떤 값을 다른 변수에 대입하면 값이 이동하고
힙에 데이터를 갖는 변수는 사전에 해당 데이터가 이동하여 소유권이 다른 변수에 이동되지 않은 이상 스코프를 벗어나면 drop에 의해 제거됨
fn main() {
let s1 = gives_ownership(); // gives_owner_ship이 자신의 반환 값을 s1으로 이동시킴
let s2 = String::from("hello"); // s2가 스코프안에 들어옴
let s3 = takes_and_gives_back(s2); // s2는 takes_ane_give_back으로 이동되는데, 이 함수 또한 자신의 반환 값을 s3로 이동시킴
} // 여기서 s1과 s3가 스코프 밖으로 벗어나면서 drop 됨
// s2는 이동되었기 때문에 아무일도 일어나지 않음
fn gives_ownership() -> String { // 자신의 반환 값을 자신의 호출자 함수로 이동 시킬 것임
let some_string = String::from("yours"); // some_string이 스코프 안으로 들어옴
some_string // some_string이 반환되고 호출자 함수 쪽으로 이동됨
}
// String을 취하고 같은 것을 반환
fn takes_and_gives_back(a_string: String) -> String { // a_string이 스코프 안으로 들어옴
a_string // a_string이 반환되고 호출자 함수 쪽으로 이동됨
}
- 매개변수의 소유권을 돌려주는 방법 (튜플을 사용)
튜플을 사용하면 소유권을 돌려주면서 원하는 값을 가져올 수 있지만, 많은 작업량이 수반됨
fn main() {
let s1 = String::from("hello");
let (s2, len) = calculate_length(s1);
println!("length of {} is {}", s2, len);
}
fn calculate_length(s: String) -> (String, usize) {
let length = s.len();
(s, length)
}
소유권의 참조와 대여
참조자를 만들면 소유권이 이동된 변수를 함수 호출 이후에도 사용할 수 있음
참조자 (reference)는 해당 주소에 저장된 데이터에 접근할 수 있도록 주솟값에 해당하는 포인터와 같은 것으로 그 데이터는 다른 어떤 변수가 소유하고 있음
포인터와는 달리 참조자는 살아있는 동안 특정 타입에 대한 유효한 값을 가리키는 것을 보장
// 값의 소유권을 넘기는 대신 개체의 참조자를 넘기는 예제
fn main() {
let s1 = String::from("hello");
let len = calculate_length(&s1);
println!("length of {} is {}", s1, len);
}
fn calculate_length(sL &String) -> usize { // s는 String의 참조자
s.len()
} // s가 스코프 밖으로 벗어나지만 참조하고 있는 것을 소유하지 않으므로 버려지지 않음 (애초에 소유한 것이 없으니 버릴 것도 없는 것)
앰퍼샌드 & 기호를 사용하면 참조자를 나타내게됨
이는 어떤 값의 소유권을 가져오지 않으면서 해당 값을 참조하게 함
이떄 참조자는 참조하는 것을 수정할 수 없음
- 역참조 (dereferencing)
&를 이용한 참조의 반대 개념으로, * 기호를 사용
- 대여 (borrowing)
참조자를 만드는 행위
가변 참조자 (mutable reference)
본래 참조자의 경우 참조하고 있는 값을 수정할 수 없지만, 가변 참조자를 이용하면 참조하고 있는 값을 수정할 수 있음
fn main() {
let mut s = String::from("hello");
change(&mut s); // 가변 참조자 생성
}
fn change(some_string: &mut String) {
some_string.push_str(", world");
}
그러나 가변 참조자에는 큰 제약이 있는데,
어떤 값에 대한 가변 참조자가 있으면 그 값에 대한 가변 참조자를 만들 수 없음 (이중 가변 참조는 안된다는 것)
let mut s = String::from("hello");
let r1 = &mut s;
let r2 = & mut s; // 불가능
이러한 제약을 통해 러스트는 데이터 경합 (data race)를 방지할 수 잇음
- 데이터 경합
둘 이상의 포인터가 동시에 같은 데이터에 접근
포인터 중 하나 이상이 데이터에 쓰기 작업을 수행
데이터 접근 동기화 매커니즘이 없음
let mut s = String::from("hello");
{
let r1 = &mut s; // 스코프를 지정하면 새 가변 참조자를 만들 수 있음
} // r1이 스코프를 벗어나며 무효화되기 때문
let r2 = & mut s;
// --------------------------------------------------------
let r11 = &s; // 문제 없음
let r22 = &s; // 문제 없음
let r33 = &mut s; // 불가능. 불변 참조자가 있는 동안에 값은 값에 대한 가변 참조자를 만들 수 없음
// --------------------------------------------------------
let r111 = &s; // 문제 없음
let r222 = &s; // 문제 없음
println!("{} {}", r111, r222);
// 여기서 r111과 r222의 스코프가 종료됨
// 이 이후로 r111과 r222가 사용되지 않으므로 이후에 정의되는 가변 참조자는 인정됨
let r33 = &mut s; // 정상 (아무튼 그렇다고함)
가변 참조자는 스코프로 구분하면 동시에 존재가 가능함
추가로 가변 참조자와 불변 참조자가 동시에 존재할 때도 동일한 법칙이 적용됨
그러나 불변 참조자는 한 번 사용되고나면 스코프가 같아도 스코프가 종료되기 때문에
가변 참조자가 정의되어도 문제가 없음
즉, 컴파일러가 이 참조자가 어떤 지점 이후로 스코프 이후로 끝까지 사용되지 않음이 분명하면 문제가 없다는 것
댕글링 참조
댕글링 포인터에 대해 참조하는 것을 의미
- 댕글링 포인터 (dangling pointer)
어떤 메모리를 가리키는 포인터가 남아있는 상황에서 일부 메모리를 해제해버림으로써 다른 개체가 할당 받았을지도 모르는 메모리를 참조하게 된 포인터를 의미
포인터가 있는 언어에서는 이러한 포인터를 만들기 쉽지만, 러스트에서는 어떤 데이터의 참조자를 만들면 해당 참조자가 스코프를 벗어나기 전에 데이터가 먼저 스코프를 벗어나는지 컴파일러에서 확인하기 때문에 댕글링 참조가 발생하지 않도록 보장
슬라이스 타입 (slice type)
슬라이스는 컬렉션을 통쨰로 참조하지 않고 컬렉션의 연속된 일련의 요소를 참조하도록 해줌
즉, 참조자의 일종으로서 소유권을 갖지 않음
// 공백 문자로 구분된 단어들의 문자열을 입력 받아 해당 문자열의 첫 번째 단어를 반환
fn first_word(s: &String) -> usize {
let bytes = s.as_bytes(); // String을 바이트 배열로 변환
for (i, &item) in bytes.iter().enumerate() { // 바이트 배열에 사용할 반복자를 iter 메서드로 생성. enumerate 메서드로 튜플(인덱스, 참조자)로 반환
if item == b' ' { // item이 바이트 공백인 경우
return i;
}
}
s.len()
}
fn main() {
let mut s = String::from("hello world");
let word = first_word(&s); // word는 값 5를 받음
s.clear(); // String을 비워서 ""으로 만듦
// word에는 여전히 5가 들어있지만 이 5를 의미있게 쓸 수 있는 문자열이 더이상 존재하지 않기 때문에 word는 유효하지 않게됨
}
문자열 슬라이스
String의 일부를 가리키는 참조자를 의미
fn main() {
let s = String::from("hello world");
let hello = &s[0..5]; // 문자열 슬라이스. string의 0번 인덱스에서 (5-1)번 인덱스까지 참조. s의 0번째 바이트를 가리키는 포인터와 길이가 5인 슬라이스
let hello = &s[..5]; // 위와 동일
let world = &s[6..11]; // 문자열 슬라이스. string의 6번 인덱스에서 (11-1)번 인덱스까지 참조. s의 7번째 바이트를 가리키는 포인터와 길이가 5인 슬라이스
let len = s.len();
let world = &s[6..len]; // 위와 동일
let hello_world = &s[..]; // 전체 슬라이스
}
- 슬라이스 개선
// 공백 문자로 구분된 단어들의 문자열을 입력 받아 해당 문자열의 첫 번째 단어를 반환
fn first_word(s: &str) -> &str { // &str = 문자열 슬라이스를 나타내는 타입
let bytes = s.as_bytes(); // String을 바이트 배열로 변환
for (i, &item) in bytes.iter().enumerate() { // 바이트 배열에 사용할 반복자를 iter 메서드로 생성. enumerate 메서드로 튜플(인덱스, 참조자)로 반환
if item == b' ' { // item이 바이트 공백인 경우
return &s[0..i];
}
}
&s[..]
}
fn main() {
let my_string = String::from("hello world");
let word = first_word(&my_string[..]);
let word = first_word(&my_string); // 참조자에 대해서도 동작 (&my_string[..]과 동일)
let literal_str = "hello world";
let word = first_word(&literal_str[..]);
let word = first_word(literal_str); // 리터럴에 대해서도 동작 (함수에서 문자열 슬라이스를 파라미터로 받으므로)
}'Programming > Rust' 카테고리의 다른 글
| 러스트 프로젝트 관리 (패키지, 크레이트, 모듈) (4) | 2025.09.01 |
|---|---|
| 러스트의 열거형과 패턴 매칭 (1) | 2025.08.24 |
| 러스트의 구조체에 대해 알아보자 (3) | 2025.08.18 |
| 러스트의 일반적인 프로그래밍 개념 (6) | 2025.08.12 |
| 러스트 개념부터 설치, 기초 까지 (1) | 2025.08.11 |
