포괄적 프로그래밍과 표준 C++ 라이브러리

소개와 활용 III (학술주석판)

출판 정보

프로그램 세계 1999년 3월호에 실린 “The C++ Programming Language” 연재 기사의 일부입니다.

  • 1회: C++의 새로운 언어적 특징 (1998.5): 진화를 위한 기초작업
  • 2회: C++의 새로운 언어적 특징 (1998.6): 포괄적 프로그래밍을 위한 진화 I
  • 3회: C++의 새로운 언어적 특징 (1998.8): 포괄적 프로그래밍을 위한 진화 II
  • 4회: 포괄적 프로그래밍과 표준 C++ 라이브러리: 소개와 활용 I
  • 5회: 포괄적 프로그래밍과 표준 C++ 라이브러리 (1999.1): 소개와 활용 II
  • 6회: 포괄적 프로그래밍을 위한 프로그래밍 기법 I (1999.2): 체계적 단정문 기능
  • 7회: 포괄적 프로그래밍과 표준 C++ 라이브러리 (1999.3): 소개와 활용 III (본 기사)

여태까지 진행된 논의를 기반으로 실제의 표준 라이브러리에 준하는 iterator 인터페이스와 그 기능적 특성을 좀더 깊이 살펴보도록 하자.


Iterator와 재사용성 #

이전 기사를 통해 여러 번 언급한 바와 같이 C++ 표준 라이브러리는 Algorithm-Iterator-Container로 구성된다. 여기서 Iterator는 Container와 algorithm의 지나친 의존관계를 제거하는 인터페이스 역할을 한다. Algorithm-Iterator-Container (AIC) 아키텍처: 이는 STL의 정신적 핵심입니다. 25년 후 현대 언어도 같은 원칙을 따릅니다. Kotlin sequences, Rust iterators, JavaScript generators 모두 이 구조를 반영합니다. 쉽게 말하자면 algorithm에서 필요한 것은 일련의 원소이고 Container는 원소의 모임이다. 결국 algorithm이 필요로 하는 것은 컨테이너 그 자체가 아니라 컨테이너의 원소를 하나 씩 가져 와서 사용할 수 있는 제어 기능이다.

재사용성은 일반적으로 효율성의 손실로 이어진다. 그러나 이번C++라이브러리의 주요 부품군인 STL은 다르다. 효율성 vs 일반성의 해결: 이것이 STL의 최대 성과입니다. Dynamic polymorphism (virtual functions)은 비용이 있지만, template-based generic programming은 컴파일 타임에 특화되어 0 overhead abstraction을 제공합니다. 잘 아시다시피 STL은 Alexander Stepanov와 Meng Lee의 연구 성과다. 그 들은 C++ 언어 자체를 변화 시켜서라도 효율성과 일반성이라는 두 마리 토끼를 모두 잡으려고 무던한 노력을 하였다.

STL의 설계자들이 주목한 C++언어의 기능은 바로 template이었다. 그들은 재사용 라이브러리의 주된 설계 및 구현 모델인 객체지향적 언어기능에 거의 의존하지 않았다. 객체지향적 재 사용성은 한 마디로 subtyping polymorphism에 의존하는 것이고 subtyping polymorphism의 일반적인 구현전략은 method overriding을 위한 dynamic binding이다. Dynamic Binding의 비용: Virtual function calls은 함수 포인터 indirection이므로 최적화(inlining)를 방해합니다. C++11의 virtual 키워드 최적화와 C++20의 devirtualization techniques도 이 문제를 해결하려는 시도들입니다. Dynamic binding이란 결국 indirection이며 성능의 감소는 필연적이다. 이에 반해 template에 기반한 C++의 generic 또는 parametric polymorphism은 컴파일러가 안정적으로 지원하기만 한다면 효율성의 감소가 거의 없다.


배열의 포인터와 Iterator #

표준 C++ 라이브러리의 Iterator가 cursor라 불리는 모델에 따라 설계되었다는 점은 지난 호에 이미 설명하였다. Cursor 모델의 정의: Iterator는 “현재 위치(where)“를 나타내는 추상화입니다. SQL의 CURSOR, 데이터베이스의 seek pointer와 동일한 개념입니다. 이는 Range-based (begin/end)이 아닌 State-based (position) 모델입니다. Cursor 방식만을 두고 얘기한다면 Iterator란 “포인터의 추상화"에 불과하다.

포인터는 특정객체의 위치를 가리키는 객체다. 그러므로, 포인터 변수 p가 가리키는 원소를 얻어내려면:

*p

라고 operator*(dereference operator)(read/write)를 사용하면 된다. 다음으로, 만일 p가 배열의 특정원소를 가리키는 포인터 변수라면:

p++
++p

와 같이 위치이동을 하는 연산이 가능하다. Increment 연산의 두 가지 형태: p++ (post-increment)와 ++p (pre-increment)는 의미상 다릅니다. 포인터에서는 차이가 없지만, Iterator에서는 post-increment가 임시 객체를 생성하므로 비용이 더 클 수 있습니다.

또한 p는 배열의 포인터기 때문에 subscript (index) 연산자를 사용해서 random으로 다음과 같이 원소를 참조할 수 있다.

p[i] (== p + i)

부가적으로 C/C++의 배열에는 아주 심각한 문제가 있다는 점을 지적하고 넘어가야 하겠다. 배열의 근본적 문제: C 배열은 크기 정보를 잃어버립니다. int a[10]에서 int* p = a로 decay하면 크기 정보가 소실됩니다. 이는 C++11의 std::array<T, N>과 C++20의 std::span<T>로 해결되었습니다. 포인터변수를 사용해서 배열을 조작하는 경우 배열의 크기를 알 수가 없기 때문에 프로그래머는 포인터 연산을 하거나 indexing을 하는 경우 본래 배열의 범위를 넘지않는지 항시 검사할 필요가 있다.

int a[4] = { 1, 2, 3, 4 };
int* p= a;
int* const end = a + 4;
while (p != end) { ...; p++; }
[first, last) 관례: end = a + 4는 past-the-end pointer입니다. 이 [begin, end) 범위 관례는 STL의 모든 곳에서 사용되며, Python의 슬라이싱, Rust의 ranges, JavaScript의 Array.slice와도 일치합니다.

Iterator Category를 이해하자 #

C++ 표준 라이브러리가 제공하는 Iterator는 연산의 성질에 따라 다섯 가지로 분류된다. Five Iterator Categories: 이 분류는 1998년 표준화 과정에서 확립되었습니다. 참고: cppreference: Iterator categories InputIterator, OutputIterator, ForwardIterator, BidirectionalIterator, RandomAccessIterator.

struct InputIterator {};
struct OutputIterator {};
struct ForwardIterator : public InputIterator, OutputIterator {}
struct BidirectionalIterator : public ForwardIterator {}
struct RandomAccessIterator : public BidirectionalIterator {}
Iterator 계층구조의 오해: 이 inheritance는 개념적 설명일 뿐입니다. 실제 std::vector<T>::iterator가 어떤 클래스에서 상속되지는 않습니다. 이는 tag-based dispatch를 통해 구현됩니다.

위에서 제시한 분류방식을 이해하기 위해 iterator category마다 적용될 수 있는 연산을 살폅도록 하자.

Let p : OutputIterator
   v, *p : T
        *p =  v (write)
        ++p, p++  (iteration)

Let p, k : InputIterator
   v, *p : T
        v = *p (read)
        p->
        ++p, p++  (iteration)
        p == k, p != k (equality comparable)
OutputIterator의 특성: write-only입니다. 읽기가 보장되지 않습니다. std::ostream_iterator가 좋은 예입니다. Input/Output을 엄격히 분리한 것은 매우 순수한 디자인입니다.

ForwardIterator는 InputIterator이자 OutputIterator다. BidirectionalIterator는 ForwardIterator에 역방향 이동을 추가한 것이다. RandomAccessIterator는:

Let p, k : RandomAccessIterator, i : difference_type
        p[i]
        p += k, p -= k
        p < k, p > k, p >= k, p <= k (Ordered)
Random Access의 비용: 일반적으로 ForwardIterator는 O(n)이지만, RandomAccessIterator는 O(1) 접근을 보장합니다. 이는 알고리즘 선택에 직접적 영향을 미칩니다.

위와 같이 Iterator의 분류는 매우 직관적이고 배우기 쉽다. 문법이 일반적인 포인터의 조작과 크게 다를 바가 없기 때문이다. 사실 위와 같은 분류에 따르자면 포인터 변수는 RandomAccessIterator에 포함된다.


두 마리 토끼를 잡는 법: Tag Dispatch Pattern #

예를 들어 The C++ Programming Language 3rd에서의 제시하는 예를 살펴보자. distance는 주어진 두 iterator간의 range 값을 얻어내는 함수다.

template<class InputIterator>
inline typename Iterator::difference_type
distance(InputIterator first, InputIterator last)
{ // O(n) time complexity
    typename Iterator::difference_type dist = 0;
    while (first++ != last) dist++;
    return dist;
}

그러나 RandomAccessIterator라면 위와 같은 구현은 매우 비효율적이며 다음과 같이 간단한 연산으로 계산하는 편이 낫다.

template<class RandomAccessIterator>
inline typename RandomAccessIterator::difference_type
distance(RandomAccessIterator first, RandomAccessIterator last)
{ // O(1) time complexity
    return last - first;
}
Template Specialization의 한계: 같은 이름, 같은 파라미터 수로는 두 가지 template을 정의할 수 없습니다. 그래서 나중 섹션에서 보는 tag dispatch가 필요합니다. 현대 C++20 concepts는 이 문제를 우아하게 해결합니다.

문제는 위와 같이 동일한 이름의 동일한 parameter를 가진 template함수가 공존할 수 없다는 점이다. 그러므로 STL의 설계자들은 객체지향적 방법을 빌지않고 특정 category의 Iterator를 위해 특화된 구현을 제공할 수 있는 동시에 동일한 인터페이스를 제공할 수 있는 방법이 필요했다.

만일 Iterator를 n 만큼 이동하는 연산 advance()를 구현한다고 가정하자.

template<class UnknownIterator, class Distance>
inline void advance(UnknownIterator& i, Distance n) { ... }

문제는 각각의 Iterator마다 적합한 알고리즘이 다르다는 것이다. ForwardIterator라면 i를 n만큼 하나씩 선형적으로 이동하는 방법이외에는 없으며 i를 되감을 수는 없기 때문에 n은 항시 양의 정수여야 한다. 알고리즘의 복잡도 차이: ForwardIterator는 O(n), BidirectionalIterator도 O(n), 하지만 RandomAccessIterator는 O(1)입니다. 이런 차이를 runtime이 아닌 compile-time에 해결하는 것이 tag dispatch의 핵심입니다.

RandomAccessIterator는 보다 효율적인 구현이 가능하다. 앞의 두 함수는 O(n)의 계산방식이 필요하지만 이 경우엔 n을 i에 가감하는 것만으로 충분하다O(1).

결국 효율성과 일반성을 모두 잡아낼 수 있는 프로그래밍 기법이 필요한 것이다. C++ overloading 기능의 특성을 잘 활용하면 원하는 목표를 쉽게 달성할 수 있다. 아이디어 그 자체는 매우 단순하다. 실제 사용되지는 않지만 C++ 의 type deduction기능을 보조해주는 부가적인 인자를 하나 더 추가하는 것이다.

template<class InputIterator, class Distance>
inline void advance_with_tag(InputIterator& iter, Distance n, std::input_iterator_tag)
{
     while (n--) ++iter;
}
Tag Parameter: input_iterator_tag() 인자는 실제 값이 필요 없고 타입만 필요합니다. 이는 C++의 type system을 활용한 genius한 해결책입니다. C++20 concepts는 이를 더 우아하게 구현하지만, tag dispatch는 여전히 유효합니다.

이제 BidirectionalIterator, RandomAccessIterator를 위한 advance_with_tag를 작성하자.

template <class BidirectionalIterator, class Distance>
inline void advance_with_tag(BidirectionalIterator& bi, Distance n,
                      bidirectional_iterator_tag) {
    if (n > 0) while (n--) ++bi;
    else while (n++) --bi;
}

template <class BidirectionalIterator, class Distance>
inline void advance_with_tag(BidirectionalIterator& i, Distance n,
                      random_access_iterator_tag) {
  i += n;
}

이제 남은 것은 Iterator category에 따라 각기 다른 구현을 호출할 수 있는 인터페이스 함수를 구현하는 일이다.

template <class InputIterator, class Distance>
inline void advance(InputIterator& i, Distance n)
{
    return advance_with_tag(i, n, typename InputIterator::iterator_category());
}
Transparent Dispatch: 사용자는 category를 명시적으로 지정할 필요가 없습니다. 컴파일러가 InputIterator::iterator_category에서 타입 정보를 추출하고, 함수 오버로드 해석(overload resolution)을 통해 올바른 advance_with_tag를 호출합니다.

사용자는 iterator의 category에 대한 정보를 명시적으로 제공해야 할 필요가 전혀 없기 때문에 advance를 사용하더라도 특정 iterator에 의존하지 않는 포괄적함수를 작성할 수 있다. 헤더 파일을 통해 제공되는 거의 모든 알고리즘은 이와 같은 방식으로 작성된다.

마지막으로 표준 라이브러리가 제공하는 iterator_traits class가 왜 필요한 가를 이해하면서 이 단락을 마무리 짓도록 하자. traits class가 필요한 이유는 Iterator class로 구현되지 않았다 하더라도 표준 iterator의 속성을 공유할 수 있게 하기 위해서다. iterator_traits의 역할: int* (built-in pointer)는 iterator base class를 상속하지 않습니다. 하지만 iterator_traits<int*>를 특화하여 같은 인터페이스를 제공할 수 있습니다. 이는 C++의 template specialization을 활용한 polymorphism입니다.

만일 traits class가 없다면 class로 구현되지 않는 iterator는 위에서 distance나 advance같은 함수를 사용할 수가 없다. 정수배열의 int* 같은 경우엔 Iterator::iterator_category같은 type attribute가 있을 수 없기 때문이다.

먼저 가장 일반적인 iterator_traits class는 다음과 같이 구현되어있다.

template <class Iterator>
struct iterator_traits {
  typedef typename Iterator::iterator_category iterator_category;
  typedef typename Iterator::value_type        value_type;
  typedef typename Iterator::difference_type   difference_type;
  typedef typename Iterator::pointer           pointer;
  typedef typename Iterator::reference         reference;
};

그러나 위의 구현은 단지 class로 구현된 iterator를 위한 정의이고 T* (포인터) iterator를 위해서는 별도의 구현이 필요하다.

template <class T>
struct iterator_traits<T*> { // Built-in Iterator
  typedef random_access_iterator_tag iterator_category;
  typedef T                          value_type;
  typedef ptrdiff_t                  difference_type;
  typedef T*                         pointer;
  typedef T&                         reference;
};

template <class T>
struct iterator_traits<const T*> {
  typedef random_access_iterator_tag iterator_category;
  typedef T                          value_type;
  typedef ptrdiff_t                  difference_type;
  typedef const T*                   pointer;
  typedef const T&                   reference;
};
Template Specialization: iterator_traits<int*>는 primary template과 다른 구현을 제공합니다. 이를 partial specialization 또는 explicit specialization이라 합니다. C++의 매우 강력한 기능이며, 현대에도 essential합니다. 참고: cppreference: Template specialization

iterator_traits class를 사용하여 advance를 수정하면:

template <class InputIterator, class Distance>
inline void advance(InputIterator& i, Distance n)
{
    return advance_with_tag(i, n, typename iterator_traits<InputIterator>::iterator_category());
}

위와 같다. 그리고 단순 배열이라고 해도 advance/distance같은 함수를 사용할 수 있다는 점이 무엇보다도 중요하다.


참고 문헌 및 웹 자료 #

원본 자료 #

C++ 표준 문서 #

Iterator 고급 개념 #

C++20 현대화 #

관련 디자인 패턴 #

역사 및 배경 #

현대 언어의 유사 개념 #


[이 주석판은 1998-1999년의 세 편 연재 기사를 완성합니다. 각 파일은 역사적 의미와 2024년 현대적 관점을 모두 담고 있습니다.]