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 `_`
- 에러와 노트 부분이 이야기해 줍니다: 러스트 스트링은 인덱싱을 지원하지 않는다고.
- 그렇지만 왜 안되는 걸까요?
- 이 질문에 답하기 위해서는 러스트가 어떻게 스트링을 메모리에 저장하는지에 관하여 살짝 이야기해야 합니다.
내부적 표현
String
은 Vec<u8>
을 감싼 것입니다.
Listing 8-14에서 보았던 몇가지 적절히 인코딩된 UTF-8 예제 스트링을 살펴봅시다.
// 첫번째로, 이것입니다:
let len = String::from("Hola").len();
- 이 경우,
len
은 4가 되는데, - 이는 스트링 “Hola”를 저장하고 있는
Vec
이 4바이트 길이라는 뜻입니다. - UTF-8로 인코딩되면 각각의 글자들이 1바이트씩 차지한다는 것이죠.
- 그런데 아래 예제는 어떨까요?
let len = String::from("Здравствуйте").len();
- 이 스트링의 길이가 얼마인지 묻는다면, 여러분은 12라고 답할런지도 모릅니다.
- 그러나 러스트의 대답은 24입니다.
- 이는 “Здравствуйте”를 UTF-8로 인코딩된 바이트들의 크기인데, 각각의 유니코드 스칼라 값이 저장소의 2바이트를 차지하기 때문입니다.
- 따라서, 스트링의 바이트들 안의 인덱스는 유효한 유니코드 스칼라 값과 항상 대응되지는 않을 것입니다.
이를 보여주기 위해, 다음과 같은 유효하지 않은 러스트 코드를 고려해 보세요:
let hello = "Здравствуйте";
let answer = &hello[0];
answer
의 값은 무엇이 되어야 할까요?- 첫번째 글자인
З
이 되어야 할까요?- UTF-8로 인코딩될 때,
З
의 첫번째 바이트는208
이고, 두번째는151
이므로, answer
는 사실208
이 되어야 하지만,208
은 그 자체로는 유효한 문자가 아닙니다.208
을 반환하는 것은 사람들이 이 스트링의 첫번째 글자를 요청했을 경우 사람들이 기대하는 것이 아닙니다;- 하지만 그게 러스트가 인덱스 0에 가지고 있는 유일한 데이터죠.
- 바이트 값을 반환하는 것은 아마도 유저들이 원하는 것이 아닐 것입니다.
- 심지어는 라틴 글자들만 있을 때도요:
&"hello"[0]
는h
가 아니라104
를 반환합니다.
- UTF-8로 인코딩될 때,
- 기대치 않은 값을 반환하고 즉시 발견하기 힘들지도 모를 버그를 야기하는 것을 방지하기 위해,
- 러스트는 이러한 코드를 전혀 컴파일하지 않고 이러한 오해들을 개발 과정 내에서 일찌감치 방지합니다.
바이트와 스칼라 값과 문자소 클러스터(Grapheme cluster)! 이런!
UTF-8에 대한 또다른 지점은, 실제로는 러스트의 관점에서 문자열을 보는 세 가지의 의미있는 방식이 있다는 것입니다:
바이트
, 스칼라 값
, 그리고 문자소 클러스터
(글자
라고 부르는 것과 가장 근접한 것)입니다.
데바가나리 글자로 쓰여진 힌디어 “नमस्ते”를 보면, 이것은 궁극적으로 아래와 같이 u8
값들의 Vec
으로서 저장됩니다:
[224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224, 164, 164, 224, 165, 135]
이건 18바이트이고 컴퓨터가 이 데이터를 궁극적으로 저장하는 방법입니다. 만일 우리가 이를 유니코드 스칼라 값, 즉 러스트의 char
타입인 형태로 본다면, 아래와 같이 보이게 됩니다:
['न', 'म', 'स', '्', 'त', 'े']
- 여섯개의
char
값이 있지만, 네번쨰와 여섯번째는 글자가 아닙니다: - 그 자체로는 이해할 수 없는 발음 구별 부호입니다.
- 마지막으로, 만일 이를 문자소 클러스터로서 본다면, 사람들이 발음할 이 힌디 단어를 구성하는 네 글자를 얻습니다:
["न", "म", "स्", "ते"]
러스트는 컴퓨터가 저장하는 가공되지 않은(raw) 스트링을 번역하는 다른 방법을 제공하여, 데이터가 담고 있는 것이 어떤 인간의 언어든 상관없이 각각의 프로그램이 필요로 하는 통역방식을 선택할 수 있도록 합니다.
- 러스트가
String
을 인덱스로 접근하여 문자를 얻지 못하도록 하는 마지막 이유는 인덱스 연산이 언제나 상수 시간(O(1))에 실행될 것으로 기대받기 때문입니다. - 그러나
String
을 가지고 그러한 성능을 보장하는 것은 불가능한데, 그 이유는 러스트가 스트링 내에 얼마나 많은 유효 문자가 있는지 알아내기 위해 내용물의 시작 지점부터 인덱스로 지정된 곳까지 훑어야 하기 때문입니다.
스트링 슬라이싱하기
스트링 인덱싱의 리턴 타입이 어떤 것이 (바이트 값인지, 캐릭터인지, 문자소 클러스터인지, 혹은 스트링 슬라이스인지) 되어야 하는지 명확하지 않기 때문에 스트링의 인덱싱은 종종 나쁜 아이디어가 됩니다.
따 라서, 여러분이 스트링 슬라이스를 만들기 위해 정말로 인덱스를 사용하고자 한다면 러스트는 좀 더 구체적으로 지정하도록 요청합니다.
여러분의 인덱싱을 더 구체적으로 하고 스트링 슬라이스를 원한다는 것을 가리키기 위해서, []
에 숫자 하나를 사용하는 인덱싱보다, []
와 범위를 사용하여 특정 바이트들이 담고 있는 스트링 슬라이스를 만들 수 있습니다
let hello = "Здравствуйте";
let s = &hello[0..4];
여기서 s
는 스트링의 첫 4바이트를 담고 있는 &str
가 될 것입니다. 앞서 우리는 이 글자들이 각각 2바이트를 차지한다고 언급했으므로, 이는 s
가 “Зд”이 될 것이란 뜻입니다.
- 만약에
&hello[0..1]
라고 했다면 어떻게 될까요? - 답은 다음과 같습니다:
- 러스트는 벡터 내에 유효하지 않은 인덱스에 접근했을 때와 동일한 방식으로 런타임에 패닉을 발생시킬 것입니다.
스트링 내에서 반복적으로 실행되는 메소드
다행히도, 스트링의 요소에 접근하는 다른 방법이 있습니다.
만일 개별적 인 유니코드 스칼라 값에 대한 연산을 수행하길 원한다면, 가장 좋은 방법은 chars
메소드를 이용하는 것입니다. chars
를 “नमस्ते”에 대해 호출하면 char
타입의 6개의 값으로 나누어 반환하며, 여러분은 각각의 요소에 접근하기 위해 이 결과값에 대해 반복(iterate)할 수 있습니다
for c in "नमस्ते".chars() {
println!("{}", c);
}
// न म स ् त े
bytes
메소드는 가공되지 않은 각각의 바이트를 반환하는데, 여러분의 문제 범위에 따라 적절할 수도 있습니다:
for b in "नमस्ते".bytes() {
println!("{}", b);
}
// 224 164 168 224
하지만 유효한 유니코드 스칼라 값이 하나 이상의 바이트로 구성될지도 모른다는 것을 확실히 기억해주세요.
스트링으로부터 문자소 클러스터를 얻는 방법은 복잡해서, 이 기능은 표준 라이브러리를 통해 제공되지 않습니다. 여러분이 원하는 기능이 이것이라면 crates.io에서 사용 가능한 크레이트가 있습니다.
스트링은 그렇게 단순하지 않습니다
- 종합하면, 스트링은 복잡합니다.
- 다른 프로그래밍 언어들은 이러한 복잡성을 프로그래머에게 어떻게 보여줄지에 대해 각기 다른 선택을 합니다.
- 러스트는
String
데이터의 올바른 처리가 모든 러스트 프로그램에 대한 기본적인 동작이 되도록 선택했는데, 이는 솔직히 프로그래머들이 UTF-8 데이터를 처리하는데 있어 더 많은 생각을 해야한다는 의미입니다. - 이러한 거래는 다른 프로그래밍 언어들에 비해 더 복잡한 스트링을 노출시키지만, 한편으로는 여러분의 개발 생활 주기 후반에 비 ASCII 캐릭터를 포함하는 에러를 처리해야 하는 것을 막아줄 것입니다.
이것보다 살짝 덜 복잡한 것으로 옮겨 갑시다: 해쉬맵이요!
해쉬맵(hash map)
마지막으로 볼 일반적인 컬렉션은 해쉬맵
입니다.
HashMap<K, V>
타입은K
타입의 키에V
타입의 값을 매핑한 것을 저장합니다.- 이 매핑은 해쉬 함 수(hashing function) 을 통해 동작하는데, 해쉬 함수는 이 키와 값을 메모리 어디에 저장할지 결정합니다.
- 많은 다른 프로그래밍 언어들도 이러한 종류의 데이터 구조를 지원하지만, 종종
해쉬
,맵
,오브젝트
,해쉬 테이블
, 혹은연관 배열(associative)
등과 같은 그저 몇몇 다른 이름으로 이용됩니다.
이 장에서는 해쉬맵의 기본 API를 다룰 것이지만, 표준 라이브러리의 HashMap
에 정의되어 있는 함수 중에는 더 좋은 것들이 숨어있습니다.
항상 말했듯이, 더 많은 정보를 원하신다면 표준 라이브러리 문서를 확인하세요.
새로운 해쉬맵 생성하기
우리는 빈 해쉬맵을 new
로 생성할 수 있고, insert
를 이용하여 요소를 추가할 수 있습니다.
use std::collections::HashMap;
let mut scores = HashMap::new();
scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Yellow"), 50);
Listing 8-20: 새로운 해쉬맵을 생성하여 몇 개의 키와 값을 집어넣기
- 먼저 표준 라이브러리의 컬렉션 부분으로부터
HashMap
을use
로 가져와야할 필요가 있음을 주목하세요. - 우리가 보고 있는 세 가지 일반적인 컬렉션 중에서 이 해쉬맵이 제일 덜 자주 사용되는 것이기 때문에, 프렐루드(prelude) 내에 자동으로 가져와지는 기능에 포함되어 있지 않습니다.
- 또한 해쉬맵은 표준 라이브러리로부터 덜 지원을 받습니다;
- 예를 들면 해쉬맵을 생성하는 빌트인 매크로가 없습니다.
- 벡터와 마찬가지로, 해쉬맵도 데이터를 힙에 저장합니다.
- 이
HashMap
은String
타입의 키와i32
타입의 값을 갖습니다. - 벡터와 비슷하게 해쉬맵도 동질적입니다:
- 모든 키는 같은 타입이어야 하고, 모든 값도 같은 타입이여야 합니다. -> ???
- 해쉬맵을 생성하는 또다른 방법은 튜플의 벡터에 대해
collect
메소드를 사용하는 것인데, 이 벡터의 각 튜플은 키와 키에 대한 값으로 구성되어 있습니다. collect
메소드는 데이터를 모아서HashMap
을 포함한 여러 컬렉션 타입으로 만들어줍니다.- 예를 들면, 만약 두 개의 분리된 벡터에 각각 팀 이름과 초기 점수를 갖고 있다면, 우리는
zip
메소드를 이용하여 “Blue”와 10이 한 쌍이 되는 식으로 튜플의 벡터를 생성할 수 있습니다. - 그 다음 Listing 8-21과 같이
collect
메소드를 사용하여 튜플의 벡터를HashMap
으로 바꿀 수 있습니다:
- 예를 들면, 만약 두 개의 분리된 벡터에 각각 팀 이름과 초기 점수를 갖고 있다면, 우리는
use std::collections::HashMap;
let teams = vec![String::from("Blue"), String::from("Yellow")];
let initial_scores = vec![10, 50];
let scores: HashMap<_, _> = teams.iter().zip(initial_scores.iter()).collect();
Listing 8-21: 팀의 리스트와 점수의 리스트로부터 해쉬맵 생성하기
타입 명시 HashMap<_, _>
가 필요한데 이는 collect
가 다른 많은 데이터 구조로 바뀔 수 있고, 러스트는 여러분이 특정하지 않으면 어떤 것을 원하는지 모르기 때문입니다.
그러나 키와 값의 타입에 대한 타입 파라미터에 대해서는 밑줄을 쓸 수 있으며 러스트는 벡터에 담긴 데이터의 타입에 기초하여 해쉬에 담길 타입을 추론할 수 있습니다.
해쉬맵과 소유권
i32
와 같이Copy
트레잇을 구현한 타입에 대하여, 그 값들은 해쉬맵 안으로 복사됩니다.String
과 같이 소유된 값들에 대해서는, 아래의 Listing 8-22와 같이 값들이 이동되어 해쉬맵이 그 값들에 대한 소유자가 될 것입니다:
use std::collections::HashMap;
let field_name = String::from("Favorite color");
let field_value = String::from("Blue");
let mut map = HashMap::new();
map.insert(field_name, field_value);
// field_name과 field_value은 이 지점부터 유효하지 않습니다.
// 이들을 이용하는 시도를 해보고 어떤 컴파일러 에러가 나오는지 보세요!
Listing 8-22: 키와 값이 삽입되는 순간 이들이 해쉬맵의 소유가 되는 것을 보여주는 예
insert
를 호출하여 field_name
과 field_value
를 해쉬맵으로 이동시킨 후에는 더 이상 이 둘을 사용할 수 없습니다.
- 만일 우리가 해쉬맵에 값들의 참조자들을 삽입한다면, 이 값들은 해쉬맵으로 이동되지 않을 것입니다.
- 하지만 참조자가 가리키고 있는 값은 해쉬맵이 유효할 때까지 계속 유효해야합니다.
- 이것과 관련하여 10장의 “라이프타임을 이용한 참조자 유효화”절에서 더 자세히 이야기할 것입니다.
해쉬맵 내의 값 접근하기
Listing 8-23과 같이 해쉬맵의 get
메소드에 키를 제공하여 해쉬맵으로부터 값을 얻어올 수 있습니다:
use std::collections::HashMap;
let mut scores = HashMap::new();
scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Yellow"), 50);
let team_name = String::from("Blue");
let score = scores.get(&team_name);
- 여기서
score
는 블루 팀과 연관된 값을 가지고 있을 것이고, 결과값은Some(&10)
일 것입니다. - 결과값은
Some
으로 감싸져 있는데 왜냐하면get
이Option<&V>
를 반환하기 때문입니다; - 만일 해쉬맵 내에 해당 키에 대한 값이 없다면
get
은None
을 반환합니다. - 우리가 6장에서 다루었던 방법 중 하나로
Option
을 처리해야 할 것입니다.
우리는 벡터에서 했던 방법과 유사한 식으로 for
루프를 이용하여 해쉬맵에서도 각각의 키/값 쌍에 대한 반복작업을 할 수 있습니다:
use std::collections::HashMap;
let mut scores = HashMap::new();
scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Yellow"), 50);
for (key, value) in &scores {
println!("{}: {}", key, value);
}
// Yellow: 50
// Blue: 10
해쉬맵 갱신하기
키와 값의 개수가 증가할 수 있을지라도, 각각의 개별적인 키는 한번에 연관된 값 하나만을 가질 수 있습니다.
해쉬맵 내의 데이터를 변경하길 원한다면, 키에 이미 값이 할당되어 있을 경우에 대한 처리를 어떻게 할지 결정해야 합니다.
- 예전 값을 완전히 무시하면서 예전 값을 새 값으로 대신할 수도 있습니다.
- 혹은 예전 값을 계속 유지하면서 새 값은 무시하고, 해당 키에 값이 할당되지 않을 경우에만 새 값을 추가하는 방법을 선택할 수도 있습니다.
- 또는 예전 값과 새 값을 조합할 수도 있습니다.
값을 덮어쓰기
만일 해쉬맵에 키와 값을 삽입하고, 그 후 똑같은 키에 다른 값을 삽입하면, 키에 연관지어진 값은 새 값으로 대신될 것입니다. 아래 Listing 8-24의 코드가 insert
를 두 번 호출함에도, 해쉬맵은 딱 하나의 키/값 쌍을 담게 될 것인데 그 이유는 두 번 모두 블루 팀의 키에 대한 값을 삽입하고 있기 때문입니다:
키에 할당된 값이 없을 경우에만 삽입하기
- 특정 키가 값을 가지고 있는지 검사하고, 만일 가지고 있지 않다면 이 키에 대한 값을 삽입하고자 하는 경우는 흔히 발생합니다.
- 해쉬맵은 이를 위하여
entry
라고 하는 특별한 API를 가지고 있는데, 이는 우리가 검사하고자 하는 키를 파라미터로 받습니다. entry
함수의 리턴값은 열거형Entry
인데, 해당 키가 있는지 혹은 없는지를 나타냅니다.- 우리가 옐로우 팀에 대한 키가 연관된 값을 가지고 있는지 검사하고 싶어한다고 해봅시다. 만일 없다면, 값 50을 삽입하고, 블루팀에 대해서도 똑같이 하고 싶습니다. 엔트리 API를 사용한 코드는 아래의 Listing 8-25와 같습니다:
use std::collections::HashMap;
let mut scores = HashMap::new();
scores.insert(String::from("Blue"), 10);
scores.entry(String::from("Yellow")).or_insert(50);
scores.entry(String::from("Blue")).or_insert(50);
println!("{:?}", scores);
Listing 8-25: entry
메소드를 이용하여 어떤 키가 값을 이미 갖고 있지 않을 경우에만 추가하기
Entry
에 대한 or_insert
메소드는 해당 키가 존재할 경우 관련된 Entry
키에 대한 값을 반환하도록 정의되어 있고, 그렇지 않을 경우에는 파라미터로 주어진 값을 해당 키에 대한 새 값을 삽입하고 수정된 Entry
에 대한 값을 반환합니다. 이 방법은 우리가 직접 로직을 작성하는 것보다 훨씬 깔끔하고, 게다가 빌림 검사기와 잘 어울려 동작합니다.
예전 값을 기초로 값을 갱신하기
해쉬맵에 대한 또다른 흔한 사용 방식은 키에 대한 값을 찾아서 예전 값에 기초하여 값을 갱신하는 것입니다. 예를 들어, Listing 8-26은 어떤 텍스트 내에 각 단어가 몇번이나 나왔는지를 세는 코드를 보여줍니다. 단어를 키로 사용하는 해쉬맵을 이용하여 해당 단어가 몇번이나 나왔는지를 유지하기 위해 값을 증가시켜 줍니다. 만일 어떤 단어를 처음 본 것이라면, 값 0
을 삽입할 것입니다.
use std::collections::HashMap;
let text = "hello world wonderful world";
let mut map = HashMap::new();
for word in text.split_whitespace() {
let count = map.entry(word).or_insert(0);
*count += 1;
}
println!("{:?}", map);
Listing 8-26: 단어와 횟수를 저장하는 해쉬맵을 사용하여 단어의 등장 횟수 세기
- 이 코드는
{"world": 2, "hello": 1, "wonderful": 1}
를 출력할 것입니다. or_insert
메소드는 실제로는 해당 키에 대한 값의 가변 참조자 (&mut V
)를 반환합니다.- 여기서는
count
변수에 가변 참조자를 저장하였고, 여기에 값을 할당하 기 위해 먼저 애스터리스크 (*
)를 사용하여count
를 역참조해야 합니다. - 가변 참조자는
for
루프의 끝에서 스코프 밖으로 벗어나고, 따라서 모든 값들의 변경은 안전하며 빌림 규칙에 위배되지 않습니다.
해쉬 함수
- 기본적으로,
HashMap
은 서비스 거부 공격(Denial of Service(DoS) attack)에 저항 기능을 제공할 수 있는 암호학적으로 보안되는 해쉬 함수를 사용합니다. - 이는 사용 가능한 가장 빠른 해쉬 알고리즘은 아니지만, 성능을 떨어트리면서 더 나은 보안을 취하는 거래는 가치가 있습니다.
- 만일 여러분이 여러분의 코드를 프로파일하여 기본 해쉬 함수가 여러분의 목표에 관해서는 너무 느리다면, 다른 해쉬어(hasher) 를 특정하여 다른 함수로 바꿀 수 있습니다.
- 해쉬어는
BuildHasher
트레잇을 구현한 타입을 말합니다.- 트레잇과 이를 어떻게 구현하는지에 대해서는 10장에서 다룰 것입니다.
- 여러분의 해쉬어를 바닥부터 새로 구현해야할 필요는 없습니다;
- crates.io에서는 많은 수의 범용적인 해쉬 알고리즘을 구현한 해쉬어를 제공하는 공유 라이브러리를 제공합니다.
정리
벡터, 스트링, 그리고 해쉬맵은 프로그램 내에서 여러분이 데이터를 저장하고, 접근하고, 수정하고 싶어하는 곳마다 필요한 수많은 기능들을 제공해줄 것입니다.
이제 여러분이 풀 준비가 되어있어야 할만한 몇가지 연습문제를 소개합니다:
- 정수 리스트가 주어졌을 때, 벡터를 이용하여 이 리스트의 평균값(mean, average), 중간값(median, 정렬했을 때 가장 가운데 위치한 값), 그리고 최빈값(mode, 가장 많이 발생한 값; 해쉬맵이 여기서 도움이 될 것입니다)를 반환해보세요.
- 스트링을 피그 라틴(pig Latin)으로 변경해보세요. 각 단어의 첫번째 자음은 단어의 끝으로 이동하고 “ay”를 붙이므로, “first”는 “irst-fay”가 됩니다. 모음으로 시작하는 단어는 대신 끝에 “hay”를 붙입니다. (“apple”은 “apple-hay”가 됩니다.) UTF-8 인코딩에 대해 기억하세요!
- 해쉬맵과 벡터를 이용하여, 사용자가 회사 내의 부서에 대한 피고용인 이름을 추가할 수 있도록 하는 텍스트 인터페이스를 만들어보세요. 예를들어 “Add Sally to Engineering”이나 “Add Amir to Sales” 같은 식으로요. 그후 사용자가 각 부서의 모든 사람들에 대한 리스트나 알파벳 순으로 정렬된 부서별 모든 사람에 대한 리스트를 조회할 수 있도록 해보세요.
표준 라이브러리 API 문서는 이 연습문제들에 대해 도움이 될만한 벡터, 스트링, 그리고 해쉬맵의 메소드들을 설명해줍니다!
우리는 연산이 실패할 수 있는 더 복잡한 프로그램으로 진입하고 있는 상황입니다; 따라서, 다음은 에러 처리에 대해 다룰 완벽한 시간이란 뜻이죠!