この記事は FUN Advent Calender 2022(Part1) 2日目の記事です。

昨日は


まえがき

こんにちは。公立はこだて未来大学 学部1年のYoureinです。
この記事では、公立はこだて未来大学学部1年と2年に口を揃えて大好きと言わしめる「C言語」の魅力に迫ります

「あなたの知らないプログラミング基礎の世界」など、仰々しいタイトルですが、実際はC言語のおもしろ仕様を見ていこうという記事です。

対象読者

  • プログラミング基礎などを終えて、ある程度C言語の読み書きが自由にできる人。

テスト環境

  • Windows
    • gcc (MinGW.org GCC Build-2) 9.2.0
  • Linux(Ubuntu on WSL2)
    • gcc (Ubuntu 9.4.0-1ubuntu1~20.04.1) 9.4.0
  • Linux(Pop!_os)
    • gcc gcc (Ubuntu 11.3.0-1ubuntu1~22.04) 11.3.0

sizeof

C言語にはintlongdoubleなどの組み込み形と呼ばれる変数の型があります。それぞれの型には、その型が表現可能な値の上限/下限があります。

例えばint型は、32bit(4Byte)の領域を持ち、その表現可能な値の範囲は

$-1\times(2^{32-1}) \le x \le 2^{32-1} - 1$

です。

ところで、long型に関しては、あなたの環境では4Byteかもしれませんし、8Byteであるかもしれません。
これは、macOS/Linux/FreeBSD/UnixなどはLP64という型モデルを使用するのに対し、64bit環境のWindowsはLLP64という型モデルを使うことに起因します。

実際に以下のコードを異なる環境でコンパイルしてみることにします。

#include <stdio.h>
int main() {
    long i = 0;
    printf("%d\n", sizeof(i));
}

このコードはWindows環境でコンパイルしたとき4を。Linux環境でコンパイルしたとき8を出力します。
これはC言語だけではなく、C++でも同じであり、intlongが同じbit長であることを仮定して書かれたプログラムで問題が発生する場合があります。

さらに、int型も必ずしも32bitの幅を持つとは限りません。1
組み込みコンピューターなどで用いられるC言語では、int型のbit幅が16bitである場合もあります。2

当然と言えば当然ですが、WSL上でコンパイルしたときも、long型は8Byteとなります。

教訓として、基本的にアドレス型はlongなどで持っておくとよいです。3

オーバーフロー

sizeofのところで、int型の最大値が $2^{32-1} - 1$ ということを書きました。
ところで、以下のプログラムの実行結果はどうなるでしょうか?

#include <stdio.h>
int main() {
    int a = 2147483647;
    printf("%d\n", a);
    a++;
    printf("%d\n", a);
}

おそらく、大抵の環境で 2147483647-2147483648が表示されると思います。
このように、ある型が表せる値の範囲を超えたときに、本来意図しない値になるような動作のことをオーバーフローと言います。

ところで、なぜ2147483647+1 = -2147483648なのでしょうか?
これは、下位31bitがすべて1である状態で1が足されたことによって32bit目に繰り上がったことが原因です。これがパッと来ない人は符号付き整数、もとい、2の補数表現を見直してきてください。

このように、本来ある型が表現可能な領域を超えた値を使用しようとした場合、壊れることがわかりました。しかし、どのように壊れるかは誰もわかりません。C言語では、これは未定義動作です。
C言語はunsignedな値、例えばunsigned intなどに対してはUINT_MAX + 1が0であることを規定しています。
一方で、signedな値、例えばintなどについて、INT_MAX + 1が何になるかは規定していません。

INT_MAX + 1

大抵の環境ではで示したように、INT_MINになります。
しかし、あえてここでsigned intのオーバーフローが定義されていないのには歴史的な理由があります。

コンピューターが符号付き整数を表現するとき、主に2の補数(two's complement)が使われます。
しかし、他の表現方法もあり、1の補数(one's complement)や、単純に符号bitの0, 1で、真の値を-1倍したりするなどの表現方法があります。

すなわち、同じ負数の表現にも以下のようなバリエーションがあるわけです

  • sign bitが単純に符号の役割をする場合
  • sign bitが $2^N$ の役割をする場合
  • sign bitが $2^N - 1$ の役割をする場合

適当に上げても3種類ありますから、おそらく世の中にはまた別の記法、表現方法があるはずです。4

ということで、負数の表現方法は環境ごとに大きく違う可能性があることから、あえてオーバーフロー時の動作を定義しないことで、その環境によってもっとも自然な振る舞いをオーバーフロー時に行うようになっているわけです。
私達が普段使うgccなどのコンパイラがオーバーフロー時にINT_MINに戻るような動作をするのは、2の補数表現を使うコンピューターにおいてはそれがもっとも自然な振る舞いであるからと言えます。

ところで、現代において2の補数表現を使わない汎用電子計算機なんてどこにあるんだという話になりますが、どうやらCの標準化委員会も同じような結論に達したらしく、C23から符号付き整数はすべて2の補数表現によることが標準となるようです。5

NOTE 2 The sign representation defined in this document is called two’s complement. Previous revisions of this document additionally allowed other sign representations.

ということで、過去のCの標準で認めていた2の補数表現でない符号付き整数の表現方法は2023 $+ \hspace{0.2em} n$ 年をもって淘汰されることとなるようです。

c-'0'

#include <stdio.h>

int main() {
    char c = '5';
    printf("%d", c-'0');
}

このプログラムの出力結果は5です。当然ですね。(本当に当然でしょうか?)
講義においては、これによってchar型の変数をint型に変換できる的なことを言われていましたが、それは本当でしょうか?

まず、char型とは何でしょう。文字ですか?いいえ、少なくともC言語においてはただの整数値です。普通にcharという型名を使って文字を操作しようとするとき、ASCIIという文字コードをC言語では使います。最近の他の言語ではここがASCIIではなく、UTF-8なことが多いです。一応C言語でもUTF-8を扱う方法はあったはずです。

さて、ASCIIは0~255の整数値を文字に一対一で対応させたものです。より詳しくは、https://www.ascii-code.comを見てください。
今までの話を考えると、別にchar型のままでも整数として扱うことはできそうです。

実際に以下のコードを実行してみましょう

#include <stdio.h>

int main() {
    char c = '1';
    int n = c;
    printf("%d\n", n);
}

出力結果は49になるはずです。
これはなにか誤った値が出力されているわけではありません。ASCIIが49という整数を'1'という文字に割り当てているにすぎません。
ということで、別に「char型の変数から'0'を引くと整数になる。」という現象が起こるわけではないということがわかりました。

char is not int

c-'0'では、char型はint型と似たような性質があることを説明しました。
ところで、char型はint型でしょうか?

答えは"False"です。

#include <stdio.h>

int main() {
    char c = '1';
    printf("%ld\n", sizeof(c));
}

これを実行すると1が表示されます。すなわち、char型は1Byteのサイズを持つ変数であることがわかります。
したがってcharintではないです。
次に、charはunsignedなのかsignedなのかを確かめたいです。

1で初期化した変数を7bit左にシフトすることで確かめることができることはわかるでしょう。
もしcharがsignedなら0b10000000は-128として、unsignedなら0b10000000は128として扱われます。

1 : 00000001
? : 10000000
#include <stdio.h>

int main() {
    char c0 = 1;
    c0<<=7;
    printf("%d\n", c0);
}

結果は…

-128

どうやら、charはunsignedではないようです。実際に、unsignedをcharにつけることができます。

int main() {
    char c0 = 1;
    unsigned char c1 = 1;
    c0<<=7;
    c1<<=7;
    printf("%d %d\n", c0, c1);
}

として実行してみると、

-128 128

を得ます。

さて、本来、違う型同士で計算をするとき、プログラムはコンパイル時にエラーを出力します。

int a = 1;
char b = '1';
printf("%d\n", a+b);

本当ならこれはエラーとなって欲しいコードです。が、動きます。
C言語はこのようにどちらかの型をもう一方に合わせる(キャストする)ことができるとき、暗黙的にもう一方の型に合わせて計算します。
基本的にサイズが大きい方の方に合わせられるので、bはintにキャストされた上で計算されることになります。'1'はASCIIで49ですから、1+49が計算されることになり、これは50を出力します。

しかし、これはRustでi32i64の足し算をするようなもので、本当はエラーなり警告なりを出してほしいなぁと思うところでもあります。
Rustでは当然以下のようなエラーが出ますよね。

fn main(){
    let a: i32 = 1;
    let b: i64 = 49;
    println!{"{}", a+b};
}
error[E0308]: mismatched types
  --> src/main.rs:18:22
   |
18 |     println!{"{}", a+b};
   |                      ^ expected `i32`, found `i64`

error[E0277]: cannot add `i64` to `i32`
  --> src/main.rs:18:21
   |
18 |     println!{"{}", a+b};
   |                     ^ no implementation for `i32 + i64`
   |
   = help: the trait `Add<i64>` is not implemented for `i32`

Some errors have detailed explanations: E0277, E0308.
For more information about an error, try `rustc --explain E0277`.
error: could not compile `ac` due to 2 previous errors

他の言語のことを見てみると、大抵は整数の文字が並べられた文字列を整数に変換するような関数が存在していたりします。

例えばPython。int()でキャストできます。

print("123"+1)
print(int("123")+1)
TypeError: can only concatenate str (not "int") to str
124

例えばRust。parse::<T>()でキャストできます。(本当はunwrapできなかった場合のExceptionを処理したほうがいいんでしょうが…)

fn main(){
    let a = "123";
    let b = 1;
    println!{"{}", a.parse::<i32>().unwrap() + b};
}
124

const char is int

もうちょっとcharで遊んでみます。

printf("%ld\n", sizeof('a'));

これは何を出力するでしょう?

4

です。

はて、先程charは1Byteの符号付き整数であると確認したはずです。
しかしC言語は文字定数に4Byteを割り当てるようになっているんですね。

ということは、

if (a == '1')

のような条件式を書いたとき、左辺は1byteで右辺は4byteの符号付き整数なわけですから、aはintに暗黙的にキャストされて比較されていることになります。

さて、では以下はどうでしょう?

printf("%ld\n", sizeof("a"));

この出力は

2

となります。
これはconst char *なので、普通に1文字に1Byteとられます。したがってaと終端文字に2文字で2Byteです。

ところで、だからといって

const char c = 'a';
printf("%ld\n", sizeof(c));

が4を出してくるわけではないです。
実際、このコードは

1

を出力します。

変ですね。

Boolean

はこだて未来大学の1年生はC言語の前にProcessingを用いて、ビジュアルプログラミングを行うことになります。
なかなか珍しいカリキュラムだと思いますが、まあ今回はそこらへんの話はおいておきます。

Boolean typed = false;

void draw() {
    background(0);
    if (typed) {
        text("True", width/2, height/2);
    }
    else {
        text("False", width/2, height/2);
    }
}

void keyTyped() {
    typed = true;
}

Processing, もといJavaにはBooleanというTrue, Falseの2値を扱う型が存在します。
プログラミングになれてくるとBooleanがあるとうれしいなぁという場面があったりしますが、C言語にBool型がないというのは割と有名な話だと思います。

が、実はC言語にもBool型があります。
1999年に策定されたC99で、_Boolというマクロが策定されました。6
これにより「C言語でニセモノではあるけどBool型が!」となりましたが、同時にstdbool.hというヘッダファイルが追加されており、includeするとBool型がつかえるように

#include <stdbool.h>

int main() {
    bool b = false;    
}

と、思ったかもしれませんが、このboolは_Boolに展開されるので中身は同じでした。というオチまでついています。7
「じゃあunsigned intでいいじゃねえか!」と思う気持ちはわかりますが、以下のコードを実行してみると

#include <stdbool.h>
#include <stdio.h>

int main() {
    _Bool a = 1;
    bool b = false;
    unsigned int c = 0;
    printf("%ld %ld %ld\n", sizeof(a), sizeof(b), sizeof(c));
}

以下の実行結果を得ます。

1 1 4

実際コンパイル途中のアセンブラを見てみても

movb    $1, -6(%rbp)
movb    $0, -5(%rbp)
movl    $0, -4(%rbp)

というように、1Byteの型が使われているのがわかります。

もし_Boolを使わずになにかCにもともとある別の形を使うとして、無駄な3Byteが惜しいなら、unsigned charを使うと良いですね

C23の話

ところで、C23ではついにstdbool.hなしでbool, true, falseを使えるようになるらしいです。89
ただし、defineによるマクロなので型が新しく追加されるという話ではないです。

あとがき

うまぴょい

とりあえずプロ基礎やってたら当たりそうなおもしろ仕様をいろいろを列挙してみました。他にもC言語には長寿言語ならではのおもしろ仕様がたくさんあります。
goto文など、一部の構文も個人的にはおもしろ仕様の一部だと思っています(アセンブラやBASICからの輸入によって生まれた言語仕様だと思っていますが、違うんでしょうか?)。ポインタのあたりもいろいろいじってみると面白い仕様とか、頭がバグるのをいろいろ作ったりできますね。

(ここで詳しく説明はしませんが、下のようなことができますね)

よくこのあたりの仕様を持ってきて、「C言語難しい!」という人がいますが、それは当然で、「MT車は運転がムズい!油断するとすぐエンストする!」と言っているのと同じだと思っています。C/C++言語に触れることは、むしろ今主流の言語では気づかないような仕様やコンピューターの動作に触れることになり、自分自身のプログラミングにおけるパラダイムを考え直すいい機会にもなると思います。

別にC言語を使って大規模なシステムを作ってみろ!と言いたいわけではなく、ほんの少しだけ、レジスタやメモリに寄り添ってプログラムを設計できるようになるといいですね。というお気持ち表明を最後に添えておくことにします。

おすすめ記事$n$選

この記事を読んで「C言語おもしれ~w」となる人向けです。


さて、明日は

  • Part1で「健康」さんの「アイマスかるたをつくったやつ」
  • Part2で「しんほ👋🦕」さんの「デザイナー進路って山道すぎるという話を書こうと思ってたけど次が美大生VSの話なので、なんか面白い話が思いつけばいいなと思っています。焼肉食べたい」
  • Part3で「fewless」さんの「絶対必要ないし邪魔だけど 大学生活が楽しくなるものn選」

です!

ぼくも焼肉食べたいですね


  1. そのような場合は、intが16bit, longが32bit, longlongが64bit幅であることが多いです。 

  2. 例えばRenesasのR8Cシリーズは16bitCPUを使っているため、High-performance Embedded WorkshopでコンパイルしたC/C++はintが16bit幅である場合などがあったような気がしていますが、いまいち正確性にかける情報です。 

  3. 一応sizeXX_tという、bit長を明示できる型もあるにはあるんですが、そっちはそもそも環境によって型が存在していたりいなかったりするので、移植性が著しくないプログラムであるといえると思います。例えば、WindowsのMinGW g++では__int128_tが使えないですが、Linuxとかでつかえる、いわゆる普通のg++では使えるということがあったりします。(まあこれはコンパイラの独自実装なので、ここであげる例としては不十分かもしれません。) 

  4. もちろん符号なし整数についても表現方法はたくさんあると思いますが、ここではコンピューターにおける数値はすべて2進数で表現されていると仮定することにして、BCDなどの存在は忘れることにします。 

  5. https://www.open-std.org/jtc1/sc22/wg14/www/docs/n3054.pdf 

  6. プロ基礎のコンパイル環境はC99だったはずです。 

  7. https://github.com/gcc-mirror/gcc/blob/master/gcc/ginclude/stdbool.h 

  8. https://www.open-std.org/jtc1/sc22/wg14/www/docs/n2934.pdf 

  9. https://www.open-std.org/jtc1/sc22/wg14/www/docs/n2935.pdf