Is char Nice ? : C言語で問題のあるChar型でインクリメントされないコード?

6 min read

こんにちは、無能です。

気になったので試してました。

https://blog.httrack.com/blog/2014/05/30/c-corner-cases-and-funny-things

まずは上記の状態でコンパイルして実行してみます。

#include <stdio.h>

/** Return the next character within a \0-terminated string, or EOF. **/
int my_read_char(const char *buffer, size_t *offs) {
  if (buffer[*offs] != '\0') {
    return buffer[*offs++];  /* here's the trap */
  } else {
    return EOF;
  }

int main() {
  const char *buffer = "Hello, World!";
  size_t offs = 0;

  printf("Reading characters from buffer:\n");
  while (1) {
    int ch = my_read_char(buffer, &offs);
    if (ch == EOF) {
      break;
    }
    printf("Character read: %c, offs: %zu\n", ch, offs);
  }

  return 0;
}

これでコンパイルして実行してみる

gcc -o bugcode bugcode.c
./bugcode
Character read: H, offs: 0
Character read: H, offs: 0
Character read: H, offs: 0
Character read: H, offs: 0
Character read: H, offs: 0
Character read: H, offs: 0
Character read: H, offs: 0
Character read: H, offs: 0
Character read: H, offs: 0
Character read: H, offs: 0
Character read: H, offs: 0
Character read: H, offs: 0
Charact^C
alleycat:[haturatu]:~/clang$ 

ぎゃあああ!!!

ということで危険ですが、logというファイルにログを吐き出させて初期の実行を見てみます。
そもそも、急いでps -ef | grep bugcodeをしてプロセスID確認してkillしたのにも関わらず・・・

alleycat:[haturatu]:~/clang$ ls -la
合計 1256824
drwxr-xr-x  2 haturatu haturatu       4096  6月 14 23:38 .
drwx------ 39 haturatu haturatu       4096  6月 14 23:38 ..
~
-rw-r--r-- 1 haturatu haturatu 1286922240  6月 14 23:39 log

とんでもないファイルサイズになり、1.2GBほどになりました・・・もはやシステムをクラッシュできるほどの危険なコードであります・・・。viewで確認できることできず、vimでは開けました。

alleycat:[haturatu]:~/clang$ head log
Reading characters from buffer:
Character read: H, offs: 0
Character read: H, offs: 0
Character read: H, offs: 0
Character read: H, offs: 0
Character read: H, offs: 0
Character read: H, offs: 0
Character read: H, offs: 0
Character read: H, offs: 0

最初から更新(インクリメント)されていません。

これがどういうことかChatGPTに助けてもらいながら理解していきたい。

EOF と \0 の区別
EOF(End Of File)は、ファイルの終わりを示す特殊な値であり、通常 -1 です。一方、'\0' は C 文字列の終端を示すヌル文字であり、その値は 0 です。

なんかここでわかったようで、頭が混乱してきました。
一度冷静になるためにここの時点でこのコードで起きるフローを聞きます。my_read_char_with_castに変わってますが気にしないでください。

フローの詳細
初期状態:

offs は 0。
バッファにはヌル終端がない('\0' がない)。
ループの開始:

my_read_char_with_cast 関数が呼ばれる。
buffer[*offs] は 'H' (最初の文字)なので '\0' ではない。
'H' が返され、offs が 1 になる。
ループの続行:

次の呼び出しで buffer[1] ('e')が読み取られ、返される。
offs が 2 になる。
繰り返し:

このプロセスが続く。
最終的に offs が 11 になり、buffer[11] ('!')が読み取られ、返される。
offs が 12 になる。
無限ループ:

buffer[12] の値は未定義(バッファ外のメモリ領域を参照する)であり、'\0' ではない。
buffer[*offs] は常に '\0' ではないため、EOF に達せずに無限ループが発生する。

なんだか逆にわからなくなりました。実際にテストした結果は最初から無限ループを起こしているのでoffsが12までそもそも達していません。デバッグすると

Returning character: H
Character read: H, offs: 0
my_read_char called with offs: 0
Returning character: H
Character read: H, offs: 0
my_read_char called with offs: 0
Returning character: H
Character read: H, o^C
alleycat:[haturatu]:~/clang$ 

やはり、offs0のままです。

さて、ここでHTTrackのブログに戻りましょう。

この関数は、ASCII > 127 に対して負の値を返します。特に、 ASCII 255 (0xFF) に対しては値-1 を返します。これはEOF の値でもあります。

なるほど?ではなぜ-1を返すのか?AIに聞いてみます。

char型の特殊性
符号付き8ビット整数: C言語のchar型は、通常、符号付きの8ビット整数として扱われます。これは、-128から127までの範囲を表すことができます。
ワープアラウンド: char型の値が最大値(127)に達すると、次のインクリメント操作で最小値(-128)にワープアラウンドします。これは、2の補数表現によるもので、127をインクリメントすると-128になるという挙動です。

ASCII 255(0xFF)に関する考察
ASCII範囲外: ASCIIテーブルでは、値255(0xFF)は範囲外であり、通常は制御文字として扱われます。
EOFとの関係: C言語では、EOFはエンド・オブ・ファイルを示す特別な値で、通常は-1として扱われます。EOFは、ファイルの終わりを示すために使用されますが、ここではASCII値255(0xFF)に対する返り値として使われています。
関数の挙動の意味
この関数がASCII 255(0xFF)に対して-1を返す理由は、以下のようになります:

範囲外の値: ASCII 255はchar型の範囲外であり、通常は無効な値として扱われます。
EOFの利用: この関数では、EOF(-1)をバッファの終端を示すために使用しています。ASCII 255に対する返り値が-1であることは、これがEOFを示す一種の暗黙の規約となっています。
このような設計は、関数がバッファの終端を検出し、EOFを返すことで、呼び出し元がデータの読み取りを停止する信号として機能します。ただし、この関数の文脈では、ASCII 255に対する特定の処理が必要な場合や、ASCII 255を正しく扱いたい場合には注意が必要です。

なるほど、そもそもchar型では対応できないんですね。そもそもASCII 127とASCII 255の違いから生まれてそのおかげでEOFが-1として返り値と返されるから無限ループになると。

HTTrackのサイトの修正コードは以下です。しかし、以下だと現状gccでコンパイルしましたが無限ループ発生したので以下でもだめなようです。

/** Return the next character within a \0-terminated string, or EOF. **/
int my_read_char(const char *buffer, size_t *offs) {
  if (buffer[*offs] != '\0') {
    return (unsigned char) buffer[*offs++];
  } else {
    return EOF;
  }

AIくんはこれを掲示してきました。

int my_read_char(const char *buffer, size_t *offs) {
  if (buffer[*offs] != '\0') {
    return buffer[(*offs)++];
  } else {
    return EOF;
  }
}

結局、キャストするにしても明示的にchar型としてインクリメントしてあげないとだめなようです。

これの違いについてまた聞いてみました。

違いの詳細
1. unsigned char へのキャストを含む関数
キャストの理由:
buffer は char の配列であり、buffer[*offs] は char 型の値を返します。
char 型は符号付きの8ビット整数であることが多いですが、これは実装に依存します(コンパイラやプラットフォームによっては符号なしの場合もあります)。
文字列内の値が負の値を取る場合、キャストしていない場合、負の値が int 型に拡張されるときに符号が保持される可能性があります。
例えば、buffer[*offs] の値が -1 の場合、キャストしないと -1 が返されますが、キャストすると 255(0xFF)として返されます。
2. キャストなしの関数
符号付き文字の扱い:
buffer[*offs] が負の値を持つ可能性がある場合、キャストしないとそのまま負の値が返されます。
これは場合によっては意図しない動作を引き起こす可能性があります。

さて、結果的には?

どちらを使うべきか?
キャストを含む関数:

文字データをバイナリとして扱う場合や、符号なしの値として扱いたい場合には、unsigned char にキャストするのが一般的です。
これは特に非ASCII文字(例えば、UTF-8エンコーディングのデータなど)を扱う場合に重要です。
キャストなしの関数:

ASCII文字のみを扱う場合や、符号付きのまま扱うことが意図されている場合には、キャストしなくても問題ない場合があります。

結構これが特殊な例ではあるのですね。
後者のキャストしない場合はchar型として扱いますということを**buffer[(*offs)++]**として明示的にしてあげることでchar型のデフォルトとして符号なし扱いになることでちゃんとchar型としてインクリメントしてくれるということでしょうか。

文字コードの違いって結構可視化しずらいもので、結構身近にあるトラップってたくさんありますよね・・・。いやー、面白かった!