Generic Programming in C++, Part 3

데이터 생성과 처리의 분리, 그리고 Iterator

데이터 생성과 처리의 분리 #

accumulate의 문제는 데이터 (수열)의 생성과 처리가 한 함수 내부에 모두 뒤엉켜 있다는 점이다. 그래서 accumulate의 범용성을 높이려면 자료의 생성은 별도로 분리해내서 어딘 가로 떠 넘겨야만 한다. 동시에 accumulate는 임의의 생성과정을 수용해 낼 수 있도록 보다 추상화 되어야만 한다. 이러한 논리가 수긍이 가지 않는다면 기계 야구장을 상상하면 보다 이해가 쉽다. 야구장에서 공은 일정한 간격으로 정해진 기계에서 정해진 양만큼 발사된다. 사용자는 오로지 잘 받아 치는 데만 신경을 쓰면 되는 것이지 공을 발사하는 기계에는 신경을 쓸 필요가 없다. 즉 공의 생성과 처리가 완전히 따로 분리되어 있다. 사용자는 단지 동전을 집어넣어서 공의 생성을 요청하고 주는 대로 받아 치다가 더 이상 나오지 않으면 그만두고 야구장을 나오면 그만이다. 그러나 우리가 개발한 accumulate는 그리 느긋하지 못하다. 직접 공을 발사하는 방법을 주인으로부터 알아온 다음 모든 공에다 증가하는 순서로 번호를 붙이고 마지막 번호를 확인할 때 까지 공을 발사하러 뛰어 같다가 다시 돌아와 방망이를 잡고 받아 치고 또 공을 발사하러 뛰어가고 또 다시 헉헉거리며 돌아와서 공을 쳐내고 하는 식으로 지나치게 적극적으로 공놀이를 즐기고 있다. 게다가 다른 야구장을 찾거나 같은 야구장이라도 그 공을 발사하는 기계의 동작원리가 바뀌면 매번 야구장 주인과 그리 즐겁지 않은 개인적인 대화(?)를 나누지 않을 수 없다. 이쯤 되면 공놀이는 더 이상 즐거운 일이 아니다. 그러므로 공의 생성은 기계에 맡겨야 제대로 공놀이에 집중할 수 있는 것이다. 자 그럼 어떻게 이 새로운 작전을 실현할 수 있는 가. 매우 어렵게 보이지만 해결책은 단순하다. 야구놀이를 유심히 관찰하면 거기에 답이 있다. 공을 발사하는 기계는 일정량의 공을 미리 가지고 있는 컨테이너 객체로 볼 수 있다. 그러므로 우리는 일정한 양의 원소를 담을 수 있는 컨테이너를 개발한 다음 처리할 원소를 거기다 담아 accumulate로 보내주기만 하면 된다. accumulate는 컨테이너가 제공하는 인터페이스를 사용하여 원소를 하나씩 얻어오고 종전과 같은 방식으로 처리하면 원소의 생성작업에 관여하지 않아도 좋다. 이 때도 가능하면 본래의 프로그램의 손을 대지 않게끔 프로그램 하는데 주의를 기울이자. 최종 사용자인 main에서는 단지 1에서 10까지의 덧셈을 원할 뿐이다. 그 사용자는 컨테이너니 생성과 처리의 분리니 하는 따위에는 관심이 있을 리가 없기 때문이다. 그리고 이와 같은 작업은 추상화를 통해 가능해지며 그러므로 진정 가치가 있는 프로그래밍은 추상화와 일반화 작업을 통해서만 얻어질 수 있다고 감히 단언 할 수 있는 것이다.

이제 적절하게 재사용할 수 있는 컨테이너를 개발하자. 배열처럼 동작하는 컨테이너가 아무래도 편리할 것이기 때문에 그와 유사한 최소 인터페이스만을 제공하는 배열형 컨테이너를 만들자 보자.

#include <cstdlib> // size_t가 필요하다.

template<typename T>
class vector {
    size_t _sz;
    T *_vec;
public:
    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]; }
};

다음 accumulate를 다음과 같이 변경하자. 이제 원소의 생성은 accumulate 밖에서 이루어지고 vector에 담겨져 들어온다.

template<typename T>
T accumulate(T (*binop)(T, T), T idelem, const vector<T>& vT, T (*term)(T))
{
    T sum = idelem;
    for (size_t i = 0;  i < vT.size() ; ++i )
    {
        sum = binop(sum, term( vT[i] ));
    }
    return sum;
}

이제 나머지 add_from_to함수도 accumulate의 변화에 따라 다음과 같이 변경할 필요가 있다.

template<typename T>
T add_from_to(T begin, T end) {

    T (*addT)(T, T) = add<T>;
    T (*idT)(T) = id<T>;

    vector<T> vT(end-begin+1);
    for (size_t i = 0; i < vT.size(); ++i) {
        vT[i] = begin++;
    }
    return accumulate(addT, T(0), vT, idT);
}

그런데 아무래도 add와 같은 함수 인자가 걸린다. 명시적인 포인터 생성을 위해 항시 저렇게 사용하는 것은 너무 불편하다. 문제가 되는 것은 template이 생성된 코드를 의미하는 것이 아니므로 인자로 넘길 수 없다는 점이다. 이참에 그에 대한 근본적인 해결책을 고안하고 넘어가는 게 차후를 위해서 좋을 듯 싶다. 아이디어는 이미 지난 호에 소개된 함수처럼 동작하는 객체 function object, 또는 Functor를 만들어 넘기는 것이다. 먼저 add, id를 Functor화 하자.

struct add {
    template<typename T>
    T operator()(T x, T y) { return x + y; }
};

struct id {
    template<typename T>
    T operator()(T x) { return x; }
};

member template을 이용하여 정의된 Functor를 사용하면 add_from_to는 다소 말끔한 코드가 되고 보기도 편하다.

template<typename T>
T add_from_to(T begin, T end) {
    vector<T> vT(end-begin+1);
    for (size_t i = 0; i < vT.size(); ++i) {
        vT[i] = begin++;
    }
    return accumulate(add(), T(0), vT, id());
    // add,id는 class이므로 생성을 요청 해야 함을 잊지 말자.
}

이에 따라 accumulate의 변화는 어쩔 수가 없다. 그러나 개선된 것이므로 우려할 바가 아니다. 단지 template 인자가 2개 더 늘어 났을 뿐 내부 코드의 변화는 거의 없다. 그리고 BinFtor나 UnaryFtor는 사실 복잡한 형 검사를 하지 않으며 단지 binop는 binop(x,y) 그리고 term은 term(x) 식으로 문법적인 검사에만 의존한다. 그러므로 여기다 정상적인 함수 포인터를 인자로 넘겨도 무방하다. 즉 이전의 add_from_to의 정의를 그대로 써도 잘 동작한다.

template<typename BinFtor,
         typename UnaryFtor,
         typename T>
T accumulate(BinFtor binop, T idelem, const vector<T>& vT, UnaryFtor term) {
    T sum = idelem;
    for (size_t i = 0; i < vT.size(); i++)
    {
        sum = binop(sum, term(vT[i]));
    }
    return sum;
}

이와 같은 방식을 사용하면 무작위 수열의 처리도 가능하다. 다소 일반화된 무작위 수열의 처리 즉 n개의 무작위 수열에 함수 f를 적용하고 이를 모두 accumulate하는 함수를 작성해 보자. 먼저 무작위 functor를 개발한다.

template<typename T>
struct Rand {
    Rand() {
        srand((unsigned)time(NULL));
    }

    T operator()(void) {
        return static_cast<T>(rand());
    }
};

Rand의 경우에는 member template을 쓸 수 없다는 점에 유의하자. srand/rand는 시드가 약하고 분포가 균일하지 않다. C++11부터 <random>std::mt19937, std::uniform_int_distribution을 쓰는 게 맞다. (2026) Function template의 type은 return type에 의해서는 유추되지 않기 때문에 사용하려면 explicit qualification을 써야 하지만 이 경우는 적절하지 못하다. 그러므로 class template을 만드는 게 더욱 유리하다.

proc_n_random_numbers은 앞의 generic 함수를 참조하여 다음과 같이 간단하게 작성할 수 있다.

template<typename T, typename BinFtor, typename UnaryFtor>
T proc_n_random_numbers(size_t n, BinFtor binop, UnaryFtor uop, T idelems)
{
    vector<T> vT(n);
    for (Rand<T> r, size_t i = 0; i < vT.size(); ++i) {
        vT[i] = r();
    }
    return accumulate( binop, idelems, vT, uop);
}

여기서 add../proc..이 둘 다 내부에서 vector에 뭔가를 하고 있다는 점을 눈 여겨 보자. 즉 둘 다 공통적으로 하고 있는 연산은 일정한 크기의 vector를 초기화 하고 있다는 점이다. 그러므로 뭔가 초기화작업을 하는 generate라는 함수를 만드는 것이 더욱 좋을 것 같다. genenerate함수는 매우 쉽다.

template<typename T, typename Functor>
generate(vector<T>& v, Functor f)
{
    for (size_t i = 0; i < v.size(); i++)
        v[i] = f();
}

그리고 proc..을 generate를 사용하여 다시 작성하자.

template<typename T, typename BinFtor, typename UnaryFtor>
T proc_n_random_numbers(size_t n, BinFtor binop, UnaryFtor uop, T idelems)
{
    vector<T> vT(n);
    generate( vT, Rand<T>());
    return accumulate( binop, idelems, vT, uop);
}

문제는 add_from_to다. 그냥은 generate를 사용할 수 없으므로 step같은 Functor를 만들어야만 한다. 그리고 k씩 증가하는 값을 생성하는 것이다.

template<typename T>
class advance {
    T _init, _k;
public:
    explicit advance(T init=T(), T k=T(1)) : _init(init), _k(k) { }
    T operator()(void) {
        T old = _init; _init += _k; return old;
    }
};

그러면 add_from_to의 코드는 generate를 사용할 수 있다.

template<typename T>
T add_from_to(T begin, T end) {
    vector<T> vT(end-begin+1, T(0));
    generate( vT, advance<T>(begin));
    return accumulate(add(), T(0), vT, id());
}

이 정도면 독자들도 add_from_to와 proc..이 거의 동일한 형태의 문제를 풀고 있다는 점을 간파했으리라 생각한다. 그리고 두 함수의 일반화는 독자들에게 숙제로 남겨둔다. 매우 간단하지만 연습은 되리라 생각한다. 중요한 것은 add_from_to와 proc…이 어떻게 같은 구조를 가진다는 점을 발견하게 되었는가 하는 것이다. 짐작하다시피 generate의 필요와 개발에 의해서다. 즉 일반화와 추상화는 한번에 드러나지 않는 경우도 많으며 단계를 밟아가며 주의 깊게 전체 프로그램의 구조를 지켜볼 필요가 있다. 그리고 항시 모듈화 더 나아가서 추상화와 일반화를 항시 염두에 두지 않으면 재사용성이 높은 프로그램 구조란 요원한 목표일 뿐인 것이다.

자 이제는 더 이상의 문제가 없는 가를 따져 보자. 다시 말해서 accumulate가 지나치게 의존하고 있는 점이 없는 가를 살펴보고 해결책을 모색하는 것이다. 당장에 독자들도 눈치를 챘겠지만 accumulate는 array란 random-accessible 컨테이너에만 의존하고 있다. 그리고 그러한 제약은 accumulate가 하는 처리과정에 비하면 너무 지나친 제약이다. 우선 순차적 처리를 할 뿐인데 random-accessible 연산자 operator[]을 사용하고 있다. 또한 컨테이너가 오리지 배열과 같은 데이터 구조만 있는 것이 아니다. 순차적으로 원소를 얻어낼 수 만 있다면 어떤 컨테이너든지 accumulate의 처리대상이 될 자격이 있다. 이와 같이 보유하고 있는 원소를 선형구조로 토해낼 수 있는 컨테이너를 특히 시퀀스(sequence)라고 한다. 그렇다면 어떻게 해야 시퀀스(컨테이너)와 처리과정 사이의 의존성을 없앨 수 있을 까? 필자 생각엔 이제 야구장에서 발길을 돌려 대량생산을 대량처리가 이루어 지고 있는 공장을 관찰해 보아야 할 것 같다.

작업 분화의 핵심 - 컨베이어 벨트의 표현 Iterator #

근대산업의 대량생산을 가능하게 하는 자동화된 공정의 일등 공신은 컨베이어 시스템이다. 처리되어야 할 동일한 부품이 대량으로 입하되면 하나씩 또는 여러 개를 한 묶음으로 하여 컨베이어 벨트 위에 놓여진다. 직공 또는 로봇이 일정한 시간간격에 맞추어 쉴 틈 없이 동일한 작업을 반복하여 전체 제품을 처리한다. 모두 처리된 제품은 다음 단계의 공정을 밟기 위하여 모두 수거되거나 바로 이어지는 컨베이어 벨트로 연결된다. 만일 다음 단계의 공정이 현 공장에서 처리되지 않는 것이라면 모든 부품은 컨테이너에 옮겨져 수송되어야 한다. 이때 부품은 방금 이루어진 공정에 의하여 물리적으로나 화학적으로 완전히 다른 제품으로 변경될 수 있으며 이전에 사용하던 컨테이너 시설로는 불충분하다는 판단이 들면 다른 형태의 컨테이너가 사용되는 것이 합리적이다. 수집,수송 단계가 공정과는 완전히 분리되어 있다는 점을 주목할 필요가 있다. 또한 공정과정을 살펴보자. 전체 부품을 처리하는 단계가 고정되어 있을 경우라면 모든 단계를 파이프라인 방식으로 열거하여 끊임없이 입하되는 부품을 처리하는 것이 가장 빠른 방법이다. 즉 특정 컨베이어 벨트 라인은 특정한 작업만을 하는 방식으로 배치되어 있고 절대 변경할 필요가 없는 경우가 이에 해당한다. 그러나 현대의 산업사회와 같이 다품종 소량생산을 요구하는 경우나 제한된 공장부지나 인력자원 그리고 비싼 로봇을 재원으로 확충하기 어려운 경우에 위와 같은 방식은 매우 경직된 생산체계가 아닐 수 없다. 결국 컨베이어 벨트는 오로지 정해진 간격으로 동종의 부품을 계속 열거하고 이동하는 역할만을 담당한다. 그리고 처리과정을 담당하는 로봇은 프로그램 가능한 방식으로 사용자의 주문에 따라 단계별로 다른 일을 할 수 있도록 설계되어 있다면 매우 유동적인 생산체계를 갖추는 것이 가능하다.

난데 없이 공장이야기를 늘어놓아서 의아해 할 수도 있겠지만 설명한 바와 같은 대량생산/대량처리/다품종 소량생산 등의 처리방식이 바로 앞서 우리가 개발했던 accumulate의 구조적 결함의 개선책이다. 또한 지금부터 설명하는 프로그래밍의 구조가 바로 Standard Template Library의 명성을 자자하게 한 결정적인 이유이다. 필자가 이렇게 공정과정이나 야구장을 예로 들어가며 설명하는 이유는 STL에 대한 몇 번의 강의 경험에서 얻게 된 설명 방법이다. 처음에 필자는 STL을 정말 있는 그대로 설명하려고 애썼다. STL은 다음 다음과 같은 헤더파일로 구성되어 있고 컨테이너에는 뭐가 있고 Functor에는 무엇이 있다고 열심히 서너 시간 열거하고 나면 배워야겠다는 생각을 전파하는 데는 성공하였을 지 모르지만 정작 핵심적인 구조를 이해하여 이후의 프로그래밍 작업에 적극 활용하는 이는 별로 찾아볼 수가 없었다. 물론 배우는 이의 자세에도 문제가 있겠지만 가르치는 방식도 커다란 문제라는 점을 알게 되었고 가장 중요한 것은 STL이 어디 외계에서 온 새로운 이론적 체계에 의해 만들어진 라이브러리가 아니라는 점을 지적하여 듣는 사람으로 하여금 거부감을 없애는 데 있다. 다시 밝히지만 STL의 구조는 필자가 설명한 공장의 구조와 완전히 동일하다. 그러므로 STL을 사용하여 프로그램 한다는 것은 공단을 설계하는 것이다. 이 공장은 무슨 작업을 하므로 어떤 종류의 로봇과 프로그램이 필요하다 (Functor). 작업의 흐름은 이렇게 이어져야 한다 (Work flow). 어떤 컨테이너를 사용하여 운반하고 수송하고 저장하는 것이 가장 합리적인가 - 배열이냐 linked list냐? 컨베이어 벨트는 몇 개나 있으면 되고 작업의 흐름을 위해 어떻게 다른 벨트와 연결되어야 하는 가? 등을 결정하는 것이 바로 STL을 사용하는 아니 전체 C++ 표준 라이브러리를 사용하여 프로그램 하는 방법이다. 이와 같은 방식으로 객체지향적 프로그래밍이라 기보다는 이론적인 용도로만 주로 사용되어오던 고차 함수형 프로그래밍 언어에서 사용되어 오던 것과 거의 동일한 즉 Generic Programming 기법이다. 이와 같은 프로그래밍 기법의 안전성과 유용성은 이미 입증되었지만 이전에는 이를 효율적으로 처리하기가 힘들었을 뿐이다. C++가 template을 지원하고 이를 극단적으로 활용한 STL이 설계되어 일반과 효율성이란 상충하는 두 마리 토끼를 모두 잡아버린 셈이다.

이제 공장과 STL의 구조를 직접 비교하면서 accumulate의 해결책을 제시하자. 공장에서 작업자와 컨테이너 사이의 의존관계를 없앤 것은 바로 컨베이어 벨트 때문이다. 컨베이어 벨트는 현 공정과정에서 처리되어야 할 동종의 부품을 하나씩 요청에 따라 열거하는 역할을 한다. 그리고 처리된 원소를 다음 컨베이어 벨트로 넘긴다. 개발한 accumulate가 오로지 vector에만 의존할 수 밖에 없었던 이유는 컨테이너에서 직접 부품을 끄집어내어 처리했기 때문이다. 그러므로 중요한 모듈화와 추상화작업이 한 단계 생략된 것이다. 로봇은 이미 함수 인자로 잘 처리되어있다. 그러므로 이제 어떤 컨테이너라도 가지고 있는 부품을 하나씩 토해놓을 수 있는 컨베이어 벨트의 추상화가 필요하며 이것이 바로 STL의 핵심적인 Iterator의 개념이다. 결론적으로 STL은 작업과정을 추상화한 “generic algorithm” 즉 제어의 구조와 다수의 컨테이너 각각 부품에 가해져야 할 처리공정을 추상화한 함수객체 – Functor, 그리고 컨테이너와 알고리즘, 알고리즘과 알고리즘과의 연결을 가능하게 하는 Iterator로 이루어져 있다.

자 이제 관찰된 해결책으로 accumulate를 개선해 보자.


4부: 실제의 STL로 이어집니다.