January 6, 2009, Tuesday, 5

Article:InlineSpirit

From IdeA thinKING

Jump to: navigation, search

Contents

요약

boost::spirit 라이브러리의 사용 방법에 대해 간단히 알아봅니다. [1]

서론

간단한 script 언어나 네트웍 프로토콜, 혹은 파일 포맷을 text 기반으로 설계할 때 문제가 되는 부분중의 하나가 parsing을 어떻게 할것인가입니다. 이 parsing을 쉽게 하기 위해 포맷에 많은 제약 사항을 가하기도 합니다만 이는 사용자가 파일을 수정하기 어렵게 만드는 원인이 되기도 합니다.

그렇다고 제대로 된 문법을 사용하자니 parser를 만드는 작업이 만만치가 않습니다. 바닥부터 구현하기엔 손이 너무 많이 가고 lex, yacc와 같은 도구를 사용하기엔 사용 방법을 익히기가 쉽지 않으며 preprocessing 단계를 추가하는 작업이 환경에 따라 문제가 되곤 합니다.

이러한 모든 단점들을 상당 부분 해결한 것이 boost의 spirit 라이브러리입니다. 먼저 C++ template 문법을 사용하였기 때문에 preprocessing 없이 컴파일만으로 작업이 가능합니다. 또한 EBNF와 유사한 문법을 사용하므로 이에 대한 지식이 어느 정도 있다면 쉽게 parser를 구현할 수 있습니다.

본 강좌에서는 이 boost::spirit 라이브러리의 사용법에 대해 알아봅니다.

(본 강좌에서는 boost::spirit을 처음부터 끝까지 설명하진 않습니다. 보다 자세한 내용은 Spirit User's Guide 페이지를 참고하세요.)

Inline Spirit 사용법

먼저 아주 간단한 문법의 경우 코드 중간에 parser를 inline으로 삽입하여 parsing을 할 수 있습니다.

예제

이번 장에서는 다음과 같은 파일을 한 줄씩 읽어서 parsing을 해야 한다고 가정하고 이를 위해 inline spirit parser를 만들어 사용해 보도록 하겠습니다.

 foo = 1, 3, 5, 6;
 bar = 1024 , 475;

이 줄을 parsing하면 결과로 std::map<std::string, std::vector<int> > 객체에 다음과 같이 값이 들어가 있어야 합니다.

 "foo" -> 1, 3, 5, 6
 "bar" -> 1024, 475

먼저 파일에서 한 줄씩 읽어 parsing 함수로 넘겨주는 함수를 다음과 같이 만듭니다. 여기서는 test.txt라는 파일 이름을 하드코딩하여 사용합니다.

int main()
{
  using namespace std;
 
  const int MAXLINE = 1024;
 
  ifstream is;
  is.open("test.txt", ifstream::in);
 
  char buff[MAXLINE];
  for (int i = 1; is.good(); ++i) {
    is.getline(buff, sizeof(buff));
 
    if (!do_parse(buff)) {
      cout << "line: " << i << " - parse error. buff is \"" << buff << "\"" << endl;
    }
  }
 
  is.close();
}

그리고 다음과 같이 두 줄로 구성된 test.txt 파일을 만들어 놓습니다.

 foo = 1, 3, 5, 6;
 bar = 1024 , 475;

그럼 이제 do_parse() 함수를 구현해볼까요?

EBNF 문법을 spirit으로 변환하기

먼저 원하는 문법을 EBNF 문법으로 나타내보면 다음과 같습니다.

 digit  ::= [0-9]
 alpha  ::= [a-zA-Z]
 name   ::= alpha (alpha | digit)*
 number ::= digit+
 line   ::= name '=' number ( ',' number)* ';'

이 문법을 spirit에서 사용하는 C++ 코드로 나타내면 다음과 같습니다.

name   = alpha_p >> *(alnum_p);
line   = name >> '=' >> int_p >> *(ch_p(',') >> int_p) >> ';';

여기서 _p 가 붙은 것들은 spirit에서 기본적으로 제공하는 primitive 타입들입니다. [2]

간단히 변환과정을 설명하면 먼저 순서를 나타내기 위해 원래 EBNF 문법에서는 공백을 사용하나 C++에서는 공백 operator가 없기 때문에 >> 를 사용합니다. 즉,

 a b
 =>
 a >> b

로 변환됩니다.

다음으로 '::=' 는 그냥 '=' 으로 변환하고 '( )'나 '|'는 그대로 사용합니다.

마지막으로 kleene star '*' 이나 '+'는 EBNF에서는 postfix로 사용하던 것을 prefix로 변환합니다.

 a*
 =>
 *a
 a+
 =>
 +a

마지막으로 C++ 문법이기 때문에 라인의 끝에 ';'을 넣어주면 변환 완료입니다.

inline parsing

본 장에서는 이를 inline으로 구현해야 하므로 위의 EBNF와 같이 여러 rule들을 정의하여 사용할 수 없습니다. 따라서 위의 문법을 한줄로 표현하면 다음과 같습니다.

(alpha_p >> *(alnum_p)) >> '=' >> int_p >> *(ch_p(',') >> int_p) >> ';'

이 문법을 이용하여 do_parse 함수를 아래와 같이 구현합니다.

bool do_parse(const char* line)
{
  using namespace boost::spirit;
 
  parse_info<> info = parse(line,
			    (
			     (alpha_p >> *(alnum_p)) 
			     >> '=' 
			     >> int_p 
			     >> *(ch_p(',') >> int_p) 
			     >> ';'
			     ),
			    space_p
			    );
  return info.full;
}

여기서 return 값인 info.full은 parse 함수가 입력된 line의 끝까지 parsing을 성공적으로 했음을 나타냅니다.

parse 함수의 세번째 인자는 skip parameter로 line을 parsing하는 동안 skip할 문자를 나타냅니다. 여기서 사용된 space_p는 space, tab, return, newline 문자와 match되는 타입입니다. 따라서, line 중간에 space나 tab이 들어 있어도 이는 parsing하는데 영향을 주지 않습니다. 현재 사용된 문법은 space가 의미가 없으므로 space_p를 skip parameter로 사용하나 space나 newline 문자가 의미가 있는 문법에서는 space_p를 사용해서는 안됩니다.

actions

현재까지 구현한 내용으론 주어진 line을 parsing해서 성공했는지 실패했는지를 알려주기는 하나 실제로 parsing 결과로 얻어지는 것이 없습니다. 실제 parsing 결과를 처리하기 위해 사용하는 것이 action입니다.

Action을 사용하는 방법은 문법 뒤에 [] 괄호를 붙이고 이 괄호안에 action을 정의한 functor나 function pointer를 넣어주면 됩니다. 이러한 action functor를 actor라고 합니다.

예를 들어 위의 문법에 name 부분을 parsing하여 이를 std::string 타입에 넣는 action을 추가하고 싶다면 다음과 같이 하면 됩니다.

std::string name;
 
parse_info<> info = parse(line,
			   (
			    (alpha_p >> *(alnum_p))[assign_a(name)] 
			    >> '=' 
			    >> int_p 
			    >> *(ch_p(',') >> int_p) 
			    >> ';'
			   ),
			   space_p
			  );

여기서 name 부분은 (alpha_p >> *(alnum_p)) 부분이므로 전체를 감싼 괄호뒤에 action을 넣습니다. 여기서 assign_a 는 spirit에서 기본으로 제공하는 actor로 parsing결과를 특정 변수에 대입하는 작업을 합니다. [3] 물론 자신만의 actor도 쉽게 만들 수 있습니다. 이 사용자 정의 action에 대해서는 아래에서 다시 알아보겠습니다.

spirit에서 제공하는 또 하나의 actor로 push_back_a 가 있으며 push_back을 지원하는 container에 parsing한 값을 넣어주는 작업을 합니다.

그럼 이 actor들을 사용하여 원하는 작업을 해봅시다.

bool do_parse(const char* line, std::map<std::string, std::vector<int> >& result)
{
  using namespace boost::spirit;
 
  std::string name;
  std::vector<int> array;
 
  parse_info<> info = parse(line,
			    (
			     (alpha_p >> *(alnum_p))[assign_a(name)] 
			     >> '=' 
			     >> int_p[push_back_a(array)] 
			     >> *(ch_p(',') >> int_p[push_back_a(array)]) 
			     >> ';'
			     ),
			    space_p
			    );
  if (info.full) {
    result[name] = array;
  }
  return info.full;
}

주의 사항

위의 코드에서는 assign_a와 push_back_a를 사용하여 local scope에 정의된 name과 array 변수에 값을 채운 다음 parsing이 성공적으로 되었을 경우에 result에 값을 넣어주고 있습니다.

혹시 위의 함수에서 array같은 temporary object의 생성을 줄이기 위해 다음과 같이 코딩을 하려고 생각할 수 있습니다.

bool do_parse(const char* line, std::map<std::string, std::vector<int> >& result)
{
  using namespace boost::spirit;
 
  std::string name;
 
  parse_info<> info = parse(line,
			    (
			     (alpha_p >> *(alnum_p))[assign_a(name)] 
			     >> '=' 
			     >> int_p[push_back_a(result[name])] 
			     >> *(ch_p(',') >> int_p[push_back_a(result[name])]) 
			     >> ';'
			     ),
			    space_p
			    );
  return info.full;
}

하지만 위와 코드는 동작하지 않습니다.이유는 parsing이 시작될 때 result[name]은 이미 name이 ""인 값으로 생성되어 push_back_a안에 reference로 저장되었기 때문에 assign_a에서 name에 값이 대입되더라도 push_back_a에서 사용되는 map의 키로는 사용되지 않기 때문입니다.

사용자 정의 action

그럼 위의 문제를 해결하기 위해 사용자 정의 action을 사용해 봅시다. 먼저 여기서 사용할 사용자 정의 action은 name을 가지는 std::string을 reference로 가지고 있어야 합니다. 왜냐하면 처음 action이 생성된 이후에 name이 변경된 경우, 이 변경된 name을 사용해야 하기 때문입니다. 그리고 두번째로 결과로 사용될 std::map 객체 역시 reference로 가지고 있어야 합니다.

이런 사항을 염두에 두고 사용자 정의 action을 만들면 다음과 같습니다.

struct map_push_back_a
{
  typedef std::string name_type;
  typedef std::vector<int> data_type;
  typedef std::map<name_type, data_type> cont_type;
 
  map_push_back_a(name_type& name, cont_type& cont) : name_(name), cont_(cont) {
  }
 
  void operator()(int val) const {
    cont_[name_].push_back(val);
  }
 
  name_type& name_;
  cont_type& cont_;
};

template parameter를 사용하여 좀 더 범용적으로 만들 수 있겠으나 여기서는 std::map<std::string, std::vector<int> >의 경우에만 사용되는 action으로 만들었습니다.

각 action을 만들때는 특정 규칙이 어떤 operator()를 호출하는지를 알아야 합니다. 여기서는 int_p 규칙이 사용되는데 int_p의 action으로는 int형을 인자로 받는 operator() 가 사용됩니다.

위의 action을 써서 do_parse함수를 다시 작성하면 아래와 같습니다.

bool do_parse(const char* line, std::map<std::string, std::vector<int> >& result)
{
  using namespace boost::spirit;
 
  std::string name;
 
  parse_info<> info = parse(line,
			    (
			     (alpha_p >> *(alnum_p))[assign_a(name)]
			     >> '='
			     >> int_p[map_push_back_a(name, result)]
			     >> *(ch_p(',') >> int_p[map_push_back_a(name, result)])
			     >> ';'
			     ),
			    space_p
			    );
  return info.full;
}

마무리

마지막으로 main 함수에 다음과 같이 결과를 확인할 수 있는 코드를 넣어줍니다.

int main()
{
  ...
  for (std::map<std::string, std::vector<int> >::iterator i = result.begin(); i != result.end(); ++i) {
    cout << (*i).first << '=';
    copy((*i).second.begin(), (*i).second.end(), ostream_iterator<int>(cout, " "));
    cout << endl;
  }
}

컴파일 후 실행시키면 다음과 같은 결과가 나옵니다.

 bar=1024 475 
 foo=1 3 5 6

만약 test.txt 파일을 다음과 같이 수정한 후 실행시키면 결과는 다음과 같습니다.

 foo = 1, 3, 5, 6;
 1ar = 1024 , 475;
 line: 2 - parse error. buff is "1ar = 1024 , 475;"
 foo=1 3 5 6 

이 경우엔 name의 첫번째 글자는 alpha_p이어야 하는데 digit이 왔기 때문에 parsing 에러가 발생했습니다.


결론

본 강좌에서는 간단한 문법을 parsing할 경우에 사용할 수 있는 spirit의 inline usage에 대해 알아보았습니다. 다음 시간에는 복잡한 문법의 경우에 사용할 수 있는 rule-based usage에 대해 알아보겠습니다.