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

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

출판 정보

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

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

포괄적 프로그래밍은 객체지향성과 다른 방식으로 소프트웨어 재사용성 문제를 해결한다. 이번 호에는 표준 라이브러리의 핵심인 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형 배열을 생성한 것이다.

[학술주석] 이 정정 사항은 비타입 템플릿 파라미터(non-type template parameter)의 문법을 정확히 다루기 위한 것입니다. 1998년 당시 C++ 표준 라이브러리가 아직 확정되지 않았으므로, 구현에 따라 인터페이스가 다양했음을 보여줍니다. 현대의 C++20과 달리, 당시에는 배열 크기를 컴파일 타임에 결정하는 방식이 제한적이었고, 나중에 std::array<T, N> (C++11)로 표준화됩니다.


독자들의 학습을 위하여 #

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

[학술주석] 1998년 당시의 컴파일러 상황을 묘사한 대목입니다. EGCS(Experimental GNU Compiler System)는 나중에 GCC로 통합되었고, namespace 지원의 부재는 C++98 표준화 과정에서 중요한 이슈였습니다. “좋은 질의 소프트웨어는 컴파일러의 안정성으로부터 온다"는 주장은 현재까지도 유효하며, 컴파일러가 언어의 구현 품질을 결정한다는 통찰은 Rust, Go 등 현대 언어 설계에서도 핵심 원칙으로 이어집니다. SGI STL과 ObjectSpace는 C++11 이후 표준 라이브러리로 수렴되었습니다.

2024년 비평: 개발환경과 도구의 역할 #

흥미롭게도, 25년 전 이 글의 주장은 여전히 관련성이 있습니다. 당시 “IDE의 화려함이 개발 생산성을 보장하지 않는다"는 주장은 2024년의 “AI 코드 생성 도구가 좋은 설계를 대체하지 못한다"와 정확히 맞닿아 있습니다. 다만 차이점은 명확합니다.

첫째, 1998년의 문제는 도구(컴파일러)의 기능 부족이었고, 2024년의 문제는 도구의 기능 과잉입니다. 당시 표준 지원이 불완전했으므로 신뢰할 만한 구현체를 찾아야 했지만, 지금은 표준이 안정적이므로 표준 준수 여부를 확인하는 것이 중요합니다.

둘째, “신뢰할 수 있는 컴파일러에서 먼저 검증한 후 배포 환경으로 옮긴다"는 전략은 현대의 CI/CD, 크로스 플랫폼 테스트, 컨테이너라는 개념으로 진화했습니다. 기본 원칙(안정성 우선, 품질 검증)은 같지만 실행 방식이 고도화되었습니다.

셋째, 당시 “좋은 구현체를 찾기 어렵다"는 문제는 오늘날 “어떤 구현체를 선택할 것인가"로 바뀌었습니다. GCC, Clang, MSVC, Intel C++, ARM Compiler 등이 모두 C++ 표준을 지원하므로, 선택의 문제가 됩니다.


표준 라이브러리를 응용한 포괄적 프로그래밍 #

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

[학술주석] 여기서 저자는 교육학적 전환을 선언합니다. 단순한 문법 나열에서 벗어나 “왜 이렇게 설계했는가"라는 설계 철학을 강조합니다. 이는 1990년대 후반 교육의 큰 전환점으로, 이후 많은 고급 프로그래밍 교과서의 모델이 됩니다. 특히 “객체지향과의 혼합기법"이라는 표현은 당시의 화두였습니다. OOP가 주류였던 시대에 함수형(generic) 패러다임을 동시에 다루는 것이 얼마나 획기적이었는지를 보여줍니다.

2024년 비평: 교육론적 가치 #

이 섹션의 메타-메타 수준 관찰은 흥미롭습니다. 저자는 “지금까지 주입식 강의를 했지만, 이제부터 설계 원리 중심으로 전환한다"고 선언합니다. 이것은 단순한 수사(rhetoric)가 아니라, 1998년 당시 한국 프로그래밍 교육의 수준을 의미합니다.

2024년 관점에서 보면:


More general, more reusable #

간단한 예제로부터 시작하자. 그냥 1에서 10까지 더하는 프로그램을 C++로 작성해보자.

#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;
}

[학술주석] 의도적으로 극히 단순한 예제를 선택했습니다. 이 “작은 문제"로부터 시작하여 일반화 과정을 추적하는 것이 이 기사의 전략입니다. 이는 Dijkstra의 “The Humble Programmer"와 맞닥뜨리는 철학입니다. “큰 시스템을 만들기 위해 작은 것부터 배운다"는 원칙 대신, “작은 문제를 일반화해서 큰 설계 원칙을 발견한다"는 역방향 학습입니다.

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

[학술주석] “클립보드를 이용한 재사용(??)“이라는 표현은 비꼬는 뉘앙스입니다. 1998년에도 copy-paste 프로그래밍이 만연했음을 알 수 있습니다. 현대의 “프로그래머가 일반화를 잘 하지 못한다"는 문제는 25년 전과 동일합니다. 이는 개발 환경의 문제가 아니라 설계 마인드셋의 문제임을 시사합니다.

// 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) {  }

[학술주석] 첫 번째 추상화 단계입니다. 하지만 이 함수는 여전히 “1부터 시작한다"는 가정을 내포합니다. 이것은 인터페이스에 암묵적 계약(implicit contract)이 있음을 보여줍니다. 현대의 관점에서는 이를 C++20의 concept나 주석으로 명시화해야 합니다.

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

[학술주석] 여기서 저자는 **Open/Closed Principle (OCP)**를 간접적으로 설명합니다. “사용자 코드는 변경하지 않고 기능을 확장한다"는 원칙은 SOLID 원칙 중 하나입니다. 1998년에는 이 개념이 아직 공식적 용어가 아니었을 수 있으므로(SOLID는 2000년대 초반에 정립), 이 글은 선제적 설명이라 할 수 있습니다.

// 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를 작성한다면 아래와 같고 항등원과 곱셈을 제외하면 꼴이 완전히 같다.

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

[학술주석] 버그 발견: 코드에 오류가 있습니다. next <= beginnext <= end이어야 합니다. 저자도 이를 인식했는지, 다음 섹션에서 오류 정정을 합니다. 이는 손으로 쓴 원고를 타이핑하는 과정에서 발생한 오류로 보입니다.

즉 함수 내부의 제어의 흐름은 완전히 동일하므로 이진연산과 그 연산에 대한 항등원 만을 인자로 넘긴다면 어떤 연산이든지 같은 함수를 다시 사용하여 코드를 작성할 수 있다. 더욱 일반화된 함수의 이름을 accumulate라고 하자. 이때도 물론 직접관련이 없는 사용자 코드는 있는 그대로 유지하는 것이 중요하다.

// 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
// default argument기능을 사용하면 더 간단하다.
// 물론 main함수를 간단히 손봐야 겠지만 그 정도는 애교로 봐줄 수 있다
int add_from_to(int begin=1, int end) { return accumulate( add, 0, begin, end); }
int multiply_from_to(int begin=1, 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;
}

[학술주석] 여기서 **고차 함수(Higher-Order Function)**의 개념이 등장합니다. binopidentity를 함수 인자로 받는 것은 함수형 프로그래밍의 핵심입니다. 1998년 당시 C++에서는 함수 포인터가 유일한 방식이었고, 함수 객체(functor)는 아직 일반적이지 않았습니다.

2024년 비평: 일반화의 단계적 발전 #

이 섹션의 구조 전개는 탁월합니다. 1→10의 구체적 계산에서 시작하여, 단계별로:

  1. 함수 추출 (1→n)
  2. 범위 매개변수화 (begin, end)
  3. 연산 추상화 (binop)
  4. 항등원소 분리 (identity)

이러한 개진 방식은 **점진적 일반화(Progressive Generalization)**의 교재적 모범입니다.

현대의 관점에서의 비평:

그럼에도 이 설명 방식의 가치는 불변입니다. “왜 이 추상화가 필요한가"를 구체적으로 보여주기 때문입니다.


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

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

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

[학술주석] 저자는 현실적인 제약을 명확히 하고 있습니다. 이는 **추상화의 한계(Abstraction Limits)**를 인식하는 성숙한 관점입니다. 많은 초보 프로그래머는 한 번의 추상화로 “모든 문제"를 해결할 수 있다고 생각하지만, 실제로는 새로운 요구사항이 새로운 추상화 계층을 요구합니다.

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

이제 좀더 일반화된 accumulate를 만들어 보자. binop이 수열의 각 원소를 처리하는 데 binop이 아닌 다른 함수, 예를 들어 unary operation을 사용하는 경우라면 어떻게 할까? 먼저 unary operation으로 각 항목을 제곱하는 함수를 제공한다면:

int f(int x) { return x * x; }

그리고 accumulate를 다음과 같이 수정해 보자:

template<typename T>
T accumulate(T init, int (*binop)(T, T), int (*f)(T), int begin, int end)
{
    T sum = init;
    for (int i = begin; i <= end; i++)
    {
        sum = binop(sum, f(i));
    }
    return sum;
}

[학술주석] 이제 함수 객체로 나아가기 전 단계입니다. 함수 포인터의 개수가 늘어나고 있습니다. 이는 곧 인터페이스의 복잡도 증가로 이어지며, 다음 섹션에서 템플릿과 functor로의 전환을 준비합니다.

그런데 아무래도 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; }
};

[학술주석] 여기서 **함수 객체(Function Object, Functor)**가 도입됩니다. 이는 C++의 독특한 기능으로, operator()를 오버로드하여 객체를 함수처럼 사용합니다. 1990년대 중반에는 이 패턴이 매우 혁신적이었습니다.

특히 주목할 점은 operator()도 템플릿입니다 (멤버 템플릿). 이는 같은 add 객체가 int, double, string 등 모든 타입에 적용될 수 있음을 의미합니다. 이는 제네릭 함수 객체의 개념이며, STL의 기반입니다.

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이므로 생성을 요청 해야 함을 잊지 말자.
}

[학술주석] add(), id()는 임시 객체(temporary object) 생성입니다. C++의 임시 객체는 표현식이 계산되는 동안 존재합니다. 이 관례는 현대 STL에서도 표준입니다.

이에 따라 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;
}

[학술주석] 이것이 C++의 Structural Typing (또는 Duck Typing)의 한 형태입니다. BinFtor는 구체적 타입이 아니라, “binop(x, y) 호출이 가능한 모든 타입"을 의미합니다. 즉:

모두 BinFtor 자리에 올 수 있습니다. 현대 C++20에서는 이를 std::invocable concept으로 명시화합니다.

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

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

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

[학술주석] 현대의 관점에서 여러 문제가 있습니다:

  1. srand((unsigned)time(NULL))는 시드 설정이 약합니다. 현대에는 std::random_device, std::mt19937 사용.
  2. static_cast<T>(rand())는 타입 안전하지 않습니다. T가 포인터라면?
  3. operator()(void)에서 상태를 변경하지만 const가 아닙니다.

하지만 1998년의 표준 라이브러리는 <random>을 포함하지 않았습니다. C++11에 와서야 추가되었습니다.

Rand의 경우에는 member template을 쓸 수 없다는 점에 유의하자. Function template의 type은 return type에 의해서는 유추되지 않기 때문에 사용하려면 explicit qualification을 써야 하지만 이 경우는 적절하지 못하다. 그러므로 class template을 만드는 게 더욱 유리하다.

[학술주석] 이것은 C++ 템플릿 메타프로그래밍의 미묘한 점입니다. 멤버 함수 템플릿 vs. 클래스 템플릿의 선택은:

저자의 선택은 올바릅니다. 호출 지점에서 리턴 타입을 명시할 수 없으므로.

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

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);
}

[학술주석] C++11 이전 코드이므로 for 루프 초기화가 조금 다릅니다. for (Rand<T> r, size_t i = 0; ...)는 두 개의 선언을 하고 있습니다. 현대 C++에서는 이를 루프 전에 선언하거나 구조화된 바인딩을 사용합니다.

여기서 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();
}

[학술주석] 여기서 **코드 중복 제거(DRY - Don’t Repeat Yourself)**가 실제로 어떻게 작동하는지 보여줍니다. generate 함수는 두 곳의 벡터 초기화 로직의 공통 부분을 추출합니다. 이는 **리팩토링(Refactoring)**의 정석입니다.

현대 C++에서는 std::generate<algorithm>에 있습니다 (C++98부터).

그리고 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;
    }
};

[학술주석] 여기서 **상태를 가진 함수 객체(Stateful Functor)**가 등장합니다. _init_k를 멤버로 가지므로, 각 호출마다 상태가 변합니다. 이는 함수형 프로그래밍의 순수 함수 개념과는 다릅니다. 현대에는 std::iota, std::ranges::iota_view 등으로 이를 더 우아하게 처리합니다.

특히 주목할 점: explicit 키워드는 단일 인자 생성자가 암묵적으로 변환되는 것을 방지합니다. 1998년에도 이러한 세밀한 제어가 중요했습니다.

그러면 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의 필요와 개발에 의해서다. 즉 일반화와 추상화는 한번에 드러나지 않는 경우도 많으며 단계를 밟아가며 주의 깊게 전체 프로그램의 구조를 지켜볼 필요가 있다. 그리고 항시 모듈화 더 나아가서 추상화와 일반화를 항시 염두에 두지 않으면 재사용성이 높은 프로그램 구조란 요원한 목표일 뿐인 것이다.

[학술주석] 이 단락은 메타인지적(metacognitive) 관찰입니다. 저자는 단순히 “이렇게 하면 된다"가 아니라 “이 구조를 발견하는 과정"을 설명합니다. 이는 **설계 철학(Design Philosophy)**을 전달하는 교육법입니다.

2024년 비평: 함수형 프로그래밍의 선구 #

이 섹션이 보여주는 것은 함수형 프로그래밍의 핵심입니다. 1998년 당시 C++는 “객체지향 언어"로만 알려져 있었으나, 이 글은 함수형 개념(고차 함수, 함수 객체, 불변성)을 명시적으로 소개합니다.

현대적 평가:


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

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

[학술주석] 이것은 개념을 설명하는 메타포(Metaphor) 입니다. 컨베이어 벨트를 Iterator에, 로봇을 Algorithm에 대응시킵니다. 이러한 설명 기법은 복잡한 컴퓨터 개념을 산업 현장의 물리적 과정으로 치환하여 이해를 높입니다.

흥미롭게도, 저자는 “다품종 소량생산"이라는 용어를 쓰고 있습니다. 이는 1990년대 후반 일본의 린 제조(Lean Manufacturing) 이론이 영향을 미친 것으로 보입니다. 즉, 제네릭 프로그래밍은 소프트웨어 버전의 린 제조라는 은유입니다.

난데 없이 공장이야기를 늘어놓아서 의아해 할 수도 있겠지만 설명한 바와 같은 대량생산/대량처리/다품종 소량생산 등의 처리방식이 바로 앞서 우리가 개발했던 accumulate의 구조적 결함의 개선책이다. 또한 지금부터 설명하는 프로그래밍의 구조가 바로 Standard Template Library의 명성을 자자하게 한 결정적인 이유이다. 필자가 이렇게 공정과정이나 야구장을 예로 들어가며 설명하는 이유는 STL에 대한 몇 번의 강의 경험에서 얻게 된 설명 방법이다. 처음에 필자는 STL을 정말 있는 그대로 설명하려고 애썼다. STL은 다음 다음과 같은 헤더파일로 구성되어 있고 컨테이너에는 뭐가 있고 Functor에는 무엇이 있다고 열심히 서너 시간 열거하고 나면 배워야겠다는 생각을 전파하는 데는 성공하였을 지 모르지만 정작 핵심적인 구조를 이해하여 이후의 프로그래밍 작업에 적극 활용하는 이는 별로 찾아볼 수가 없었다. 물론 배우는 이의 자세에도 문제가 있겠지만 가르치는 방식도 커다란 문제라는 점을 알게 되었고 가장 중요한 것은 STL이 어디 외계에서 온 새로운 이론적 체계에 의해 만들어진 라이브러리가 아니라는 점을 지적하여 듣는 사람으로 하여금 거부감을 없애는 데 있다.

[학술주석] 이것은 교육학적 반성(Educational Reflection)입니다. 저자는 처음에 “기능 중심 설명(Feature-centric)“으로 가르쳤으나, 효과가 없었음을 깨닫고 “구조 중심 설명(Architecture-centric)“으로 전환했다고 합니다. 이는 학습 심리학의 구성주의(Constructivism) 관점과 일치합니다.

“STL이 외계 이론이 아니라는 점"이라는 표현은, 당시 STL이 얼마나 혁신적이고 이해하기 어렵다고 여겨졌는지를 시사합니다. 현대의 개발자들도 새로운 개념(async/await, monads, algebraic effects)에 대해 같은 저항감을 가집니다.

다시 밝히지만 STL의 구조는 필자가 설명한 공장의 구조와 완전히 동일하다. 그러므로 STL을 사용하여 프로그램 한다는 것은 공단을 설계하는 것이다. 이 공장은 무슨 작업을 하므로 어떤 종류의 로봇과 프로그램이 필요하다 (Functor). 작업의 흐름은 이렇게 이어져야 한다 (Work flow). 어떤 컨테이너를 사용하여 운반하고 수송하고 저장하는 것이 가장 합리적인가 - 배열이냐 linked list냐? 컨베이어 벨트는 몇 개나 있으면 되고 작업의 흐름을 위해 어떻게 다른 벨트와 연결되어야 하는 가? 등을 결정하는 것이 바로 STL을 사용하는 아니 전체 C++ 표준 라이브러리를 사용하여 프로그램 하는 방법이다. 이와 같은 방식으로 객체지향적 프로그래밍이라 기보다는 이론적인 용도로만 주로 사용되어오던 고차 함수형 프로그래밍 언어에서 사용되어 오던 것과 거의 동일한 즉 포괄적 프로그래밍 기법이다. 이와 같은 프로그래밍 기법의 안전성과 유용성은 이미 입증되었지만 이전에는 이를 효율적으로 처리하기가 힘들었을 뿐이다. C++가 template을 지원하고 이를 극단적으로 활용한 STL이 설계되어 일반과 효율성이라는 상충하는 두 마리 토끼를 모두 잡아버린 셈이다.

[학술주석] 이 단락은 패러다임의 전환을 선언합니다. “객체지향 프로그래밍"에서 “제네릭 프로그래밍"으로의 이동입니다. 특히 “고차 함수형 프로그래밍 언어"라는 참조는 Lisp, Scheme, Haskell 등을 의미합니다. 1998년 당시 이러한 선지적 통찰은 매우 드물었습니다.

“일반과 효율성이라는 상충하는 두 마리 토끼를 모두 잡아버린 셈"이라는 표현은 STL의 본질을 정확히 캡처합니다. 이는:

이 두 가치의 조화입니다.

2024년 비평: 메타포의 한계와 강점 #

이 공장 메타포는 훌륭하지만 한계도 있습니다:

강점:

  1. 직관성: 비전공자도 이해 가능
  2. 구조적 통찰: 왜 컨테이너, 알고리즘, 반복자가 필요한지 명확
  3. 설계 고려사항 제시: “어떤 컨테이너를 선택할 것인가"라는 실질적 질문 제기

한계:

  1. 선형성 가정: 실제 알고리즘은 훨씬 복잡 (재귀, 백트래킹, 병렬화 등)
  2. 부작용 무시: 공장은 부작용(상태 변화)을 다루지만, 함수형 STL 알고리즘은 순수성을 가정
  3. 실시간성: 공장 메타포는 배치 처리를 암시하지만, 스트리밍, 이벤트 기반 처리는 설명 불가

그럼에도 25년이 지나도 이 메타포는 여전히 유효합니다. 현대의 ReactiveX, RxJS, Akka streams 등도 같은 철학을 공유합니다.


실제의 STL #

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

[학술주석] 용어의 정확성에 대한 강조입니다. “STL"은 역사적 명칭이며, 현대 표준에서는 “Standard Library"의 일부입니다. Alexander Stepanov의 기여를 명시적으로 인정하는 것도 학문적 윤리를 보여줍니다.

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

[학술주석] MFC(Microsoft Foundation Classes), OWL(Object Windows Library), VCL(Visual Component Library)는 모두 1990년대의 GUI 라이브러리입니다. 이들과 STL을 비교하는 것은 흥미로운데, “STL이 더 배우기 쉽다"는 주장은:

이는 개념의 정확성과 일관성의 가치를 시사합니다. STL이 모듈 수에서는 많지만, 개념적으로는 더 명확하다는 뜻입니다.

2024년 비평: 사고방식의 변화 #

“프로그래밍의 사고 방식을 STL 방식으로 변화하기"라는 표현은 매우 중요합니다. 이는:

  1. 패러다임 전환: 동적 dispatch (OOP) → 정적 타입 분석 (Generic)
  2. 복잡성의 이동: 런타임 유연성 → 컴파일 타임 최적화
  3. 오류 처리: 예외 기반 → 타입 기반

현대에도 이 문제는 유효합니다:


결론: 1998년 메시지, 2024년 의미 #

이 기사의 핵심 메시지는 **“좋은 설계는 개념적 통일성에서 나온다”**는 것입니다.

1998년의 메시지:

2024년에 다시 읽으면:

이 기사가 오늘날 여전히 읽힐 가치가 있는 이유는, 기술 트렌드가 아닌 설계 원칙을 설명하기 때문입니다.


[이 주석은 계속될 예정입니다. 다음 부분은 두 번째 파일(1998년 9월, 10월)의 주석으로 진행할 예정입니다.]