[C++] std::iterator, 왜 사용할까?

iterator, 왜 사용할까?

iterator의 개념

먼저 C++로 코딩을 하신다고 하면, 반복자 iterator에 대해서 꼭 아시는게 좋습니다. iterator는 C++98 이전부터 있어왔던 포인터의 일종입니다. 매우 강력한 기능이고, 의외로 C++을 사용할 때 C와 다르게 포인터를 사용할 일이 정말 특이한 경우가 아니라면 거의 없는데, iterator는 정말 많이 사용합니다.
iterator에서 가장 많이 사용하는 메소드는 begin()end()인데, 각각 컨테이너의 시작점, 그리고 끝점 + 1 칸을 리턴합니다.

(그림 출처: cppreference vector begin, end)

end()가 가장 끝점이 아닌, 가장 끝점에서 한 칸 더 나아간 위치를 리턴한다는 것을 주의하십시오. 그리고 iterator가 포인터인 만큼, 리턴하는 값이 주소이지 값(value) 타입이 아니란 것도 주의해야 합니다.

또한 iterator는 operator++ 연산도 지원합니다. ++ 연산 시 iterator가 한 칸 더 이동합니다.

iterator를 다음과 같이 사용할 수 있습니다.


int main()
{
    std::vector<int> vec = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
    std::list<int> lis = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
    std::array<int, 10> arr = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
    std::string str = "Hello World";

    std::cout << "Vector: ";
    for (auto iter = vec.begin(); iter != vec.end(); iter++) {
        std::cout << *iter << " ";
    }
    std::cout << std::endl;

    std::cout << "List: ";
    for (auto iter = lis.begin(); iter != lis.end(); iter++) {
        std::cout << *iter << " ";
    }
    std::cout << std::endl;

    std::cout << "Array: ";
    for (auto iter = arr.begin(); iter != arr.end(); iter++) {
        std::cout << *iter << " ";
    }
    std::cout << std::endl;

    std::cout << "String: ";
    for (auto iter = str.begin(); iter != str.end(); iter++) {
        std::cout << *iter << " ";
    }
    std::cout << std::endl;

    return 0;
}

결과:


Vector: 1 2 3 4 5 6 7 8 9 10 
List: 1 2 3 4 5 6 7 8 9 10 
Array: 1 2 3 4 5 6 7 8 9 10 
String: H e l l o   W o r l d

참고로 일반 C-스타일의 배열은 iterator를 지원하지 않습니다;; 우리가 C-스타일 어레이를 버리고 std::array를 사용해야만 하는 이유죠.

그리고 위 예제에서 볼 수 있듯이, iterator는 list에도 지원합니다. 또한 사실 다음 예제와 같이, map/set, unordered_map/set, span 등 거의 대부분의 STL 컨테이너가 모두 지원합니다.


int main()
{
    std::unordered_map<int, int> hashmap;
    hashmap[1] = 1;
    hashmap[2] = 2;
    hashmap[3] = 3;
    std::unordered_set<int> hashset;
    hashset.emplace(1);
    hashset.emplace(2);
    hashset.emplace(3);
    std::map<int, int> map;
    map[1] = 1;
    map[2] = 2;
    map[3] = 3;
    std::set<int> set;
    set.emplace(1);
    set.emplace(2);
    set.emplace(3);

    std::cout << "hashmap: " << std::endl;
    for (auto iter = hashmap.begin(); iter != hashmap.end(); iter++) {
        std::cout << iter->first << " " << iter->second << std::endl;
    }

    std::cout << "hashset: " << std::endl;
    for (auto iter = hashset.begin(); iter != hashset.end(); iter++) {
        std::cout << *iter << std::endl;
    }

    std::cout << "map: " << std::endl;
    for (auto iter = map.begin(); iter != map.end(); iter++) {
        std::cout << iter->first << " " << iter->second << std::endl;
    }

    std::cout << "set: " << std::endl;
    for (auto iter = set.begin(); iter != set.end(); iter++) {
        std::cout << *iter << std::endl;
    }

    return 0;
}

결과:


hashmap: 
3 3
2 2
1 1
hashset: 
3
2
1
map: 
1 1
2 2
3 3
set: 
1
2
3

begin, cbegin, rbegin

begin(), end()의 예시는 위에서 살펴보았습니다.

한편 읽기 전용 iterator(const_iterator)라 해서, iterator가 가리키는 값을 수정할 수 없는 iterator도 존재합니다. cbegin(), cend()가 그것입니다.

std::vector<int> vec = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
for (auto iter = vec.cbegin(); iter != vec.cend(); iter++) {
    std::cout << *iter << " ";
}

이렇게 되면 값을 수정할 수 없으므로 더 안전한 프로그래밍을 할 수 있겠죠. 그리고 역방향 iterator(reverse iterator)라 해서 역방향으로 iterator가 돌 수도 있습니다.

(그림 출처: cppreference vector rbegin)

std::vector<int> vec = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
for (auto iter = vec.rbegin(); iter != vec.rend(); iter++) {
    std::cout << *iter << " ";
}

결과:

10 9 8 7 6 5 4 3 2 1

수정할 수 없는 역방향 iterator의 경우 crbegin(), crend()라 합니다.

range base for loop

사실 만약 range base for loop을 사용했다면, 이미 iterator를 적극적으로 사용하고 계신 겁니다.

범위 기반 for loop의 내부 구조를 까보면, iterator와 완전히 동일하게 작동하거든요.


int main()
{
    std::vector<int> vec = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
    std::cout << "vec: ";
    for (const auto &i : vec) {
        std::cout << i << " ";
    }
    std::cout << std::endl << "map: " << std::endl;
    std::unordered_map<int, int> map;
    map.emplace(1, 2);
    map.emplace(2, 3);
    map.emplace(3, 4);
    for (const auto& [key, value] : map) {
        std::cout << key << " " << value << std::endl;
    }

    return 0;
}

결과:


vec: 1 2 3 4 5 6 7 8 9 10 
map: 
3 4
2 3
1 2

참고로


for (auto i : vec) {
    std::cout << i << std::endl;
}

for (auto i = vec.begin(); i != vec.end(); ++i) {
    std::cout << *i << std::endl;
}

위 코드는 어셈블리 구조상 완전히 동일합니다. 또한 cppreference에서 range base for loop의 구현을 보시면

(출처: cppreference range base for)
C++17 전까지만 해도 위에서 본 iterator 사용 패턴과 완벽하게 동일한 것을 알 수 있고, 그 이후에도 조금씩의 형태 변화는 있었지만 거의 동일하단 것을 알 수 있습니다.

한편 C++에서 반복문을 돌 때 가장 최적화가 잘 된 것은 range base for loop입니다. 따라서 range base for loop을 사용할 수 있는 상황이라면 최대한 range base for loop을 사용하시면 됩니다.

그런데 이 특히 vector에서 iterator, range base for loop을 절대 사용해선 안 되는 경우가 있습니다. 바로 push, emplace 등의 연산을 하는 경우인데 이 부분에 대한 이야기는 iterator에 push, emplace를 사용하면 안 되는 이유라는 글을 참고하세요.

iterator 정확한 선언

우리가 지금껏 iterator에 대해서 열심히 사용했는데, 전부 iterator를 auto로만 선언해와서 정확한 선언 방식을 모를 때가 많습니다. 사실 그 선언이 너무 복잡해서 그냥 auto로 선언하는 게 제일 베스트인데, 그래도 선언 타입은 다음과 같습니다.
std::container_name::iterator iter;
container_name은 iterator가 가리키고자 하는 컨테이너 종류입니다.
예시를 몇 가지 보여주자면

std::vector<int>::iterator v_iter = vec.begin();
std::list<int>::iterator l_iter = lst.begin();
std::deque<int>::iterator d_iter = deq.begin();

사실 워낙 길고 복잡하여서 그냥 auto 쓰는게 제일 안전하고 가독성도 좋아 보입니다.

iterator의 다른 유용한 사용 예시

iterator는 여러 cpp의 기본 제공 STL 라이브러리와 호응이 잘 맞습니다.

find

find 함수는 조건에 맞는 iterator를 반환합니다.

int main()
{
    std::vector<int> vec = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
    auto it = std::find(vec.begin(), vec.end(), 5);

    if (it != vec.end())
        std::cout << "Found " << *it << " at position " << std::distance(vec.begin(), it) << std::endl;
    else
        std::cout << "Not found" << std::endl;

    return 0;
}

결과:

Found 5 at position 5

참고로 보통 iterator를 반환할 때, 만약 조건에 해당하는 대상이 없다면 보통 end()를 반환합니다. 따라서 대상이 있는지 없는지를 체크할 때는 end()를 찾으면 됩니다.

distance

distance 함수는 iterator 사이의 거리를 반환합니다.

int main()
{
    std::vector<int> vec = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9};

    const auto dist = std::distance(vec.begin(), vec.end());
    std::cout << "Distance: " << dist << std::endl;

    const auto iter5 = std::find(vec.begin(), vec.end(), 5);
    const auto dist_To_5 = std::distance(vec.begin(), iter5);
    std::cout << "Distance to 5: " << dist_To_5 << std::endl;

    return 0;
}

결과:

Distance: 10
Distance to 5: 5

for_each

for_each는 일종의 반복문인데, 역시나 cpp에서 가장 최적화가 잘 되어 있는 반복문 중 하나이므로 사용할 수 있으면 사용하시면 됩니다. 정확히 말하자면, for_each는 일정 범위 내에 주어진 함수를 수행합니다.

int main()
{
    std::vector<int> vec = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
    std::for_each(vec.begin(), vec.end(), [](int i) { std::cout << i << " "; });

    return 0;
}

결과:

0 1 2 3 4 5 6 7 8 9

참고로 이런 STL 라이브러리들은 C++20부터 도입된 range의 개념으로 더 강력해졌는데, 추후 글에서 이에 대해 소개할 수 있으면 소개하겠습니다.
또 다른 유용한 기능들이 많지만, 본 글은 STL과 다른 표준 라이브러리를 소개하는게 아닌 iterator에 대해서 소개하는 글이기 때문에 여기까지만 소개하겠습니다. 필요한 경우 직접 찾아보시면 될 것 같습니다.

마무리

사실 iterator는 C++에 STL 라이브러리가 도입될 때, 그러니깐 대략 C++98 부근부터 존재했었으므로 모던 C++ 카테고리에 들어가는게 맞는지는 모르겠습니다. 그런데 이 카테고리의 다른 글들이 C++의 여러 기능들을 소개하고 있으므로, 그냥 해당 카테고리 안에 편입시켰습니다.