본문 바로가기

Programming/Rust

Rust Ownership (Rust만의 특별한 메모리 관리법)

Ownership은 Rust만의 특별한 메모리 관리법이다.

모든 프로그램은 실행중인 동안에 어떤 방법으로든 컴퓨터의 메모리를 관리해야한다.

 

어떤 언어는 GC를 이용해서 사용하지 않는 메모리를 찾아 지속적으로 해제해준다.

또 다른 언어는 프로그래머가 직접 메모리를 할당하고 해제하며 관리한다.

 

Rust는 Ownership이라는 Rust만의 특별한 메모리 관리법을 사용한다.

쉽게 말해서 개발자 입장에서는 GC보다 복잡하지만 더 성능이 뛰어난 GC(?)라고 보면 된다.

 

(C, C++로 주로 개발하던 개발자는 Rust Ownership에 혁신을 느낄 것이며... GC로 동작하는 언어에 익숙하던 개발자는 오히려 개발할 땐 불편함을 느낄 수 있다)

 

하지만 중요한 포인트는 GC의 런타임 도중 발생하는 오버헤드의 리스크가 없고, C나 C++처럼 런타임 도중에 메모리 Hole이 발생하는 경우가 없다.

Rust의 Ownership 메모리 관리법은 컴파일 타이밍에 거의 모든 메모리 Hole을 잡아주기 때문이다.

 

Ownership Rules

Ownership에는 몇가지 규칙이 존재한다.

- Rust가 다루는 값은 Owner라고 부르는 변수를 가지고 있다.

- 특정 시점에 값의 Owner는 단 하나 뿐이다.

- Owner가 범위를 벗어나면 그 값은 제거된다.

 

 변수의 범위

{
	let s = "hello"; // 이 지점부터 변수s 유효
	// s를 이용해 필요한 동작 수행
} // s의 범위가 끝나므로 더이상 유효하지 않음

위의 예제처럼 변수의 범위를 보면 다른 프로그래밍과 크게 다르지 않다. (GC도 위와 같이 할당하고 해제할 것이다)

Ownership만의 규칙을 보려면 좀 더 복잡한 데이터 타입을 살펴봐야한다.

String

Rust에서 문자열 타입은 두가지가 존재한다.

- String 데이터 타입

- 문자열 literals

 

두 타입의 가장 큰 차이점은 String 데이터 타입은 변경 가능한 값이고, 문자열 literals은 변경이 불가능한 값이라는 점이다.

 

왜 이런 차이가 발생할까?

두 가지 데이터 타입이 메모리를 관리하는 방식이 다르기 때문이다.

 

문자열 literals은 고정된 값이기 때문에, 스택에 저장되고, 범위를 벗어나면 스택에서 제거된다.

String 데이터 타입은 가변 문자열이기 때문에, 컴파일 시점에 크기를 결정할 수 없다.

 

그래서 반드시 다음과 같은 절차를 거쳐야한다.

1. 런타임 중에 OS에 메모리 할당 요청

2. 사용이 완료된 후에 메모리 해제

 

1번의 경우 개발자가 코드에 String::from 를 작성하여 요청한다.

2번의 경우 Rust가 직접 처리해준다.

 

메모리 할당과 해제

Rust는 변수가 가진 범위를 벗어나는 순간 자동으로 변수에 할당된 메모리를 해제한다.

{
	let s = String::from("hello"); // 변수s는 이 지점부터 유효
} // 이 범위를 벗어나는 순간 변수s는 유효하지 않다.

위 예제처럼 s가 중괄호를 만나 범위를 벗어나느 순간, Rust는 drop이라는 특별한 함수를 호출하여 메모리를 해제한다.

 

당장 위의 예제는 심플해보이지만, 복잡한 코드에서는 예상과 다르게 동작할 수 있다.

먼저 고정된 값의 변수를 살펴보면 다음과 같이 동작한다.

{
	let x = 5; // 스택에 x 할당
	let y = x; // 스택에 y 할당
} // 스택에서 x, y 모두 해제

고정 크기의 단순한 값이기 때문에 x, y 모두 스택에 저장됐다가 해제된다.

{
	let s1 = String::from("hello") // 힙에 s1 할당
	let s2 = s1; // s1 -> s2로 이동됨
    // 이 시점에서 s2는 유효하지만, s1은 유효하지 않음.
    // s1을 사용하려하면 컴파일 에러
} // s2 메모리 해제

가변 가능한 변수 값의 경우 힙에 저장되는데, 여기서 가장 중요한 포인트는 s1이 s2로 이동(Move)됐다는 점이다.

실제 데이터가 복사되지 않고 문자열에 대한 포인터가 복사된다.

실제 데이터가 복사되지 않는 이유는, 데이터 크기가 크다면 런타임 성능이 크게 떨어지기 때문이다.

 

따라서 가변 타입 변수의 경우 아래처럼 동작한다.

이렇게 s1에서 s2로 데이터가 이동됐기 때문에, 이동되는 시점부터 s1이라는 변수를 무효화된다.

 

복제 (Clone)

그럼 s1이라는 변수를 계속 사용할 순 없을까?

복제를 사용하면 s1, s2 모두 사용 가능하다.

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

	println!("s1 = {}, s2 = {}", s1, s2); // s1, s2 모두 유효
}

위처럼 clone() 이라는 키워드를 사용하면 데이터 값이 그대로 복사된다.

이 코드는 복사하는 데이터 크기에 따라 무거운 작업이 될 수도 있다.

 

복제(Clone)은 위에서 살펴본 이동(Move)과는 다르게 메모리가 아래와 같이 동작한다.

 

복사 (Copy)

다시 고정된 값의 데이터를 살펴보자.

고정된 값의 경우 아래와 같이 별다른 키워드 없이, 다른 값에 대입을 해도 두 변수 모두 유효함을 볼 수 있다.

 

이유가 뭘까?

고정된 값의 경우 앞서 설명했듯이 스택영역에 저장된다.

스택 영역에 저장되는 값들은 데이터 값이 그대로 복사된다.

 

Ownership과 함수

지금까지 변수에 값을 할당할 때 메모리가 어떻게 동작하는 살펴봤다.

그렇다면 함수 파라미터로 변수를 넘길 땐 어떻게 Ownership이 동작할까?

{
	let s = String::from("hello")
	takes_ownership(s) // 변수s move
	// 이 시점에 변수s 무효화

	let x = 5;
	makes_copy(x); // 변수x copy
	// 이 시점에 변수x 유효
}

가변 타입 변수를 생성하고 함수의 파라미터로 전달하면 그 시점에서 Ownership이 해당 함수로 넘어갔음을 의미한다.

위에서 살펴본 이동과 비슷한 개념이다.

 

하지만, 스택영역에 생성된 고정 타입 변수는 함수로 넘길 때 Copy가 일어난다.

 

참조와 대여

그런데 이것도 위에서 살펴본 것처럼 함수로 변수를 넘겨주더라도 계속 사용하고 싶으면 어떻게 하면 될까?

참조와 대여를 사용하면 된다.

fn main() {
	let s1 = String::from("hello");
	let len = calculate_length(&s1);
}

fn calculate_length(s: &String) -> usize {
	s.len()
} // 이 시점에서 s가 범위를 벗어난다.
// 하지만, s는 참조 변수이므로 값에 대한 소유권이 없다.
// 따라서 아무일도 일어나지 않는다.

파라미터로 넘길 때 &를 붙여주고 함수에서도 &키워드를 붙여 받는다.

이렇게 되면 함수 파라미터로 참조를 전달하게 되고 이것을 대여라고 한다.

 

말 그대로 이 함수는 값을 대여받았으므로 Ownership이 존재하지 않는다.

따라서 이 함수가 끝나더라도 해당 값에 대한 메모리 해제가 일어나지 않는다.

 

또한 이 함수는 대여받았기 때문에 값에 대한 변경이 불가능하다.

하지만 이렇게 대여한 변수도 변경이 가능하도록 특별한 대여를 해줄 수 있는데, 그것이 바로 가변 참조이다.

 

이렇게 특별한 가변 참조에는 중요한 제약사항이 있다.

1. 같은 범위 내에서 가변 참조는 반드시 하나만 존재해야한다.

2. 같은 범위 내에서 불변 참조가 이미 있다면, 가변 참조를 생성할 수 없다.

'Programming > Rust' 카테고리의 다른 글

Rust란 ? (Rust 기본 개념)  (0) 2021.02.05