4.1. 소유권이 뭔가요

소유권 개요

소유권은 러스트에서 메모리 관리법을 지배하는 규칙 모음이다.
가비지 컬렉터, 개발자의 직접적인 메모리 해제가 아닌 제3의 방법으로 메모리를 해제한다.

스택과 힙에 대하여

러스트에서는 스택과 힙 중에서 어느 곳에 값을 저장하는지를 개발자가 선택할 수 있다.
둘 다 런타임에 이용하게 된다.
그러나 스택은 값을 들어온 순서대로 저장하고, 역순으로 제거한다.
스택에 들어가는 값은 명확하고 크기가 정해져있어야만 한다.
컴파일 타임에 크기가 확정되지 않는 데이터는 힙에 저장되어야만 한다.

힙에 데이터가 들어갈 때는 운영체제가 여유가 있는지 먼저 확인한다.
메모리 할당자는 남는 공간을 찾고, 해당 공간을 사용 중이라 표시한 후에야 지점을 가리키는 포인터를 실행 프로세스에 반환한다.
포인터 자체는 스택에 저장될 수 있으나, 결국 실제 데이터가 있는 장소는 참조를 통해 접근해야 한다.
말만 들어도 힙은 속도가 스택보다 느리다.
공간을 할당받는 과정도, 포인터를 통해 데이터 값을 가져오는 과정조차 느리다.
스택은 실제 메모리 상에서도 가까운 공간임이 보장되지만, 데이터의 물리적 위치가 중구난방하다.

함수를 호출하면 함수 속 변수(포인터가 있을 수도 있다)들이 스택에 푸시된다. 그리고 함수가 종료될 때 팝된다.
유의할 점은 스택 역시 런타임에 자유롭게 사용된다는 것이다.

이러한 상황에서, 소유권의 주된 관심사는 힙 메모리의 관리이다.
소유권의 규칙은 다음과 같다.

변수의 스코프

{
	let s  = "halo";
	
}

위 코드에서 s는 블록 안에서만 유효하다.
이후에는 버려지는 것이다.
정확하게는 스코프 내에서 등장했을 때부터 유효한 것이다.

String 타입을 통한 고찰

3.2. 데이터 타입에서 나왔던 원시 타입들은 명확한 크기가 정해져 있어 무조건 스택에 저장되고, 스코프를 벗어날 때 문제 없이 제거되도록 되어 있었다.
하지만 이제는 힙 영역에 위치하게 되는 타입을 살펴볼 것이다.
여태 봤던 문자열들은 리터럴이었다.
쓰기는 편하지만, 불변성을 가지고 있다.
프로그래밍 시점에 값을 알 수 없기에 할 수 있는 일도 제한된다.
그래서 String이라는 힙 영역에 할당되는 데이터를 다룰 것이다.

    let mut s = String::from("hello");

    s.push_str(", world!"); // push_str()이 문자열에 리터럴을 추가합니다

    println!("{}", s); // 이 줄이 `hello, world!`를 출력합니다

위 코드에서 s는 mut로 선언되어 추가적인 문자열을 받을 수 있는 상태가 되었다.
참고로 ::은 함수를 사용할 때 함수명 없이 String 타입에 특정된 from함수임을 나타내는 네임스페이스 연산자이다.
5.3. 메서드 문법, 7.3. 경로를 사용하여 모듈 트리의 아이템 참조하기에서 자세히 보게 될 것이다.

문자열 리터럴은 컴파일 타임에 내용이 추적되고 하드코딩된다.
String 타입은 실행 시점에 힙에 메모리가 할당되며 내용 수정, 크기 변경이 가능하다.
이는 두 가지를 의미한다.

이중 첫번째는 String::from을 하는 시점에 해결됐다.
두번째를 해결하는 것이 바로 소유권이다.

{
	let s = String::from("heelo");
}

이 코드에서 스코프가 끝날 때, 러스트에서는 drop이라는 특별함수가 호출된다.
이 함수는 해당 타입(여기에서는 String)을 개발한 개발자가 직접 메모리 해제 코드를 작성해 넣을 수 있게 만들어져 있다.
참고로 C++에서는 Resource Acquisition Is Initialization, RAII라 하여 아이템의 수명이 끝나는 시점에 메모리를 해제하는 패턴이 존재한다.

변수와 데이터 간 상호작용 방식: 이동

러슽에서는 동일한 데이터에 여러 변수가 상호작용 가능하다.
Pasted image 20240501015940.png
참고로 이러한 코드는 원시타입에서 문제가 발생하지 않는다.
스택에 들어간 데이터는 변수들이 상호작용할 때 그저 복사될 뿐이다.
Pasted image 20240501020054.png
s1의 데이터는 이렇게 힙 영역에 모습을 띄고 있다.
이때 s2는 hello 값을 가리키는 포인터를 복사받는다.
즉 실제 hello가 아니라, 포인터와 길이, 용량을 나타내는 값을 복사받는 것이다.
그렇다면 실제 hello가 위치한 곳에 대해 두 포인터가 가리키는 꼴이 된다.
이런 상황에서 포인터에 해당하는 주소에 대한 메모리 해제를 하면 무슨 일이 일어나는가?
두 포인터에 대해 해제를 진행하면 중복 해제(double free) 문제가 발생하게 된다.
이는 메모리 안정성 버그이며 메모리 손상의 원인이 된다.

메모리 안정성을 위해, 러스트에서는 s2에 복사된 이후로 s1이 유효하지 않다고 판단한다.
다른 언어에서 얕은 복사와 비슷하지만, 조금은 다르다.
기존 변수는 무효화가 되어버리기에, 이를 이동(move)라고 표현한다.

변수와 데이터 간 상호작용 방식: 클론

그럼 정말 복사하고 싶을 때는 어떻게 하는가?
clone이라는 공용 메서드를 사용하면 된다.

    let s1 = String::from("hello");
    let s2 = s1.clone();

    println!("s1 = {}, s2 = {}", s1, s2);

이렇게 명시적으로 clone을 하지 않는 이상은, 깊은 복사는 일어나지 않는다.

스택에만 저장되는 데이터: 복사

그럼 위에서 잠시 본 정수형에 대해서는 어떻게 되는가?
해당 데이터는 스택에 저장되어 있다.
크기가 고정되어 있고, 복사본도 빠르게 만들 수 있어 굳이 이동을 시킬 필요가 없다.
심지어 스코프를 벗어나면 버려질 것이 보장되어 있기에 일반 복사를 시키는 것이다.
성능 상 차이도 없다.

러스트에는 스택에 저장되는 타입에 달아놓을 수 있는 Copy 트레잇이 있다.
이를 사용하면 이 타입의 변수는 사용되어도 이동되지 않고 복사되며, 대입 이후에도 사용가능하다.
그러나 구현 일부분에 Drop 트레잇이 들어간다면 Copy를 어노테이션할 수 없다.
어노테이션을 추가하는 방법은 부록에서 다룬다.

Copy가 가능한 타입은 무엇인가?
가능한 목록이 어느 정도 있다.

소유권과 함수

함수에서 값을 전달하는 방식은 변수에서 대입하는 것과 유사하다.
함수에 변수를 전달하는 것 역시 이동이나 복사가 일어난다.

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가 스코프 밖으로 벗어납니다. 별다른 일이 발생하지 않습니다.

위의 코드에서 함수를 통해 s의 값은 이동되었으므로, takes_ownership 이후에는 s를 사용할 수 없다.
이는 컴파일 단에서 에러가 보장되어 개발자의 실수를 줄일 수 있다.

반환값과 스코프

위의 과정은 값을 함수로부터 받는 데에서도 마찬가지로 성립한다.

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가 스코프 밖으로 벗어나면서 버려집니다. s2는 이동되어서 아무 일도
  // 일어나지 않습니다. s1은 스코프 밖으로 벗어나고 버려집니다.

fn gives_ownership() -> String {             // gives_ownership은 자신의 반환 값을
                                             // 자신의 호출자 함수로 이동시킬
                                             // 것입니다

    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이 반환되고 호출자 함수 쪽으로 이동합니다
}

Pasted image 20240501023717.png
보다시피 s1은 없던 값을 받아 사용되지 않았다는 경고가 나오고, s2의 값은 s3로 이동되어 s2는 경고를 출력하지 않고 있다.

이런 방식을 통해 메모리 안전성은 보장되지만, 함수에 값을 잠깐만 보내고 다시 사용해야 할 경우는 부지기수이다.
이런 경우에 함수에서 함수 결과값과 원래 값을 같이 튜플로 묶어서 돌려보내는 것은 가능하지만, 이는 번거롭다.
이를 위해 존재하는 것이 참조자 이다.