벡터(Vector)로 값의 목록 저장하기
벡터(Vector)로 값의 목록 저장하기
이번에는 여러 값을 한꺼번에 담아 두는 방법을 배워요. 여러 값이 메모리에서 서로 나란히 놓이도록 관리해 주는 컬렉션을 다루는데, 그중 우리가 처음 살펴볼 타입이 바로 Vec<T>예요. 값의 수는 몇 개든 상관없지만, 한 벡터에는 같은 타입의 값만 담을 수 있다는 점을 기억해 두세요.
출처: Rust 공식문서
본문
새 벡터 만들기
비어 있는 새 벡터를 만들 땐 Vec::new 함수를 호출해요. Listing 8-1이 바로 그 예시예요.
fn main() {
let v: Vec<i32> = Vec::new();
}
Listing 8-1: i32 타입의 값을 담을, 비어 있는 새 벡터 생성
여기서 타입 애너테이션을 붙였다는 점을 눈여겨볼게요. 값을 아직 아무것도 넣지 않았으니 Rust는 우리가 어떤 종류의 요소를 저장하려는지 알 수 없어요. 이 점이 꽤 중요해요. 벡터는 제네릭으로 구현되어 있는데, 제네릭을 직접 써서 타입을 만드는 방법은 10장에서 다뤄요. 지금은 표준 라이브러리가 제공하는 Vec<T> 타입이 어떤 타입이든 담을 수 있다는 것만 알아두면 돼요. 특정 타입을 담는 벡터를 만들 때는 각괄호(< >) 안에 그 타입을 지정하면 돼요. Listing 8-1에서는 v 안의 Vec<T>가 i32 타입 요소를 담을 거라고 Rust에게 알려준 셈이에요.
하지만 더 자주 마주치는 건 초깃값을 넣어 벡터를 만드는 경우예요. 그때는 Rust가 저장하려는 값의 타입을 추론해 주기 때문에 타입 애너테이션을 쓸 일이 거의 없어요. Rust는 편리하게도 vec! 매크로를 제공해서, 여러분이 준 값들을 담는 새 벡터를 만들어 줘요. Listing 8-2는 1, 2, 3을 담는 새 Vec<i32>를 만들어요. 정수 타입은 기본값이 i32이기 때문인데, 이 내용은 3장의 “Data Types” 절에서 다뤘죠.
fn main() {
let v = vec![1, 2, 3];
}
Listing 8-2: 값을 담은 새 벡터 생성
초깃값으로 i32 값을 줬기 때문에 Rust가 v의 타입이 Vec<i32>임을 추론할 수 있고, 타입 애너테이션은 필요 없어요. 이제 벡터를 어떻게 수정하는지 살펴볼게요.
벡터 수정하기
벡터를 만든 뒤 요소를 추가하고 싶다면 push 메서드를 쓰면 돼요. Listing 8-3을 볼게요.
fn main() {
let mut v = Vec::new();
v.push(5);
v.push(6);
v.push(7);
v.push(8);
}
Listing 8-3: push 메서드로 벡터에 값 추가
다른 변수와 마찬가지로 값이 바뀌길 원하면 mut 키워드로 가변(mutable)으로 만들어야 해요. 이건 3장에서 다뤘던 내용이죠. 안에 넣는 숫자는 전부 i32 타입이고 Rust가 데이터로부터 이를 추론하기 때문에 Vec<i32> 애너테이션은 필요 없어요.
벡터의 요소 읽기
벡터에 저장된 값을 참조하는 방법은 두 가지예요. 인덱싱(indexing)을 쓰거나 get 메서드를 쓰는 거죠. 다음 예시에서는 명확성을 위해 이 함수들이 반환하는 값의 타입을 주석으로 달아 뒀어요.
Listing 8-4은 인덱싱 문법과 get 메서드, 두 방법 모두로 벡터의 값에 접근하는 모습을 보여줘요.
fn main() {
let v = vec![1, 2, 3, 4, 5];
let third: &i32 = &v[2];
println!("The third element is {third}");
let third: Option<&i32> = v.get(2);
match third {
Some(third) => println!("The third element is {third}"),
None => println!("There is no third element."),
}
}
Listing 8-4: 인덱싱 문법과 get 메서드로 벡터의 요소에 접근
여기서 몇 가지를 짚어볼게요. 세 번째 요소를 얻기 위해 인덱스 값을 2로 쓰는 건, 벡터가 숫자로 인덱싱되고 0부터 시작하기 때문이에요. &와 []를 쓰면 해당 인덱스 값의 요소에 대한 참조를 얻을 수 있어요. get 메서드에 인덱스를 인자로 넘기면 match와 함께 쓸 수 있는 Option<&T>를 반환해요.
Rust가 요소를 참조하는 방법을 두 가지로 준 이유는, 존재하는 요소의 범위를 벗어난 인덱스 값을 쓸 때 프로그램이 어떻게 동작할지 선택하게 해 주려는 거예요. 요소가 다섯 개인 벡터에서 인덱스 100에 접근하려 할 때 어떤 일이 벌어지는지 각 기법별로 볼게요. Listing 8-5예요.
fn main() {
let v = vec![1, 2, 3, 4, 5];
let does_not_exist = &v[100];
let does_not_exist = v.get(100);
}
Listing 8-5: 요소가 다섯 개인 벡터에서 인덱스 100에 접근 시도
이 코드를 실행하면 첫 번째 [] 메서드는 존재하지 않는 요소를 참조하기 때문에 프로그램이 패닉(panic)을 일으켜요. 이 방법은 벡터의 끝을 넘어선 요소 접근 시도가 있으면 프로그램이 그냥 죽기를 바랄 때 가장 어울려요.
반면 get 메서드에 벡터 범위를 벗어난 인덱스를 넘기면 패닉 없이 None을 반환해요. 이 방법은 정상적인 상황에서도 가끔 벡터 범위를 넘어선 요소에 접근할 일이 있을 때 쓰면 돼요. 그러면 코드에 Some(&element)이든 None이든 처리하는 로직을 넣으면 되는데, 이는 6장에서 다룬 내용이에요. 예를 들어 인덱스가 사람이 입력한 숫자에서 나온다면, 실수로 너무 큰 숫자를 입력해서 프로그램이 None 값을 받았을 때, 현재 벡터에 몇 개의 항목이 있는지 사용자에게 알려주고 유효한 값을 다시 입력하게 할 수 있어요. 그게 오타로 프로그램이 죽는 것보다 훨씬 사용자 친화적이죠.
프로그램이 유효한 참조를 갖고 있을 때는 대여 검사기(borrow checker)가 소유권 규칙과 대여 규칙(4장에서 다룸)을 강제해서, 이 참조와 벡터 내용에 대한 다른 참조들이 유효하게 유지되도록 보장해요. 같은 스코프에서 가변 참조와 불변 참조를 함께 가질 수 없다는 규칙을 기억하시죠. 그 규칙이 적용되는 게 Listing 8-6이에요. 벡터의 첫 번째 요소에 불변 참조를 들고 있으면서 끝에 요소를 추가하려는 상황이죠. 이 함수에서 나중에 그 요소를 다시 참조하려고 하면 이 프로그램은 컴파일되지 않아요.
fn main() {
let mut v = vec![1, 2, 3, 4, 5];
let first = &v[0];
v.push(6);
println!("The first element is: {first}");
}
Listing 8-6: 요소에 대한 참조를 들고 있는 동안 벡터에 요소 추가 시도
이 코드를 컴파일하면 이런 에러가 나요.
$ cargo run
Compiling collections v0.1.0 (file:///projects/collections)
error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable
--> src/main.rs:6:5
|
4 | let first = &v[0];
| - immutable borrow occurs here
5 |
6 | v.push(6);
| ^^^^^^^^^ mutable borrow occurs here
7 |
8 | println!("The first element is: {first}");
| ----- immutable borrow later used here
For more information about this error, try `rustc --explain E0502`.
error: could not compile `collections` (bin "collections") due to 1 previous error
Listing 8-6의 코드는 작동해야 할 것처럼 보이지 않나요? 첫 번째 요소에 대한 참조가 벡터 끝의 변경에 왜 신경을 쓰는 걸까요? 이 에러는 벡터가 동작하는 방식 때문이에요. 벡터는 값을 메모리에 서로 나란히 놓기 때문에, 벡터가 현재 저장된 자리에 모든 요소를 나란히 둘 공간이 부족하면 벡터 끝에 새 요소를 추가하는 게 새 메모리를 할당하고 기존 요소들을 새 공간에 복사해야 할 수도 있어요. 그 경우 첫 번째 요소에 대한 참조는 해제된 메모리를 가리키게 돼요. 대여 규칙이 바로 프로그램이 그런 상황에 빠지지 않게 막아주는 거예요.
Note:
Vec<T>타입의 구현 세부사항에 대해 더 자세히 알고 싶다면 “The Rustonomicon”을 참고하세요.
벡터의 값 반복하기
벡터의 각 요소에 차례로 접근하려면, 인덱스로 하나씩 접근하기보다 모든 요소를 반복(iterate)하는 게 낫겠죠. Listing 8-7은 i32 값 벡터의 각 요소에 대한 불변 참조를 얻고 출력하기 위해 for 루프를 쓰는 방법을 보여줘요.
fn main() {
let v = vec![100, 32, 57];
for i in &v {
println!("{i}");
}
}
Listing 8-7: for 루프로 요소를 반복해 벡터의 각 요소 출력
가변 벡터의 각 요소에 대한 가변 참조를 반복해서 모든 요소를 변경할 수도 있어요. Listing 8-8의 for 루프는 각 요소에 50을 더해요.
fn main() {
let mut v = vec![100, 32, 57];
for i in &mut v {
*i += 50;
}
}
Listing 8-8: 벡터의 요소에 대한 가변 참조 반복
가변 참조가 가리키는 값을 바꾸려면, += 연산자를 쓰기 전에 * 역참조(dereference) 연산자로 i 안의 값에 먼저 접근해야 해요. 역참조 연산자에 대한 자세한 이야기는 15장의 “Following the Reference to the Value” 절에서 할게요.
벡터를 반복하는 일은, 불변이든 가변이든, 대여 검사기의 규칙 덕분에 안전해요. Listing 8-7과 Listing 8-8에서 for 루프 본문 안에서 항목을 삽입하거나 제거하려 하면, Listing 8-6에서 겪은 것과 비슷한 컴파일 에러가 나요. for 루프가 들고 있는 벡터에 대한 참조가 벡터 전체를 동시에 수정하는 걸 막아주는 거예요.
열거형(Enum)으로 여러 타입 저장하기
벡터는 같은 타입의 값만 저장할 수 있어요. 그건 좀 불편할 수 있죠. 서로 다른 타입의 항목 목록을 저장해야 하는 경우는 분명히 있으니까요. 다행히도 열거형의 배리언트(variant)들은 같은 열거형 타입 아래에 정의되기 때문에, 서로 다른 타입의 요소를 표현하는 하나의 타입이 필요할 때 열거형을 정의해서 쓰면 돼요!
예를 들어 스프레드시트의 어떤 행에서 값을 가져오는데, 그 행의 열 중 일부는 정수, 일부는 부동소수점 숫자, 일부는 문자열이라고 해볼게요. 서로 다른 값 타입을 담을 배리언트를 가진 열거형을 정의하면, 모든 열거형 배리언트는 같은 타입, 즉 열거형 타입으로 취급돼요. 그러면 그 열거형을 담는 벡터를 만들어서, 결국 서로 다른 타입들을 담을 수 있게 되는 거예요. 이걸 Listing 8-9에서 보여줘요.
fn main() {
enum SpreadsheetCell {
Int(i32),
Float(f64),
Text(String),
}
let row = vec![
SpreadsheetCell::Int(3),
SpreadsheetCell::Text(String::from("blue")),
SpreadsheetCell::Float(10.12),
];
}
Listing 8-9: 여러 타입의 값을 한 벡터에 저장하도록 열거형 정의
Rust는 각 요소를 저장하는 데 힙(heap)에서 정확히 얼마만큼의 메모리가 필요한지 알아야 하므로, 컴파일 시점에 벡터에 어떤 타입들이 들어올지 알고 있어야 해요. 벡터에 어떤 타입이 허용되는지도 명확히 해야 하고요. 만약 Rust가 어떤 타입이든 벡터에 담을 수 있게 허용했다면, 그 타입들 중 하나 이상이 벡터 요소에 수행되는 연산에서 에러를 일으킬 가능성이 생겨요. 열거형에 match 표현식을 더해서 쓰면, 6장에서 다룬 것처럼 Rust가 컴파일 시점에 가능한 모든 경우가 처리되도록 보장해 줘요.
런타임에 프로그램이 벡터에 저장하게 될 타입들의 전체 집합을 미리 알 수 없다면 열거형 기법은 통하지 않아요. 그럴 땐 대신 트레이트 객체(trait object)를 쓸 수 있는데, 이건 18장에서 다룰게요.
지금까지 벡터를 쓰는 가장 흔한 방법들을 살펴봤으니, 표준 라이브러리가 Vec<T>에 정의해 놓은 수많은 유용한 메서드를 확인하려면 API 문서를 꼭 훑어보세요. 예를 들어 push 외에도, pop 메서드는 마지막 요소를 제거하면서 그 값을 반환해 줘요.
벡터가 버려지면 요소도 함께 버려진다
다른 struct와 마찬가지로, 벡터는 스코프를 벗어날 때 해제돼요. Listing 8-10에 주석으로 표시해 뒀어요.
fn main() {
{
let v = vec![1, 2, 3, 4];
// do stuff with v
} // <- v goes out of scope and is freed here
}
Listing 8-10: 벡터와 그 요소가 버려지는 위치
벡터가 버려지면 그 내용물도 전부 버려져서, 담고 있던 정수들도 정리돼요. 대여 검사기는 벡터 내용물에 대한 어떤 참조든 벡터 자체가 유효한 동안에만 사용되도록 보장해 줘요.
이제 다음 컬렉션 타입인 String으로 넘어가 볼게요!