본문 바로가기

반복자

반복자로 서로 다른 컨테이너의 요소와 범위를 공통 방식으로 순회하고 종류·const·무효화 규칙을 익힙니다.

std::vector는 인덱스로 원소에 접근하지만, std::list나 std::set에는 인덱스가 없습니다. 컨테이너마다 순회하는 방법이 다르다면, 정렬이나 검색 같은 알고리즘을 컨테이너마다 따로 만들어야 합니다. 반복자(iterator)는 어떤 컨테이너든 같은 방식으로 원소를 가리키고 다음 원소로 넘어가게 해, 알고리즘과 컨테이너를 이어 줍니다.

이 글은 반복자의 기본 사용법, 반복자의 종류와 능력, 원소를 바꾸지 못하게 하는 const 반복자, 그리고 컨테이너를 바꿀 때 반복자가 무효가 되는 규칙을 다룹니다. 반복자는 포인터와 배열에서 본 포인터를 일반화한 것이어서, 간접 참조와 증가 같은 사용법이 포인터와 비슷합니다.

반복자의 기본 사용법

컨테이너의 begin()은 첫 원소를, end()는 마지막 원소의 바로 다음 위치를 가리키는 반복자를 돌려줍니다. *it로 가리키는 원소에 접근하고 ++it로 다음 원소로 넘어가며, it != end()인 동안 순회합니다.

iterator_basics.cpp
#include <iostream>
#include <list>
#include <string>
#include <vector>

int main() {
    std::vector<int> numbers{10, 20, 30, 40, 50};
    auto it = numbers.begin();
    std::cout << *it << ' ';
    ++it;
    std::cout << *it << ' ';
    it = numbers.begin() + 3;
    std::cout << *it << '\n';

    for (auto pos = numbers.begin(); pos != numbers.end(); ++pos) {
        std::cout << *pos << ' ';
    }
    std::cout << '\n';

    std::list<std::string> fruits{"Apple", "Banana", "Cherry"};
    auto fit = fruits.begin();
    std::cout << *fit << ' ';
    ++fit;
    std::cout << *fit << ' ';
    --fit;
    std::cout << *fit << '\n';
}
10 20 40
10 20 30 40 50
Apple Banana Apple

std::vector의 반복자는 numbers.begin() + 3처럼 한 번에 여러 칸을 건너뛸 수 있고, std::list의 반복자는 ++와 --로 한 칸씩만 움직입니다. 반복자의 정확한 타입은 std::vector<int>::iterator처럼 길기 때문에 보통 auto로 받습니다. for 반복문에서 본 범위 기반 for도 안에서는 begin()과 end()를 써서 이렇게 순회합니다.

[begin(), end())는 시작은 포함하고 끝은 포함하지 않는 반열린 범위입니다. end()는 원소가 아니므로 간접 참조할 수 없고, 빈 컨테이너에서는 begin()과 end()가 같아서 begin()도 간접 참조할 수 없습니다. 또 서로 다른 컨테이너의 begin()과 end()를 짝지은 범위는 올바른 범위가 아닙니다.

반복자의 종류

반복자는 지원하는 연산에 따라 몇 가지 종류로 나뉘며, 아래 그림처럼 위 단계로 갈수록 할 수 있는 일이 늘어납니다.

C++ · ITERATOR CAPABILITY

반복자는 알고리즘이 요구하는 이동 능력을 제공한다

input에서 contiguous까지 capability가 누적된다. output iterator는 이 사다리의 다음 단계가 아니라 쓰기 목적의 독립된 능력 축이다.

반복자의 누적 이동 능력과 연속 저장 요구를 구분한다5능력4포함방향간선. Input의읽기/순회요구에서Forward의multi-pass,양방향이동,임의접근,연속저장까지누적된다. Input이라는분류자체가쓰기금지를뜻하지않는다. C++20Contiguous추가개념임을명시하고Output쓰기계약을이사슬로잘못편입하지않는다. 각반복자가실행중다음종류로변하는시간흐름이아니며모바일에도같은포함방향을유지한다.
Input읽기 · 한 번 순회
연결 관계Input · 읽기 · 한 번 순회 → Forward · 읽기 · 반복 순회
Forward읽기 · 반복 순회
연결 관계Forward · 읽기 · 반복 순회 → Bidirectional · 앞·뒤로 이동
Bidirectional앞·뒤로 이동
연결 관계Bidirectional · 앞·뒤로 이동 → Random access · 상수 시간 점프
Random access상수 시간 점프
연결 관계Random access · 상수 시간 점프 → Contiguous · 연속 저장까지 보장 · C++20 개념
Contiguous연속 저장까지 보장C++20 개념

아래로 갈수록 능력 추가Output은 별도 쓰기 계약Input이 쓰기를 금지하지 않음

반복자의 누적 이동 능력과 연속 저장 요구를 구분한다5능력4포함방향간선. Input의읽기/순회요구에서Forward의multi-pass,양방향이동,임의접근,연속저장까지누적된다. Input이라는분류자체가쓰기금지를뜻하지않는다. C++20Contiguous추가개념임을명시하고Output쓰기계약을이사슬로잘못편입하지않는다. 각반복자가실행중다음종류로변하는시간흐름이아니며모바일에도같은포함방향을유지한다.
Input읽기 · 한 번 순회
연결 관계Input · 읽기 · 한 번 순회 → Forward · 읽기 · 반복 순회
Forward읽기 · 반복 순회
연결 관계Forward · 읽기 · 반복 순회 → Bidirectional · 앞·뒤로 이동
Bidirectional앞·뒤로 이동
연결 관계Bidirectional · 앞·뒤로 이동 → Random access · 상수 시간 점프
Random access상수 시간 점프
연결 관계Random access · 상수 시간 점프 → Contiguous · 연속 저장까지 보장 · C++20 개념
Contiguous연속 저장까지 보장C++20 개념

아래로 갈수록 능력 추가Output은 별도 쓰기 계약Input이 쓰기를 금지하지 않음

  1. Input

    한 번 전진하며 값을 읽을 수 있습니다.

  2. Forward

    같은 범위를 여러 번 같은 순서로 통과할 수 있습니다.

  3. Bidirectional

    전진뿐 아니라 --it로 뒤로 이동합니다.

  4. Random access

    상수 시간 위치 이동·차이·순서 비교를 제공합니다.

  5. Contiguous

    C++20에서 요소가 실제 연속 메모리에 놓였음을 표현합니다.

  6. Output는 별도 축

    값을 쓰는 능력이지 input 계층의 상위 단계가 아닙니다.

알고리즘은 필요한 최소 capability를 선언한다. 예를 들어 std::sort는 random-access iterator를 요구한다.

종류할 수 있는 일예
입력 반복자읽으며 한 번만 앞으로 순회std::istream_iterator
순방향 반복자여러 번 앞으로 순회std::forward_list
양방향 반복자앞뒤로 한 칸씩 이동std::list, std::set, std::map
임의 접근 반복자it + n, it[n], < 비교로 상수 시간에 이동std::deque
연속 반복자(C++20)임의 접근에 더해 원소가 메모리에 이어져 있음std::vector, std::array, 배열

쓰기 전용으로 한 번만 앞으로 가는 출력 반복자(output iterator)는 이 단계와 따로 있는 종류이며, std::ostream_iterator가 그 예입니다. 알고리즘은 필요한 종류를 정해 두므로, std::find는 입력 반복자로 충분하지만 std::sort는 임의 접근 반복자가 필요해 std::list에는 쓸 수 없습니다. 그래서 std::list는 정렬을 멤버 함수 sort()로 따로 제공합니다.

반복자의 종류에 맞춰 움직이는 도우미 함수도 있습니다. <iterator>의 std::next(it, n)과 std::prev(it, n)은 이동한 새 반복자를 돌려주고, std::distance(first, last)는 두 반복자 사이의 원소 수를 셉니다. 다만 std::prev는 뒤로 움직여야 하므로 양방향 반복자 이상에서만 쓸 수 있습니다. 임의 접근 반복자에서는 이들이 상수 시간이지만, 그 밖의 반복자에서는 한 칸씩 움직이므로 원소 수에 비례하는 시간이 듭니다.

const 반복자와 역방향 반복자

원소를 읽기만 할 때는 cbegin()과 cend()가 돌려주는 const_iterator를 씁니다. 이 반복자는 움직일 수는 있지만, 가리키는 원소를 바꿀 수는 없습니다.

const_and_reverse.cpp
#include <iostream>
#include <vector>

int main() {
    std::vector<int> data{1, 2, 3, 4, 5};

    for (auto it = data.cbegin(); it != data.cend(); ++it) {
        std::cout << *it << ' ';
        // *it = 10; // 컴파일 오류: const_iterator로는 원소를 바꿀 수 없음
    }
    std::cout << '\n';

    for (auto it = data.rbegin(); it != data.rend(); ++it) {
        std::cout << *it << ' ';
    }
    std::cout << '\n';
}
1 2 3 4 5
5 4 3 2 1

const_iterator의 const는 const 포인터와 포인터 상수의 const int*처럼 가리키는 원소를 막는 것입니다. 반대로 const std::vector<int>::iterator는 반복자 객체 자체를 고정한 것이어서 원소는 바꿀 수 있지만 반복자를 움직일 수 없습니다. rbegin()과 rend()는 마지막 원소부터 거꾸로 순회하는 역방향 반복자(reverse iterator)를 돌려주며, 이때도 ++가 한 칸 앞, 즉 이전 원소로 이동합니다.

반복자 무효화

컨테이너에 원소를 넣거나 지우면, 그 전에 얻어 둔 반복자가 더 이상 올바른 위치를 가리키지 않을 수 있습니다. 이것을 반복자 무효화(iterator invalidation)라고 하며, 무효가 된 반복자를 쓰는 것은 정의되지 않은 동작입니다. 어느 반복자가 무효가 되는지는 컨테이너와 연산마다 다릅니다.

C++ · INVALIDATION

수정 직후 무효화 범위를 확인하고 반복자를 다시 세운다

컨테이너와 연산마다 무효화 범위가 다르다. vector 삽입의 재할당 여부를 먼저 보고, deque·node 기반·unordered 계열은 각각의 전용 규칙을 확인한다.

vector 삽입의 재할당 여부로 무효화 범위를 나눈다원5노드4간선. vector삽입조건예→재할당조건,아니요→다른연산/컨테이너전용규칙. 재할당예→전체기존반복자참조및end무효,아니요→삽입지점과그뒤및기존end무효. 결과노드들사이간선은없고reserve여부만으로모든연산의무효화를보장하지않는다. 모바일에서도같은조건/범위를유지한다. vector 삽입?insert / push_back재할당 발생?저장 공간 변경모두 무효기존 원소 반복자·참조기존 end() 포함삽입 지점·그 뒤반복자 · 참조 무효기존 end() 포함전용 규칙 조회다른 연산·컨테이너예예아니요아니요기존 end와 기존 원소를 구분erase 반환이 end인지 확인
vector 삽입의 재할당 여부로 무효화 범위를 나눈다원5노드4간선. vector삽입조건예→재할당조건,아니요→다른연산/컨테이너전용규칙. 재할당예→전체기존반복자참조및end무효,아니요→삽입지점과그뒤및기존end무효. 결과노드들사이간선은없고reserve여부만으로모든연산의무효화를보장하지않는다. 모바일에서도같은조건/범위를유지한다.vector 삽입?insert / push_back재할당 발생?저장 공간 변경모두 무효기존 원소 반복자·참조기존 end() 포함삽입 지점·그 뒤반복자 · 참조 무효기존 end() 포함전용 규칙 조회다른 연산·컨테이너예예아니요아니요기존 end와 기존 원소를 구분erase 반환이 end인지 확인
  1. 연산을 식별

    insert·erase·reserve·push_back 중 실제 연산을 확인합니다.

  2. 컨테이너 규칙 적용

    deque·node 기반·unordered 컨테이너의 서로 다른 보장을 섞지 않습니다.

  3. vector 삽입 구분

    재할당이면 모두 무효입니다. 재할당이 없으면 삽입 지점과 그 뒤의 iterator/reference 및 기존 end()가 무효입니다.

  4. erase 반환값 사용

    it = container.erase(it)로 다음 위치를 받습니다. end()인지 확인한 뒤 역참조합니다.

range-for는 반복 중 구조 변경을 자동으로 안전하게 만들지 않는다.

위 그림처럼 std::vector에 원소를 넣을 때 공간이 모자라 더 큰 공간으로 옮기는 재할당이 일어나면, 기존 원소를 가리키는 반복자, 참조, 포인터와 end()가 모두 무효가 됩니다. 재할당이 없으면 넣은 위치와 그 뒤만 무효가 되고, 앞의 원소는 그대로 유효합니다. erase는 지운 위치와 그 뒤를 무효로 만듭니다.

컨테이너넣을 때지울 때
std::vector재할당이 있으면 모두, 없으면 넣은 위치와 그 뒤지운 위치와 그 뒤
std::deque양쪽 끝에 넣으면 반복자는 모두 무효, 참조는 유지연산마다 다름
std::list, std::set, std::map무효가 되지 않음지운 원소만
비정렬 연관 컨테이너재해시가 일어나면 반복자 모두지운 원소만
invalidation.cpp
#include <iostream>
#include <vector>

int main() {
    std::vector<int> values{10, 20, 30};
    values.reserve(8);
    auto first = values.begin();
    values.push_back(40);
    std::cout << *first << ' ' << values.size() << '\n';

    auto next = values.erase(values.begin() + 1);
    if (next != values.end()) {
        std::cout << *next << '\n';
    }

    for (int value : values) {
        std::cout << value << ' ';
    }
    std::cout << '\n';
}
10 4
30
10 30 40

reserve(8)은 원소 8개를 담을 공간을 미리 마련할 뿐 원소 수를 바꾸지 않으며, 이 뒤로는 원소가 8개를 넘기 전까지 재할당이 일어나지 않습니다. 그래서 push_back(40) 뒤에도 first는 유효해 10을 가리킵니다. erase는 20을 지우고, 지운 원소의 다음 위치를 가리키는 새 반복자를 돌려주므로 next는 30을 가리킵니다. 마지막 원소를 지우면 돌려받은 반복자가 end()이므로, 간접 참조하기 전에 확인해야 합니다.

순회하면서 원소를 지울 때는 erase가 돌려준 반복자로 이어 가야 합니다. 다음 함수는 짝수를 모두 지웁니다.

erase_while_iterating.cpp
#include <vector>

void removeEven(std::vector<int>& v) {
    for (auto it = v.begin(); it != v.end();) {
        if (*it % 2 == 0) {
            it = v.erase(it);
        } else {
            ++it;
        }
    }
}

지운 경우에는 erase가 돌려준 다음 위치로 가고, 지우지 않은 경우에만 ++it로 넘어갑니다. C++20에서는 같은 일을 std::erase_if(v, [](int x) { return x % 2 == 0; }); 한 줄로 할 수 있습니다. 범위 기반 for도 안에서 반복자와 end()를 들고 있으므로, 그 안에서 원소를 넣거나 지우면 안 됩니다.

더 깊이: 무효가 된 반복자를 실행 중에 잡아내는 빌드

무효가 된 반복자를 쓰는 것은 정의되지 않은 동작이어서, 보통의 빌드에서는 우연히 옛 값을 읽고 그대로 지나가 버리기도 합니다. 그래서 표준 라이브러리 구현들은 개발 중에 이런 실수를 잡는 디버그용 빌드를 제공합니다. GCC의 libstdc++는 -D_GLIBCXX_DEBUG로 컴파일하면 디버그 모드가 켜지며, 이 모드의 반복자는 자기가 어느 컨테이너를 가리키는지 알고 있어서 범위 끝을 넘어 움직이거나 이미 사라진 컨테이너의 반복자를 간접 참조하면 문제를 표준 오류에 출력하고 프로그램을 멈춥니다. 알고리즘의 전제도 검사해서, 같은 비교 기준으로 정렬되어 있어야 하는 입력 범위가 그렇지 않으면 알려 줍니다.

이 모드는 std::vector 같은 표준 클래스 템플릿의 크기와 동작을 바꾼다는 점에 주의해야 합니다. libstdc++ 문서에 따르면 디버그 모드로 컴파일한 코드와 그렇지 않은 코드는, 두 번역 단위 사이에 컨테이너를 주고받지 않을 때만 함께 링크할 수 있습니다. MSVC에서는 _ITERATOR_DEBUG_LEVEL 매크로가 같은 역할을 하며, 디버그 빌드에서는 기본값 2로 반복자 디버깅이 켜지고 릴리스 빌드에서는 기본값 0으로 꺼집니다.

반복자 어댑터

반복자의 동작을 바꾸는 반복자 어댑터(iterator adapter)도 있습니다. 앞에서 본 역방향 반복자가 그 하나이고, std::back_inserter(v)가 만드는 삽입 반복자는 값을 대입할 때마다 v.push_back을 불러 원소를 추가합니다. 알고리즘이 결과를 쓸 위치로 삽입 반복자를 넘기면, 미리 공간을 만들지 않아도 결과가 컨테이너에 추가됩니다. 이 쓰임은 다음 글의 알고리즘에서 봅니다.

정리

  • 반복자는 컨테이너의 원소를 가리키는 객체로, *it로 접근하고 ++it로 넘어가며 [begin(), end()) 범위를 순회합니다.
  • end()는 원소가 아니므로 간접 참조할 수 없고, 빈 컨테이너에서는 begin()도 간접 참조할 수 없습니다.
  • 반복자는 입력, 순방향, 양방향, 임의 접근, 연속의 순서로 능력이 늘며, 알고리즘은 필요한 종류를 요구합니다.
  • const_iterator는 원소를 바꾸지 못하게 하고, 역방향 반복자는 마지막 원소부터 거꾸로 순회합니다.
  • 컨테이너를 바꾸면 반복자가 무효가 될 수 있으며, 그 범위는 컨테이너와 연산마다 다르므로 바꾼 뒤에는 필요한 반복자를 다시 얻습니다.
  • 순회하며 지울 때는 erase가 돌려준 반복자로 이어 가거나, C++20의 std::erase_if를 씁니다.

연습 문제

1. 값 바꾸기와 마지막 원소 지우기

invalidation.cpp의 처음 값을 {4, 5, 6}으로, 추가하는 값을 7로 바꾸면 세 줄은 무엇을 출력하나요? 마지막 원소를 erase해 돌려받은 반복자를 간접 참조해도 되나요?

해설 보기

출력은 4 4, 6, 4 6 7입니다. 마지막 원소를 지우면 다음 원소가 없으므로 erase는 end()를 돌려주는데, 이는 올바른 반환값이지만 원소를 가리키지 않으므로 간접 참조하면 안 됩니다. 그래서 예제처럼 end()와 비교한 뒤에 씁니다.

2. 잘못된 삭제 루프

다음 코드로 짝수를 지우면 어떤 문제가 생기나요? v는 std::vector<int>라고 하고, erase를 부른 바로 뒤에 루프가 it로 무엇을 하는지를 따라가 보세요.

bad_erase.cpp
for (auto it = v.begin(); it != v.end(); ++it) {
    if (*it % 2 == 0) {
        v.erase(it);
    }
}
해설 보기

v.erase(it) 뒤에 it는 무효가 되었는데, 루프가 그 반복자에 ++it를 해 정의되지 않은 동작이 됩니다. 운 좋게 동작하는 것처럼 보여도, 연속된 짝수를 건너뛰거나 마지막 원소를 지운 뒤 end()를 넘어가는 문제가 생길 수 있습니다. it = v.erase(it);로 돌려받은 반복자를 쓰고, 지우지 않았을 때만 ++it하도록 고칩니다.

3. 리스트의 반복자

std::list<std::string> names{"Banana", "Cherry"};에서 auto it = names.begin();으로 Banana를 가리킨 뒤 names.push_front("Apple");을 하면, *it는 무엇인가요? 같은 일을 std::vector로 하면 어떤가요?

해설 보기

std::list는 원소를 넣어도 기존 반복자가 무효가 되지 않으므로 it는 여전히 Banana를 가리킵니다. std::vector에는 push_front가 없고, insert(v.begin(), "Apple")로 앞에 넣으면 넣은 위치와 그 뒤의 반복자가 무효가 되므로 it를 쓸 수 없습니다. 원소의 위치가 바뀌지 않아야 하는 경우에 std::list를 고르는 이유입니다.

다음 글 알고리즘에서는 반복자 범위에 정렬, 검색, 변환 같은 연산을 적용하는 표준 알고리즘과, 결과를 쓸 공간을 준비하는 방법을 다룹니다.