Generic Programming in C++, Part 1

이야기의 시작

출판 정보

프로그램 세계 1998년 8월호에 실린 “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와 별반 다를 바가 없다는 점을 쉽게 눈치 챌 수 있다. 이 시점에서 우리는 더 일반화하는 것을 선택하든 가 아니면 이전처럼 클립보드를 이용한 코드 재사용을 택하든 가 판단을 내려야만 한다. 일반화와 추상화를 택한 독자라면 필자를 따라 열심히 작업을 진행하기로 하자. 아직도 추상화의 길은 멀다.


2부: 함수 인자를 사용한 일반화, 그리고 template으로 이어집니다.