Generic Programming in C++ (1)

소개와 활용 I

출판 정보

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

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

Generic Programming 원문에서는 “포괄적 프로그래밍"이라 번역했다. 당시 국내 기술 문서에서 흔히 쓰던 표현이지만 지금 보면 어색하다. 이 글에서는 원문을 보존하는 원칙의 예외로, 본문 전체에 걸쳐 Generic Programming으로 고쳐 쓴다. 은 객체지향성과 다른 방식으로 소프트웨어 재사용성 문제를 해결한다. 이번 호에는 표준 라이브러리의 핵심인 Standard Template Library의 설계 원칙 및 그 구조에 대한 논의를 시작한다.

이전 호 정오표

Dr. Bjarne Stroustrup의 “The C++ Programming Language, 3rd. Edition"에 소개된 c_array를 예로 들면서 그 객체의 생성문법에 오류가 있었다. c_array는 2개의 template parameter를 요구한다. 그러므로:

c_array< int > iary(5);

c_array< 5, int> iary(5);

로 고쳐야만 한다. 즉, 모든 원소가 5로 초기화된, 크기가 5로 고정된 int형 배열을 생성한 것이다.

1998년 학습 환경 — 컴파일러 추천

실제 현장에서 프로그램에 여념이 없는 독자라면 사실 이 연재기사를 통해 소개되는 실험적 기능을 이용하여 안정된 프로그램을 하는 데 꽤 시간이 걸릴 것이 분명하다. 그 이유는 일단 제대로 모든 언어기능을 지원하는 compiler가 없어서 연습할 환경이 그리 마땅치 않기 때문이고, 다행히 몇몇 compiler가 부분적으로 새로운 기능을 지원하지만 여전히 버그가 많고 어떤 경우에는 너무 무성의한 에러 메시지를 내놓는 바람에 사용자를 질리게 만드는 경우도 있다. 필자가 이런 얘기를 꺼내는 이유는 이쯤 되면 아마도 독자들이 이 기사를 통해서 소개된 언어 기능들을 실험해 보고자 할 것이 분명하기 때문에 쉽게 구할 수 있는 학습용 컴파일러를 하나쯤은 알아두어야 할 필요가 있을 것 같아서다. 특히 template 기능을 제대로 지원하고 전체 C++ 표준 라이브러리는 아니더라도 STL 만큼은 잘 제공해 주는 컴파일러라면 더욱 좋다. 필자는 첫 기사에서 기대를 건다고 밝힌 바 있는 EGCS를 감히 권하는 바이다. EGCS(Experimental GNU Compiler System)는 이듬해인 1999년 4월 FSF로부터 공식 GCC 관리권을 넘겨받아 같은 해 7월 GCC 2.95로 재출시됐다. 현재는 GCC, Clang, MSVC 모두 C++ 표준을 충실히 구현한다. — GNU Compiler Collection (Wikipedia) 아직 namespace 등의 기능을 지원하진 않고있지만 매우 진보적이고 빠르게 개선되고 있는 GNU C++ 컴파일러이다. 필자가 글을 쓰고 있는 현재 (1998년 8월 6일) 1.0.3a까지 발표된 것을 확인하였다. template 기능의 처리며 오류 발생시의 메시지가 매우 마음에 든다. 우리나라에 널리 알려진 몇 개의 상용 C++ 컴파일러들 처럼 동문서답식의 오류 메시지로 개발자들을 방황하게(?) 만들지 않아서 좋다. 필자가 EGCS를 사용해 보며 다시 금 깨닫게 된 것은 개발환경이 화려하고 인터페이스만 직관적이라고 해서 실제 개발 과정이 생산적인 것은 결코 아니라는 사실이다. 좋은 질의 소프트웨어는 더더욱 컴파일러의 안정성과 기본기능의 충실한 구현으로부터 오는 것이다. 어쩔 수 없이 다른 컴파일러를 사용할 수 밖에 없는 독자라고 하더라도 가능하다면 우선 자신의 프로그램을 비교적 신뢰할 수 있는 컴파일러에서 구현하고 검사해본 다음 자신의 환경으로 가지고 가서 고쳐보기를 권하는 바이다. ~~여전히 불만족스럽고 불편하겠지만 조만 간에 표준에 부합하는 상용 컴파일러는 나오기가 힘들 것이기 때문이기도 하다.~~ 빗나간 예측. 2000년대 초부터 GCC, MSVC, 이후 Clang이 C++ 표준을 충실히 지원하기 시작했다. 필자는 STL 또한 SGI STL이나 ObjectSpace의 Standard Toolkit을 쓴다. SGI STL은 이후 C++ 표준 라이브러리에 흡수됐다. 현재는 #include <numeric>, #include <algorithm> 등 표준 헤더만 쓰면 된다. 더욱이 둘 다 무료이며 구현의 질(Quality of Implementation; QOI) 또한 상당하다.

표준 라이브러리를 응용한 Generic Programming #

이번 C++의 가장 큰 변화는 역시 template 기능과 이를 극단적으로 활용한 표준 라이브러리이며 표준 라이브러리의 가장 핵심적인 부분은 STL이다. 그러므로 STL을 중심으로 Generic Programming을 익히는 것이 이번 C++의 변화를 실질적으로 체험하는 방법이며, 더 나아가서 고급 C++ 사용자라면 객체지향형과의 적절한 혼합기법을 연구하는 것이 발전적인 학습방법이란 점을 수 차례 언급한 바 있다. 그래서 논의되는 내용의 특성상, 지금 까지는 주입식이며 열거형일 수 밖에 없었지만 이번 호부터는 방법을 좀 달리하여 구체적으로 유용한 class들을 설계하고 구현해가며 왜 그와 같은 언어적 확장이 필요하고 실제 프로그래밍에 표준 라이브러리를 적극 활용하는 것이 좋은 개발방법인가를 역으로 설명하기로 하였다. 결론적으로 지난 호 까지 거의 대부분의 새로운 언어 특징을 소개하였으므로 이번부터는 단순 열거식의 기능 나열이 아닌 활용을 통한 학습에 더욱 중점을 둘 생각이다. 독자들은 자연스럽게 STL의 Generic Programming 기법에 점차 익숙해지리라 믿는다. 특히 이번 호는 Generic Programming과 STL등의 표준 라이브러리 구현에 활용된 프로그래밍 기법을 기초부터 차근히 논의하고 있으므로 이전에 이와 같은 프로그래밍 기법을 학습할 기회를 가지지 못했던 독자들은 STL의 고급 활용법을 다루기 위한 사전 준비정도로 파악하면 적당할 것이라 본다.

More general, more reusable #

간단한 예제로부터 시작하자. 그냥 1에서 10까지 더하는 프로그램을 C++로 작성해보자. using namespace std;를 전역 범위나 헤더 파일이 아니라 함수 안에서 쓰고 있으니 큰 문제는 없지만 std::cout처럼 쓰는 게 더 낫다. (2026)

#include <iostream>
int
main(int, char**)
{
    int sum = 0;
    for (int i = 1; i <= 10; ++i)
    {
        sum += i;
    }
    using namespace std; 
    cout << "sum (1 to 10) = " << sum << endl;
    return 0;
}

위의 프로그램은 1에서 10까지 더하는 경우에만 동작한다. 그러나, 당장에 1에서 15 까지 누적하는 프로그램을 작성하는 경우 같은 형태의 for문을 재작성할 수 밖에 없다. 즉 전혀 재사용 가능한 코드가 없는 것이다. 블록 복사로 코드를 깡그리 복사해서 10을 15로 바꾸는 짓을 하지 않는 다음에야 별 도리가 없다. 때때로 실제 프로그래밍에선 클립보드를 이용한 재사용(?)도 매우 실용적인 경우가 있긴 하지만 이 글을 읽고 있는 독자들이라면 웃으며 당장에 함수를 사용하여 1에서 n까지의 더하는 프로그램을 작성할 것이다. 그리고 분할 컴파일을 위해서 별도의 파일로 분리해 낼 것이다. 물론 너무 기초적인 것이지만 이와 같은 간단한 일반화를 통해서도 우리는 훨씬 활용도가 높은 프로그램을 작성할 수 있으며 실제 프로그래밍 시에 이런 단순한 추상화 조차도 잘 실천되고 있지않는 게 사실이다. 어쨌든 우리는 아래와 같이 한 단계 더 일반화된 프로그램을 작성할 수 있다.

// main.cpp
#include <iostream>

int add_to_end(int end); // == 1 + 2 + ... + end

int main(int, char**) {
    using namespace std;
    cout << "sum (1 to 10) = " << add_to_end(10) << endl;
    return 0;
}

// add.cpp
int add_to_end(int end) {  }

반복적으로 사용할 수 있는 독립적인 기능을 add함수로 분리해내어서 일단계 일반화가 가능했다. 그러나 add_to_end는 여전히 시작이 항시 1이어야 한다는 제약이 있다. 그러므로 시작점 또한 함수의 인자로 만들어 버리면 훨씬 재사용성이 높을 것이다. 그리고 중요한 것은 main함수 - add_to_end의 사용자 코드- 는 전혀 변경하지 않고 확장해야 한다. 그 자체가 추상화의 아주 중요한 목적 중의 하나이기 때문이다. 그러므로 일단 합산할 범위를 인자로 받아 덧셈을 하는 함수가 필요하며 그 기능적 특성상 add.cpp file로 집어넣는 게 옳다.

// add.cpp

// prototyping
int add_to_end( int end );
int add_from_to(int begin, int end);

// implementation
int add_to_end(int end) { return add_from_to(1, end); }
int add_from_to(int begin, int end) {  }

이제 더 일반화할 수는 없을 까 고민해 보자. 우리가 작성한 add_from_to는 begin에서 end까지 sum에 모든 열거 가능한 정수를 합산하는 것이다. 만일 우리가 multply_from_to를 작성한다면 아래와 같고 항등원과 곱셈을 제외하면 꼴이 완전히 같다. next <= beginnext <= end로 바로 잡았다. (2026)

int multiply_from_to(int begin, int end) {
    int sum = 1;
    for (int next = begin; next <= end; ++next) { sum *= next; }
    return sum;
}

즉 함수 내부의 제어의 흐름은 완전히 동일하므로 이진연산과 그 연산에 대한 항등원 만을 인자로 넘긴다면 어떤 연산이든지 같은 함수를 다시 사용하여 코드를 작성할 수 있다. 더욱 일반화된 함수의 이름을 accumulate라고 하자.이때도 물론 직접관련이 없는 사용자 코드는 있는 그대로 유지하는 것이 중요하다. default argument를 지정한 매개변수는 그렇지 않은 매개변수 앞에 못 오는 게 당연한데도 begin=1처럼 default argument를 지정해서 add_to_end를 없앨 수 있다는 착각을 주석까지 붙여서 설명하는 잘못을 저질렀다. 이어지는 기사에서 바로 잡기는 했지만, 말도 안되는 소리라서 일찌감치 여기서도 (add_to_end를 그대로 두어) 틀린 것을 바로잡아두었다. (2026)

// add.cpp
// prototyping
int add_from_end( int end );
int multiply_from_end(int begin, int end);
int accumulate( int (*binop)(int, int), int identity, int begin, int end);

// some binary functions for paramerization
int add(int x, int y) { return x + y; }
int multiply(int x, int y) { return x * y; }

// Implementation
int add_to_end(int end) { return add_from_to(1, end); }
int add_from_to(int begin, int end) { return accumulate( add, 0, begin, end); }
int multiply_from_to(int begin, int end) { return accumulate( multiply, 1, begin, end); }

int accumulate(int (*binop)(int, int), int identity, int begin, int end ) {
    int sum = identity;
    for (int next = begin; next <= end; ++next)
    {
        sum = binop( sum, next );
    }
    return sum;
}
// C++20: <numeric> + <functional> + <ranges>
#include <numeric>
#include <functional>
#include <ranges>

int add_to_end(int end) { return add_from_to(1, end); }

int add_from_to(int begin, int end) {
    auto r = std::views::iota(begin, end + 1);
    return std::accumulate(r.begin(), r.end(), 0, std::plus<int>{});
}

int multiply_from_to(int begin, int end) {
    auto r = std::views::iota(begin, end + 1);
    return std::accumulate(r.begin(), r.end(), 1, std::multiplies<int>{});
}

이쯤 되면 제법 사전선언(prototyping) 해야 할 함수의 양이 많아졌기 때문에 헤더파일로 분리하는 게 좋을 듯 싶다. 그러므로 이후의 논의에서는 add.h만을 #include하는 것으로 하고 논의의 편의를 위해서 prototyping도 생략하기로 하자. 물론 새로운 함수가 작성되고 file내에서 anonymous (이전의static 선언과 같은 효과) namespace로 둘러싸여진 함수가 아니라면 add.h에 그 사전 선언정보가 자동적으로 추가된다고 가정하자.

accumulate는 초기에 우리가 작성하고자 하던 것에 비하면 매우 일반화된 함수이고 재활용가능성이 높다. 이 과정에서 직접 만들어본 accumulate는 실제로 C++98 표준 라이브러리의 std::accumulate(<numeric>)로 채택됐다. 현재는 직접 구현할 필요 없다. C++20부터는 std::ranges::fold_left도 쓸 수 있다. 독자들은 의구심이 들 것이다. 겨우 1에서 10까지 더하려고 저렇게 나 프로그램을 해야 한단 말인가? 초기에 프로그램이 오히려 읽기도 쉽고 간단하지 않는 가? 옳은 말이다. 뒤에 줄줄이 일반화해온 프로그램은 맨 처음의 그 단순한 코드보다 성능면에서도 가독성 면에서도 결코 나아진 것이 없다. 그러므로 만일 프로그램을 하루만 작성하고 말 것이라면 맨 처음 같이하면 가장 현명한 사람이다. 그러나 문제는 프로그램이라는 것이 하루만하고 그만두는 작업이 아니라는 점이며 만일 우리가 일반화를 통한 추상화 없이 프로그램을 작성한다면 날이 갈수록 우리는 코드를 쓰다가 지쳐버릴 것이 자명하다. 그 증거는 바로 이렇다. 위에서 작성한 프로그램으로 만족하려고 하는 데 또다시 각각의 원소를 제곱하여 모두 더하거나 곱하는 프로그램을 작성해야만 한다고 하자. 그와 같은 함수 역시 제어의 흐름이 accumulate와 별반 다를 바가 없다는 점을 쉽게 눈치 챌 수 있다. 이 시점에서 우리는 더 일반화하는 것을 선택하든 가 아니면 이전처럼 클립보드를 이용한 코드 재사용을 택하든 가 판단을 내려야만 한다. 일반화와 추상화를 택한 독자라면 필자를 따라 열심히 작업을 진행하기로 하자. 아직도 추상화의 길은 멀다.

함수 인자를 사용하는 일반화 #

accumulate는 함수 인자 binop 덕택에, 적용되는 연산에 독립적으로 수열의 결과치를 얻어낼 수 있는 매우 일반적인 함수가 되었다. 그러나 앞에서 잠시 언급한 바와 같이 accumulate는 여전히 심한 제약이 있다. 다음의 두 가지 문제를 풀어야 하는 경우에 accumulate는 전혀 재사용이 불가능하다.

  1. 수열의 각 항목을 제곱하여 더하거나 곱하는 경우
  2. 수열이 1씩 증가하지 않는 경우

즉 accumulate는 반드시 1씩 증가하는 정수를 열거하고 있고 열거된 각 수는 주어진 binop에 있는 그대로 적용될 수 밖에 없다. 그러므로 제어구조가 완전히 동일함에도 불구하고 accumulate는 전혀 재사용될 수가 없는 것이다. 그러므로 우리는 accumulate를 제외한 다른 프로그램을 손대지 않고 일반화하는 프로그래밍 기법이 필요한데 binop의 경우처럼 함수 인자와 default argument를 사용해서 매우 간단하게 해결할 수 있다.

int add(int x, int y) { return x + y; }
int succ(int x) { return add(x, 1); }
int id(int x) { return x; }

int accumulate(int (*binop)(int, int), int idelem, int begin, int end,
    int (*step)(int)=succ,
    int (*term)(int)=id)
{
    int sum = idelem;
    for (int next = begin; next <= end; next = succ(next))
    {
        sum = binop(sum, id(next));
    }
    return sum;
}

이제는 만일 4씩 증가하는 정수열의 제곱의 곱이나 합을 구하는 함수를 작성해야 한다면 다음과 같이 프로그램하면 된다.

int square(int x) { return x*x; }
int plus4(int x) { return add(x, 4); }
int square_accumulate_by_step4( int (*binop)(int, int), int idelem, int begin, int end) {
    return accumulate( binop, idelem, begin, end, plus4, square);
}

그럼 위와 같은 일반화 과정을 거치고서도 재사용성에 저해되는 요소가 있는 가라는 질문에 답해보자. 이미 C++와 상당히 친숙한 독자라면 위의 함수가 가진 치명적인 약점을 이미 처음부터 간파하고 있었을 것이다. 다음과 같은 문제를 여태까지 개발한 accumulate를 가지고 잘 풀 수 있는 가를 따져보는 것으로 충분하다.

double 형으로 1에서 10까지 0.5씩 증가하는 수열의 곱이나 합을 구하는 함수를 작성하라.

Type-casting, type-conversion등은 이 문제를 해결하는 데 아무런 도움이 되지 않는 다. 그리고 일반적으로 C/C++에서 mixed type arithmetic 연산은 프로그램의 이식성이나 안전성에 가장 위험한 요소이므로 아예 생각하지 않는 편이 좋다. 자 그러므로 이제 template이 등장할 때가 되었다.

형과의 독립성 – (Type) Parameteric polymorphism #

여태까지 실컷 개발한 함수가 형이 다르다고 아무런 쓸모가 없다면 너무 슬픈 일이다. 형이 다르다고 제어구조가 달라질 리가 없음에도 언어가 형과 함수내부의 논리적 제어구조가 서로 분리될 수 있도록 지원하지 않는 다면 프로그래머에게는 악몽과 같은 일이 아닐 수 없다. 그러므로 독자가 C 프로그래머라면 필요할 때마다 모든 형을 위하여 동일한 프로그램을 형 이름만 바꾸어 가며 열심히 베끼고 편집하거나 C언어의 표준 라이브러리가 지원하는 quicksort와 같이 void */sizeof 연산자와의 전쟁을 시작해야 하는 도리밖에는 없다.

그러나 다행스럽게도 C++는 template을 지원한다. 주지하다시피 C++의 template이란, 여태 우리가 인자의 수를 늘리고 독립적인 추출해낼 수 있는 함수를 인자로 만든 것 처럼 accumulate와 같이 재사용성이 높은 함수를 특정한 형에만 의존하지 않도록 형 자체를 인자로 만들 수 있도록 하는 기능이다. 또한, C++의 template은 사용시점에 구체적으로 주어진 형에 따라 제약이 심하기는 하지만 어느 정도 수준에서 형을 유추하여 필요한 코드를 묵시적으로 생성해낸다. 그러므로 이러한 기능을 잘 활용하면 기존의 프로그램을 수정할 필요 없이 generic accumulate를 작성할 수 있다. 그리고 그 방법은 너무나 간단하다.

// add.h

export
template<typename T> T accumulate(T (*binop)(T, T), T idelem, T begin, T end,
    T (*step)(T)=succ,
    T (*term)(T)=id);


// add.cpp
template<typename T>
T accumulate(T (*binop)(T, T), T idelem, T begin, T end,
    T (*step)(T),
    T (*term)(T))
{
    T sum = idelem;
    for (T next = begin; next <= end; next = step(next))
    {
        sum = binop(sum, term(next));
    }
    return sum;
}

나머지 코드는 전혀 손댈 필요가 없어야 정상이고 만일 독자가 사용하는 compiler가 위와 같은 변화 후에 알 수 없는 에러 메시지를 내놓는 다면 template을 제대로 지원하지 못하기 때문에 생긴 버그라고 보면 된다. 물론 필자가 사용하는 EGCS의 경우도 “export"ing 기능을 아직 지원하진 않아서 accumulate의 정의를 add.cpp로 분리해 낼 수는 없었다. export template은 C++98 표준에 있었지만 실제로 구현한 컴파일러가 없었다. 결국 C++11에서 폐기됐다. 지금은 template 정의를 헤더에 두는 것이 관례다. (2026)

다음으로 accumulate 이외에 여태 만들어둔 함수는 정말 가만히 놔두어도 좋은 가를 살펴 보자. 아마도 accumulate같은 문제점을 지적할 수 밖에 없을 것이다. 당장에야 int형만을 위해 사용하는 데는 문제가 없지만 double, float, short, long등의 built-in 형과 사용자 정의형 까지 무수히 많은 적용 가능한 형을 위해서 아무리 작은 함수라 하더라도 깡그리 다시 작성 한다는 것은 그리 유쾌한 일이 아니다. 최소한 여태 우리가 해왔던 작업의 방향과도 일치하지 않는다. 그러므로 모두 generic 함수로 만들어버리자. template를 각 함수 머리에 같다 붙이고 편집기로 전체 파일을 블록 설정한 다음 int를 모두 T로 변환하면 끝이다. 작업은 너무나도 쉽다. 그러나 그 효과는 크다.

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

template<typename T>
T succ(T x) { return add(x, 1); }

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

template<typename T>
T add_to_end(T end) { return add_from_to(1, end); }

그런데 문제는 add_from_to를 template으로 만들면서 발생한다. 더 이상 accumulate의 step, term을 위한 default argument로 succ/id의 함수 포인터를 사용할 수 없기 때문이다. 즉 이게 바로 C++가 효율성을 위하여 희생한 부분으로 좀 지저분하지만 잔 기술로 해결하는 수밖에 없다. 일단 accumulate의 사전선언부분에서 default arguments를 삭제하고 add_from_to의 정의를 다음처럼 변경하자. 일단은 별 무리가 없는 듯 보인다.

template<typename T>
T add_from_to(T begin, T end) {
    return accumulate(add<T>, T(0), begin, end, succ<T>, id<T>);
}

add는 이미 설명한 바와 같이 explicit type qualification기능을 사용하여 특정형의 template정의가 사용될 수 있도록 제한함에 의해 template 함수가 인자로 넘어갈 수 있도록 시도한 것이다. 다시 말하자면 add그 자체는 실제 생성된 코드가 아니므로 그 포인터를 취할 수가 없다. 그러므로 add로 형을 제약하여 정상적인 C++ 함수 포인터의 생성을 유도한 것으로 볼 수 있다. 사실 의도는 좋았지만 위와 같이 template을 사용하는 것은 불가능하다. 위와 같이 사용하면 C++는 안타깝게도 add의 형을 제대로 유추하지 못한다. 그러므로 바로 넘기지 않고 일단변수에 할당한 다음 사용하는 잔꾀를 부려야 제대로 동작하게 된다. 좀 너저분하지만:

template<typename T>
T add_from_to(T begin, T end) {
    T (*addT)(T, T) = add<T>;
    T (*succT)(T) = succ<T>;
    T (*idT)(T) = id<T>;
    return accumulate(addT, T(0), begin, end, succT, idT);
}

이제 약간 욕심을 더 부려보자. 예전에 C언어에서 함수형 포인터를 사용할 때 문법 때문에 typedef를 써서 좀 더 코드를 읽기 쉽게 만들어 본 경험이 있을 것이다. 만일 int형 이진 연산자를 빈번히 사용해야 한다면:

typedef int (*intBinOp)(int, int);
intBinOp addint = add<int>;

같이 사용할 수 있었다. 그런데 문제는 template typedef 가 없다는 점이다. C++11에서 using alias template으로 해결됐다. template<typename T> using BinaryOp = T(*)(T, T); — 아래의 struct 우회 없이 바로 쓸 수 있다. (2026) 즉:

template< typename T> typedef T (*intBinOp)(int, int);

같은 선언이 불가능 하다 (이번 C++ 표준에서 필자가 매우 불만족스러워 하는 것 중에 하나다.). 그러므로 이때는 또 한번 template class를 사용하는 잔 기술을 부리는 수 박에 없다. 어느 쪽이 더 마음에 드는 지는 독자의 판단에 맡기는 바이지만 어쨌든 template typedef 대용으로 사용되는 숙어이므로 알아두면 편리할 때가 있을 것이라고 생각한다.

template<typename T>
struct BinaryOp {
    typedef T (*type)(T, T);
};

template<typename T>
struct UnaryOp {
    typedef T (*type)(T);
};

template<typename T>
T accumulate(BinaryOp<T>::type binop, T idelem, T begin, T end,
    UnaryOp<T>::type step,
    UnaryOp<T>::type term) {  }

template<typename T>
T add_from_to(T begin, T end) {
    BinaryOp<T>::type addT = add<T>;
    UnaryOp<T>::type succT = succ<T>, idT = id<T>;

    return accumulate(addT, T(0), begin, end, succT, idT);
}

자 이제 독자들도 필자처럼 더 이상의 일반화가 불가능한가를 생각하는 습관이 들었을 것이라고 생각한다. 이러한 생각을 유도할 때 가장 좋은 것은 이미 작성된 기능과 유사하지만 성질이 약간 다른 특수한 문제를 스스로 생각해보고 개발된 프로그램이 여전히 사용될 수 있는 가를 따져 보는 것이다. 여태까지 개발한 프로그램에서 가장 일반적이고 핵심적인 함수는 accumulate다. Accumulate 그 자체는 template 기능을 사용함에 의해 더 이상의 일반화가 불가능 할 만큼 개선되었다. 그러나, 문제는 accumulate내부에서 처리되는 자료인 수열에 있다. 즉, accumulate가 처리할 수 있는 수열은 A(i+1) = term( step( Ai ) )라는 규칙을 따라 생성되고 있기 때문에 이와 같은 틀에 잘 들어맞지 않는 random sequence, 외부로부터 입력되는 T 형의 원소들, 이미 배열이나 어떤 컨테이너 내부에 들어 있는 원소들을 처리하기 위해 accumulate를 사용하는 것은 불가능하거나 매우 고통스런 작업이 될 것이다. 또 한가지 지나친 제약은 accumulate 내부의 for문에서 next <= end라는 종결조건에도 있다. 이와 같이 for문의 종결조건은 원소간의 엄격한 순서관계에 의존하기 때문에 만일 처리되어야 할 수열이 A(i+1) > A(I)라는 조건을 만족하지 않는 방식으로 생성된다면 무한반복이나 조기종료라는 비정상적인 수행결과를 얻을 수 밖에 없다. 그렇다면 여태까지의 일반화 과정이 무의미한 것인가? 그렇지 않다. 단지 작업이 완결되지 않았기 때문일 뿐이다. 문제는 accumulate 자체의 일반화 과정에 있다고 하기 보다 불완전한 추상화 때문에 발생한 모순이라는 편이 더욱 합당한 분석이라고 할 수 있다.

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

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를 개선해 보자.

실제의 STL #

다시 강조하지만 STL은 표준 라이브러리의 일부다. 그러므로 STL이란 명칭으로 표준 라이브러리의 일부를 따로 분리하여 거론하는 것은 별로 바람직 하지 않다. 다만 초기에 이러한 라이브러리를 설계하고 구현한 Alexander Stepanov가 그의 관련 논문에서 부여한 명칭을 설명의 편의를 위해 언급하는 것 뿐이므로 오해가 없었으면 한다.

사실 필자는 STL을 아주 상세하게 설명하고 싶지않다. 그 수많은 기능을 모두 알지 못하기도 하거니와 이미 잘 정리된 문서가 웹상에 널려 있으므로 그 참고자료는 아주 많은 셈이며 또 다시 여기에 반복하는 것은 지면의 낭비일 수 있다. 필자는 단지 그 활용 기법과 보완 책 기본적인 설계원칙을 설명하고자 한다. 그러므로 나머지는 독자스스로의 학습에 달려있다. 사실 STL의 학습은 MFC/OWL/VCL등의 상용 라이브러리에 비하면 훨씬 쉽고 진도가 빠르다는 점을 필자 스스로도 체험한 바 있다. 오히려 문제가 되는 점은 처음에 이 라이브러리를 접했을 때 오는 사고방식의 혼동에서 온다. 그러므로 STL을 잘 사용하고 싶은 독자라면 라이브러리가 제공하는 클래스와 클래스가 제공하는 인터페이스 등을 학습하는 것도 물론 해야 할 과정이지만 프로그래밍의 사고 방식을 STL 방식으로 변화하기 위해 노력하는 것이 더욱 중요하다. 그와 같은 사고 방식은 이미 위에서 언급한 바 있다.

이제 STL의 실제 구조를 살펴보자. 이미 언급한 바와 같이 STL은 컨테이너 – Iterator – 알고리즘 – Functor로 이루어져 있다. 우선 컨테이너의 전체적으로 살펴보자.

std::<Container> #

STL은 다수의 컨테이너 클래스를 제공한다. 모두 프로그램에 범용적으로 사용될 수 있는 부품이며 잘 설계되어 있어 편리하고 사용방법도 거의 동일하여 배우기도 쉽다. STL의 컨테이너는 크게 두 가지로 분류할 수 있으며 그 분류에 따라 제공되는 연산도 각기 제약이 있다. 먼저 원소를 초기에 삽입한 순서에 의존하여 순차적으로 저장하고 열거하는 단순한 컨테이너 – Sequence들이 있으며 자료구조를 학습한 독자라면 이미 상당히 익숙한 명칭일 것이다. <vector>, <deque>, <list>, <stack>, <queue>, <priority_queue> 등이 이에 해당하는데 파일의 이름과 동일한 명칭의 template class를 제공한다. 여기서 <stack>, <queue>, <priority_queue> 매우 특수한 Sequence이므로 이후에 별도로 설명하기로 하자. Sequence와는 달리 Key값의 관계에 따라 원소를 내부적으로 재 배치하고 다시 key값으로 원소를 열거할 수 있는 컨테이너가 제공되며 이를 associative 컨테이너라고 한다. STL이 제공하는 Associative는 <set>, <map> 두 종류이며 각각 동일한 key 값을 갖는 원소의 중복을 허락하는 <multiset> <multimap>을 부가적으로 제공하고 있다. STL이 좋은 점은 서로 다른 컨테이너라도 거의 같은 기능을 사용하는 경우 완전히 동일한 인터페이스를 제공하기 때문에 사용상의 불편한 점을 거의 느낄 수가 없다는 점이다.