컨테이너를 걷는 방법
std::vector, std::map, std::set, std::unordered_map을 배웠다면 이제 이런 생각이 들 수 있습니다.
"컨테이너마다 내부 구조가 다른데, C++은 어떻게 같은 방식으로 순회할 수 있을까?"
vector는 배열처럼 원소가 나란히 놓여 있습니다. map은 보통 트리 구조로 구현됩니다. unordered_map은 해시 테이블을 사용합니다. 내부 모습이 전혀 다르니 원소를 찾아가는 방식도 다를 수밖에 없습니다.
그런데 C++에서는 다음과 같은 코드가 자연스럽게 동작합니다.
#include <iostream>
#include <vector>
#include <map>
int main() {
std::vector<int> scores{90, 80, 100};
for (auto it = scores.begin(); it != scores.end(); ++it) {
std::cout << *it << 'n';
}
std::map<std::string, int> ages{
{"Kim", 20},
{"Lee", 25},
{"Park", 22}
};
for (auto it = ages.begin(); it != ages.end(); ++it) {
std::cout << it->first << ": " << it->second << 'n';
}
}여기서 it이 바로 반복자(iterator)입니다. 반복자는 컨테이너 안의 특정 원소 위치를 가리키는 객체입니다. 겉모습은 포인터와 비슷합니다. *it으로 원소에 접근하고, ++it으로 다음 원소로 이동합니다. 하지만 반복자는 단순한 포인터가 아닙니다. 컨테이너의 내부 구조를 알고, 그 컨테이너에 맞는 방식으로 다음 원소를 찾아가는 "길잡이"입니다.
STL에서 반복자는 매우 중요합니다. 컨테이너는 데이터를 담고, 알고리즘은 데이터를 처리합니다. 반복자는 이 둘을 이어 주는 공통 언어입니다.
std::vector<int> numbers{3, 1, 4, 1, 5};
// begin()과 end()가 범위를 만들고,
// 알고리즘은 그 범위 안에서 동작한다.
std::sort(numbers.begin(), numbers.end());std::sort는 vector라는 컨테이너 자체를 직접 받지 않습니다. 대신 numbers.begin()부터 numbers.end()까지의 범위를 받습니다. 이것이 STL의 핵심 감각입니다. 컨테이너와 알고리즘은 반복자로 만납니다.

반복자는 포인터처럼 생겼다
반복자를 처음 볼 때는 포인터처럼 생각하면 이해가 쉽습니다.
#include <iostream>
#include <vector>
int main() {
std::vector<int> numbers{10, 20, 30};
auto it = numbers.begin();
std::cout << *it << 'n'; // 10
++it;
std::cout << *it << 'n'; // 20
++it;
std::cout << *it << 'n'; // 30
}numbers.begin()은 첫 번째 원소를 가리키는 반복자를 반환합니다. *it은 반복자가 가리키는 원소를 뜻합니다. ++it은 다음 원소로 이동하라는 뜻입니다.
포인터를 배웠다면 다음 코드가 떠오를 수 있습니다.
int arr[]{10, 20, 30};
int* p = arr;
std::cout << *p << 'n';
++p;
std::cout << *p << 'n';반복자의 사용법은 이와 닮았습니다. 하지만 중요한 차이가 있습니다. 포인터는 메모리 주소를 직접 다루는 도구이고, 반복자는 컨테이너가 제공하는 순회 도구입니다. vector의 반복자는 실제 포인터와 매우 비슷하게 동작할 수 있지만, map의 반복자는 트리의 다음 노드를 찾아가야 합니다. unordered_map의 반복자는 해시 버킷을 건너다닐 수도 있습니다.
그래서 반복자를 "포인터처럼 쓰는 객체"라고 생각하면 좋습니다. 포인터와 닮았지만, 컨테이너마다 지켜야 할 규칙이 있습니다.
std::map<std::string, int> scoreByName{
{"Kim", 90},
{"Lee", 80}
};
for (auto it = scoreByName.begin(); it != scoreByName.end(); ++it) {
std::cout << it->first << " scored " << it->second << 'n';
}map의 원소는 키와 값의 쌍입니다. 따라서 *it은 std::pair<const std::string, int>와 비슷한 객체를 뜻합니다. 이때 it->first는 키, it->second는 값을 의미합니다.
std::cout << (*it).first << 'n';
std::cout << it->first << 'n';위 두 줄은 같은 뜻입니다. it->first는 (*it).first를 더 편하게 쓴 표현입니다. 포인터에서 구조체 멤버에 접근할 때 ->를 쓰는 것과 같은 감각입니다.
begin과 end가 만드는 반열린 구간
반복자를 제대로 이해하려면 begin()과 end()를 정확히 알아야 합니다.
begin()은 첫 번째 원소를 가리킵니다. 반면 end()는 마지막 원소를 가리키지 않습니다. end()는 마지막 원소 바로 다음 위치를 나타냅니다.
std::vector<int> v{10, 20, 30};
// v.begin() -> 10
// 20
// 30
// v.end() -> 마지막 다음 위치이 구조를 C++에서는 흔히 반열린 구간이라고 부릅니다. 수학식처럼 쓰면 [begin, end)입니다. 시작은 포함하고, 끝은 포함하지 않는 범위입니다.
처음에는 이상해 보일 수 있습니다. 왜 end()가 마지막 원소를 가리키지 않을까요? 이유는 순회가 훨씬 깔끔해지기 때문입니다.
for (auto it = v.begin(); it != v.end(); ++it) {
std::cout << *it << 'n';
}이 코드는 이렇게 읽으면 됩니다.
"첫 번째 원소에서 시작해서, 끝 표시를 만나기 전까지 반복하라."
end()가 마지막 다음 위치를 가리키면 빈 컨테이너도 자연스럽게 표현됩니다.
std::vector<int> empty;
if (empty.begin() == empty.end()) {
std::cout << "비어 있습니다.n";
}비어 있는 컨테이너에는 첫 번째 원소가 없습니다. 따라서 시작 위치와 끝 위치가 같습니다. 이 규칙 덕분에 같은 반복문으로 빈 컨테이너도 안전하게 처리할 수 있습니다.
주의할 점은 분명합니다. end()는 실제 원소가 아닙니다. 따라서 *v.end()처럼 역참조하면 안 됩니다.
std::vector<int> v{10, 20, 30};
// 잘못된 코드
// std::cout << *v.end() << 'n';end()는 "여기서 멈춰야 한다"는 표지판이지, 밟고 지나갈 수 있는 땅이 아닙니다.
반복자 타입 직접 쓰기
앞의 예제에서는 auto를 사용했습니다. 실제 반복자 타입은 꽤 길기 때문입니다.
std::vector<int> numbers{10, 20, 30};
std::vector<int>::iterator it = numbers.begin();이 코드에서 std::vector<int>::iterator가 반복자 타입입니다. std::vector<int> 안에 정의된 iterator라는 뜻입니다. 컨테이너마다 반복자 타입이 다르므로 이렇게 컨테이너 이름 안쪽에 붙어 있습니다.
map은 타입이 더 길어집니다.
std::map<std::string, int> ages;
std::map<std::string, int>::iterator it = ages.begin();그래서 반복자를 사용할 때는 auto가 매우 유용합니다.
for (auto it = ages.begin(); it != ages.end(); ++it) {
std::cout << it->first << ": " << it->second << 'n';
}여기서 auto는 타입을 대충 쓰겠다는 뜻이 아닙니다. 컴파일러가 오른쪽 식을 보고 정확한 타입을 추론하라는 뜻입니다. 반복자처럼 타입 이름이 길고 명확한 경우에는 auto가 코드를 훨씬 읽기 좋게 만듭니다.
물론 auto를 쓴다고 해서 반복자의 규칙이 사라지는 것은 아닙니다. it이 어떤 컨테이너의 반복자인지, 그 반복자로 원소를 수정할 수 있는지, 삽입이나 삭제 뒤에도 유효한지 생각해야 합니다.
iterator와 const_iterator
반복자는 크게 두 가지 관점에서 볼 수 있습니다. 하나는 원소를 수정할 수 있는 반복자이고, 다른 하나는 읽기만 가능한 반복자입니다.
std::vector<int> numbers{10, 20, 30};
for (std::vector<int>::iterator it = numbers.begin(); it != numbers.end(); ++it) {
*it += 1;
}iterator는 원소를 수정할 수 있습니다. 위 코드는 모든 원소에 1을 더합니다.
반면 const_iterator는 원소를 읽을 수는 있지만 수정할 수 없습니다.
std::vector<int> numbers{10, 20, 30};
for (std::vector<int>::const_iterator it = numbers.cbegin();
it != numbers.cend();
++it) {
std::cout << *it << 'n';
// 오류: const_iterator로는 원소를 수정할 수 없다.
// *it += 1;
}cbegin()과 cend()는 읽기 전용 반복자를 반환합니다. 여기서 c는 const를 뜻합니다.
auto를 쓰면 더 간단합니다.
for (auto it = numbers.cbegin(); it != numbers.cend(); ++it) {
std::cout << *it << 'n';
}읽기만 할 생각이라면 cbegin()과 cend()를 사용하는 습관이 좋습니다. 코드를 읽는 사람에게 "이 순회에서는 원소를 바꾸지 않는다"는 의도가 드러납니다.
함수에서도 이 차이가 중요합니다.
#include <iostream>
#include <vector>
void printNumbers(const std::vector<int>& numbers) {
for (auto it = numbers.begin(); it != numbers.end(); ++it) {
std::cout << *it << 'n';
}
}여기서 numbers는 const std::vector<int>&입니다. 함수 안에서 numbers를 수정할 수 없습니다. 따라서 numbers.begin()도 수정 가능한 iterator가 아니라 읽기 전용 const_iterator를 반환합니다.
즉, 컨테이너가 const이면 순회도 자연스럽게 읽기 전용이 됩니다.
void increaseAll(std::vector<int>& numbers) {
for (auto it = numbers.begin(); it != numbers.end(); ++it) {
*it += 1;
}
}
void printAll(const std::vector<int>& numbers) {
for (auto it = numbers.cbegin(); it != numbers.cend(); ++it) {
std::cout << *it << 'n';
}
}두 함수의 의도는 다릅니다. increaseAll은 원소를 바꾸므로 수정 가능한 참조를 받습니다. printAll은 읽기만 하므로 const 참조를 받습니다. 반복자도 이 설계에 맞춰 선택해야 합니다.
좋은 C++ 코드는 "바꿀 수 있는 것"과 "바꾸면 안 되는 것"을 타입으로 구분합니다. iterator와 const_iterator는 그 구분을 순회 코드에 적용한 도구입니다.
range-based for문은 반복자를 감싼 문법이다
C++에서는 반복자를 직접 쓰지 않고도 컨테이너를 쉽게 순회할 수 있습니다.
std::vector<int> numbers{10, 20, 30};
for (int n : numbers) {
std::cout << n << 'n';
}이 문법을 범위 기반 for문이라고 부릅니다. 이미 앞에서 여러 번 사용했습니다. 이제 이 문법이 반복자와 어떻게 연결되는지 볼 차례입니다.
위 코드는 대략 다음과 같은 반복자 코드와 비슷한 의미입니다.
for (auto it = numbers.begin(); it != numbers.end(); ++it) {
int n = *it;
std::cout << n << 'n';
}즉, 범위 기반 for문은 내부적으로 begin()과 end()를 이용해 순회합니다. 반복자 문법을 더 읽기 좋게 감싼 형태라고 볼 수 있습니다.
하지만 여기에는 중요한 선택이 숨어 있습니다.
for (auto n : numbers) {
n += 10;
}이 코드는 numbers의 원소를 바꾸지 않습니다. n은 각 원소의 복사본이기 때문입니다. 원소를 수정하려면 참조를 사용해야 합니다.
for (auto& n : numbers) {
n += 10;
}읽기만 할 때는 const auto&가 좋은 기본 선택입니다.
for (const auto& n : numbers) {
std::cout << n << 'n';
}작은 int 정도는 복사해도 부담이 거의 없습니다. 하지만 std::string이나 직접 만든 클래스 객체는 복사 비용이 생길 수 있습니다. 읽기만 한다면 const auto&를 사용하면 불필요한 복사를 피하면서 수정도 막을 수 있습니다.
std::vector<std::string> names{"Kim", "Lee", "Park"};
for (const auto& name : names) {
std::cout << name << 'n';
}범위 기반 for문을 사용할 때의 감각은 다음과 같이 정리할 수 있습니다.
| 형태 | 의미 | 주로 쓰는 경우 |
|---|---|---|
auto x | 원소를 복사 | 작고 복사해도 되는 값 |
auto& x | 원소를 수정 가능한 참조로 사용 | 원소를 바꿔야 할 때 |
const auto& x | 원소를 읽기 전용 참조로 사용 | 객체 복사를 피하고 읽기만 할 때 |
반복자를 직접 쓰는 코드는 더 세밀한 제어가 필요할 때 유용합니다. 예를 들어 순회 중에 원소를 삭제하거나, 현재 위치를 저장하거나, 특정 위치에 삽입해야 할 때는 반복자를 직접 다루게 됩니다. 반대로 단순히 전체 원소를 읽거나 수정하는 정도라면 범위 기반 for문이 더 읽기 쉽습니다.
컨테이너마다 반복자의 능력이 다르다
모든 반복자가 똑같이 움직일 수 있는 것은 아닙니다. vector의 반복자는 배열처럼 이동할 수 있습니다.
std::vector<int> v{10, 20, 30, 40};
auto it = v.begin();
std::cout << *(it + 2) << 'n'; // 30vector는 원소가 연속된 메모리에 놓이므로 it + 2처럼 여러 칸을 한 번에 이동할 수 있습니다. 이런 반복자를 임의 접근 반복자라고 부릅니다. 이름은 어렵지만 감각은 간단합니다. 배열 인덱스처럼 빠르게 원하는 위치로 이동할 수 있다는 뜻입니다.
반면 map의 반복자는 그렇게 움직일 수 없습니다.
std::map<std::string, int> ages{
{"Kim", 20},
{"Lee", 25},
{"Park", 22}
};
auto it = ages.begin();
++it; // 가능
// 오류: map 반복자는 it + 2를 지원하지 않는다.
// auto next = it + 2;map은 내부적으로 정렬된 구조를 유지합니다. 다음 원소로 이동하는 ++it은 가능하지만, "두 칸 뒤로 바로 점프"하는 방식은 지원하지 않습니다.
그래서 알고리즘마다 요구하는 반복자 능력이 다릅니다. 예를 들어 std::sort는 임의 접근 반복자가 필요합니다. 따라서 vector에는 사용할 수 있지만 list나 map의 반복자 범위에는 그대로 사용할 수 없습니다.
std::vector<int> v{3, 1, 2};
std::sort(v.begin(), v.end()); // 가능
std::map<std::string, int> m;
// std::sort(m.begin(), m.end()); // 불가능이 사실은 STL을 배울수록 중요해집니다. 컨테이너는 저마다 장점이 있고, 반복자는 그 컨테이너의 구조에 맞는 이동 방법을 제공합니다. 반복자는 공통 언어이지만, 모든 컨테이너를 똑같은 속도와 똑같은 방식으로 다루게 해 주는 마법은 아닙니다.
반복자로 원소 찾기
반복자는 순회뿐 아니라 "찾은 위치"를 표현할 때도 사용됩니다.
#include <algorithm>
#include <iostream>
#include <vector>
int main() {
std::vector<int> numbers{10, 20, 30, 40};
auto it = std::find(numbers.begin(), numbers.end(), 30);
if (it != numbers.end()) {
std::cout << "찾았습니다: " << *it << 'n';
} else {
std::cout << "찾지 못했습니다.n";
}
}std::find는 값을 찾으면 그 위치를 가리키는 반복자를 반환합니다. 찾지 못하면 end()를 반환합니다. 여기서도 end()는 "없음"을 표현하는 표지판 역할을 합니다.
이 패턴은 STL 전체에서 자주 등장합니다.
auto it = std::find(v.begin(), v.end(), target);
if (it == v.end()) {
// 찾지 못함
} else {
// 찾음, *it 사용 가능
}map에서는 컨테이너가 자체적으로 find 멤버 함수를 제공합니다.
std::map<std::string, int> scores{
{"Kim", 90},
{"Lee", 80}
};
auto it = scores.find("Kim");
if (it != scores.end()) {
std::cout << it->first << ": " << it->second << 'n';
}map::find도 찾으면 해당 원소의 반복자를 반환하고, 못 찾으면 end()를 반환합니다. 이처럼 반복자는 "현재 위치"뿐 아니라 "검색 결과"를 표현하는 데에도 쓰입니다.
반복자 무효화란 무엇인가?
반복자를 사용할 때 가장 조심해야 할 주제는 반복자 무효화(iterator invalidation)입니다.
반복자는 컨테이너 안의 특정 위치를 가리킵니다. 그런데 컨테이너의 구조가 바뀌면 기존 위치 정보가 더 이상 유효하지 않을 수 있습니다. 이때 그 반복자는 무효화되었다고 말합니다.
가장 대표적인 예는 vector입니다.
std::vector<int> numbers{10, 20, 30};
auto it = numbers.begin();
numbers.push_back(40);
// 위험할 수 있음
// std::cout << *it << 'n';왜 위험할까요? vector는 원소를 연속된 메모리에 저장합니다. 그런데 push_back을 했을 때 기존 공간이 부족하면 더 큰 메모리 공간을 새로 확보하고, 기존 원소를 그곳으로 옮깁니다. 그러면 예전 반복자는 옛날 메모리 위치를 가리키게 됩니다. 그 위치는 더 이상 vector의 원소가 아닐 수 있습니다.
이것이 무효화입니다. 겉으로는 it이라는 변수가 아직 남아 있지만, 그 안의 위치 정보는 더 이상 믿을 수 없습니다.
std::vector<int> numbers;
numbers.reserve(10);
numbers.push_back(10);
numbers.push_back(20);
auto it = numbers.begin();
numbers.push_back(30);
// reserve로 충분한 공간을 확보했다면 재할당은 일어나지 않을 수 있다.
// 하지만 이런 세부 조건에 기대기보다,
// 삽입 후에는 필요한 반복자를 다시 얻는 습관이 안전하다.
std::cout << *numbers.begin() << 'n';reserve를 사용하면 재할당 가능성을 줄일 수 있습니다. 하지만 입문 단계에서는 "vector에 삽입해서 구조가 바뀌면 기존 반복자를 조심하자"라는 감각이 더 중요합니다.
삭제도 마찬가지입니다.
std::vector<int> numbers{10, 20, 30, 40};
auto it = numbers.begin() + 1; // 20을 가리킴
numbers.erase(it);
// it는 더 이상 사용하면 안 된다.
// std::cout << *it << 'n'; // 위험erase로 원소를 삭제하면 그 뒤의 원소들이 앞으로 당겨질 수 있습니다. 따라서 삭제된 위치와 그 뒤쪽을 가리키던 반복자는 더 이상 믿을 수 없습니다.
안전한 삭제 패턴
순회하면서 원소를 삭제해야 하는 상황은 자주 나옵니다. 예를 들어 점수가 60점 미만인 학생을 목록에서 제거한다고 해 봅시다.
잘못 작성하기 쉬운 코드는 다음과 같습니다.
std::vector<int> scores{90, 40, 70, 50, 100};
for (auto it = scores.begin(); it != scores.end(); ++it) {
if (*it < 60) {
scores.erase(it); // 위험한 패턴
}
}이 코드는 문제가 있습니다. erase(it)를 호출하면 it가 무효화됩니다. 그런데 반복문의 끝에서 다시 ++it가 실행됩니다. 무효화된 반복자를 증가시키는 셈입니다.
vector::erase는 삭제한 원소 다음 위치를 가리키는 반복자를 반환합니다. 이 반환값을 받아야 안전합니다.
#include <iostream>
#include <vector>
int main() {
std::vector<int> scores{90, 40, 70, 50, 100};
for (auto it = scores.begin(); it != scores.end(); ) {
if (*it < 60) {
it = scores.erase(it);
} else {
++it;
}
}
for (int score : scores) {
std::cout << score << ' ';
}
}이 반복문에는 증감식이 없습니다.
for (auto it = scores.begin(); it != scores.end(); )대신 본문 안에서 직접 이동합니다. 삭제했다면 erase가 반환한 다음 위치로 이동합니다. 삭제하지 않았다면 ++it로 다음 원소로 이동합니다.
이 패턴은 반드시 익숙해져야 합니다.
for (auto it = container.begin(); it != container.end(); ) {
if (삭제해야_한다면) {
it = container.erase(it);
} else {
++it;
}
}이 구조를 쓰면 삭제 직후에 무효화된 반복자를 사용하지 않습니다. 또한 연속으로 삭제해야 하는 원소도 안전하게 처리할 수 있습니다.
예를 들어 다음처럼 60점 미만이 연속으로 있어도 문제없이 지워집니다.
std::vector<int> scores{90, 40, 50, 70};
for (auto it = scores.begin(); it != scores.end(); ) {
if (*it < 60) {
it = scores.erase(it);
} else {
++it;
}
}40을 지우면 50이 그 자리로 당겨집니다. erase는 바로 그 50의 위치를 반환합니다. 따라서 다음 반복에서 50도 검사할 수 있습니다.
vector 삽입과 삭제에서 특히 조심할 점
vector는 빠르고 편리한 컨테이너입니다. 하지만 삽입과 삭제에서는 내부 원소 이동이 일어날 수 있습니다.
std::vector<int> v{10, 20, 30};
auto it = v.begin() + 1; // 20 위치
v.insert(it, 15);insert는 지정한 위치 앞에 새 원소를 삽입합니다. 위 코드는 20 앞에 15를 넣습니다. 결과는 {10, 15, 20, 30}입니다.
하지만 삽입 과정에서 재할당이 일어날 수 있습니다. 재할당이 일어나면 기존 반복자는 모두 무효화될 수 있습니다. 재할당이 일어나지 않더라도 삽입 위치와 그 뒤쪽 반복자는 영향을 받을 수 있습니다.
입문 단계에서는 다음 규칙을 기억하면 충분합니다.
| 작업 | 조심할 점 |
|---|---|
push_back | 공간이 부족해 재할당되면 기존 반복자, 포인터, 참조가 무효화될 수 있다. |
insert | 삽입 위치 이후의 반복자는 무효화될 수 있고, 재할당되면 더 넓게 무효화된다. |
erase | 삭제된 원소와 그 뒤쪽 위치를 가리키던 반복자는 무효화될 수 있다. |
이 규칙은 반복자뿐 아니라 포인터와 참조에도 영향을 줍니다.
std::vector<std::string> names{"Kim", "Lee"};
std::string& first = names[0];
auto it = names.begin();
names.push_back("Park");
// push_back 중 재할당이 일어났다면 first와 it 모두 위험해질 수 있다.vector의 원소를 가리키는 참조, 포인터, 반복자는 컨테이너의 재할당 앞에서 모두 조심해야 합니다. 그래서 vector에 원소를 추가하거나 삭제한 뒤에는 기존 위치 정보를 계속 사용해도 되는지 확인해야 합니다.
가장 안전한 습관은 구조 변경 뒤에 필요한 반복자를 다시 얻는 것입니다.
std::vector<int> v{10, 20, 30};
v.push_back(40);
auto it = v.begin(); // 변경 후 새로 얻는다.작은 습관처럼 보이지만, 실제 프로그램에서는 이런 차이가 버그를 크게 줄입니다.
map과 set의 반복자 감각
map과 set은 vector와 내부 구조가 다릅니다. 원소가 연속된 배열처럼 저장되지 않습니다. 그래서 중간 삽입 때문에 뒤쪽 원소들이 줄줄이 밀리는 구조가 아닙니다.
std::set<int> numbers{30, 10, 20};
for (auto it = numbers.begin(); it != numbers.end(); ++it) {
std::cout << *it << ' ';
}set은 정렬된 순서로 순회됩니다. 위 코드는 보통 10 20 30 순서로 출력됩니다.
map도 키 기준으로 정렬된 순서로 순회됩니다.
std::map<std::string, int> ages{
{"Lee", 25},
{"Kim", 20},
{"Park", 22}
};
for (const auto& pair : ages) {
std::cout << pair.first << ": " << pair.second << 'n';
}map의 원소는 키와 값의 쌍입니다. 범위 기반 for문에서는 보통 const auto& pair로 받습니다. 이때 pair.first가 키, pair.second가 값입니다.
C++17부터는 구조적 바인딩을 사용해 더 읽기 좋게 쓸 수 있습니다.
for (const auto& [name, age] : ages) {
std::cout << name << ": " << age << 'n';
}[name, age]는 쌍으로 묶인 값을 각각의 이름으로 풀어 받는 문법입니다. map을 순회할 때 특히 편리합니다.
for (auto& [name, age] : ages) {
age += 1;
}값은 바꿀 수 있지만 키는 바꿀 수 없습니다. map에서 키는 정렬 위치를 결정합니다. 만약 키를 마음대로 바꿀 수 있다면 map의 정렬 구조가 깨질 수 있습니다. 그래서 map 원소의 키는 사실상 읽기 전용입니다.
for (auto& [name, age] : ages) {
// name = "Choi"; // 불가능하다고 생각하는 편이 좋다.
age += 1; // 값 수정은 가능
}컨테이너의 성격은 반복자 사용법에도 드러납니다. vector는 위치와 순서가 중요하고, map은 키와 값의 관계가 중요하며, set은 중복 없는 값의 집합이라는 성격이 중요합니다.
end를 비교 대상으로 사용하기
반복자에서 end()는 자주 등장합니다. 특히 검색과 순회에서 end()는 "끝" 또는 "찾지 못함"을 표현합니다.
std::vector<std::string> names{"Kim", "Lee", "Park"};
auto it = std::find(names.begin(), names.end(), "Lee");
if (it != names.end()) {
std::cout << "이름: " << *it << 'n';
}여기서 중요한 순서가 있습니다. 먼저 it != names.end()인지 확인하고, 그 다음에 *it를 사용해야 합니다.
잘못된 코드는 다음과 같습니다.
// 잘못된 코드
// std::cout << *it << 'n';
// if (it != names.end()) { ... }찾지 못한 경우 it는 end()입니다. end()를 역참조하는 것은 잘못입니다. 따라서 반복자에서 값을 꺼내기 전에는 그 반복자가 실제 원소를 가리키는지 확인해야 합니다.
이 감각은 포인터의 null 검사와 비슷합니다. 포인터를 사용하기 전에 null인지 확인하듯, 검색 결과 반복자를 사용하기 전에는 end()인지 확인합니다.
auto it = scores.find("Han");
if (it == scores.end()) {
std::cout << "점수가 없습니다.n";
} else {
std::cout << it->second << 'n';
}반복자를 안전하게 쓰는 코드는 대부분 이 작은 확인에서 시작합니다.
반복자를 함수 인자로 받기
반복자는 범위를 표현할 수 있으므로 함수의 인자로도 사용할 수 있습니다.
#include <iostream>
#include <vector>
void printRange(std::vector<int>::const_iterator first,
std::vector<int>::const_iterator last) {
for (auto it = first; it != last; ++it) {
std::cout << *it << ' ';
}
std::cout << 'n';
}
int main() {
std::vector<int> numbers{10, 20, 30, 40, 50};
printRange(numbers.begin(), numbers.end());
printRange(numbers.begin() + 1, numbers.begin() + 4);
}두 번째 호출은 20 30 40을 출력합니다. begin() + 1은 두 번째 원소, begin() + 4는 다섯 번째 원소 위치입니다. 범위는 [first, last)이므로 마지막 위치는 포함되지 않습니다.
이 방식은 STL 알고리즘의 기본 형태입니다. 알고리즘은 컨테이너 전체를 몰라도 됩니다. 시작 반복자와 끝 반복자만 알면 그 구간을 처리할 수 있습니다.
다만 위 함수는 std::vector<int>에만 사용할 수 있습니다. 나중에 템플릿을 배우면 반복자 타입을 일반화해서 여러 컨테이너에 사용할 수 있는 함수를 만들 수 있습니다.
template <typename Iterator>
void printRange(Iterator first, Iterator last) {
for (auto it = first; it != last; ++it) {
std::cout << *it << ' ';
}
std::cout << 'n';
}이 코드는 아직 완전히 이해하지 않아도 괜찮습니다. 중요한 것은 반복자가 범위를 표현하고, 그 범위를 함수나 알고리즘에 넘길 수 있다는 점입니다. 템플릿과 제네릭 프로그래밍을 배우면 이 감각이 큰 힘을 발휘합니다.
안전한 순회 패턴 정리
반복자는 강력하지만, 몇 가지 규칙을 지켜야 안전합니다.
첫째, end()는 원소가 아닙니다. 비교 대상으로만 사용하고 역참조하지 않아야 합니다.
for (auto it = v.begin(); it != v.end(); ++it) {
std::cout << *it << 'n';
}둘째, 읽기만 한다면 const_iterator나 const auto&를 사용합니다.
for (auto it = v.cbegin(); it != v.cend(); ++it) {
std::cout << *it << 'n';
}
for (const auto& value : v) {
std::cout << value << 'n';
}셋째, 순회 중 삭제할 때는 erase의 반환값을 받습니다.
for (auto it = v.begin(); it != v.end(); ) {
if (*it < 0) {
it = v.erase(it);
} else {
++it;
}
}넷째, vector에 삽입하거나 삭제한 뒤에는 기존 반복자, 포인터, 참조가 계속 유효한지 의심해야 합니다.
auto it = v.begin();
v.push_back(100);
// it를 계속 쓰기 전에 무효화 가능성을 생각해야 한다.
it = v.begin();다섯째, 단순한 전체 순회는 범위 기반 for문을 우선 고려합니다.
for (const auto& item : items) {
std::cout << item << 'n';
}반복자를 직접 써야 하는 순간은 분명히 있습니다. 특정 위치에 삽입하거나 삭제할 때, 알고리즘의 결과 위치를 받아야 할 때, 부분 범위를 넘겨야 할 때입니다. 하지만 단순히 전체를 훑는 코드라면 범위 기반 for문이 더 간결하고 실수를 줄여 줍니다.
작은 실습: 이름 목록에서 짧은 이름 제거하기
마지막으로 반복자 삭제 패턴을 이용해 간단한 프로그램을 만들어 봅시다. 이름 목록에서 길이가 3보다 짧은 이름을 제거하겠습니다.
#include <iostream>
#include <string>
#include <vector>
int main() {
std::vector<std::string> names{"Kim", "Jo", "Lee", "A", "Park", "Choi"};
for (auto it = names.begin(); it != names.end(); ) {
if (it->size() < 3) {
it = names.erase(it);
} else {
++it;
}
}
for (const auto& name : names) {
std::cout << name << 'n';
}
}여기서 it->size()는 (*it).size()와 같은 뜻입니다. 반복자가 가리키는 std::string 객체의 size() 함수를 호출하는 것입니다.
프로그램은 다음과 같은 흐름으로 동작합니다.
Kim -> 길이 3, 유지
Jo -> 길이 2, 삭제
Lee -> 길이 3, 유지
A -> 길이 1, 삭제
Park -> 길이 4, 유지
Choi -> 길이 4, 유지결과적으로 Kim, Lee, Park, Choi가 남습니다.
이 예제에는 반복자의 핵심이 모두 들어 있습니다. begin()에서 시작하고, end() 전까지 순회하며, *it 또는 it->로 원소에 접근합니다. 삭제할 때는 erase의 반환값을 받아 다음 위치로 이동합니다. 출력할 때는 범위 기반 for문과 const auto&를 사용해 읽기 전용 순회를 표현합니다.
반복자는 STL의 공통 언어다
반복자를 배우면 STL의 구조가 한층 선명해집니다. 컨테이너는 데이터를 담습니다. 반복자는 데이터의 범위를 가리킵니다. 알고리즘은 그 범위를 처리합니다.
std::vector<int> numbers{5, 1, 4, 2, 3};
std::sort(numbers.begin(), numbers.end());
auto it = std::find(numbers.begin(), numbers.end(), 4);
if (it != numbers.end()) {
std::cout << "찾은 값: " << *it << 'n';
}이 짧은 코드 안에서 컨테이너, 반복자, 알고리즘이 함께 움직입니다. vector가 데이터를 담고, begin()과 end()가 범위를 만들고, sort와 find가 그 범위를 처리합니다.
반복자는 포인터처럼 보이지만 포인터보다 더 넓은 개념입니다. 컨테이너마다 내부 구조가 달라도, 반복자라는 공통 약속을 통해 같은 방식으로 순회하고 검색하고 처리할 수 있습니다.
다음 단계에서는 이 반복자를 본격적으로 사용하는 알고리즘 라이브러리를 살펴봅니다. std::sort, std::find, std::count, std::transform, std::remove_if 같은 도구를 배우면 "for문을 직접 쓰는 코드"에서 "의도를 알고리즘으로 표현하는 코드"로 한 걸음 나아가게 됩니다. 반복자는 그 문을 여는 열쇠입니다.
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.
