Chap.8 common-collections
러스트의 표준 라이브러리에는 컬렉션
이라 불리는 여러 개의 매우 유용한 데이터 구조들이 포함되어 있습니다.
대부분의 다른 데이터 타입들은 하나의 특정한 값을 나타내지만, 컬렉션은 다수의 값을 담을 수 있습니다.
내장된 배열(build-in array)와 튜플 타입과는 달리, 이 컬렉션들이 가리키고 있는 데이터들은 힙에 저장되는데, 이는 즉 데이터량이 컴파일 타임에 결정되지 않아도 되며 프로그램이 실행될 때 늘어나거나 줄어들 수 있다는 의미입니다.
각각의 컬렉션 종류는 서로 다른 용량과 비용을 가지고 있으며, 여러분의 현재 상황에 따라 적절한 컬렉션을 선택하는 것은 시간이 지남에 따라 발전시켜야 할 기술입니다.
이번 장에서는 러스트 프로그램에서 굉장히 자주 사용되는 세 가지 컬렉션에 대해 논의해 보겠습니다:
- 벡터(vector) 는 여러 개의 값을 서로 붙어 있게 저장할 수 있도록 해줍니다.
- 스트링(string) 은 문자(character)의 모음입니다.
String
타입은 이전에 다루었지만, 이번 장에서는 더 깊이 있게 이야기해 보겠습니다. - 해쉬맵(hash map 은 어떤 값을 특정한 키와 연관지어 주도록 해줍니다. 이는 맵(map) 이라 일컫는 좀더 일반적인 데이터 구조의 특정한 구현 형태입니다.
표준 라이브러리가 제공해주는 다른 종류의 컬렉션에 대해 알고 싶으시면, the documentation를 봐 주세요.
벡터
우리가 보게될 첫번째 콜렉션은 벡터
라고도 알려진 Vec<T>
입니다.
- 벡터는 메모리 상에 서로 이웃하도록 모든 값을 집어넣는 단일 데이터 구조 안에 하나 이상의 값을 저장하도록 해줍니다.
- 벡터는 같은 타입의 값만을 저장할 수 있습니다.
새 벡터 만들기
비어있는 새 벡터를 만들기 위해서는, 아래의 Listing 8-1과 같이 Vec::new
함수를 호출해 줍니다:
let v: Vec<i32> = Vec::new();
- 여기에 타입 명시(type annotation)를 추가한 것을 주목하세요.
- 이 벡터에 어떠한 값도 집어넣지 않았기 때문에, 러스트는 우리가 저장하고자 하는 요소의 종류가 어떤 것인지 알지 못합니다.
- 벡터는 제네릭(generic)을 이용하여 구현되었습니다;
- 표준 라이브러리가 제공하는
Vec
타입은 어떠한 종류의 값이라도 저장할 수 있다는 것, - 그리고 특정한 벡터는 특정한 타입의 값을 저장할 때, 이 타입은 꺾쇠 괄호
(<>)
안에 적는다는 것만 알아두세요.
- 표준 라이브러리가 제공하는
- 일단 우리가 값을 집어넣으면 러스트는 우리가 저장하고자 하는 값의 타입을 대부분 유추할 수 있으므로, 좀 더 현실적인 코드에서는 이러한 타입 명시를 할 필요가 거의 없습니다.
- 초기값들을 갖고 있는
Vec<T>
을 생성하는 것이 더 일반적이며, 러스트는 편의를 위해vec!
매크로를 제공합니다. - 이 매크로는 우리가 준 값들을 저장하고 있는 새로운
Vec
을 생성합니다. 1
,2
,3
을 저장하고 있는 새로운Vec<i32>
을 생성할 것입니다:
let v = vec![1, 2, 3];
Listing 8-2: 값을 저장하고 있는 새로운 벡터 생성하기
초기 i32
값들을 제공했기 때문에, 러스트는 v
가 Vec
타입이라는 것을 유추할 수 있으며, 그래서 타입 명시는 필요치 않습니다.
벡터 갱신하기
벡터를 만들고 여기에 요소들을 추가하기 위해서 push
메소드를 사용할 수 있습니다:
let mut v = Vec::new();
v.push(5);
v.push(6);
v.push(7);
v.push(8);
Listing 8-3: push
메소드를 사용하여 벡터에 값을 추가하기
어떤 변수에 대해 그 변수가 담고 있는 값이 변경될 수 있도록 하려면, mut
키워드를 사용하여 해당 변수를 가변으로 만들어 줄 필요가 있습니다.
우리가 집어넣는 숫자는 모두 i32
타입이며, 러스트는 데이터로부터 이 타입을 추론하므로, 우리는 Vec<i32>
명시를 붙일 필요가 없습니다.
벡터를 드롭하는 것은 벡터의 요소들을 드롭시킵니다
struct
와 마찬가지로, Listing 8-4에 달려있는 주석처럼 벡터도 스코프 밖으로 벗어났을 때 해제됩니다:
{
let v = vec![1, 2, 3, 4];
// v를 가지고 뭔가 합니다
}
// <- v가 스코프 밖으로 벗어났고, 여기서 해제됩니다
벡터의 요소들 읽기
지금까지 벡터를 만들고, 갱신하고, 없애는 방법에 대해 알아보았으니, 벡터의 내용물을 읽어들이는 방법을 알아보는 것이 다음 단계로 좋아보입니다. 벡터 내에 저장된 값을 참조하는 두 가지 방법이 있습니다.
{
let v = vec![1, 2, 3, 4, 5];
let third: &i32 = &v[2];
let third: Option<&i32> = v.get(2);
}
Listing 8-5: 인덱스 문법 혹은 get
메소드를 사용하여 벡터 내의 아이템에 접근하기
두가지 세부사항을 주목하세요.
- 첫번째로, 인덱스값
2
를 사용하면 세번째 값이 얻어집니다:- 벡터는 0부터 시작하는 숫자로 인덱스됩니다.
- 두번째로, 세번째 요소를 얻기 위해 두 가지 다른 방법이 사용되었습니다:
&
와[]
를 이용하여 참조자를 얻은 것과,get
함수에 인덱스를 파라미터로 넘겨서Option<&T>
를 얻은 것입니다.
러스트가 벡터 요소를 참조하는 두가지 방법을 제공하는 이유는 여러분이 벡터가 가지고 있지 않은 인덱스값을 사용하고자 했을 때 프로그램이 어떻게 동작할 것인지 여러분이 선택할 수 있도록 하기 위해서입니다.
let v = vec![1, 2, 3, 4, 5];
let does_not_exist = &v[100];
let does_not_exist = v.get(100);
Listing 8-6: 5개의 요소를 가진 벡터에 100 인덱스에 있는 요소에 접근하기
-
이 프로그램을 실행하면, 첫번째의
[]
메소드는panic!
을 일으키는데, 이는 존재하지 않는 요소를 참조하기 때문입니다.이 방법은 여러분의 프로그램이 벡터의 끝을 넘어서는 요소에 접근하는 시도를 하면 프로그램이 죽게끔 하는 치명적 에러를 발생하도록 하기를 고려하는 경우 가장 좋습니다.
-
get
함수에 벡터 범위를 벗어난 인덱스가 주어졌을 때는 패닉 없이None
이 반환됩니다. 보통의 환경에서 벡터의 범위 밖에 있는 요소에 접근하는 것이 종종 발생한다면 이 방법을 사용할만 합니다. 여러분의 코드는 우리가 6장에서 본 것과 같이Some(&element)
혹은None
에 대해 다루는 로직을 갖추어야 합니다.
유효하지 않은 참조자
프로그램이 유효한 참조자를 얻을 때, 빌림 검사기(borrow checker)
가 소유권 및 빌림 규칙을 집행하여 이 참조자와 벡터의 내용물로부터 얻은 다른 참조자들이 계속 유효하게 남아있도록 확실히 해줍니다.
같은 스코프 내에서 가변 참조자와 불변 참조자를 가질 수 없다는 규칙을 상기하세요.
이 규칙은 아래 예제에서도 적용되는데, Listing 8-7에서는 벡터의 첫번째 요소에 대한 불변 참조자를 얻은 뒤 벡터의 끝에 요소를 추가하고자 했습니다:
let mut v = vec![1, 2, 3, 4, 5];
let first = &v[0];
v.push(6);
// error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable
Listing 8-7의 코드는 동작을 해야만 할것처럼 보일 수도 있습니다:
- 왜 첫번째 요소에 대한 참조자가 벡터 끝에 대한 변경을 걱정해야 하죠?
- 이 에러에 대한 내막은 벡터가 동작하는 방법 때문입니다:
- 새로운 요소를 벡터의 끝에 추가하는 것은
- 새로 메모리를 할당하여 예전 요소를 새 공간에 복사하는 일을 필요로 할 수 있는데,
- 이는 벡터가 모든 요소들을 붙여서 저장할 공간이 충분치 않는 환경에서 일어날 수 있습니다.
- 이러한 경우, 첫번째 요소에 대한 참조자는 할당이 해제된 메모리를 가리키게 될 것입니다.
- 빌림 규칙은 프로그램이 이러한 상황에 빠지지 않도록 해줍니다.
노트:
Vec<T>
타입의 구현 세부사항에 대한 그밖의 것에 대해서는 https://doc.rust-lang.org/stable/nomicon/vec.html 에 있는 노미콘(The Nomicon)을 보세요:
벡터 내의 값들에 대한 반복처리
만일 벡터 내의 각 요소들을 차례대로 접근하고 싶다면, 하나의 값에 접근하기 위해 인덱스를 사용하는것 보다는, 모든 요소들에 대해 반복처리를 할 수 있습니다.
let v = vec![100, 32, 57];
for i in &v {
println!("{}", i);
}
Listing 8-8: for
루프를 이용한 요소들에 대한 반복작업을 통해 각 요소들을 출력하기
만일 모든 요소들을 변형시키길 원한다면 가변 벡터 내의 각 요소에 대한 가변 참조자로 반복작업을 할 수도 있습니다.
let mut v = vec![100, 32, 57];
for i in &mut v {
*i += 50;
}
Listing 8-9: 벡터 내의 요소에 대한 가변 참조자로 반복하기
가변 참조자가 참고하고 있는 값을 바꾸기 위해서, i
에 +=
연산자를 이용하기 전에 역참조 연산자 (*
)를 사용하여 값을 얻어야 합니다.
열거형을 사용하여 여러 타입을 저장하기
이 장의 시작 부분에서, 벡터는 같은 타입을 가진 값들만 저장할 수 있다고 이야기했습니다. 이는 불편할 수 있습니다; 다른 타입의 값들에 대한 리스트를 저장할 필요가 있는 상황이 분명히 있지요. 다행히도, 열거형의 variant는 같은 열거형 타입 내에 정의가 되므로, 백터 내에 다른 타입의 값들을 저장할 필요가 있다면 열거형을 정의하여 사용할 수 있습니다!
예를 들어, 스프레드시트의 행으로부터 값들을 가져오고 싶은데, 여기서 어떤 열은 정수를, 어떤 열은 실수를, 어떤 열은 스트링을 갖고 있다고 해봅시다. 우리는 다른 타입의 값을 가지는 variant가 포함된 열거형을 정의할 수 있고, 모든 열거형 variant들은 해당 열거형 타입, 즉 같은 타입으로 취급될 것입니다. 따라서 우리는 궁극적으로 다른 타입을 담은 열거형 값에 대한 벡터를 생성할 수 있습니다. Listing 8-10에서 이를 보여주고 있습니다:
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-10: 열거형을 정의하여 벡터 내에 다른 타입의 데이터를 담을 수 있도록 하기
러스트가 컴파일 타임에 벡터 내에 저장될 타입이 어떤 것인지 알아야할 필요가 있는 이유는 각 요소를 저장하기 위해 얼만큼의 힙 메모리가 필요한지 알기 위함입니다.
- 부차적인 이점은 이 백터에 허용되는 타입에 대해 명시적일 수 있다는 점입니다.
- 만일 러스트가 어떠한 타입이든 담을수 있는 벡터를 허용한다면, 벡터 내의 각 요소마다 수행되는 연산에 대해 하나 혹은 그 이상의 타입이 에러를 야기할 수도 있습니다.
열거형과 match
표현식을 사용한다는 것은 6장에서 설명한 바와 같이 러스트가 컴파일 타임에 모든 가능한 경우에 대해 처리한다는 것을 보장해준다는 의미입니다.
만약 프로그램을 작성할 때 여러분의 프로그램이 런타임에 벡터에 저장하게 될 타입의 모든 경우를 알지 못한다면, 열거형을 이용한 방식은 사용할 수 없을 것입니다.
대신 트레잇 객체(trait object)를 이용할 수 있는데, 이건 17장에서 다루게 될 것입니다.
지금까지 벡터를 이용하는 가장 일반적인 방식 중 몇가지에 대해 논의했는데, 표준 라이브러리의 Vec
에 정의된 수많은 유용한 메소드들이 있으니 API 문서를 꼭 살펴봐 주시기 바랍니다. 예를 들면, push
에 더해서, pop
메소드는 제일 마지막 요소를 반환하고 지워줍니다. 다음 콜렉션 타입인 String
으로 넘어갑시다!
스트링
새로운 러스트인들은 흔히들 스트링 부분에서 막히는데 이는 세 가지 개념의 조합으로 인한 것입니다:
- 가능한 에러를 꼭 노출하도록 하는 러스트의 성향,
- 많은 프로그래머의 예상보다 더 복잡한 데이터 구조인 스트링,
- 그리고 UTF-8입니다. 다른 언어들을 사용하다 왔을 때 이 개념들의 조합이 러스트의 스트링을 어려운 것처럼 보이게 합니다.
스트링이 컬렉션 장에 있는 이유는 스트링이 바이트의 컬렉션 및 이 바이트들을 텍스트로 통역할때 유용한 기능을 제공하는 몇몇 메소드로 구현되어 있기 때문입니다.
이번 절에서는 생성, 갱신, 값 읽기와 같은 모든 컬렉션 타입이 가지고 있는, String
에서의 연산에 대해 이야기 해보겠습니다.
또한 String
을 다른 컬렉션들과 다르게 만드는 부분, 즉 사람과 컴퓨터가 String
데이터를 통역하는 방식 간의 차이로 인해 생기는 String
인덱싱의 복잡함을 논의해보겠습니다.
스트링이 뭔가요?
러스트는 핵심 언어 기능 내에서 딱 한가지 스트링 타입만 제공하는데, 이는 바로 스트링 슬라이스
인 str
이고, 이것의 참조자 형태인 &str
을 많이 봤죠.
- 이는 다른 어딘가에 저장된 UTF-8로 인코딩된 스트링 데이터의 참조자입니다.
- 예를 들어, 스트링 리터럴은 프로그램의 바이너리 출력물 내에 저장되어 있으며, 그러므로 스트링 슬라이스입니다.
String
타입은 핵심 언어 기능 내에 구현된 것이 아니고 러스트의 표준 라이브러리
를 통해 제공되며, 커질 수 있고, 가변적이며, 소유권을 갖고 있고, UTF-8로 인코딩된 스트링 타입입니다.
러스트인들이 스트링
에 대해 이야기할 때, 그들은 보통 String
과 스트링 슬라이스 &str
타입 둘 모두를 이야기한 것이지, 이들 중 하나를 뜻한 것은 아닙니다.
이번 절은 대부분 String
에 관한 것이지만, 두 타입 모두 러스트 표준 라이브러리에서 매우 많이 사용되며 String
과 스트링 슬라이스 모두 UTF-8로 인코딩되어 있습니다.
또한 러스트 표준 라이브러리는 OsString
, OsStr
, CString
, 그리고 CStr
과 같은 몇가지 다른 스트링 타입도 제공합니다. 심지어 어떤 라이브러리 크레이트들은 스트링 데이터를 저장하기 위해 더 많은 옵션을 제공할 수 있습니다. *String
/*Str
이라는 작명과 유사하게, 이들은 종종 소유권이 있는 타입과 이를 빌린 변형 타입을 제공하는데, 이는 String
/&str
과 비슷합니다. 이러한 스트링 타입들은, 예를 들면 다른 종류의 인코딩이 된 텍스트를 저장하거나 다른 방식으로 메모리에 저장될 수 있습니다. 여기서는 이러한 다른 스트링 타입은 다루지 않겠습니다; 이것들을 어떻게 쓰고 어떤 경우 에 적합한지에 대해 알고 싶다면 각각의 API 문서를 확인하시기 바랍니다.
새로운 스트링 생성하기
Vec
에서 쓸 수 있는 많은 연산들이 String
에서도 마찬가지로 똑같이 쓰일 수 있는데, new
함수를 이용하여 스트링을 생성하는 것으로 아래의 Listing 8-11과 같이 시작해봅시다:
let mut s = String::new();
Listing 8-11: 비어있는 새로운 String
생성하기
- 이 라인은 우리가 어떤 데이터를 담아둘 수 있는
s
라는 빈 스트링을 만들어 줍니다. - 종종 우리는 스트링에 담아두고 시작할 초기값을 가지고 있을 것입니다.
- 그런 경우,
to_string
메소드를 이용하는데, 이는Display
트레잇이 구현된 어떤 타입이든 사용 가능하며, 스트링 리터럴도 이 트레잇을 구현하고 있습니다.
let data = "initial contents";
let s = data.to_string();
// the method also works on a literal directly:
let s = "initial contents".to_string();
Listing 8-12: to_string
메소드를 사용하여 스트링 리터럴로부터 String
생성하기
이 코드는 initial contents
를 담고 있는 스트링을 생성합니다.
또한 스트링 리터럴로부터 String
을 생성하기 위해서 String::from
함수를 이용할 수도 있습니다. Listing 8-13의 코드는 to_string
을 사용하는 Listing 8-12의 코드와 동일합니다:
스트링이 UTF-8로 인코딩되었음을 기억하세요. 즉, 아래의 Listing 8-14에서 보는 것처럼 우리는 인코딩된 어떤 데이터라도 포함시킬 수 있습니다:
let hello = String::from("السلام عليكم");
let hello = String::from("Dobrý den");
let hello = String::from("Hello");
let hello = String::from("שָׁלוֹם");
let hello = String::from("नमस्ते");
let hello = String::from("こんにちは");
let hello = String::from("안녕하세요");
let hello = String::from("你好");
let hello = String::from("Olá");
let hello = String::from("Здравствуйте");
let hello = String::from("Hola");
위의 모두가 유효한 String
값입니다.
스트링 갱신하기
String
은 크기가 커질 수 있으며 이것이 담고 있는 내용물은 Vec
의 내용물과 마찬가지로 더 많은 데이터를 집어넣음으로써 변경될 수 있습니다.
추가적으로, +
연산자나 format!
매크로를 사용하여 편리하게 String
값들을 서로 접합(concatenation)할 수 있습니다.
push_str
과 push
를 이용하여 스트링 추가하기
스트링 슬라이스를 추가하기 위해 push_str
메소드를 이용하여 String
을 키울 수 있습니다:
let mut s1 = String::from("foo");
let s2 = "bar";
s1.push_str(&s2);
println!("s2 is {}", s2);
만일 push_str
함수가 s2
의 소유권을 가져갔다면, 마지막 줄에서 그 값을 출력할 수 없었을 것입니다. 하지만, 이 코드는 우리가 기대했던 대로 작동합니다!
+
연산자나 format!
매크로를 이용한 접합
종종 우리는 가지고 있는 두 개 의 스트링을 조합하고 싶어합니다. 한 가지 방법은 아래 Listing 8-18와 같이 +
연산자를 사용하는 것입니다:
let s1 = String::from("Hello, ");
let s2 = String::from("world!");
let s3 = s1 + &s2; // s1은 여기서 이동되어 더이상 쓸 수 없음을 유의하세요
+
연산자를 사용하여 두 String
값을 하나의 새로운 String
값으로 조합하기
위의 코드 실행 결과로서, 스트링 s3
는 Hello, world!
를 담게 될 것입니다. s1
이 더하기 연산 이후에 더이상 유효하지 않은 이유와 s2
의 참조자가 사용되는 이유는 +
연산자를 사용했을 때 호출되는 함수의 시그니처와 맞춰야 하기 때문입니다 +
연산자는 add
메소드를 사용하는데, 이 메소드의 시그니처는 아래처럼 생겼습니다:
fn add(self, s: &str) -> String {
-
이는 표준 라이브러리에 있는 정확한 시그니처는 아닙니다.
-
첫번째로,
s2
는&
를 가지고 있는데, -
이는
add
함수의s
파라미터 때문에 첫번째 스트링에 두번째 스트링의참조자
를 더하고 있음을 뜻합니다: -
우리는
String
에&str
만 더할 수 있고, 두String
을 더하지는 못합니다.- 하지만, 잠깐만요 -
&s2
의 타입은&String
이지,add
의 두번째 파라미터에 명시한 것 처럼&str
은 아니죠. &s2
를add
호출에 사용할 수 있는 이유는&String
인자가&str
로 강제될 수 있기 때문입니다.add
함수가 호출되면, 러스트는 역참조 강제(deref coercion) 라 불리는 무언가를 사용하는데, 이는add
함수내에서 사용되는&s2
가&s2[..]
로 바뀌는 것으로 생각할 수 있도록 해줍니다.- 역참조 강제에 대한 것은 15장에서 다룰 것입니다.
add
가 파라미터의 소유권을 가져가지는 않으므로,s2
는 이 연산 이후에도 여전히 유효한String
일 것입니다.
- 하지만, 잠깐만요 -
-
두번째로, 시그니처에서
add
가self
의 소유권을 가져가는 것을 볼 수 있는데, 이는self
가&
를 안 가지고 있기 때문입니다. -
즉 Listing 8-18의 예제에서
s1
이add
호출로 이동되어 이후에는 더 이상 유효하지 않을 것이라는 의미입니다. -
따라서
let s3 = s1 + &s2;
가 마치 두 스트링을 복사하여 새로운 스 트링을 만들 것처럼 보일지라도, 실제로 이 구문은s1
의 소유권을 가져다가s2
의 내용물의 복사본을 추가한 다음, 결과물의 소유권을 반환합니다. -
달리 말하면, 이 구문은 여러 복사본을 만드는 것처럼 보여도 그렇지 않습니다:
-
이러한 구현은 복사보다 더 효율적입니다.
s
에 tic-tac-toe
을 설정합니다. format!
매크로는 println!
과 똑같은 방식으로 작동하지만, 스크린에 결과를 출력하는 대신 결과를 담은 String
을 반환해줍니다.
format!
을 이용한 버전이 훨씬 읽기 쉽고, 또한 어떠한 파라미터들의 소유권도 가져가지 않습니다.
let s1 = String::from("tic");
let s2 = String::from("tac");
let s3 = String::from("toe");
let s = s1 + "-" + &s2 + "-" + &s3;
let s = format!("{}-{}-{}", s1, s2, s3);
스트링 내부의 인덱싱
다른 많은 프로그래밍 언어들에서, 인덱스를 이용한 참조를 통해 스트링 내부의 개별 문자들에 접근하는 것은 유효하고 범용적인 연산에 속합니다.
그러나 러스트에서 인덱싱 문법을 이용하여 String
의 부분에 접근하고자 하면 에러를 얻게 됩니다.
let s1 = String::from("hello");
let h = s1[0];
// note: the type `std::string::String` cannot be indexed by `_`
- 에러와 노트 부분이 이야기해 줍니다: 러스트 스트링은 인덱싱을 지원하지 않는다고.
- 그렇지만 왜 안되는 걸까요?
- 이 질문에 답하기 위해서는 러스트가 어떻게 스트링을 메모리에 저장하는지에 관하여 살짝 이야기해야 합니다.