콘텐츠로 이동

Rust 표준 라이브러리 활용 팁: 매일 쓰는 작은 기술들

부엌 서랍에는 작은 도구들이 들어 있죠. 감자칼, 병따개, 마늘 다지기 같은 것들요. 큰 칼 하나로도 모든 요리를 할 수는 있지만, 작은 도구가 있으면 더 빠르고 안전하게 할 수 있어요. Rust 표준 라이브러리에도 그런 서랍이 있어요.

이 페이지에서는 그 서랍 속을 하나씩 구경해요. 기술마다 짧게, 일상 속 비유 하나, 실행해 볼 수 있는 예제 하나, 그리고 “이럴 때 써요” 한 줄을 붙였어요. 한 번에 다 외울 필요는 없어요. 지금은 가볍게 훑어보고, 나중에 내 코드에서 “뭔가 어색한데?” 싶은 부분이 보이면 다시 찾아오세요.

&mut에서 값 꺼내기: mem::take, replace, swap

섹션 제목: “&mut에서 값 꺼내기: mem::take, replace, swap”

봉투가 씌워진 쓰레기통을 떠올려 보세요. 가득 찬 봉투를 꺼내도 되지만, 곧바로 새 봉투를 끼워야 한다는 조건이 있어요. 쓰레기통이 봉투 없이 비어 있는 순간은 없어요.

Rust에도 똑같은 규칙이 있어요. &mut self만 가지고 있을 때는 필드를 그냥 이동(move)해서 꺼낼 수 없어요. 구조체에 구멍이 생기기 때문이에요. 그렇게 하려고 하면 컴파일러가 E0507로 막아 줘요. std::mem의 함수들은 항상 무언가를 대신 남겨 두는 방법으로 이 문제를 해결해요.

  • mem::take(&mut x)는 값을 꺼내고, 그 자리에 타입의 기본값(빈 Vec, 빈 String, 0, None)을 남겨요.
  • mem::replace(&mut x, new)는 new를 넣고, 원래 있던 값을 돌려줘요.
  • Option::take()는 Option 안의 값을 꺼내고 None을 남겨요.
use std::mem;
struct Inbox {
unread: Vec<String>,
status: String,
draft: Option<String>,
}
impl Inbox {
// 안 읽은 메시지를 전부 넘겨주고, 빈 Vec을 남겨요.
fn read_all(&mut self) -> Vec<String> {
mem::take(&mut self.unread)
}
// 새 상태를 넣고, 이전 상태를 돌려받아요.
fn set_status(&mut self, new: &str) -> String {
mem::replace(&mut self.status, new.to_string())
}
// 임시 저장 글이 있으면 꺼내고, None을 남겨요.
fn send_draft(&mut self) -> Option<String> {
self.draft.take()
}
}
fn main() {
let mut inbox = Inbox {
unread: vec!["점심 같이 먹을래요?".to_string(), "청구서 #42".to_string()],
status: "온라인".to_string(),
draft: Some("6시에 봐요".to_string()),
};
let messages = inbox.read_all();
println!("읽음 {:?}, 아직 안 읽음: {:?}", messages, inbox.unread);
let old = inbox.set_status("회의 중");
println!("이전 상태 '{old}', 지금 상태 '{}'", inbox.status);
println!("보냄: {:?}", inbox.send_draft());
println!("다시 보냄: {:?}", inbox.send_draft());
}

실행 결과:

읽음 ["점심 같이 먹을래요?", "청구서 #42"], 아직 안 읽음: []
이전 상태 '온라인', 지금 상태 '회의 중'
보냄: Some("6시에 봐요")
다시 보냄: None
▶ 직접 실행해 보기

clone()이 어디에도 없다는 점을 보세요. 메시지와 이전 상태는 복사되는 게 아니라 밖으로 이동해요. 그래서 이 함수들은 안전하면서도 비용이 거의 들지 않아요.

같은 가족이 둘 더 있어요. mem::swap(&mut a, &mut b)는 두 값을 맞바꿔요. 식탁 위의 접시 두 개를 서로 바꿔 놓는 것처럼요. Option::replace(new)는 Option에 새 값을 넣고 이전 값을 돌려줘요.

use std::mem;
fn main() {
// 한쪽 목록을 처리하는 동안 다른 쪽 목록을 채워요.
let mut filling = vec!["주문 1", "주문 2"];
let mut processing: Vec<&str> = Vec::new();
mem::swap(&mut filling, &mut processing);
println!("처리 중 {processing:?}, 채우는 중 {filling:?}");
// Option::replace: 새 값을 넣고, 이전 값을 돌려받아요.
let mut now_playing = Some("노래 A");
let previous = now_playing.replace("노래 B");
println!("이전 {previous:?}, 지금 {now_playing:?}");
}

실행 결과:

처리 중 ["주문 1", "주문 2"], 채우는 중 []
이전 Some("노래 A"), 지금 Some("노래 B")
▶ 직접 실행해 보기

이럴 때 써요: &mut 접근만 있는데, 안에 든 값의 소유권이 필요할 때.

슬라이스: windows, chunks와 친구들

섹션 제목: “슬라이스: windows, chunks와 친구들”

windows(2)는 자 위를 미끄러지는 돋보기라고 생각하면 돼요. 눈금 두 개를 한 번에 보여 주고, 한 칸씩 옆으로 움직여요. chunks(3)은 달걀을 세 개짜리 상자에 담는 것과 같아요. 마지막 상자는 덜 찰 수도 있어요. chunks_exact(3)은 꽉 찬 상자만 주고, 남은 달걀은 .remainder()에 따로 모아 둬요.

이 메서드들은 모든 슬라이스에서 쓸 수 있으니, 배열과 벡터에서도 쓸 수 있어요.

fn main() {
let temps = [12, 15, 14, 19, 21];
// windows(2): 이웃한 두 값씩, 겹치면서
for pair in temps.windows(2) {
println!("{} -> {}: 변화 {}", pair[0], pair[1], pair[1] - pair[0]);
}
// chunks(3): 3개씩 묶음, 마지막 묶음은 더 작을 수 있어요
for batch in emails.chunks(3) {
println!("보내는 중: {batch:?}");
}
// chunks_exact(2): 꽉 찬 묶음만, 나머지는 remainder()에서 기다려요
let seats = [1, 2, 3, 4, 5];
let pairs = seats.chunks_exact(2);
let leftover = pairs.remainder();
for pair in pairs {
println!("짝 {pair:?}");
}
println!("남은 것: {leftover:?}");
}

실행 결과:

12 -> 15: 변화 3
15 -> 14: 변화 -1
14 -> 19: 변화 5
19 -> 21: 변화 2
짝 [1, 2]
짝 [3, 4]
남은 것: [5]
▶ 직접 실행해 보기

슬라이스 도우미 몇 개만 더 알면, 반복문 없이도 자주 하는 질문에 답할 수 있어요. split_first()는 첫 번째 항목과 나머지를 줘요. 선생님이 명단의 첫 이름을 부르고, 나머지 명단은 도우미에게 넘겨주는 것처럼요. split_last()는 같은 일을 끝에서부터 해요.

fn main() {
let command = ["git", "commit", "-m", "오타 수정"];
// split_first: 첫 번째 항목과, 나머지의 슬라이스
if let Some((program, args)) = command.split_first() {
println!("프로그램: {program}, 인자: {args:?}");
}
// split_last: 마지막 항목과, 그 앞의 전부
if let Some((message, _rest)) = command.split_last() {
println!("메시지: {message}");
}
println!("git으로 시작해요? {}", command.starts_with(&["git"]));
println!("-m이 있어요? {}", command.contains(&"-m"));
// iter().rev(): 슬라이스는 그대로 두고 거꾸로 걸어가요
let history = ["열기", "편집", "저장"];
let undo_order: Vec<_> = history.iter().rev().collect();
println!("되돌리기 순서: {undo_order:?}");
}

실행 결과:

프로그램: git, 인자: ["commit", "-m", "오타 수정"]
메시지: 오타 수정
git으로 시작해요? true
-m이 있어요? true
되돌리기 순서: ["저장", "편집", "열기"]
▶ 직접 실행해 보기

split_first와 split_last는 Option을 돌려줘요. 빈 슬라이스에는 첫 번째 항목이 없으니까요. 그래서 if let을 썼어요.

이럴 때 써요: 이웃한 값(windows), 묶음(chunks), 또는 “맨 앞 하나와 나머지”(split_first)가 필요할 때.

반복자 페이지에서 map, filter, sum, collect를 다뤘어요. 여기서는 도구 상자의 다음 칸을 열어 봐요.

카드를 나눠 준다고 상상해 보세요. 가끔은 다음 카드를 뽑기 전에 먼저 슬쩍 보고 싶을 때가 있죠. .peekable()은 그렇게 할 수 있는 반복자를 만들어 줘요. .peek()은 다음 항목을 꺼내지 않고 보여 주고, .next_if(...)는 조건을 통과할 때만 꺼내요. 작은 파서(parser)에 딱 필요한 기능이에요.

fn main() {
let input = "12+345-6";
let mut chars = input.chars().peekable();
let mut tokens: Vec<String> = Vec::new();
while let Some(c) = chars.next() {
if c.is_ascii_digit() {
let mut number = c.to_string();
// 다음 글자도 숫자일 때만 꺼내요.
while let Some(d) = chars.next_if(|next| next.is_ascii_digit()) {
number.push(d);
}
tokens.push(number);
} else {
tokens.push(c.to_string());
}
}
println!("{tokens:?}");
}

실행 결과:

["12", "+", "345", "-", "6"]
▶ 직접 실행해 보기

peekable이 없다면, 숫자를 더 찾다가 +를 읽어 버리고, 그 +는 그대로 사라져 버릴 거예요.

이럴 때 써요: 다음 항목을 보고 지금 항목을 어떻게 할지 정해야 할 때.

목록 훑기: zip, enumerate, step_by, take_while, skip_while, chain

섹션 제목: “목록 훑기: zip, enumerate, step_by, take_while, skip_while, chain”
메서드 이렇게 생각해 보세요 이럴 때 써요
zip 양쪽 이를 하나씩 맞물리는 지퍼 짝을 이루는 목록이 두 개 있을 때
enumerate 목록의 줄마다 번호 붙이기 항목과 함께 인덱스도 필요할 때
step_by(n) 계단을 두 칸(또는 n칸)씩 오르기 n번째 항목마다 하나씩 원할 때
take_while 싫어하는 맛이 나올 때까지 사탕 먹기 목록의 앞부분을, 처음 “아니요”가 나오기 전까지 원할 때
skip_while 본방송이 시작될 때까지 광고 건너뛰기 처음 “아니요”가 나온 곳부터 나머지를 원할 때
chain 기차 객차 두 칸 연결하기 목록 하나 뒤에 다른 목록을 이어 붙일 때
fn main() {
let names = ["지아", "민준", "서연"];
let scores = [88, 92, 75];
// zip은 두 목록을 짝지어 주고, enumerate는 0부터 세는 번호를 붙여요
for (i, (name, score)) in names.iter().zip(scores.iter()).enumerate() {
println!("{}. {name}: {score}", i + 1);
}
// step_by: 한 시간 중 15분마다
let quarters: Vec<u32> = (0..60).step_by(15).collect();
println!("15분 단위: {quarters:?}");
// 배열에 into_iter()를 쓰면 숫자 자체를 받아요
let queue = [3, 5, 8, 12, 4];
let small: Vec<i32> = queue.into_iter().take_while(|n| *n < 10).collect();
let rest: Vec<i32> = queue.into_iter().skip_while(|n| *n < 10).collect();
println!("처음 큰 수가 나오기 전: {small:?}, 그 뒤로: {rest:?}");
// chain: 한 목록 뒤에 다른 목록을
let morning = ["이메일", "스탠드업"];
let afternoon = ["코드 리뷰", "배포"];
let day: Vec<_> = morning.iter().chain(afternoon.iter()).collect();
println!("{day:?}");
}

실행 결과:

1. 지아: 88
2. 민준: 92
3. 서연: 75
15분 단위: [0, 15, 30, 45]
처음 큰 수가 나오기 전: [3, 5, 8], 그 뒤로: [12, 4]
["이메일", "스탠드업", "코드 리뷰", "배포"]
▶ 직접 실행해 보기

take_while은 12에서 완전히 멈춘다는 점에 주목하세요. 그 뒤의 4는 작은 수지만, 아예 검사되지 않아요. 모든 항목을 하나하나 살펴보는 filter와 다른 점이 바로 이거예요.

질문에 답하기: fold, position, find_map, partition, flat_map

섹션 제목: “질문에 답하기: fold, position, find_map, partition, flat_map”
메서드 이렇게 생각해 보세요 이럴 때 써요
sum 계산기의 더하기 버튼 그냥 합계만 필요할 때
fold 언덕을 굴러 내려가며 점점 커지는 눈덩이 결과 하나를 나만의 방식으로 만들 때
position “민준이는 몇 번 자리에 앉았지?” 처음 조건에 맞는 항목의 인덱스가 필요할 때
find_map 문이 열릴 때까지 열쇠를 하나씩 꽂아 보기 항목마다 무언가를 시도하고, 처음 성공한 결과를 원할 때
partition 빨래를 바구니 두 개에 나눠 담기 예/아니요 질문으로 목록 하나를 둘로 나눌 때
flat_map 봉지 여러 개를 탁자 하나에 쏟기 항목 하나가 여러 항목이 될 때
fn main() {
let orders = [12, 40, 7, 55, 30];
// sum: 그냥 다 더해요
let total: i32 = orders.iter().sum();
// fold: 나만의 방식으로 더해요 (여기서는 주문마다 수수료 2가 붙어요)
let with_fees = orders.iter().fold(0, |acc, price| acc + price + 2);
println!("합계 {total}, 수수료 포함 {with_fees}");
// position: 처음 조건에 맞는 항목의 인덱스
println!("50을 넘는 첫 주문의 인덱스: {:?}", orders.iter().position(|p| *p > 50));
// partition: "예"용 Vec 하나, "아니요"용 Vec 하나
let (big, small): (Vec<i32>, Vec<i32>) = orders.into_iter().partition(|p| *p >= 30);
println!("큰 주문 {big:?}, 작은 주문 {small:?}");
// find_map: 하나씩 숫자로 바꿔 보고, 처음 성공한 것을 써요
let inputs = ["abc", "", "42", "7"];
let first_number = inputs.iter().find_map(|s| s.parse::<i32>().ok());
println!("첫 번째 숫자: {first_number:?}");
// flat_map: 줄마다 여러 단어가 되고, 전부 목록 하나에 들어가요
let lines = ["우유 사기", "엄마한테 지금 전화하기"];
let words: Vec<&str> = lines.iter().flat_map(|line| line.split(' ')).collect();
println!("{words:?}");
}

실행 결과:

합계 144, 수수료 포함 154
50을 넘는 첫 주문의 인덱스: Some(3)
큰 주문 [40, 55, 30], 작은 주문 [12, 7]
첫 번째 숫자: Some(42)
["우유", "사기", "엄마한테", "지금", "전화하기"]
▶ 직접 실행해 보기

fold는 시작값(0)과, “지금까지의 결과”와 다음 항목을 받는 클로저를 인자로 받아요. 단순한 합계만 필요하다면 sum이 의도를 더 분명하게 보여 줘요. 규칙을 직접 정해야 할 때 fold를 꺼내 쓰세요.

Vec을 그 자리에서 정리하기: dedup과 retain

섹션 제목: “Vec을 그 자리에서 정리하기: dedup과 retain”

이 둘은 반복자 메서드가 아니라 Vec의 메서드지만, 같은 서랍에 넣어 둘 만해요. dedup은 같은 노래를 연달아 두 번 틀지 않는 DJ 같아요(나중에 다시 트는 건 괜찮아요). retain은 입구의 경비원이에요. 검사를 통과한 항목만 안에 남아요.

fn main() {
// dedup은 바로 옆에 붙어 있는 중복을 없애요
let mut clicks = vec!["홈", "홈", "상점", "상점", "상점", "장바구니", "홈"];
clicks.dedup();
println!("{clicks:?}");
// retain은 조건을 통과한 항목만 남겨요
let mut cart = vec![("사과", 3), ("빵", 0), ("우유", 1), ("달걀", 0)];
cart.retain(|(_, qty)| *qty > 0);
println!("{cart:?}");
}

실행 결과:

["홈", "상점", "장바구니", "홈"]
[("사과", 3), ("우유", 1)]
▶ 직접 실행해 보기

마지막 "홈"이 살아남은 건, 다른 "홈"과 바로 붙어 있지 않았기 때문이에요. 중복을 모두 없애고 싶다면, 정렬 페이지에서 보여 주는 것처럼 먼저 정렬하세요.

이럴 때 써요: 새 Vec을 만들지 않고 지금 있는 Vec을 바꾸고 싶을 때.

Option은 도착했을 수도, 아직 안 왔을 수도 있는 택배 상자 같아요. 상자를 열 때마다 match를 제대로 쓸 수도 있지만, 대부분은 한 단어짜리 도우미로 똑같은 뜻을 표현할 수 있어요. Option 페이지에서 unwrap_or와 match를 다뤘으니, 여기서는 그다음 도우미들을 소개할게요.

fn main() {
let nickname: Option<String> = None;
let age: Option<u32> = Some(17);
let width: Option<u32> = Some(3);
let height: Option<u32> = Some(4);
// unwrap_or_default: 값, 없으면 타입의 기본값 ("", 0, 빈 Vec...)
println!("별명: '{}'", nickname.unwrap_or_default());
// map_or: None이면 대체 값, 아니면 값을 변환해요
println!("{}", age.map_or("나이 모름".to_string(), |a| format!("{a}살")));
// filter: 조건을 통과할 때만 값을 남겨요
println!("성인 나이: {:?}", age.filter(|a| *a >= 18));
// zip: 둘 다 Some일 때만 Some
println!("넓이: {:?}", width.zip(height).map(|(w, h)| w * h));
// and_then: 그 자체로 None이 될 수 있는 단계를 이어 붙여요
let good = Some("42").and_then(|s| s.parse::<u32>().ok());
let bad = Some("사십이").and_then(|s| s.parse::<u32>().ok());
println!("파싱 결과: {good:?}, {bad:?}");
}

실행 결과:

별명: ''
17살
성인 나이: None
넓이: Some(12)
파싱 결과: Some(42), None
▶ 직접 실행해 보기

함수 안에서는 Option에도 ?를 쓸 수 있어요. 값이 None이면 함수가 곧바로 None을 반환해요. Result를 반환하는 함수라면, 먼저 ok_or(error)나 ok_or_else(|| error)로 Option을 Result로 바꿔 주세요. 에러를 만드는 데 일이 좀 든다면 ok_or_else를 쓰세요. 필요할 때만 실행되거든요.

struct Employee {
name: String,
manager: Option<String>,
}
// Option에 `?`: None이면 곧바로 None을 반환해요.
fn manager_initial(e: &Employee) -> Option<char> {
let manager = e.manager.as_ref()?;
manager.chars().next()
}
// ok_or_else가 None을 에러로 바꿔 주니, Result 함수에서도 `?`를 쓸 수 있어요.
fn port_from(setting: Option<&str>) -> Result<u16, String> {
let raw = setting.ok_or_else(|| "PORT가 설정되지 않았어요".to_string())?;
raw.parse::<u16>().map_err(|e| e.to_string())
}
fn main() {
let ana = Employee { name: "지아".to_string(), manager: Some("하은".to_string()) };
let grace = Employee { name: "하은".to_string(), manager: None };
println!("{}: {:?}", ana.name, manager_initial(&ana));
println!("{}: {:?}", grace.name, manager_initial(&grace));
println!("{:?}", port_from(Some("8080")));
println!("{:?}", port_from(None));
println!("{:?}", port_from(Some("팔십")));
// transpose: Option<Result<..>>가 Result<Option<..>>로 바뀌어요
let retries_setting: Option<&str> = Some("3");
let retries: Result<Option<u8>, _> = retries_setting.map(|s| s.parse::<u8>()).transpose();
println!("{retries:?}");
}

실행 결과:

지아: Some('하')
하은: None
Ok(8080)
Err("PORT가 설정되지 않았어요")
Err("invalid digit found in string")
Ok(Some(3))
▶ 직접 실행해 보기

invalid digit found in string은 Rust가 직접 만드는 에러 메시지라서 영어로 나와요. “문자열에 잘못된 숫자가 있다”는 뜻이에요. 그리고 chars().next()는 한글 이름에서도 첫 글자('하')를 제대로 꺼내 줘요.

transpose는 자주 쓰이진 않지만, “설정값이 있을 수도 없을 수도 있고, 파싱이 실패할 수도 있다”는 상황에 딱 맞는 깔끔한 답이에요. 이걸 쓰면 결과에 ?를 붙이면서도 Option은 그대로 유지할 수 있어요.

이럴 때 써요: Option에 대한 match에서 의미 있는 갈래는 하나뿐이고, 나머지 하나는 뻔할 때.

외투 보관소를 생각해 보세요. 이름을 말하면, 직원이 그 이름의 옷걸이를 찾거나 새로 하나 걸어 두고 건네줘요. 두 번이 아니라 한 번에 끝나요. 이게 .entry(key)예요. 해시맵 페이지에서는 대표적인 카운터인 entry(key).or_insert(value)를 보여 줬어요. 알아 두면 좋은 변형이 두 가지 더 있어요.

  • or_insert_with(f)는 키가 없을 때만 기본값을 만들어요. or_default()는 같은 일을 타입의 기본값으로 해요.
  • and_modify(f)는 키가 있으면 값을 바꿔요. 뒤에 or_insert를 이어 붙이면 “새 키”인 경우도 처리할 수 있어요.
use std::collections::HashMap;
fn main() {
let sales = [("사과", 3), ("배", 2), ("사과", 5), ("자두", 1), ("배", 4)];
// and_modify + or_insert: 있으면 갱신, 없으면 추가
let mut totals: HashMap<&str, i32> = HashMap::new();
for (fruit, qty) in sales {
totals.entry(fruit).and_modify(|t| *t += qty).or_insert(qty);
}
// or_insert_with: 빈 Vec은 필요할 때만 만들어요
let mut orders: HashMap<&str, Vec<i32>> = HashMap::new();
for (fruit, qty) in sales {
orders.entry(fruit).or_insert_with(Vec::new).push(qty);
}
// HashMap의 순서는 무작위라서, 출력하기 전에 키를 정렬해요
let mut fruits: Vec<&str> = totals.keys().copied().collect();
fruits.sort();
for fruit in fruits {
println!("{fruit}: 합계 {}, 주문 {:?}", totals[fruit], orders[fruit]);
}
}

실행 결과:

배: 합계 6, 주문 [2, 4]
사과: 합계 8, 주문 [3, 5]
자두: 합계 1, 주문 [1]
▶ 직접 실행해 보기

한글 문자열은 가나다순으로 정렬돼요. 그래서 배, 사과, 자두 순서로 나와요.

이럴 때 써요: “키가 있으면 바꾸고, 없으면 넣기”를 직접 쓰려는 순간. 그렇게 쓰면 키를 두 번 찾게 되거든요.

never 타입 !: 절대 돌아오지 않는 코드

섹션 제목: “never 타입 !: 절대 돌아오지 않는 코드”

일방통행 길처럼, 한번 들어가면 다시는 나오지 못하는 길이 있어요. Rust에서 끝나지 않는 표현식은 never 타입을 가지며, !로 써요. panic!, return, break, continue, 끝없는 loop, 그리고 std::process::exit가 모두 이 타입이에요.

! 값은 실제로 존재할 수 없기 때문에, 컴파일러는 이 값이 어떤 타입의 자리에든 들어갈 수 있게 해 줘요. 그래서 match의 한 갈래는 i32를 주고, 다른 갈래는 return을 해도 괜찮아요. 두 번째 갈래는 값을 만들어 내지 않으니, 타입을 깨뜨릴 일이 없거든요.

use std::process;
// `-> !`는 "이 함수는 절대 반환하지 않아요"라는 뜻
fn quit(message: &str) -> ! {
println!("{message}");
process::exit(0)
}
fn level_points(level: &str) -> u32 {
let points: u32 = match level {
"쉬움" => 10,
"어려움" => 30,
_ => return 0, // `return`은 `!` 타입이라서, u32가 와야 할 자리에 들어갈 수 있어요
};
points * 2
}
fn main() {
println!("쉬움: {}, 어려움: {}, 이상한 값: {}", level_points("쉬움"), level_points("어려움"), level_points("???"));
let mut total = 0;
for input in ["4", "앗", "6"] {
let n: i32 = match input.parse() {
Ok(n) => n,
Err(_) => continue, // `continue`도 `!`예요
};
total += n;
}
println!("합계: {total}");
let config_file: Option<&str> = None;
let config = match config_file {
Some(path) => path,
None => quit("설정 파일이 없어서 멈춰요"),
};
println!("불러옴: {config}"); // 출력되지 않아요
}

실행 결과:

쉬움: 20, 어려움: 60, 이상한 값: 0
합계: 10
설정 파일이 없어서 멈춰요
▶ 직접 실행해 보기

주의할 점이 하나 있어요. process::exit는 프로그램을 즉시 멈추고, 아직 살아 있는 값들의 정리 코드(소멸자, destructor)를 실행하지 않아요. 실제 프로그램에서는 보통 main에서 그냥 반환하는 쪽이 더 부드러운 방법이에요.

코드의 뼈대를 잡는 동안 !를 자리 표시자로 쓰는 매크로가 두 개 있어요. todo!()는 “나중에 쓸게요”, unimplemented!()는 “여기서는 지원하지 않아요”라는 뜻이에요. 둘 다 실제로 실행되면 패닉하지만, 나머지 프로그램은 컴파일되게 해 줘요.

fn export_pdf(report: &str) -> Vec<u8> {
todo!("PDF 내보내기는 v2에서 지원해요")
}

이 함수를 호출하면 not yet implemented: PDF 내보내기는 v2에서 지원해요가 나와요. unimplemented!("PDF 내보내기")라면 메시지는 not implemented: PDF 내보내기예요. 앞쪽의 영어 부분은 Rust가 직접 붙이는 문구예요.

이럴 때 써요: 함수가 프로그램을 멈춰야 하거나, 어떤 갈래가 값을 주는 대신 일찍 빠져나가야 할 때.

주머니 속 공구 같은 것들이에요. 아주 작지만 매주 쓰게 될 거예요.

  • matches!(value, pattern)은 “이 값이 패턴에 맞나요?“에 true나 false로 답해요(자세한 건 패턴 매칭 페이지에서).
  • std::cmp::min / max는 두 값 중 작은 쪽이나 큰 쪽을 골라 주고, .clamp(low, high)는 값을 범위 안에 묶어 둬요. 100에서 더 돌아가지 않는 볼륨 다이얼처럼요.
  • .abs_diff(other)는 두 수 사이의 거리를 줘요. 항상 양수이고, 부호 없는 타입에서도 절대 오버플로가 나지 않아요.
  • dbg!(expr)는 파일, 줄, 표현식, 값을 출력한 다음 그 값을 그대로 돌려줘요. 그래서 어떤 표현식이든 감쌀 수 있어요.
  • assert!(condition)은 조건이 거짓이면 패닉해요. debug_assert!도 같은 일을 하지만 디버그 빌드에서만 동작해서, 릴리스 빌드에서는 비용이 전혀 없어요.
use std::cmp::{max, min};
enum Order {
Pending,
Shipped,
Delivered,
Cancelled,
}
fn main() {
let orders = [Order::Pending, Order::Delivered, Order::Shipped, Order::Cancelled, Order::Pending];
// matches!: 예/아니요 패턴 검사, |는 "또는"
let open = orders.iter().filter(|o| matches!(o, Order::Pending | Order::Shipped)).count();
println!("진행 중인 주문: {open}");
// min, max, clamp
println!("최솟값 {}, 최댓값 {}", min(7, 3), max(7, 3));
let volume = 140;
let safe = volume.clamp(0, 100);
println!("볼륨 {volume}, 제한 후 {safe}");
// abs_diff: 얼마나 떨어져 있는지, 절대 음수가 아니에요
let (guess, secret): (u32, u32) = (30, 42);
println!("정답까지 {} 차이 나요", guess.abs_diff(secret));
// dbg!는 stderr에 출력하고 값을 그대로 돌려줘요
let doubled = dbg!(safe * 2);
// assert!는 이 조건이 거짓이 되면 프로그램을 멈춰요
assert!(doubled <= 200, "두 배 볼륨이 너무 커요: {doubled}");
debug_assert!(safe <= 100); // 디버그 빌드에서만 검사해요
println!("모든 검사 통과");
}

실행 결과:

진행 중인 주문: 3
최솟값 3, 최댓값 7
볼륨 140, 제한 후 100
정답까지 12 차이 나요
모든 검사 통과
▶ 직접 실행해 보기

위의 실행 결과에 dbg! 줄이 없는 이유는, dbg!가 stdout이 아니라 stderr에 출력하기 때문이에요. Playground에서는 “Standard Error” 칸에 나타나요.

[src/main.rs:28:19] safe * 2 = 200

그래서 디버깅하는 동안 dbg!를 여기저기 뿌려 둬도 괜찮아요. 프로그램의 진짜 출력에 섞이지 않으니까요. 다만 배포하기 전에는 꼭 지워 주세요.

정렬에도 작은 도구들이 있어요. 자세한 건 정렬 페이지에서 다루고, 여기서는 짧게만 볼게요. sort_unstable은 sort보다 조금 빠르고, 단순한 숫자라면 이걸로 충분해요. sort_by_key는 필드 하나를 기준으로 정렬해요. binary_search는 정렬된 데이터에서 항목을 아주 빨리 찾아요. 사전을 첫 페이지부터 읽는 대신 가운데쯤을 펼쳐서 찾는 것처럼요.

struct Player {
name: &'static str,
score: u32,
}
fn main() {
// sort_unstable: 조금 더 빠르지만, 같은 값끼리는 자리가 바뀔 수 있어요
let mut ids = vec![42, 7, 19, 3, 25];
ids.sort_unstable();
println!("{ids:?}");
// binary_search: 찾으면 Ok(인덱스), 없으면 들어갈 자리 Err(인덱스)
println!("19 찾기: {:?}", ids.binary_search(&19));
println!("20 찾기: {:?}", ids.binary_search(&20));
// sort_by_key: 필드 하나를 기준으로 정렬
let mut players = vec![
Player { name: "지아", score: 120 },
Player { name: "민준", score: 95 },
Player { name: "서연", score: 150 },
];
players.sort_by_key(|p| p.score);
let names: Vec<&str> = players.iter().map(|p| p.name).collect();
println!("낮은 점수부터: {names:?}");
}

실행 결과:

[3, 7, 19, 25, 42]
19 찾기: Ok(2)
20 찾기: Err(3)
낮은 점수부터: ["민준", "지아", "서연"]
▶ 직접 실행해 보기

Err(3)은 걱정할 실패가 아니에요. 20이 목록에 없다는 것, 그리고 목록을 정렬된 상태로 유지하려면 인덱스 3에 넣으면 된다는 것을 알려 주는 거예요.

이럴 때 써요: 이 도구들이 이미 해 주는 일을 작은 if나 반복문으로 직접 쓰고 있는 자신을 발견했을 때.

Default가 없는 타입에 mem::take 쓰기. mem::take는 그 자리에 무언가를 남겨야 하니까, 그 타입에서 “비어 있다”는 게 무엇인지 알아야 해요.

use std::mem;
struct Connection {
host: String,
}
struct Pool {
active: Connection,
}
impl Pool {
fn reset(&mut self) -> Connection {
mem::take(&mut self.active) // ❌ "비어 있는" Connection이 뭘까요?
}
}
error[E0277]: the trait bound `Connection: Default` is not satisfied
--> src/main.rs:13:19
|
13 | mem::take(&mut self.active) // ❌ "비어 있는" Connection이 뭘까요?
| --------- ^^^^^^^^^^^^^^^^ the trait `Default` is not implemented for `Connection`
| |
| required by a bound introduced by this call
|
help: consider annotating `Connection` with `#[derive(Default)]`

컴파일러는 “Connection이 Default 트레이트를 구현하지 않았다”고 알려 주고, #[derive(Default)]를 붙여 보라고 제안해요. 고치는 방법은 mem::replace(&mut self.active, Connection { host: "db2".to_string() })로 대신 넣을 값을 직접 정하거나, 필드를 Option<Connection>으로 저장하고 self.active.take()를 쓰는 거예요. #[derive(Default)]는 그 타입에 “빈” 값이 정말 말이 될 때만 붙이세요.

Result를 반환하는 함수에서 Option에 ? 쓰기. 둘은 자동으로 섞이지 않아요.

fn port_from(setting: Option<&str>) -> Result<u16, String> {
let raw = setting?; // ❌ None에는 돌려줄 에러 메시지가 없어요
raw.parse::<u16>().map_err(|e| e.to_string())
}
error[E0277]: the `?` operator can only be used on `Result`s, not `Option`s, in a function that returns `Result`
--> src/main.rs:2:22
|
1 | fn port_from(setting: Option<&str>) -> Result<u16, String> {
| ---------------------------------------------------------- this function returns a `Result`
2 | let raw = setting?; // ❌ None에는 돌려줄 에러 메시지가 없어요
| ^ use `.ok_or(...)?` to provide an error compatible with `Result<u16, String>`

“Result를 반환하는 함수에서는 Option에 ?를 쓸 수 없다”는 뜻이에요. 고치는 방법도 컴파일러가 이미 알려 주고 있어요. setting.ok_or("PORT가 설정되지 않았어요".to_string())?라고 쓰거나, 위 예제처럼 ok_or_else를 쓰면 돼요. 이 에러의 다른 형태는 E0277에서 볼 수 있어요.

정렬되지 않은 데이터에 dedup이나 binary_search를 기대하기. dedup은 바로 옆에 붙은 중복만 없애고, 정렬되지 않은 데이터에 binary_search를 쓰면 의미 없는 답이 나와요. 둘 다 컴파일 에러가 아니라서 놓치기 쉬워요. 먼저 정렬하세요.

chunks_exact의 남은 항목 잊어버리기. chunks_exact(3)을 for 반복문으로 돌리면, 마지막 1~2개 항목은 조용히 건너뛰어요. 그 항목들이 중요하다면 .remainder()를 처리하거나, 대신 chunks를 쓰세요.

가격 [100, 104, 98, 110, 107]이 있어요. 하루에서 다음 날로 넘어갈 때의 변화를 담은 Vec을 만들어 출력하고, 가장 크게 오른 값을 출력해 보세요. 힌트: windows(2)는 두 값을 작은 슬라이스 w로 주니까, 변화는 w[1] - w[0]이에요. 가장 큰 값은 .iter().max()로 찾을 수 있어요.

정답 보기
fn main() {
let prices = [100, 104, 98, 110, 107];
let changes: Vec<i32> = prices.windows(2).map(|w| w[1] - w[0]).collect();
println!("변화: {changes:?}");
let biggest_rise = changes.iter().max();
println!("가장 큰 상승: {biggest_rise:?}");
}

실행 결과:

변화: [4, -6, 12, -3]
가장 큰 상승: Some(12)
▶ 직접 실행해 보기

가격이 다섯 개면 변화는 네 개예요. windows(2)는 한 칸 움직일 때마다 짝을 하나씩 만들기 때문이에요. max()가 Option을 반환하는 건, 빈 목록에는 가장 큰 값이 없기 때문이에요.

queue: Vec<String> 필드를 가진 Printer 구조체를 만들어 보세요. add(&mut self, file: &str) 메서드와, 대기 중인 파일을 모두 넘겨주고 대기열을 비우는 flush(&mut self) -> Vec<String> 메서드를 만들어요. 파일 두 개를 추가하고 flush한 다음, 무엇을 인쇄했는지와 남은 파일이 몇 개인지 출력하세요. 힌트: flush에는 &mut self밖에 없으니, std::mem::take를 쓰세요.

정답 보기
use std::mem;
struct Printer {
queue: Vec<String>,
}
impl Printer {
fn add(&mut self, file: &str) {
self.queue.push(file.to_string());
}
fn flush(&mut self) -> Vec<String> {
mem::take(&mut self.queue)
}
}
fn main() {
let mut printer = Printer { queue: Vec::new() };
printer.add("report.pdf");
printer.add("photo.png");
let jobs = printer.flush();
println!("작업 {}개 인쇄 중: {jobs:?}", jobs.len());
println!("대기열에 남은 작업: {}", printer.queue.len());
}

실행 결과:

작업 2개 인쇄 중: ["report.pdf", "photo.png"]
대기열에 남은 작업: 0
▶ 직접 실행해 보기

flush에서 self.queue만 그대로 돌려주려고 하면 E0507 에러가 나요. mem::take는 Vec을 밖으로 옮기고 빈 Vec을 남겨 두니, 복사가 전혀 일어나지 않아요.

u16으로 파싱되는 첫 번째 문자열을 돌려주는 함수 first_port(candidates: &[&str]) -> Option<u16>을 만들어 보세요. ["", "abc", "70000", "8080", "9090"]과 ["none", "-1"]로 각각 호출하고, 찾은 포트를 출력하세요. 없으면 3000을 출력해요. 힌트: s.parse::<u16>().ok()와 함께 find_map을 쓰고, 그다음 unwrap_or(3000)을 붙이세요.

정답 보기
fn first_port(candidates: &[&str]) -> Option<u16> {
candidates.iter().find_map(|s| s.parse::<u16>().ok())
}
fn main() {
let from_config = ["", "abc", "70000", "8080", "9090"];
let all_bad = ["none", "-1"];
println!("사용할 포트: {}", first_port(&from_config).unwrap_or(3000));
println!("사용할 포트: {}", first_port(&all_bad).unwrap_or(3000));
}

실행 결과:

사용할 포트: 8080
사용할 포트: 3000
▶ 직접 실행해 보기

"70000"은 숫자처럼 보이지만 u16에 들어가지 않아요(가장 큰 값이 65535예요). 그래서 파싱이 실패하고 find_map은 다음으로 넘어가요. "8080"에서 멈추고, "9090"은 아예 보지도 않아요.

  • mem::take, mem::replace, Option::take는 그 자리에 무언가를 남겨 두는 방식으로 &mut에서 값을 꺼내요. mem::swap은 두 값을 맞바꿔요.
  • 슬라이스에서 windows는 겹치는 이웃을, chunks는 묶음을, split_first / split_last는 “항목 하나와 나머지”를 줘요.
  • peekable로는 앞을 미리 볼 수 있고, zip, enumerate, chain, fold, find_map, partition은 손으로 쓰던 반복문 상당수를 대신해요.
  • unwrap_or_default, map_or, filter, ok_or 같은 Option 도우미 덕분에 작은 match를 일일이 쓰지 않아도 되고, ?는 Option에서도 동작해요.
  • entry()에 or_insert_with나 and_modify를 붙이면 한 번의 조회로 HashMap을 갱신할 수 있어요.
  • never 타입 !는 끝나지 않는 코드(panic!, return, continue, process::exit)의 타입이라서, 값이 와야 할 어떤 자리에든 들어갈 수 있어요.

이해도 점검

1. items가 Vec일 때, mem::take(&mut self.items)는 그 자리에 무엇을 남길까요?

2. [1, 2, 3, 4].windows(2)는 무엇을 줄까요?

3. let x: i32 = match s { "a" => 1, _ => return };가 컴파일되는 이유는 무엇일까요?

4. 함수는 Result를 반환하는데, 가진 건 Option이에요. ? 앞에 무엇을 붙여야 할까요?

점수: 0 / 4