Hello, world! ✋
printf("Hello, world!\n");
7 年前、ここから私のプログラミングライフが始まりました。
プログラミングの基本のキとして扱われがちな Helllo, world! ですが、ここで使っている標準出力関数ってかなりリッチですよね?
例えば、C 言語の printf 関数は、
- 可変長引数を受け取れる
- フォーマット指定子(
%dなど)に変数を埋め込める - 文字によってバイトサイズの異なる UTF-8 を扱える
etc...
かなり複雑な作りになっていることが想像できます 🤔
上記 3 点を満たす簡易版 printf 関数を自作してみました!
成果物 🏁
ポイント 💡
1. 可変長引数を受け取る 🎁
void my_printf(const char *format, ...) {
va_list args;
va_start(args, format);
while (*format) {
if (*format == '%') {
format++;
switch (*format) {
case 'd': my_putint(va_arg(args, int)); break;
case 's': my_puts(va_arg(args, char*)); break;
case 'c': {
char c = (char)va_arg(args, int);
write(1, &c, 1);
break;
}
case '%': write(1, "%", 1); break;
default: write(1, "?", 1); break;
}
} else {
my_put_utf8_char(&format);
continue;
}
format++;
}
va_end(args);
}
1.1 va_list
可変長引数を格納するための変数。
va_list args;
これを使って可変長引数を操作します。
1.2 va_start
可変長引数の処理を開始します。
va_start(args, format);
-
argsを初期化する -
format(最初の固定引数) の次の引数から取得できる
1.3 va_arg
可変長引数の次の引数を取得します。
int value = va_arg(args, int);
- 呼び出すたびに次の引数を取得する
- 型は
int,double,char*など 正しく指定する必要がある
1.4 va_end
可変長引数の処理を終了します。
va_start を呼んだら必ず va_end で終了します。
va_end(args);
2. フォーマット指定子に変数を埋め込む 👷
my_printf 関数では、format の文字を 1 文字ずつ処理します。
format++ でポインタをインクリメントすることで、1 バイトずつ処理できます。
以下の流れでフォーマット指定子を処理します。
2.1 if (*format == '%') でフォーマット指定子を検出
format の現在の文字が '%' である場合、次の文字を確認するために format++ します。
if (*format == '%') {
format++;
2.2 switch 文でフォーマット指定子を判定
switch (*format) で、次の文字が d(整数)、s(文字列)、c(文字)、%(リテラルの %)のどれなのかを判定します。
switch (*format) {
2.3 %d(整数)の処理
整数型 int を va_arg で取得し、my_putint に渡します。
case 'd':
my_putint(va_arg(args, int));
break;
-
va_arg(args, int)によってargsからint型の値を取り出す - 取り出した値を
my_putintに渡し、整数を文字列に変換してwriteで出力する
2.4 %s(文字列)の処理
文字列のポインタ(char *)を va_arg で取得し、my_puts に渡します。
case 's':
my_puts(va_arg(args, char*));
break;
void my_puts(const char *s) {
write(1, s, strlen(s));
}
-
va_arg(args, char*)によってargsからchar *型(文字列の先頭アドレス)を取得する -
my_putsはwrite(1, s, strlen(s));で文字列全体を出力する
2.5 %c(文字)の処理
文字 char を取得し、write で直接出力します。
case 'c': {
char c = (char)va_arg(args, int);
write(1, &c, 1);
break;
}
-
va_arg(args, int)でint型の値を取得し、char型にキャストする- 可変長引数を使うとき、
char,shortなどの小さいデータ型はint型に昇格するため(default argument promotion)
- 可変長引数を使うとき、
-
write(1, &c, 1);で 1 バイトだけ出力する
2.6 %%(リテラルの %)の処理
%% を見つけた場合、write(1, "%", 1); で % をそのまま出力します。
case '%':
write(1, "%", 1);
break;
2.7 default の処理
サポートされていないフォーマット指定子の場合、? を出力します。
default:
write(1, "?", 1);
break;
※ write の使い方 🧑🏫
int write(int fd, void *buf, unsigned int byte)
引数
| 引数 | 説明 |
|---|---|
| fd(ファイルディスクリプタ) | 書き込む対象(1 = 標準出力, 2 = 標準エラー) |
| buf(データバッファ) | 書き込むデータのアドレス |
| byte(バイト数) | 書き込むデータのサイズ |
戻り値
- 成功: 書き込んだバイト数
- 失敗: -1
3. my_putint の処理 🔢
3.1 バッファの準備
int 型は -2147483648 〜 2147483647 の最大 11 桁。
これに終端ヌル文字 \0 を加えた 12 バイト確保します。
char buf[12];
3.2 負の数の扱い
負の数フラグを立て、絶対値を保持します。
if (n < 0) {
is_negative = 1;
n = -n;
}
3.3 数値を逆順にバッファに格納
数値を逆順でバッファに格納します。
ex. 1234 → ["4", "3", "2", "1"]
if (n == 0) {
buf[i++] = '0';
} else {
while (n > 0) {
buf[i++] = (n % 10) + '0';
n /= 10;
}
}
-
n % 10で一番右の桁を取得し、それを'0'〜'9'に変換する -
n /= 10で次の桁へ移動する
3.4 負の数なら '-' を追加
負の数のとき、末尾に '-' を追加します。
ex. -56789 → ["9", "8", "7", "6", "5", "-"]
if (is_negative) {
buf[i++] = '-';
}
3.5 バッファの反転
バッファの左右を反転します。
for (int j = 0; j < i / 2; j++) {
char temp = buf[j];
buf[j] = buf[i - j - 1];
buf[i - j - 1] = temp;
}
ex. -56789
buf の状態 |
操作 |
|---|---|
["9", "8", "7", "6", "5", "-"] |
buf[0] ↔ buf[5] (9 ↔ -) |
["-", "8", "7", "6", "5", "9"] |
buf[1] ↔ buf[4] (8 ↔ 5) |
["-", "5", "7", "6", "8", "9"] |
buf[2] ↔ buf[3] (7 ↔ 6) |
["-", "5", "6", "7", "8", "9"] |
完了 |
3.6 文字列終端を追加
C 言語の文字列として扱うため、最後にヌル文字 '\0' を追加します。
buf[i] = '\0';
3.7 write で出力
整数を標準出力に書き込みます。
終端ヌル文字 \0 は除いています。
write(1, buf, i);
4. UTF-8 を扱う 🇯🇵
ASCII 文字は format++ でポインタを 1 バイトずつ進めることで 1 文字ずつ処理できますが、UTF-8 文字は 2 〜 4 バイトの文字も存在するため、何バイトの文字か判定する必要があります。
my_put_utf8_char では次の文字が何バイトの文字か判定し、1 文字ずつ文字を出力します。
4.1 文字列の先頭バイトを取得
void my_put_utf8_char(const char **p) {
const unsigned char *s = (const unsigned char *)(*p);
-
pはconst char**(文字列のポインタへのポインタ) -
*pで指している先頭文字のポインタを取得 -
sをunsigned char*にキャストして扱う-
charは 符号付き(signed char) の可能性があり、ビットパターンが負の値として解釈されないようにするため - ex.
"こ"の先頭バイト:0xE3(11100011)→-29
-
4.2 先頭バイトを見て、UTF-8 のバイト数を判定
if ((*s & 0x80) == 0) bytes = 1; // ASCII (1バイト)
else if ((*s & 0xE0) == 0xC0) bytes = 2; // 2バイト文字
else if ((*s & 0xF0) == 0xE0) bytes = 3; // 3バイト文字
else if ((*s & 0xF8) == 0xF0) bytes = 4; // 4バイト文字
ビットパターンによる判定
以下のように、UTF-8 の文字は先頭バイトのビットパターンで、何バイトの文字かが決まっています。
| 文字の種類 | 先頭バイトのビットパターン | バイト数 |
|---|---|---|
| ASCII | 0xxxxxxx | 1 |
| 2 バイト | 110xxxxx | 2 |
| 3 バイト | 1110xxxx | 3 |
| 4 バイト | 11110xxx | 4 |
& 演算を使って、このビットパターンに一致するかどうかを判定しています。
各判定の意味
-
(*s & 0x80) == 0-
0x80(10000000)との論理積をとる - 結果が
0なら ASCII(1 バイト) - ex.
'A'(01000001→0)
-
-
(*s & 0xE0) == 0xC0-
0xE0(11100000)との論理積をとる - 結果が
0xC0(11000000)なら 2 バイト - ex.
"あ"(11000011 10000010→11000000)
-
-
(*s & 0xF0) == 0xE0- 結果が
0xE0(11100000)なら 3 バイト - ex.
"漢"(11101000 10000010 10010010→11100000)
- 結果が
-
(*s & 0xF8) == 0xF0- 結果が
0xF0(11110000)なら 4 バイト - ex. 絵文字
"🌍"(11110000 10011111 10011000 10001100→11110000)
- 結果が
4.3 文字を出力
write(1, s, bytes);
-
writeを使って判定したバイト数bytesだけ出力する -
sは先頭バイトのポインタなので、bytesバイトだけ出力することで 1 文字を正しく表示できる
4.4 ポインタを進める
*p += bytes;
-
*p(呼び出し元のポインタ)をbytesバイトだけ進める - これにより、次のループで次の文字の先頭バイトを処理できる
動作確認
int main() {
setlocale(LC_ALL, ""); // UTF-8 のロケール設定
my_printf("ゼロ: %d\n", 0);
my_printf("正の数: %d\n", 1234);
my_printf("負の数: %d\n", -56789);
my_printf("英語: %s\n", "Hello, world!");
my_printf("日本語: %s\n", "こんにちは 世界");
my_printf("char: %c\n", 'A');
my_printf("%%\n");
my_printf("%a\n");
return 0;
}
コンパイル
gcc my-printf.c -o my-printf
実行
./my-printf
実行結果
ゼロ: 0
正の数: 1234
負の数: -56789
英語: Hello, world!
日本語: こんにちは 世界
char: A
%
?
ちゃんと動きました 🙌
おわりに
printf 関数が実際はどのような実装になっているか知りませんが、かなり複雑なことをしていそうです 🙈
今回は以下を実装しませんでしたが、これらも実装しようとすると大変そうです 😵💫
- 単精度浮動小数点数
%f、倍精度浮動小数点数%lfなどのフォーマット指定子 - 幅・精度指定(ex.
%5d,%.3f)
プログラミングの世界への扉を叩いたときの気持ちを思い出して、引き続き精進します ✊
おすすめしたい C 言語の名著 📖
-
苦しんで覚える C 言語
https://9cguide.appspot.com/