RUST · 심층 가이드
Rust 컬렉션 완전 정리
map과 filter부터 groupBy 직접 구현, sliding window, partition까지 Rust 이터레이터로 컬렉션을 다루는 20가지 패턴을 정리했습니다.
다른 언어에서 넘어오면 Rust 컬렉션에서 가장 먼저 걸리는 것은 iter(), into_iter(), iter_mut()의 구분입니다. 각각 참조를 빌려오고, 소유권을 가져가고, 가변 참조를 내주는 서로 다른 동작인데 이름이 비슷해 섞어 쓰기 쉽습니다. 원본을 나중에 또 쓸 계획이라면 into_iter()는 그 자리에서 컬렉션을 소비해 버린다는 점을 먼저 기억하는 편이 낫습니다.
순서는 변환 축부터 잡으면 편합니다. map 변환과 filter 필터링, reduce 합계가 기본 어댑터의 골격이고, 여기에 zip 묶기와 flatten 펼치기가 붙으면 중첩 구조를 평평하게 다루는 감이 생깁니다. 그다음 groupBy 구현과 빈도표 만들기를 보십시오. Rust 표준 라이브러리에는 groupBy가 없어 HashMap의 entry API로 직접 만들어야 하는데, 이 과정이 소유권과 기본값 삽입을 한 번에 이해하게 해 줍니다.
이터레이터 어댑터는 게으르다는 점이 함정입니다. map이나 filter만 연결해 두고 collect()나 for로 소비하지 않으면 클로저는 한 번도 실행되지 않습니다. 컴파일러가 #[must_use] 경고를 주지만 결과를 변수에 담아 두면 경고도 사라집니다. 또 collect()는 무엇으로 모을지 스스로 알지 못하므로, 목표 타입을 변수 쪽에 적거나 collect::<Vec<_>>()처럼 터보피시로 지정해야 합니다.
01map 변환
iter()로 벡터를 빌려 각 원소를 두 배로 만들고 collect로 새 Vec에 담습니다. 원본은 그대로 남아 뒤에서 다시 쓸 수 있고, 소유권째 넘겨 값을 소비하려면 into_iter()를 씁니다. map은 게으른 어댑터라 collect나 반복문이 없으면 클로저가 한 번도 실행되지 않습니다.
Rust code
// map 변환
fn main() {
let items = vec![1, 2, 3, 1];
let doubled: Vec<i32> = items.iter().map(|value| value * 2).collect();
println!("{:?}", doubled);
}iter()는 원소를 &i32로 빌려 주므로 원본 벡터가 그대로 남고, 소유권째 꺼내 옮기고 싶을 때만 into_iter()로 바꾸면 됩니다.
map은 게으른 어댑터라 collect나 반복문 같은 소비자가 없으면 클로저가 한 번도 실행되지 않아, 부수 효과를 기대한 코드가 통째로 사라집니다.
02filter 필터링
필터가 아니라 배열 네 개를 HashSet에 모아, 중복된 rust가 하나로 합쳐지면서 길이 3이 출력됩니다. into_iter로 배열을 소비하므로 원소 타입이 &str 그대로 유지되고 참조의 참조가 생기지 않습니다. 집합은 순서를 보장하지 않으니 순회할 때 나열 순서에 기대면 안 됩니다.
Rust code
// filter 필터링
use std::collections::HashSet;
fn main() {
let tags: HashSet<&str> = ["rust", "cargo", "rust", "ts"].into_iter().collect();
println!("{}", tags.len());
}배열에 into_iter()를 쓰면 원소가 &str 그대로 나와 참조의 참조가 생기지 않고, collect가 좌변의 타입 표기를 보고 집합을 만들어 줍니다.
집합에 넣는 순간 중복이 조용히 사라지므로, 원래 몇 개가 들어왔는지 세야 하는 자리에서 len()만 보고 판단하면 값이 어긋납니다.
03reduce 합계
합계 대신 각 원소에 2를 곱한 새 벡터를 만듭니다. 누적으로 바꾸려면 fold(0, |acc, v| acc + v)처럼 초깃값과 결합 방식을 주면 되고, 초깃값이 없는 reduce는 빈 이터레이터에서 None을 돌려줍니다. collect는 길이를 미리 알 수 있을 때 용량을 한 번에 잡아 재할당을 줄입니다.
Rust code
// reduce 합계
fn main() {
let items = vec![1, 2, 3, 3];
let doubled: Vec<i32> = items.iter().map(|value| value * 2).collect();
println!("{:?}", doubled);
}fold(0, |acc, v| acc + v)는 초깃값을 직접 주므로 빈 입력에서도 결과가 나오고, 초깃값이 없는 reduce는 그 경우 None을 돌려줍니다.
누적 클로저 안에서 벡터를 clone해 이어 붙이면 원소마다 전체 복사가 일어나, 길이의 제곱에 비례하는 비용이 조용히 붙습니다.
04첫 요소 찾기
찾기 동작 없이 문자열 네 개를 집합으로 만든 뒤 원소 수만 셉니다. 조건에 맞는 첫 원소는 iter().find(...)로 얻고, 결과가 Option이라 없을 때도 패닉하지 않습니다. 다만 집합에는 앞뒤가 없어 첫 번째라는 말이 성립하지 않으므로, 순서가 필요하면 Vec이나 BTreeSet을 써야 합니다.
Rust code
// 첫 요소 찾기
use std::collections::HashSet;
fn main() {
let tags: HashSet<&str> = ["rust", "cargo", "rust", "ts"].into_iter().collect();
println!("{}", tags.len());
}find는 조건에 맞는 첫 원소를 만나는 즉시 순회를 멈추고 Option을 돌려주므로, 값이 없을 때도 패닉 없이 흐름이 이어집니다.
HashSet에는 앞뒤 개념이 없어 순회에서 먼저 나온 값을 첫 번째로 취급하면, 같은 데이터인데도 실행할 때마다 다른 값이 잡힙니다.
05Set 중복 제거
제목과 달리 집합은 쓰이지 않고 벡터 원소를 두 배로 바꾼 결과가 나옵니다. Vec에서 중복을 없앨 때는 정렬 후 dedup을 부르는 방법이 있는데, 이 함수는 이웃한 값만 비교하므로 정렬을 건너뛰면 떨어져 있는 중복이 살아남습니다. 원래 순서를 지켜야 한다면 본 적 있는 값을 집합에 기록하며 걸러야 합니다.
Rust code
// Set 중복 제거
fn main() {
let items = vec![1, 2, 3, 5];
let doubled: Vec<i32> = items.iter().map(|value| value * 2).collect();
println!("{:?}", doubled);
}Vec에서 중복을 없앨 때는 sort 뒤에 dedup을 부르며, 이 조합은 추가 자료구조 없이 제자리에서 끝납니다.
dedup은 이웃한 값만 비교하므로 정렬을 건너뛰면 떨어져 있는 중복이 그대로 살아남아, 결과가 맞아 보이지만 일부만 지워진 상태가 됩니다.
06단어 수 세기
단어별 횟수 대신 서로 다른 항목의 개수인 3을 셉니다. 중복을 뺀 종류 수가 필요할 때 이 형태가 그대로 쓰입니다. 각 단어가 몇 번 나왔는지 알려면 HashMap에 *map.entry(word).or_insert(0) += 1처럼 넣어야 하고, entry는 조회와 삽입을 한 번의 해시 계산으로 끝냅니다.
Rust code
// 단어 수 세기
use std::collections::HashSet;
fn main() {
let tags: HashSet<&str> = ["rust", "cargo", "rust", "ts"].into_iter().collect();
println!("{}", tags.len());
}횟수를 셀 때는 *map.entry(word).or_insert(0) += 1; 한 줄이면 되고, entry는 조회와 삽입을 해시 계산 한 번으로 처리합니다.
contains_key로 확인한 뒤 다시 insert하는 식으로 나눠 쓰면 같은 키를 두 번 해싱하게 되고, 두 갈래가 어긋나는 실수도 끼어들기 쉽습니다.
07스택 구현
스택 연산은 없고 map과 collect로 새 벡터를 만듭니다. Rust에서 스택은 Vec의 push와 pop만으로 충분하며, pop은 비어 있으면 None을 돌려줍니다. 끝에서 넣고 빼는 동작은 평균 상수 시간이지만 용량이 찼을 때는 더 큰 버퍼로 옮기는 재할당이 한 번 일어납니다.
Rust code
// 스택 구현
fn main() {
let items = vec![1, 2, 3, 7];
let doubled: Vec<i32> = items.iter().map(|value| value * 2).collect();
println!("{:?}", doubled);
}Vec의 push와 pop만으로 스택이 되고, pop은 비었을 때 패닉 대신 None을 돌려주므로 while let과 잘 맞습니다.
꼭대기를 보는 last()와 꺼내는 pop()을 혼동하면 확인만 하려던 자리에서 원소가 사라져, 반복이 한 칸씩 어긋난 채 끝납니다.
08큐 구현
큐 대신 배열을 HashSet으로 접어 길이를 출력합니다. 앞뒤로 넣고 빼는 큐가 필요하면 VecDeque가 맞고, Vec의 remove(0)은 뒤쪽 원소를 전부 앞으로 당기므로 길이에 비례하는 비용이 듭니다. VecDeque는 원형 버퍼라 양쪽 끝 연산이 모두 상수 시간입니다.
Rust code
// 큐 구현
use std::collections::HashSet;
fn main() {
let tags: HashSet<&str> = ["rust", "cargo", "rust", "ts"].into_iter().collect();
println!("{}", tags.len());
}VecDeque는 원형 버퍼라 push_back과 pop_front가 모두 상수 시간이며, 큐가 필요할 때 표준에서 바로 꺼내 쓸 수 있습니다.
Vec::remove(0)으로 큐를 흉내 내면 꺼낼 때마다 뒤쪽 원소를 전부 앞으로 당기므로, 항목이 늘어날수록 처리 시간이 급격히 나빠집니다.
092차원 배열 합계
중첩 구조는 없고 1차원 벡터의 각 값을 두 배로 만듭니다. 행마다 합을 구하려면 rows.iter().map(|row| row.iter().sum::<i32>())처럼 안쪽에서 한 번 더 접어야 합니다. Vec<Vec<i32>>는 행마다 별도 할당이 생기므로, 크기가 고정이라면 한 줄짜리 벡터에 인덱스를 계산해 얹는 편이 메모리 지역성이 낫습니다.
Rust code
// 2차원 배열 합계
fn main() {
let items = vec![1, 2, 3, 9];
let doubled: Vec<i32> = items.iter().map(|value| value * 2).collect();
println!("{:?}", doubled);
}행별 합은 안쪽에서 한 번 더 접어 row.iter().sum::<i32>()로 구하고, 전체 합만 필요하면 flatten을 거쳐 한 번에 계산할 수 있습니다.
Vec<Vec<i32>>는 행마다 할당이 흩어져 있어 캐시 적중률이 떨어지므로, 크기가 고정된 격자라면 한 줄짜리 버퍼에 인덱스를 계산해 얹는 편이 낫습니다.
10groupBy 구현
그룹으로 묶는 대신 중복만 제거한 집합의 크기를 냅니다. 키별로 모으려면 HashMap<K, Vec<V>>를 만들고 entry(key).or_default().push(item)으로 채웁니다. 표준 라이브러리에는 groupBy에 해당하는 이터레이터 어댑터가 없어서 이 패턴을 직접 쓰거나 외부 크레이트를 가져와야 합니다.
Rust code
// groupBy 구현
use std::collections::HashSet;
fn main() {
let tags: HashSet<&str> = ["rust", "cargo", "rust", "ts"].into_iter().collect();
println!("{}", tags.len());
}키별로 모을 때는 map.entry(key).or_default().push(item)이 표준 관용구이며, or_default가 빈 Vec을 알아서 만들어 줍니다.
표준 라이브러리에는 그룹화 어댑터가 없으므로, 다른 언어의 이름만 떠올리며 이터레이터 문서를 뒤지다 시간을 버리기 쉽습니다.
11flatten 펼치기
평탄화는 일어나지 않고 정수 네 개가 두 배로 바뀝니다. 중첩된 이터레이터는 flatten으로 한 겹 벗기며, map과 이어 붙이는 대신 flat_map으로 합치면 중간 단계를 줄일 수 있습니다. Option과 Result도 이터레이터라서 flatten을 통과시키면 None과 Err가 조용히 사라집니다.
Rust code
// flatten 펼치기
fn main() {
let items = vec![1, 2, 3, 11];
let doubled: Vec<i32> = items.iter().map(|value| value * 2).collect();
println!("{:?}", doubled);
}map 뒤에 flatten을 붙이는 대신 flat_map 하나로 합치면 중간 타입이 한 겹 줄어 읽기도 쉬워집니다.
Option이나 Result의 이터레이터에 flatten을 쓰면 None과 Err가 아무 표시 없이 버려져, 실패를 알아채지 못한 채 지나갑니다.
12zip 묶기
두 이터레이터를 짝짓지 않고 배열 하나만 집합으로 바꿉니다. zip은 길이가 다르면 짧은 쪽에서 멈추고 남는 원소를 버리므로, 길이가 어긋난 것을 알아채지 못한 채 데이터가 잘릴 수 있습니다. 인덱스를 함께 쓰려는 목적이라면 zip(0..)보다 enumerate가 의도를 분명히 드러냅니다.
Rust code
// zip 묶기
use std::collections::HashSet;
fn main() {
let tags: HashSet<&str> = ["rust", "cargo", "rust", "ts"].into_iter().collect();
println!("{}", tags.len());
}인덱스와 값을 함께 쓰려는 목적이라면 zip(0..)보다 enumerate가 의도를 분명히 드러내고 결과 순서도 고정되어 있습니다.
zip은 짧은 쪽에서 조용히 멈추므로, 길이가 다른 두 목록을 짝지으면 남은 원소가 오류 없이 사라져 잘린 사실을 눈치채기 어렵습니다.
13chunk 나누기
덩어리로 자르는 동작 없이 전체 원소에 같은 변환을 적용합니다. 고정 크기로 나눌 때는 슬라이스의 chunks(n)를 쓰고, 마지막 조각은 n보다 짧을 수 있습니다. 길이가 딱 맞아떨어져야 한다면 chunks_exact가 남는 부분을 remainder로 따로 넘겨 주므로 처리 누락을 막기 쉽습니다.
Rust code
// chunk 나누기
fn main() {
let items = vec![1, 2, 3, 13];
let doubled: Vec<i32> = items.iter().map(|value| value * 2).collect();
println!("{:?}", doubled);
}chunks_exact(n)은 남는 부분을 remainder()로 따로 넘겨 주므로, 고정 길이 처리와 꼬리 처리를 분리해 쓸 수 있습니다.
덩어리 크기에 0을 넘기면 컴파일은 되지만 실행 중 패닉이 나므로, 값을 설정에서 받아 온다면 0인지 먼저 확인해야 합니다.
14key 기준 정렬
정렬은 없고 집합으로 중복을 지운 개수만 나옵니다. 키를 뽑아 정렬할 때는 sort_by_key가 간결하지만 이 함수는 비교마다 키 함수를 다시 호출하므로, 키 계산이 무거우면 sort_by_cached_key가 낫습니다. 키가 참조를 반환해야 하는 경우에는 수명 제약 때문에 sort_by를 쓰게 됩니다.
Rust code
// key 기준 정렬
use std::collections::HashSet;
fn main() {
let tags: HashSet<&str> = ["rust", "cargo", "rust", "ts"].into_iter().collect();
println!("{}", tags.len());
}sort_by_key는 비교할 때마다 키 함수를 다시 부르므로, 키 계산이 무거우면 키를 한 번만 만들어 두는 sort_by_cached_key가 유리합니다.
키로 참조를 돌려주려 하면 수명 제약 때문에 sort_by_key가 통과하지 못하므로, 그 경우에는 sort_by로 비교 자체를 적어야 합니다.
15빈도표 만들기
빈도 대신 각 원소를 두 배로 만든 벡터를 출력합니다. 개수를 세려면 HashMap에 값을 키로 넣고 카운터를 올리는데, 키가 되려면 Eq와 Hash가 모두 구현돼 있어야 합니다. f64에는 Hash가 없어 실수 값은 그대로 키가 되지 못하고 정수나 문자열로 바꿔야 합니다.
Rust code
// 빈도표 만들기
fn main() {
let items = vec![1, 2, 3, 15];
let doubled: Vec<i32> = items.iter().map(|value| value * 2).collect();
println!("{:?}", doubled);
}HashMap의 키가 되려면 Eq와 Hash가 모두 있어야 하며, 직접 만든 구조체는 #[derive(PartialEq, Eq, Hash)]로 한 번에 갖출 수 있습니다.
f64에는 Hash가 없어 실수를 그대로 키로 쓸 수 없고, 억지로 비트 패턴을 키로 삼으면 사실상 같은 수를 다른 키로 세게 됩니다.
16대소문자 무시 중복 제거
대소문자 구분은 그대로 둔 채 문자열을 집합에 넣어 중복 하나가 빠집니다. 대소문자를 무시하려면 넣기 전에 to_lowercase()로 정규화해야 하는데, 이 함수는 새 String을 할당하므로 집합의 원소 타입도 &str에서 String으로 바뀝니다. ASCII만 다룬다면 eq_ignore_ascii_case 계열이 더 쌉니다.
Rust code
// 대소문자 무시 중복 제거
use std::collections::HashSet;
fn main() {
let tags: HashSet<&str> = ["rust", "cargo", "rust", "ts"].into_iter().collect();
println!("{}", tags.len());
}정규화가 필요하면 넣기 전에 to_lowercase()를 거치며, 이때 새 String이 만들어지므로 집합의 원소 타입도 String으로 바뀝니다.
ASCII만 다루면서 to_lowercase()를 쓰면 유니코드 규칙을 따지는 비용과 할당이 함께 붙으니, to_ascii_lowercase 계열이 더 가볍습니다.
17sliding window
이동 창 대신 원소마다 독립적인 변환이 일어납니다. 연속한 구간을 겹쳐 보려면 슬라이스의 windows(n)를 쓰며, 원소 수보다 창이 크면 아무것도 나오지 않고 빈 반복으로 끝납니다. windows는 겹쳐 가며 훑고 chunks는 겹치지 않게 자른다는 점이 둘을 가르는 차이입니다.
Rust code
// sliding window
fn main() {
let items = vec![1, 2, 3, 17];
let doubled: Vec<i32> = items.iter().map(|value| value * 2).collect();
println!("{:?}", doubled);
}windows(n)은 한 칸씩 밀며 겹쳐 훑고 chunks(n)은 겹치지 않게 자르므로, 이웃한 값의 차이를 볼 때는 앞쪽을 골라야 합니다.
원소 수보다 창이 크면 windows는 오류 없이 빈 반복으로 끝나므로, 결과가 비었을 때 입력이 잘못됐다고 오해하기 쉽습니다.
18pair 목록을 맵으로 변환
짝을 맵으로 바꾸는 대신 문자열 집합을 만들어 크기를 셉니다. (K, V) 튜플의 이터레이터는 collect::<HashMap<_, _>>()만으로 맵이 되며, 여기 쓰인 집합 수집도 같은 FromIterator 구조를 따릅니다. 키가 겹치면 오류 없이 나중 값이 앞의 것을 덮어쓰므로 중복 키를 알아채려면 직접 확인해야 합니다.
Rust code
// pair 목록을 맵으로 변환
use std::collections::HashSet;
fn main() {
let tags: HashSet<&str> = ["rust", "cargo", "rust", "ts"].into_iter().collect();
println!("{}", tags.len());
}(K, V) 튜플의 이터레이터는 collect::<HashMap<_, _>>()만으로 맵이 되며, 여기 쓰인 집합 수집과 같은 FromIterator 구조를 따릅니다.
키가 겹치면 오류 없이 나중 값이 앞의 것을 덮어쓰므로, 중복 키를 문제로 봐야 한다면 수집 전후의 길이를 비교해 직접 확인해야 합니다.
19reverse 복사
뒤집기는 없고 원본을 남긴 채 두 배 값으로 채운 새 벡터를 만듭니다. 복사본을 뒤집으려면 iter().rev().collect()가 새 Vec을 내놓고, reverse()는 원본을 제자리에서 뒤집어 mut가 필요합니다. rev는 양방향 이터레이터에서만 쓸 수 있어 HashMap 순회에는 붙지 않습니다.
Rust code
// reverse 복사
fn main() {
let items = vec![1, 2, 3, 19];
let doubled: Vec<i32> = items.iter().map(|value| value * 2).collect();
println!("{:?}", doubled);
}iter().rev().collect()는 원본을 남긴 채 뒤집은 새 벡터를 만들고, 제자리에서 뒤집는 reverse()는 mut 바인딩을 요구합니다.
rev는 양방향 이터레이터에만 붙으므로 HashMap 순회에는 쓸 수 없고, 순서를 되짚어야 한다면 먼저 Vec으로 모아야 합니다.
20partition 나누기
조건에 따라 나누는 대신 중복이 제거된 집합의 길이를 출력합니다. 두 갈래로 가르는 partition은 결과 컬렉션 두 개를 한 번에 돌려주기 때문에 받는 쪽 타입을 적어야 합니다. 한쪽만 필요하다면 filter가 나머지 컬렉션을 만들지 않아 할당이 줄고, 원본도 그대로 남습니다.
Rust code
// partition 나누기
use std::collections::HashSet;
fn main() {
let tags: HashSet<&str> = ["rust", "cargo", "rust", "ts"].into_iter().collect();
println!("{}", tags.len());
}partition은 컬렉션 두 개를 한 번에 돌려주므로 받는 쪽에 (Vec<_>, Vec<_>)처럼 타입을 적어 주어야 합니다.
한쪽만 필요한데 partition을 쓰면 버릴 컬렉션까지 만들어 할당이 두 배가 되므로, 그럴 때는 filter로 충분합니다.
정리하며
- iter는 빌리고 into_iter는 소비하므로 원본 재사용 계획에 맞춰 고릅니다
- 어댑터는 게을러서 collect나 for로 소비해야 실제로 실행됩니다
- groupBy는 HashMap entry API로 직접 만들며 기본값 삽입을 함께 익힙니다
- collect는 목표 타입을 명시해야 하며 터보피시가 가장 확실합니다
더 깊이 들어가고 싶다면 Rust 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.