いづいづブログ

アジャイルコーチになりたい札幌在住SEです。アジャイル札幌スタッフ&ScrumFestSapporo実行委員。Like:パクチー/激辛/牡蠣/猫/初期仏教

wcコマンドのソースコードを読んでみた

f:id:izumii-19:20190603235249p:plain:w400


Rubyの学習の一貫でwcコマンドと同等のものをRubyで書くことにチャレンジしているんですが、なぜだか-wオプションを指定した時の挙動(単語数のカウント)が一致しないのです。

じゃあwcコマンドのCのソースコード読めばいいじゃんってなるんですけどね、Cに苦手意識があって読むのがおっくうでした。

ただこのままじゃ完成させられない気がしてきたので意を決して内容を理解することにしました。自分用に内容をまとめておきます。

そもそもwcコマンドってなに?という人はググったらたくさん良記事が出てくるのでそちらを参考にしてください。

前提

一口にwcコマンドといっても、シェルのコマンドにはGNU版とBSDFree版の2種類があります。 macOSBSD UNIXベースなので、macで動作するwcコマンドはBSDFree版になります。

ちなみにGNU版とBSDFree版は互換性があるわけではないので同じコマンドでも少し振る舞いが違ったりすることがあります。

今回読んだBSDFree版wcコマンドのソースコードはこちら。⇒ wc.c

以降の内容は、ソースコードを一部抜粋して載せているだけで全体は載っていないので、必要であればこのコードを並べながらブログを読み進めるとわかりやすいかもです。

関数の構成

関数はシンプルに3つ。

  • main() … メイン関数。
  • cnt() … 指定したオプションに応じて件数を算出し画面に表示する。
  • usage() … 使用方法を画面に出力する。*1

ソースコードを解析する

ここからは実際にソースコードを解析(というほどでもないけど)していきます。

オプションとフラグの関係

wcコマンドで受け取るオプションによってフラグ(doXXX)を立て、以降はそのフラグで処理を分岐しているところが多いです。

なのでオプションとフラグの関係を最初にまとめておきます。

フラグ/
オプション
dochar
バイト数
domulti
文字数
doline
改行数
doword
単語数
なし 1 0 1 1
-c 1 0 0 0
-m 0 1 0 0
-l 0 0 1 0
-w 0 0 0 1

main()

main()の中身を読んでいきます。

   while ((ch = getopt(argc, argv, "clmw")) != -1)
        switch((char)ch) {
        case 'l':
            doline = 1;
            break;
        case 'w':
            doword = 1;
            break;
        case 'c':
            dochar = 1;
            domulti = 0;
            break;
        case 'm':
            domulti = 1;
            dochar = 0;
            break;
        case '?':
        default:
            usage();
        }
    argv += optind;
    argc -= optind;

まずはgetopt()を使ってコマンドライン引数のオプション(ハイフン'-'で始まる文字)を解析し、オプションごとにフラグを立てます。

getopt()では指定値(上のコードでは"clmw")以外の値が入ってきた場合「?」を返すので、「?」の場合はusage()をコールし使用方法を表示しておしまい。



   errors = 0;
    total = 0;
    if (!*argv) {
        if (cnt((char *)NULL) != 0)
            ++errors;
        else
            (void)printf("\n");
    }
    else do {
        if (cnt(*argv) != 0)
            ++errors;
        else
            (void)printf(" %s\n", *argv);
        ++total;
    } while(*++argv);

while()を抜けたあとのargv[]にはファイル名が残っているはずなので、そのファイル名を引数にしてcnt()を実行。

ちなみにファイル名が存在しない場合(if (!*argv) { ~側の処理)でもエラーと判断せずに、引数にNullを指定してcnt()を実行しています。

これは「ファイル名が存在しない場合は、標準入力からの入力データをインプットとして処理を実行する」ためです。(ちなみに、引数にファイル名が渡ってこない、かつ標準入力からのインプットもない場合はcnt()内でエラー処理をしています)。

cnt()は、引数に指定されているファイル数分実行され、そのたびにtotalがインクリメントされていきます。



   if (total > 1) {
        if (doline)
            (void)printf(" %7ju", tlinect);
        if (doword)
            (void)printf(" %7ju", twordct);
        if (dochar || domulti)
            (void)printf(" %7ju", tcharct);
        (void)printf(" total\n");
    }

total > 1であれば最後にtotal行を表示します。

つまりmain()の処理はなんてことはなくて、(1)オプションを解析し、(2)ファイルの数分だけcnt()をコールしているだけ。

実際に件数を算出する処理はcnt()でやっているようなのでこのあとはcnt()の中身を理解していきます。

cnt()

cnt()はファイル名を引数として該当するオプションに応じた件数を算出し、正常終了(=0)かエラー終了(=1)かを返却する関数です。

   if (file == NULL) {
        file = "stdin";
        fd = STDIN_FILENO;
    } else {
        if ((fd = open(file, O_RDONLY, 0)) < 0) {
            warn("%s: open", file);
            return (1);
        }
    }

引数にファイル名が渡ってこない場合は標準入力をインプットとします。

引数にファイル名が渡ってきた場合は、そのファイルを読み取り専用で開きます。

ファイルオープンでエラーだったら、エラー(=1)を返却して処理を終了します。



   if (doword || (domulti && MB_CUR_MAX != 1))
        goto word;
  • doword が立っている => オプションなしか、-wオプション指定
  • domulti && MB_CUR_MAX != 1 => -mオプション指定、かつ MB_CUR_MAX != 1

MB_CUR_MAXは 現在のロケールでのマルチバイト文字の最大長のことです。

つまり、オプションなしか、-wオプション指定か、「-mオプション指定、かつ マルチバイト文字の最大長が1以外」だったらword:ラベルまで処理をスキップします。

2バイト以上のマルチバイト文字が含まれる場合は、文字数をカウントするときに単純に1バイト=1文字としてカウントできないので、それ専用の処理をword:ラベル以降でやっているということですね。

マルチバイト、ちょっとめんどくさい。

とりあえずword:ラベル以降の処理は後半で記載するとして、次。



   if (doline) {
        while ((len = read(fd, buf, buf_size))) {
            if (len == -1) {
                warn("%s: read", file);
                (void)close(fd);
                return (1);
            }
            charct += len;
            for (p = buf; len--; ++p)
                if (*p == '\n')
                    ++linect;
        }
        tlinect += linect;
        (void)printf(" %7ju", linect);
        if (dochar) {
            tcharct += charct;
            (void)printf(" %7ju", charct);
        }
        (void)close(fd);
        return (0);
    }

ここはdolineフラグが立っている(オプションなしか-lオプション指定)の場合の処理です。

ファイルの中身をbuf_sizeずつ読み込み(while ((len = read(fd, buf, buf_size))) { ~ 側の処理)、\nをみつけたらlinectをインクリメントします。このとき同時にtotal用の変数tlinectもインクリメントしていきます。

つまり、-lオプションの「行数を表示する」というのは、正確にいうと行数を数えているわけではなく改行の数を数えているということです*2

同時にdocharフラグも立っている場合、バイト数も同時にカウントします。ここでもtotal用の変数tcharctを同時にインクリメントしています。

読み込みに失敗した場合はワーニングを表示してエラー(=1)で終了。



   if (dochar || domulti) {
        if (fstat(fd, &sb)) {
            warn("%s: fstat", file);
            (void)close(fd);
            return (1);
        }
        if (S_ISREG(sb.st_mode)) {
            (void)printf(" %7lld", (long long)sb.st_size);
            tcharct += sb.st_size;
            (void)close(fd);
            return (0);
        }
    }

ここはdocharかdomultiフラグが立っている場合(オプションなしか、-cオプション指定か、-mオプション指定」)の場合の処理で、バイト数をカウントします。

domultiフラグに関してはあらかじめここで処理を分岐しているため、1文字=1バイトの場が対象になります。

1文字=1バイトなのでバイト数と文字数は同じカウントの方法で良いというわけですね。

cnt() - word:ラベル以降の処理

       while (len > 0) {
            if (!domulti || MB_CUR_MAX == 1) {  …(1)
                clen = 1;
                wch = (unsigned char)*p;
            } else if ((clen = mbrtowc(&wch, p, len, &mbs)) ==  
                (size_t)-1) {              …(2)
                if (!warned) {
                    errno = EILSEQ;
                    warn("%s", file);
                    warned = 1;
                }
                memset(&mbs, 0, sizeof(mbs));
                clen = 1;
                wch = (unsigned char)*p;
            } else if (clen == (size_t)-2)     …(3)
                break;
            else if (clen == 0)
                clen = 1;
            charct++;
            len -= clen;
            p += clen;
            if (wch == L'\n')       …(4)
                ++linect;
            if (iswspace(wch))     …(5)
                gotsp = 1;
            else if (gotsp) {       …(6)
                gotsp = 0;
                ++wordct;
            }
        }

word:ラベル以降の処理でキモになるのはこの2つ目のwhile文の中。1つずつ順番に確認していきます。

(1)~(3)までの処理では条件に応じて、mbrtowc()でマルチバイト文字が何バイトかを検査しclenに値を格納しています。同時にマルチバイト文字(のポインタ)をwch格納しています。

順番にifによるclenの値を見ていきます。

  • !domulti || MB_CUR_MAX == 1`

  dowordフラグが立っているか、
  「domultiフラグが立っているかつ MB_CUR_MAX == 1」だったら、clen =1

  • (clen = mbrtowc(&wch, p, len, &mbs)) == (size_t)-1

  mbrtowc()の結果、不正なマルチバイト列に遭遇した場合、clen =1

  • clen == (size_t)-2

  mbrtowc()の結果、完全なマルチバイト文字を解析できなかった場合、clen = (size_t)-2*3

  • clen == 0

  L'\0' ワイド文字を認識した場合、clen =1

  • 上記以外

  clen = mbrtowc()が返却したバイト数

clenにバイト数を取得したらcharctをインクリメントして文字数をカウントしていきます。 そしてclenのバイト数分だけポインタを進めてまた次の文字を解析していき、lenが0になるまでループを継続します。

(4)は他のところでもでてきたように\nをみつけたら行数カウントlinectをインクリメントする処理。

(5)と(6)は単語数のカウントをしています。

(5)はiswspace()を使ってwchがホワイトスペース文字かどうかをチェックしています。Trueが返ってきたらスペース取得フラグ(gotsp) を立てていて、

(6) では、iswspace()を使ってwchがホワイトスペース文字かどうかを検査し、ホワイトスペース文字ではない場合かつスペース取得フラグがOn(gotsp=1)の場合、単語数カウントwordctをインクリメントします。その後スペース取得フラグをリセット(gotsp=0)。

ちなみにiswspace() がTrueを返すのは

  • 空白 (0x20, ' ')
  • 改頁 (0x0c, '\f')
  • 改行 (0x0a, '\n')
  • 復帰 (0x0d, '\r')
  • 水平タブ (0x09, '\t')
  • 垂直タブ (0x0b, '\v')

だとここ書いてあったのですが、自分のmacでwcコマンドを実行するかぎりでは

  • ノーブレークスペース (0xa0)*4

もTrueと判断されているようなので、ロケールによって多少違いがある模様。

この後linect、wordct、charctをそれぞれ標準出力に出力する処理がありますが、ここは割愛します。

以上でwc.cの中身はひととおり解析できました。

所感

自分の書いたRubyのコードと実際のwcコマンドで単語数のカウントが一致しなかったのは、このノーブレークスペース (0xa0)をホワイトスペース文字としてカウントしているかどうかの違いだったっぽいです。

ノーブレークスペースを考慮しなくてよければ、単語数のカウントは単純にsplit()するだけでうまくいったんだけどなぁ。

とりあえず、なぜwcコマンドと自作wcコマンドの単語数カウントが一致しないかはcのソースコードを読むことで仕組みがわかり、最終的に同じように実装することができました。

何度も何度もこのコードを読みましたが(多分20回以上読んでいる)、読めば読むほど理解できるようになったので大変だったけど解析できてよかったです。

*1:usage()の中身については見ればわかるような内容なのでブログでの解説はしません。

*2:けっこういろんなところの説明に「行数」を表示すると書いてある

*3:完全なマルチバイト文字を解析できなかった場合というのは、マルチバイト文字の途中という意味かな?

*4:"だ"という文字を16進数に変換すると"0xe3 0x81 0xa0"となり、この"0xa0"がノーブレークスペース。