Devin.KR

Vec·String·HashMap - 소유권과 함께 쓰는 컬렉션

개발자KR 조회 0

이 장에서 배우는 것

앞 장에서는 값이 없을 수 있음과 실패할 수 있음을 Option 과 Result 로 표현했다. 이 장에서는 가계부 프로그램이 실제로 기록을 쌓고 분류별로 묶을 때 쓰는 컬렉션 세 가지를 다룬다. 기록을 담는 Vec, 글자를 담는 String 과 &str, 분류 이름으로 합계를 찾는 HashMap 이다. 세 타입 모두 힙에 데이터를 두기 때문에 소유권과 빌림 규칙이 코드 모양을 결정한다.

  • Vec 의 원소를 빌리는 동안 벡터를 수정할 수 없는 이유를 메모리 배치로 설명할 수 있다.
  • Vec 에서 원소를 읽기, 고치기, 꺼내기 중 어느 방식이 소유권에 어떤 영향을 주는지 구분한다.
  • String 과 &str 의 차이를 알고, UTF-8 문자열을 정수 위치로 인덱싱할 수 없는 이유를 설명한다.
  • HashMap 의 entry API 로 "없으면 넣고, 있으면 고친다" 를 한 번의 조회로 쓴다.
  • 해시맵을 결정적인 순서로 출력하도록 정렬한다.

문제 상황

가계부 도구는 텍스트 파일이나 입력에서 한 줄씩 기록을 읽는다. 한 줄은 분류, 금액, 메모 세 칸이다. 이 줄들을 읽어 다음 일을 해야 한다.

  • 기록이 몇 건이 될지 모르므로 길이가 늘어나는 저장소가 필요하다.
  • 메모가 비어 있으면 "(없음)" 을 채워 넣는다. 저장된 기록을 제자리에서 고치는 일이다.
  • 메모는 한글이다. 긴 메모는 화면 폭에 맞춰 잘라 보여 주고 싶은데, 몇 바이트에서 자를지가 문제가 된다.
  • 분류별 합계를 구한다. 분류 이름은 미리 정해지지 않았으므로 "처음 보는 분류면 0 에서 시작하고, 이미 있으면 더한다" 가 필요하다.

다른 언어에서는 이 코드를 별생각 없이 쓴다. 리스트에 append 하면서 첫 원소를 들고 있어도 되고, 문자열을 s[0:2] 로 자르기도 하고, 맵에서 값을 꺼내 고친 뒤 다시 넣기도 한다. Rust 에서는 이 세 가지가 각각 컴파일 오류, 실행 중 패닉, 불필요한 이중 조회로 이어진다. 이유를 알면 규칙이 자의적이지 않다는 것이 보인다.

Vec: 빌림과 수정

벡터의 배치

Vec<T> 는 스택에 포인터, 길이(len), 용량(capacity) 세 값을 두고, 실제 원소는 힙의 연속된 버퍼에 둔다. 용량이 가득 찬 상태에서 push 를 하면 더 큰 버퍼를 새로 잡아 원소를 옮기고 옛 버퍼를 반납한다. 이 재할당 때문에 원소를 빌린 참조가 남아 있는 채로 push 를 허용할 수 없다.

push 로 버퍼가 옮겨지면 옛 버퍼를 가리키던 참조는 반납된 곳을 가리키게 된다.

아래 코드는 이 상황을 그대로 옮긴 것이다.

fn main() {
    let mut v = vec![1, 2, 3];
    let first = &v[0];
    v.push(4);
    println!("{first}");
}

컴파일하면 error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable 이 나온다. 요지는 first 가 v 를 공유해서 빌리고 있고 그 빌림이 println! 까지 이어지는데, 그 사이에 push 가 v 를 수정용으로 빌리려 한다는 것이다. 앞 장까지 다룬 규칙, 곧 읽기 참조는 여럿이어도 되지만 수정 참조가 있으면 다른 참조가 없어야 한다는 규칙이 그대로 적용된다. 벡터에서는 그 규칙이 실제 메모리 사고를 막는다. 옛 버퍼가 반납된 뒤에 first 를 읽는 일은 C 에서 흔한 버그인데, Rust 는 컴파일 단계에서 막는다.

println! 을 push 앞으로 옮기면 first 의 마지막 사용이 앞당겨져 빌림이 끝나므로 컴파일된다. 빌림은 변수의 유효 범위가 아니라 마지막으로 쓰이는 지점까지 이어진다.

읽기, 고치기, 꺼내기

벡터를 다루는 방식은 소유권 관점에서 세 가지로 나뉜다. 읽기는 공유 빌림, 고치기는 수정 빌림, 꺼내기는 소유권 이동이다.

Vec 메서드가 요구하는 빌림과 결과
메서드필요한 빌림결과비고
&v[i]공유원소 참조범위를 벗어나면 패닉
v.get(i)공유Option<&T>범위를 벗어나면 None
v.iter_mut()수정원소별 &mut T제자리에서 고칠 때 사용
v.push(x)수정없음x 의 소유권이 벡터로 이동
v.remove(i)수정원소 T소유권을 돌려받고 뒤 원소를 당김
v.retain(f)수정없음조건이 참인 원소만 남김

원소가 String 처럼 힙 데이터를 가진 타입이면 인덱스로 값을 꺼내 변수에 옮길 수 없다. 벡터가 원소의 주인이므로 인덱스 문법은 참조만 준다. 이 내용은 뒤의 "실무에서 자주 틀리는 것" 에서 오류 메시지와 함께 다룬다.

가계부 코드에서 수정 빌림은 for e in entries.iter_mut() 형태로 나온다. 루프 변수 e 가 &mut Entry 이므로 e.memo.push_str(...) 처럼 원소 안의 필드를 직접 고칠 수 있다. 이 루프가 도는 동안에는 entries 를 다른 방식으로 쓸 수 없다.

함수 인자로 벡터를 넘길 때는 &Vec<Entry> 보다 &[Entry] 로 받는 편이 좋다. &[Entry] 는 슬라이스(slice)라 부르는, 연속된 원소 일부를 가리키는 참조다. 벡터 전체도, 배열도, 벡터의 일부 구간도 같은 함수에 넘길 수 있다.

String 과 &str, 그리고 UTF-8

두 타입의 역할

String 은 힙에 문자열 버퍼를 소유하고 늘리거나 고칠 수 있다. &str 은 어딘가에 있는 UTF-8 바이트열 일부를 빌려 보는 참조다. 소스 코드에 쓴 "식비" 같은 리터럴도 &str 이다. 벡터와 슬라이스의 관계와 같다고 보면 된다.

String 과 &str 비교
항목String&str
데이터의 주인자신다른 곳(빌림)
내용 수정가능(push_str 등)불가능
함수 인자로 받을 때소유권이 필요할 때만읽기만 하면 이쪽을 선호
만드는 법String::from, to_string리터럴, &s, s.as_str()

읽기만 하는 함수는 &str 로 받으면 String 이든 리터럴이든 모두 받을 수 있다. 위 코드의 parse_line(line: &str) 이 그 예다.

UTF-8 과 인덱싱

Rust 문자열은 항상 유효한 UTF-8 이다. UTF-8 에서 영문자는 1바이트지만 한글 한 글자는 3바이트를 차지한다. 그래서 "식비".len() 은 글자 수 2 가 아니라 바이트 수 6 이다. s[0] 처럼 정수 하나로 인덱싱하는 문법이 없는 것도 이 때문이다. "0번째" 가 첫 바이트인지 첫 글자인지 모호하고, 글자 단위로 찾으려면 앞에서부터 세어야 해서 비용이 상수가 아니기 때문이다.

한글 한 글자는 3바이트이고 글자 경계인 0, 3, 6 에서만 문자열을 자를 수 있다.

구간 슬라이싱 &s[0..3] 은 바이트 위치로 자른다. 위치가 글자 경계에 맞으면 "식" 을 주지만, &s[0..1] 처럼 글자 중간이면 컴파일은 되고 실행 중에 패닉이 난다. 메시지는 byte index 1 is not a char boundary 로 시작한다. 안전하게 시도하려면 s.get(0..1) 을 쓴다. 경계가 아니면 None 을 돌려준다.

글자 단위 작업은 chars() 반복자로 한다. 처음 N글자만 취하려면 s.chars().take(n).collect::<String>() 로 쓴다. 글자 수는 s.chars().count() 로 센다. 여기서 "글자" 는 유니코드 스칼라 값(char) 하나를 뜻한다. 이모지 조합이나 자모 결합처럼 화면에서 한 글자로 보이는 것이 여러 char 로 이루어지는 경우는 이 책 범위 밖이다.

HashMap 과 entry API

분류별 합계의 첫 시도

HashMap<K, V> 는 키로 값을 찾는 표다. 표준 라이브러리에 있지만 프렐류드(prelude, 별도 선언 없이 쓸 수 있는 이름 목록)에는 없어서 use std::collections::HashMap; 가 필요하다. 분류별 합계를 처음 떠올리는 방식은 이렇다.

let old = totals.get(category).copied().unwrap_or(0);
totals.insert(category, old + amount);

동작은 하지만 키를 두 번 해시하고 두 번 찾는다. 읽고 나서 쓰는 두 단계라서 코드 의도도 흐려진다.

entry 로 한 번에

entry(key) 는 그 키의 칸을 가리키는 Entry 값을 돌려준다. 칸이 비어 있을 수도 있고 차 있을 수도 있다. 여기에 or_insert(기본값) 을 이으면 비어 있을 때만 기본값을 넣고, 어느 쪽이든 값에 대한 &mut 참조를 돌려준다. 그 참조를 역참조해서 바로 더한다.

*totals.entry(category).or_insert(0) += amount;

한 줄로 "없으면 0 에서 시작, 있으면 그 값에 더한다" 가 된다. * 는 앞 장들에서 본 대로 참조가 가리키는 값에 접근하는 연산이다. 값 타입이 Vec 처럼 기본값이 명확하면 or_default() 를 쓴다. 그 타입의 기본값을 자동으로 넣는다.

groups.entry(category).or_default().push(memo);

이 코드는 "분류 칸이 없으면 빈 Vec 을 만들고, 거기에 메모를 추가한다" 를 뜻한다. or_insert 든 or_default 든 돌려주는 참조는 맵을 수정용으로 빌린 것이므로, 그 참조를 쓰는 문장이 끝나기 전에는 맵을 다시 만질 수 없다. 위처럼 한 문장 안에서 쓰고 끝내는 것이 자연스럽다. 더 많은 조합은 표준 라이브러리 문서의 Entry 항목에서 확인할 수 있다.

키의 소유권과 출력 순서

insert 와 entry 는 키를 값으로 받는다. 키가 String 이면 소유권이 맵으로 이동하고, &str 이면 참조가 복사되어 들어간다. 이 장의 예제는 Entry 의 category: String 을 as_str() 로 빌려서 HashMap<&str, i64> 를 만든다. 문자열을 복제하지 않는 대신 맵이 원본 기록을 빌려 쓰므로, 기록 벡터가 살아 있는 동안에만 맵을 쓸 수 있다. 이 관계를 컴파일러에게 명시하는 방법은 수명을 다루는 장에서 자세히 본다. 이 장의 함수들은 입력 참조가 하나뿐이라 생략된 수명으로 충분하다.

해시맵의 순회 순서는 정해져 있지 않고 실행할 때마다 달라질 수 있다. 출력이 항상 같아야 하는 가계부 요약이라면 (키, 값) 쌍을 벡터로 모아 정렬한 뒤 출력한다. (&str, i64) 튜플은 서로 비교할 수 있어서 sort() 를 바로 쓸 수 있다.

완성 코드

src/main.rs 한 파일이다. 기록 일곱 줄 중 두 줄은 일부러 잘못된 형식이다.

use std::collections::HashMap;

struct Entry {
    category: String,
    amount: i64,
    memo: String,
}

fn parse_line(line: &str) -> Result<Entry, String> {
    let parts: Vec<&str> = line.split(',').map(|p| p.trim()).collect();
    if parts.len() != 3 {
        return Err(format!("칸이 3개가 아니다: {line}"));
    }
    let amount: i64 = parts[1]
        .parse()
        .map_err(|_| format!("금액을 읽을 수 없다: {}", parts[1]))?;
    Ok(Entry {
        category: parts[0].to_string(),
        amount,
        memo: parts[2].to_string(),
    })
}

fn total_by_category(entries: &[Entry]) -> HashMap<&str, i64> {
    let mut totals = HashMap::new();
    for e in entries {
        *totals.entry(e.category.as_str()).or_insert(0) += e.amount;
    }
    totals
}

fn memos_by_category(entries: &[Entry]) -> HashMap<&str, Vec<&str>> {
    let mut groups: HashMap<&str, Vec<&str>> = HashMap::new();
    for e in entries {
        groups
            .entry(e.category.as_str())
            .or_default()
            .push(e.memo.as_str());
    }
    groups
}

fn largest_expense(entries: &[Entry]) -> Option<&Entry> {
    entries
        .iter()
        .filter(|e| e.amount < 0)
        .min_by_key(|e| e.amount)
}

fn shorten(text: &str, max_chars: usize) -> String {
    if text.chars().count() <= max_chars {
        return text.to_string();
    }
    let head: String = text.chars().take(max_chars - 1).collect();
    format!("{head}…")
}

fn main() {
    let raw_lines = [
        "식비, -12000, 점심김밥",
        "교통, -1450, 지하철",
        "급여, 3000000, 9월 급여",
        "식비, -8500, 친구와 저녁 약속",
        "교통, -2800, ",
        "식비, abc, 잘못된줄",
        "카페, -4500",
    ];

    let mut entries: Vec<Entry> = Vec::new();
    for line in raw_lines {
        match parse_line(line) {
            Ok(entry) => entries.push(entry),
            Err(msg) => println!("건너뜀: {msg}"),
        }
    }

    for e in entries.iter_mut() {
        if e.memo.is_empty() {
            e.memo.push_str("(없음)");
        }
    }
    println!("읽은 기록: {}건", entries.len());

    let totals = total_by_category(&entries);
    let mut rows: Vec<(&str, i64)> = totals.into_iter().collect();
    rows.sort();
    for (name, sum) in &rows {
        println!("{name}: {sum}");
    }

    let memos = memos_by_category(&entries);
    let mut names: Vec<&str> = memos.keys().copied().collect();
    names.sort();
    for name in names {
        let list: Vec<String> = memos[name].iter().map(|m| shorten(m, 6)).collect();
        println!("{name}: {}", list.join(" | "));
    }

    match largest_expense(&entries) {
        Some(e) => println!("가장 큰 지출: {} {} ({})", e.category, e.amount, e.memo),
        None => println!("지출 기록이 없다"),
    }

    let word = "식비";
    println!("{word}: 바이트 {}, 글자 {}", word.len(), word.chars().count());
    println!("{:?} {:?}", word.get(0..1), word.get(0..3));
}

줄별 해설

parse_line. split(',') 은 쉼표 기준 조각을 &str 으로 돌려주는 반복자이고, trim() 은 양쪽 공백을 제거한다. 조각이 원본 line 을 빌려 보므로 문자열 복사가 일어나지 않는다. 칸이 세 개가 아니면 Err 로 일찍 돌아간다. 금액은 parse() 가 Result 를 주고, map_err 로 오류 타입을 String 메시지로 바꾼 뒤 ? 로 넘긴다. 앞 장에서 다룬 흐름이다. 마지막에 to_string() 으로 각 조각을 String 으로 복제해 Entry 가 자기 데이터를 소유하게 한다. Entry 가 line 이 사라진 뒤에도 살아 있어야 하기 때문이다.

total_by_category. 인자가 &[Entry] 슬라이스라서 벡터를 그대로 넘겨도 된다. 루프의 e 는 &Entry 다. e.category.as_str() 로 String 을 &str 로 빌려 키로 쓰고, entry(..).or_insert(0) 이 돌려준 &mut i64 에 금액을 더한다. 맵의 타입은 반환 타입에서 추론된다.

memos_by_category. 값이 Vec<&str> 라서 or_default() 가 빈 벡터를 넣고, 곧바로 push 로 메모를 빌려 추가한다. 여기서도 문자열은 복제하지 않는다.

largest_expense. 음수 금액만 걸러 내고 min_by_key 로 가장 작은 값, 곧 절댓값이 가장 큰 지출을 찾는다. 기록이 없을 수 있으므로 Option<&Entry> 를 돌려준다. 원소를 꺼내지 않고 참조만 돌려주므로 벡터의 소유권에는 영향이 없다.

shorten. 글자 수를 chars().count() 로 세고, 초과하면 앞의 max_chars - 1 글자에 말줄임표를 붙인다. 바이트 슬라이싱이 아니라 chars() 를 쓰므로 한글 중간에서 잘리지 않는다. 여섯 글자 제한에 "친구와 저녁 약속" 은 아홉 글자라서 "친구와 저…" 가 된다.

main 의 읽기 루프. raw_lines 배열을 값으로 순회하면 각 원소는 &str 이다. match 로 성공한 기록만 push 하고, 실패는 메시지를 출력하고 건너뛴다. push 는 Entry 의 소유권을 벡터로 옮긴다.

수정 루프. iter_mut() 로 원소를 수정 빌림하고, 메모가 비어 있으면 push_str 로 "(없음)" 을 붙인다. e.memo 가 String 이기 때문에 가능한 일이다. 이 루프가 끝난 뒤에야 entries 를 읽기용으로 다시 빌린다.

합계 출력. totals.into_iter().collect() 로 맵을 소비해 (키, 값) 벡터로 바꾸고 sort() 한다. 튜플 정렬은 첫 원소인 문자열을 바이트 순서로 비교한다. 한글 음절은 유니코드 순서가 가나다 순서와 같으므로 "교통, 급여, 식비" 순이 된다. for (name, sum) in &rows 는 벡터를 빌려 순회한다.

메모 출력. memos.keys().copied() 는 키 참조 &&str 에서 &str 을 복사해 꺼낸다. 정렬한 키로 memos[name] 을 조회하면 그 분류의 Vec<&str> 이 나오고, shorten 을 거쳐 join 으로 이어 붙인다.

마지막 두 줄. "식비" 의 바이트 수는 6, 글자 수는 2 다. get(0..1) 은 글자 중간이라 None, get(0..3) 은 첫 글자 Some("식") 이다. {:?} 는 Option 을 출력할 때 쓰는 디버그 형식이다.

실행 결과

$ cargo run
건너뜀: 금액을 읽을 수 없다: abc
건너뜀: 칸이 3개가 아니다: 카페, -4500
읽은 기록: 5건
교통: -4250
급여: 3000000
식비: -20500
교통: 지하철 | (없음)
급여: 9월 급여
식비: 점심김밥 | 친구와 저…
가장 큰 지출: 식비 -12000 (점심김밥)
식비: 바이트 6, 글자 2
None Some("식")

실무에서 자주 틀리는 것

1. 순회하면서 같은 벡터에 추가한다

지출 하나마다 "세금 메모" 기록을 덧붙이려는 코드다.

for e in &entries {
    if e.amount < 0 {
        entries.push(Entry {
            category: String::from("메모"),
            amount: 0,
            memo: e.memo.clone(),
        });
    }
}

error[E0502]: cannot borrow `entries` as mutable because it is also borrowed as immutable 이 나온다. 순회 중인 &entries 가 공유 빌림이라 push 가 끼어들 수 없다. 새 기록을 별도 벡터에 모은 뒤 순회가 끝나고 합친다.

let mut extra: Vec<Entry> = Vec::new();
for e in &entries {
    if e.amount < 0 {
        extra.push(Entry {
            category: String::from("메모"),
            amount: 0,
            memo: e.memo.clone(),
        });
    }
}
entries.extend(extra);

2. 인덱스로 String 을 꺼내 가져가려 한다

let names = vec![String::from("식비"), String::from("교통")];
let first = names[0];

error[E0507]: cannot move out of index of `Vec<String>` 가 나온다. 벡터가 원소의 주인이므로 인덱스로 소유권을 빼앗을 수 없다는 뜻이다. 읽기만 하면 빌리고, 독립된 사본이 필요하면 복제한다. 벡터에서 완전히 빼내려면 remove 를 쓴다.

let first = &names[0];            // 빌림
let copy = names[0].clone();      // 복제

3. 한글 문자열을 바이트 위치로 자른다

let memo = String::from("점심김밥");
let head = &memo[0..4];

컴파일은 되지만 실행하면 byte index 4 is not a char boundary 패닉이 난다. 4 는 두 번째 글자 중간이다. 글자 수 기준으로 자르려면 chars().take(n) 을 쓴다. 바이트 위치가 입력에 따라 달라질 수 있으면 get(..) 으로 Option 을 받는다.

let head: String = memo.chars().take(2).collect();   // "점심"
let maybe = memo.get(0..4);                          // None

4. 해시맵을 그대로 순회해 출력한다

for (name, sum) in &totals {
    println!("{name}: {sum}");
}

오류는 없지만 실행할 때마다 순서가 달라질 수 있다. 출력을 비교하는 테스트나 사용자에게 보여 주는 요약이 불안정해진다. 순회 전에 정렬한다.

let mut rows: Vec<(&str, i64)> = totals.into_iter().collect();
rows.sort();
for (name, sum) in &rows {
    println!("{name}: {sum}");
}

한눈에 보기

이 장의 핵심 규칙 요약
주제규칙대표 코드
Vec 빌림원소 참조가 살아 있는 동안 push 불가&v[0] 뒤 v.push(4) 는 E0502
Vec 수정제자리 수정은 수정 빌림for e in v.iter_mut()
Vec 원소 이동인덱스로는 소유권을 못 뺌&v[i], clone(), remove(i)
String / &str소유하면 String, 빌려 읽으면 &strfn f(s: &str)
UTF-8한글 한 글자는 3바이트, 정수 인덱싱 없음chars(), get(a..b)
HashMap 갱신entry 로 한 번에 조회와 삽입*m.entry(k).or_insert(0) += n
HashMap 출력순서가 불특정이므로 정렬into_iter().collect() 후 sort()
entry 와 함께 쓰는 값 채우기 메서드
메서드빈 칸일 때돌려주는 것쓰는 경우
or_insert(v)v 를 넣음&mut V합계 0 에서 시작
or_default()기본값을 넣음&mut V빈 Vec 이나 0 으로 시작
or_insert_with(f)함수 결과를 넣음&mut V기본값 생성 비용이 클 때

연습 문제

  1. 아래 코드는 컴파일되지 않는다. 오류 코드와 원인을 말하고, 두 가지 방법으로 고쳐라.
    let mut v = vec![10, 20, 30];
    let last = v.last().unwrap();
    v.clear();
    println!("{last}");
  2. "가계부" 의 len() 과 chars().count() 는 각각 얼마인가. get(0..3) 과 get(1..4) 의 결과는 무엇인가.
  3. 문장을 공백으로 나눠 단어별 등장 횟수를 세는 함수 count_words(text: &str) -> HashMap<&str, usize> 를 entry 로 작성하라.
  4. &[Entry] 를 받아 분류별로 (건수, 합계) 를 담은 HashMap<&str, (u32, i64)> 를 돌려주는 함수를 작성하라. 이 장의 Entry 구조체를 쓴다.

정답과 해설

1. 오류는 E0502 다. last 가 v 의 원소를 공유 빌림하고 있는데 v.clear() 가 수정 빌림을 요구한다. clear 로 원소가 사라지면 last 가 가리킬 곳이 없어지므로 막는 것이 맞다. 고치는 방법 하나는 println! 을 clear 앞으로 옮겨 빌림을 먼저 끝내는 것이다. 다른 하나는 값을 복사하는 것이다.

let last = *v.last().unwrap();   // i64 는 복사되므로 빌림이 끝난다
v.clear();
println!("{last}");

2. 세 글자이고 글자당 3바이트이므로 len() 은 9, chars().count() 는 3 이다. get(0..3) 은 첫 글자 경계에 맞아 Some("가") 다. get(1..4) 는 시작 위치 1 이 글자 중간이라 None 이다. 같은 구간을 [1..4] 로 슬라이싱했다면 패닉이 났을 것이다.

3.

fn count_words(text: &str) -> HashMap<&str, usize> {
    let mut counts = HashMap::new();
    for w in text.split_whitespace() {
        *counts.entry(w).or_insert(0) += 1;
    }
    counts
}

단어 조각이 text 를 빌려 보므로 반환 맵은 text 가 살아 있는 동안만 유효하다. 횟수는 반환 타입 덕분에 usize 로 추론된다.

4.

fn count_and_sum(entries: &[Entry]) -> HashMap<&str, (u32, i64)> {
    let mut map = HashMap::new();
    for e in entries {
        let slot = map.entry(e.category.as_str()).or_insert((0, 0));
        slot.0 += 1;
        slot.1 += e.amount;
    }
    map
}

or_insert 가 돌려준 &mut (u32, i64) 를 변수 slot 에 받아 두 필드를 차례로 고친다. slot 이 살아 있는 동안 map 은 수정 빌림 상태이므로, 루프 한 바퀴 안에서만 쓰고 끝내야 한다. 이 장의 예제 데이터에 적용하면 식비는 (2, -20500) 이 된다.

댓글 0

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

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