JS, TS랑 비교하며 Rust 공부하기 (2)

안 까먹기 위해 러스트로 코테 문제를 꾸준히 풀고 있으면서 블로그에 글을 쓰는 건 계속 미뤄버렸다.
러스트로 코테 문제를 풀면서 느낀 점이 있는데 숫자 타입과 소유권이 참 번거롭다는 것이다. (JS가 참 편한 언어다…)
어쨋든 얼른 정리해서 끝내버리자.


소유권

러스트에서 소유권을 이해하기 위해서는 먼저 메모리 관리 방식부터 알아야 한다.
러스트는 가비지 컬렉터 없이, 변수가 속한 스코프를 벗어나는 순간 해당 메모리를 자동으로 해제한다. 이때 컴파일러가 자동으로 drop이라는 특별한 함수를 호출한다.
소유권은 러스트가 안전하게 메모리를 관리하기 위해 도입한 핵심 규칙이며, 다음과 같은 원칙을 따른다.

  • 모든 값은 반드시 하나의 소유자(변수)를 가진다.
  • 동시에 두 개 이상의 소유자를 가질 수 없다.
  • 소유자가 스코프를 벗어나는 순간, 해당 값의 메모리는 해제된다.
  • 값의 소유권은 다른 변수로 이동할 수 있으며, 이동 후 원래 소유자는 더 이상 해당 값을 사용할 수 없다.

스택 메모리와 힙 메모리

러스트에서 값이 스택에 저장되는지, 힙에 저장되는지는 프로그램의 동작 방식과 성능에 큰 영향을 준다. 특히 소유권이 힙에 저장되는 데이터를 안전하고 효율적으로 관리하기 위해 설계되었음을 이해하면, 러스트의 메모리 관리 원리를 더 쉽게 파악할 수 있다.

스택에 저장되는 데이터는 반드시 크기가 고정되어 있어야 하며, 컴파일 시점에 크기를 알 수 있어야 한다. 반면, 크기를 컴파일 시점에 알 수 없거나 런타임에 변할 수 있는 데이터는 힙에 저장된다.

스택에 데이터를 추가하는 것은 빠르다. 운영체제가 미리 확보해 둔 스택 메모리 공간에 단순히 포인터를 이동시키기만 하면 되기 때문이다. 반면, 힙에 데이터를 저장하려면 운영체제가 비어 있는 적절한 메모리 공간을 찾아야 하고, 그 위치를 기록하는 과정이 필요해 추가적인 비용이 발생한다. 또한, 힙에 저장된 데이터는 스택 데이터보다 접근 속도가 느리다. 이는 힙 데이터에 접근할 때 반드시 해당 메모리 위치를 간접적으로 참조해야 하기 때문이다.

힙에 저장되는 데이터

힙 영역을 사용하는 변수는 소유권의 이동이 발생하며, 이는 얕은 복사와 달리 기존 변수를 무효화한다. 이러한 소유권 이동은 이중 해제 에러와 같은 메모리 관리 문제를 방지하기 위해 설계되었다.

let s1 = String::from("hello");
let s2 = s1; // s1은 더 이상 유효하지 않기 때문에 스코프를 벗어나도 메모리를 해제할 필요가 없다.

println!("{}, world!", s1); // error

힙 데이터까지 깊은 복사하고 싶을 땐 clone이라는 공용 메서드를 사용할 수 있다.

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

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

함수로 값을 전달할 때와 함수가 값을 반환할 때도 소유권의 이동이 발생한다.

 let s1 = String::from("hello");

 let s2 = takes_ownership(s1);  // s1의 값이 함수로 이동되며, 더 이상 유효하지 않다.

소유권 이동 없이 값을 사용하고 싶을땐 참조자를 이용하면 된다.

스택에만 저장되는 데이터

스택에만 저장되는 데이터는 컴파일 타임에 크기가 고정되기 때문에 소유권의 이동이 발생하지 않고 복사된다.
이러한 타입에는 Copy트레이트가 구현되어 있다.
하지만 Drop트레이트가 구현된 경우엔 Copy트레이트를 어노테이션 할 수 없다.

Copy 가능한 타입 중 일부는 아래와 같다.

  • 정수형 타입
  • bool 타입
  • 부동 소수점 타입
  • 문자 타입
  • Copy 가능한 타입만으로 구성된 튜플, 배열

참조자

참조자는 메모리 주소를 담아 해당 위치에 저장된 데이터에 접근할 수 있게 해주는 개념으로, 포인터와 유사하다. 그러나 참조자는 생존 기간 동안 반드시 특정 타입의 유효한 값을 가리키도록 보장되며, 이 점에서 일반 포인터보다 안전하다.
러스트의 참조는 안전성을 위해 기능이 제한되어 있으며, 내부적으로는 메모리 주소를 보관하지만 C 언어처럼 직접 주소 연산을 수행할 수 없다.
러스트는 자동 참조와 자동 역참조를 지원하여 값을 읽을 때는 * 없이 자연스럽게 사용할 수 있다. 단, 값을 수정하려면 *를 사용해 명시적으로 역참조해야 한다.
타입 앞에 &를 붙이면 해당 타입의 참조 타입을 나타내며, 변수 앞에 &를 붙이면 그 변수의 참조를 생성한다. 참조를 생성하는 행위를 대여라고 부른다.

fn calculate_length(s: &String) -> usize {
    s.len()
} // s에는 소유권이 없으므로 s가 더 이상 사용되지 않을 때도 이 참조자가 가리킨 값이 버려지지 않는다.

let s1 = String::from("hello");

let len = calculate_length(&s1);

println!("The length of '{}' is {}.", s1, len);

가변 참조자

&mut를 사용하면 가변 참조를 생성하거나 나타낼 수 있으며, 이를 통해 참조한 값을 수정할 수 있다.

fn change(some_string: &mut String) {
    some_string.push_str(", world");
}

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

change(&mut s);

참조자는 단 하나의 가변 참조자만 갖거나, 여러 개의 불변 참조자를 가질 수 있다.

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

let r1 = &mut s;
let r2 = &mut s; // error
let mut s = String::from("hello");

let r1 = &s;
let r2 = &s;
let r3 = &mut s; // error

참조자는 정의된 지점부터 시작하여 해당 참조자가 마지막으로 사용된 부분까지 유효하다.

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

let r1 = &s;
let r2 = &s;

println!("{} and {}", r1, r2);

let r3 = &mut s;

println!("{}", r3);

댕글링 참조

러스트에서는 어떤 데이터의 참조자를 만들면, 해당 참조자가 스코프를 벗어나기 전에 데이터가 먼저 스코프를 벗어나는지 컴파일러에서 확인하여 댕글링 참조가 생성되지 않도록 보장한다.

fn dangle() -> &String {
    let s = String::from("hello");

    &s
} // error

슬라이스

슬라이스는 배열이나 벡터처럼 메모리에 연속적으로 저장된 값들의 부분 참조를 가능하게 해주는 타입이다.
슬라이스는 참조자의 일종으로서 소유권을 갖지 않는다.
슬라이스의 타입은 &[type] 혹은 &str로 표현한다.
&변수[시작..끝]로  슬라이스를 생성하고 끝 인덱스는 포함하지 않는다.
슬라이스는 내부적으로 시작 위치, 길이를 데이터 구조에 저장하며, 길이는 끝에서 시작을 뺀 값이다.

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

let hello = &s[0..5];
let hello = &s[..5]; 

let world = &s[6..11];
let world = &s[6..];

let hello_world = &s[0..11];
let hello_world = &s[..];

let a = [1, 2, 3, 4, 5];

let slice = &a[1..3];

문자열 슬라이스를 매개변수로 사용하면 String의 슬라이스 혹은 String에 대한 참조자를 인수로 전달할 수 있다.

fn test_slice(s: &str) -> &str {
    s
}

let value = String::from("test");

test_slice(&value);

익숙하지 않은 개념이다 보니 숙지하는 데 꽤 시간이 걸린다.
안 쓰다 보면 금방 까먹기 때문에 지속적으로 보고 코드를 짜보자.
다음 글의 시작은 구조체다.