Rust · 기본
소유권부터 배우는 Rust
제어 흐름 - 식으로 쓰는 if 와 loop
if 식, loop 값 반환, while, for 와 범위, 레이블
개발자KR · 원고 갱신
이 장에서 배우는 것
앞 장에서는 값을 변수에 묶는 방법과 불변이 기본이라는 규칙을 다뤘다. 이 장에서는 프로그램의 흐름을 정하는 문법을 익힌다. 다른 언어에서 if 와 반복문을 써 봤다면 낯익은 모양이겠지만, Rust 에서는 이들 상당수가 값을 돌려주는 식(expression)이다. 이 차이를 알면 변수를 가변으로 선언하고 나중에 채우는 코드가 줄고, 불변 변수를 쓰는 쪽으로 자연스럽게 기운다.
- if 가 값을 돌려주는 식이라는 점을 이해하고
let오른쪽에 쓸 수 있다. loop에서break로 값을 꺼내는 방법을 안다.while과for의 쓰임을 구분하고, 범위1..4와1..=4의 차이를 정확히 말할 수 있다.- 레이블(label)로 중첩 반복문을 한 번에 빠져나온다.
문제 상황
가계부 도구에 기록이 쌓였다고 하자. 기록 하나는 분류 이름과 금액이다. 수입은 양수, 지출은 음수로 적는다. 이제 다음 일을 시켜야 한다.
- 수입 합계와 지출 합계를 구하고, 잔액이 0 이상이면 "흑자", 아니면 "적자"라는 글자를 붙인다.
- 분류별 지출 합계를 낸다.
- 기록을 앞에서부터 읽다가 지출 누계가 한도를 넘는 순간 멈춘다.
- 매달 잔액을 모으면 목표 금액에 몇 개월 만에 닿는지 센다.
- 분류와 기록을 이중으로 훑다가 큰 지출을 처음 발견하면 두 겹의 반복을 모두 끝낸다.
C 나 Java 스타일로 쓰면 결과를 담을 변수를 먼저 만들고 분기마다 값을 대입하게 된다. Rust 에서도 그렇게 쓸 수는 있지만, 분기와 반복이 값을 직접 돌려주므로 훨씬 짧고 안전하게 쓸 수 있다.
if 는 문장이 아니라 식이다
식은 계산하면 값이 나오는 코드 조각이고, 문(statement)은 동작만 하고 값을 내놓지 않는 코드 조각이다. Rust 의 if 는 식이다. 각 갈래 블록의 마지막 식이 그 갈래의 값이 된다. 마지막 식 뒤에는 세미콜론을 붙이지 않는다.
let balance = 2_416_600;
let status = if balance >= 0 { "흑자" } else { "적자" };
변수 status 는 한 번 정해지면 바뀌지 않으므로 mut 없이 선언한다. 앞 장에서 배운 불변 기본 원칙과 잘 맞는다.
조건은 반드시 bool 이다
조건식은 bool 타입이어야 한다. 0 이나 빈 값을 거짓으로 취급하는 암묵적 변환은 없다. 다음 코드는 컴파일되지 않는다.
let balance: i64 = 100;
if balance {
println!("잔액이 있다");
}
컴파일러는 error[E0308]: mismatched types 를 내고, bool 이 필요한 자리에 i64 가 왔다고 알려 준다. if balance != 0 처럼 비교 결과를 bool 로 만들어 쓰면 된다. 조건에는 괄호를 씌우지 않는 것이 관례다.
갈래의 타입은 같아야 한다
if 가 값을 돌려주려면 모든 갈래가 같은 타입이어야 한다. 한쪽이 글자이고 다른 쪽이 숫자이면 변수의 타입을 정할 수 없다.
let flag = true;
let x = if flag { 1 } else { "없음" };
이 코드는 error[E0308]: `if` and `else` have incompatible types 로 막힌다. 또 값으로 쓰는 if 에는 else 가 있어야 한다. else 가 없으면 조건이 거짓일 때 줄 값이 없기 때문이다. 조건이 여러 개이면 else if 를 이어 붙인다.
반복: loop, while, for
Rust 의 반복문은 세 가지다. 무엇을 기준으로 멈추는지가 서로 다르다.
| 구문 | 멈추는 기준 | break 로 값 반환 | 주로 쓰는 때 |
|---|---|---|---|
loop | 안에서 break 를 만날 때 | 가능 | 몇 번 돌지 미리 모를 때 |
while | 조건이 거짓이 될 때 | 불가능 | 상태를 보며 돌 때 |
for | 순회할 값이 다 떨어질 때 | 불가능 | 범위나 묶음을 훑을 때 |
loop 는 값을 돌려줄 수 있다
loop 는 무한히 도는 반복문이다. 끝내려면 break 가 필요하다. break 뒤에 식을 쓰면 그 값이 loop 전체의 값이 된다.
let mut saved = 0;
let mut months = 0;
let months_needed = loop {
saved += 2_416_600;
months += 1;
if saved >= 10_000_000 {
break months;
}
};
끝의 세미콜론은 let 문을 끝내는 것이다. break 로 값을 내보내는 것은 loop 에서만 된다. while 이나 for 에서 break 값 을 쓰면 컴파일러가 거부한다(break with value from a while loop). 조건이 끝내 참이 되지 않으면 loop 는 끝나지 않으므로, 매달 모으는 금액이 0 이하일 수 있는 상황에서는 이 방식을 그대로 쓰면 안 된다.
while 은 조건을 본다
while 조건 { … } 은 반복 전에 조건을 검사하고, 참인 동안 돈다. 조건에 쓰인 변수를 본문 안에서 바꾸지 않으면 영원히 끝나지 않는 코드가 된다. 이 장의 예제에서는 지출 누계가 한도를 넘거나 기록이 끝날 때까지 읽는 데 쓴다.
for 와 범위
for 변수 in 값들 { … } 는 값들을 하나씩 꺼내 변수에 묶고 본문을 실행한다. 정해진 횟수만큼 돌 때는 범위(range)를 쓴다.
| 표기 | 포함하는 값 | 예 | 원소 수 |
|---|---|---|---|
1..4 | 1 이상 4 미만 | 1, 2, 3 | 3 |
1..=4 | 1 이상 4 이하 | 1, 2, 3, 4 | 4 |
(1..=4).rev() | 거꾸로 | 4, 3, 2, 1 | 4 |
(0..10).step_by(5) | 5 칸씩 건너뜀 | 0, 5 | 2 |
배열도 for 로 훑을 수 있다. 예제의 기록은 (&str, i64) 짝을 원소로 갖는 배열이며, for (name, amount) in records 처럼 짝을 바로 풀어서 받는다. 여기서 &str 은 큰따옴표 글자 상수의 타입이라고만 알아 두자. 배열 원소가 가진 값은 모두 복사되는 종류라서, 같은 배열을 여러 번 훑어도 문제가 없다. 값이 복사되지 않는 경우는 다음 장의 소유권에서 다룬다.
레이블로 바깥 반복문 제어하기
break 와 continue 는 기본적으로 가장 안쪽 반복문에만 작용한다. 두 겹 이상을 한 번에 다루려면 반복문 앞에 작은따옴표로 시작하는 이름표, 곧 레이블(label)을 붙이고 break '이름 으로 그 반복문을 지정한다.
'search: for category in categories {
for (name, amount) in records {
if name == category && -amount >= 40_000 {
break 'search;
}
}
}
continue 'search 는 바깥 반복문의 다음 회차로 곧장 넘어간다. 레이블은 loop 에도 붙일 수 있고, break 'outer 값 처럼 값을 실어 보낼 수도 있다. 다만 레이블이 필요한 코드는 읽기 어려워지기 쉬우니, 중첩이 세 겹 이상이면 구조를 다시 생각해 보는 편이 낫다.
완성 코드
cargo new ledger 로 만든 프로젝트의 src/main.rs 를 아래 내용으로 바꾼다.
fn main() {
let records: [(&str, i64); 7] = [
("월급", 2_500_000),
("식비", -12_000),
("교통", -1_450),
("식비", -8_500),
("교통", -1_450),
("취미", -45_000),
("식비", -15_000),
];
let categories = ["식비", "교통", "취미"];
let mut income = 0;
let mut expense = 0;
for i in 0..records.len() {
let (_, amount) = records[i];
if amount >= 0 {
income += amount;
} else {
expense += -amount;
}
}
let balance = income - expense;
let status = if balance >= 0 { "흑자" } else { "적자" };
println!("수입 {income}원, 지출 {expense}원, 잔액 {balance}원 ({status})");
for category in categories {
let mut total = 0;
for (name, amount) in records {
if name == category {
total += -amount;
}
}
println!("{category}: {total}원");
}
for week in 1..=4 {
let cumulative = 100_000 * week;
println!("{week}주차 누적 예산 {cumulative}원");
}
let limit = 20_000;
let mut spent = 0;
let mut count = 0;
while count < records.len() && spent <= limit {
let (_, amount) = records[count];
if amount < 0 {
spent += -amount;
}
count += 1;
}
let verdict = if spent > limit { "한도 초과" } else { "한도 이내" };
println!("{count}번째 기록까지 읽었을 때 지출 누계 {spent}원 ({verdict})");
let goal = 10_000_000;
let mut saved = 0;
let mut months = 0;
let months_needed = loop {
saved += balance;
months += 1;
if saved >= goal {
break months;
}
};
println!("잔액을 매달 저축하면 {months_needed}개월 만에 {goal}원에 도달한다");
let big = 40_000;
let mut found = "없음";
'search: for category in categories {
for (name, amount) in records {
if name == category && -amount >= big {
found = category;
break 'search;
}
}
}
println!("{big}원 이상 지출이 처음 나온 분류: {found}");
}
줄별 해설
기록 배열. [(&str, i64); 7] 은 "글자와 64비트 정수의 짝 7개"라는 타입이다. 금액은 원 단위 정수로 두고, 지출은 음수로 적는다. 숫자 사이의 밑줄은 자릿수를 읽기 쉽게 하려는 표기일 뿐이다.
수입·지출 합산. 0..records.len() 은 0 부터 6 까지의 인덱스를 만든다. let (_, amount) = records[i]; 는 짝의 첫 값을 _ 로 버리고 금액만 받는다. if 는 여기서 값을 쓰지 않고 동작만 하는 문으로 쓰였다. income 과 expense 는 처음에 정수 0 으로 두지만, amount 와 더해지므로 컴파일러가 i64 로 추론한다.
잔액과 상태. status 는 if 식의 값을 그대로 받는다. 이후 어디에서도 바뀌지 않으므로 mut 가 없다. 출력 문자열의 {income} 처럼 중괄호 안에 변수 이름을 쓰면 그 값이 들어간다.
분류별 합계. 바깥 for 는 분류 이름을 하나씩 꺼내고, 안쪽 for 는 기록 전체를 훑으며 이름이 같은 지출만 더한다. total 은 분류마다 새로 0 에서 시작하므로 바깥 반복 안에서 선언한다. 지출은 음수이므로 -amount 로 부호를 뒤집어 더한다.
주차별 예산. 1..=4 는 4 를 포함하므로 4주차까지 나온다. week 는 반복 때마다 새로 묶이는 변수라서 mut 없이 쓴다.
while. 조건은 "아직 읽을 기록이 남았고, 누계가 한도를 넘지 않았다"이다. count < records.len() 을 먼저 검사해야 인덱스가 범위를 벗어나지 않는다. && 는 왼쪽이 거짓이면 오른쪽을 검사하지 않는다. 본문 끝의 count += 1 이 조건에 쓰인 변수를 바꾸므로 언젠가 멈춘다. 멈춘 뒤에는 if 식으로 한도 초과 여부를 글자로 바꾼다.
loop. 잔액을 매달 저축한다고 가정하고 목표에 닿을 때까지 돈다. break months; 의 값이 months_needed 가 된다. 이 코드는 balance 가 양수일 때만 끝난다는 전제 위에 있다.
레이블. found 는 찾지 못했을 때를 대비해 "없음"으로 시작한다. 조건이 참이 되면 found 에 분류를 넣고 break 'search; 로 두 겹을 함께 벗어난다. 이 코드에서는 레이블 없이 break 만 쓰면 안쪽 반복만 끝나고 다음 분류를 계속 검사한다.
실행 결과
cargo 가 stderr 에 내는 컴파일 진행 메시지는 생략하고 프로그램의 출력만 적는다. 경고는 나오지 않는다.
$ cargo run
수입 2500000원, 지출 83400원, 잔액 2416600원 (흑자)
식비: 35500원
교통: 2900원
취미: 45000원
1주차 누적 예산 100000원
2주차 누적 예산 200000원
3주차 누적 예산 300000원
4주차 누적 예산 400000원
4번째 기록까지 읽었을 때 지출 누계 21950원 (한도 초과)
잔액을 매달 저축하면 5개월 만에 10000000원에 도달한다
40000원 이상 지출이 처음 나온 분류: 취미
실무에서 자주 틀리는 것
1. 갈래 끝에 세미콜론을 붙인다
세미콜론은 식을 문으로 바꾸고 값을 버린다. 갈래 마지막에 붙이면 if 의 값이 빈 값 () 이 된다.
let status = if balance >= 0 { "흑자"; } else { "적자"; };
println!("{status}");
() 는 Display 를 구현하지 않으므로 error[E0277]: `()` doesn't implement `std::fmt::Display` 가 나온다. 세미콜론을 뺀다.
let status = if balance >= 0 { "흑자" } else { "적자" };
println!("{status}");
2. 범위의 끝값이 포함된다고 생각한다
한 달을 4주로 나눠 출력하려는데 3주차까지만 나온다.
for week in 1..4 {
println!("{week}주차");
}
1..4 는 4 를 포함하지 않는다. 끝값까지 필요하면 1..=4 로 쓴다.
for week in 1..=4 {
println!("{week}주차");
}
3. 안쪽 break 로 바깥 반복까지 끝냈다고 착각한다
for category in categories {
for (name, amount) in records {
if name == category && -amount >= 40_000 {
found = category;
break;
}
}
}
이 break 는 안쪽 for 만 끝낸다. 이후 분류에서 조건이 또 참이 되면 found 가 덮어 씌워져, "처음 나온 분류"가 아니라 "마지막으로 나온 분류"가 된다. 바깥 반복문에 레이블을 붙이고 지정한다.
'search: for category in categories {
for (name, amount) in records {
if name == category && -amount >= 40_000 {
found = category;
break 'search;
}
}
}
4. while 안에서 조건 변수를 갱신하지 않는다
let mut count = 0;
while count < records.len() {
let (_, amount) = records[count];
println!("{amount}");
}
count 가 바뀌지 않아 같은 줄을 끝없이 출력한다. 터미널에서 Ctrl+C 로 멈춘다. 본문 끝에 count += 1; 을 넣거나, 인덱스가 필요 없다면 for 로 바꾼다. 이 경우에는 for (_, amount) in records 가 더 안전하다.
한눈에 보기
| 문법 | 의미 | 기억할 점 |
|---|---|---|
if 조건 { a } else { b } | 조건에 따라 a 또는 b 를 값으로 돌려줌 | 조건은 bool, 갈래 타입은 같아야 함 |
loop { break 값; } | 무한 반복 후 값을 꺼냄 | 값 반환은 loop 에서만 가능 |
while 조건 { … } | 조건이 참인 동안 반복 | 조건 변수를 본문에서 갱신 |
for x in 1..=4 | 범위나 배열을 하나씩 순회 | .. 는 끝 제외, ..= 는 끝 포함 |
'이름: for … { break '이름; } | 지정한 바깥 반복문을 끝냄 | 없으면 가장 안쪽만 영향받음 |
연습 문제
- 잔액
balance가 0 보다 크면 "흑자", 0 이면 "본전", 0 보다 작으면 "적자"가 되는status를else if를 사용한 한 개의let문으로 작성하시오. - 다음 두 반복문이 각각 몇 번 실행되는지, 마지막에 출력되는 값은 무엇인지 쓰시오.
for n in 1..5 { println!("{n}"); }
for n in (1..=5).rev() { println!("{n}"); } - 1,000원으로 시작해 매달 두 배가 되는 금액이 처음으로 100,000원을 넘는 것은 몇 개월 뒤인지,
loop와break 값을 써서 구하는 코드를 작성하시오. - 1 부터 9 까지의 두 수
a,b를 이중for로 훑어 곱이 30 이상인 첫 조합을 레이블로 찾아4x8같은 형식으로 출력하는 프로그램을 작성하시오.
정답과 해설
1번.
let status = if balance > 0 {
"흑자"
} else if balance == 0 {
"본전"
} else {
"적자"
};
모든 갈래가 글자 상수이고 마지막에 else 가 있어 값으로 쓸 수 있다. 갈래 끝에 세미콜론이 없다는 점을 확인한다.
2번. 첫 번째는 1..5 라서 1, 2, 3, 4 를 출력하며 4 번 실행되고 마지막 값은 4 이다. 두 번째는 5, 4, 3, 2, 1 을 출력하며 5 번 실행되고 마지막 값은 1 이다. 끝값을 포함하는지가 갈리는 지점이다.
3번.
fn main() {
let mut amount = 1_000;
let mut months = 0;
let result = loop {
amount *= 2;
months += 1;
if amount > 100_000 {
break months;
}
};
println!("{result}개월");
}
출력은 7개월 이다. 6개월 뒤에는 64,000원이고 7개월 뒤에 128,000원이 되어 처음 100,000원을 넘는다.
4번.
fn main() {
let mut found = (0, 0);
'outer: for a in 1..=9 {
for b in 1..=9 {
if a * b >= 30 {
found = (a, b);
break 'outer;
}
}
}
println!("{}x{}", found.0, found.1);
}
출력은 4x8 이다. a 가 1 부터 3 일 때는 곱이 최대 27 이라 조건을 만족하지 못한다. a 가 4 일 때 b 가 7 까지는 28 이하이고, 8 에서 32 가 되어 처음 조건이 참이 된다. 레이블 없이 break 만 쓰면 a 가 5 이상인 조합이 found 를 덮어 써서 결과가 달라진다.
READER FEEDBACK
질문·의견
내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.