콘텐츠로 이동

Rust 연습 문제

Rust에 관한 글을 읽는 것도 도움이 되지만, 실력으로 남는 건 직접 Rust 코드를 써 볼 때예요. 이 페이지에는 작은 문제 18개가 주제별로, 쉬운 것부터 어려운 것 순서로 준비되어 있어요.

문제마다 먼저 과제를 읽고, 직접 풀어 본 다음에 정답을 열어 보세요. 막히거나 컴파일러 에러가 나도 전혀 괜찮아요. 그것도 배우는 과정이고, Rust의 에러 메시지는 여러분이 얻을 수 있는 최고의 힌트인 경우가 많아요.

연습하는 방법: 다른 탭에서 Rust Playground를 열거나, cargo new practice로 프로젝트를 만드세요(Rust 시작하기 참고). 거기에 답을 쓰고 정답과 비교해 보세요. 코드가 달라도 같은 결과가 출력된다면 아마 괜찮은 답일 거예요. 좋은 방법은 거의 항상 하나 이상 있거든요.

1부터 15까지 숫자를 출력하세요. 단, 3의 배수일 때는 Fizz, 5의 배수일 때는 Buzz, 둘 다의 배수일 때는 FizzBuzz를 출력해야 해요.

힌트: 1..=15를 도는 for 반복문과 %(나머지) 연산자를 쓰세요. “둘 다의 배수”인지를 가장 먼저 확인하세요.

정답 보기
fn main() {
for n in 1..=15 {
if n % 15 == 0 {
println!("FizzBuzz");
} else if n % 3 == 0 {
println!("Fizz");
} else if n % 5 == 0 {
println!("Buzz");
} else {
println!("{n}");
}
}
}

실행 결과:

1
2
Fizz
4
Buzz
Fizz
7
8
Fizz
Buzz
11
Fizz
13
14
FizzBuzz
▶ 직접 실행해 보기

순서가 중요해요. n % 3 == 0을 먼저 확인하면 15에서 Fizz가 출력되고, FizzBuzz 분기에는 영영 도달하지 못해요. 3과 5로 모두 나누어떨어지는 수는 15로도 나누어떨어져요.

텍스트를 거꾸로 뒤집어 돌려주는 함수 reverse(s: &str) -> String을 작성하세요. "stressed"는 "desserts"가 되어야 해요.

힌트: .chars()는 문자를 하나씩 꺼내 주고, .rev()는 반복자를 뒤집고, .collect()로 String을 만들 수 있어요.

정답 보기
fn reverse(s: &str) -> String {
s.chars().rev().collect()
}
fn main() {
println!("{}", reverse("stressed"));
println!("{}", reverse("hello"));
println!("{}", reverse("año"));
}

실행 결과:

desserts
olleh
oña
▶ 직접 실행해 보기

왜 바이트가 아니라 .chars()를 쓸까요? Rust의 String은 UTF-8이라서 ñ 같은 문자는 2바이트를 차지해요(한글 한 글자는 3바이트예요). 바이트를 뒤집으면 문자가 쪼개져서 올바르지 않은 텍스트가 돼요. .chars()는 문자 단위로 다뤄 줘요. 자세한 내용은 문자열을 보세요.

문자열에 있는 모음(a, e, i, o, u)의 개수를 세는 count_vowels(s: &str) -> usize를 작성하세요. 소문자와 대문자를 모두 세야 해요.

힌트: .chars().filter(...).count()를 쓰세요. "aeiouAEIOU" 같은 문자열에는 .contains(c) 메서드가 있어서 각 문자를 검사할 수 있어요.

정답 보기
fn count_vowels(s: &str) -> usize {
s.chars().filter(|c| "aeiouAEIOU".contains(*c)).count()
}
fn main() {
println!("{}", count_vowels("Hello, World"));
println!("{}", count_vowels("Rust is fun"));
println!("{}", count_vowels("AEIOU and sometimes y"));
println!("{}", count_vowels("rhythm"));
}

실행 결과:

3
3
10
0
▶ 직접 실행해 보기

filter는 클로저에 각 항목의 참조자(&char)를 넘겨줘요. 그래서 char 값 자체를 얻으려고 *c라고 써요. 이 패턴은 클로저와 반복자 페이지에서 설명해요.

이 프로그램은 컴파일되지 않아요. shout 함수의 매개변수와 두 번의 호출, 이 부분만 바꿔서 HELLO!가 두 번 출력되게 만드세요.

fn shout(text: String) {
println!("{}!", text.to_uppercase());
}
fn main() {
let word = String::from("hello");
shout(word);
shout(word);
}
error[E0382]: use of moved value: `word`
--> src/main.rs:8:11
|
6 | let word = String::from("hello");
| ---- move occurs because `word` has type `String`, which does not implement the `Copy` trait
7 | shout(word);
| ---- value moved here
8 | shout(word);
| ^^^^ value used here after move

컴파일러는 “이동된 값 word를 사용했다”고 알려 줘요. 7번째 줄에서 값이 이동(move)했는데, 8번째 줄에서 또 쓰려고 했다는 뜻이에요.

힌트: shout는 텍스트를 소유할 필요 없이 보기만 하면 돼요. 소유권을 참고하세요.

정답 보기
fn shout(text: &str) {
println!("{}!", text.to_uppercase());
}
fn main() {
let word = String::from("hello");
shout(&word);
shout(&word);
}

실행 결과:

HELLO!
HELLO!
▶ 직접 실행해 보기

함수가 String을 받으면, 호출할 때 문자열이 함수 안으로 이동해서 main에서는 다시 쓸 수 없어요. &str를 받으면 함수는 문자열을 빌리기만 해요. 컴파일러는 word.clone()도 제안하는데, 그것도 동작하지만 쓸데없이 문자열 전체를 복사해요. 여기서는 대여가 더 좋은 해결책이에요.

이 프로그램도 컴파일되지 않아요. 목록에 Ben을 추가하는 건 그대로 두면서, Ana가 출력되게 만드세요.

fn main() {
let mut names = vec![String::from("Ana")];
let first = &names[0];
names.push(String::from("Ben"));
println!("{first}");
}
error[E0502]: cannot borrow `names` as mutable because it is also borrowed as immutable
--> src/main.rs:4:5
|
3 | let first = &names[0];
| ----- immutable borrow occurs here
4 | names.push(String::from("Ben"));
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ mutable borrow occurs here
5 | println!("{first}");
| ----- immutable borrow later used here

“names가 이미 불변으로 대여되어 있어서 가변으로 대여할 수 없다”는 에러예요. 3번째 줄의 불변 대여가 5번째 줄까지 쓰이는데, 그 사이인 4번째 줄에서 가변 대여를 하려고 했어요.

힌트: 문제는 순서예요. push가 벡터를 바꾸는 순간에도 first가 아직 쓰이고 있어요. 대여를 참고하세요.

정답 보기
fn main() {
let mut names = vec![String::from("Ana")];
names.push(String::from("Ben"));
let first = &names[0];
println!("{first}");
}

실행 결과:

Ana
▶ 직접 실행해 보기

Rust는 왜 여기서 이렇게 엄격할까요? push를 하다가 공간이 더 필요해지면, 벡터는 모든 항목을 메모리의 더 큰 새 자리로 옮길 수 있어요. 그러면 first는 더 이상 유효하지 않은 옛 자리를 가리키게 돼요. Rust의 규칙(읽는 쪽 여럿 또는 쓰는 쪽 하나, 둘이 동시에는 안 됨)은 이런 버그를 프로그램이 실행되기도 전에 막아 줘요. push 다음에 참조를 만들면 해결돼요.

첫 단어(첫 번째 공백 앞까지 전부)를 돌려주는 first_word(s: &str) -> &str를 작성하세요. 공백이 없으면 문자열 전체를 돌려줘요. 새 String을 만들지 말고, 원래 문자열의 슬라이스를 돌려주세요.

힌트: s.find(' ')는 첫 번째 공백의 위치를 Option<usize>로 돌려줘요. &s[..i]는 처음부터 i 앞까지의 슬라이스예요.

정답 보기
fn first_word(s: &str) -> &str {
match s.find(' ') {
Some(i) => &s[..i],
None => s,
}
}
fn main() {
println!("{:?}", first_word("hello world"));
println!("{:?}", first_word("Rust"));
println!("{:?}", first_word(""));
}

실행 결과:

"hello"
"Rust"
""
▶ 직접 실행해 보기

돌려주는 &str는 입력과 같은 메모리를 가리키기 때문에 아무것도 복사되지 않아요. 컴파일러는 결과가 입력보다 오래 살 수 없다는 걸 알고 있어요. 이게 바로 라이프타임 생략(lifetime elision)이에요. 참조가 하나 들어오고 하나 나가면, 라이프타임을 직접 쓸 필요가 없어요.

더 짧게 쓰면 s.split_whitespace().next().unwrap_or("")예요. 이 방법은 앞쪽의 남는 공백도 건너뛰어요.

largest(numbers: &[i32]) -> Option<i32>를 작성하세요. 가장 큰 수를 돌려주고, 목록이 비어 있으면 None을 돌려줘요. 이번에는 내장된 .max()를 쓰지 말고 반복문을 직접 작성해 보세요.

힌트: 첫 번째 항목을 “지금까지 가장 큰 수”로 두고 시작하세요. numbers.first()는 Option을 돌려주니까, 빈 목록인 경우도 알아서 처리돼요.

정답 보기
fn largest(numbers: &[i32]) -> Option<i32> {
let mut biggest = *numbers.first()?;
for &n in numbers {
if n > biggest {
biggest = n;
}
}
Some(biggest)
}
fn main() {
println!("{:?}", largest(&[3, 41, 7, 12]));
println!("{:?}", largest(&[-5, -2, -9]));
println!("{:?}", largest(&[]));
}

실행 결과:

Some(41)
Some(-2)
None
▶ 직접 실행해 보기

numbers.first()?는 목록이 비어 있으면 바로 None을 돌려주고 끝내요. 함수가 Option을 돌려주는 이유가 이거예요. “아무것도 없는 것 중에 가장 큰 것”에는 마땅한 답이 없고, Rust는 그걸 분명히 표현하게 해요. 실제 코드에서는 numbers.iter().max()가 같은 일을 해요.

첫 번째 항목 대신 0으로 시작하는 건 대표적인 버그예요. [-5, -2, -9] 목록에서 엉뚱하게 0이 나와 버리거든요.

문장에서 각 단어가 몇 번 나오는지 세어 보세요. 대소문자와 문장 부호는 무시하고, 단어를 알파벳 순서로 출력하세요.

힌트: BTreeMap<String, usize>를 쓰세요. HashMap처럼 동작하지만 키를 정렬된 상태로 유지해요. map.entry(word).or_insert(0)으로 1을 더할 카운터를 얻을 수 있어요.

정답 보기
use std::collections::BTreeMap;
fn main() {
let text = "The cat sat on the mat. The end!";
let mut counts: BTreeMap<String, usize> = BTreeMap::new();
for word in text.split_whitespace() {
let clean = word
.trim_matches(|c: char| !c.is_alphanumeric())
.to_lowercase();
if clean.is_empty() {
continue;
}
*counts.entry(clean).or_insert(0) += 1;
}
for (word, count) in &counts {
println!("{word}: {count}");
}
}

실행 결과:

cat: 1
end: 1
mat: 1
on: 1
sat: 1
the: 3
▶ 직접 실행해 보기

왜 HashMap이 아니라 BTreeMap일까요? HashMap은 항목을 예측할 수 없는 순서로 내놓고, 그 순서는 실행할 때마다 달라질 수도 있어요. 정렬된 결과가 필요하다면 BTreeMap을 쓰거나, HashMap을 Vec으로 모은 다음 정렬하세요.

trim_matches는 mat.에서 .를, end!에서 !를 없애고, to_lowercase는 The와 the를 같은 단어로 만들어 줘요.

vec![1, 2, 3, 4, 5, 6, 7, 8]에서 짝수만 골라 제곱한 새 벡터 [4, 16, 36, 64]를 만드세요. 그리고 그 합을 출력하세요. for 반복문 말고 반복자 메서드를 쓰세요.

힌트: .iter(), .filter(...), .map(...), .collect()를 이어서 쓰세요. 그다음 .iter().sum()으로 더하면 돼요.

정답 보기
fn main() {
let numbers = vec![1, 2, 3, 4, 5, 6, 7, 8];
let squares: Vec<i32> = numbers
.iter()
.filter(|n| *n % 2 == 0)
.map(|n| n * n)
.collect();
let total: i32 = squares.iter().sum();
println!("{:?}", squares);
println!("합계: {total}");
}

실행 결과:

[4, 16, 36, 64]
합계: 120
▶ 직접 실행해 보기

이 체인은 요리 레시피처럼 위에서 아래로 읽으면 돼요. 숫자들을 가져와서, 짝수만 남기고, 각각 제곱해서, Vec에 담아요. 반복자는 게을러서(lazy), .collect()가 결과를 달라고 할 때까지 실제로는 아무것도 실행되지 않아요. 반복자를 참고하세요.

sum_numbers(input: &str) -> Result<i32, ParseIntError>를 작성하세요. "1, 2, 3" 같은 텍스트를 받아서 각 숫자를 파싱하고 합을 돌려줘요. 숫자가 아닌 조각이 하나라도 있으면 프로그램을 멈추지 말고 에러를 돌려주세요.

힌트: ','로 나누고, 조각마다 .trim()한 다음 .parse()하세요. 에러가 나면 ? 연산자로 바로 돌려주면 돼요.

정답 보기
use std::num::ParseIntError;
fn sum_numbers(input: &str) -> Result<i32, ParseIntError> {
let mut total = 0;
for piece in input.split(',') {
let n: i32 = piece.trim().parse()?;
total += n;
}
Ok(total)
}
fn main() {
for input in ["1, 2, 3", "10,20, 30", "4, 다섯, 6", ""] {
match sum_numbers(input) {
Ok(sum) => println!("{input:?} -> 합계 {sum}"),
Err(e) => println!("{input:?} -> 에러: {e}"),
}
}
}

실행 결과:

"1, 2, 3" -> 합계 6
"10,20, 30" -> 합계 60
"4, 다섯, 6" -> 에러: invalid digit found in string
"" -> 에러: cannot parse integer from empty string
▶ 직접 실행해 보기

에러 메시지는 Rust 표준 라이브러리가 만든 거라서 영어로 나와요. invalid digit found in string은 “문자열에 숫자가 아닌 글자가 있다”, cannot parse integer from empty string은 “빈 문자열은 정수로 파싱할 수 없다”는 뜻이에요.

?는 글자 하나로 두 가지 일을 해요. parse()가 Ok(n)을 주면 n을 꺼내 주고, Err(e)를 주면 함수가 그 자리에서 Err(e)를 돌려줘요. 그래서 이 함수도 Result를 돌려줘야 해요.

보너스: Result는 .sum()과도 함께 쓸 수 있어요. 그래서 이 한 줄로도 같은 일을 하고, 첫 번째 에러에서 멈춰요.

input.split(',').map(|p| p.trim().parse::<i32>()).sum()

사용자마다 이메일이 있을 수도, 없을 수도 있어요. @ 뒤의 부분을 돌려주는 email_domain(user: &User) -> Option<&str>를 작성하세요. 이메일이 없거나, 이메일에 @가 없으면 None을 돌려줘요. 그다음 이름으로 사용자를 찾아서 도메인을 출력하세요.

힌트: user.email.as_deref()는 Option<String>을 Option<&str>로 바꿔 줘요. email.split_once('@')는 Option<(&str, &str)>를 돌려줘요. ? 연산자는 Option에도 쓸 수 있어요. Option을 참고하세요.

정답 보기
struct User {
name: String,
email: Option<String>,
}
fn email_domain(user: &User) -> Option<&str> {
let email = user.email.as_deref()?;
let (_, domain) = email.split_once('@')?;
Some(domain)
}
fn main() {
let users = vec![
User { name: String::from("Ana"), email: Some(String::from("[email protected]")) },
User { name: String::from("Ben"), email: None },
User { name: String::from("Cleo"), email: Some(String::from("cleo-at-home")) },
];
for name in ["Ana", "Ben", "Cleo", "Dan"] {
let domain = users
.iter()
.find(|u| u.name == name)
.and_then(email_domain)
.unwrap_or("(도메인 없음)");
println!("{name}: {domain}");
}
}

실행 결과:

Ana: example.com
Ben: (도메인 없음)
Cleo: (도메인 없음)
Dan: (도메인 없음)
▶ 직접 실행해 보기

여기에는 “실패”하는 경우가 세 가지 있어요. Dan은 아예 없고, Ben은 이메일이 없고, Cleo의 이메일에는 @가 없어요. 각각이 Option 단계 하나이고, ?나 .and_then()은 처음 만나는 None에서 멈춰요. if 검사도 필요 없고, 널 포인터 에러도 없어요. 맨 끝의 .unwrap_or(...)가 기본값을 정해 줘요.

소유자와 잔액을 가진 Account 구조체를 만들고, deposit(amount)와 withdraw(amount) 메서드를 넣으세요. withdraw는 Result<u64, String>을 돌려줘야 해요. 성공하면 새 잔액을, 돈이 모자라면 에러 메시지를 돌려줘요. 잔액은 절대 0보다 작아지면 안 돼요.

힌트: 계좌를 바꾸는 메서드는 &mut self를 받아요. 빼기 전에 잔액을 확인하세요. 구조체와 Result를 참고하세요.

정답 보기
struct Account {
owner: String,
balance: u64,
}
impl Account {
fn new(owner: &str) -> Account {
Account { owner: owner.to_string(), balance: 0 }
}
fn deposit(&mut self, amount: u64) {
self.balance += amount;
}
fn withdraw(&mut self, amount: u64) -> Result<u64, String> {
if amount > self.balance {
return Err(format!(
"{amount} 출금 실패: 잔액이 {}밖에 없어요",
self.balance
));
}
self.balance -= amount;
Ok(self.balance)
}
}
fn main() {
let mut account = Account::new("Ana");
account.deposit(100);
for amount in [30, 50, 40] {
match account.withdraw(amount) {
Ok(left) => println!("{amount} 출금, {left} 남음"),
Err(e) => println!("에러: {e}"),
}
}
println!("{}의 최종 잔액: {}", account.owner, account.balance);
}

실행 결과:

30 출금, 70 남음
50 출금, 20 남음
에러: 40 출금 실패: 잔액이 20밖에 없어요
Ana의 최종 잔액: 20
▶ 직접 실행해 보기

u64(부호 없는 정수)를 쓰면 안전장치가 하나 더 생겨요. 음수 잔액은 아예 담을 수가 없거든요. 확인하는 코드를 깜빡했다면, 디버그 모드에서 self.balance -= amount는 잔액이 조용히 음수가 되는 대신 “attempt to subtract with overflow”(빼기 오버플로)라는 메시지와 함께 패닉을 일으켜요.

area(&self) -> f64와 name(&self) -> String, 이렇게 두 메서드를 가진 트레이트 Shape를 정의하세요. 반지름을 가진 Circle과, 너비와 높이를 가진 Rectangle에 이 트레이트를 구현하세요. 둘을 하나의 Vec에 담고, 각각의 이름과 넓이를 소수점 둘째 자리까지 출력하세요.

힌트: 서로 다른 타입을 하나의 Vec에 담으려면 Vec<Box<dyn Shape>>를 쓰세요. 트레이트와 트레이트 객체를 참고하세요. {:.2}는 소수점 둘째 자리까지 출력해요.

정답 보기
trait Shape {
fn area(&self) -> f64;
fn name(&self) -> String;
}
struct Circle {
radius: f64,
}
struct Rectangle {
width: f64,
height: f64,
}
impl Shape for Circle {
fn area(&self) -> f64 {
std::f64::consts::PI * self.radius * self.radius
}
fn name(&self) -> String {
format!("원 (r = {})", self.radius)
}
}
impl Shape for Rectangle {
fn area(&self) -> f64 {
self.width * self.height
}
fn name(&self) -> String {
format!("직사각형 ({} x {})", self.width, self.height)
}
}
fn main() {
let shapes: Vec<Box<dyn Shape>> = vec![
Box::new(Circle { radius: 1.0 }),
Box::new(Rectangle { width: 3.0, height: 4.0 }),
Box::new(Circle { radius: 2.5 }),
];
let mut total = 0.0;
for shape in &shapes {
println!("{}: {:.2}", shape.name(), shape.area());
total += shape.area();
}
println!("전체 넓이: {total:.2}");
}

실행 결과:

원 (r = 1): 3.14
직사각형 (3 x 4): 12.00
원 (r = 2.5): 19.63
전체 넓이: 34.78
▶ 직접 실행해 보기

반복문은 지금 다루는 도형이 무엇인지 모르고, 신경 쓰지도 않아요. 각 항목이 “Shape이다”라는 것만 알고, 그래서 area()와 name()을 호출할 수 있어요. 나중에 Triangle을 추가하더라도 새 impl을 하나 쓰기만 하면 되고, 반복문은 하나도 바뀌지 않아요.

7번 문제로 돌아가서, 이번에는 비교할 수 있는 모든 타입에서 동작하게 만들어 보세요. 정수, 소수, 문자, 문자열 모두요. 시그니처는 largest<T: PartialOrd>(items: &[T]) -> Option<&T>예요.

힌트: T: PartialOrd는 “T는 <와 >를 지원한다”는 뜻이에요. 항목을 복사하거나 clone할 필요가 없도록 참조자(&T)를 돌려주세요. 제네릭을 참고하세요.

정답 보기
fn largest<T: PartialOrd>(items: &[T]) -> Option<&T> {
let mut biggest = items.first()?;
for item in items {
if item > biggest {
biggest = item;
}
}
Some(biggest)
}
fn main() {
println!("{:?}", largest(&[3, 41, 7, 12]));
println!("{:?}", largest(&[2.5, -1.0, 9.75]));
println!("{:?}", largest(&['r', 'u', 's', 't']));
println!("{:?}", largest(&["pear", "apple", "zucchini"]));
let empty: Vec<i32> = Vec::new();
println!("{:?}", largest(&empty));
}

실행 결과:

Some(41)
Some(9.75)
Some('u')
Some("zucchini")
None
▶ 직접 실행해 보기

본문은 7번 문제와 거의 같아요. 실제로 바뀐 건 <T: PartialOrd> 부분뿐이에요. 이 제약(bound)이 없으면 컴파일러는 item > biggest를 허락하지 않아요. 모든 타입을 비교할 수 있는 건 아니니까요. 문자열은 알파벳 순서로 비교되기 때문에 "zucchini"가 가장 커요.

빈 벡터에 붙인 Vec<i32>도 눈여겨보세요. 항목이 하나도 없으면 Rust가 T가 무엇인지 추측할 수 없으니, 직접 알려 줘야 해요.

손님 명단에 어떤 이름은 여러 번 적혀 있어요. 두 번 이상 나오는 이름을 하나씩만, 알파벳 순서로 돌려주는 find_repeats(words: &[&str]) -> Vec<String>을 작성하세요. 그리고 서로 다른 손님이 몇 명인지도 출력하세요.

힌트: HashSet은 이미 본 값을 기억해 둬요. insert 메서드는 값이 이미 있었으면 false를 돌려줘요. HashSet에는 정해진 순서가 없으니, 마지막에 결과를 정렬하세요.

정답 보기
use std::collections::HashSet;
fn find_repeats(words: &[&str]) -> Vec<String> {
let mut seen = HashSet::new();
let mut repeats = HashSet::new();
for &word in words {
// 단어가 이미 집합에 있었다면 insert는 false를 돌려줘요
if !seen.insert(word) {
repeats.insert(word);
}
}
let mut result: Vec<String> = repeats.into_iter().map(String::from).collect();
result.sort();
result
}
fn main() {
let guests = ["ana", "ben", "cleo", "ben", "dan", "ana", "ben"];
println!("중복: {:?}", find_repeats(&guests));
let unique: HashSet<&str> = guests.iter().copied().collect();
println!("서로 다른 손님 수: {}", unique.len());
println!("중복: {:?}", find_repeats(&["x", "y", "z"]));
}

실행 결과:

중복: ["ana", "ben"]
서로 다른 손님 수: 4
중복: []
▶ 직접 실행해 보기

집합을 왜 두 개나 쓸까요? seen은 지금까지 나온 모든 이름을 담고, repeats는 두 번째로 만난 이름을 담아요. "ben"은 세 번 나오지만, repeats가 집합이라서 한 번만 저장돼요.

HashSet으로 모으는 건 서로 다른 값의 개수를 세는 가장 빠른 방법이기도 해요. 중복은 그냥 사라지니까요. result.sort() 줄이 없으면 중복 이름의 순서가 실행할 때마다 달라질 수 있어요.

단어 목록을 짧은 것부터 긴 것 순서로 정렬하세요. 길이가 같으면 대소문자를 무시하고 A부터 Z 순서로 놓으세요. 그리고 그 결과를 그냥 .sort()한 결과와 비교해 보세요.

힌트: sort_by는 두 항목을 비교해서 Ordering을 돌려주는 클로저를 받아요. .then_with(...)는 앞의 비교가 같을 때만 쓰이는 두 번째 비교를 덧붙여요. 정렬과 검색을 참고하세요.

정답 보기
fn main() {
let mut words = vec!["pear", "Fig", "banana", "apple", "kiwi", "Cherry", "date"];
let mut plain = words.clone();
plain.sort();
println!("기본 정렬: {plain:?}");
words.sort_by(|a, b| {
a.len()
.cmp(&b.len())
.then_with(|| a.to_lowercase().cmp(&b.to_lowercase()))
});
println!("길이 기준: {words:?}");
}

실행 결과:

기본 정렬: ["Cherry", "Fig", "apple", "banana", "date", "kiwi", "pear"]
길이 기준: ["Fig", "date", "kiwi", "pear", "apple", "banana", "Cherry"]
▶ 직접 실행해 보기

이 문제의 단어는 길이(len())로 정렬하기 때문에 영어로 두었어요. 한글은 한 글자가 3바이트라서 len()의 결과가 글자 수와 달라지거든요.

기본 정렬 결과를 보세요. "Cherry"와 "Fig"가 맨 앞에 와요. 문자열은 문자 코드로 비교되는데, 대문자는 모든 소문자보다 앞에 있기 때문이에요. 그래서 동점일 때는 to_lowercase()로 바꾼 값끼리 비교해요.

같은 규칙을 더 짧게 쓰면 words.sort_by_key(|w| (w.len(), w.to_lowercase()))예요. 튜플은 필드 순서대로 비교되니까, 길이가 먼저 결정하고 동점이면 소문자로 바꾼 단어가 결정해요.

17. 매치 가드로 배송비 계산하기

섹션 제목: “17. 매치 가드로 배송비 계산하기”

아래 열거형에 대해 match를 하나만 써서 shipping_cost(p: Package) -> String을 작성하세요.

enum Package {
Letter { grams: u32 },
Parcel { kg: u32, express: bool },
Pickup,
}

규칙은 이래요. 직접 수령(pickup)은 무료예요. 편지는 20g까지 $1이고, 더 무거우면 $3이에요. 30kg이 넘는 소포는 아예 보낼 수 없어요. 특급 소포는 $10에 1kg당 $2가 붙어요. 그 밖의 소포는 $5에 1kg당 $1이 붙어요.

힌트: match 갈래(arm)에는 패턴 뒤에 if를 붙일 수 있는데, 이걸 가드(guard)라고 해요. Package::Letter { grams } if grams <= 20 => ...처럼요. 갈래는 위에서 아래로 검사되니까, “너무 무거움” 규칙을 특급 규칙보다 위에 두세요. 패턴 매칭을 참고하세요.

정답 보기
enum Package {
Letter { grams: u32 },
Parcel { kg: u32, express: bool },
Pickup,
}
fn shipping_cost(p: Package) -> String {
match p {
Package::Pickup => String::from("무료 (직접 수령)"),
Package::Letter { grams } if grams <= 20 => String::from("$1 (일반 편지)"),
Package::Letter { grams } => format!("$3 (큰 편지, {grams} g)"),
Package::Parcel { kg, .. } if kg > 30 => format!("너무 무거워서 배송 불가 ({kg} kg)"),
Package::Parcel { kg, express: true } => format!("${} (특급)", 10 + kg * 2),
Package::Parcel { kg, express: false } => format!("${} (일반 소포)", 5 + kg),
}
}
fn main() {
let packages = vec![
Package::Letter { grams: 15 },
Package::Letter { grams: 80 },
Package::Parcel { kg: 4, express: false },
Package::Parcel { kg: 4, express: true },
Package::Parcel { kg: 35, express: true },
Package::Pickup,
];
for p in packages {
println!("{}", shipping_cost(p));
}
}

실행 결과:

$1 (일반 편지)
$3 (큰 편지, 80 g)
$9 (일반 소포)
$18 (특급)
너무 무거워서 배송 불가 (35 kg)
무료 (직접 수령)
▶ 직접 실행해 보기

흥미로운 건 35kg짜리 특급 소포예요. 이 소포는 갈래 두 개에 맞지만, 첫 번째 갈래만 선택돼요. 특급 갈래를 “너무 무거움” 갈래보다 위에 두면, 보낼 수도 없는 소포에 태연하게 $80를 청구하게 돼요.

마지막 갈래에 .. 대신 express: false라고 쓴 점도 보세요. 꼭 그래야 하는 건 아니지만, 이 갈래가 무엇을 처리하는지 정확하게 드러나요. 컴파일러는 여전히 모든 경우를 다루는지 검사해요. 이 검사에서 가드는 계산에 들어가지 않기 때문에, 가드가 없는 Package::Letter { grams } 갈래가 꼭 있어야 해요.

창고 보고서에는 "사과 12"처럼 한 줄에 품목이 하나씩 있어요. 그런데 몇몇 줄은 망가져 있어요. 비어 있거나, 개수가 숫자가 아니에요. 반복자 메서드만 써서(for 반복문 없이), 파싱에 성공하고 개수가 0보다 큰 줄만 남기세요. 그 줄들을 쉼표로 구분해 이름: 개수 형식으로 출력하고, 그다음 총합을 출력하세요.

힌트: .lines()는 텍스트를 줄 단위로 나눠요. filter_map은 클로저가 돌려준 Some 값만 남기기 때문에, 거르기와 변환을 한 번에 할 수 있어요. .ok()는 Result를 Option으로 바꿔 줘요. 반복자를 참고하세요.

정답 보기
fn main() {
let report = "사과 12\n바나나 x\n배 0\n\n체리 40\n자두 7";
let in_stock: Vec<(&str, u32)> = report
.lines()
.filter_map(|line| line.split_once(' '))
.filter_map(|(name, count)| count.parse::<u32>().ok().map(|n| (name, n)))
.filter(|&(_, n)| n > 0)
.collect();
let list: Vec<String> = in_stock
.iter()
.map(|(name, n)| format!("{name}: {n}"))
.collect();
let total: u32 = in_stock.iter().map(|(_, n)| n).sum();
println!("{}", list.join(", "));
println!("총 개수: {total}");
}

실행 결과:

사과: 12, 체리: 40, 자두: 7
총 개수: 59
▶ 직접 실행해 보기

한 줄씩 따라가 보세요. 빈 줄에는 공백이 없으니 split_once가 None을 주고, 첫 번째 filter_map이 그 줄을 버려요. "바나나 x"는 잘 나뉘지만 "x"가 파싱되지 않으니 두 번째 filter_map이 버려요. "배 0"은 두 단계를 모두 통과하지만, 개수가 0이라서 filter가 걸러 내요.

Vec으로는 한 번만 collect하고, 그 벡터를 두 번 훑어요. 한 번은 텍스트를 만들 때, 한 번은 총합을 구할 때요. 반복자는 한 번만 쓸 수 있으니까, 정리된 데이터를 저장해 두는 편이 전부 두 번 파싱하는 것보다 간단해요.

18문제를 모두 풀었다면 정말 잘했어요. 실력이 확실히 늘었을 거예요. 🦀

  • Rustlings(영어): 망가진 작은 프로그램들을 터미널에서 하나씩 고쳐 나가는 공식 연습 모음이에요. 이 페이지 다음 단계로 아주 좋아요.
  • Exercism Rust 트랙(영어): 테스트가 딸린 문제가 100개쯤 있고, 사람 멘토에게 무료로 풀이 피드백을 받을 수 있어요.
  • Rust by Example(영어): 주제별로 짧고 실행 가능한 예제가 있어요. 문제를 풀다가 문법이 막힐 때 유용해요.
  • Iterator 트레이트 — 표준 라이브러리 문서(영어): filter_map, fold, zip 등 모든 반복자 메서드가 작은 예제와 함께 정리되어 있어요.
  • HashSet — 표준 라이브러리 문서(영어): union, intersection, difference를 포함한 모든 집합 메서드가 있어요.