벡터에 여러 값을 목록으로 저장하기
우리가 보게 될 첫 번째 컬렉션은 벡터라고도 알려진 Vec<T>
입니다. 벡터는 메모리
상에 서로 이웃하도록 모든 값을 집어넣는 단일 데이터 구조 안에 하나 이상의 값을
저장하도록 해줍니다. 벡터는 같은 타입의 값만을 저장할 수 있습니다. 이는 여러분이
파일 내의 텍스트의 라인들이라던가 장바구니의 아이템 가격들 같은 아이템 리스트를
저장하는 상황일 경우 유용합니다.
새 벡터 만들기
비어있는 새 벡터를 만들기 위해서는, 다음 예제 8-1과 같이 Vec::new
함수를
호출해 줍니다:
fn main() { let v: Vec<i32> = Vec::new(); }
예제 8-1: i32
타입의 값을 가질 수 있는 비어있는 새 벡터
생성
여기에 타입 명시(type annotation)를 추가한 것을 주목하세요. 이 벡터에 어떠한 값도
집어넣지 않았기 때문에, 러스트는 우리가 저장하고자 하는 요소의 종류가 어떤 것인지
알지 못합니다. 이는 중요한 지점입니다. 벡터는 제네릭(generic)을 이용하여 구현됐습니다;
제네릭을 이용하여 여러분만의 타입을 만드는 방법은 10장에서 다룰 것입니다.
지금 당장은, 표준 라이브러리가 제공하는 Vec
타입은 어떠한 종류의 값이라도 저장할
수 있다는 것만 기억해 둡시다. 특정한 타입의 값을 저장할 벡터를 만들 때는
꺾쇠괄호(<>) 안에 해당 타입을 지정합니다. 예제 8-1에서는 러스트에게
v
안의 Vec
가 i32
타입의 요소를 가질 것이고 알려주었습니다.
대부분의 경우는 초깃값들과 함께 Vec<T>
를 생성하고 러스트는 우리가 저장하고자
하는 값의 타입을 대부분 유추할 수 있으므로, 이러한 타입 명시를 할 필요가 거의
없습니다. 러스트는 편의를 위해 vec!
매크로를 제공하는데, 이 매크로는 우리가
준 값들을 저장하고 있는 새로운 Vec
을 생성합니다. 예제 8-2는 1
, 2
,
3
을 저장하고 있는 새로운 Vec<i32>
을 생성할 것입니다.
3장의 “데이터 타입” 절에서 본 것처럼,
기본 정수형이 i32
기 때문에 여기서도 타입은 i32
입니다.
fn main() { let v = vec![1, 2, 3]; }
예제 8-2: 값을 저장하고 있는 새로운 벡터 생성하기
러스트는 i32
값이 초깃값으로 설정된 것을 이용해, v
의 타입을 Vec<i32>
로
추론할 수 있습니다. 따라서 타입 명시는 필요 없습니다. 다음으로는 벡터를
수정하는 방법을 살펴보겠습니다.
벡터 갱신하기
벡터를 만들고 여기에 요소들을 추가하기 위해서는 다음 예제 8-3과 같이
push
메서드를 사용할 수 있습니다:
fn main() { let mut v = Vec::new(); v.push(5); v.push(6); v.push(7); v.push(8); }
예제 8-3: push
메서드를 사용하여 벡터에 값을
추가하기
3장에서 설명한 바와 같이, 어떤 변수의 값을 변경하려면 mut
키워드를 사용하여
해당 변수를 변경 가능하게 만들어야 합니다. 또한 Vec<i32>
타입 명시를 붙이지
않아도 되는 이유는, 우리가 집어넣은 숫자가 모두 i32
타입인 점을 이용해
러스트가 v
의 타입을 추론하기 때문입니다.
벡터 요소 읽기
벡터 내에 저장된 값을 참조하는 두 가지 방법이 있습니다. 다음의 예제에서는 특별히 더 명료하게 하기 위해 함수들이 반환하는 값의 타입을 명시했습니다.
예제 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."), } }
예제 8-4: 인덱스 문법 혹은 get
메서드를 사용하여
벡터 내의 아이템에 접근하기
여기 몇 가지 세부 사항이 있습니다. 벡터의 인덱스는 0부터 시작하므로,
세 번째 값을 얻어오기 위해서는 인덱스 값 2
를 사용합니다.
&
와 []
를 사용하면 인덱스 값에 위치한 요소의 참조자를 얻게 됩니다.
get
함수에 인덱스를 매개변수로 넘기면, match
통해 처리할 수 있는
Option<&T>
를 얻게 됩니다.
러스트가 벡터 요소를 참조하는 두 가지 방법을 제공하는 이유는 여러분이 벡터가 가지고 있지 않은 인덱스 값을 사용하고자 했을 때 프로그램이 어떻게 동작할 것인지 여러분이 선택할 수 있도록 하기 위해서입니다. 예를 들어, 아래의 예제 8-5과 같이 5개의 요소를 가지고 있는 벡터가 있고 100 인덱스에 있는 요소에 접근하려고 시도한 경우 어떤 일이 생기는지 확인해 봅시다:
fn main() { let v = vec![1, 2, 3, 4, 5]; let does_not_exist = &v[100]; let does_not_exist = v.get(100); }
예제 8-5: 5개의 요소를 가진 벡터에 100 인덱스에 있는 요소에 접근하기
이 프로그램을 실행하면, 첫 번째의 []
메서드는 panic!
을 일으키는데, 이는
존재하지 않는 요소를 참조하기 때문입니다. 이 방법은 여러분의 프로그램이
벡터의 끝을 넘어서는 요소에 접근하는 시도를 하면 프로그램이 죽게끔 하는
치명적 에러를 발생하도록 하기를 고려하는 경우 가장 좋습니다.
get
함수에 벡터 범위를 벗어난 인덱스가 주어졌을 때는 패닉 없이 None
이
반환됩니다. 보통의 환경에서 벡터의 범위 밖에 있는 요소에 접근하는 것이
종종 발생할 수도 있다면 이 방법을 사용할 만합니다. 여러분의 코드는 우리가
6장에서 본 것과 같이 Some(&element)
혹은 None
에 대해 다루는 로직을
갖추어야 합니다. 예를 들어 인덱스는 사람이 직접 번호를 입력하는 것으로
들어올 수도 있습니다. 만일 사용자가 잘못하여 너무 큰 숫자를 입력하여
프로그램이 None
값을 받았을 경우, 여러분은 사용자에게 현재 Vec
에
몇 개의 아이템이 있으며 유효한 값을 입력할 또 한 번의 기회를 줄 수도
있습니다. 이런 편이 오타 때문에 프로그램이 죽는 것보다는 더 사용자
친화적이겠죠!
프로그램이 유효한 참조자를 얻을 때, 빌림 검사기 (borrow checker) 가 (4장에서 다루었던) 소유권 및 빌림 규칙을 집행하여 이 참조자와 벡터의 내용물로부터 얻은 다른 참조자들이 계속 유효하게 남아있도록 확실히 해줍니다. 같은 스코프 내에서 가변 참조자와 불변 참조자를 가질 수 없다는 규칙을 상기하세요. 이 규칙은 아래 예제에서도 적용되는데, 예제 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}");
}
예제 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` due to previous error
예제 8-6의 코드는 동작해야 할 것처럼 보일런지도 모르겠습니다: 첫 번째 요소의 참조자가 벡터 끝부분의 변경이랑 무슨 상관일까요? 이를 이해하기 위해, 잠시 벡터의 동작 방법을 알아보도록 하겠습니다. 벡터는 모든 요소가 서로 붙어서 메모리에 저장됩니다. 그리고 새로운 요소를 벡터 끝에 추가할 경우, 현재 벡터 메모리 위치에 새로운 요소를 추가할 공간이 없다면, 다른 넉넉한 곳에 메모리를 새로 할당하고 기존 요소를 새로 할당한 공간에 복사합니다. 이 경우, 기존 요소의 참조자는 해제된 메모리를 가리키게 되기 때문에, 이러한 상황을 빌림 규칙으로 막아둔 것이죠.
Note:
Vec<T>
타입의 구현 세부 사항에 대한 그 밖의 것에 대해서는 “러스토노미콘 (The Rustonomicon)”을 보세요:
벡터 내의 값들에 대한 반복처리
만일 벡터 내의 각 요소를 차례대로 접근하기 위해서는, 인덱스를 사용하여
한 번에 하나의 값에 접근하기보다는 모든 요소에 대해 반복처리를 합니다.
예제 8-7은 for
루프를 사용하여 i32
의 벡터 내에 있는 각 요소에 대한
불변 참조자를 얻어서 이를 출력하는 방법을 보여줍니다:
fn main() { let v = vec![100, 32, 57]; for i in &v { println!("{i}"); } }
예제 8-7: for
루프를 이용한 요소들에 대한
반복 작업을 통해 각 요소를 출력하기
만일 모든 요소을 변형시키길 원한다면 가변 벡터 내의 각 요소에 대한 가변
참조자로 반복 작업을 할 수도 있습니다. 예제 8-8의 for
루프는 각 요소에
50
을 더할 것입니다:
fn main() { let mut v = vec![100, 32, 57]; for i in &mut v { *i += 50; } }
예제 8-8: 벡터 내의 요소에 대한 가변 참조자로 반복하기
가변 참조자가 가리키는 값을 수정하려면,
+=
연산자를 쓰기 전에 *
역참조 연산자로 i
의 값을 얻어야 합니다.
역참조 연산자는 15장
"Following the Pointer to the Value with the Dereference Operator"
에서 자세히 알아볼 예정입니다.
벡터에 대한 반복처리는 불변이든 가변이든 상관없이 빌림 검사 규칙에
의해 안전합니다. 만일 예제 8-7과 예제 8-8의 for
루프
본문 내에서 아이템을 추가하거나 지우는 시도를 했다면 예제 8-6의
코드에서 본 것과 유사한 컴파일 에러가 발생할 것입니다. for
루프가
가지고 있는 벡터에 대한 참조자는 전체 벡터에의 동시다발적 수정을
막습니다.
열거형을 이용해 여러 타입 저장하기
벡터는 같은 타입을 가진 값들만 저장할 수 있습니다. 이는 불편할 수 있습니다; 다른 타입의 아이템들에 대한 리스트를 저장해야 하는 상황도 분명히 있으니까요. 다행히도, 열거형의 배리언트는 같은 열거형 타입 내에 정의가 되므로, 벡터 내에 다른 타입의 값들을 저장할 필요가 있다면 열거형을 정의하여 사용할 수 있습니다!
예를 들어, 스프레드시트의 행으로부터 값들을 가져오고 싶은데, 여기서 어떤 열은 정수를, 어떤 열은 실수를, 어떤 열은 문자열을 갖고 있다고 해봅시다. 우리는 다른 타입의 값을 가지는 배리언트가 포함된 열거형을 정의할 수 있고, 모든 열거형 배리언트들은 같은 타입, 즉 해당 열거형 타입인 것으로 취급될 것입니다. 따라서 우리는 궁극적으로 다른 타입을 담은 열거형 값에 대한 벡터를 생성할 수 있습니다. 예제 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), ]; }
예제 8-9: 열거형을 정의하여 벡터 내에 다른 타입의 데이터를 담을 수 있도록 하기
러스트가 컴파일 타임에 벡터 내에 저장될 타입이 어떤 것인지 알아야 할 필요가
있는 이유는 각 요소를 저장하기 위해 얼마만큼의 힙 메모리가 필요한지 알기
위함입니다. 또한 이 벡터에 허용되는 타입에 대해 명시적이어야 합니다. 만일
러스트가 어떠한 타입이든 담을 수 있는 벡터를 허용한다면, 벡터 내의 각 요소마다
수행되는 연산에 대해 하나 혹은 그 이상의 타입이 에러를 야기할 수도 있습니다.
열거형과 match
표현식을 사용한다는 것은 6장에서 설명한 바와 같이 러스트가
컴파일 타임에 모든 가능한 경우에 대해 처리한다는 것을 보장해 준다는 의미입니다.
만약 프로그램을 작성할 때 여러분의 프로그램이 런타임에 벡터에 저장하게 될 타입의 모든 경우를 알지 못한다면, 열거형을 이용한 방식은 사용할 수 없을 것입니다. 대신 트레잇 객체(trait object)를 이용할 수 있는데, 이건 17장에서 다룰 예정입니다.
지금까지 벡터를 이용하는 가장 일반적인 방식 중 몇 가지에 대해 논의했는데, 표준
라이브러리의 Vec
에 정의된 수많은 유용한 메서드들이 있으니
API 문서를 꼭 살펴봐 주시기를 바랍니다. 예를 들면,
push
에 더해서, pop
메서드는 제일 마지막 요소를 반환하고 지워줍니다.
벡터가 버려지면 벡터의 요소도 버려집니다
예제 8-10에 주석으로 표시된 것처럼, 벡터는 스코프를 벗어날 때 해제됩니다.
struct
와 마찬가지죠.
fn main() { { let v = vec![1, 2, 3, 4]; // do stuff with v } // <- v goes out of scope and is freed here }
예제 8-10: 벡터와 요소들이 버려지는 위치를 표시
벡터가 버려질 때 벡터의 내용도 전부 버려집니다. 즉, 벡터가 가지고 있던 정수들의 메모리도 정리됩니다. 빌림 검사기는 벡터의 내용물에 대한 참조자의 사용이 해당 벡터가 유효한 동안에만 일어났는지 확인합니다.
이제 다음 컬렉션 타입인 String
으로 넘어갑시다!