Generic Programming in C++ (2)

소개와 활용 II

출판 정보

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

  • 1회: C++의 새로운 언어적 특징 (1998.5): 진화를 위한 기초작업
  • 2회: C++의 새로운 언어적 특징 (1998.6): 포괄적 프로그래밍을 위한 진화 I
  • 3회: C++의 새로운 언어적 특징 (1998.8): 포괄적 프로그래밍을 위한 진화 II
  • 4회: 포괄적 프로그래밍과 표준 C++ 라이브러리 (1998.9): 소개와 활용 I
  • 5회: 포괄적 프로그래밍과 표준 C++ 라이브러리 (1998.10): 소개와 활용 II (본 기사)

STL은 재사용성이 뛰어난 다수의 generic 원문에서는 “포괄적"으로 표기했다. 이 연재 전체에 걸쳐 Generic Programming으로 고쳐 쓴다. Container와 Algorithm으로 이루어져 있으며 Iterator에 의해 plug-in방식으로 결합된다. generic algorithm 그 자체는 함수인자를 일반화한 functor로 재사용성이 극대화된다.

이전 호 정오표

오래 동안 기사를 연재하지 않아서 기억해 줄 독자가 있을 지도 의심스럽지만, 지난 연재 기사 290 쪽 오른쪽 단에 게시된 코드에서 default argument 기능을 사용하여 인터페이스를 간편하게 만들 수 있다는 설명은 명백한 오류다. 너무 쉬운 기능을 잘못 사용한 것이라서 설명하기도 어색하지만 혹시 독자 중에서 그와 같은 기능이 새로운 C++의 언어기능인가 하고 물어오는 분이 있을 까 싶어 부끄러움을 무릅쓰고 지적하지 않을 수 없었다. Default argument기능은 전과 같다. 단지 필자가 덤벙대다 실수한 것이다.

또 291쪽 오른쪽 상단에 게시된 accumulate의 코드에 실수가 있다.

int accumulate() {  for (; next = succ(next)) { sum = binop(sum, id(next)); } }

int accumulate() {  for (; next = step(next)) { sum = binop(sum, term(next)); } }

로 바뀌어야 한다. 필자로서도 잘 이해가 가지 않는 일인데 코드를 옮겨 붙이면서 오류가 있었을 것이라 짐작할 뿐이다. 여하튼 거듭 독자에게 사과를 드리고 싶다. 특히 필자의 어설픈 글을 꼼꼼하게 읽어 보고 오류를 지적해준 독자, 신성전자의 프로그래머 정재선 씨에게 거듭 고마움을 전하고 싶다.

들어가면서 #

지난 연재 기사를 접한 독자들은 왜 STL을 구성하는 부품과 인터페이스를 직접 소개하지 않는 지 의문을 가질 수 있다. 필자의 대답은 간단하다. 그 상세한 사용법은 개발환경에서 제공하는 사용설명서가 가장 좋다는 것이다. 더욱이 세세한 인터페이스를 열거하기 위해서 지면을 낭비할 필요는 없을 것이다. 만일 사용하고 있는 개발환경이 믿을 만한 STL 구현이나 이를 포함하는 표준 라이브러리를 제공해 주지 않는 다 하더라도 크게 걱정할 필요는 없다. 인터넷 상에서 SGI STL, ObjectSpace Stadard Toolkit등 무료 표준 라이브러리의 배포본을 쉽게 구할 수 있고 포함된 설명서도 충분히 훌륭하다. 이 기사를 점하면서 표준 C++ 프로그래밍에 관심이 생긴 독자라면 언제나 관련 문서를 읽을 수 있도록 가까운 곳에 비치해 두는 편이 좋다.

필자가 지면을 통해 얘기하고 싶은 것은 상세한 기능의 열거보다 표준 라이브러리를 잘 사용하는 데 기본이 되는 개념이다. 그러기 위해서는 STL의 설계원칙과 프로그래밍 형식을 이해하여야만 한다. 이미 거론한 바 있듯이 STL은 Generic Programming을 이해하여야만 잘 사용할 수 있다. 그래서 필자는 가능한 독자들이 쉽게 공감할 수 있는 예제를 들어 지난 기사와 같은 방식으로 Generic Programming 기법을 익히는 연습과정을 즐길 수 있도록 얘기를 풀어나가고 싶다. 또한, STL 그 자체는 C언어 정도의 표현력을 사용하는 경우에도 매우 유용한 라이브러리이며 애초부터 그렇게 설계되어 있다는 점을 강조하고 싶다. 그러나 C++ 사용자라면 단순히 소극적인 응용에 그치지 말고 직접 새로운 함수나 컨테이너를 STL의 관례에 따라 만들어 보라고 권하고 싶다.

지난 호 기사에서 우리는 accumulate란 template 함수를 추상화하는 과정을 통해 Generic Programming 기법을 부분적으로 논의하였다. 최종적인 해결책은 “원소의 생성과 처리과정을 추상화 해서, 원소를 만들어 내는 모듈(Generator)과 이를 처리하는 모듈을 분리(Algorithm)하자"는 것이었다. 그러나 이전의 accumulate는 여전히 배열이라는 특정한 컨테이너, 더 정확히 말하자면 특정 컨테이너 인터페이스에 종속한다는 단점이 있었다. 알고리즘, 즉 “처리과정이 컨테이너에 의존한다"는 말은 컨테이너마다 원소를 열거하는 방법에 차이가 있으며 알고리즘이 특정 컨테이너가 제공하는 원소 열거 인터페이스에 지나치게 의존하고 있다는 말이다. 원소의 열거과정이 열거된 원소의 처리 과정과 완전히 분리되지 않고서는 accumulate와 컨테이너 사이의 불필요한 의존관계를 없앨 수가 없다. 이를 극복하여 더욱 재사용성이 높은 accumulate를 개발하기 위해서는 공장의 대량 생산체계를 모방한 새로운 개념이 필요하고 결국 컨베이어 벨트를 모방하는 표현단위가 필요하다는 결론에 이르렀다. 그러므로 임의의 컨테이너는 모두 컨베이어 벨트에 하나 씩 자신의 원소를 꺼내 놓을 수 있는 공통적인 방법을 제공하여야만 한다. 이와 같이 컨테이너와 알고리즘 사이의 불필요한 의존도를 크게 줄이고 임의의 컨테이너가 열거하는 원소를 임의의 알고리즘을 사용하여 처리할 수 있도록 하는 인터페이스가 바로 Iterator(열거자)다.

Iterator는 표준 라이브러리의 재사용성을 크게 높여주는 핵심적인 기능으로 Iterator 인터페이스를 잘 이해하고 사용법에 익숙해지는 것이 고급 표준 C++ 프로그래머가 되는 지름길이다. 그런 뜻에서 필자는 이 연재기사의 내용 둘로 구분하여 병행적으로 이어가고 있다. 그 하나는 “Generic Programming” - 이전 기사에서는 “표준 라이브러리를 이용한 Generic Programming"과 “실제의 Stdlib” –이전 기사에서는 “실제의 STL"이다. 전자는 표준 C++ 라이브러리의 핵심적인 프로그래밍 기법을, 다른 하나는 실제의 표준 라이브러리를 이해하는 데 중점을 두어 기사를 다루고 있으므로 독자는 프로그래밍에 대한 이론적 이해와 라이브러리의 활용에 관한 지식을 함께 맛볼 수 있으리라 믿는다.

Generic Programming #

More general, more reusable #

그렇다면 iterator의 개념이 어떻게 accumulate와 vector사이의 의존관계를 제거할 수 있을 까? 즉 어떻게 accumulate를 더욱 일반화하여 임의의 C를 위한 accumulate를 만들 수 있을 까? 한발 뒤로 물러서서 생각한다면 이 질문의 답은 “어떻게 서로 구현이 다른 컨테이너의 열거과정을 동일한 인터페이스로 추상화할 수 있을 까?“에 있다. Iterator는 다음 두 가지 연산을 적용할 수 있는 추상적 데이터형으로 볼 수 있으며 아래는 이 개념을 C++의 class로 표현한 것이다.

class Element;
class iterator {
    bool hasNext() const ; // 열거할 원소가 아직도 남아있는 가?
    const Element& nextElem() const ; // 원소의 추출과 Cursor의 이동
};

위의 인터페이스에는 두어가지 문제가 있다. 첫째, 특정 원소의 형과 의존 관계가 있다. 그러므로 다음과 같은 작업이 필요하다.

template<typename Element> // 인터페이스의 형 의존성을 제거한다.
class iterator {
    bool hasNext() const ;
    const Element& nextElem() const ;
};

두 번째 iterator는 본질적으로 특정 컨테이너의 구현에 의존할 수 밖에 없다. Iterator란 특정 컨테이너 열거 방식을 사용자에게 숨기는 데 목적이 있으므로 당연히 Iterator는 컨테이너의 내부 구현을 직접 사용해야 한다. 다시 말하자면 컨테이너와 분리해서 Iterator를 구현한다는 것은 최소한 바람직한 설계는 아니다. 만일 지난 번 기사에서 소개한 바 있는 vector class의 iterator 인터페이스를 설계한다면 다음과 같을 것이다.

template<class Element>
class iterator {
    
public:
    iterator(const vector< Element >* rv);
    bool hasNext() const;
    const Element& nextElem() const;
};

이때 iterator ctor의 인자로 전달된 *rv가 iterator 객체를 소유하는 것으로 풀이할 수 있다. 눈치가 빠른 독자라면 위의 설계가 약간 문제가 있다는 점을 금방 알아차렸을 것이다. 위의 iterator class는 오로지 vector를 위한 iterator다. 즉, 특정 데이터 형에만 귀속하는 class를 전역으로 선언하는 것은 합리적인 인터페이스의 설계가 아닌 것이 분명하다. 예를 들어 linked list 방식으로 구현되는 컨테이너의 iterator가 필요하다고 하자. 이미 만들어진 vector를 위한 iterator와 구분해야 하는 것은 당연한 사실이고 특정한 컨테이너를 위한 iterator를 구분하기 위하여 vector_iterator, list_iterator, tree_iterator 식으로 명칭을 변경할 필요가 있다. 그러므로 vector_iterator는 vector 컨테이너의 name space에 list_iterator는 list 내부에서 선언되어야 더욱 바람직하다. 그리고 이 같은 목적에 가장 적합한 언어기능이 nested class다. 그 간단한 구현 방식을 독자들에게 소개하기 위해서 이미 지난 번 기사에서 소개한 vector class의 nested iterator 인터페이스를 다음처럼 작성해 보았다.

// 아래의 코드는 충분히 안전성을 검증하지 않았다. 단지 독자에게 실감나는
// 예제를 보여주기 위해 급하게 쓴 것이므로 실험과 이해의 간단한 예로써 활용해 주기
// 바란다.

// File : vector.h
// Date : 1998/9
// Update : 1998/11
// Description :
//  Do not use this implementation for serious use.
//  This code is just written to show a visible example of container/iterator pattern to my readers.

template<typename T>
class vector {
    size_t _sz;
    T *_vec;
public:
    class iterator {
        const vector* _rv;
        mutable size_t _cursor;
    public:
        iterator(const vector* rv) : _rv(rv), _cursor(0) {}
        bool hasNext() const { return _cursor != _rv->size(); }
        const T& nextElem() const { return (*_rv)[ _cursor++ ]; }
    };

    typedef T value_type;
    explicit vector(size_t size, T init=T())
    : _sz( size )
    {
        _vec = new T[_sz];
        for (size_t i = 0; i < _sz; i++)
            _vec[ i ] = init;
    }
    ~vector() { delete[] _vec; }

    size_t size() const { return _sz; }
    const T& operator[](size_t i) const { return _vec[i]; }
    T& operator[](size_t i) { return _vec[i]; }
    const iterator elements() const { return iterator(this); }
};

이제 vector의 iterator 인터페이스를 이용해서 accumulate를 다시 구현할 수 있다.

template<typename BinFtor, typename UnaryFtor, typename CT,
    typename Element>
inline T
accumulate(BinFtor binop, Element id_elem, const CT& c, UnaryFtor term) {
    T sum = id_elem;
    typename CT::iterator cb = c.elements();
    while (cb.hasNext())
    {
        T e = cb.nextElem();
        sum = binop(sum, term(e));
    }
    return sum;
}

개선된 accumulate의 코드를 자세히 살펴보자. 독자들이 눈 여겨 보아야 할 점은 accumulate의 정의가 더 이상 특정 컨테이너의 열거방식과 무관하다는 점이다. 이제 accumulate는 어떤 컨테이너라 하더라도 내부에 iterator interface를 제공하기만 한다면 그대로 accumulate의 인자로 사용할 수 있다. 말 그대로 accumulate는 이제 정말 generic하다. 꺼림칙한 점은 iterator의 기능성이나 안전성과 연관된 문제를 얼마든지 흠잡을 수 있지만 어쨌든 지금 진행되는 논의의 핵심은 아니므로 일단 피해가기로 하자.

위의 예제만으로도 generic programming의 표현력을 충분히 맛볼 수 있지만 algorithm/iterator/container 설계의 장점을 실감하려면 몇 가지 유용한 예를 더 들어보는 것이 좋다. 만일 linked list 방식의 list 컨테이너를 설계된 인터페이스에 따라 구현한다면 어떻게 해야 할까? 독자 스스로 연습 삼아 해 볼만한 숙제가 아닐까 생각한다. 어쨌든 list의 인터페이스는 다음과 같이 생겼을 것이고:

template<typename Element>
class list {
public:
    class iterator {
    public:
        iterator(const list* rv);
        bool hasNext() const;
        const Element& nextElem() const;
    };
    typedef Element value_type;

    explicit list( Element init=T());
    ~list();

    size_t size() const;
    const Element& head() const; // head of [x1, x2, x3, ..., xn] == x1
    const list& tail() const; // tail of [x1, x2, x3, ..., xn] == [x2, ..., xn]
    bool nil();
    void push_back(const Element& e); // push_back o to [x1, x2, x3, ..., xn] = [x1, x2, x3, ..., xn, o]
    const iterator elements() const;
};

다음처럼 언제든지 accumulate를 사용하여 원하는 일을 할 수 있다는 사실이다.

void f()
{
    list<double> lst1;
    lst1.push_back(2.0);
    lst1.push_back(3.0);
    ...
    accumulate(add(), id(), lst1, 0.0); // Success : no dependency on any type of containers
}

이제 좀더 실제 stdlib가 제공하는 인터페이스에 근접하도록 논의를 깊이를 더할 필요가 잇겠다. 논의가 진행되면 진행될 수록 개선된 iterator의 인터페이스는 표준 stdlib에 더욱 근접하게 될 것이 자명하다. 일단 이후의 설명을 읽기 전에 iterator 개념에 익숙하지 않은 독자라면 본 기사의 “iterator와 cursor"란을 미리 읽어보기를 권한다. 뒤에서 설명이 더해지겠지만 cursor란 place-holder다. 그리고 hasNext()와 nextElement()란 내부적으로 숨겨져 있는 cursor를 움직이고 현재 cursor가 가리키는 원소를 차례로 읽어내고자 하는 연산이다. 위의 인터페이스는 충분히 쓸만하다. 그러나 현재의 인터페이스는 cursor 모델이 제공할 수 있는 모든 기능을 다 포괄하지 않고 있다. cursor 모델은 현재 위치에 있는 원소를 읽어내는 것 뿐만 아니라 새로운 원소로 변경하고자 할 때 편리한 인터페이스를 제공할 수 있으며 매우 효율적인 구현 모델이라는 장점이 있다. 만일 이러한 기능을 제공하기 위해 위에서 앞서 정의한 iterator 인터페이스를 확장한다면 다음과 같을 것이다.

template<typename Element>
class vector {
public:
    class iterator {
        const vector* _rv;
        mutable size_t _cursor;
    public:
        iterator(const vector* rv) : _rv(rv), _cursor(0) {}
        bool hasNext() const { return _cursor != _rv->size(); }
        const Element& elem() const { return (*_rv)[ _cursor ]; }
        Element& elem() { return (*_rv)[_cursor]; }
        void advance(size_t n=1) const { _cursor += n; }
    };
...
};

이제 현재 위치에 있는 원소를 읽어오거나 바꾸는 인터페이스는 elem()으로 제공되고 iterator의 움직임은 advance()를 명시적으로 사용하도록 변경하였다. 전처럼 nextElem()이 현재의 원소 값을 return value로 얻어오고 묵시적으로 cursor의 위치가 하나 앞으로 진행하는 편리함을 포기해야 하겠지만 용도는 더욱 일반화된다.

위와 같이 인터페이스가 변경되어서 얻게 되는 이점은 여러 가지 이유로 명백하다. 필자가 지난 기사에서 작성했던 몇 개의 generic function을 예로 들어 설명하면 쉽게 이해할 수 있을 것이다.

일단 accumulate는 목표한 대로 특정 컨테이너와 논리적으로 분리되었다. 물론 Iterator 덕분이다. 그러나 나머지 add_from_to/proc_n_random_numbers, 내부적으로 사용되는 generate같은 함수들은 충분히 일반화하지 않았다. 또한 불필요하게 vector 컨테이너에 의존하고 있다. 가장 먼저 generator를 살펴보자. 이제 iterator 인터페이스를 통해서 컨테이너를 간접적으로 사용할 수 있기 때문에 generator를 일반화하는 것은 매우 쉽다. 물론 vector 내의 iterator가 새로 설계된 인터페이스에 맞추어 다시 작성되었다는 가정하에 아래의 generator는 충분히 일반적인 generic procedure라고 할 수 있다. 마찬가지로 어떤 컨테이너 라도 정해진 iterator 인터페이스를 제공하기만 하면 generate를 사용할 수 있기 때문이다.

template<typename CT, typename Functor>
void generate(CT& ct, Functor f)
{
  typename CT::iterator it = ct.elements();
  while (it.hasNext())
  {
      v.elem() = f();
      v.advance(); // 명시적인 cursoring
  }
}
// std::ranges::generate — 컨테이너와 생성 functor를 받아 채워 넣는다.
#include <algorithm>
#include <vector>

std::vector<int> v(10);
std::ranges::generate(v, f); // f는 인자 없는 generator functor

다음 add_from_to를 살펴보자. add_from_to는 사실 간단한 함수지만 컨테이너의 필요성을 보이기 위해 일부러 복잡하게 vector를 사용했다는 사실을 떠올리길 바란다. 그리고 연속적인 정수를 만들어 내기 위해 functor succ을 고안했었다. functor가 generic procedure 자체의 재사용성을 극대화 하는 데 사용될 수 있다는 점은 이미 알고 있다. 그러나 우선 add_from_to에는 vector가 없어야 한다. 그러면 이러한 알고리즘의 구현에 무엇이 가장 적절한 프로그래밍 스타일인가? 우리가 정말 필요한 것은 “연속해서 정수를 열거해내는 표현과 accumulate"다. 그런데 열거를 위해 고안해 낸 것이 iterator이고 여기에도 적용하지 못할 이유가 없을 것이다. 분명한 것은 컨테이너는 불필요하다는 사실이다. 컨테이너가 없다면 iterator도 없다. 그러면 다시 원점으로 돌아온다. iterator란 오로지 컨테이너만을 위한 개념인가? 답은 그렇지 않다는 것이다. 바로 이러한 경우에 적합한 기법이 임의의 생성 알고리즘을 추상화한 iterator이고 여기서는 succ이 바로 그 역할을 해야만 한다. 결론짓자면 succ의 이전 구현을 완전히 변경할 필요가 있고 이 때문에 succ은 다음과 같이 매우 재미있는 역할을 하는 class가 된다.

template<typename Numeric>
class succ {
    Numeric start_, end_, step_, cur_;
    bool infinite_;
public:
    typedef succ iterator;
    explicit succ(Numeric begin=T(1))
      : start_(begin), step_(1), cur_(begin), infinite_(true)
      {
      }

    explicit succ(Numeric begin, Numeric end, Numeric step=T(1))
      : start_(begin), end_(end), step_(step), cur_(begin), infinite_(false)
      {
      }
    bool hasNext() { return infinite_? true: (cur_ <= end_); }
    const T& elem() const { return cur_; }
    void advance() { cur_++; }
    iterator elements() const { return *this; }
};
// succ<Numeric>의 역할을 std::views::iota가 대신한다.
#include <ranges>

auto finite   = std::views::iota(1, 11);       // 1, 2, …, 10
auto infinite = std::views::iota(1);            // 1, 2, 3, … (무한)
auto stepped  = std::views::iota(0, 20)
              | std::views::stride(3);          // 0, 3, 6, …, 18 (C++23)

독자들은 필자가 무엇을 하겠다는 것 인지 이미 짐작했으리라 생각한다. 이제 succ은 원소의 생성 알고리즘을 알고있는 똑똒한 iterator다. 만일 succ의 구현이 위와 같다면 add_from_to의 구현은 다음과 같이 간단하고도 재미있어 진다.

template<typename T>
T add_from_to(T begin, T end) {
    return accumulate(add(), T(0), succ<T>(begin, end), id());
}
// std::views::iota + std::accumulate로 대체
#include <ranges>
#include <numeric>

template<typename T>
T add_from_to(T begin, T end) {
    auto range = std::views::iota(begin, end + 1);
    return std::accumulate(range.begin(), range.end(), T(0));
}

전보다 매우 깔끔할 뿐만아니라 무슨 컨테이너 같은 것이 필요없게 되었다. 게다가 매우 막강한 기능의 succ class까지 얻었다. (바로 이것이 일반화를 통한 재사용성의 이익이다!) 마지막으로 proc_n_random_numbers를 살펴보자. 여기서 적용해야 할 프로그래밍 기법도 add_from_to의 경우와 거의 같다. 바로 Rand(n)이 똑똒해 져야만 한다. 그래서 Rand class가 위의 succ과 같은 방식으로 구현된다면 proc_n_random_numers의 구현은 전혀 변경할 필요가 없다. ( Rand class는 독자들이 재미 삼아 작성하도록 남겨두고 싶다.)

자, 지금까지의 작업이라면 충분하지 않을까? 그러나 아직도 갈 길은 멀다. 주의 깊고 경험이 많은 C++ 프로그래머라면 이미 필자의 프로그래밍 스타일과 논의의 전개 방식에 몇 가지 불만을 품고 있었을 것이 분명하다. 필자는 여태까지 제시한 인터페이스는 전혀 C++ 답지 않다. 다시 말하자면 C++의 operator overloading등 다양한 표현력의 이점을 잘 활용하고 있지 않았다. 둘째, 위에서 작성된 accumulate등의 함수는 충분히 재사용성이 높지만 C built-in array와 함께 쓸 수가 없다. 당연히 bulit-in array에는 필자가 제안한 것과 같은 iterator 인터페이스가 없다. 이러한 지적이 별 것 아니라고 생각하는 독자가 있겠지만 사실 충분히 설득력이 있는 말이다. 우리가 쓰고 있는 언어는 분명히 C언어의 정의를 포함하는 C++라는 복잡한 언어이기 때문이다. 이 모든 불만을 다음 호에서 말끔하게 해결하도록 하자.

출판 정보

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

  • 1회: C++의 새로운 언어적 특징 (1998.5): 진화를 위한 기초작업
  • 2회: C++의 새로운 언어적 특징 (1998.6): 포괄적 프로그래밍을 위한 진화 I
  • 3회: C++의 새로운 언어적 특징 (1998.8): 포괄적 프로그래밍을 위한 진화 II
  • 4회: 포괄적 프로그래밍과 표준 C++ 라이브러리 (1998.9): 소개와 활용 I
  • 5회: 포괄적 프로그래밍과 표준 C++ 라이브러리 (1998.10): 소개와 활용 II (본 기사)

실제의 Stdlib #

std::<container> 계속 #

이미 설명한 바와 같이 표준 라이브러리(이하 stdlib)는 크게 두 종류의 컨테이너를 제공한다. 즉 어떤 특성으로 원소를 보관하고 추출해 낼 수 있느냐에 따라 Sequence와 Associative로 분류한다. 표준 라이브러리가 제공하는 여러 컨테이너는 각기 인터페이스 및 성능에 차이가 있으므로 응용에 따라 적합한 종류의 컨테이너를 선택적으로 사용하는 것이 좋다. 앞으로의 논의를 간결하게 진행하기 위해서 임의의 컨테이너를 C라고 표기하자. 만일 C라는 컨테이너가 T형의 원소들을 저장한다면 C++의 template 표기법을 빌어 그 컨테이너의 type을 C라고 적기로 하자. 특정한 유형의 더 구체적인 컨테이너를 취급할 때를 위해서 Sequence는 S로 Associative는 A등으로 약식 표기를 사용하면 더욱 편리할 것이다. (그리고 곧 이와 같은 표기가 매우 유용하다는 사실을 알게 된다.)

기본적인 이론과 용어: 형적 제약(Type constraints) #

더 체계적인 논의를 진행하려면 몇 가지 개념과 용어를 정의하고 넘어갈 필요가 있다. 이 단락에서 소개하는 용어는 SGI STL의 문서의 관례를 크게 참조했다. SGI STL이 사용하는 용어는 표준과 다소 차이가 있으나 형식면에서나 의미론적 명세를 비교해 보더라도 표준문서보다 잘 정리되어있고 정확하다. 표준 라이브러리(이하 stdlib)를 심도 있게 사용할 의지가 있다면 곧 소개할 용어를 더욱 눈 여겨 보아둘 필요가 있다. 물론 필자가 인용한 용어의 해설은 익숙하지 않은 독자들의 이해를 돕기 위해 내용을 크게 줄이고 풀어 쓴 것이지만 stdlib를 사용하여 새로운 부품을 개발하는 경우에, 소프트웨어의 사용설명서 및 기능적 명세를 기술하는 데 충분히 유용하게 사용될 수 있으리라 믿는다. 이제 컨테이너의 설계와 관련된 개념에 관한 논의를 진행하도록 하자.

먼저, 컨테이너에는 아무 원소나 마구 집어넣을 수 없다는 점을 인식해야 한다. 컨테이너의 성질에 따라 집어넣을 수 있는 원소가 있고 그렇지 않은 원소가 있으므로 이러한 특성을 기술할 수 있는 방법이 있어야 편리하다. 다시 말하자면 어떤 컨테이너 C를 사용할 때 T는 C의 특성을 따라 최소한의 인터페이스를 제공해야만 한다. 예를 들어보자. C++ stdlib가 제공하는 컨테이너에 set이 있다. 수학에서 사용하는 집합의 개념을 따르자면 집합의 모든 원소를 정의하는 전체집합은 최소한 “같은 값인가"를 비교할 수 있는 어떤 집합이어야 한다. 이를 다시 우리가 채택한 표기를 사용하여 적어보면 set에서 E형에 속하는 e1,e2는 e1 == e2의 연산(인터페이스)을 적용(제공)할 수 있어야 한다는 말이다. 집합이란 것이 본디 같은 원소가 연거푸 포함될 수 없도록 정의된 컨테이너이기 때문에 새로운 원소가 추가될 때마다 이미 포함된 원소인지를 검사해 볼 필요가 있다. C++ 식으로 얘기를 하자면 임의의 type이 E가 되려면 그 형은 반드시 operator== 연산자를 제공해야 한다는 말이다. 그런데 문제는 C++ 자체의 언어적 결함에 있다. 안타깝게도 C++에는 그와 같은 제약 조건을 명시적으로 표현할 수 있는 방법이 없다. 보다 구체적으로 우리가 집합과 같은 template class를 정의한다면:

template<typename T> set { };
// T는 반드시 operator==을 정의하는 어떤 형이어야 하지만 ...

C++를 사용해서 T가 최소한 만족해야만 하는 조건을 언어수준에서 명시적으로 표현하는 방법이 없다. C++20에서 드디어 concepts가 도입되어 이 문제가 해결됐다: template<std::equality_comparable T> class set { }; — 컴파일러가 T의 자격을 정적으로 검사한다. (2026) 물론 C++ 이외의 고급 프로그래밍 언어 중에는 “형적 제약 조건"을 프로그램 코드의 일부로 기술할 수 있는 언어가 있으며 컴파일러는 T가 지정된 조건을 만족하는 지를 정적으로 검사할 수 있다. 형적 제약 조건을 검사할 수 있는 기능이 제공되면 C++의 template과 같이 (type-)parametric polymorphism을 지원하는 언어에 매우 유용하고 별도의 문서화도 필요 없다. 필자는 만일 C++가 형 제약을 표현할 수 있었다면 아마 그 문법은 아래와 같지 않았을 까 생각한다.

template
< class T {
    operator==(const T&);
  };
>
class set {
...
};

위와 같은 표현이 가능하다면 C++의 template 기능은 더욱 반가운 기능이다. 그러나 현실은 위와 같은 기능이 없다는 것이고 그 대안으로 적용할 만한 별다른 프로그래밍 기법도 아직 발견되지 않았기 때문에 필요한 제약 조건을 명시하기 위하여 주석이나 문서에 의존하는 수 밖에 없다.

이제 형적 제약이 필요한 이유와 이를 명시하는 것이 중요한 가하는 점은 지금까지의 설명으로 충분히 공감대를 이루었으리라 믿고 본격적인 관련 용어의 정리에 들어가 보도록 하자. 아래와 같이 임의의 type T가 다음과 같은 연산자 인터페이스를 제공하고 그 의미를 준수한다면 “T는 Assignable"하다고 하고 “T ∈ Assignable"이라고 표기하기로 하자.

Let x, y ∈ T
    T(x), T t = x // copy ctor
    x = y // (canonical) assignment

이 제약이 필요한 예로 stdlib의 generic 연산 중에 swap을 들 수 있다.

std::swap( x, y )  /* <algorithm> */

swap에 적용되는 객체 x, y는 반드시 같은 형이어야 함은 물론이고 x, y ∈ T ∈ Assignable 해야 한다. swap 함수는 copy ctor와 copy assignment를 사용하여 전형적으로 다음처럼 구현되기 때문이다.

template <class T> inline void swap(T& a, T& b)  {
    T tmp = a; // copy ctor가 필요하다.
    // copy assignment 가 필요하다.
    a = b;
    b = tmp;
}
C++11부터는 move semantics 덕분에 std::swap이 복사 없이 이동으로 처리된다. 복사 비용이 큰 타입(예: std::vector)에서 성능 차이가 크다. (2026)

그러므로 줄여서 말하자면 “T ∈ Assignable"해야만 한다. swap은 C++가 정확히 형인자의 성질을 제약할 수 있는 기능을 제공했다면 다음과 같이 정의되었을 것이다.

template <class T {
    T(const T&);
    T& operator=(const T&);
}; >
inline void swap(T& a, T& b)  {
    T tmp = a; // copy ctor가 필요하다.
    // copy assignment 가 필요하다.
    a = b;
    b = tmp;
}
/* 그러나, T(T&), T& operator=(T&)가 필요하다고 하면 위의 번거로움은 생략할 수 있을 것이다. 즉 C++의 class 설계의 관례에 따라 default ctor/meberwise copy ctor/memberwise copy assignment등은 모두 자동적으로 생성되므로 형 제약에서도 이와 같은 간편함을 활용하여 특별한 말이 없더라고 default ctor등의 연산자를 제공하는 것으로 가정할 수 있다. */

그러나 C++에서는 정확한 사용법 – 형 인자가 어떤 자격을 갖춘어야 하는 가!–을 잘 정리한 문서나 주석에 의존할 수 밖에 없다. 그러므로 template 정의를 기술하는 프로그래머는 형인자의 이름을 그저 T라고 하지 말고 좀더 부지런을 떨어서 인자의 이름이 뭔가 사용자에게 단서를 제공할 수 있도록 하는 게 더 나은 방법이다. 이러한 관례를 따라 위의 swap함수도 다음과 같이 성질에 따라 형 인자를 이름을 바꾸는 편이 더 좋다.

template <class Assignable> inline void swap(Assignable & a, Assignable & b) {  }

이름을 길게 쓴다고 해서 T와 Assignable에 차이가 있는 것은 아니지만 함수의 사용자는 (물론 작성자인 자기 자신을 포함해서) swap함수의 인자로 건네 줄 수 있는 객체의 형적 제약 조건이 “T ∈ Assignable"이란 점을 확실히 인식할 수 있기 때문이다.

자주 사용하게 될 몇 가지 형 제약조건을 더 살펴보도록 하자. (독자들은 필자가 위와 같은 개념을 상세하게 다루는 이유를 보다 잘 이해했을 것이라 믿는다.)

T ∈ DefaultConstuctible
    T()

T ∈ EqualityComparable
    x == y
    x != y

T ∈ LessThanComparable
    x < y
    x > y
    x <= y
    x >= y, where x, y ∈ T

이제부터는 위에서 정의한 제약 조건의 이름을 사용하여 template 정의에 필요한 type 인자의 형 제약 조건을 간결하고 정확하게 적도록 노력할 필요가 있다.

컨테이너란? C<T ∈ Assignable> #

우선 stdlib가 제공하는 모든 C는 다음과 같은 공통된 연산을 제공하여야 한다. 바꾸어 말하면 아래에 제시된 인터페이스를 제공해야 stdlib의 컨테이너라고 할 수 있다.

CT ∈ C< T ∈ Assignable >
    CT::iterator c.begin(void)      //iterator의 시작
    CT::iterator c.end()            //iterator의 끝
    CT::size_type c.size()          //현재 저장된 원소의 개수
    CT::size_type c.max_size()      //컨테이너의 최대 용량
    bool c.empty()                  //Is the container empty ?
    void c.swap(d)                  //두 컨테이너의 원소를 바꿔 치기
  where c, d ∈ CT

보시다시피 구구 절절히 풀어놓는 것보다 stdlib가 제공하는 컨테이너란 무엇인가를 정확히 알 수 있고 주장할 수 있다. 입장을 바꾸어 설명하면 새로운 컨테이너를 만든다고 하더라도 위와 같은 인터페이스를 제공해 주어야 표준적인 컨테이너라고 할 수 있는 것이다.

다른 인터페이스의 용도는 쉽게 짐작할 수 있지만 iterator 기능을 제공하는 begin()/end()는 사전 지식이 없는 경우 금방 이해하기가 어렵다. 여태까지 설명한 바와 같이 iterator란 컨베이어 벨트처럼 컨테이너 내부의 원소를 어떤 순서에 따라 열거하는 과정을 추상화한 객체다. 간단히 말하자면 iterator란 열거과정의 추상화다. 열거과정을 추상화하여 iterator로 표현하는 기법은 C++ stdlib의 독창적인 개념이 아니라 이미 잘 알려진 것이며 C++의 경우는 이 중 cursor라 불리는 모델을 채택하였다. cursor는 사실 우리에게 친숙한 개념이다. 본래는 데이터베이스에서 단위 정보의 검색에 사용되는 용어지만 컴퓨터 편집기의 cursor와 개념상 다를 게 없다. notepad나 vi 편집기의 cursor를 생각하면 더 알기 쉽다. 우리가 대부분 사용하고 있는 스크린 편집기는 파일전체의 내용을 모두 화면상에 보여주기 때문에 사용자는 어디를 수정해야 하는 지 쉽게 알 수 있다. 이 때 cursor가 매우 중요한 역할을 하게 된다. cursor가 있기 때문에 사용자는 “현재 위치"라는 정보를 항시 유지 할 수 있다. 변경하거나 삽입해야 할 데이터가 있다면 cursor를 움직이기만 하면 된다. (cursor란 얼마나 독창적이고 편리한 인터페이스 개념인가?) C++ stdlib의 컨테이너가 가지고 있는 원소의 변경, 참조는 바로 이 cursor interface를 사용한다. 그러므로 iterator란 바로 cursor가 움직일 수 있는 지정된 영역에 불과하다!

Iterator와 cursor #

Cursor방식의 iterator란 “컨테이너 내부에 현재 어떤 원소를 가리키고 있는 가?” 다시 말해서 현재 열거할 원소는 컨테이너의 내부의 어떤 위치에 있는 가하는 “where"의 정보를 추상화한 것이다. C++식으로 말하자면 “pointer abstraction” 즉 place-holder를 말한다. 그러므로, c.begin()은 컨테이너 c가 열거할 첫 번째 원소가 저장되어 있는 곳이며 *c.begin()은 바로 저장된 원소를 추출하는 연산이고, c.end()는 모든 원소를 열거하고 난 뒤의 가상적 상태이다. 이를 past-the-end라고 지칭하고 전축에 비유한다면 레코드의 모든 트랙을 재생한 후 바늘이 쉬는 지점과 같다. 그러므로 그 의미를 C++ 코드로 표현한다면:

CT::iterator where = c.begin(); // 컨테이너의 첫번째 위치를 가리키는 cursor

where++; // 다음 원소가 있는 위치로 cursor를 이동

if (where == c.end()) { cout << "No more elements" << endl; } // past-the-end

꼼꼼한 독자라면 위의 코드의 동작방식이 C의 Built-in array을 포인터 방식으로 처리하는 것과 다를 바가 없다는 점을 느낄 것이다. 사실이 그렇다. C의 Built-in array는 random-accessible컨테이너다 – operator. 그리고 각 배열원소를 가리키는 포인터가 바로 cursor다. 다음 호에는 이와 같은 이해를 바탕으로 Sequence/Associative 컨테이너에 대한 보다 상세한 논의를 진행하기로 하자.

Part 7으로 이어집니다.


참고 #