TR1 간단하지만 긴 소개

컴퓨터 이야기/C++ 2010. 5. 25. 04:06

[이글의 최신 Update 문서는 항상 여기에서 확인할 수 있습니다]

(다음 글은 다음 원문의 후반부를 의미를 해치지 않는 선에서 약간 편집하면서 번역한 것입니다. 전반부를 번역한 글은 여기를 클릭하세요)

원문 보기: Dr. Dobb's | The Technical Report on C++ Library Extensions | 5/9/2005

TR1에 추가된 컨테이너

Alex Stepanov, Dave Musser, Meng Lee 세 사람에 의해 구현된 STL이 C++ 표준 라이브러리의 가장 혁신적인 부분입니다(자세한 건 Alexander Stepanov and Meng Lee, "The Standard Template Library," HP Technical Report HPL-95-11 (R.1), 1995 를 보세요). sortrandom_shuffle 과 같은 일반화된 STL 알고리즘들은 vector 와 deque 과 같은 STL 컨테이너에 저장된 객체, 기본 배열에 저장된 객체, 심지어는 나중에 정의될 만한 데이터 구조상의 객체에 적용할 수 있습니다. 왜냐하면 반복자(iterator)를 통해 그 객체틀에 접근할 수 있기만 하면 되니까요. 바꿔말하면, 새로 정의한 알고리즘이 반복자 인터페이스를 통해 객체에 접근하기만 하면 기정의된 STL 컨테이너뿐 아니라 앞으로 정의될 컨테이너에도 작동할 수 있게 됩니다. 게다가 함수 호출 형식으로 불러 쓸 수 있는 "함수 객체"를 통해 STL 알고리즘의 작동 방식을 바꿀 수 있도록 해주기도 합니다. 예를 들어, find_if-인자로 주어진 범위내에서 주어진 조건에 맞는 특정 요소를 찾는 알고리즘-는 조건을 "함수 객체"로 넘겨 받고, sort 는 비교 연산 자체를 "함수 객체"로 넘겨 받습니다.

STL 이 워낙 확장이 용이하도록 설계되었기 때문에 사람들이 얼마 지나지 않아 확장하기 시작했습니다. 컨테이너에 꼭 있었으면 하는 게 몇 가지 있었습니다. STL 은 개수 변경이 가능하고 순서화된 요소(variable-sized sequence of elements)를 위한 세 가지 컨테이너를 제공했습니다: vector, list, deque. 그리고, balanced tree에 기반한 집합 및 사전 클래스도 포함하고 있었죠: "소위 연관 컨테이너"인 set, map, multiset, multimap. 이걸 보고 나면 보통 한 가지—hash tables—가 빠졌다는 생각이 들기 마련입니다. Perl, Python, Java 와 같은 다른 언어들은 다 hash 기반 사전 클래스가 있는데, C++ 라고 없으란 법 있겠습니까 ?

이렇게 된 건 다 역사적인 것 때문이었습니다. STL에 hash table 을 추가하자고 1995년에 제안됐었습니다(자세한 건 Javier Barreiro, Robert Fraley, and David R. Musser, "Hash Tables for the Standard Template Library," X3J16/94-0218 and WG21/N0605, 1995 를 보세요). 그런데, 표준에 반영하기에는 너무 늦었던 것이죠. 각 벤더들은 나름대로 이 간극을 매꿔꿨습니다. 구현된 주요 라이브러리 대부분이 어떤 형태로든 hash table을 포함하고 있습니다. 예를 들자면, Microsoft Visual C++ 에 딸려오는 Dinkumware, GCC에 딸려오는 GNU libstdc++ 등이 있습니다. 비슷한 기능을 하는데 서로 호환되지 않는 구현들이 있다면 자연스럽게 표준화의 대상이 되기 마련이고, 결국 TR1에 이르러서 hash table이 표준에 포함된 것이죠.

TR1 hash table 은 특징적인 기능을 많이 가지고 있습니다만 간단한 예제에서는 표준 연관 컨테이너가 사용되는 것과 거의 차이점이 없습니다. 다음 코드를 보면 hash table 클래스 이름이 약간 이상하게 느껴질 수도 있을 것 같네요.

#include <tr1/unordered_map>
#include <iostream>
#include <string>

int main()
{
  using namespace std;
  using namespace std::tr1; 
  typedef unordered_map<string, unsigned long> Map;   // 여기
  Map colors;

  colors["black"] = 0xff0000ul;
  colors["red"] = 0xff0000ul;
  colors["green"] = 0x00ff00ul;
  colors["blue"] = 0x0000fful;
  colors["white"] = 0xfffffful;

  for (Map::iterator i = colors.begin();
       i != colors.end();
       ++i)
  cout << i->first << " -> " << i->second << endl;
}

다른 라이브러리에서는 보통 hash_map 이라고 이름을 지었는데, TR1에서는 왜 unordered_map 이라고 이름을 지었을까요 ? 안타깝게도, 이전 라이브러리에서 hash_map 이라는 이름을 이미 써버렸기 때문에 다른 이름을 쓸 수 밖에 없었던 거죠. 기존의 STL hash table 들이 서로 호환되질 않았기 때문에 TR1 hash table 이 기존 것들과 호환되게 만드는 게 불가능해서 기존과는 다른 인터페이스를 채용했는데, 그러다 보니 다른 이름을 쓰는 게 낫겠다는 결론에 도달하게 됐던 것입니다.

순서 없는 연관 컨테이너도 다른 STL 컨테이너와 마찬가지로 동일한(homogeneous) 요소들만 허용합니다. 다시 말하자면, std::vector<T> 안에 있는 요소던지std::trl::unordered_set<T> 에 있는 요소던지 다 같은 타입을 가져야 한다는 거죠. 표준에 다른 타입이 허용되는 컨테이너가 딱 하나있기는 합니다: std::pair<T, U> 는 서로 다른 타입일 수 있는 두 개의 객체를 포함하고 있습니다. pair 는 항상 두 객체를 하나로 묶어야할 경우에 유용합니다. 함수가 여러개의 값을 리턴해야할 때가 그런 경우입니다. 예를 들어, 연관 컨테이너인 set 과 map (unordered_set 과 unordered_map도 동일함)은 pair<iterator, bool> 를 리턴하는 insert 멤버 함수가 있습니다. 리턴값의 bool 부분은 새로운 요소가 진짜로 삽입이 됐는지 아니면 이전에 이미 같은 키를 가진 요소가 있었는지를 구분해줍니다. iterator 부분은 이전에 이미 존재하던 요소를 가리키거나 새로 삽입된 요소를 가리킵니다.

그런데, 꼭 두 개의 값을 묶을 때만 pair 를 쓸 수 있다는 건 너무 심한 제약이 아닐까요 ? 함수가 꼭 하나의 값만 리턴하라는 법이 없듯이 두개의 값만 리턴하라는 법도 없을테니까요. 수학에서 n-tuple 이라는 게 순서가 있는 n 개 값의 묶음이라는 건 기억하실 겁니다. pair는 n = 2 인 경우의 tuple 이라고 할 수 있을 겁니다. TR1은 이런 다른 타입이 가능한(heterogeneous) 컨테이너로 std::tr1::tuple 를 도입했습니다.

tuple 를 구현하는 건 상당히 고수준의 프로그래밍을 필요하지만, 사용하는 건 무척 간단합니다. pair 랑 비슷하게 템플릿 파라미터로 값들의 타입을 몇 개라도 제공하기만 하면 됩니다. (물론 파라미터 숫자에 한계가 있긴하지만 통상적으로 상당히 큰 값입니다) 예를 들어 다음과 같이 말입니다:

#include <tr1/tuple>
#include <string>

using namespace std;
using namespace std::tr1;
...
tuple<int, char, string> t = make_tuple(1, 'a', "xyz");


어떤 함수가 tuple 을 통해 여러 개의 값을 리턴한다면 pair 와 비슷하게 당연히 값을 한번에 하나씩 꺼내올 수 있기를 바랄 것입니다. 그럴 경우 다음과 같이 하시면 됩니다:

tuple<int, int, int> tmp = foo();
int x = get<0>(tmp);
int y = get<1>(tmp);
int z = get<2>(tmp);

pair first 와 second  멤버 변수를 통해 엑세스하는 것과는 약간 다르지만 아이디어는 기본적으로 비슷합니다. tuple 을 이용하면 약간 더 쉽게 할 수도 있습니다. 임시 변수를 사용하는 대신 그냥 다음과 같이 하시면 됩니다:

tie(x, y, z) = foo();

그러면, 복잡한 일은 라이브러리가 다 알아서 해주게 됩니다.

보통 함수가 여러개의 값을 리턴해야할 때 쓰는 방법이 여러개의 참조자 매개변수를 쓰거나, 리턴 타입으로 쓸 ad hoc 클래스를 정의하는 것입니다(div_t을 생각해 보세요). tuple 을 활용한다면 그럴 필요가 없어지겠죠.

Infrastructure: Smart Pointer 와 Wrapper

tuple 이 유용한 이유는 string 이 유용한 이유와 비슷합니다. 상당히 기본적인 기능이기 때문에 상위 수준의 라이브러리에서 인터페이스를 기술할 때 쓸 수 있다는 것입니다. 이런 기초적인 기능이 공통의 vocabulary로 사용돼서 독립적으로 작성된 부분들이 서로 communicate 할 수 있게 되는 것입니다. TR1은 이런 특성을 갖는 몇 가지 유틸리티를 추가했습니다.

자원의 라이프타임을 관리하는 일은 대부분 프로그램에서 발생하는 공통적인 문제입니다. 메모리를 할당하거나 네트워크 소켓을 열었다면, 언제 메모리를 해제하고, 언제 소켓을 닫아야 할까요 ? 이 문제에 대한 두 가지 해결책은 자동 변수를 사용하거나("resource acquisition is initialization," 또는 RAII) 또는 garbage collection을 이용하는 것입니다. 두 가지 방법이 유용하긴 하지만 모든 경우에 적용 가능한 건 아닙니다. RAII 는 자원의 라이프타임이 정적으로 정해질 수 있고, 프로그램의 lexical 구조에 맞아 떨어질 때-예를 들어, 한 함수의 scope 에서만 필요하다던지-, 쓸 수 있습니다. 반면 garbage collection 은 메모리 관리에는 적합하지만 다른 자원에는 필요 이상으로 복잡한 해결책일 것입니다. 이에 대한 다른 대안으로 참조 카운트 smart pointer를 들 수 있습니다.

참조 카운트 포인터의 기본 아이디어는 명확합니다. T* 타입의 raw 포인터를 사용하지 않고, 포인터 "처럼 보이는" wrapper 클래스를 사용하는 것입니다. 마치 보통 포인터처럼 '*' 연산자를 써서 포인터가 가리키는 객체에 접근하기 위해 dereference 할 수도 있고, '->' 연산자를 써서 객체 멤버에 접근할 수도 있습니다. 차이점이 있다면 이 wrapper 클래스는 몇 가지 기본 연산과 생성자, 소멸자, 할당 연산자를 나름대로 정의해서 특정 객체에 대해 얼마나 많은 소유자가 있는지에 대한 정보를 유지할 수 있다는 것입니다.

TR1 의 참조 카운트 포인터 클래스는 shared_ptr 입니다. 간단한 경우에 대해서는 auto_ptr  클래스처럼 작동합니다. new 연산자를 이용해 생성한 객체로 shared_ptr 를 초기화할 수 있고, 그 shared_ptr 를 가리키는 다른 shared_ptr 이 없다면 shared_ptr 이 현재 scope를 떠날 때 객체도 함께 소멸됩니다.

같은 객체를 가리키는 인스턴스가 여러개 있을 때 재밌는 일이 벌어지게 됩니다. 객체를 가리키는 마지막 shared_ptr  인스턴스가 사라질 때까지 객체는 소멸되지 않습니다. 생성자와 소멸자 호출을 로깅하는 클래스를 써서 이런식으로 작동하는 것을 쉽게 확인해 볼 수 있습니다. 다음 코드에서 보듯이 두 포인터 p1p2 는 모두 A 객체를 가리킵니다. 두 포인터 모두 현재 scope를 떠날 때 소멸될 것입니다. 그렇지만, shared_ptr  의 소멸자가  객체에 대한 소유자의 개수를 정확히 유지하고 있기 때문에 마지막  shared_ptr  이 소멸될 때에야 진짜 객체를 소멸시키게 됩니다.

#include <iostream>
#include <tr1/memory>

using namespace std;
using namespace std::tr1;

struct A {
  A() { cout << "Create" << endl; }
  A(const A&) { cout << "Copy" << endl; }
  ~A() { cout << "Destroy" << endl; } 
};

int main() {
  shared_ptr<A> p1(new A);
  shared_ptr<A> p2 = p1;
  assert (p1 != NULL && p2 != NULL && p1 == p2);
}

물론, 실제 예들은 이것 보다는 복잡할 것입니다. 전역 변수에 shared_ptr 을 할당할 수도 있으며, 함수 인자로 넘겨 주고, 리턴값으로 받을 수도 있으며, STL 컨테이너에 넣을 수도 있습니다. vector<shared_ptr<my_class> > 는 다형성 객체를 담을 수 있는 컨테이터를 구현할 수 있는 가장 손쉬운 방법입니다(자세한 것은 "Containers of Pointers," C/C++ Users Journal, 2001년 10월을 보세요).

TR1에서 shared_ptr 을 제공함으로써 다음 두 가지 이점을 얻을 수 있습니다.

  • tuple 및 string 과 마찬가지로 프로그램을 작성하기 위한 공통의 vocabulary 가 됩니다. 동적으로 할당된 메모리에 대한 포인터를 리턴하는 함수를 작성하고 싶다면 shared_ptr 을 리턴 타입으로 갖는 함수를 작성하면, 그 함수의 사용자도 shared_ptr 을 쓸 수 있다고 확신할 수 있게 되는 거죠. 나름대로 작성한 참조 카운트 클래스를 사용한다면 이렇게 확신할 수는 없을 것입니다.
  • 그리고, shared_ptr 은 잘 작동합니다. shared_ptr 은 겉보기와 다르게 그리 단순하지는 않습니다. 많은 사람들이 참조 카운트 smart pointer 를 작성하곤 했지만, 모든 corner case 를 제대로 처리하는 걸 만든 사람은 거의 없었습니다. 특히나 멀티쓰레딩 환경에서는 shared_ptr 같은 클래스는 잘못되기 쉽상입니다. 그러니 shared_ptr  같이 잘 테스트되고 사용된 경험도 풍부한 구현이 유용할 것입니다.

그렇다고 해서 참조 카운트 포인터가 모든 자원 관리 버그 문제를 해결해 주는 건 아닙니다. 여전히 어떤 프로그래밍 원칙이 필요합니다. 여전히 두 가지 문제점이 발생할 여지가 있습니다. 첫째로, 두 개의 객체가 서로를 가리킨다고 가정해 봅시다. If xy 를 가리키는  shared_ptr 을 포함하고 있고, yx 를 가리키는 shared_ptr 를 포함하고 있다면, 프로그램 내에서 x 또는 y 를 가리키고 소유자가 하나도 없더라도 어느 쪽의 참조 카운트도 0 이 되지 않게 됩니다. 이런 경우 cycle 이 형성되어 결국 메모리 누수가 일어나게 됩니다. 둘째로, 같은 객체에 대해 shared_ptr 과 일반 포인터를 함께 쓰는 경우입니다:

my_class* p1 = new my_class;
shared_ptr<my_class> p2(p1)
...

p1 이 마지막 shared_ptr (p2 이던 p2 의 복사본이던)보다 더 오래 존재하게 된다면 p1 은 유효하지 않은 포인터가 되어버립니다. 즉, 이미 소멸된 객체를 가리키게 된다는 것이죠. 이런 상황에서 p1 을 역참조하면 프로그램이 바로 비정상 종료하게 될 것입니다. 어떤 smart pointer 라이브러리는 smart pointer 내부의 raw 포인터를 접근하지 못하게 하여 이 문제를 해결하려고 시도하기도 하지만 shared_ptr 은 그렇지 않습니다. 일반 용도(general purpose)로 쓰는 smart pointer 클래스라면 당연히 내부의 raw 포인터를 보여줘야만 한다고 생각합니다. 그렇지 않으면 '->' 연산자가 작동할 수 없으니까요. 게다가 일부러 문법적인 장애를 두는 것은 정상적인 사용도 어렵게 만들뿐이니까요.

보통 사이클을 피하기 위해 참조 카운트 포인터와 raw 포인터를 섞어 쓰기 때문에 이 두 가지 형태의 잠재적인 버그 소스는 함께 따라 가는 경우가 많습니다. 두 개의 객체가 서로를 참조해야 하는 경우 사용할 수 있는 테크닉은 하나는 소유개념이 있는 포인터를 쓰고, 다른 하나는 소유개념이 없는 포인터를 쓰는 것입니다. shared_ptr 소유개념이 있는 포인터로 사용하고, 다른 포인터를 소유개념이 없는 걸로 사용하면 됩니다. 이런 테크닉은 소유개념이 없는 포인터로 raw 포인터를 사용하지 않는다면 완벽한 해결책이라고 할 수 있습니다. TR1 smart pointer 라이브러리는 이런 경우를 위한 더 나은 해결책-weak_ptr -을 제공합니다. weak_ptr 은 이미 shared_ptr  이 관리하고 있는 객체를 가리킬 수 있습니다. 마지막 shared_ptr  이 사라지더라도 객체가 소멸되는 것을 막아주지는 않지만, raw 포인터와는 달리 dangling reference가 되더라도 프로그램 비정상 종료를 일으키지 않고, 또한 다른 shared_ptr 과 소유권을 공유하는 shared_ptr 로 변환할 수도 있습니다. 다음 코드에서 shared_ptrweak_ptr 을 섞어 쓰는 예를 확인할 수 있습니다.

class my_node {
public:
  ...
private:
  weak_ptr<my_node> parent;
  shared_ptr<my_node> left_child;
  shared_ptr<my_node> right_child;
};

TR1 에는 smart pointer 말고도 몇 가지 다른 기본적인 기능을 포함하고 있습니다. 말하자면, 함수나 함수 객체 같은 것을 쉽게 해줄 수 있는 것들 말입니다. 그 중에 가장 유용한 게 function wapper 클래스 일 것 같습니다.

A 타입과 B 타입의 인자를 취하고 C 타입을 리턴하는 뭔가를 작성해야 한다고 칩시다. C++는  이런 걸 표현할 수 있는 다양한 방법을 제공합니다. 일반 함수로 작성할 수도 있고,

C f(A a, B b) { ... }

A 를 별도 클래스로 작성했다면 다음과 같이 멤버 함수로 작성할 수도 있을 것입니다.

class A {
...
C g(B b);
};

아니면 std::plus<T> 처럼 함수 객체로 작성할 수도 있습니다.

struct h {
C operator()(A, B) const {...}
};

물론 이런 방법들은 서로 문법에서나 의미론에서나 차이가 있습니다. 특히, 멤버 함수의 경우 다른 방식으로 호출이 됩니다. mem_fun  을 이용하면 문법적 차이는 극복할 수 있습니다.(또는 TR1의 mem_fn 이나 bind 를 이용하면 더 편하게 할 수 있습니다). 근데 피할 수 없는 게 한 가지 있습니다. 바로 type 입니다. A×B->C 라는 같은 처리를 하는 세 가지 다른 방법이 있는데, 언어 입장에서 보면 이 세 가지 방법은 모두 다른 타입입니다. 이런 다른 방법을 하나의 타입으로 표현할 수 있다면 유용할 것입니다.

그게 바로 TR1의 function wrapper 클래스가 하는 일입니다. function<C(A,B)> 이라는 타입은 A 와 B 를 취하고, C 를 리턴하는 어떤 함수라도 표현할 수 있습니다. 그게 일반 함수이던, 멤버 함수이던, 함수 객체이던 상관 없이 말입니다. function 를 구현하는 건 상당히 복잡하지만 그런 복잡함은 라이브러리에 모두 감춰져 있으니 사용자 입장에서는 신경 쓰지 않아도 됩니다. 그저 적당한 타입을 갖는 function 을 인스턴스화하고 의미가 있는 어떤 거라도 할당한 후, 일반 함수 호출하듯 쓰면 되는 것입니다(물론, 인자 개수에 제한이 있긴 하지만, 제한도 보통은 상당히 클 겁니다)

예를 들어, my_class 를 인자로 취하고 아무것도 리턴하지 않는 어떤 함수라도 function<void(my_class)> 를 통해 호출할 수 있습니다. 다음 코드를 참고하시기 바랍니다.

#include <tr1/functional>
#include <vector>
#include <iostream>

using namespace std;
using namespace std::tr1;

struct my_class
{
  void f() { cout << "my_class::f()" << endl; }
};

void g(my_class) {
  cout << "g(my_class)" << endl;
}

struct h {
  void operator()(my_class) const {
    cout << "h::operator()(my_class)" << endl;
  }
};

int main()
{
  typedef function<void(my_class)> F;
  vector<F> ops;
  ops.push_back(&my_class::f);
  ops.push_back(&g);
  ops.push_back(h());

  my_class tmp;
  for (vector<F>::iterator i = ops.begin();
       i != ops.end();
       ++i)
    (*i)(tmp);
}

function<void(my_class)> 객체를 vector 에 넣은 것은 function wrapper 클래스가 왜 유용한지를 보여주기 위해 일부러 그런 것입니다. 한 마디로 표현하자면, callback 입니다. 상위 라이브러리가 사용자가 넘겨준 callback 을 담고 있을 일관된 메커니즘을 갖게 된 것입니다. 이러한 메커니즘이 나중에 dynamism 과 loose coupling을 필요로 하는 새로운 라이브러리에 널리 사용될 거라고 예상됩니다.

Application 용: 정규식

다른 라이브러리 작성을 도와주는 저수준 컴포넌트도 중요하겠지만, 프로그래머의 문제를 바로 해결해 줄 수 있는 라이브러리 컴포넌트도 중요할 것입니다. 대부분의 프로그램은 텍스트를 처리해야 하는데, 텍스트 처리에 사용되는 고전적인 방법중에 하나가 정규식을 이용한 패턴 매칭입니다. 정규식은 컴파일러, 워드 프로세서, 설정 파일을 읽어야 하는 대부분의 프로그램에 이용되고 있습니다. Perl 에서는 정규식을 상당히 잘 지원하기 때문에 텍스트를 처리하는 스크립트를 작성하기가 무척 쉽습니다. 반대로 C++ 에서는 정규식을 잘 지원하지 않아서 C++ 의 약점이 되곤했죠. 다행이 TR1에서는 정규식을 지원하기 시작했습니다.

TR1 정규식은 상당히 풍부한 기능과 선택사항을 제공하지만 기본적 사용법은 아주 간단합니다. Python 이나 자바에서 정규식을 사용해 봤다면 친숙하게 느껴지실 겁니다. 먼저 매치하고 싶은 패턴-ECMA-262 의 표준 문법에 따라 표현해야 합니다. JavaScript 에서도 같은 문법을 사용하고 있습니다-을 표현하는 tr1::regex 객체를 생성한 후, 문자열에 대해 그 패턴을 매치하는 알고리즘 (regex_match, regex_search, regex_replace) 중 하나를 수행하면 됩니다. regex_match 는 문자열이 정규식 패턴에 의해 매치되는지 여부를 검사하는 반면, regex_search 는 문자열이 패턴에 매치되는 substring을 포함하고 있는지를 검사합니다.  regex_matchregex_search 공히 매치가 성공했는지를 나타내기 위해 bool 을 리턴합니다. 또한 match_results 를 사용해서 더 자세한 매치 결과를 알아낼 수도 있습니다.

TR1 정규식 라이브러리는 표준 IOStream 라이브러리 같이 템플릿 인자를 지원하긴 하지만 대부분 경우 템플릿 인자를 사용하지 않게 될 것입니다. 아마 basic_regex 보다는 regex-basic_regex<char> 의 줄임 표현입니다-를 사용하게 될 것이고, 이건 basic_istream 보다는 istream 을 사용하게 되는 현상과 비슷한니다. match_results 도 두 가지 specialization 이 있어서 match_results 를 직접 쓰진 않게 될 것 같습니다. 문자열(string)을 검색한다면 smatch 를 사용하면 되고, 문자 배열(array of char)을 검색한다면 cmatch 를 사용하면 됩니다.

다음 코드는 UNIX grep 핵심 부분을 작성하는 데 TR1 정규식을 사용하는 방법을 보여 주고 있습니다.

#include <regex>
#include <string>
#include <iostream>

using namespace std;
using namespace std::tr1;

bool
do_grep(const string& exp, istream& in, ostream& out)
{
  regex r(exp);
  bool found_any = false;
  string line;

  while (getline(in, line))
    if (regex_search(line, r)) {
    found_any = true;
    out << line;
  }

  return found_any;
}

do_grep
은 매치하는지 여부만 다루고 있고, substring 은 다루지 않고 있습니다. 정규식을 사용하는 주요 이유 중 하나가 복잡한 문자열을 개별 필드로 변환하기 위해서입니다. 다음 (가), (나) 코드는 미국식 또는 유럽식 날짜 표현을 서로 변환하기 위한 예제를 통해서 이런 경우에 대한 예를 든 것입니다.

(가)

const string datestring = "10/31/2004";
const regex r("(\\d+)/(\\d+)/(\\d+)");
smatch fields;

if (!regex_match(datestring, fields, r))
  throw runtime_error("not a valid date");

const string month = fields[1];
const string day = fields[2];
const string year = fields[3];

(나)

const string date = "10/31/2004";
const regex r("(\\d+)/(\\d+)/(\\d+)");
const string date2 = regex_replace(date, r, "$2/$1/$3");

regex_search 는 한 문자열 안에 하나 이상의 매치가 있다고 하더라도 항상 첫번째 매치만을 리턴합니다. 모든 매치를 다 찾고 싶다면 어떻게 해야할까요 ? 다음 코드와 같이 정규식 반복자를 사용해서 분해한 후에 vector 를 초기화하면 됩니다.

const string str =
"a few words on regular expressions";
const regex pat("[a-zA-Z]+");

sregex_token_iterator first(str.begin(),
str.end(), pat);
sregex_token_iterator last;

vector<string> words(first, last);

미래:

확실히 TR1 전체를 한 기사로 다루는 건 무리가 있네요. shared_ptrfunction 은 비교적 자세히 언급했지만, reference_wrapper, result_of, mem_fn, bind 는 중간에 잠깐 스치는 식으로만 언급했습니다. 그리고, tuple 과 순서 없는 연관 컨테이너(unordered_set, unordered_map)은 자세히 언급했지만 array<T, N> 에 대해서는 전혀 언급하지 못했습니다. 라이브러리 작성자들에게 유용한 타입 특성자(type traits), 난수 생성기, 특수한 수학 함수 등에 대해서는 다루지 못했습니다. Bessel 함수, hypergeometric 함수, Riemann z 함수 등에 관심이 있다면 한 번 직접 찾아보시길 바랍니다. 마지막으로 C99 호환성에 대해 다루진 않았지만, 예상하는 대로 잘 작동할 것입니다.

이 글을 쓰는 현재(2005년 5월) TR1 라이브러리를 완벽하게 구현한 제품은 없지만 작업 중인 것이 몇 가지 있긴 합니다:

  • Metrowerks CodeWarrior 9.0 은 TR1 을 부분적으로 구현하고 있습니다. function, shared_ptr, tuple 등이 지원됩니다.
  • smart pointer, 정규식, 난수 생성기 등 TR1의 많은 부분들이 Boost 에서 넘어왔습니다. Boost 는 상당히 많은 컴파일러와 플랫폼에서 무료로 이용 가능합니다.
  • Dinkumware 는 현재 TR1 전체를 구현 중에 있습니다. 현존하는 가장 좋은 sincos 구현에 필적할만한 정확도를 달성할 목표를 가지고 cyl_bessel_jriemann_zeta 를 구현 중에 있는 유일한 회사입니다.
  • GNU libstdc++ 는 TR1 을 열심히 구현 중에 있고, GCC 다음 릴리즈인 GCC 4.0에 TR1이 부분적으로 제공될 예정입니다. 어느 정도나 제공될지는 장담하기는 어렵습니다.

TR1 은 허풍으로 말하는 라이브러리가 아니라 진짜 쓸 수 있는 라이브러리입니다. 이 기사에 제시된 모든 코드는 실제 컴파일 해 본 것들입니다(물론, "..." 부분만 빼구요 ^^;). GNU libstdc++ 가 아직 실험적이고 완벽하진 않지만, 순서 없는 연관 컨테이너, tuple, functional, smart pointers 등은 테스트할 수 있었습니다. TR1 정규식은 libstdc++ 에서 아직 제대로 구현하지 않고 있기 때문에 대신 Boost.Regex를 사용했습니다. 헤더 파일과 네임스페이스만 바꿔서 말입니다.

표준화 위원회가 TR1 작업을 거의 마쳤기 때문에 다음 단계의 라이브러리 확장에 대해 생각할 때가 됐습니다. 다음에는 뭘 기대할 수 있을까요 ? 표준화 위원회가 아직 TR2 제안서에 대한 얘기도 시작하지 않아서 아직 말하기에는 너무 이른 감이 있습니다. 저는 사람들이 요청하는 확장 기능을 유지하고 있습니다(http://docs.google.com/View?docid=ajfb44js8vjx_bchdmtqvpnxv4 원문에는 http://lafstern.org/matt/wishlist.html 최근들어 writely 로 바꾸었나 봅니다). 물론, wish 리스트가 진짜 제안서로 나오기까지는 긴 여정이 필요하겠지요.

제가 진짜 원하는 건 실제적인 일-HTML 과 XML 파싱하기, GIF 와 JPEG 이미지 처리하기, 디렉토리 읽기, HTTP 처리하기 등-을 처리하는 라이브러리입니다. 문자열 공백문자 없애기, 문자열을 대문자로 바꾸기와 같은 작업도 간단히 할 수 있었으면 좋겠습니다. 라이브러리 작성자가 사용할 수 있는 일반적인 infrastructure 를 만들어 두었으니, 그걸 이용해서 매일 매일 사용할 수 있는 라이브러리를 만들 때가 아닐까요 ?

그렇지만 표준화 위원회는 표준화 작업을 수행하지 새로운 것을 만들어내지는 않는다는 것을 기억할 필요가 있습니다. 실제 필요한 일을 수행한다고 생각되는 라이브러리들만 TR2에 들어가게 될 것입니다. 여러분은 전체 "wish 리스트" 나 저의 제안이나, Boost 라이브러리에서 영감을 얻을 수도 있을 것입니다. 몇 년 전에 쓴 기사에서 밝인 바와 같이(자세한 것은 "And Now for Something Completely Different," C/C++ Users Journal, 2002년 1월을 보세요), 라이브러리 확장 제안서는 특정 문제점 영역이 왜 중요한지, 해결책은 어떤 식인지, 그 해결책이 기존의 것과 어떻게 연관되어 있는지, 라이브러리 전체에 어떤 영향을 미칠 것인지에 대해 설명해야 합니다. 그리 간단한 일은 아니지만 이제 그 때 당시 보다는 수월해졌습니다. 이제 라이브러리 확장 제안서를 어떤 식으로 작성해야 하는지 샘플이 있기 때문입니다. TR1 당시 수락된 라이브러리 확장 제안서 모음이 http://open-std.org/jtc1/sc22/wg21/docs/ library_technical_report.html 에 있으며, TR2 제안서의 모델로 활용될 예정입니다.

TR1이 완성되었으니 다음 단계를 생각할 때입니다. 다음 단계는 여러분에게 달려 있기도 합니다!

참고문헌

Austern, Matthew H. Generic Programming and the STL: Using and Extending the C++ Standard Template Library, Addison-Wesley, 1998.

Josuttis, Nicolai. The C++ Standard Library: A Tutorial and Reference, Addison-Wesley, 1999.

Langer, Angelika and Klaus Kreft. Standard C++ IOStreams and Locales: Advanced Programmer's Guide and Reference, Addison-Wesley, 2000.

Lischner, Ray. STL Pocket Reference, O'Reilly & Associates, 2003.

Meyers, Scott. Effective STL: 50 Specific Ways to Improve Your Use of the Standard Template Library, Addison-Wesley, 2001.

Musser, David R., Gilmer Derge, and Atul Saini. STL Tutorial and Reference Guide: C++ Programming with the Standard Template Library, Second Edition, Addison-Wesley, 2001.

Plauger, P.J., Alexander A. Stepanov, Meng Lee, and David R. Musser. The C++ Standard Template Library, Prentice Hall, 2000.

(번역하는 게 정말 쉽지 않네요. 전반부 번역한 후 빨리 후반부도 번역하려고 했는데, 결국 일주일이나 걸렸습니다. 매일 매일 짬짬히 번역했는데도 상당히 걸리네요. 그래도 번역이 나름 잘된 것 같아(제 기준으로) 마음은 뿌듯합니다.혹시 원문이랑 비교해 보시고 좀 이상하다 싶으면 좀 알려 주세요)

http://yesarang.tistory.com/56 출처

'컴퓨터 이야기 > C++' 카테고리의 다른 글

파일 다이얼로그  (0) 2010.07.17
Self documenting 합시다!!  (1) 2010.07.17
헤더 파일은 적당히 나눠야 한다  (0) 2010.05.25
전 처리기 pragma 키워드  (0) 2010.05.25
f()와 f(void)의 차이점과 활용  (0) 2010.05.25
:

헤더 파일은 적당히 나눠야 한다

컴퓨터 이야기/C++ 2010. 5. 25. 04:02

참 오랜만에 글을 올려 보네요. ^^; 이번 글도 오래전부터 생각은 하고 있었는데, 이제서야 맘을 먹고 쓰게 됐습니다. 언제나 이 게으름을 극복할 수 있을까요 ?

이번 글에서는 헤더 파일과 컴파일 시간간 관계에 대해서 얘기하려고 합니다. 실제 실험을 통해 설명하는 게 쉬울 것 같습니다.

C++에는 Standard Template Library 라는 게 있다는 건 모두 아시리라 생각합니다. STL의 헤더 파일을 보면 <vector>, <list>, <map>, <deque>, <stack> 등 각 필요한 기능별로 헤더 파일이 비교적 상세하게 나눠져 있는 걸 볼 수 있습니다. 만약 STL의 개발자가 이렇게 상세하게 나눠놓지 않고 <stl>이라는 하나의 헤더 파일만 include 하면 되도록 개발해 놓았다면 컴파일 시간에 어떤 영향을 미치게 될까요 ? 실험을 통해 어떤 영향이 있을지 살펴 보겠습니다.

다음 두 개의 소스를 비교해 보시기 바랍니다.

/// @file main.cpp
/// 지금처럼 STL 헤더 파일이 비교적 상세하게 나눠져 있는 경우
#include <vector>   /// 필요한 헤더 파일만 include 합니다.

using namespace std;

int main(void)
{
  vector<int> vi;

  return 0;
}

/// @file main_all.cpp
/// STL header 파일이 stl 하나로 합쳐져 있는 경우
#include <stl>    /// 전체 라이브러리 헤더 파일을 include 합니다.

using namespace std;

int main(void)
{
  vector<int> vi;

  return 0;
}

/// @file stl
/// STL 헤더 파일이 stl 하나로 합쳐진 경우를 흉내냄
#include <vector>
#include <list>
#include <map>
#include <set>
#include <deque>
#include <stack>
#include <functional>
#include <algorithm>

이 둘 간의 100번씩 연속해서 컴파일한 후 시간을 비교해 봤더니 다음과 같은 결과가 나오더군요.

main

real    0m23.919s
user    0m19.309s
sys     0m2.488s

main_all

real    0m32.496s
user    0m27.194s
sys     0m2.900s

참고로 제가 테스트한 환경은 다음과 같습니다.

Ubuntu 8.04
g++ 4.2.3
$ cat /proc/cpuinfo
processor    : 0
vendor_id    : GenuineIntel
cpu family   : 6
model        : 13
model name   : Intel(R) Pentium(R) M processor 2.00GHz
stepping     : 8
cpu MHz      : 798.000
cache size   : 2048 KB

위 결과에서 두 경우가 24:32로 상당한 컴파일 시간 차이가 있음을 알 수 있습니다. 헤더 파일을 하나로 합쳤을 때가 그렇지 않을 경우에 비해 거의 25% 정도 늘어나는 것을 확인할 수 있습니다.

제가 이전에 사내 어떤 공용 라이브러리 소스에서 모든 헤더 파일을 하나의 헤더 파일에서 include 해 놓고, 각 응용 프로그램은 그 헤더 파일만 include 하도록 해 놓은 것을 본 적이 있습니다. 이렇게 할 경우 위 실험 결과를 생각해 보건데, 전체 응용 프로그램을 컴파일하는데 얼마나 시간이 많이 걸릴지 예상할 수 있습니다.

많은 개발자들이 컴파일 시간에 대해서는 별로 신경쓰지 않지만, 프로젝트 규모가 커지다 보면 컴파일 시간이 기다리기 힘들 정도로 길어지는 경우가 많습니다. 그렇게 될 경우, 코딩-컴파일-디버깅 싸이클이 길어지게 되고, 전체 개발 일정에도 영향을 미칠 수 있습니다.

요즘은 빌드 기술도 많이 발전하여 distributed build니 parallel build니 컴파일 시간을 많이 단축시켜 주는 툴이 있기도 합니다. 프로젝트 규모가 워낙 크고 어느 정도 개발이 진행되었다면 이런 툴들을 활용하는 것이 당연하겠지만, 그보다는 먼저 개발하기 전에 소스 파일들 간의 dependency 가 너무 많이 걸려 있지는 않도록 주의깊게 설계하는 것도 중요합니다. 그런 dependency 를 줄일 수 있는 방법 중의 하나가 이 글에서 제안하고 있는 헤더 파일을 적당히 나누라는 규칙이 되겠습니다. 다음 경우를 생각해 보시기 바랍니다.

all.h가 a.h, b.h ~ z.h 까지 포함하고 있고, a.c 라는 소스는 b.h에 있는 함수를 사용하지만, all.h만을 include하도록 해 놓았다면 어떻게 될까요 ? a.c 의 dependency list 에는 all.h 뿐 아니라 a.h ~ z.h 도 모두 포함됩니다. 그렇다면, 그 중에 하나만 수정되더라도 a.c 는 재컴파일 될 것입니다. 더불어 all.h 를 include 했던 모든 소스 파일들이 컴파일 되겠지요.

물론 헤더 파일을 아주 상세하게 나눈다면, 심지어 함수 하나에 헤더 파일 하나 정도로 나눌 수도 있겠지요. 그렇지만 이렇게 할 경우, 너무 많은 헤더 파일들을 열어야 하니, 역시 개발자들이 불편하게 될 것입니다. 너무 많은 헤더 파일을 열게 되니, 역시 컴파일 시간에 좋지 않은 영향을 미칠 수도 있을 것이구요. 그렇다면 적당히 중용을 찾아야 할 것입니다.

프로젝트 규모가 커질 수록

헤더 파일은 적당히 나눠야 한다

라는 규칙을 명심하시기 바랍니다. 적당을 위한 기준은 개발편의성과 컴파일시간간의 tradeoff 임도 기억하시기 바랍니다.

참고로 실험을 위해 다음과 같이 Makefile 을 작성했습니다.

CXX = g++
CFLAGS = -Wall -c -I. -o
LDFLAGS = -lstdc++
SRCS = main.cpp main_all.cpp

main: main.o

main_all: main_all.o

%.o:%.cpp
        $(CXX) $(CFLAGS) $@ $<

depend:
        makedepend -- $(CFLAGS) -- $(SRCS)

clean:
        rm -f *.o main main_all core* *.bak

make 를 호출하기 위한 shell script 를 다음과 같이 작성하였고,

$ cat incltst
#!/bin/bash

usage()
{
  echo "Usage: incltst main|main_all"
}

if [[ $1 != "main" && $1 != "main_all" ]]
then
  usage
  exit 0
fi

for (( i=0 ; $i < 100 ; ++i )) ; do
  make $1
  make clean
done

실행 시간은 다음 명령을 수행하여 측정하였습니다.

$ time ./incltst main
$ time ./incltst main_all

다른 분들도 측정하실 수 있도록 소스 파일 첨부합니다.


여러분이 실험한 결과도 한 번 댓글로 알려 주시면 감사하겠습니다.

글 읽어 주셔서 감사합니다. 이 글의 출처를 다음과 같은 형식으로 알려주신다면 비상업적 용도로 얼마든지 퍼다 나르셔도 좋습니다. ^^;

원문출처: 헤더 파일은 적당히 나눠야 한다(김윤수의 이상계를 꿈꾸며)

'컴퓨터 이야기 > C++' 카테고리의 다른 글

파일 다이얼로그  (0) 2010.07.17
Self documenting 합시다!!  (1) 2010.07.17
TR1 간단하지만 긴 소개  (0) 2010.05.25
전 처리기 pragma 키워드  (0) 2010.05.25
f()와 f(void)의 차이점과 활용  (0) 2010.05.25
: