この記事は FUN Advent Calender 2022(Part1) 2日目の記事です。
昨日は
- Part1で「yuhi」さんの「P2HACKS2022 開催に向けて」
- Part2で「かしわか」さんの「熱に肝炎(?)腫れ、嚢腫」
- Part3でSegmentation fault (core dumped)
まえがき
こんにちは。公立はこだて未来大学 学部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言語にはint
やlong
、double
などの組み込み形と呼ばれる変数の型があります。それぞれの型には、その型が表現可能な値の上限/下限があります。
例えば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++でも同じであり、int
とlong
が同じ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のサイズを持つ変数であることがわかります。
したがってchar
はint
ではないです。
次に、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でi32
とi64
の足し算をするようなもので、本当はエラーなり警告なりを出してほしいなぁと思うところでもあります。
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からの輸入によって生まれた言語仕様だと思っていますが、違うんでしょうか?)。ポインタのあたりもいろいろいじってみると面白い仕様とか、頭がバグるのをいろいろ作ったりできますね。
(ここで詳しく説明はしませんが、下のようなことができますね)
アハ体験 pic.twitter.com/DXxLUd6xvd
— 1c51fa91e4d4545543542199ffa7c642 (@Yourein1) November 10, 2022
よくこのあたりの仕様を持ってきて、「C言語難しい!」という人がいますが、それは当然で、「MT車は運転がムズい!油断するとすぐエンストする!」と言っているのと同じだと思っています。C/C++言語に触れることは、むしろ今主流の言語では気づかないような仕様やコンピューターの動作に触れることになり、自分自身のプログラミングにおけるパラダイムを考え直すいい機会にもなると思います。
別にC言語を使って大規模なシステムを作ってみろ!と言いたいわけではなく、ほんの少しだけ、レジスタやメモリに寄り添ってプログラムを設計できるようになるといいですね。というお気持ち表明を最後に添えておくことにします。
おすすめ記事$n$選
この記事を読んで「C言語おもしれ~w」となる人向けです。
- 未定義動作は未定義動作だよという話 - えびちゃんの日記
- C++ の未定義動作を書く人は、何が起こっても知ーらないっ! @monkukui2 - Qiita
- この記事に関してはコメント欄も読むとよいです。
- intXX_tに関して - えびちゃんの日記
- 新しい情報はそんなにないと思いますが、単純に「C++で整数を扱う型ってそんなにあるんだ~」的に思いながら読み進めるとおもしろいです。
- メモリを壊してみましょう 学校では教えてくれないこと (技術コラム集)組込みの門 ユークエスト
- ユーザーが静的にメモリを確保できる前提みたいなのがサンプルコードの中で出てくるんですが、まあいい感じに解釈するといいでしょう。(実際うまくやるとできたはずです)
- アセンブラを書いたことがある人やC言語でマイコンのレジスタをいじったことがある人がいれば、アドレスを静的に呼び出すのはなんとなくわかりますよね。
- John Regehr's Integers in C
- プログラムを高速化する話 - 京大マイコンクラブ
- メモリに近い話が多いです。
さて、明日は
- Part1で「健康」さんの「アイマスかるたをつくったやつ」
- Part2で「しんほ👋🦕」さんの「デザイナー進路って山道すぎるという話を書こうと思ってたけど次が美大生VSの話なので、なんか面白い話が思いつけばいいなと思っています。焼肉食べたい」
- Part3で「fewless」さんの「絶対必要ないし邪魔だけど 大学生活が楽しくなるものn選」
です!
ぼくも焼肉食べたいですね
-
そのような場合は、intが16bit, longが32bit, longlongが64bit幅であることが多いです。 ↩
-
例えばRenesasのR8Cシリーズは16bitCPUを使っているため、High-performance Embedded WorkshopでコンパイルしたC/C++はintが16bit幅である場合などがあったような気がしていますが、いまいち正確性にかける情報です。 ↩
-
一応
sizeXX_t
という、bit長を明示できる型もあるにはあるんですが、そっちはそもそも環境によって型が存在していたりいなかったりするので、移植性が著しくないプログラムであるといえると思います。例えば、WindowsのMinGW g++では__int128_tが使えないですが、Linuxとかでつかえる、いわゆる普通のg++では使えるということがあったりします。(まあこれはコンパイラの独自実装なので、ここであげる例としては不十分かもしれません。) ↩ -
もちろん符号なし整数についても表現方法はたくさんあると思いますが、ここではコンピューターにおける数値はすべて2進数で表現されていると仮定することにして、BCDなどの存在は忘れることにします。 ↩
-
https://www.open-std.org/jtc1/sc22/wg14/www/docs/n3054.pdf ↩
-
プロ基礎のコンパイル環境はC99だったはずです。 ↩
-
https://github.com/gcc-mirror/gcc/blob/master/gcc/ginclude/stdbool.h ↩
-
https://www.open-std.org/jtc1/sc22/wg14/www/docs/n2934.pdf ↩
-
https://www.open-std.org/jtc1/sc22/wg14/www/docs/n2935.pdf ↩