CodeGym /행동 /C++ SELF /이터레이터: begin/end, cbegin/cend

이터레이터: begin/end, cbegin/cend

C++ SELF
레벨 13, 레슨 3
사용 가능

1. 이터레이터가 왜 필요하고 어떻게 구성되는가

가끔은 이렇게 생각하기 쉽습니다: “for (const auto& x : v) 를 쓰면 되잖아, 삶 끝났네.” 그리고 종종 그 말은 맞습니다. 하지만 부분 컨테이너만 순회해야 하거나, 찾은 요소에서 멈춰야 하거나, 제자리에서 요소를 신중히 변경해야 하거나, STL 코드를 읽어야 할 때처럼 더 많은 제어가 필요할 때가 있습니다. 이터레이터는 순회를 위한 “수동 조작 모드”와 같아서 코드가 약간 길어지지만 더 많은 가능성을 제공합니다.

생각해보면 range-for는 “첫 요소” 역에서 “마지막 요소” 역까지 정해진 경로로 가는 기차이고, 이터레이터는 개인용 자전거와 같습니다: 속도, 정차, 경로를 스스로 선택할 수 있습니다. 물론 가끔 헬멧(즉 end() 검사)을 잊을 수 있지만 우리는 배우는 중입니다.

이터레이터를 “일반화된 포인터”로 보기: 마법 없는 아이디어

"이터레이터"라는 단어는 초보자를 겁주려고 만든 것처럼 들리기도 합니다(가끔 사실이긴 합니다). 하지만 아이디어는 단순합니다: 이터레이터는 컨테이너의 위치를 가리키는 객체입니다. 포인터와 비슷한 연산을 제공하므로 자주 비교됩니다: 다음 요소로 이동할 수 있고(++it), 현재 요소를 얻을 수 있고(*it), 다른 위치와 비교할 수 있습니다(it != end).

왜 “일반화된”가 붙을까요? 포인터는 연속 메모리(배열)에서만 제대로 동작하는 반면, 이터레이터는 다양한 컨테이너를 위한 단일 인터페이스이기 때문입니다. std::vector는 요소를 연속으로 저장하고, std::array도 마찬가지지만, STL에는 더 복잡한 컨테이너도 있습니다. 오늘은 vector/array 범위 내에서 이터레이터의 가장 실용적인 부분을 살펴봅니다.

작은 비유: 포인터는 집의 주소이고 이터레이터는 현재 집을 알려주고 "다음 집으로 이동"하라고 말해주는 내비게이터입니다. 어떤 지역(컨테이너)에서는 "다음 집"을 계산하기가 간단하고, 다른 곳에서는 더 복잡하지만 우리에게는 인터페이스가 동일합니다.

begin()과 end(): "첫"과 "마지막 뒤"

이터레이터가 유용하려면 컨테이너가 두 가지 핵심 위치를 제공할 수 있어야 합니다: 시작과 끝. C++에서는 이것을 begin()과 end() 쌍으로 제공합니다.

하나의 중요한 점을 이해하는 것이 매우 중요합니다(그걸 이해하면 나머지는 논리적입니다): end()는 마지막 요소가 아닙니다. 이것은 마지막 요소 바로 다음 위치입니다. 즉 경계, 정지선, 출구 문 같은 것이지 요소가 아닙니다.

머릿속에 두기 좋은 그림은 다음과 같습니다:

begin() -> [ elem0 ][ elem1 ][ elem2 ] -> end()
           ^                      ^
          *를 사용할 수 있음       end()는 역참조할 수 없음

이렇게 설계된 이유는 루프를 쓰기 아주 편하기 때문입니다: "end()에 도달할 때까지 작업한다". 만약 끝이 “마지막 요소”였다면 루프가 경계와 항상 헷갈렸을 수 있습니다.

간단한 예: begin()과 end()가 존재하고 우리가 하나에서 다른 하나로 갈 수 있다는 것을 보여줍니다.

#include <iostream>
#include <vector>

int main() {
    std::vector<int> v{10, 20, 30};

    auto it = v.begin();
    std::cout << *it << '\n'; // 10
}

여기서 it는 첫 요소를 가리키며, *it는 그 요소를 읽습니다. 포인터처럼 보이죠?

이터레이터 루프의 뼈대: "초기화 → 조건 → 증감"

인덱스 기반 루프를 쓸 때는 카운터(i)를 만들고 i < size()를 비교하며 ++i를 합니다. 이터레이터 루프에서 카운터 역할은 이터레이터(it), 경계 역할은 end(), 그리고 증감 역할은 ++it가 담당합니다.

전형적인 뼈대는 다음과 같습니다:

for (auto it = v.begin(); it != v.end(); ++it) {
    // 작업: *it 사용
}

그리고 맞습니다, 이때 auto가 진짜로 유용해집니다: 이터레이터 타입은 길고 지금은 명시할 필요가 없습니다.

간단한 예: 모든 요소 출력.

#include <iostream>
#include <vector>

int main() {
    std::vector<int> v{10, 20, 30};

    for (auto it = v.begin(); it != v.end(); ++it) {
        std::cout << *it << ' ';
    }
    std::cout << '\n'; // 10 20 30
}

여기서 ++it는 접두사 증감입니다. 일반적인 int에서는 큰 차이가 없지만, C++ 스타일 이터레이터에서는 접두사 ++it를 쓰는 것이 관례이며 많은 경우 후위 증감보다 비용이 적을 수 있습니다.

간단한 흐름도는 다음과 같습니다:

flowchart TD
    A["it = begin() 얻기"] --> B{"it != end()인가?"}
    B -- 아니요 --> E[루프 종료]
    B -- 예 --> C[*it 사용]
    C --> D[++it]
    D --> B

2. 이터레이터를 통한 요소 접근

역참조 *it: 읽기와 수정

*it를 쓰면 현재 요소를 얻습니다. 중요한 차이점은 대부분의 경우 *it는 복사본이 아니라 컨테이너 요소에 대한 접근이라는 점입니다. 즉 표현식 *it는 실제 요소를 가리키며 컨테이너가 const가 아니고 이터레이터가 const가 아니면 그 요소를 변경할 수 있습니다.

읽기와 수정을 비교하기 위해 간단한 교육용 앱 "ToDo Lite"를 예로 들겠습니다. 작업들은 std::vector<std::string>의 문자열입니다.

먼저 단순 출력:

#include <iostream>
#include <string>
#include <vector>

int main() {
    std::vector<std::string> tasks{"buy milk", "learn iterators", "sleep"};

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

이제 각 작업에 접두사 "TODO: "를 추가해 보겠습니다.

#include <iostream>
#include <string>
#include <vector>

int main() {
    std::vector<std::string> tasks{"buy milk", "learn iterators"};

    for (auto it = tasks.begin(); it != tasks.end(); ++it) {
        *it = "TODO: " + *it;
    }

    for (const auto& s : tasks) {
        std::cout << s << '\n';
    }
    // TODO: buy milk
    // TODO: learn iterators
}

여기서 *it가 할당문의 왼쪽에 있는 것은 "컨테이너의 요소를 교체하라"는 의미입니다. 즉 벡터를 직접 수정하는 것입니다. 이터레이터는 바로 이런 상황에서 특히 유용합니다: 컨테이너 내의 "위치"를 명시적으로 다루기 때문입니다.

가독성을 위한 작은 트릭: 요소에 대한 참조를 바로 꺼내 쓰면 편리할 때가 있습니다.

#include <iostream>
#include <string>
#include <vector>

int main() {
    std::vector<std::string> tasks{"buy milk", "learn iterators"};

    for (auto it = tasks.begin(); it != tasks.end(); ++it) {
        auto& task = *it;          // task — 요소에 대한 참조
        task += " [ok]";             // 벡터의 요소를 변경
    }

    for (const auto& s : tasks) std::cout << s << '\n';
    // buy milk [ok]
    // learn iterators [ok]
}

이렇게 하면 코드가 더 읽기 쉬워집니다: task는 요소이며 우리가 그것을 수정하고 있습니다.

cbegin()/cend()와 const_iterator: "읽기 전용" 모드

때로는 컨테이너를 순회할 때 실수로 아무것도 변경하지 않도록 하고 싶을 때가 있습니다. 물론 조심하면 되지만 실제로 "조심하라"는 전략은 완벽하지 않습니다. 그래서 C++에는 의도를 명확히 고정할 수 있는 방법이 있습니다: 읽기 전용 순회입니다.

이를 위해 cbegin()과 cend()가 존재합니다 — 이들은 상수 이터레이터를 반환하며 이를 통해 요소를 변경할 수 없습니다. cbegin()/cend()의 동작은 표준 라이브러리에서 "더 명시적인 const-순회"로 규정되어 있습니다.

예: 작업들의 총 길이를 계산해 보겠습니다(읽기 전용).

#include <iostream>
#include <string>
#include <vector>

int main() {
    std::vector<std::string> tasks{"buy milk", "learn iterators"};

    std::size_t total = 0;
    for (auto it = tasks.cbegin(); it != tasks.cend(); ++it) {
        total += it->size(); // 또는 (*it).size() 처럼 쓸 수 있음
    }

    std::cout << total << '\n'; // 23
}

여기서 -> 연산자가 등장합니다. 이는 "역참조해서 멤버/메서드에 접근"을 의미합니다. 즉 it->size()는 (*it).size()와 동일하지만 더 간결하고 읽기 쉬운 경우가 많습니다.

중요한 점: 컨테이너 자체가 const이면 begin()도 읽기 전용 이터레이터를 반환합니다. 하지만 cbegin()을 사용하면 코드에서 의도를 명확히 표현할 수 있습니다: "요소를 변경하지 않겠다". 의도는 컴파일러가 사람보다 더 잘 읽는 경우가 있습니다.

수정이 실제로 금지되는 예를 보여줍니다:

#include <string>
#include <vector>

int main() {
    std::vector<std::string> tasks{"buy milk"};

    for (auto it = tasks.cbegin(); it != tasks.cend(); ++it) {
        // *it = "oops"; // 불가능: it는 "읽기 전용"
    }
}

주석은 단순한 장식이 아니라 실제로 주석을 제거하면 컴파일러가 "불가능"하다고 알려줄 것입니다.

작은 역사적 사실: 표준 라이브러리에는 std::cbegin/std::cend와 같은 자유 함수도 존재하며, 이러한 기능은 표준에 바로 추가된 것이 아니라 필요성에 대한 논의가 있었습니다. 기억해야 할 것은 "const-순회"가 실제로 중요하고 실용적인 관행이라는 것입니다.

3. range-for가 begin()/end()와 어떻게 연결되는가

range-for는 마법처럼 보이지만, 기본 아이디어는 동일합니다: 시작과 끝이 있고 우리는 begin에서 end까지 갑니다. 다시 말해 range-for는 "설탕 문법"(편리한 표기)이고 이터레이터는 그 설탕을 만드는 "원료"입니다.

매우 단순화하면 이렇게 생각할 수 있습니다:

for (auto x : v) { ... }

대략적으로 다음과 같습니다:

for (auto it = v.begin(); it != v.end(); ++it) {
    auto x = *it;
    ...
}

문자 그대로의 변환은 아니고 세부 사항이 있지만 학습 모델로는 훌륭합니다: 왜 range-for가 왼쪽에서 오른쪽으로 동작하는지, 왜 end()를 포함하지 않는지, 그리고 왜 "시작/끝"이 컨테이너 순회의 기초인지 이해할 수 있습니다.

실습으로 같은 ToDo 벡터를 확인해 보면: 먼저 이터레이터로, 그 다음 range-for로 같은 결과를 얻습니다.

#include <iostream>
#include <string>
#include <vector>

int main() {
    std::vector<std::string> tasks{"buy milk", "learn iterators"};

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

    for (const auto& t : tasks) {
        std::cout << t << '\n';
    }
}

남의 코드에서 이터레이터가 보인다고 해서 그 작성자가 당신을 벌주려는 것은 아닙니다. 보통은 range-for보다 약간 더 많은 제어가 필요해서 이터레이터를 쓴 것입니다.

4. 실습: 번호 매기기와 검색

교육용 애플리케이션은 보통 "리스트 주변에 약간의 로직이 필요해"라는 방향으로 성장합니다. ToDo 리스트에서는 보통 작업을 번호와 함께 출력하고 특정 단어를 포함한 첫 작업을 찾고 싶어합니다. 이터레이터는 바로 이런 상황에서 유용합니다: 정확히 멈춰야 할 위치에서 멈추고 그 위치를 저장할 수 있습니다.

번호와 함께 작업 출력

이터레이터 자체에는 기본적으로 인덱스가 들어있지 않으므로 간단히 카운터를 만듭니다:

#include <iostream>
#include <string>
#include <vector>

int main() {
    std::vector<std::string> tasks{"buy milk", "learn iterators", "sleep"};

    int index = 1;
    for (auto it = tasks.cbegin(); it != tasks.cend(); ++it) {
        std::cout << index << ") " << *it << '\n';
        ++index;
    }
    // 1) buy milk
    // 2) learn iterators
    // 3) sleep
}

출력은 분명히 "읽기 전용"이므로 cbegin()/cend()를 사용합니다. 이렇게 하면 컴파일러도 우리의 의도를 알게 됩니다.

부분 문자열을 포함한 첫 작업 찾기

나중에는 알고리즘을 사용해 더 예쁘게 할 수 있지만 오늘은 이터레이터의 메커니즘을 느끼기 위해 수동 루프를 사용합니다.

#include <iostream>
#include <string>
#include <vector>

int main() {
    std::vector<std::string> tasks{"buy milk", "learn iterators", "sleep"};
    std::string needle = "learn";

    auto it = tasks.begin();
    for (; it != tasks.end(); ++it) {
        if (it->find(needle) != std::string::npos) {
            break;
        }
    }

    if (it != tasks.end()) {
        std::cout << "Found: " << *it << '\n'; // Found: learn iterators
    } else {
        std::cout << "Not found\n";
    }
}

여기서 중요한 점이 몇 가지 있습니다.

첫째, 우리는 루프 밖에서 it를 따로 선언했습니다 — 루프 이후에도 결과를 사용하고 싶기 때문입니다. 만약 for 안에서 auto it = ...처럼 선언했다면 루프가 끝날 때 이터레이터의 유효 범위도 끝나서 결과를 확인할 수 없습니다.

둘째, 루프 후에 it != tasks.end()를 검사합니다. 이것은 핵심 패턴입니다: "끝까지 도달했다면 찾지 못한 것, 그보다 전에 멈췄다면 찾은 것." 그리고 이것은 end()가 "마지막 뒤"이기 때문에 가능하며, 따라서 "요소 없음"의 명확한 표시가 됩니다.

5. 이터레이터 사용 시 흔한 실수들

실수 #1: end()를 역참조하기 (그리고 일반적으로 먼저 *it 한 다음 생각하기).
가장 흔한 문제는 *it를 쓰기 전에 it가 실제 요소를 가리키는지 확인하지 않는 것입니다. 만약 it == v.end()라면 그것은 "마지막 뒤" 위치이고 요소가 없으므로 역참조는 잘못입니다. 습관을 들여야 합니다: 루프가 끝난 뒤 이터레이터를 사용하려면 먼저 end()와 비교하세요.

실수 #2: end()가 마지막 요소라고 기대하는 것.
이해하기 쉬운 실수입니다. 머릿속의 "end = 끝 = 마지막"이라는 생각 때문입니다. STL에서 이것은 "범위의 끝"이지 "마지막 요소"가 아닙니다. 따라서 루프 조건은 it != end()처럼 쓰고, it <= end()처럼 쓰지 말아야 하며 end를 처리에 포함하려고 하지 마세요. "end() = 마지막 뒤"라는 공식을 기억하면 대부분 경계 오류가 사라집니다.

실수 #3: cbegin()/cend()를 사용하고 나서 요소를 변경하려고 시도하는 것.
이것은 실수가 아니라 컴파일러가 친절하게 알려주는 "정지 신호"입니다. cbegin()를 골랐다면 순회는 읽기 전용이라는 의미입니다. 만약 수정이 필요하면 의도적으로 begin()/end()를 사용하세요.

실수 #4: ++it 증감을 빼먹어 무한 루프에 빠지는 것.
이터레이터 루프는 일반적인 for입니다: 증감이 필요합니다. 실수로 ++it를 빼먹으면 루프가 멈추지 않고 프로그램이 정지한 것처럼 보입니다. 구조(초기화, 조건, 증감)를 꼼꼼히 확인하세요.

실수 #5: 서로 다른 컨테이너(또는 서로 다른 벡터)의 이터레이터를 섞어 쓰는 것.
한 컨테이너의 이터레이터를 다른 컨테이너의 end()와 비교하는 것은 논리적으로 무의미합니다. 보통 변수 이름이 같아서(v, v2) 코드 조각을 복사하면서 생깁니다. 이터레이터를 해당 컨테이너와 가깝게 유지하고, 함수가 생기기 전까지는 이터레이터를 멀리 흩어놓지 마세요(그건 나중에 배웁니다).

1
과제
C++ SELF, 레벨 13, 레슨 3
잠금
인벤토리 출력
인벤토리 출력
1
과제
C++ SELF, 레벨 13, 레슨 3
잠금
사전의 길이
사전의 길이
1
과제
C++ SELF, 레벨 13, 레슨 3
잠금
대량 보정
대량 보정
1
과제
C++ SELF, 레벨 13, 레슨 3
잠금
첫 번째 매치
첫 번째 매치
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION