koturnの日記

転職したい社会人2年生の技術系日記.ブログ上のコードはコピペ自由です.

C言語でダブルクオートで囲まれたカラムを含むCSVファイルのパースを行う

TL;DR

タイトル通り,C言語でダブルクオートで囲まれたカラムを含むCSVファイル(RFC 4180 2.6章,2.7章参照)のパースを実装した.

背景

2021年にもなってC言語を書かなければならないことがあり,その中でCSVファイルのパースを行う必要があった.

既存の実装を利用しようと,「C言語 CSV」でググってみたのだが,fgets()sscanf() を利用した貧弱なCSVのパースのみであり,実用に耐えないものばかりであった(「1行読み取って」の時点で改行を含むカラムを扱うことができないため,CSVのパースとしては失格である).

そのため,RFC 4180 2.6章,2.7章に従ったCSVを取り扱えるパースを実装した.

本題

とにかく実装は下記の通り. とりあえず動作が確認できるものとして,コマンドライン引数で指定されたCSVファイルをタブ区切りで出力するプログラムとしているが,キモは get_next_csv_token() である. この関数でCSVの1カラムが取得できるので,EOFに到達するまで繰り返し呼び出す.

実装1

#include <assert.h>
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
#include <string.h>


#if defined(NDEBUG)
#  ifdef _MSC_VER
#    define ASSUME(x)  __assume(x)
#  else
#    define ASSUME(x)
#  endif  // _MSC_VER
#elif defined(assert)
#  define ASSUME(x)  assert(x)
#else
#  define ASSUME(x)
#endif  // defined(NDEBUG)


//! CSVのカラム取得結果の列挙体
typedef enum
{
  //! 通常のカラム
  CSVTOKENTYPE_COLUMN = 0,
  //! 行末のカラム
  CSVTOKENTYPE_EOL_COLUMN = 1,
  //! 既にEOFに到達しており,読み取りを行ったときに返却される値
  CSVTOKENTYPE_NO_COLUMN = 3,
  //! バッファサイズに格納できないときに返却される値
  CSVTOKENTYPE_BUFSIZE_ERROR = -1
} csv_token_type_t;


//! カラムデータの読み取り中断時の状態を格納する構造体
typedef struct
{
  //! バッファサイズ溢れで格納出来なかった文字
  int prev_char;
  //! ダブルクオートで囲まれているかどうか
  bool is_quoted;
  //! 読み取り文字数
  size_t n_read;
} csv_parse_state_t;


static void show_usage(FILE *fp, const char *progname);
static int show_csv_parse_result(const char *filepath);
static csv_token_type_t get_next_csv_token(FILE* fp, char *dst_buf, size_t dst_buf_size, char delim, csv_parse_state_t *state);


/*!
 * @brief このプログラムのエントリポイント
 *
 * @param [in] argc コマンドライン引数の数
 * @param [in] argv コマンドライン引数の配列
 * @return 終了ステータス
 */
int
main(int argc, const char *argv[])
{
  if (argc < 2) {
    show_usage(stderr, argv[0]);
    return 64;  // EX_USAGE
  }

  for (int i = 1; i < argc; i++) {
    printf("==================== CSV File No.%d ====================\n", i);
    show_csv_parse_result(argv[i]);
  }

  return EXIT_SUCCESS;
}


/*!
 * @brief このプログラムの使用方法を表示する
 *
 * @param [in,out] fp 出力先ファイルストリーム
 * @param [in] progname プログラム名
 */
static void
show_usage(FILE *fp, const char *progname)
{
  fprintf(fp, "[Usage]\n");
  fprintf(fp, "  %s [CSV file]...\n", progname);
}


/*!
 * @brief CSVファイルのパース結果を表示する
 *
 * 行番号を付け,1カラムごとにタブ区切りでカラム内容を出力する
 *
 * @param [in] filepath 対象となるCSVファイル
 * @return 正常終了時は0,それ以外は非0
 */
static int
show_csv_parse_result(const char *filepath)
{
  FILE *fp = fopen(filepath, "r");
  if (fp == NULL) {
    perror("fopen");
    return errno;
  }

  size_t colbuf_size = 8192;
  char *colbuf = (char *)malloc(colbuf_size);
  if (colbuf == NULL) {
    fclose(fp);
    perror("malloc");
    return errno;
  }
  char *colbuf2;

  csv_token_type_t ctt;
  csv_parse_state_t state = {0};

  int linenr = 1;
  printf("Line %d: ", linenr);

  while ((ctt = get_next_csv_token(fp, &colbuf[state.n_read], colbuf_size, ',', &state)) != CSVTOKENTYPE_NO_COLUMN) {
    switch (ctt) {
      case CSVTOKENTYPE_COLUMN:
        printf("%s\t", colbuf);
        break;
      case CSVTOKENTYPE_EOL_COLUMN:
        printf("%s\nLine %d: ", colbuf, ++linenr);
        break;
      case CSVTOKENTYPE_NO_COLUMN:
        // 絶対にここは通らないがコンパイラの警告抑制のため
        ASSUME(0);
        break;
      case CSVTOKENTYPE_BUFSIZE_ERROR:
        colbuf_size *= 2;
        // オーバーフローのチェックはしない
        colbuf2 = (char *)realloc(colbuf, colbuf_size);
        if (colbuf2 == NULL) {
          free(colbuf);
          fclose(fp);
          perror("realloc");
          return errno;
        }
        colbuf = colbuf2;
        break;
      default:
        // 絶対にここは通らないがコンパイラの警告抑制のため
        ASSUME(0);
        break;
    }
  }
  putchar('\n');

  free(colbuf);
  fclose(fp);

  return 0;
}


/*!
 * @brief CSVファイルの次のカラムを得る
 *
 * @param [in,out] fp ファイルストリーム
 * @param [in,out] dst_buf カラムデータ格納先バッファ
 * @param [in]     dst_buf_size カラムデータ格納先バッファのサイズ
 * @param [in]     delim 区切り文字
 * @param [in]     state 読み取り状態
 * @retval CSVTOKENTYPE_COLUMN 通常のカラムの読み取り時
 * @retval CSVTOKENTYPE_EOL_COLUMN 行末のカラムの読み取り時
 * @retval CSVTOKENTYPE_NO_COLUMN fpが既にEOFに到達しているとき
 * @retval CSVTOKENTYPE_BUFSIZE_ERROR バッファに格納できないとき
 */
static csv_token_type_t
get_next_csv_token(FILE* fp, char *dst_buf, size_t dst_buf_size, char delim, csv_parse_state_t *state)
{
  int c = EOF;
  size_t pos = 0;
  csv_token_type_t ctt = CSVTOKENTYPE_EOL_COLUMN;

  // 初回なら読み取り
  if (state->prev_char == '\0') {
    c = fgetc(fp);
    if (c == EOF) {
      return CSVTOKENTYPE_NO_COLUMN;
    }
    if (c == '"') {
      state->is_quoted = true;
    }
  } else {
    // 前回バッファイサイズ溢れで格納できなかった文字を格納
    dst_buf[pos++] = (char)state->prev_char;
  }

  if (state->is_quoted) {
    // ダブルクオート部の読み取り
    while ((c = fgetc(fp)) != EOF) {
      if (c == '"' && (c = fgetc(fp)) != '"') {
        // ダブルクオート囲い終わりの場合
        state->is_quoted = false;
        break;
      }
      // ダブルクオート以外
      // または,ダブルクオートが連続する場合(ダブルクオートのエスケープ)
      if (pos + 1 >= dst_buf_size) {
        goto buffer_size_error;
      }
      dst_buf[pos++] = (char)c;
    }
  }

  // ダブルクオート外の読み取り
  for (c = (c == EOF ? fgetc(fp) : c); c != EOF; c = fgetc(fp)) {
    if (c == '\n') {
      ctt = CSVTOKENTYPE_EOL_COLUMN;
      break;
    } else if (c == delim) {
      ctt = CSVTOKENTYPE_COLUMN;
      break;
    } else {
      if (pos + 1 >= dst_buf_size) {
        goto buffer_size_error;
      }
      dst_buf[pos++] = (char)c;
    }
  }

  dst_buf[pos] = '\0';

  state->prev_char = '\0';
  state->is_quoted = false;
  state->n_read = 0;

  return ctt;

buffer_size_error:
  dst_buf[pos] = '\0';
  state->prev_char = c;
  state->n_read += pos;
  return CSVTOKENTYPE_BUFSIZE_ERROR;
}

get_next_csv_token() はバッファサイズの確認も行っており,バッファ溢れが起こる場合,出力先バッファに格納する文字を state に退避して,エラーを返す作りとしている. 呼び出し元で,返り値の確認をし,バッファ溢れがあった場合は格納先バッファを realloc() で2倍の容量で再確保するようにしている.

実装2(簡易実装)

バッファ溢れ時の処理を大雑把にし,もうちょっとシンプルな実装にするなら以下のようにしてもよいと思う. ただし,この実装はバッファ溢れの度にシーク位置を戻して,バッファを拡張後,再度読み直すようにしているので,バッファ溢れが頻繁に起こるならば非効率である. (show_csv_parse_result()get_next_csv_token() 以外は前のものと同じなので省略)

/*!
 * @brief CSVファイルのパース結果を表示する
 *
 * 行番号を付け,1カラムごとにタブ区切りでカラム内容を出力する
 *
 * @param [in] filepath 対象となるCSVファイル
 * @return 正常終了時は0,それ以外は非0
 */
static int
show_csv_parse_result(const char *filepath)
{
  FILE *fp = fopen(filepath, "r");
  if (fp == NULL) {
    perror("fopen");
    return errno;
  }

  size_t colbuf_size = 2;
  char *colbuf = (char *)malloc(colbuf_size);
  if (colbuf == NULL) {
    fclose(fp);
    perror("malloc");
    return errno;
  }
  char *colbuf2;

  csv_token_type_t ctt;

  int linenr = 1;
  printf("Line %d: ", linenr);

  while ((ctt = get_next_csv_token(fp, colbuf, colbuf_size, ',', CSVPARSEERRACT_REWIND_TO_HEAD)) != CSVTOKENTYPE_NO_COLUMN) {
    switch (ctt) {
      case CSVTOKENTYPE_COLUMN:
        printf("%s\t", colbuf);
        break;
      case CSVTOKENTYPE_EOL_COLUMN:
        printf("%s\nLine %d: ", colbuf, ++linenr);
        break;
      case CSVTOKENTYPE_NO_COLUMN:
        // 絶対にここは通らないがコンパイラの警告抑制のため
        ASSUME(0);
        break;
      case CSVTOKENTYPE_BUFSIZE_ERROR:
        colbuf_size *= 2;
        // オーバーフローのチェックはしない
        colbuf2 = (char *)realloc(colbuf, colbuf_size);
        if (colbuf2 == NULL) {
          free(colbuf);
          fclose(fp);
          perror("realloc");
          return errno;
        }
        colbuf = colbuf2;
        break;
      default:
        // 絶対にここは通らないがコンパイラの警告抑制のため
        ASSUME(0);
        break;
    }
  }
  putchar('\n');

  free(colbuf);
  fclose(fp);

  return 0;
}


/*!
 * @brief CSVファイルの次のカラムを得る
 *
 * @param [in,out] fp ファイルストリーム
 * @param [in,out] dst_buf カラムデータ格納先バッファ
 * @param [in]     dst_buf_size カラムデータ格納先バッファのサイズ
 * @param [in]     delim 区切り文字
 * @param [in]     erract バッファ溢れエラー時の動作
 * @retval CSVTOKENTYPE_COLUMN 通常のカラムの読み取り時
 * @retval CSVTOKENTYPE_EOL_COLUMN 行末のカラムの読み取り時
 * @retval CSVTOKENTYPE_NO_COLUMN fpが既にEOFに到達しているとき
 * @retval CSVTOKENTYPE_BUFSIZE_ERROR バッファに格納できないとき
 */
static csv_token_type_t
get_next_csv_token(FILE* fp, char *dst_buf, size_t dst_buf_size, char delim, csv_parse_erract_t erract)
{
  int c;
  size_t pos = 0;
  csv_token_type_t ctt = CSVTOKENTYPE_EOL_COLUMN;
  long spos = erract == CSVPARSEERRACT_REWIND_TO_HEAD ? ftell(fp) : 0;

  c = fgetc(fp);
  if (c == EOF) {
    return CSVTOKENTYPE_NO_COLUMN;
  }
  if (c == '"') {
    // ダブルクオート部の読み取り
    while ((c = fgetc(fp)) != EOF) {
      if (c == '"' && (c = fgetc(fp)) != '"') {
        // ダブルクオート囲い終わりの場合
        break;
      }
      // ダブルクオート以外
      // または、ダブルクオートが連続する場合(ダブルクオートのエスケープ)
      if (pos + 1 >= dst_buf_size) {
        goto buffer_size_error;
      }
      dst_buf[pos++] = (char)c;
    }
  }

  // ダブルクオート外の読み取り
  for (; c != EOF; c = fgetc(fp)) {
    if (c == '\n') {
      break;
    } else if (c == delim) {
      ctt = CSVTOKENTYPE_COLUMN;
      break;
    } else {
      if (pos + 1 >= dst_buf_size) {
        goto buffer_size_error;
      }
      dst_buf[pos++] = (char)c;
    }
  }

  dst_buf[pos] = '\0';
  return ctt;

buffer_size_error:
  if (erract == CSVPARSEERRACT_REWIND_TO_HEAD) {
    fseek(fp, spos, SEEK_SET);
  } else {
    ungetc(c, fp);
  }
  dst_buf[pos] = '\0';
  return CSVTOKENTYPE_BUFSIZE_ERROR;
}

まとめ

C言語RFC 4180の2.6章,2.7章に従ったCSVのパースを実装した. 実運用上ではバッファサイズの制限や,カンマ,改行を含むカラムがあるCSVファイルを扱うことが可能なように最初から考えておいた方がよいと思う.

(本当はちゃんと動作するライブラリを使用するのがよいと思う)

なお,RFC 4180 2.1章ではCSVの改行はCR+LFとなっているが,この記事では従っていない.

参考