본문으로 바로가기

가변 길이 문자열 DString을 사용하려면

Tcl이나 Perl, Java, VisualBasic과 같은 고급 언어에서는 문자열을 서로 연결하여 얼마든지 길게 만들 수 있지만, C언어에서는 몇 바이트의 메모리를 확보하여 인접한 영역에 복사하는 작업을 모두 작성해야 합니다(본질적으로 C++에서도 마찬가지입니다). Tcl 라이브러리에서는 특히 리스트 형태의 문자열을 효율적으로 처리하기 위해 가변 길이의 문자열을 처리하는 API 함수군이 준비되어 있습니다. 그보다는 Tcl의 리스트를 다루기 위해 만들어진 함수가 편리해서 공개된 식으로 받아들일 수도 있을 것입니다. 이는 Tcl의 배열 변수를 다루기 위해 만들어진 다음에서 다루는 해시 테이블도 마찬가지입니다.

 

가변 길이 문자열(DString)은 Tcl_DString 타입의 변수로 표현되며, 함수의 인수로는 해당 포인터가 사용됩니다. DString은 사용하기 전에 Tcl_DStringInit으로 초기화하고, 사용이 끝나면 Tcl_DStringFree로 정리해야 합니다.

Tcl_DString ds;
Tcl_DString *dsp = &ds;
Tcl_DStringInit(dsp);
/* ... */
Tcl_DStringFree(dsp);

DString을 조작하는 API 함수군은 대부분 'DString 뒤에 새로운 문자열을 연결' 처리를 하는 것들이 대부분인데, 이는 DString이 리스트를 다루기 위해 설계되었을 것임을 짐작할 수 있습니다. 기본이 되는 것은 Tcl_DStringAppend이며, 나머지는 파생된 함수들입니다.

DString* dsp;
char*    str;
int      length;
Tcl_DStringAppend(dsp, str, length);
Tcl_DStringAppendElement(dsp, str);

length는 보통 strlen(str) 값을 지정하지만, -1을 지정하면 자동으로 그 값이 됩니다. Tcl_DStringAppendElement는 dsp에 문자열 str을 추가할 때, str에 공백이 포함된 경우 자동으로 앞뒤를 { }로 묶어 Tcl 목록으로 만들어 주는 파생 API 함수로, 많은 경우 이 함수가 편리할 겁니다.

Tcl_DStringStartSublist(dsp);
Tcl_DStringAppendElement(dsp, str1);
Tcl_DStringAppendElement(dsp, str2);
/* ... */
Tcl_DStringEndSublist(dsp);

Tcl_DStringStartSublist와 Tcl_DStringEndSublist는 'Tcl 리스트의 리스트' 나 'Tcl 리스트의 리스트의 리스트' 나...(이하 생략)를  만들 때 유용합니다. 위와 같이 먼저 Tcl_DStringStartSublist를 호출하고 마지막으로 Tcl_DStringEndSublist를 호출하면, 그 사이에 추가한 부분이 { }로 둘러싸인 리스트가 됩니다.

 

연결하고 싶은 만큼 연결했으면 최종 결과를 꺼내서 사용해야 합니다. DString의 내용을 가져오려면 매크로 Tcl_DStringValue를, 그 길이를 가져오려면 Tcl_DStringLength를 사용하면 됩니다. 또한 이렇게 만들어진 DString을 그대로 Tcl 명령의 결과로 반환하고 싶다면, Tcl_SetResult 대신 Tcl_DStringResult를 사용합니다.

Tcl_DStringResult(interp, dsp);
Tcl_DStringFree(dsp);
return TCL_OK;

다음 예제는 stringsfile이라는 새로운 Tcl 커맨드를 추가하는 소스입니다. 'stringsfile 파일명'이라고 하면, 파일 안에 있는 5개 이상의 연속된 인쇄 가능 문자를 목록으로 반환하는 명령어입니다. 즉, 리눅스 표준 프로그램 strings의 단순화된 버전입니다.

#include <stdio.h>
#include <ctype.h>
#include <sys/stat.h>
#include "tcl.h"
#define  MAXLEN 1024

static char ereason[1024];
#define RERROR \
{ Tcl_SetResult(interp,ereason,TCL_STATIC); return TCL_ERROR; }

static int getsize(char* filename)
{
  struct stat sbuf;
  if(stat(filename, & sbuf) < 0) return -1;
  return sbuf.st_size;
}

static int stringsfileHandleProc(ClientData clientData, Tcl_Interp* interp, int argc, char* argv[])
{
  FILE* fp;
  int   i, sz, len;
  char* p, * q, * filebuf;
  char  buf[MAXLEN];
  Tcl_DString ds, * dsp;
  Tcl_DString dsfilename;
  char* filename;

  dsp = & ds;

  if(argc == 1){
    sprintf(ereason, "too few arguments, usage: %s filename", argv[0]);
    RERROR;
  }
  filename = argv[1];
  if((sz = getsize(filename)) == -1){
    sprintf(ereason, "cannot stat file %s", filename); RERROR;
  }
  if((fp = fopen(filename, "rb")) == NULL){
    sprintf(ereason, "cannot open %s", filename); RERROR;
  }
  if((filebuf = (char* )Tcl_Alloc(sz)) == NULL){
    strcpy(ereason, "memory exhausted"); fclose(fp); RERROR;
  }
  if((len = fread(filebuf, 1, sz, fp)) != sz){
    sprintf(ereason, "problems occurred in reading from %s", filename);
    Tcl_Free(filebuf); fclose(fp); RERROR;
  }
  fclose(fp);
  Tcl_DStringInit(dsp);
  for(p=filebuf, i=0; i<len; ){
    if(isgraph(*p)){
      int clen;
      for(clen=0,q=buf; clen<MAXLEN&&isgraph;(*p); clen++) *q++ = *p++;
      *q = '\0'; p++; i+=clen+1;
      if(clen > 5) Tcl_DStringAppendElement(dsp, buf);
    }
    else{
      p++; i++;
    }
  }
  Tcl_Free(filebuf);
  Tcl_DStringResult(interp, dsp);
  Tcl_DStringFree(dsp);
  return TCL_OK;
}

DLLEXPORT int Stringsfile_Init(Tcl_Interp* interp)
{
  Tcl_CreateCommand(interp, "stringsfile", stringsfileHandleProc, NULL, NULL);
  return TCL_OK;
}

해시 테이블을 사용하려면

Tcl 언어의 배열 변수와 같이 '키'와 '값'을 쌍으로 저장하는 해시 테이블은 데이터베이스 스타일의 애플리케이션에서 많이 사용되는 데이터 구조인데, Tcl 함수를 사용하면 다양한 기능을 쉽게 사용할 수 있습니다. 아래 예제는 조금 길지만, 해시 관련 Tcl API 11개 중 9개가 사용되는 무식한(?) 예제이다.

대전   50%
서울   40%
경기도 30%

위와 같은 지역에 대한 강수확률을 텍스트 파일을 읽으면서 한 줄씩 '키 + 값'으로 해시 테이블에 저장합니다.

#include <stdio.h>
#include "tcl.h"

static void hash_test(char* filename){
  Tcl_HashTable* htable;
  Tcl_HashEntry* hentry;
  Tcl_HashSearch* hsearch;
  FILE* fp;
  char buf[BUFSIZE];

  if((fp = fopen(filename, "r")) == NULL){
    fprintf(stderr, "Ootto! cannot open file %s.\n", filename);
    return;
  }
  /*  해시 테이블을 초기화합니다. */
  htable =(Tcl_HashTable* ) Tcl_Alloc(sizeof(Tcl_HashTable));
  Tcl_InitHashTable(htable, TCL_STRING_KEYS);
  /* 파일을 읽으면서 (키, 값) 쌍을 저장합니다. */
  while(fgets(buf, BUFSIZE, fp) != NULL){
    char key[BUFSIZE];
    int newf;
#if 0
    /* 이것은 잘못된 방법(작동하지 않음) */
    char value[BUFSIZE];
#else
    /* value는 포인터로 참조되므로 반드시 한개에 한번씩 영역 확보 필요*/
    char* value;
    value = (char* ) Tcl_Alloc(BUFSIZE);
#endif
    if(sscanf(buf, "%s%s", key, value) == 2){
      hentry = Tcl_CreateHashEntry(htable, key, &newf;);
      fprintf(stdout, "new entry: %s %s\n", key, value);
      Tcl_SetHashValue(hentry, value);
    }
  }
  fclose(fp);
  /* 해시 정보 덤프 */
  {
    char* p;
    p = Tcl_HashStats(htable);
    fprintf(stdout, "***%s\n", p);
    free(p);
  }
  /* 저장된 항목을 순차적으로 읽기 */
  hsearch = (Tcl_HashSearch* ) Tcl_Alloc(sizeof(Tcl_HashSearch));
  hentry = Tcl_FirstHashEntry(htable, hsearch);
  do {
    char* keyp, * valuep;
    keyp   = Tcl_GetHashKey(htable, hentry);
    valuep = (char* )Tcl_GetHashValue(hentry);
    fprintf(stdout, "key[%s] value[%s]\n", keyp, valuep);
    hentry = Tcl_NextHashEntry(hsearch);
  } while(hentry != NULL);

  /* 해제 */
  Tcl_DeleteHashTable(htable);
  Tcl_Free((char* )hsearch);
  Tcl_Free((char* )htable);
}

int main(int argc, char* argv[])
{
  if(argc == 1){
    fprintf(stderr, "usage:%s filename\n", argv[0]); return 1;
  }
  hash_test(argv[1]);
  return 0;
}

해시 처리를 위한 구조체 타입은 3가지가 있습니다. Tcl_HashTable은 해시 테이블을 개별적으로 식별하고 표현하기 위한 구조체이고, Tcl_HashEntry는 해시 테이블에 저장된 개별 '키'와 '값' 쌍(엔트리)을 표현하는 구조체이며, Tcl_HashSearch는 해시 테이블에서 엔트리를 하나씩 꺼내어 처리할 때, 현재 어디까지 탐색했는지와 같은 정보를 유지하기 위한 구조체입니다.

 

해시 테이블도 DString과 마찬가지로 '준비'와 '정리'가 필요합니다. 이는 각각 이름 그대로 Tcl_InitHashTable과 Tcl_DeleteHashTable로 수행합니다.

Tcl_HashTable* htable;
htable =(Tcl_HashTable* ) Tcl_Alloc(sizeof(Tcl_HashTable));
Tcl_InitHashTable(htable, TCL_STRING_KEYS);
/* ... */
Tcl_DeleteHashTable(htable);
Tcl_Free(htable);

Tcl_InitHashTable의 두 번째 인수는 기호 상수로 지정하는데, 자세한 내용은 매뉴얼을 참고하시기 바랍니다. 대부분의 경우 TCL_STRING_KEYS로 충분할 것입니다.

 

해시 테이블이 초기화되면 실제로 엔트리를 추가할 수 있지만, 엔트리 값은 Tcl_Alloc을 사용하여 각각 별도의 영역을 확보해야 합니다.

Tcl_HashEntry* hentry = Tcl_CreateHashEntry(htable, key, &newf);
Tcl_SetHashValue(hentry, value);

엔트리 추가는 먼저 키를 지정하고, 해시 테이블에서 해당 키의 위치를 (아직 없다면 새로 만들어서) 받고, 해당 영역에 값을 설정하는 2단계 작업으로 이루어집니다. 전자가 Tcl_CreateHashEntry, 후자가 Tcl_SetHashValue의 역할이며, newf에는 기존에 해당 키가 존재하지 않아 새로 만든 경우에는 1, 이미 해당 키가 테이블 내에 존재하는 경우에는 0이 설정됩니다. Tcl_CreateHashEntry는 지정한 키가 테이블에 없는 경우에는 강제로 만들지만, 없으면 NULL을 반환하고 있을 때만 해당 엔트리를 반환하도록 하려면 Tcl_FindHashEntry를 대신 사용할 수 있습니다.

 

Tcl_HashStats는 사용해 보면 알겠지만 거의 디버깅 용도로만 유용하며, 실제로는 거의 사용하지 않을 겁니다.

 

해시 테이블에서 모든 엔트리를 하나씩 순서대로 가져오는 것은 Tcl_FirstHashEntry와 Tcl_NextHashEntry를 사용합니다. 어떤 항목이 몇 번째로 나올지 예측할 수 없으므로 이에 의존하지 않는 처리를 해야 합니다. 이들 API는 Tcl_HashEntry 타입의 포인터를 반환하므로, 그중에서 키와 값을 가져오려면 이름 그대로 Tcl_GetHashKey와 Tcl_GetHashValue API를 사용하면 됩니다. 후자의 반환 값의 타입은 'ClientData'(void* 같은 것)이므로, 필요에 따라 캐스트(강제 타입 변환)하여 사용해야 합니다.

hsearch = (Tcl_HashSearch* ) Tcl_Alloc(sizeof(Tcl_HashSearch));
hentry = Tcl_FirstHashEntry(htable, hsearch);
do {
  char* keyp, * valuep;
  keyp   = Tcl_GetHashKey(htable, hentry);
  valuep = (char* )Tcl_GetHashValue(hentry);
  fprintf(stdout, "key[%s] value[%s]\n", keyp, valuep);
  hentry = Tcl_NextHashEntry(hsearch);
} while(hentry != NULL);

 

그리고 Tcl_DeleteHashEntry는 해시 테이블에서 엔트리를 삭제합니다.

정규 표현식 매치 루틴을 사용하려면

C 프로그램을 작성할 때에도 정규 표현식을 사용할 수 있으면 좋겠다고 생각할 때가 많지만, 정규 표현식도 일종의 언어이기 때문에 루틴을 일일이 손으로 작성할 수는 없습니다. 아래 프로그램은 Tcl의 정규표현식 API를 사용한 리눅스의 grep 명령어처럼 사용할 수 있는 툴입니다.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "tcl.h"

#define USE_LOWER_FUNCTIONS
#define BUFSIZE 4096

static void grepFile(Tcl_Interp* interp, char* regexpString, char* filename)
{
  FILE* fp;
  char buf[BUFSIZE];
  int lineno = 1;

  if((fp = fopen(filename, "r")) == NULL){
    fprintf(stderr, "Error: cannot open %s.\n", filename);
    return;
  }

  while(fgets(buf, BUFSIZE, fp) != NULL){
    buf[strlen(buf)-1] = '\0';
#ifdef USE_LOWER_FUNCTIONS /* 더 자세한 매치 */
    {
      char* p;
      char* startp, * endp;
      int found = 1;
      Tcl_RegExp regexp = Tcl_RegExpCompile(interp, regexpString);
      for(p=buf; found; ){
        switch(Tcl_RegExpExec(interp, regexp, p, buf)){
        case -1:
          fprintf(stderr, "Error: %s\n", interp->result);
          fclose(fp);
          return;
        case 1:
          {
            int i;
            for(i=0; i<10; i++){ /* 횟수는 적당히 */
              char mbuf[BUFSIZE];
              char* q, *pp;
              Tcl_RegExpRange(regexp, i, & startp, & endp);
              if(startp == NULL || endp == NULL) break;
              for(q=mbuf,pp=startp; pp<endp; *q++=*pp++){ ; }
              *q = '\0';
              if(i == 0){
                fprintf(stdout, "[%s][%d] %s\n", filename, lineno, mbuf);
                p = endp;
              }
              else{
                fprintf(stdout, "  [%d][%d] %s\n", lineno, i, mbuf);
              }
            }
          }
          break;
        default:
          found = 0;
          break;
        }
      }
    }
#else /* 각 행이 일치하는지 여부만 알 수 있는 방법 */
    switch(Tcl_RegExpMatch(interp, buf, regexpString)){
    case -1:
      fprintf(stderr, "Error: illegal regexp: %s\n", regexpString);
      fclose(fp);
      return;
    case 1:
      fprintf(stdout, "[%s][%d] %s\n", filename, lineno, buf);
      break;
    default:
      break;
    }
#endif
    lineno++;
  }
  fclose(fp);
}

int main(int argc, char* argv[])
{
  char* regexpString = NULL;
  Tcl_Interp* interp;
  int i;
  int count = 0;

  if((interp = Tcl_CreateInterp()) == NULL){
    fprintf(stderr, "FATAL: initialization error.\n");
    return 1;
  }
  Tcl_FindExecutable(argv[0]);
  if(Tcl_Init(interp) != TCL_OK){
    fprintf(stderr, "FATAL: initialization error: %s\n",
    Tcl_GetStringResult(interp));
    return 1;
  }
  for(i=1; i< argc; i++){
    if(regexpString == NULL){
      regexpString = argv[i];
    }
    else{
      grepFile(interp, regexpString, argv[i]);
      count++;
    }
  }
  if(count == 0){
    fprintf(stderr, "Usage:%s regexp file...\n", argv[0]);
    return 0;
  }
  return 0;
}

 

우선 가장 간단한 Tcl_RegExpMatch부터 살펴봅니다. 어떤 문자열이 어떤 정규식과 일치하는 부분을 '포함하는지', '포함하지 않는지'만 알고 싶을 때는 이 API 하나로 충분합니다. 문자열(char* )과 정규식(char* )을 전달하여 일치하는 부분을 포함하면 1, 포함하지 않으면 0, 애초에 정규식이 문법 오류라면 -1을 반환합니다.

    switch(Tcl_RegExpMatch(interp, buf, regexpString)){
    case -1:
      fprintf(stderr, "Error: illegal regexp: %s\n", regexpString);
      return;
    case 1:
      /* 일치 */ break;
    default:
      /* 불일치 */ break;
    }

어떤 문자열이 정규 표현식에 일치하는 부분을 포함할 경우 그것이 몇 번째부터 몇 번째까지인지, 어떤 문자열에서 두 군데 이상이 일치할 경우 각각 몇 번째부터 몇 번째까지인지, 그리고 "( )"로 둘러싸인 부분을 포함하는 정규 표현식을 포함할 경우 어떤 "( )"가 어디에 일치했는지 알고 알고 싶다면, 3개의 API를 조합하여 사용해야 합니다.

    Tcl_RegExp regexp = Tcl_RegExpCompile(interp, regexpString);
    for(p=buf; found; ){
      switch(Tcl_RegExpExec(interp, regexp, p, buf)){
        case -1: /* 문법 오류 */ break;
        case  0: break;
        case 1:
          {
            int i;
            for(i=0; i<10; i++){ /* 횟수는 적당히 */
              Tcl_RegExpRange(regexp, i, &startp, &endp);
              if(startp == NULL || endp == NULL) break;
              /* ... */ p = endp;
            }
          }
          break;
      }
    }

먼저 Tcl_RegExpCompile을 통해 정규식을 Tcl_RegExp 타입의 변수로 변환합니다. 이것은 처리 속도를 빠르게 하기 위해 매번 필요한 처리를 한 번으로 끝내기 위한 것으로, 이 변수의 내용에 대해서는 신경 쓸 필요가 없습니다. 그리고 문자열에 두 군데 이상 일치하는 경우를 생각하여 루프 안에서 Tcl_RegExpExec으로 실제 매칭을 수행합니다. 이 API는 네 개의 인수를 받는데, 세 번째와 네 번째 인수를 어떻게 지정하느냐가 포인트입니다. 세 번째 인자로는 실제로 매칭할 문자열을 지정하고, 네 번째 인자로는 현재 매칭시켜 볼 문자열 전체를 전달합니다. 만약 세 번째와 네 번째가 다른 주소로 온 경우에는 정규 표현식의 '^'로 시작하는 것은 절대 일치하지 않습니다. 다만 일치하는 경우, Tcl_RegExpRange를 통해 몇 번째부터 몇 번째까지 일치하는지를 알 수 있다. 이 API도 4개의 인수를 취하며, 세 번째와 네 번째 인수는 각각 포인터의 포인터로 전달합니다. 두 번째 인수가 0인 경우에는 일치한 부분 전체, 1 이상인 경우에는 각각 "( )"로 둘러싸인 부분 정규식에 일치한 부분 문자열을 대상으로 일치한 부분의 첫 번째 문자의 주소와 일치한 부분의 마지막 문자의 다음 문자의 주소 가 각각 세 번째, 네 번째 인수에 저장됩니다. 만약 NULL로 반환되면 일련의 처리는 끝입니다.