블로그 메인으로
블로그 메인으로

C++ 반복자는 왜 그렇게 헷갈리는 걸까?

약 11분 분량 · 작성 · 수정 #C++#언어

현재 블로그 이전 작업 중입니다. 이전이 어느 정도 완료되면 https://eatch.dev/s/iter 링크를 통해서도 접속 가능하도록 조치하겠습니다.

이 글의 인터랙티브 콘탠츠는 데스크톱 환경에 최적화되어 있으며, 모바일 환경에서 사용하기 어려울 수 있습니다.

에츠허르 데이크스트라Edsger W. Dijkstra번호 붙이기를 0부터 시작해야 하는 이유Why numbering should start at zero라는 글을 남긴 적이 있습니다. 전체 내용은 별도의 보충글에 정리해 두었고, 이 중 글과 관련이 있는 부분을 발췌하면 이렇습니다.

자연수의 부분수열 2, 3, ..., 12를 위험천만한 줄임표 없이 나타낼 때, 다음 4종류의 방식 중 하나를 고를 수 있다.

(중략)

자연수에는 최솟값이 존재한다. b와 d와 같이 하한을 제외할 경우 가장 작은 자연수부터 시작하는 부분수열의 하한이 비자연수가 되어야 한다. 이는 더러우니 하한은 a와 c처럼 ≤로 표기하는 것을 선택한다. 한편 가장 작은 자연수부터 시작하는 부분수열을 생각하자. 상한을 포함할 경우 부분수열이 빈 수열이 되었을 때 상한이 비자연수가 되어야 한다. 이는 더러우니 상한은 a와 d처럼 <로 표기하는 것을 선택한다. 이로써 방식 a를 가장 우선하는 것으로 결론지을 수 있다.

처음 접하면 다소 비직관적일 수 있는 C++의 반복자 역시 이 글의 내용으로 어느 정도 설명할 수 있습니다. 하지만...

참고

std::lower_boundstd::upper_bound의 정의

std::lower_boundstd::upper_bound가 어떻게 정의되어 있는지 궁금하시다면 cppreference.com의 정의를 참고할 수 있습니다. 이때 (어떤 조건 f에 대해) 분할되었다partitioned는 것은 어떤 점을 기준으로 왼쪽의 모든 원소 l에 대해 f(l)이 참, 오른쪽의 모든 원소 r에 대해 f(r)이 거짓임을 의미합니다.

[value를 기준으로] 분할된 범위 [first, last)에서 value 앞에 오지 않는 첫 원소를 찾는다.

Searches for the first element in the partitioned range [first, last) which is not ordered before value.

cppreference.com 중 std::lower_bound 항목

분할된 범위 [first, last)에서 value 뒤에 오는 첫 원소를 찾는다.

Searches for the first element in the partitioned range [first, last) which is ordered after value.

cppreference.com 중 std::upper_bound 항목

역방향 반복자에 대한 설명까지 날로 먹을 수는 없었습니다.

이 네 가지 의문점이 제가 C++의 STL을 처음 배우면서 가장 헷갈렸던 부분인데, 네이버 블로그를 사용하던 시절에 같은 주제로 글을 올린 적이 있습니다. 물론 역방향 반복자에 대해서도 '한 칸 앞을 참조해야 .end()를 시작점으로, .begin()을 끝점 너머로 재사용할 수 있다'는 설명을 할 수 있지만, 이 글에서는 반복자를 이해하는 조금 더 통합적인 시각을 제시하려고 합니다.

다른 멘탈 모델로 보기

모르는 개념을 새로 배울 때는 이미 알던 개념을 멘탈 모델로 삼아 비유하는 것이 큰 도움이 됩니다. 제가 반복자를 처음 배울 때 만들었던 멘탈 모델은 마인크래프트 핫바였습니다.

마인크래프트 게임 내 핫바의 모습. 9개의 사각형이 가로로 붙어 있고 이 중 일부에 아이템이 들어 있다. 선택되어 있는 아이템이 더 크고 밝은 사각형으로 강조되어 있다.

제가 만들었던 비유는 다음과 같습니다.

사실 이렇게 의식적으로 생각하면서 멘탈 모델을 정했다기보다는 그동안 마인크래프트를 했던 경험이 쌓여 반복자를 배우자마자 무의식적으로 연결된 것에 가깝습니다.

유감스럽게도 이 멘탈 모델은 완벽하지 않고, 자연스럽게 위 단락에서 제시했던 네 가지 의문점을 가지게 됩니다.

결국 저는 반복자를 더 직관적으로 이해하기 위해 다른 멘탈 모델을 찾기 시작했고, 그 결과가 위에서 링크한 구 블로그 글입니다. 당시의 글에서 제시했고 지금도 사용하고 있는 멘탈 모델은 텍스트 에디터의 커서입니다. 지금부터는 커서로 어떻게 반복자를 설명할 수 있는지 하나씩 짚어보겠습니다.

참고

이 글에서는 마우스 포인터(역시 '커서')나 인공지능 기반 IDE 커서Cursor를 다루지 않으며, '커서'라고 하면 항상 텍스트 커서를 의미합니다.

가·감산

C++의 반복자 분류 중 이 글과 관련이 있는 것은 가장 포괄적인 것부터 다음과 같은 4(+1)종류입니다.

이 글에서는 증감 연산부터 다루도록 하겠습니다.

에디터를 클릭하고 키보드로 커서를 움직여 보세요. 배열이 진해진 상태라면 포커스가 잘 들어왔다는 의미입니다.

아마도 이게 어딜 봐서 반복자냐고 하실 분들이 있을지도 모르겠습니다. (자료구조를 가리키는) 반복자라면 무릇 무언가를 가리키고 있어야 할 텐데, 지금 이 커서가 도대체 무엇을 가리키고 있나요? 문자와 문자 사이?

사실 이 글에서 읽기 연산보다도 증감 연산을 먼저 다룬 것은, 커서를 움직일 수 없는 텍스트 에디터는 상상하기조차도 어렵기 때문에 설명의 편의를 위해 부득이하게 순서를 바꿨기 때문입니다. 바로 다음 단락에서 읽기 연산을 설명드릴 테니 일단 들어보세요. 좌우 방향키로 커서를 이동하는 것이 반복자의 증감 연산에 대응한다는 것까지 의심하시지는 않으리라고 믿겠습니다.

읽기 연산

아래아한글을 써보신 경험이 있다면 삽입insert 모드수정overtype 모드라는 개념을 들어보았을 것이라고 생각합니다. '제대로 된' 웬만한 텍스트 에디터에는 모두 있는 기능인데, Visual Studio Code는 의외로 출시된 지 9년 7개월(!!)이 지나서야 지원하기 시작했습니다.

삽입 모드는 우리가 항상 사용하는 친숙한 모드로, 문자를 입력하면 문자와 문자 사이에 삽입insert됩니다. 한편 수정 모드로 진입하면 세로선 모양이었던 커서가 블록 모양이 되어 원래 위치 바로 뒤의 문자가 깜박이고, 이때 문자를 입력하면 깜박이는 문자가 지워지고 새로운 문자로 덧씌워집니다overtype. 이 깜박이는 문자가 바로 반복자가 가리키는 데이터입니다. 아까 보았던 인터랙티브 에디터에 수정 모드 기능을 추가해 보겠습니다.

수정 모드를 켠 채로 커서를 끝점으로 움직여보면 커서가 블록 모양을 유지하지 않고 세로선 모양으로 돌아오는 것을 볼 수 있습니다. '다음 문자'가 없기 때문인데, 잠깐 생각해보면 바로 이 위치가 끝점 너머 반복자라는 것을 알 수 있습니다.

임의 접근

임의 접근 반복자는 증감 연산자로 한 칸씩만 이동하는 대신 덧·뺄셈 연산자로 임의 칸만큼 이동할 수 있는데, Vim을 제외하면 이런 기능을 지원하는 에디터는 거의 없습니다. 그 대신 여기서는 마우스 클릭으로 원하는 위치에 커서를 가져다놓는 것에 대신 비유할 수 있겠습니다.

쓰기 연산

에디터(editor)라고 했으니 더 미루지 말고 편집 기능을 추가해 보겠습니다(마참내!). 여기서는 0부터 9까지의 숫자를 입력할 수 있으며, 위에서 소개한 삽입 모드와 수정 모드 이외에도 다른 모드를 하나 더 지원하고 각각 C++에서 지원하는 반복자 연산에 비유할 수 있습니다.

조작 설명이 점점 길어지고 있으니 다음부터는 기본적으로 접어 두도록 하겠습니다. 설명을 확인하려면 에디터 아래의 '▶ 조작' 부분을 누르면 됩니다.

역방향 반복자

반복자의 기본 연산을 모두 다뤘으니 지금부터는 심화된 동작을 다뤄보도록 하겠습니다.

std::reverse_iterator (cf. std::make_reverse_iterator)로 만드는 역방향 반복자의 가장 이상한 점은 역참조(*it)했을 때 한 칸 앞의 원소를 가리킨다는 점인데, 지금까지 다룬 커서 비유로 따지면 수정 모드로 진입했을 때 깜박이는 문자가 커서 오른쪽이 아니라 왼쪽이 되는 꼴과 같습니다. 그런데 왜 애초에 커서 오른쪽 문자가 깜박일까요? 글을 쓸 때 왼쪽에서 오른쪽으로 쓰기 때문에 다음으로 편집할 내용인 오른쪽 문자가 자연스럽게 선택되는 것은 아닐까요?

저를 포함해서 이 블로그의 독자분들은 좌횡서left-to-right 문자 체계에(만) 익숙할 것이라고 생각하는데, 거꾸로 오른쪽에서 왼쪽으로 쓰는 우횡서right-to-left 문자 체계도 있습니다. 아랍 문자와 히브리 문자가 대표적인 예시고, W3C에서는 2025년 6월 12일 기준 12개의 우횡서 문자 체계를 제시하고 있습니다. 우횡서 문자 체계로도 컴퓨터를 사용할 수 있어야 하기 때문에 텍스트 에디터도 여기에 대응하고 있는데, 좌횡서 문자 체계를 사용할 때와 다른 점은 다음과 같습니다.

양방향 커서가 활성화된 상태에서 구글에 접속한 화면. 커서의 상단에 오른쪽으로 가로선이 뻗어 있다.

그러면 과연 우횡서 문자 체계를 쓰다가 수정 모드로 진입하면 커서의 왼쪽 문자가 깜박일까요? 네!!!

참고로 위의 움짤에서 커서가 잠깐 순간이동하는 것처럼 보이는 것은 좌횡서가 주인 텍스트 안에 우횡서 텍스트가 포함되어 있는 것으로 판정되었기 떄문으로 보입니다.

(사실 위의 움짤을 찍으려고 우횡서와 수정 모드 커서를 모두 지원하는 소프트웨어를 찾아서 한참을 헤맸습니다. 사용한 소프트웨어는 GIMP 3.0입니다.)

지금까지의 내용으로 미루어보아 역방향 반복자는 같은 자료구조를 좌횡서 대신 우횡서 관점으로 보는 것에 비유할 수 있겠습니다. 아래의 텍스트 에디터에서는 Ctrl+R로 좌횡서(정방향)와 우횡서(역방향)를 전환할 수 있고, 커서의 방향 표시로 현재 사용 중인 방향을 확인할 수 있습니다. 실제 텍스트 에디터의 우횡서 동작을 최대한 비슷하게 모사하려고 노력하긴 했는데 잘못 구현했다면 꼭 알려주세요.

범위 지정

마지막으로 다룰 것은 반복자를 사용한 범위 지정입니다. C++에서는 자료구조 안의 범위를 표현할 때 반복자 2개를 사용해 시작과 끝을 표현하는데, 이는 텍스트를 블록 지정하는 것에 비유할 수 있습니다. 사실 블록 지정을 한다고 커서가 2개 생기는 것은 아니지만(Visual Studio Code 등 진짜 멀티커서를 지원하는 텍스트 에디터도 있습니다), 에디터 내부에서는 커서의 위치를 정수 하나로 표현했듯 선택한 범위의 시작점과 끝점 역시 정수 하나씩으로 표현할 수 있다는 점에서 유효한 비유라고 생각합니다.

아래 에디터에도 범위 지정 기능을 추가해 두었고, Shift 키나 마우스 드래그로 원하는 범위의 텍스트를 선택할 수 있습니다.

범위 연산

보통 텍스트 에디터에서 블록 지정을 하면 무슨 작업을 하나요?

이외에도 여러 가지 기능을 지원할 수 있는데, 이런 블록 편집 기능은 C++에서는 반복자 범위를 받는 알고리즘 함수에 대응합니다. 아래 에디터에서는 C++의 여러 가지 알고리즘 함수 중 일부를 구현해 두었습니다. 영역을 선택한 상태에서...

상한과 하한

이쯤에서 std::lower_boundstd::upper_bound의 정의를 한 번 더 살펴보기로 하겠습니다.

참고

std::lower_boundstd::upper_bound의 정의

std::lower_boundstd::upper_bound가 어떻게 정의되어 있는지 궁금하시다면 cppreference.com의 정의를 참고할 수 있습니다. 이때 (어떤 조건 f에 대해) 분할되었다partitioned는 것은 어떤 점을 기준으로 왼쪽의 모든 원소 l에 대해 f(l)이 참, 오른쪽의 모든 원소 r에 대해 f(r)이 거짓임을 의미합니다.

[value를 기준으로] 분할된 범위 [first, last)에서 value 앞에 오지 않는 첫 원소를 찾는다.

Searches for the first element in the partitioned range [first, last) which is not ordered before value.

cppreference.com 중 std::lower_bound 항목

분할된 범위 [first, last)에서 value 뒤에 오는 첫 원소를 찾는다.

Searches for the first element in the partitioned range [first, last) which is ordered after value.

cppreference.com 중 std::upper_bound 항목

지금까지 보았던 반복자의 커서 비유에서 어떤 원소 x를 가리키는 반복자는 x보다 앞에 오는 부분과 x를 포함해서 x보다 뒤에 오는 부분을 분할하는 것으로도 볼 수 있습니다. 이를 염두에 두고 '앞에 온다'와 '뒤에 온다'를 각각 '미만'과 '초과'를 의미하는 것으로 생각하면 위의 정의는 다음과 같이 해석할 수 있습니다.

즉, std::lower_boundstd::upper_bound의 순서쌍을 반환하는 std::equal_range는 주어진 범위를 value 미만인 부분, value와 동일한 부분, value를 초과하는 부분으로 3분할하는 것으로 생각할 수 있고, 반환되는 두 반복자로 이루어진 범위는 value와 같은 부분에 해당한다는 것을 직관적으로 이해할 수 있습니다.

아래의 정렬-미리된 배열™에서 Ctrl+Shift+0~9를 입력해서 그 숫자만큼의 std::equal_range를 실행해볼 수 있습니다. 혹시 주어진 숫자의 분포가 너무 치우쳐 있거나 다른 배열에서 실험해 보고 싶다면 왼쪽 아래의 다시 생성 버튼을 누르면 됩니다.

복습해보기

지금까지 언급했던 모든 내용을 정리해 봅시다. 마인크래프트 핫바보다 텍스트 커서가 반복자를 훨씬 더 잘 설명할 수 있었습니다.

마지막으로 아래의 에디터에는 지금까지 소개한 모든 기능을 모두 켜 두겠습니다. Ctrl+Shift+0~9를 입력하면 선택하기 전에 텍스트 전체에 대해 먼저 정렬을 수행합니다.

지금까지 커서 멘탈 모델이 정말 효과적이었던 탓인지 현재의 저는 프로그래밍 언어 안에서 범위를 지정할 때 가능한 한 무조건 a 방식을 사용하고(c 방식을 사용하는 Haskell 리스트 문법은 각성하라), 심지어는 C에서도 포인터를 C++ 반복자를 쓰는 느낌으로 사용합니다. (저는 백준 주력 언어가 C++가 아니라 C입니다!)

백준 온라인 저지에 제출한 C 소스 코드의 일부. 매개변수로 포인터를 입력받고 포인터를 반환하는 lower_bound와 upper_bound를 직접 구현하였다.

아무튼 제 결론은 이렇습니다. 커서 멘탈 모델 받아들이고 광명 찾자

생성형 AI 투명성

이 글을 작성하는 데 생성형 AI를 다음과 같이 사용했습니다.

간접 사용

Claude를 사용해 임의 칸만큼 커서 이동 명령을 지원하는 텍스트 에디터의 종류에 대해 보조 조사를 진행했습니다.