Devin.KR

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 없이 선언한다. 앞 장에서 배운 불변 기본 원칙과 잘 맞는다.

if 식은 조건에 따라 두 갈래 중 하나의 값을 골라 변수에 묶는다.

조건은 반드시 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..41 이상 4 미만1, 2, 33
1..=41 이상 4 이하1, 2, 3, 44
(1..=4).rev()거꾸로4, 3, 2, 14
(0..10).step_by(5)5 칸씩 건너뜀0, 52

배열도 for 로 훑을 수 있다. 예제의 기록은 (&str, i64) 짝을 원소로 갖는 배열이며, for (name, amount) in records 처럼 짝을 바로 풀어서 받는다. 여기서 &str 은 큰따옴표 글자 상수의 타입이라고만 알아 두자. 배열 원소가 가진 값은 모두 복사되는 종류라서, 같은 배열을 여러 번 훑어도 문제가 없다. 값이 복사되지 않는 경우는 다음 장의 소유권에서 다룬다.

레이블로 바깥 반복문 제어하기

break 와 continue 는 기본적으로 가장 안쪽 반복문에만 작용한다. 두 겹 이상을 한 번에 다루려면 반복문 앞에 작은따옴표로 시작하는 이름표, 곧 레이블(label)을 붙이고 break '이름 으로 그 반복문을 지정한다.

레이블이 붙은 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 '이름; }지정한 바깥 반복문을 끝냄없으면 가장 안쪽만 영향받음

연습 문제

  1. 잔액 balance 가 0 보다 크면 "흑자", 0 이면 "본전", 0 보다 작으면 "적자"가 되는 status 를 else if 를 사용한 한 개의 let 문으로 작성하시오.
  2. 다음 두 반복문이 각각 몇 번 실행되는지, 마지막에 출력되는 값은 무엇인지 쓰시오.
    for n in 1..5 { println!("{n}"); }
    for n in (1..=5).rev() { println!("{n}"); }
  3. 1,000원으로 시작해 매달 두 배가 되는 금액이 처음으로 100,000원을 넘는 것은 몇 개월 뒤인지, loop 와 break 값 을 써서 구하는 코드를 작성하시오.
  4. 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

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.